4302 lines
194 KiB
JavaScript
4302 lines
194 KiB
JavaScript
'use strict';
|
||
|
||
/*
|
||
* main.js – Electron Hauptprozess für ViperCraft
|
||
* Phase 1: Fenster, Instanz-Verwaltung, Einstellungen.
|
||
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
|
||
*/
|
||
|
||
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const { execFile, spawn } = require('child_process');
|
||
// Statisches ffmpeg-Binary für die Umwandlung der Spielaufzeichnung nach MP4
|
||
// (siehe recording:save) - liefert null, falls kein Binary für diese
|
||
// Plattform/Architektur vorliegt (dann bleibt die Aufnahme als .webm erhalten)
|
||
let ffmpegPfad = null;
|
||
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
|
||
const pkg = require('../package.json');
|
||
const store = require('./store');
|
||
const services = require('./services');
|
||
const launcher = require('./launcher');
|
||
const loaders = require('./loaders');
|
||
const forge = require('./forge');
|
||
const auth = require('./auth');
|
||
const updater = require('./updater');
|
||
const icons = require('./icons');
|
||
const bedrock = require('./bedrock');
|
||
const backup = require('./backup');
|
||
const mmcimport = require('./mmcimport');
|
||
const cfimport = require('./cfimport');
|
||
const mrimport = require('./mrimport');
|
||
const mcimport = require('./mcimport');
|
||
const technicimport = require('./technicimport');
|
||
const technicdiscover = require('./technicdiscover');
|
||
const javadl = require('./javadl');
|
||
const crashreports = require('./crashreports');
|
||
const skins = require('./skins');
|
||
const servers = require('./servers');
|
||
const mcping = require('./mcping');
|
||
const discordpresence = require('./discordpresence');
|
||
const friends = require('./friends');
|
||
const crashhelp = require('./crashhelp');
|
||
|
||
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
|
||
const undici = require('undici');
|
||
globalThis.fetch = undici.fetch;
|
||
globalThis.Headers = undici.Headers;
|
||
globalThis.Request = undici.Request;
|
||
globalThis.Response = undici.Response;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Automatische Überwachung der Anmelde-Dienste
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const AUTH_ENDPOINTS = [
|
||
{ name: 'Microsoft-Login', url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode' },
|
||
{ name: 'Xbox Live', url: 'https://user.auth.xboxlive.com/user/authenticate' },
|
||
{ name: 'Minecraft-Dienste', url: 'https://api.minecraftservices.com/minecraft/profile' },
|
||
];
|
||
|
||
let authHealth = { ok: true, checked: null, down: [] };
|
||
|
||
async function checkAuthServices() {
|
||
const down = [];
|
||
for (const ep of AUTH_ENDPOINTS) {
|
||
try {
|
||
const ctrl = new AbortController();
|
||
const t = setTimeout(() => ctrl.abort(), 8000);
|
||
// Antwort-Status ist egal (401/404 = Dienst lebt); nur Ausfall/5xx zählt als "down"
|
||
const res = await fetch(ep.url, { method: 'GET', signal: ctrl.signal, headers: { 'User-Agent': 'AeroMC-Launcher/0.28' } });
|
||
clearTimeout(t);
|
||
if (res.status >= 500) down.push(ep.name);
|
||
} catch {
|
||
down.push(ep.name);
|
||
}
|
||
}
|
||
authHealth = { ok: down.length === 0, checked: new Date().toISOString(), down };
|
||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:health', authHealth);
|
||
return authHealth;
|
||
}
|
||
|
||
function applyProxy(s) {
|
||
try {
|
||
if (s.proxyEnabled && s.proxyHost && String(s.proxyPort).trim()) {
|
||
const cred = s.proxyUser ? `${encodeURIComponent(s.proxyUser)}:${encodeURIComponent(s.proxyPass || '')}@` : '';
|
||
undici.setGlobalDispatcher(new undici.ProxyAgent(`http://${cred}${s.proxyHost}:${s.proxyPort}`));
|
||
} else {
|
||
undici.setGlobalDispatcher(new undici.Agent());
|
||
}
|
||
} catch { /* ungültige Proxy-Konfig ignorieren */ }
|
||
}
|
||
|
||
let mainWindow = null;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 },
|
||
skineditor: { title: 'AeroMC – Skin-Editor', width: 1180, height: 800, minWidth: 920, minHeight: 600 },
|
||
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 },
|
||
friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 },
|
||
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 },
|
||
};
|
||
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
|
||
// Ergebnis der letzten Update-Suche (auch "läuft gerade" oder Fehler) – Fenster, die
|
||
// erst später aufgehen (z. B. Einstellungen → Info), holen es sich hierüber ab
|
||
let letzterUpdateCheck = { checking: true };
|
||
// Hauptfenster hat seine Empfänger registriert – erst dann den Update-Hinweis schicken
|
||
const hauptfensterBereit = new Promise((resolve) => ipcMain.once('renderer:ready', resolve));
|
||
|
||
async function pruefeAufUpdates() {
|
||
letzterUpdateCheck = { checking: true };
|
||
broadcast('update:checked', letzterUpdateCheck);
|
||
try {
|
||
const info = await updater.checkForUpdate(app.getVersion());
|
||
pendingUpdateInfo = info;
|
||
letzterUpdateCheck = info;
|
||
} catch (err) {
|
||
// offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen
|
||
letzterUpdateCheck = { available: false, error: (err && err.message) || String(err) };
|
||
}
|
||
broadcast('update:checked', letzterUpdateCheck);
|
||
return letzterUpdateCheck;
|
||
}
|
||
|
||
// 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: fensterFarbe(),
|
||
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,
|
||
// Clip-Modus nimmt auch bei geschlossenem (verstecktem) Fenster weiter auf –
|
||
// ohne das würde Chromium Zeichenschleife und Zeitgeber dort anhalten
|
||
backgroundThrottling: modalId !== 'record',
|
||
},
|
||
});
|
||
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));
|
||
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
|
||
// die Popup-Fenster haben ihren eigenen Kopf)
|
||
ipcMain.on('window:titleBar', (e, design) => {
|
||
if (!mainWindow || mainWindow.isDestroyed() || e.sender !== mainWindow.webContents) return;
|
||
try { mainWindow.setTitleBarOverlay(titelleistenFarben(design)); } catch { /* ältere Windows-Fassung */ }
|
||
});
|
||
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
|
||
const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster
|
||
// des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster
|
||
// (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein
|
||
// Fenster zuerst gefunden wurde, obwohl Electric__Max der Freund war, der geprüft hat)
|
||
const kontoInstanz = new Map(); // kontoId -> instanceId des gerade laufenden Spiels (für "Einladen": eigener Server)
|
||
// Ende der Konsolenausgabe des letzten Starts je Instanz – für den Absturz-Assistenten,
|
||
// weil frühe Fehler (falsches Java, zu viel RAM) in keiner Datei landen
|
||
const letzteAusgabe = new Map();
|
||
const AUSGABE_MAX = 200000;
|
||
// Herzschläge aller laufenden Spiele – um eine geänderte Sichtbarkeit sofort zu melden
|
||
const laufendeHerzschlaege = new Set();
|
||
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; }
|
||
|
||
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, '_'));
|
||
}
|
||
|
||
// Aufnahme kommt vom MediaRecorder als WebM - für Abspielbarkeit in
|
||
// praktisch jedem Player (Windows Media Player, Smart-TVs, Handys usw.) wird
|
||
// direkt nach dem Speichern per ffmpeg in eine MP4-Datei (H.264 + AAC)
|
||
// umgewandelt, das temporäre WebM danach gelöscht.
|
||
//
|
||
// -r <fps> erzwingt eine SAUBERE, konstante Bildrate im Ausgabevideo: die
|
||
// Aufnahme kommt aus einer Canvas-Zeichenschleife (für Zuschnitt/Skalierung,
|
||
// siehe erzeugeSkaliertenStream im Renderer) und deren Bild-Zeitstempel sind
|
||
// nicht perfekt gleichmäßig - ohne -r übernimmt ffmpeg diese unregelmäßigen
|
||
// Zeitstempel unverändert, was zu einer falschen/kaputten Bildrate im
|
||
// Ausgabevideo führt (z. B. fälschlich "1000fps" laut ffprobe) und manche
|
||
// Player die Datei gar nicht erst abspielen lässt.
|
||
function wandleInMp4Um(quellDatei, zielDatei, fps) {
|
||
return new Promise((resolve, reject) => {
|
||
execFile(ffmpegPfad, [
|
||
'-y', '-i', quellDatei,
|
||
'-r', String(fps || 30), '-vsync', 'cfr',
|
||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
||
'-c:a', 'aac', '-b:a', '192k',
|
||
zielDatei,
|
||
], (err, _stdout, stderr) => {
|
||
if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message));
|
||
else resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel, fps) => {
|
||
let tempDatei = null;
|
||
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}.mp4`);
|
||
let n = 2;
|
||
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.mp4`); n++; }
|
||
|
||
tempDatei = zielDatei.slice(0, -4) + '.webm.tmp';
|
||
fs.writeFileSync(tempDatei, Buffer.from(buffer));
|
||
|
||
if (ffmpegPfad) {
|
||
await wandleInMp4Um(tempDatei, zielDatei, fps);
|
||
fs.rmSync(tempDatei, { force: true });
|
||
} else {
|
||
// ffmpeg fehlt (sollte nur in einer kaputten Installation vorkommen) -
|
||
// wenigstens das unveränderte WebM behalten statt die Aufnahme zu verlieren
|
||
zielDatei = zielDatei.slice(0, -4) + '.webm';
|
||
fs.renameSync(tempDatei, zielDatei);
|
||
}
|
||
|
||
notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`);
|
||
return { ok: true, path: zielDatei };
|
||
} catch (err) {
|
||
if (tempDatei) fs.rmSync(tempDatei, { force: true });
|
||
return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// ---------- Clip-Modus ----------
|
||
// Tastenkürzel wie bei ShadowPlay; ist es schon vergeben (z. B. von ShadowPlay
|
||
// selbst), weicht AeroMC auf Strg+Alt+C aus.
|
||
const CLIP_TASTENKUERZEL = ['Alt+F10', 'Control+Alt+C'];
|
||
let clipKuerzel = null;
|
||
let clipInstanz = null;
|
||
|
||
ipcMain.handle('clip:setActive', (_e, aktiv, instanceId) => {
|
||
if (clipKuerzel) { globalShortcut.unregister(clipKuerzel); clipKuerzel = null; }
|
||
clipInstanz = aktiv ? instanceId : null;
|
||
if (!aktiv) return null;
|
||
for (const kuerzel of CLIP_TASTENKUERZEL) {
|
||
const ok = globalShortcut.register(kuerzel, () => {
|
||
const pid = clipInstanz && laufendePid.get(clipInstanz);
|
||
if (pid) zeigeIngameOverlay('Clip', 'Wird gespeichert …', pid, 2500).catch(() => {});
|
||
const win = popoutWindows.get('record');
|
||
if (win && !win.isDestroyed()) win.webContents.send('clip:hotkey');
|
||
});
|
||
if (ok) { clipKuerzel = kuerzel; break; }
|
||
}
|
||
return clipKuerzel;
|
||
});
|
||
|
||
// Abschnitte zusammensetzen, vorne auf die Clip-Länge kürzen, als MP4 speichern
|
||
ipcMain.handle('clip:save', async (_e, daten) => {
|
||
const d = daten || {};
|
||
const abschnitte = Array.isArray(d.abschnitte) ? d.abschnitte : [];
|
||
if (!abschnitte.length) return { ok: false, message: 'Keine Clip-Daten.' };
|
||
if (!ffmpegPfad) return { ok: false, message: 'ffmpeg fehlt – Clips können nicht gespeichert werden.' };
|
||
const arbeitsOrdner = fs.mkdtempSync(path.join(app.getPath('temp'), 'aeromc-clip-'));
|
||
try {
|
||
const liste = abschnitte.map((buf, i) => {
|
||
const datei = path.join(arbeitsOrdner, `teil${i}.webm`);
|
||
fs.writeFileSync(datei, Buffer.from(buf));
|
||
return `file '${datei.replace(/\\/g, '/').replace(/'/g, "'\\''")}'`;
|
||
});
|
||
const listenDatei = path.join(arbeitsOrdner, 'liste.txt');
|
||
fs.writeFileSync(listenDatei, liste.join('\n'), 'utf8');
|
||
|
||
const zielOrdner = aufzeichnungsOrdner(d.instanceName);
|
||
fs.mkdirSync(zielOrdner, { recursive: true });
|
||
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||
const serverTeil = d.serverLabel ? String(d.serverLabel).replace(/[^\w.-]+/g, '_') + '_' : '';
|
||
let zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}.mp4`);
|
||
let n = 2;
|
||
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}_${n}.mp4`); n++; }
|
||
|
||
await new Promise((resolve, reject) => {
|
||
execFile(ffmpegPfad, [
|
||
'-y', '-f', 'concat', '-safe', '0', '-i', listenDatei,
|
||
'-ss', String(Math.max(0, Number(d.vorneAbschneiden) || 0).toFixed(2)),
|
||
'-r', String(d.fps || 30), '-vsync', 'cfr',
|
||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
||
'-c:a', 'aac', '-b:a', '192k',
|
||
zielDatei,
|
||
], { maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||
if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message));
|
||
else resolve();
|
||
});
|
||
});
|
||
|
||
const pid = d.instanceId && laufendePid.get(d.instanceId);
|
||
if (pid) zeigeIngameOverlay('Clip gespeichert', `${d.laenge || ''} Sek. · ${path.basename(zielDatei)}`, pid, 5000).catch(() => {});
|
||
return { ok: true, path: zielDatei };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Clip konnte nicht gespeichert werden: ' + err.message };
|
||
} finally {
|
||
fs.rmSync(arbeitsOrdner, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('recording:openFolder', (_e, instanceName) => {
|
||
const dir = aufzeichnungsOrdner(instanceName);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
shell.openPath(dir);
|
||
return true;
|
||
});
|
||
|
||
// ---------- Aufzeichnungen-Galerie ----------
|
||
|
||
ipcMain.handle('recording:list', (_e, instanceName) => {
|
||
const ordner = aufzeichnungsOrdner(instanceName);
|
||
try {
|
||
return fs.readdirSync(ordner)
|
||
.filter((name) => name.toLowerCase().endsWith('.mp4'))
|
||
.map((name) => {
|
||
const stat = fs.statSync(path.join(ordner, name));
|
||
return { name, size: stat.size, mtimeMs: stat.mtimeMs };
|
||
})
|
||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||
} catch { return []; }
|
||
});
|
||
|
||
ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => {
|
||
if (!ffmpegPfad) return null;
|
||
const ordner = aufzeichnungsOrdner(instanceName);
|
||
const quelle = path.join(ordner, dateiname);
|
||
if (!fs.existsSync(quelle)) return null;
|
||
const cacheOrdner = path.join(ordner, '.thumbnails');
|
||
const cacheDatei = path.join(cacheOrdner, dateiname + '.jpg');
|
||
if (!fs.existsSync(cacheDatei)) {
|
||
fs.mkdirSync(cacheOrdner, { recursive: true });
|
||
await new Promise((resolve) => {
|
||
// Erster Versuch bei 1s (überspringt oft ein schwarzes/leeres Startbild),
|
||
// Fehler wird ignoriert - dann bleibt einfach kein Vorschaubild
|
||
execFile(ffmpegPfad, ['-y', '-ss', '00:00:01', '-i', quelle, '-frames:v', '1', '-vf', 'scale=240:-1', cacheDatei], () => resolve());
|
||
});
|
||
}
|
||
try {
|
||
if (!fs.existsSync(cacheDatei)) return null;
|
||
return 'data:image/jpeg;base64,' + fs.readFileSync(cacheDatei).toString('base64');
|
||
} catch { return null; }
|
||
});
|
||
|
||
ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => {
|
||
const ordner = aufzeichnungsOrdner(instanceName);
|
||
try {
|
||
fs.rmSync(path.join(ordner, dateiname), { force: true });
|
||
fs.rmSync(path.join(ordner, '.thumbnails', dateiname + '.jpg'), { force: true });
|
||
return true;
|
||
} catch { return false; }
|
||
});
|
||
|
||
ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
|
||
const ziel = path.join(aufzeichnungsOrdner(instanceName), dateiname);
|
||
if (fs.existsSync(ziel)) shell.openPath(ziel);
|
||
return true;
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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.
|
||
// Rechtsklick-Menü für Textfelder und markierten Text – Electron bringt von sich
|
||
// aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V.
|
||
// Gilt für Haupt- und alle Popup-Fenster.
|
||
app.on('web-contents-created', (_e, contents) => {
|
||
contents.on('context-menu', (_ev, params) => {
|
||
const auswahl = String(params.selectionText || '').trim();
|
||
if (!params.isEditable && !auswahl) return;
|
||
const f = params.editFlags || {};
|
||
const vorlage = params.isEditable
|
||
? [
|
||
{ label: 'Ausschneiden', role: 'cut', enabled: !!f.canCut },
|
||
{ label: 'Kopieren', role: 'copy', enabled: !!f.canCopy },
|
||
{ label: 'Einfügen', role: 'paste', enabled: !!f.canPaste },
|
||
{ type: 'separator' },
|
||
{ label: 'Alles auswählen', role: 'selectAll', enabled: !!f.canSelectAll },
|
||
]
|
||
: [{ label: 'Kopieren', role: 'copy', enabled: !!f.canCopy }];
|
||
const fenster = BrowserWindow.fromWebContents(contents);
|
||
Menu.buildFromTemplate(vorlage).popup(fenster ? { window: fenster } : {});
|
||
});
|
||
});
|
||
|
||
// Eigene App-Kennung für Windows (gleich wie appId in package.json und die
|
||
// Startmenü-Verknüpfung des Installers). Ohne das zeigt Windows bei
|
||
// Benachrichtigungen "electron.app.AeroMC" ohne Logo an.
|
||
const APP_KENNUNG = 'com.aeromc.launcher';
|
||
if (process.platform === 'win32') app.setAppUserModelId(APP_KENNUNG);
|
||
|
||
/*
|
||
* Name und Logo dieser App-Kennung bei Windows hinterlegen
|
||
* (HKCU\Software\Classes\AppUserModelId\<Kennung>). Damit stimmt die Anzeige
|
||
* auch ohne Startmenü-Verknüpfung, z. B. bei der Portable-Fassung. Das Logo
|
||
* liegt im Paket (app.asar), das Windows nicht lesen kann – deshalb eine Kopie
|
||
* im Datenordner.
|
||
*/
|
||
let benachrichtigungsLogo = null;
|
||
function registriereAppBeiWindows() {
|
||
if (process.platform !== 'win32') return;
|
||
try {
|
||
const ziel = path.join(app.getPath('userData'), 'aeromc-logo.png');
|
||
const quelle = path.join(__dirname, 'assets', 'logo.png');
|
||
if (fs.existsSync(quelle)) {
|
||
const bild = nativeImage.createFromPath(quelle);
|
||
if (!bild.isEmpty()) {
|
||
fs.writeFileSync(ziel, bild.resize({ width: 128, height: 128, quality: 'best' }).toPNG());
|
||
benachrichtigungsLogo = ziel;
|
||
}
|
||
}
|
||
const schluessel = `HKCU\\Software\\Classes\\AppUserModelId\\${APP_KENNUNG}`;
|
||
execFile('reg', ['add', schluessel, '/v', 'DisplayName', '/t', 'REG_SZ', '/d', 'AeroMC', '/f'], { windowsHide: true }, () => {});
|
||
if (benachrichtigungsLogo) {
|
||
execFile('reg', ['add', schluessel, '/v', 'IconUri', '/t', 'REG_SZ', '/d', benachrichtigungsLogo, '/f'], { windowsHide: true }, () => {});
|
||
}
|
||
} catch { /* rein kosmetisch */ }
|
||
}
|
||
|
||
const hatEinzelSperre = app.requestSingleInstanceLock();
|
||
if (!hatEinzelSperre) app.quit();
|
||
|
||
/*
|
||
* Instanz-Kennung aus den Startargumenten lesen.
|
||
*
|
||
* Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um –
|
||
* alle Schalter wandern nach vorn, alles andere ans Ende. Aus
|
||
* --launch 1.21.11
|
||
* wird dabei
|
||
* --launch --allow-file-access-from-files <pfad> 1.21.11
|
||
* und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die
|
||
* Form "--launch=<kennung>" verwendet: die bleibt ein einziges Argument.
|
||
*/
|
||
function instanzAusArgumenten(argv) {
|
||
const args = (argv || []).map(String);
|
||
|
||
for (const a of args) {
|
||
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
|
||
}
|
||
|
||
// Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von
|
||
// Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren
|
||
// ans Ende gerutscht – das letzte Argument, das kein Schalter ist.
|
||
if (args.includes('--launch')) {
|
||
const i = args.indexOf('--launch');
|
||
if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1];
|
||
for (let j = args.length - 1; j > 0; j--) {
|
||
if (!args[j].startsWith('-')) return args[j];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer
|
||
// Server-Verknüpfung ("--server=<ip>") – bleibt aus demselben Grund als ein
|
||
// einziges "--server=..."-Argument zusammen
|
||
function serverAusArgumenten(argv) {
|
||
const args = (argv || []).map(String);
|
||
for (const a of args) {
|
||
if (a.startsWith('--server=')) return a.slice('--server='.length);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Startwunsch an die Oberfläche geben – dort hängt die gesamte Rückmeldung dran
|
||
function starteInstanzImFenster(id, serverIp) {
|
||
if (!id || !mainWindow || mainWindow.isDestroyed()) return;
|
||
mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null });
|
||
}
|
||
|
||
app.on('second-instance', (_e, argv) => {
|
||
// showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann
|
||
// das Fenster echt versteckt sein (hide(), nicht nur minimiert) – das
|
||
// braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar
|
||
showMainWindow();
|
||
starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(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;
|
||
}
|
||
|
||
/*
|
||
* Baut die Argumente für eine Verknüpfung zusammen.
|
||
* Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte
|
||
* electron.exe – die weiß ohne Pfadangabe nicht, welche App sie laden soll,
|
||
* und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im
|
||
* gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort
|
||
* kein Zusatz nötig.
|
||
*/
|
||
function verknuepfungsArgs(teile) {
|
||
const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `;
|
||
return praefix + teile.join(' ');
|
||
}
|
||
|
||
/*
|
||
* Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon
|
||
* das richtige Icon als Ressource (funktioniert auch aus dem asar heraus).
|
||
* Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo) –
|
||
* dort stattdessen direkt die eigene .ico-Datei verwenden, die im
|
||
* Entwicklungsordner als normale Datei vorliegt.
|
||
*/
|
||
function verknuepfungsIcon(ziel) {
|
||
if (app.isPackaged) return ziel;
|
||
return windowIcon() || ziel;
|
||
}
|
||
|
||
// Eigene Titelleiste im Hauptfenster: Minimieren/Maximieren/Schließen bleiben
|
||
// echte Windows-Knöpfe (Andocken, Fensterlayouts …), Windows zeichnet sie aber
|
||
// in den Farben des Designs. Die Farben müssen zu --titel-bg/--titel-fg in
|
||
// styles.css bzw. designs.css passen.
|
||
const TITELLEISTE = {
|
||
dark: { color: '#2d2d30', symbolColor: '#e4e4e6' },
|
||
light: { color: '#f0f0f0', symbolColor: '#1c1c1c' },
|
||
aero: { color: '#0a0e1a', symbolColor: '#e8ecfb' },
|
||
lila: { color: '#0f0a1a', symbolColor: '#f0eafb' },
|
||
minecraft: { color: '#1e1e1f', symbolColor: '#ffffff' },
|
||
};
|
||
function titelleistenFarben(design) {
|
||
return Object.assign({ height: 32 }, TITELLEISTE[design] || TITELLEISTE.dark);
|
||
}
|
||
|
||
// Grundfarbe eines neuen Fensters passend zum Design – sonst blitzt beim
|
||
// Öffnen kurz die falsche Farbe auf, bis die Oberfläche gezeichnet ist
|
||
function fensterFarbe() {
|
||
const design = (store.getSettings() || {}).theme;
|
||
return { light: '#f0f0f0', aero: '#05070d', lila: '#08050f', minecraft: '#313233' }[design] || '#1b1e24';
|
||
}
|
||
|
||
function windowIcon() {
|
||
const png = path.join(__dirname, 'assets', 'logo.png');
|
||
const ico = path.join(__dirname, 'assets', 'icon.ico');
|
||
if (fs.existsSync(ico)) return ico;
|
||
if (fs.existsSync(png)) return png;
|
||
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
|
||
}
|
||
|
||
function createWindow(startHidden) {
|
||
mainWindow = new BrowserWindow({
|
||
width: 1200,
|
||
height: 820,
|
||
minWidth: 940,
|
||
minHeight: 640,
|
||
backgroundColor: fensterFarbe(),
|
||
title: 'AeroMC',
|
||
icon: windowIcon(),
|
||
autoHideMenuBar: true,
|
||
titleBarStyle: 'hidden',
|
||
titleBarOverlay: titelleistenFarben((store.getSettings() || {}).theme),
|
||
show: !startHidden,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: false,
|
||
},
|
||
});
|
||
|
||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||
|
||
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
|
||
// 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) {
|
||
// 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();
|
||
});
|
||
|
||
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche
|
||
// ihren Empfänger dafür registriert hat. WICHTIG: nicht auf "did-finish-load"
|
||
// verlassen – das feuert, sobald die Seite geladen ist, aber die Oberfläche
|
||
// registriert ihren IPC-Listener erst nach eigenem asynchronem Vorlauf
|
||
// (u. a. Skin-Hintergrund laden). Kommt der Startwunsch früher an als diese
|
||
// Registrierung, geht er kommentarlos verloren – ein Wettlauf, der je nach
|
||
// Netzwerk-/Ladegeschwindigkeit mal gewinnt, mal nicht. Deshalb wartet der
|
||
// Hauptprozess hier auf ein explizites Bereit-Signal der Oberfläche.
|
||
const startId = instanzAusArgumenten(process.argv);
|
||
const startServer = serverAusArgumenten(process.argv);
|
||
if (startId) {
|
||
ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
|
||
}
|
||
ipcMain.once('renderer:ready', () => oeffneVorbereitetenImport());
|
||
}
|
||
|
||
// Freundesystem-Anmeldung: Minecraft-Zugang eines Kontos für Mojangs Sitzungsprüfung
|
||
friends.setzeZugangsHelfer(async (uuid) => {
|
||
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
|
||
const konto = await auth.getValidAccountById(clientId, uuid).catch(() => null);
|
||
return konto && konto.profile && konto.mcAccessToken
|
||
? { accessToken: konto.mcAccessToken, name: konto.profile.name }
|
||
: null;
|
||
});
|
||
|
||
/*
|
||
* Das Setup kann optional andere Launcher übernehmen lassen (installer/Installer.cs):
|
||
* es legt dazu pending-import.json mit den angekreuzten Launchern ab. Beim ersten
|
||
* sichtbaren Start öffnet sich der Import-Dialog damit schon fertig durchsucht –
|
||
* übernommen wird erst, was man dort bestätigt. Die Datei gilt nur einmal.
|
||
*/
|
||
function oeffneVorbereitetenImport() {
|
||
const datei = path.join(app.getPath('userData'), 'pending-import.json');
|
||
if (!fs.existsSync(datei)) return;
|
||
// im Autostart unsichtbar gestartet: erst anbieten, wenn das Fenster gezeigt wird
|
||
if (mainWindow && !mainWindow.isVisible()) {
|
||
mainWindow.once('show', () => oeffneVorbereitetenImport());
|
||
return;
|
||
}
|
||
let quellen = [];
|
||
try {
|
||
const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, ''));
|
||
quellen = (Array.isArray(daten.sources) ? daten.sources : [])
|
||
.filter((q) => q && ['mc', 'cf', 'mr', 'mmc', 'technic'].includes(q.kind))
|
||
.map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' }));
|
||
} catch { /* beschädigt -> verwerfen */ }
|
||
try { fs.unlinkSync(datei); } catch { /* egal */ }
|
||
if (!quellen.length) return;
|
||
openPopout('import', { kind: quellen[0].kind, vorauswahl: JSON.stringify(quellen) });
|
||
}
|
||
|
||
let tray = null;
|
||
let appIsQuitting = false;
|
||
|
||
function showMainWindow() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; }
|
||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||
mainWindow.show();
|
||
mainWindow.focus();
|
||
}
|
||
|
||
function createTray() {
|
||
if (tray) return;
|
||
try {
|
||
const iconPfad = windowIcon();
|
||
const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty();
|
||
tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 }));
|
||
tray.setToolTip('AeroMC');
|
||
// Menü bei jedem Rechtsklick neu bauen – so sind Favoriten und zuletzt
|
||
// gespielte Instanzen immer aktuell
|
||
tray.on('right-click', () => tray.popUpContextMenu(trayMenue()));
|
||
tray.on('click', () => showMainWindow());
|
||
} catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ }
|
||
}
|
||
|
||
// Instanzen direkt aus dem Infobereich starten, ohne das Fenster zu öffnen:
|
||
// erst die Schnellstart-Favoriten (Strg+1 … 5), dann die zuletzt gespielten
|
||
function trayMenue() {
|
||
const alle = store.listInstances();
|
||
const nachId = new Map(alle.map((i) => [i.id, i]));
|
||
const schnell = store.getSettings().schnellstart || {};
|
||
const favoriten = Object.keys(schnell).sort()
|
||
.map((taste) => ({ taste, inst: nachId.get(schnell[taste]) }))
|
||
.filter((f) => f.inst);
|
||
const favIds = new Set(favoriten.map((f) => f.inst.id));
|
||
const zuletzt = alle.filter((i) => i.lastPlayed && !favIds.has(i.id))
|
||
.sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed)))
|
||
.slice(0, 5);
|
||
const starte = (inst) => () => starteInstanzImFenster(inst.id);
|
||
const eintrag = (inst, zusatz) => ({
|
||
label: `${inst.name} · ${inst.minecraft.version || '?'}${zusatz ? ' ' + zusatz : ''}`,
|
||
click: starte(inst),
|
||
});
|
||
|
||
const vorlage = [{ label: 'AeroMC öffnen', click: () => showMainWindow() }, { type: 'separator' }];
|
||
if (favoriten.length) {
|
||
vorlage.push({ label: 'Schnellstart', enabled: false });
|
||
favoriten.forEach((f) => vorlage.push(eintrag(f.inst, `Strg+${f.taste}`)));
|
||
}
|
||
if (zuletzt.length) {
|
||
if (favoriten.length) vorlage.push({ type: 'separator' });
|
||
vorlage.push({ label: 'Zuletzt gespielt', enabled: false });
|
||
zuletzt.forEach((i) => vorlage.push(eintrag(i)));
|
||
}
|
||
if (!favoriten.length && !zuletzt.length) vorlage.push({ label: 'Noch keine Instanz gespielt', enabled: false });
|
||
vorlage.push({ type: 'separator' }, { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } });
|
||
return Menu.buildFromTemplate(vorlage);
|
||
}
|
||
|
||
function destroyTray() {
|
||
if (tray) { tray.destroy(); tray = null; }
|
||
}
|
||
|
||
// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden
|
||
function applyAutostart(enabled) {
|
||
try {
|
||
// Gleiches Problem wie bei den Desktop-Verknüpfungen (siehe verknuepfungsArgs
|
||
// weiter oben): im Entwicklungsmodus zeigt process.execPath nur auf die nackte
|
||
// electron.exe, die ohne Pfadangabe ihren eigenen leeren Startbildschirm zeigt
|
||
// statt AeroMC zu laden. Zusätzlich: bei der Portable-Fassung zeigt
|
||
// process.execPath auf den entpackten Temp-Ordner – der wäre morgen tot,
|
||
// deshalb wie bei den Verknüpfungen über verknuepfungsZiel() auflösen.
|
||
const args = enabled
|
||
? (app.isPackaged ? ['--hidden'] : [path.join(__dirname, '..'), '--hidden'])
|
||
: [];
|
||
app.setLoginItemSettings({ openAtLogin: !!enabled, path: verknuepfungsZiel(), args });
|
||
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
|
||
// Symbol im Infobereich gibt es immer (Schnellstart per Rechtsklick)
|
||
createTray();
|
||
}
|
||
|
||
app.whenReady().then(() => {
|
||
if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst
|
||
registriereAppBeiWindows();
|
||
store.init(app.getPath('userData'));
|
||
services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern
|
||
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
|
||
services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java')));
|
||
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
|
||
auth.init(app.getPath('userData'), {
|
||
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
|
||
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
|
||
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
|
||
});
|
||
applyProxy(store.getSettings());
|
||
discordpresence.configure(store.getSettings());
|
||
const autostartAn = !!store.getSettings().autostart;
|
||
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
|
||
// von Hand an der Registry gedreht wurde oder nach einem Update)
|
||
applyAutostart(autostartAn);
|
||
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);
|
||
|
||
// echte Minecraft-Icons einmalig aus den eigenen Spieldateien holen
|
||
setTimeout(() => {
|
||
try {
|
||
const { shared, out } = iconDirs();
|
||
if (!fs.existsSync(out) || fs.readdirSync(out).length === 0) icons.extractIcons(shared, out);
|
||
} catch { /* noch keine Version heruntergeladen */ }
|
||
}, 1500);
|
||
|
||
// Reste früherer Update-Downloads aufräumen (mehrere hundert MB im Temp)
|
||
setTimeout(() => { try { updater.cleanupUpdateCache(); } catch { /* egal */ } }, 6000);
|
||
|
||
// Sicherung nach Zeitplan: kurz nach dem Start prüfen, danach stündlich.
|
||
// Die Prüfung ist billig – gesichert wird nur, wenn wirklich fällig.
|
||
setTimeout(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 60000);
|
||
setInterval(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 3600000);
|
||
|
||
// Freundesystem: alle 60s im Hintergrund prüfen, ob ein Freund neu online
|
||
// gegangen ist -> Windows-Meldung. Der erste Durchlauf legt nur die
|
||
// Vergleichsbasis an (noch keine Meldung, sonst würde jeder schon online
|
||
// befindliche Freund beim Start fälschlich als "gerade online gegangen" gelten).
|
||
setTimeout(() => { pruefeFreundeOnline().catch(() => {}); }, 15000);
|
||
setInterval(() => { pruefeFreundeOnline().catch(() => {}); }, 60000);
|
||
// Einladungen ("Komm auf meinen Server") öfter prüfen – sie gelten nur ein paar Minuten
|
||
setTimeout(() => { pruefeEinladungen().catch(() => {}); }, 8000);
|
||
setInterval(() => { pruefeEinladungen().catch(() => {}); }, 15000);
|
||
setTimeout(() => { pruefeNachrichten().catch(() => {}); }, 9000);
|
||
setInterval(() => { pruefeNachrichten().catch(() => {}); }, 15000);
|
||
// Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf
|
||
setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000);
|
||
setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000);
|
||
|
||
// gleich beim Start auf Updates prüfen; den Hinweis-Dialog aber erst zeigen,
|
||
// wenn das Hauptfenster bereit ist (sonst ginge er verloren)
|
||
pruefeAufUpdates().then(async (info) => {
|
||
if (!info.available) return;
|
||
await hauptfensterBereit;
|
||
broadcast('update:available', info);
|
||
}).catch(() => {});
|
||
|
||
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
||
setTimeout(() => checkAuthServices(), 2500);
|
||
setInterval(() => checkAuthServices(), 120000);
|
||
|
||
// Anmeldung im Hintergrund auffrischen -> kein erneuter Login beim Start nötig
|
||
const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
|
||
auth.getValidAccount(cid)
|
||
.then((acc) => {
|
||
if (acc) broadcast('auth:refreshed');
|
||
})
|
||
.catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ });
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
});
|
||
});
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') app.quit();
|
||
});
|
||
|
||
app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); });
|
||
app.on('will-quit', () => { globalShortcut.unregisterAll(); });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Java-Erkennung (für Anzeige in den Einstellungen)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function detectJava(javaPath) {
|
||
return new Promise((resolve) => {
|
||
const cmd = javaPath && javaPath.trim() ? javaPath : 'java';
|
||
execFile(cmd, ['-version'], (err, stdout, stderr) => {
|
||
if (err) return resolve({ ok: false, path: cmd, version: null, error: err.message });
|
||
// "java -version" schreibt nach stderr
|
||
const out = (stderr || stdout || '').split('\n')[0].trim();
|
||
resolve({ ok: true, path: cmd, version: out });
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Einstellungen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('settings:get', () => store.getSettings());
|
||
|
||
ipcMain.handle('settings:save', (_e, patch) => {
|
||
const s = store.saveSettings(patch);
|
||
applyProxy(s);
|
||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
|
||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
|
||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsEnabled')) {
|
||
aktualisiereFreundeTastenkuerzel();
|
||
aktualisiereAnfragenZahl().catch(() => {});
|
||
}
|
||
// Server ein-/ausgeblendet -> Freunden gleich melden, nicht erst beim nächsten Herzschlag
|
||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsShareServer')) {
|
||
for (const herzschlag of laufendeHerzschlaege) herzschlag();
|
||
}
|
||
// 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');
|
||
// an alle Fenster: das Design muss auch in offenen Popup-Fenstern wechseln
|
||
if (!nurLetzteInstanz) broadcast('settings:changed', s);
|
||
return s;
|
||
});
|
||
|
||
ipcMain.handle('settings:instancesDir', () => store.getInstancesDir());
|
||
|
||
ipcMain.handle('java:detect', (_e, javaPath) => detectJava(javaPath));
|
||
|
||
// automatische Java-Suche über das System
|
||
ipcMain.handle('java:scan', () => services.scanJava(false)); // Knopf sucht immer frisch
|
||
// aus dem Zwischenspeicher – für Dialoge, die nur die Liste anzeigen wollen
|
||
ipcMain.handle('java:list', () => services.scanJava(true));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Minecraft-Versionen (Mojang)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData')));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Mods (Modrinth)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('curseforge:testKey', async (_e, apiKey) => {
|
||
try { return await services.curseforgeTestKey(apiKey); }
|
||
catch (err) { return { ok: false, message: err.message || String(err) }; }
|
||
});
|
||
|
||
ipcMain.handle('mods:search', async (_e, opts) => {
|
||
const o = opts || {};
|
||
const source = o.source || 'modrinth';
|
||
try {
|
||
if (source === 'curseforge') {
|
||
const zugang = services.curseforgeZugang(store.getSettings());
|
||
return await services.curseforgeSearch(zugang, o);
|
||
}
|
||
return await services.modrinthSearch(o);
|
||
} catch (err) {
|
||
return {
|
||
total: 0,
|
||
hits: [],
|
||
error: (err && err.message) ? err.message : String(err),
|
||
needKey: !!(err && err.code === 'need-key'),
|
||
};
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
|
||
|
||
ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art));
|
||
|
||
// installiert ein Projekt (Modrinth oder CurseForge) samt Pflicht-Abhängigkeiten –
|
||
// auch vom Performance-Paket beim Anlegen einer Instanz genutzt
|
||
async function installiereInhalt(id, project, art) {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const typ = services.inhaltsArt(art || 'mod');
|
||
const gameVersion = inst.minecraft.version;
|
||
const loader = inst.minecraft.loader;
|
||
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
|
||
// Nur Mods brauchen einen Loader – Ressourcenpakete und Shader laufen auch
|
||
// in einer Vanilla-Instanz.
|
||
if (typ.loaderNoetig && (!loader || loader === 'vanilla')) {
|
||
return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
|
||
}
|
||
|
||
const modsDir = store.inhaltsDir(id, art || 'mod');
|
||
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
|
||
const added = [];
|
||
let firstError = null;
|
||
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
|
||
const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null;
|
||
|
||
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
|
||
async function installOne(proj, isDep) {
|
||
if (done.has(proj.projectId)) return;
|
||
done.add(proj.projectId);
|
||
try {
|
||
let res;
|
||
if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') {
|
||
res = await services.curseforgeInstall({
|
||
zugang, projectId: proj.projectId, title: proj.title,
|
||
gameVersion, loader, modsDir, art: art || 'mod',
|
||
});
|
||
if (!res.ok) {
|
||
if (!isDep && !firstError) firstError = res.message;
|
||
return;
|
||
}
|
||
// merken, welche Pflicht-Abhängigkeiten dieser Mod mitbringt – damit
|
||
// wir beim Entfernen warnen können, wenn ein anderer Mod ihn braucht
|
||
res.entry.requiredDeps = (res.dependencies || [])
|
||
.filter((d) => d.required && d.projectId).map((d) => d.projectId);
|
||
store.addMod(id, res.entry, art || 'mod');
|
||
added.push(res.entry);
|
||
for (const dep of res.dependencies || []) {
|
||
if (dep.required && dep.projectId && !done.has(dep.projectId)) {
|
||
await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true);
|
||
}
|
||
}
|
||
} else {
|
||
res = await services.modrinthInstall({
|
||
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod',
|
||
versionId: isDep ? '' : (proj.versionId || ''),
|
||
});
|
||
if (!res.ok) {
|
||
if (!isDep && !firstError) firstError = res.message;
|
||
return;
|
||
}
|
||
res.entry.requiredDeps = (res.dependencies || [])
|
||
.filter((d) => d.dependency_type === 'required' && d.project_id).map((d) => d.project_id);
|
||
store.addMod(id, res.entry, art || 'mod');
|
||
added.push(res.entry);
|
||
for (const dep of res.dependencies || []) {
|
||
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
|
||
const title = await services.modrinthProjectTitle(dep.project_id);
|
||
await installOne({ projectId: dep.project_id, title }, true);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (!isDep && !firstError) firstError = err.message;
|
||
}
|
||
}
|
||
|
||
await installOne(project, false);
|
||
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
|
||
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
|
||
}
|
||
|
||
/*
|
||
* Performance-Paket: bewährte Optimierungs-Mods, per Häkchen beim Anlegen einer
|
||
* Instanz mitinstalliert. Feste Modrinth-IDs statt Namen, damit sie sich mit den
|
||
* automatisch nachgezogenen Abhängigkeiten nicht doppeln. Was es für Version
|
||
* oder Loader (noch) nicht gibt, wird einfach übersprungen.
|
||
*/
|
||
const PERFORMANCE_MODS = [
|
||
{ projectId: 'AANobbMI', title: 'Sodium' }, // Grafik/FPS
|
||
{ projectId: 'gvQqBUqZ', title: 'Lithium' }, // Spiellogik
|
||
{ projectId: 'uXXizFIs', title: 'FerriteCore' }, // Arbeitsspeicher
|
||
{ projectId: '5ZwdcRci', title: 'ImmediatelyFast' }, // Darstellung (HUD, Text)
|
||
{ projectId: 'NNAgCjsB', title: 'Entity Culling' }, // verdeckte Objekte nicht zeichnen
|
||
{ projectId: 'nmDcB62a', title: 'ModernFix' }, // Ladezeit/Speicher
|
||
];
|
||
const PERFORMANCE_LOADER = new Set(['fabric', 'quilt', 'neoforge']);
|
||
|
||
ipcMain.handle('instances:installPerformancePack', async (_e, id) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
if (!PERFORMANCE_LOADER.has(inst.minecraft.loader)) {
|
||
return { ok: false, message: 'Das Performance-Paket gibt es für Fabric, Quilt und NeoForge.' };
|
||
}
|
||
const installiert = [];
|
||
const fehlend = [];
|
||
broadcast('ui:toast', { text: `Performance-Paket für „${inst.name}" wird installiert …` });
|
||
for (const mod of PERFORMANCE_MODS) {
|
||
const res = await installiereInhalt(id, { projectId: mod.projectId, title: mod.title }, 'mod').catch((err) => ({ ok: false, message: err.message }));
|
||
if (res.ok) installiert.push(mod.title);
|
||
else fehlend.push(mod.title);
|
||
}
|
||
broadcast('mods:changed', { id });
|
||
broadcast('ui:toast', installiert.length
|
||
? { text: `Performance-Paket: ${installiert.length} von ${PERFORMANCE_MODS.length} Mods installiert`
|
||
+ (fehlend.length ? ` (für diese Version nicht verfügbar: ${fehlend.join(', ')})` : '') + '.' }
|
||
: { text: 'Performance-Paket: Für diese Version/diesen Loader gibt es die Mods (noch) nicht.', error: true });
|
||
return { ok: true, installiert, fehlend };
|
||
});
|
||
|
||
/*
|
||
* Lokale Datei(en) direkt in den Inhaltsordner kopieren – für Mods, die
|
||
* nicht über Modrinth/CurseForge zu finden sind (Dev-Build, privater Mod,
|
||
* 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) => {
|
||
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(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'Datei hinzufügen',
|
||
properties: ['openFile', 'multiSelections'],
|
||
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
|
||
});
|
||
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
|
||
|
||
const dir = store.inhaltsDir(id, art || 'mod');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const added = [];
|
||
const failed = [];
|
||
for (const src of res.filePaths) {
|
||
try {
|
||
if (!/\.(jar|zip)$/i.test(src)) {
|
||
failed.push({ name: path.basename(src), message: 'Nur .jar/.zip-Dateien werden unterstützt.' });
|
||
continue;
|
||
}
|
||
const name = path.basename(src);
|
||
const ext = path.extname(name);
|
||
const base = name.slice(0, -ext.length);
|
||
let dest = path.join(dir, name);
|
||
// Namenskollision -> durchnummerieren statt eine vorhandene Datei zu überschreiben
|
||
let n = 2;
|
||
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext}`); n++; }
|
||
fs.copyFileSync(src, dest);
|
||
added.push(path.basename(dest));
|
||
} catch (err) {
|
||
failed.push({ name: path.basename(src), message: err.message });
|
||
}
|
||
}
|
||
return { ok: true, added, failed };
|
||
});
|
||
|
||
// verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version
|
||
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
|
||
try { return await loaders.listLoaderVersions(loader, mcVersion); }
|
||
catch { return []; }
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// OptiFine – kein offizielles Download-API (Downloads laufen bei OptiFine
|
||
// bewusst über eine Werbeseite, das umgehen wir nicht). Stattdessen: Nutzer
|
||
// lädt den Installer selbst von optifine.net, AeroMC bereitet Vanilla vor,
|
||
// startet den echten Installer sichtbar mit dem passenden Zielordner in der
|
||
// Zwischenablage und übernimmt danach die neu entstandene Profil-Version.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const optifineSetupState = new Map(); // instanceId -> { sharedDir, before: Set<string> }
|
||
|
||
function scanOptifineVersions(sharedDir, mcVersion) {
|
||
const versionsDir = path.join(sharedDir, 'versions');
|
||
if (!fs.existsSync(versionsDir)) return [];
|
||
const prefix = String(mcVersion || '');
|
||
return fs.readdirSync(versionsDir, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory())
|
||
.map((e) => e.name)
|
||
.filter((name) => /optifine/i.test(name) && (!prefix || name.startsWith(prefix)))
|
||
.filter((name) => fs.existsSync(path.join(versionsDir, name, name + '.json')))
|
||
.sort();
|
||
}
|
||
|
||
ipcMain.handle('optifine:find', (_e, mcVersion) => {
|
||
const sharedDir = path.join(app.getPath('userData'), 'shared');
|
||
return scanOptifineVersions(sharedDir, mcVersion);
|
||
});
|
||
|
||
ipcMain.handle('optifine:setup', async (_e, id) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
if (!inst.minecraft.version) return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen.' };
|
||
|
||
const picked = await dialog.showOpenDialog(mainWindow, {
|
||
title: 'OptiFine-Installer wählen (zuvor von optifine.net heruntergeladen)',
|
||
properties: ['openFile'],
|
||
filters: [{ name: 'OptiFine-Installer', extensions: ['jar'] }],
|
||
});
|
||
if (picked.canceled || !picked.filePaths.length) return { ok: false, canceled: true };
|
||
const installerPath = picked.filePaths[0];
|
||
|
||
const settings = store.getSettings();
|
||
const sharedDir = path.join(app.getPath('userData'), 'shared');
|
||
const opts = {
|
||
versionId: inst.minecraft.version,
|
||
loader: 'vanilla',
|
||
loaderVersion: '',
|
||
gameDir: store.gameDir(id),
|
||
sharedDir,
|
||
userDataDir: app.getPath('userData'),
|
||
javaPath: inst.java.path || settings.javaPath || '',
|
||
minMemMb: inst.java.minMemMb || settings.defaultMinMemMb,
|
||
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
|
||
extraArgs: '',
|
||
auth: null,
|
||
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
||
};
|
||
|
||
// Vanilla-Dateien sicherstellen (kein echter Start, da kein Konto übergeben wird)
|
||
let prep;
|
||
try {
|
||
prep = await launcher.prepareAndLaunch(opts, (evt) => {
|
||
broadcast('launch:progress', Object.assign({ id }, evt));
|
||
});
|
||
} catch (err) {
|
||
return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message };
|
||
}
|
||
if (!prep.needAuth && !prep.ok) return { ok: false, message: prep.message || 'Vorbereitung fehlgeschlagen.' };
|
||
const javaExe = (prep.java && prep.java.path) || opts.javaPath || 'java';
|
||
|
||
optifineSetupState.set(id, {
|
||
sharedDir,
|
||
before: new Set(scanOptifineVersions(sharedDir, inst.minecraft.version)),
|
||
});
|
||
|
||
try {
|
||
const child = spawn(javaExe, ['-jar', installerPath], { detached: true, stdio: 'ignore' });
|
||
child.unref();
|
||
} catch (err) {
|
||
return { ok: false, message: 'Installer konnte nicht gestartet werden: ' + err.message };
|
||
}
|
||
try { clipboard.writeText(sharedDir); } catch { /* egal */ }
|
||
|
||
return { ok: true, sharedDir };
|
||
});
|
||
|
||
ipcMain.handle('optifine:confirm', (_e, id) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const state = optifineSetupState.get(id);
|
||
const sharedDir = (state && state.sharedDir) || path.join(app.getPath('userData'), 'shared');
|
||
const nachher = scanOptifineVersions(sharedDir, inst.minecraft.version);
|
||
const vorher = (state && state.before) || new Set();
|
||
const neu = nachher.filter((v) => !vorher.has(v));
|
||
const treffer = neu[0] || nachher[0];
|
||
optifineSetupState.delete(id);
|
||
if (!treffer) {
|
||
return {
|
||
ok: false,
|
||
message: 'Keine OptiFine-Version für ' + (inst.minecraft.version || '?') + ' gefunden. '
|
||
+ 'Wurde die Installation im OptiFine-Fenster abgeschlossen und dabei der kopierte Ordner ausgewählt?',
|
||
};
|
||
}
|
||
return { ok: true, versionId: treffer };
|
||
});
|
||
|
||
ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod'));
|
||
// prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen
|
||
ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod'));
|
||
|
||
// Vorhandener Arbeitsspeicher – Grundlage für die Prüfung der RAM-Angaben
|
||
function gesamtSpeicherMb() {
|
||
return Math.round(os.totalmem() / 1048576);
|
||
}
|
||
|
||
ipcMain.handle('system:memory', () => ({
|
||
totalMb: gesamtSpeicherMb(),
|
||
freeMb: Math.round(os.freemem() / 1048576),
|
||
}));
|
||
|
||
ipcMain.handle('system:checkMemory', (_e, minMb, maxMb) =>
|
||
services.pruefeSpeicher(minMb, maxMb, gesamtSpeicherMb()));
|
||
|
||
// Neuester Absturzbericht einer Instanz; seit = Startzeitpunkt in Millisekunden
|
||
ipcMain.handle('crash:latest', (_e, id, seit) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, grund: 'keine-instanz' };
|
||
return crashreports.lies(store.gameDir(id), Number(seit) || 0);
|
||
});
|
||
|
||
// ---- Absturz-Assistent ----
|
||
// Wertet Absturzbericht, latest.log und Konsolenausgabe des letzten Starts aus
|
||
// (siehe crashhelp.js) und liefert verständliche Funde mit Lösungs-Aktion.
|
||
function absturzDaten(id, seit) {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return null;
|
||
const gameDir = store.gameDir(id);
|
||
const seitMs = Number(seit) || 0;
|
||
const bericht = crashreports.lies(gameDir, seitMs);
|
||
|
||
// latest.log nur, wenn es zu diesem Start gehört (sonst ist es vom letzten Mal)
|
||
let log = '';
|
||
try {
|
||
const logDatei = path.join(gameDir, 'logs', 'latest.log');
|
||
if (fs.existsSync(logDatei) && (!seitMs || fs.statSync(logDatei).mtimeMs >= seitMs)) {
|
||
const r = crashreports.liesLog(gameDir, 'latest.log', 4000);
|
||
if (r.ok) log = r.text;
|
||
}
|
||
} catch { /* egal */ }
|
||
|
||
const settings = store.getSettings();
|
||
const text = [bericht.ok ? bericht.text : '', log, letzteAusgabe.get(id) || ''].join('\n');
|
||
const funde = crashhelp.analysiere(text, {
|
||
loader: inst.minecraft.loader,
|
||
version: inst.minecraft.version,
|
||
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
|
||
gesamtMb: gesamtSpeicherMb(),
|
||
mods: store.listMods(id, 'mod'),
|
||
});
|
||
return { inst, funde, bericht: bericht.ok ? bericht : null, log, ausgabe: letzteAusgabe.get(id) || '' };
|
||
}
|
||
|
||
ipcMain.handle('crash:analyze', (_e, id, seit) => {
|
||
const daten = absturzDaten(id, seit);
|
||
if (!daten) return { ok: false };
|
||
return { ok: true, funde: daten.funde, bericht: daten.bericht };
|
||
});
|
||
|
||
// ---- Fehlerbericht an M_Viper (nur auf ausdrücklichen Wunsch) ----
|
||
// Vor dem Senden werden persönliche Angaben entfernt: Windows-Benutzerordner in
|
||
// Pfaden, Zugangs-Tokens und – wenn anonym – Minecraft-Name und UUID.
|
||
function regexSicher(t) { return String(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||
|
||
function bereinigeBericht(text, konto) {
|
||
let t = String(text || '');
|
||
const heim = os.homedir();
|
||
for (const variante of [heim, heim.replace(/\\/g, '/'), heim.replace(/\\/g, '\\\\')]) {
|
||
t = t.replace(new RegExp(regexSicher(variante), 'gi'), '%USERPROFILE%');
|
||
}
|
||
const benutzer = os.userInfo().username;
|
||
if (benutzer && benutzer.length > 2) t = t.replace(new RegExp('(\\\\|/)' + regexSicher(benutzer) + '(\\\\|/)', 'gi'), '$1%USERNAME%$2');
|
||
// Zugangsdaten nie mitschicken
|
||
t = t.replace(/eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, '***');
|
||
t = t.replace(/((?:accessToken|auth_access_token|auth_session|session)["'\s:=]+(?:token:)?)[^\s"',]+/gi, '$1***');
|
||
if (konto) {
|
||
if (konto.name) t = t.replace(new RegExp(regexSicher(konto.name), 'g'), '<Spieler>');
|
||
if (konto.uuid) {
|
||
const ohne = String(konto.uuid).replace(/-/g, '');
|
||
const mit = ohne.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5');
|
||
t = t.replace(new RegExp(regexSicher(ohne) + '|' + regexSicher(mit), 'gi'), '<UUID>');
|
||
}
|
||
}
|
||
return t;
|
||
}
|
||
|
||
ipcMain.handle('crash:report', async (_e, id, seit, wahl) => {
|
||
const daten = absturzDaten(id, seit);
|
||
if (!daten) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const mitName = !!(wahl && wahl.mitName);
|
||
const aktiv = aktivesFreundeKonto();
|
||
// anonym: Name/UUID aus dem Text entfernen; mit Name: nur Pfade/Tokens bereinigen
|
||
const anonymKonto = mitName ? null : (aktiv ? { name: aktiv.username, uuid: aktiv.uuid } : null);
|
||
|
||
const ende = (text, zeilen) => String(text || '').split(/\r?\n/).slice(-zeilen).join('\n');
|
||
let bericht = '';
|
||
if (daten.bericht) bericht += '--- Absturzbericht ' + (daten.bericht.datei || '') + ' ---\n' + daten.bericht.text + '\n\n';
|
||
if (daten.log) bericht += '--- latest.log (Ende) ---\n' + ende(daten.log, daten.bericht ? 150 : 400) + '\n\n';
|
||
if (daten.ausgabe) bericht += '--- Konsolenausgabe (Ende) ---\n' + ende(daten.ausgabe, 300) + '\n';
|
||
bericht = bereinigeBericht(bericht, anonymKonto).slice(-190000);
|
||
|
||
const inst = daten.inst;
|
||
const nutzlast = {
|
||
launcherVersion: app.getVersion(),
|
||
mcVersion: inst.minecraft.version || '',
|
||
loader: inst.minecraft.loader || 'vanilla',
|
||
loaderVersion: inst.minecraft.loaderVersion || '',
|
||
system: `Windows ${os.release()} · ${Math.round(os.totalmem() / 1073741824)} GB RAM · Instanz-RAM ${inst.java.maxMemMb || store.getSettings().defaultMaxMemMb || '?'} MB`,
|
||
funde: daten.funde.map((f) => ({ titel: f.titel, text: f.text })),
|
||
mods: store.listMods(id, 'mod').map((m) => ({
|
||
datei: m.filename, name: m.title || '', version: m.versionNumber || '', aktiv: m.enabled !== false,
|
||
})),
|
||
bericht,
|
||
kommentar: bereinigeBericht(String((wahl && wahl.kommentar) || '').slice(0, 2000), anonymKonto),
|
||
};
|
||
if (mitName && aktiv) { nutzlast.uuid = aktiv.uuid; nutzlast.username = aktiv.username; }
|
||
const res = await friends.sendReport(nutzlast);
|
||
return res.ok ? { ok: true, id: res.data && res.data.id } : res;
|
||
});
|
||
|
||
// Lösungs-Aktion eines Fundes ausführen
|
||
ipcMain.handle('crash:fix', async (_e, id, aktion) => {
|
||
const inst = store.getInstance(id);
|
||
const a = aktion || {};
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
try {
|
||
switch (a.typ) {
|
||
case 'mod-installieren': {
|
||
// Mod-IDs und Modrinth-Namen weichen manchmal ab -> ein paar Schreibweisen probieren
|
||
const kandidaten = [...new Set([a.projekt, crashhelp.projektFuer(a.name),
|
||
String(a.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-')].filter((k) => k && /^[\w-]{1,64}$/.test(k)))];
|
||
let letzter = null;
|
||
for (const projekt of kandidaten) {
|
||
const res = await installiereInhalt(id, { projectId: projekt, title: a.name || projekt }, 'mod').catch((err) => ({ ok: false, message: err.message }));
|
||
if (res.ok) return { ok: true, text: `„${a.name || projekt}“ installiert.` };
|
||
letzter = res.message;
|
||
}
|
||
return { ok: false, message: `„${a.name}“ wurde auf Modrinth nicht gefunden${letzter ? ` (${letzter})` : ''}. Bitte im Inhalts-Dialog von Hand suchen.` };
|
||
}
|
||
case 'mod-deaktivieren': {
|
||
const res = store.setModEnabled(id, String(a.dateiname || ''), false, 'mod');
|
||
return res.ok ? { ok: true, text: `„${a.name || a.dateiname}“ abgeschaltet.` } : { ok: false, message: 'Konnte den Mod nicht abschalten.' };
|
||
}
|
||
case 'ram': {
|
||
const mb = Math.round(Number(a.mb) || 0);
|
||
if (mb < 1024) return { ok: false, message: 'Ungültige Speichergröße.' };
|
||
const minMb = inst.java.minMemMb && inst.java.minMemMb > mb ? Math.min(mb, 1024) : inst.java.minMemMb;
|
||
store.updateInstance(id, { java: { maxMemMb: mb, minMemMb: minMb || null } });
|
||
broadcast('instances:changed');
|
||
return { ok: true, text: `Arbeitsspeicher auf ${Math.round(mb / 102.4) / 10} GB gestellt.` };
|
||
}
|
||
case 'java-auto': {
|
||
store.updateInstance(id, { java: { path: '' } });
|
||
store.saveSettings({ autoJava: true });
|
||
broadcast('instances:changed');
|
||
return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' };
|
||
}
|
||
case 'mods-ordner': {
|
||
const dir = store.inhaltsDir(id, 'mod');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
shell.openPath(dir);
|
||
return { ok: true, text: 'Mods-Ordner geöffnet.' };
|
||
}
|
||
default:
|
||
return { ok: false, message: 'Unbekannte Aktion.' };
|
||
}
|
||
} catch (err) {
|
||
return { ok: false, message: err.message };
|
||
}
|
||
});
|
||
|
||
// nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster)
|
||
ipcMain.handle('crash:relaunch', (_e, id) => {
|
||
if (!store.getInstance(id)) return false;
|
||
showMainWindow();
|
||
starteInstanzImFenster(id);
|
||
return true;
|
||
});
|
||
|
||
// ---- Serverliste (servers.dat) ----
|
||
|
||
ipcMain.handle('servers:list', (_e, id) => {
|
||
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
return servers.lies(store.gameDir(id));
|
||
});
|
||
|
||
ipcMain.handle('servers:add', (_e, id, eintrag) => {
|
||
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
return servers.fuegeHinzu(store.gameDir(id), eintrag || {});
|
||
});
|
||
|
||
ipcMain.handle('servers:remove', (_e, id, ip) => {
|
||
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
return servers.entferne(store.gameDir(id), ip);
|
||
});
|
||
|
||
ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
|
||
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
return servers.benenneUm(store.gameDir(id), ip, neuerName);
|
||
});
|
||
|
||
// Live-Status eines Servers abfragen (MOTD/Spielerzahl/Version) – wie der
|
||
// Vanilla-Mehrspieler-Bildschirm, ohne sich wirklich zu verbinden
|
||
ipcMain.handle('servers:ping', async (_e, ip) => {
|
||
const s = String(ip || '').trim();
|
||
const m = s.match(/^([^:]+)(?::(\d+))?$/);
|
||
if (!m) return { ok: false, message: 'Ungültige Adresse.' };
|
||
const host = m[1];
|
||
const port = m[2] ? Number(m[2]) : 25565;
|
||
try { return await mcping.pingServer(host, port); }
|
||
catch (err) { return { ok: false, message: err.message }; }
|
||
});
|
||
|
||
// denselben Server in weitere Instanzen übertragen
|
||
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
|
||
const erledigt = [];
|
||
const fehler = [];
|
||
for (const zielId of zielIds || []) {
|
||
const inst = store.getInstance(zielId);
|
||
if (!inst) { fehler.push({ id: zielId, message: 'Instanz nicht gefunden.' }); continue; }
|
||
const res = servers.uebertrage(store.gameDir(zielId), eintrag || {});
|
||
if (res.ok) erledigt.push(inst.name);
|
||
else fehler.push({ name: inst.name, message: res.message });
|
||
}
|
||
return { ok: true, erledigt, fehler };
|
||
});
|
||
|
||
// Logdateien der Instanz – auch nach einem Neustart des Launchers
|
||
ipcMain.handle('logs:list', (_e, id) => {
|
||
if (!store.getInstance(id)) return [];
|
||
return crashreports.listeLogs(store.gameDir(id));
|
||
});
|
||
|
||
ipcMain.handle('logs:read', (_e, id, name) => {
|
||
if (!store.getInstance(id)) return { ok: false, grund: 'keine-instanz' };
|
||
return crashreports.liesLog(store.gameDir(id), name);
|
||
});
|
||
|
||
ipcMain.handle('logs:openFolder', (_e, id) => {
|
||
if (!store.getInstance(id)) return false;
|
||
const dir = crashreports.logOrdner(store.gameDir(id));
|
||
if (!fs.existsSync(dir)) return false;
|
||
shell.openPath(dir);
|
||
return true;
|
||
});
|
||
|
||
// Ordner mit den Absturzberichten öffnen
|
||
ipcMain.handle('crash:openFolder', (_e, id) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return false;
|
||
const dir = crashreports.berichtsOrdner(store.gameDir(id));
|
||
if (!fs.existsSync(dir)) return false;
|
||
shell.openPath(dir);
|
||
return true;
|
||
});
|
||
|
||
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
|
||
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
|
||
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
|
||
if (res.ok) return res;
|
||
const ordner = services.inhaltsArt(art || 'mod').ordner;
|
||
const gruende = {
|
||
'not-found': `Die Datei liegt nicht mehr im ${ordner}-Ordner.`,
|
||
'both-exist': `Es liegen beide Fassungen im ${ordner}-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.`,
|
||
'bad-name': 'Ungültiger Dateiname.',
|
||
};
|
||
return { ok: false, message: gruende[res.reason] || res.reason };
|
||
});
|
||
|
||
// sucht für die installierten Mods nach neueren Dateien (lädt noch nichts)
|
||
ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const gameVersion = inst.minecraft.version;
|
||
const loader = inst.minecraft.loader;
|
||
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
|
||
try {
|
||
const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => {
|
||
broadcast('mods:updateProgress', p);
|
||
}, art || 'mod');
|
||
return { ok: true, ...res };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
|
||
ipcMain.handle('mods:applyUpdates', (_e, id, projectIds, art) => wendeModUpdatesAn(id, projectIds, art));
|
||
|
||
async function wendeModUpdatesAn(id, projectIds, art) {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const gameVersion = inst.minecraft.version;
|
||
const loader = inst.minecraft.loader;
|
||
const sorte = art || 'mod';
|
||
const modsDir = store.inhaltsDir(id, sorte);
|
||
const gewuenscht = new Set(projectIds || []);
|
||
const erneuert = [];
|
||
const fehler = [];
|
||
|
||
for (const mod of store.listMods(id, sorte)) {
|
||
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
|
||
try {
|
||
const res = await services.modrinthInstall({
|
||
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, art: sorte,
|
||
});
|
||
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
|
||
store.addMod(id, res.entry, sorte);
|
||
// alte Datei erst weg, wenn die neue liegt – und nie, wenn sie gleich heißt
|
||
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename, sorte);
|
||
// war der Eintrag abgeschaltet, bleibt er es auch nach dem Update
|
||
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false, sorte);
|
||
erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber });
|
||
} catch (err) {
|
||
fehler.push({ title: mod.title, message: err.message });
|
||
}
|
||
}
|
||
return { ok: true, erneuert, fehler };
|
||
}
|
||
|
||
/*
|
||
* Automatische Mod-Updates vor dem Start (Einstellung autoModUpdates): sucht
|
||
* für die über Modrinth installierten Mods nach neueren Fassungen und spielt
|
||
* sie ein. Begrenzt auf eine Höchstdauer – ein langsamer Server soll den
|
||
* Spielstart nicht minutenlang aufhalten; dann wird eben ohne Update gestartet.
|
||
*/
|
||
async function modUpdatesVorDemStart(id) {
|
||
const inst = store.getInstance(id);
|
||
if (!inst || !inst.minecraft.version || !inst.minecraft.loader || inst.minecraft.loader === 'vanilla') return null;
|
||
const mods = store.listMods(id, 'mod');
|
||
if (!mods.some((m) => m.projectId)) return null;
|
||
broadcast('launch:progress', { id, phase: 'mod-updates', detail: 'Suche nach Mod-Updates …' });
|
||
const suche = services.modrinthFindUpdates(mods, inst.minecraft.version, inst.minecraft.loader, null, 'mod');
|
||
const zeitlimit = new Promise((resolve) => setTimeout(() => resolve(null), 20000));
|
||
const gefunden = await Promise.race([suche, zeitlimit]).catch(() => null);
|
||
if (!gefunden || !gefunden.treffer || !gefunden.treffer.length) return null;
|
||
broadcast('launch:progress', { id, phase: 'mod-updates', detail: `${gefunden.treffer.length} Mod-Update(s) werden installiert …` });
|
||
const res = await wendeModUpdatesAn(id, gefunden.treffer.map((t) => t.projectId), 'mod');
|
||
broadcast('mods:changed', { id });
|
||
return res;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Microsoft-Login (Phase 3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('auth:status', () => auth.currentAccount());
|
||
ipcMain.handle('auth:health', () => authHealth);
|
||
ipcMain.handle('auth:recheck', () => checkAuthServices());
|
||
// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal
|
||
ipcMain.handle('auth:accounts', () => auth.listAccounts()
|
||
.map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) })));
|
||
ipcMain.handle('auth:setActive', (_e, id) => {
|
||
const res = auth.setActive(id);
|
||
// Ohne das hier bleibt z. B. ein separates Konten- oder Aufzeichnungs-
|
||
// Fenster (eigener Renderer-Prozess, siehe cross-window state) auf dem
|
||
// alten Konto stehen, bis irgendein anderes Ereignis zufällig ein Neuladen
|
||
// auslöst - der Listener auf 'accounts:changed' existiert im Renderer
|
||
// schon, wurde von hier nur nie ausgelöst.
|
||
broadcast('accounts:changed');
|
||
// Anfragen-Zahl am "Freunde"-Knopf gilt für das aktive Konto
|
||
ungeleseneNachrichten = [];
|
||
aktualisiereAnfragenZahl().catch(() => {});
|
||
pruefeNachrichten().catch(() => {});
|
||
return res;
|
||
});
|
||
|
||
// ---- 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;
|
||
}
|
||
|
||
// Armform (breit/schmal) eines beliebigen Kontos – steht in den Textur-Angaben
|
||
// des öffentlichen Profils bei Mojang (SKIN.metadata.model = "slim").
|
||
// Ergebnis 10 Minuten merken, nach eigenen Skin-Änderungen sofort verwerfen.
|
||
const armformCache = new Map(); // uuid -> { modell, zeit }
|
||
ipcMain.handle('skin:modelFor', async (_e, uuid) => {
|
||
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
|
||
if (!/^[0-9a-f]{32}$/.test(id)) return 'classic';
|
||
const gemerkt = armformCache.get(id);
|
||
if (gemerkt && Date.now() - gemerkt.zeit < 10 * 60 * 1000) return gemerkt.modell;
|
||
try {
|
||
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||
if (!res.ok) return gemerkt ? gemerkt.modell : 'classic';
|
||
const profil = await res.json();
|
||
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
|
||
const texturen = eigenschaft ? JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')) : {};
|
||
const skin = texturen.textures && texturen.textures.SKIN;
|
||
const modell = skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic';
|
||
armformCache.set(id, { modell, zeit: Date.now() });
|
||
return modell;
|
||
} catch {
|
||
return gemerkt ? gemerkt.modell : 'classic';
|
||
}
|
||
});
|
||
|
||
// Zeitpunkt der letzten eigenen Skin-Änderung: danach hinkt der Sitzungsserver
|
||
// (= was im Spiel zu sehen ist) noch kurz hinterher
|
||
let letzterSkinWechsel = 0;
|
||
const SKIN_NACHLAUF_MS = 3 * 60 * 1000;
|
||
|
||
// Kontoprofil + der Skin, den andere im Spiel sehen (siehe skins.waehleAnzeigeSkin)
|
||
async function profilMitSpielSkin(token) {
|
||
const p = await skins.holeProfil(token);
|
||
if (!p.ok) return p;
|
||
const kuerzlich = Date.now() - letzterSkinWechsel < SKIN_NACHLAUF_MS;
|
||
let spiel = null;
|
||
if (!kuerzlich) { try { spiel = await skins.holeSpielTexturen(p.id); } catch { /* dann eben das Kontoprofil */ } }
|
||
return skins.waehleAnzeigeSkin(p, spiel, kuerzlich);
|
||
}
|
||
|
||
ipcMain.handle('skin:profile', async () => {
|
||
const token = await aktivesToken();
|
||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
try { return await profilMitSpielSkin(token); }
|
||
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
|
||
});
|
||
|
||
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'] }],
|
||
});
|
||
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 {
|
||
const res = await skins.setzeSkin(token, dateiPfad, variante);
|
||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); 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 {
|
||
const res = await skins.setzeSkinZurueck(token);
|
||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
|
||
return res;
|
||
}
|
||
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
|
||
});
|
||
|
||
// ---------- Skin-Editor ----------
|
||
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
|
||
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
|
||
// der Hauptprozess die Datei und reicht sie als data:-Adresse weiter.
|
||
const PNG_KENNUNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||
function skinPngAusBase64(base64) {
|
||
const daten = Buffer.from(String(base64 || ''), 'base64');
|
||
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
|
||
if (daten.readUInt32BE(16) !== 64 || daten.readUInt32BE(20) !== 64) return null;
|
||
return daten;
|
||
}
|
||
|
||
ipcMain.handle('skin:currentData', async () => {
|
||
const token = await aktivesToken();
|
||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
try {
|
||
const p = await profilMitSpielSkin(token);
|
||
if (!p.ok) return p;
|
||
if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' };
|
||
const res = await fetch(p.skinUrl);
|
||
if (!res.ok) return { ok: false, message: 'Skin-Datei nicht abrufbar (HTTP ' + res.status + ').' };
|
||
const daten = Buffer.from(await res.arrayBuffer());
|
||
return { ok: true, name: p.name, variante: p.variante, dataUri: 'data:image/png;base64,' + daten.toString('base64') };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Skin konnte nicht geladen werden: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// ---------- Umhänge (offiziell von Mojang + eigene AeroMC-Umhänge) ----------
|
||
// Bilder immer als data:-Adresse in die Oberfläche (fremde Server schicken
|
||
// keine CORS-Kopfzeilen – die 3D-Ansicht dürfte sie sonst nicht laden)
|
||
async function bildAlsDataUri(url) {
|
||
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const daten = Buffer.from(await res.arrayBuffer());
|
||
if (daten.length > 6 * 1024 * 1024) throw new Error('Bild zu groß');
|
||
return 'data:image/png;base64,' + daten.toString('base64');
|
||
}
|
||
|
||
// PNG mit Seitenverhältnis 2:1 (64×32 … 1024×512) – wie der Server es verlangt
|
||
function umhangPngAusBase64(base64) {
|
||
const daten = Buffer.from(String(base64 || ''), 'base64');
|
||
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
|
||
const breite = daten.readUInt32BE(16);
|
||
const hoehe = daten.readUInt32BE(20);
|
||
if (![64, 128, 256, 512, 1024].includes(breite) || breite !== 2 * hoehe) return null;
|
||
return daten;
|
||
}
|
||
|
||
// Umhang eines beliebigen Spielers fürs Anzeigen: eigener AeroMC-Umhang hat
|
||
// Vorrang, sonst der offizielle aus Mojangs Profil. 5 Minuten gemerkt.
|
||
const umhangCache = new Map(); // uuid -> { dataUri, quelle, zeit }
|
||
async function umhangFuer(uuid) {
|
||
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
|
||
if (!/^[0-9a-f]{32}$/.test(id)) return null;
|
||
const gemerkt = umhangCache.get(id);
|
||
if (gemerkt && Date.now() - gemerkt.zeit < 5 * 60 * 1000) return gemerkt;
|
||
let ergebnis = { dataUri: null, quelle: null, zeit: Date.now() };
|
||
try {
|
||
const aero = await friends.getCapes([id]);
|
||
const eintrag = aero.ok && aero.capes && aero.capes[id];
|
||
if (eintrag && eintrag.url) ergebnis = { dataUri: await bildAlsDataUri(eintrag.url), quelle: 'aeromc', zeit: Date.now() };
|
||
} catch { /* weiter mit Mojang */ }
|
||
if (!ergebnis.dataUri) {
|
||
try {
|
||
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||
if (res.ok) {
|
||
const profil = await res.json();
|
||
const eig = (profil.properties || []).find((p) => p.name === 'textures');
|
||
const tex = eig ? JSON.parse(Buffer.from(eig.value, 'base64').toString('utf8')) : {};
|
||
const cape = tex.textures && tex.textures.CAPE;
|
||
if (cape && cape.url) ergebnis = { dataUri: await bildAlsDataUri(skins.aufHttps(cape.url)), quelle: 'mojang', zeit: Date.now() };
|
||
}
|
||
} catch { /* kein Umhang */ }
|
||
}
|
||
umhangCache.set(id, ergebnis);
|
||
return ergebnis;
|
||
}
|
||
ipcMain.handle('cape:forUuid', async (_e, uuid) => {
|
||
const r = await umhangFuer(uuid);
|
||
return r && r.dataUri ? { dataUri: r.dataUri, quelle: r.quelle } : null;
|
||
});
|
||
|
||
// Übersicht fürs Skin-Fenster: offizielle Umhänge + eigener AeroMC-Umhang
|
||
ipcMain.handle('cape:info', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
const token = await aktivesToken();
|
||
if (!konto || !token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
const ergebnis = { ok: true, name: konto.username, offiziell: [], aeromc: null, aeromcFehler: '' };
|
||
try {
|
||
const p = await skins.holeProfil(token);
|
||
if (p.ok) ergebnis.offiziell = p.capes || [];
|
||
} catch { /* ohne offizielle Liste weiter */ }
|
||
const aero = await friends.getCapes([konto.uuid]);
|
||
if (aero.ok) ergebnis.aeromc = aero.capes[String(konto.uuid).replace(/-/g, '').toLowerCase()] || null;
|
||
else { ergebnis.aeromcFehler = aero.message; ergebnis.aeromcFehlt = !!aero.fehlt; }
|
||
return ergebnis;
|
||
});
|
||
|
||
ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
|
||
const token = await aktivesToken();
|
||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
try {
|
||
const res = await skins.setzeUmhang(token, capeId || null);
|
||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||
return res;
|
||
} catch (err) { return { ok: false, message: 'Umhang konnte nicht gesetzt werden: ' + err.message }; }
|
||
});
|
||
|
||
// Der Umhang-Agent muss als echte Datei vorliegen (Java liest nicht aus app.asar):
|
||
// einmal nach userData kopieren, Dateiname mit Größe, damit ein Update ihn ersetzt.
|
||
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
|
||
function umhangAgent() {
|
||
try {
|
||
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
|
||
const groesse = fs.statSync(quelle).size;
|
||
const ordner = path.join(app.getPath('userData'), 'agent');
|
||
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
|
||
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
|
||
fs.mkdirSync(ordner, { recursive: true });
|
||
fs.writeFileSync(ziel, fs.readFileSync(quelle));
|
||
for (const alt of fs.readdirSync(ordner)) {
|
||
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
|
||
}
|
||
}
|
||
return `${ziel}=${friends.BASIS_URL}`;
|
||
} catch (err) {
|
||
console.error('Umhang-Agent nicht verfügbar:', err.message);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
|
||
function umhangFehler(res) {
|
||
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
|
||
return res;
|
||
}
|
||
|
||
ipcMain.handle('cape:upload', async (_e, base64) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
|
||
const res = await friends.uploadCape(konto.uuid, base64);
|
||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
|
||
});
|
||
|
||
ipcMain.handle('cape:remove', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
const res = await friends.removeCape(konto.uuid);
|
||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||
return res.ok ? res : umhangFehler(res);
|
||
});
|
||
|
||
// für den Editor: eigener Umhang, sonst der getragene offizielle
|
||
ipcMain.handle('cape:currentData', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
umhangCache.delete(String(konto.uuid).replace(/-/g, '').toLowerCase());
|
||
const r = await umhangFuer(konto.uuid);
|
||
if (!r || !r.dataUri) return { ok: false, keiner: true, message: 'Dein Konto hat noch keinen Umhang.' };
|
||
return { ok: true, dataUri: r.dataUri, quelle: r.quelle };
|
||
});
|
||
|
||
// Bild wählen: art 'umhang' = fertige Umhang-Datei (PNG), 'bild' = beliebiges Motiv zum Einsetzen
|
||
ipcMain.handle('cape:chooseFile', async (e, art) => {
|
||
const motiv = art === 'bild';
|
||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: motiv ? 'Bild für den Umhang wählen' : 'Umhang-Datei öffnen',
|
||
properties: ['openFile'],
|
||
filters: motiv
|
||
? [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }]
|
||
: [{ name: 'PNG-Bild', extensions: ['png'] }],
|
||
});
|
||
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
|
||
const pfad = res.filePaths[0];
|
||
try {
|
||
const st = fs.statSync(pfad);
|
||
if (st.size > 15 * 1024 * 1024) return { ok: false, message: 'Die Datei ist zu groß (über 15 MB).' };
|
||
const endung = path.extname(pfad).slice(1).toLowerCase().replace('jpg', 'jpeg');
|
||
return { ok: true, pfad, dataUri: `data:image/${endung || 'png'};base64,` + fs.readFileSync(pfad).toString('base64') };
|
||
} catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('cape:saveData', async (e, base64) => {
|
||
const daten = umhangPngAusBase64(base64);
|
||
if (!daten) return { ok: false, message: 'Ungültige Umhang-Daten.' };
|
||
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'Umhang speichern',
|
||
defaultPath: path.join(app.getPath('pictures'), 'umhang.png'),
|
||
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
|
||
});
|
||
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
|
||
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
|
||
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('skin:readData', (_e, dateiPfad) => {
|
||
const geprueft = skins.pruefeDatei(dateiPfad);
|
||
if (!geprueft.ok) return geprueft;
|
||
try { return { ok: true, dataUri: 'data:image/png;base64,' + fs.readFileSync(dateiPfad).toString('base64') }; }
|
||
catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('skin:saveData', async (e, base64) => {
|
||
const daten = skinPngAusBase64(base64);
|
||
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
|
||
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'Skin speichern',
|
||
defaultPath: path.join(app.getPath('pictures'), 'skin.png'),
|
||
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
|
||
});
|
||
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
|
||
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
|
||
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
|
||
const daten = skinPngAusBase64(base64);
|
||
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
|
||
const token = await aktivesToken();
|
||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
|
||
try {
|
||
fs.writeFileSync(tmp, daten);
|
||
const res = await skins.setzeSkin(token, tmp, variante);
|
||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
|
||
return res;
|
||
} catch (err) {
|
||
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
|
||
} finally {
|
||
try { fs.unlinkSync(tmp); } catch { /* egal */ }
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
|
||
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
|
||
|
||
// ---------- Freundesystem ----------
|
||
// Nutzt immer das aktive Konto (gleiche Quelle wie discordpresence/die
|
||
// "spielt gerade"-Anzeige: auth.currentAccount().profile.{id,name}) - keine
|
||
// eigene Anmeldung nötig.
|
||
|
||
// Findet DAS Minecraft-Fenster des angegebenen Prozesses (pid) - wichtig,
|
||
// damit der Ingame-Toast auf dem Bildschirm des prüfenden Kontos erscheint,
|
||
// nicht zufällig auf dem eines anderen laufenden Kontos (bei zwei Konten
|
||
// über denselben Launcher gäbe es sonst z. B. eine Meldung über M_Viper
|
||
// selbst, angezeigt auf M_Vipers eigenem Fenster).
|
||
//
|
||
// node-window-manager erkennt ein gerade laufendes Fenster manchmal nicht
|
||
// beim allerersten Abfragen (z. B. Spiel lädt noch, kurz minimiert) - deshalb
|
||
// mehrere Versuche mit kurzer Pause. Wird das Fenster DIESER PID trotzdem
|
||
// nicht gefunden, wird bewusst auf kein anderes Fenster ausgewichen - lieber
|
||
// keine Meldung als eine beim falschen Spieler.
|
||
async function findeMinecraftFenster(pid) {
|
||
if (!pid) return null;
|
||
let windowManager;
|
||
try { ({ windowManager } = require('node-window-manager')); } catch { return null; }
|
||
for (let versuch = 1; versuch <= 4; versuch++) {
|
||
let alleFenster;
|
||
try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; }
|
||
const treffer = alleFenster.find((w) => w.processId === pid);
|
||
if (treffer) return treffer;
|
||
if (versuch < 4) await new Promise((r) => setTimeout(r, 300));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// pid -> Anzahl gerade sichtbarer Toasts auf diesem Fenster (zum Stapeln,
|
||
// falls mehrere Freunde im selben Durchlauf gemeldet werden)
|
||
const toastsProFenster = new Map();
|
||
|
||
// 3D-Spielerkopf (isometrischer Würfel, gleiche Quelle wie im Freunde-Dialog)
|
||
// als data:-URI - vorab im Hauptprozess geladen und eingebettet, damit der
|
||
// Kopf schon beim Erscheinen des Toasts da ist statt nachzuladen. Pro uuid
|
||
// zwischengespeichert; bei Fehler/Zeitüberschreitung einfach ohne Kopf.
|
||
const spielerKopfCache = new Map();
|
||
async function spielerKopf(uuid) {
|
||
if (!uuid) return null;
|
||
if (spielerKopfCache.has(uuid)) return spielerKopfCache.get(uuid);
|
||
const abbruch = new AbortController();
|
||
const zeitgeber = setTimeout(() => abbruch.abort(), 3000);
|
||
try {
|
||
const res = await fetch(`https://crafthead.net/cube/${encodeURIComponent(uuid)}/64`, { signal: abbruch.signal });
|
||
if (!res.ok) return null;
|
||
const uri = 'data:image/png;base64,' + Buffer.from(await res.arrayBuffer()).toString('base64');
|
||
spielerKopfCache.set(uuid, uri);
|
||
return uri;
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
clearTimeout(zeitgeber);
|
||
}
|
||
}
|
||
|
||
// Kleiner, durchsichtiger Toast oben rechts auf dem Minecraft-Fenster (wie
|
||
// Discords Ingame-Overlay), bewusst OHNE Windows-Meldung als Rückfall -
|
||
// stiehlt nie den Fokus (showInactive + focusable:false), damit das Spiel
|
||
// nicht gestört wird. Jeder Toast ist ein eigenes Fenster, damit zwei
|
||
// gleichzeitig laufende Konten jeweils ihre eigene Meldung bekommen.
|
||
// Liefert true, wenn der Toast angezeigt wurde. `text` ist eine Zeile oder
|
||
// eine Liste von Zeilen; eine Zeile ist entweder Text oder { uuid, text } -
|
||
// dann steht der 3D-Spielerkopf davor.
|
||
async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
|
||
const fenster = await findeMinecraftFenster(pid);
|
||
if (!fenster) return false;
|
||
const bounds = fenster.getBounds();
|
||
|
||
const zeilen = await Promise.all((Array.isArray(text) ? text : [text]).map(async (z) => {
|
||
if (typeof z === 'string') return { text: z, kopf: null };
|
||
return { text: z.text, kopf: await spielerKopf(z.uuid), umbruch: !!z.umbruch };
|
||
}));
|
||
const breite = 320;
|
||
// Zeilen mit umbruch (z. B. Nachrichten) dürfen mehrzeilig werden – Höhe grob schätzen
|
||
const zeilenHoehe = (z) => (z.umbruch ? Math.max(z.kopf ? 24 : 18, Math.ceil(String(z.text).length / 38) * 17) : (z.kopf ? 24 : 18));
|
||
const hoehe = 44 + zeilen.reduce((summe, z) => summe + zeilenHoehe(z), 0);
|
||
const stapel = toastsProFenster.get(pid) || 0;
|
||
toastsProFenster.set(pid, stapel + 1);
|
||
const overlayFenster = new BrowserWindow({
|
||
x: Math.round(bounds.x + bounds.width - breite - 24),
|
||
y: Math.round(bounds.y + 24 + stapel * (hoehe + 8)),
|
||
width: breite,
|
||
height: hoehe,
|
||
frame: false,
|
||
transparent: true,
|
||
alwaysOnTop: true,
|
||
skipTaskbar: true,
|
||
focusable: false,
|
||
resizable: false,
|
||
hasShadow: false,
|
||
show: false,
|
||
webPreferences: { contextIsolation: true, sandbox: true },
|
||
});
|
||
overlayFenster.setIgnoreMouseEvents(true);
|
||
overlayFenster.setAlwaysOnTop(true, 'screen-saver');
|
||
|
||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
// charset=utf-8 nicht vergessen: ohne das interpretiert Chromium die
|
||
// data:-URL mit einer anderen Kodierung, wodurch z. B. "·" als "·"
|
||
// dargestellt wird (klassisches UTF-8/Latin-1-Mojibake)
|
||
const html = `data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="margin:0;overflow:hidden;background:transparent;">
|
||
<div style="font-family:Segoe UI,sans-serif;background:rgba(20,20,25,0.88);color:#fff;border-radius:8px;
|
||
padding:10px 14px;box-shadow:0 4px 16px rgba(0,0,0,0.4);box-sizing:border-box;">
|
||
<div style="font-weight:600;font-size:13px;">${esc(titel)}</div>
|
||
${zeilen.map((z) => `<div style="display:flex;align-items:${z.umbruch ? 'flex-start' : 'center'};gap:7px;font-size:12px;margin-top:${z.kopf ? 4 : 2}px;">
|
||
${z.kopf ? `<img src="${z.kopf}" style="width:20px;height:20px;flex:0 0 auto;" />` : ''}
|
||
<span style="opacity:0.85;${z.umbruch ? 'white-space:normal;word-break:break-word;line-height:1.35;' : 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'}">${esc(z.text)}</span>
|
||
</div>`).join('')}
|
||
</div>
|
||
</body></html>`)}`;
|
||
overlayFenster.loadURL(html);
|
||
overlayFenster.once('ready-to-show', () => { if (!overlayFenster.isDestroyed()) overlayFenster.showInactive(); });
|
||
setTimeout(() => {
|
||
if (!overlayFenster.isDestroyed()) overlayFenster.destroy();
|
||
toastsProFenster.set(pid, Math.max(0, (toastsProFenster.get(pid) || 1) - 1));
|
||
}, dauerMs);
|
||
return true;
|
||
}
|
||
|
||
// ---------- Tastenkürzel: Online-Freunde ingame anzeigen ----------
|
||
// Nur registriert, solange über diesen Launcher ein Spiel läuft - ein globales
|
||
// Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme
|
||
// außerhalb des Spielens nicht stören.
|
||
const FREUNDE_TASTENKUERZEL = 'Control+Alt+F';
|
||
const EINLADUNG_TASTENKUERZEL = 'Control+Alt+J';
|
||
let freundeTastenkuerzelAktiv = false;
|
||
|
||
function aktualisiereFreundeTastenkuerzel() {
|
||
const soll = kontoPid.size > 0 && store.getSettings().friendsEnabled !== false;
|
||
if (soll && !freundeTastenkuerzelAktiv) {
|
||
freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => {
|
||
zeigeOnlineFreundeIngame().catch(() => {});
|
||
});
|
||
globalShortcut.register(EINLADUNG_TASTENKUERZEL, () => {
|
||
nimmEinladungIngameAn().catch(() => {});
|
||
});
|
||
} else if (!soll && freundeTastenkuerzelAktiv) {
|
||
globalShortcut.unregister(FREUNDE_TASTENKUERZEL);
|
||
globalShortcut.unregister(EINLADUNG_TASTENKUERZEL);
|
||
freundeTastenkuerzelAktiv = false;
|
||
}
|
||
}
|
||
|
||
// Konto + Fenster-PID des Spiels im Vordergrund (bei nur einem laufenden Spiel: dieses)
|
||
function vordergrundKonto() {
|
||
let aktivePid = null;
|
||
try { aktivePid = require('node-window-manager').windowManager.getActiveWindow().processId; } catch { /* egal */ }
|
||
let eintrag = [...kontoPid].find(([, pid]) => pid === aktivePid);
|
||
if (!eintrag && kontoPid.size === 1) eintrag = [...kontoPid][0];
|
||
return eintrag || null;
|
||
}
|
||
|
||
// Zeigt die gerade online befindlichen Freunde des Kontos, dessen
|
||
// Minecraft-Fenster gerade im Vordergrund ist, als Toast auf genau diesem
|
||
// Fenster. Läuft nur ein Spiel, wird dieses genommen, auch wenn es nicht
|
||
// im Vordergrund ist.
|
||
async function zeigeOnlineFreundeIngame() {
|
||
const eintrag = vordergrundKonto();
|
||
if (!eintrag) return;
|
||
const [kontoUuid, pid] = eintrag;
|
||
|
||
const res = await friends.getFriends(kontoUuid);
|
||
if (!res.ok) { await zeigeIngameOverlay('Freunde', 'Freundesystem nicht erreichbar', pid, 4000); return; }
|
||
const spitznamen = store.getSettings().friendNicknames || {};
|
||
const online = (res.data || []).filter((f) => f.online);
|
||
const MAX_ZEILEN = 10;
|
||
const zeilen = online.slice(0, MAX_ZEILEN).map((f) => ({
|
||
uuid: f.uuid,
|
||
text: (spitznamen[f.uuid] || f.username) + (f.server ? ` · ${f.server}` : ''),
|
||
}));
|
||
if (online.length > MAX_ZEILEN) zeilen.push(`… und ${online.length - MAX_ZEILEN} weitere`);
|
||
await zeigeIngameOverlay(
|
||
online.length ? `Freunde online (${online.length})` : 'Freunde online',
|
||
zeilen.length ? zeilen : ['Gerade ist niemand online'],
|
||
pid,
|
||
8000,
|
||
);
|
||
}
|
||
|
||
// ---------- Einladungen: "Komm auf meinen Server" ----------
|
||
// Eine Einladung enthält Server-Adresse und Minecraft-Version des Einladenden.
|
||
// Im Launcher: Hinweis + "Beitreten" im Freunde-Fenster (startet eine passende
|
||
// Instanz per Quick Play). Im Spiel: Toast auf dem richtigen Fenster; ein
|
||
// laufendes Minecraft lässt sich von außen nicht auf einen anderen Server
|
||
// umlenken – Strg+Alt+J kopiert deshalb die Adresse für "Direkt verbinden".
|
||
const gezeigteEinladungen = new Set(); // Einladungs-IDs, die schon gemeldet wurden
|
||
const letzteEinladung = new Map(); // kontoUuid -> zuletzt erhaltene Einladung (für Strg+Alt+J)
|
||
|
||
async function pruefeEinladungen() {
|
||
if (store.getSettings().friendsEnabled === false) return;
|
||
const aktiv = aktivesFreundeKonto();
|
||
const konten = new Set([...kontoPid.keys()]);
|
||
if (aktiv) konten.add(aktiv.uuid);
|
||
for (const kontoUuid of konten) {
|
||
const res = await friends.getInvites(kontoUuid);
|
||
if (!res.ok) continue;
|
||
for (const einl of res.data || []) {
|
||
if (gezeigteEinladungen.has(einl.id)) continue;
|
||
gezeigteEinladungen.add(einl.id);
|
||
letzteEinladung.set(kontoUuid, einl);
|
||
const pid = kontoPid.get(kontoUuid);
|
||
if (pid) {
|
||
await zeigeIngameOverlay('Einladung', [
|
||
{ uuid: einl.fromUuid, text: `${einl.fromUsername || 'Ein Freund'} lädt dich ein · ${einl.server}` },
|
||
'Strg+Alt+J: Adresse kopieren',
|
||
], pid, 10000);
|
||
}
|
||
if (aktiv && aktiv.uuid === kontoUuid) broadcast('friends:invite', einl);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Strg+Alt+J im Spiel: Adresse der letzten Einladung in die Zwischenablage
|
||
async function nimmEinladungIngameAn() {
|
||
const eintrag = vordergrundKonto();
|
||
if (!eintrag) return;
|
||
const [kontoUuid, pid] = eintrag;
|
||
const einl = letzteEinladung.get(kontoUuid);
|
||
if (!einl) { await zeigeIngameOverlay('Einladung', 'Gerade keine offene Einladung', pid, 4000); return; }
|
||
clipboard.writeText(einl.server);
|
||
await zeigeIngameOverlay('Adresse kopiert', [
|
||
einl.server,
|
||
'Mehrspieler → Direkt verbinden → Strg+V',
|
||
], pid, 9000);
|
||
}
|
||
|
||
// Passende eigene Instanz für einen Server: gleiche Version und den Server schon
|
||
// in der Liste > gleiche Version > (Version unbekannt) Server in der Liste.
|
||
// Jeweils die zuletzt gespielte.
|
||
function instanzFuerServer(server, version) {
|
||
const norm = (a) => String(a || '').trim().toLowerCase().replace(/:25565$/, '');
|
||
const ziel = norm(server);
|
||
const neuesteZuerst = (liste) => [...liste].sort((a, b) => String(b.lastPlayed || '').localeCompare(String(a.lastPlayed || '')));
|
||
const kenntServer = (inst) => {
|
||
try {
|
||
const r = servers.lies(store.gameDir(inst.id));
|
||
return r.ok && r.server.some((x) => norm(x.ip) === ziel);
|
||
} catch { return false; }
|
||
};
|
||
const alle = store.listInstances().filter((i) => i.minecraft && i.minecraft.version);
|
||
const passend = version ? alle.filter((i) => i.minecraft.version === version) : alle;
|
||
return neuesteZuerst(passend.filter(kenntServer))[0]
|
||
|| (version ? neuesteZuerst(passend)[0] : null)
|
||
|| null;
|
||
}
|
||
|
||
ipcMain.handle('friends:join', (_e, server, version) => {
|
||
if (!server) return { ok: false, message: 'Kein Server bekannt.' };
|
||
const inst = instanzFuerServer(server, version);
|
||
if (!inst) return { ok: false, noInstance: true, version: version || '' };
|
||
showMainWindow();
|
||
starteInstanzImFenster(inst.id, server);
|
||
return { ok: true, instanceName: inst.name };
|
||
});
|
||
|
||
// keine passende Instanz: neue Vanilla-Instanz mit der Version des Freundes anlegen und beitreten
|
||
ipcMain.handle('friends:joinNew', (_e, server, version) => {
|
||
if (!server || !version) return { ok: false, message: 'Server oder Version unbekannt.' };
|
||
const inst = store.createInstance({ name: `${server} (${version})`, minecraft: { version, loader: 'vanilla' } });
|
||
broadcast('instances:changed');
|
||
showMainWindow();
|
||
starteInstanzImFenster(inst.id, server);
|
||
return { ok: true, instanceName: inst.name };
|
||
});
|
||
|
||
// eigener aktueller Server (des aktiven Kontos), falls es gerade auf einem spielt
|
||
function eigenerServer() {
|
||
const konto = aktivesFreundeKonto();
|
||
const instId = konto && kontoInstanz.get(konto.uuid);
|
||
const server = instId && letzteQuickPlayServer.get(instId);
|
||
if (!server) return null;
|
||
const inst = store.getInstance(instId);
|
||
return { konto, server, version: (inst && inst.minecraft.version) || '' };
|
||
}
|
||
ipcMain.handle('friends:myServer', () => {
|
||
const e = eigenerServer();
|
||
return e ? { server: e.server, version: e.version } : null;
|
||
});
|
||
|
||
ipcMain.handle('friends:invite', async (_e, targetUuid) => {
|
||
const e = eigenerServer();
|
||
if (!e) return { ok: false, message: 'Du bist gerade auf keinem Server – erst einem Server beitreten.' };
|
||
return friends.sendInvite(e.konto.uuid, targetUuid, e.server, e.version);
|
||
});
|
||
|
||
ipcMain.handle('friends:invites', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.getInvites(konto.uuid);
|
||
return res.ok ? { ok: true, list: res.data || [] } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:dismissInvite', async (_e, inviteId) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false };
|
||
return friends.dismissInvite(konto.uuid, inviteId);
|
||
});
|
||
|
||
// ---------- Instanz per Code teilen ----------
|
||
// Geteilt wird nur die Beschreibung: Minecraft-Version, Loader und die Liste der
|
||
// Mods/Ressourcenpakete/Shader mit ihrer genauen Modrinth/CurseForge-Version.
|
||
// Wer den Code einlöst, lädt alles selbst von dort – keine Dateien über den
|
||
// eigenen Server, keine Urheberrechtsprobleme. Von Hand hinzugefügte Dateien
|
||
// (ohne Herkunft) lassen sich so nicht übertragen und werden genannt.
|
||
const TEILBARE_ARTEN = ['mod', 'resourcepack', 'shader'];
|
||
const TEILBARE_LOADER = new Set(['vanilla', 'fabric', 'quilt', 'forge', 'neoforge']);
|
||
|
||
function teilBeschreibung(id) {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return null;
|
||
const inhalte = [];
|
||
const nichtUebertragbar = [];
|
||
for (const art of TEILBARE_ARTEN) {
|
||
for (const m of store.listMods(id, art)) {
|
||
if (m.fehlt) continue;
|
||
if (!m.projectId) { nichtUebertragbar.push(m.filename); continue; }
|
||
inhalte.push({
|
||
art,
|
||
projectId: m.projectId,
|
||
versionId: m.versionId || '',
|
||
title: m.title || m.projectId,
|
||
aktiv: m.enabled !== false,
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
format: 1,
|
||
name: inst.name,
|
||
icon: inst.icon || '',
|
||
minecraft: {
|
||
version: inst.minecraft.version,
|
||
loader: inst.minecraft.loader || 'vanilla',
|
||
loaderVersion: inst.minecraft.loaderVersion || '',
|
||
},
|
||
inhalte,
|
||
nichtUebertragbar,
|
||
};
|
||
}
|
||
|
||
ipcMain.handle('share:create', async (_e, id) => {
|
||
const beschreibung = teilBeschreibung(id);
|
||
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' };
|
||
if (!TEILBARE_LOADER.has(beschreibung.minecraft.loader)) {
|
||
return { ok: false, message: 'OptiFine-Instanzen lassen sich nicht teilen – OptiFine muss jeder selbst installieren.' };
|
||
}
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Bitte zuerst mit einem Microsoft-Konto anmelden.' };
|
||
const res = await friends.createShare(konto.uuid, beschreibung);
|
||
if (!res.ok) return res;
|
||
return {
|
||
ok: true,
|
||
code: res.data.code,
|
||
tage: res.data.days || 30,
|
||
anzahl: beschreibung.inhalte.length,
|
||
nichtUebertragbar: beschreibung.nichtUebertragbar,
|
||
};
|
||
});
|
||
|
||
ipcMain.handle('share:fetch', async (_e, code) => {
|
||
const res = await friends.getShare(code);
|
||
if (!res.ok) return res;
|
||
const m = res.data && res.data.manifest;
|
||
if (!m || !m.minecraft || !Array.isArray(m.inhalte)) return { ok: false, message: 'Die geteilte Instanz ist beschädigt.' };
|
||
return { ok: true, data: res.data };
|
||
});
|
||
|
||
ipcMain.handle('share:install', async (_e, code) => {
|
||
const abruf = await friends.getShare(code);
|
||
if (!abruf.ok) return abruf;
|
||
const m = abruf.data && abruf.data.manifest;
|
||
const version = m && m.minecraft && String(m.minecraft.version || '');
|
||
const loader = m && m.minecraft && String(m.minecraft.loader || 'vanilla');
|
||
if (!version || !/^[\w.\- ]{1,32}$/.test(version) || !TEILBARE_LOADER.has(loader) || !Array.isArray(m.inhalte)) {
|
||
return { ok: false, message: 'Die geteilte Instanz ist beschädigt.' };
|
||
}
|
||
|
||
// eindeutiger Name, damit nichts Vorhandenes ersetzt wird
|
||
const basis = String(m.name || 'Geteilte Instanz').replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 60) || 'Geteilte Instanz';
|
||
const vorhanden = new Set(store.listInstances().map((i) => String(i.name).toLowerCase()));
|
||
let name = basis;
|
||
for (let n = 2; vorhanden.has(name.toLowerCase()); n++) name = `${basis} (${n})`;
|
||
const von = abruf.data.fromUsername || 'einem Freund';
|
||
const inst = store.createInstance({
|
||
name,
|
||
icon: typeof m.icon === 'string' ? m.icon : undefined,
|
||
notes: `Geteilt von ${von} (Code ${abruf.data.code})`,
|
||
minecraft: { version, loader, loaderVersion: String(m.minecraft.loaderVersion || '') },
|
||
});
|
||
broadcast('instances:changed');
|
||
|
||
const inhalte = m.inhalte.filter((it) => it && TEILBARE_ARTEN.includes(it.art)
|
||
&& /^[\w-]{1,64}$/.test(String(it.projectId || '')));
|
||
const installiert = [];
|
||
const fehlgeschlagen = [];
|
||
for (let i = 0; i < inhalte.length; i++) {
|
||
const it = inhalte[i];
|
||
const titel = String(it.title || it.projectId).slice(0, 80);
|
||
broadcast('share:progress', { aktuell: i + 1, gesamt: inhalte.length, titel });
|
||
// schon als Abhängigkeit eines früheren Eintrags mitgekommen?
|
||
if (store.listMods(inst.id, it.art).some((x) => x.projectId === it.projectId)) { installiert.push(titel); continue; }
|
||
const res = await installiereInhalt(inst.id, {
|
||
projectId: it.projectId,
|
||
title: titel,
|
||
versionId: typeof it.versionId === 'string' ? it.versionId : '',
|
||
}, it.art).catch((err) => ({ ok: false, message: err.message }));
|
||
if (res.ok) {
|
||
installiert.push(titel);
|
||
// war beim Teilenden abgeschaltet -> hier auch
|
||
if (it.aktiv === false && res.entry) store.setModEnabled(inst.id, res.entry.filename, false, it.art);
|
||
} else {
|
||
fehlgeschlagen.push({ titel, grund: res.message || 'unbekannt' });
|
||
}
|
||
}
|
||
broadcast('instances:changed');
|
||
return {
|
||
ok: true,
|
||
instanz: { id: inst.id, name: inst.name },
|
||
installiert: installiert.length,
|
||
fehlgeschlagen,
|
||
nichtUebertragbar: Array.isArray(m.nichtUebertragbar) ? m.nichtUebertragbar.slice(0, 50) : [],
|
||
von,
|
||
};
|
||
});
|
||
|
||
// ---------- Kurznachrichten ----------
|
||
// Ungelesene Nachrichten werden zusammen mit den Einladungen abgefragt. Neue
|
||
// erscheinen im Launcher als Hinweis und – wer gerade spielt – als Meldung im Spiel.
|
||
const gezeigteNachrichten = new Set();
|
||
let ungeleseneNachrichten = []; // des aktiven Kontos (für Zähler und Liste)
|
||
|
||
async function pruefeNachrichten() {
|
||
if (store.getSettings().friendsEnabled === false) return;
|
||
const aktiv = aktivesFreundeKonto();
|
||
const konten = new Set([...kontoPid.keys()]);
|
||
if (aktiv) konten.add(aktiv.uuid);
|
||
const spitznamen = store.getSettings().friendNicknames || {};
|
||
for (const kontoUuid of konten) {
|
||
const res = await friends.getUnreadMessages(kontoUuid);
|
||
if (!res.ok) continue;
|
||
const liste = res.data || [];
|
||
const neu = liste.filter((n) => !gezeigteNachrichten.has(n.id));
|
||
neu.forEach((n) => gezeigteNachrichten.add(n.id));
|
||
if (aktiv && aktiv.uuid === kontoUuid) {
|
||
const vorher = ungeleseneNachrichten.length;
|
||
ungeleseneNachrichten = liste;
|
||
if (neu.length > 2) broadcast('friends:message', { sammel: neu.length });
|
||
else neu.forEach((n) => broadcast('friends:message', n));
|
||
if (neu.length || vorher !== liste.length) aktualisiereAnfragenZahl().catch(() => {});
|
||
}
|
||
const pid = kontoPid.get(kontoUuid);
|
||
if (pid) {
|
||
for (const n of neu.slice(-3)) {
|
||
const name = spitznamen[n.fromUuid] || n.fromUsername || 'Ein Freund';
|
||
const inhalt = n.imageUrl ? `📷 Screenshot${n.text ? ' – ' + n.text : ''}` : n.text;
|
||
await zeigeIngameOverlay('Nachricht', [{ uuid: n.fromUuid, text: `${name}: ${inhalt}`, umbruch: true }], pid, 9000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ipcMain.handle('friends:messages', async (_e, otherUuid) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.getMessages(konto.uuid, otherUuid);
|
||
if (!res.ok) return res;
|
||
// geöffnet = gelesen
|
||
if (ungeleseneNachrichten.some((n) => n.fromUuid === otherUuid)) {
|
||
await friends.markMessagesRead(konto.uuid, otherUuid).catch(() => {});
|
||
ungeleseneNachrichten = ungeleseneNachrichten.filter((n) => n.fromUuid !== otherUuid);
|
||
aktualisiereAnfragenZahl().catch(() => {});
|
||
}
|
||
return { ok: true, list: res.data || [], ich: konto.uuid };
|
||
});
|
||
|
||
// Screenshot verkleinern (höchstens 1600 px breit), als JPEG an einen Freund schicken
|
||
ipcMain.handle('friends:sendScreenshot', async (_e, otherUuid, instanceId, dateiname, text) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const datei = safeSub(instanceId, 'screenshots', String(dateiname || ''));
|
||
if (!datei || !fs.existsSync(datei)) return { ok: false, message: 'Screenshot nicht gefunden.' };
|
||
let bild = nativeImage.createFromPath(datei);
|
||
if (bild.isEmpty()) return { ok: false, message: 'Bild konnte nicht gelesen werden.' };
|
||
const { width } = bild.getSize();
|
||
if (width > 1600) bild = bild.resize({ width: 1600, quality: 'best' });
|
||
let jpeg = bild.toJPEG(85);
|
||
if (jpeg.length > 2000000) jpeg = bild.toJPEG(65);
|
||
const res = await friends.sendMessage(konto.uuid, otherUuid, String(text || '').trim().slice(0, 200), jpeg.toString('base64'));
|
||
return res;
|
||
});
|
||
|
||
ipcMain.handle('friends:sendMessage', async (_e, otherUuid, text) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const t = String(text || '').trim().slice(0, 200);
|
||
if (!t) return { ok: false, message: 'Leere Nachricht.' };
|
||
return friends.sendMessage(konto.uuid, otherUuid, t);
|
||
});
|
||
|
||
// ungelesene Nachrichten je Freund (für die Zahl am Sprechblasen-Knopf)
|
||
ipcMain.handle('friends:unreadByFriend', () => {
|
||
const zaehler = {};
|
||
for (const n of ungeleseneNachrichten) zaehler[n.fromUuid] = (zaehler[n.fromUuid] || 0) + 1;
|
||
return zaehler;
|
||
});
|
||
|
||
// ---------- Offene Anfragen + ungelesene Nachrichten (Zahl am "Freunde"-Knopf) ----------
|
||
let letzteAnfragenZahl = { anfragen: 0, nachrichten: 0 };
|
||
async function aktualisiereAnfragenZahl() {
|
||
let anfragen = 0;
|
||
const acc = auth.currentAccount();
|
||
if (store.getSettings().friendsEnabled !== false && acc && acc.profile) {
|
||
const res = await friends.getRequests(acc.profile.id);
|
||
if (!res.ok) return; // nicht erreichbar -> letzten bekannten Stand behalten
|
||
anfragen = (res.data || []).length;
|
||
}
|
||
const aktiv = aktivesFreundeKonto();
|
||
const nachrichten = aktiv && acc && acc.profile && acc.profile.id === aktiv.uuid ? ungeleseneNachrichten.length : 0;
|
||
letzteAnfragenZahl = { anfragen, nachrichten };
|
||
broadcast('friends:requestCount', letzteAnfragenZahl);
|
||
}
|
||
ipcMain.handle('friends:requestCount', () => letzteAnfragenZahl);
|
||
|
||
// eigene uuid -> (freund-uuid -> war beim letzten Durchlauf online). Pro
|
||
// Konto getrennt, damit zwei gleichzeitig spielende Konten sich die
|
||
// Vergleichsbasis nicht gegenseitig überschreiben.
|
||
const freundeOnlineBaseline = new Map();
|
||
|
||
// Prüft für JEDES gerade über diesen Launcher spielende Konto (kontoPid) dessen
|
||
// eigene Freundesliste und zeigt die Meldung auf DESSEN Fenster - nicht nur für
|
||
// das gerade "aktive" Konto, sonst bekäme bei zwei laufenden Konten immer nur
|
||
// eines die Meldungen. Ohne laufendes Spiel gibt es kein Fenster für den Toast,
|
||
// daher werden nur spielende Konten geprüft.
|
||
async function pruefeFreundeOnline() {
|
||
if (store.getSettings().friendsEnabled === false) return;
|
||
for (const [kontoUuid, pid] of kontoPid) {
|
||
const res = await friends.getFriends(kontoUuid);
|
||
if (!res.ok) continue;
|
||
const bisherige = freundeOnlineBaseline.get(kontoUuid);
|
||
const neu = new Map();
|
||
for (const f of res.data || []) {
|
||
let bekanntOnline = f.online;
|
||
// Meldet auch beim allerersten Check, wenn ein Freund schon online ist -
|
||
// nur ausgelassen, wenn er beim vorherigen Check schon als online
|
||
// gemeldet war (sonst käme jede Minute dieselbe Meldung)
|
||
if (f.online && (!bisherige || bisherige.get(f.uuid) !== true)) {
|
||
const gezeigt = await zeigeIngameOverlay(
|
||
'Freund online',
|
||
{ uuid: f.uuid, text: `${f.username} ist jetzt online` },
|
||
pid,
|
||
);
|
||
// Fenster (noch) nicht gefunden, z. B. weil das Spiel noch lädt ->
|
||
// nicht als "schon gemeldet" merken, nächster Durchlauf versucht es erneut
|
||
if (!gezeigt) bekanntOnline = false;
|
||
}
|
||
neu.set(f.uuid, bekanntOnline);
|
||
}
|
||
freundeOnlineBaseline.set(kontoUuid, neu);
|
||
}
|
||
}
|
||
|
||
function aktivesFreundeKonto() {
|
||
const acc = auth.currentAccount();
|
||
return acc && acc.profile ? { uuid: acc.profile.id, username: acc.profile.name } : null;
|
||
}
|
||
|
||
ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false);
|
||
|
||
ipcMain.handle('friends:list', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.getFriends(konto.uuid);
|
||
return res.ok ? { ok: true, list: res.data || [] } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:requests', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.getRequests(konto.uuid);
|
||
return res.ok ? { ok: true, list: res.data || [] } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:add', async (_e, targetUsername) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.sendRequest(konto.uuid, konto.username, targetUsername);
|
||
return res.ok ? { ok: true } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:sentRequests', async () => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.getSentRequests(konto.uuid);
|
||
return res.ok ? { ok: true, list: res.data || [] } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:cancel', async (_e, otherUuid) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.cancelRequest(konto.uuid, otherUuid);
|
||
return res.ok ? { ok: true } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:respond', async (_e, otherUuid, accept) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.respond(konto.uuid, otherUuid, accept);
|
||
aktualisiereAnfragenZahl().catch(() => {});
|
||
return res.ok ? { ok: true } : res;
|
||
});
|
||
|
||
ipcMain.handle('friends:remove', async (_e, otherUuid) => {
|
||
const konto = aktivesFreundeKonto();
|
||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||
const res = await friends.removeFriend(konto.uuid, otherUuid);
|
||
return res.ok ? { ok: true } : res;
|
||
});
|
||
|
||
// Spitznamen sind rein lokal (nur meine eigene Ansicht) - kein Grund, dafür
|
||
// die WordPress-Datenbank zu bemühen, landen einfach in settings.json
|
||
ipcMain.handle('friends:setNickname', (_e, uuid, nickname) => {
|
||
const aktuelle = store.getSettings().friendNicknames || {};
|
||
const neu = Object.assign({}, aktuelle);
|
||
if (nickname) neu[uuid] = nickname; else delete neu[uuid];
|
||
store.saveSettings({ friendNicknames: neu });
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle('auth:login', async () => {
|
||
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
|
||
try {
|
||
const account = await auth.login(
|
||
clientId,
|
||
(code) => {
|
||
broadcast('auth:code', code);
|
||
},
|
||
() => {},
|
||
);
|
||
return { ok: true, account };
|
||
} catch (err) {
|
||
return { ok: false, message: err.message };
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Instanzen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('instances:list', () => store.listInstances());
|
||
|
||
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
|
||
|
||
ipcMain.handle('instances:create', (_e, data) => {
|
||
const res = store.createInstance(data);
|
||
broadcast('instances:changed');
|
||
return res;
|
||
});
|
||
|
||
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));
|
||
|
||
ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id));
|
||
|
||
ipcMain.handle('instances:openFolder', (_e, id) => {
|
||
const p = store.instancePath(id);
|
||
if (fs.existsSync(p)) shell.openPath(p);
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle('instances:openGameDir', (_e, id) => {
|
||
const p = store.gameDir(id);
|
||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||
shell.openPath(p);
|
||
return true;
|
||
});
|
||
|
||
// ---- Welten & Ressourcenpakete ----
|
||
|
||
function listDir(dir, dirsOnly) {
|
||
if (!fs.existsSync(dir)) return [];
|
||
return fs.readdirSync(dir, { withFileTypes: true })
|
||
.filter((e) => (dirsOnly ? e.isDirectory() : true) && !e.name.startsWith('.'))
|
||
.map((e) => {
|
||
let size = 0;
|
||
try { size = fs.statSync(path.join(dir, e.name)).size; } catch { /* egal */ }
|
||
return { name: e.name, isDir: e.isDirectory(), size };
|
||
});
|
||
}
|
||
|
||
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
|
||
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
|
||
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
|
||
|
||
function safeSub(id, sub, name) {
|
||
const base = path.join(store.gameDir(id), sub);
|
||
const target = path.join(base, name);
|
||
// Pfad-Ausbruch verhindern
|
||
if (!target.startsWith(base + path.sep)) return null;
|
||
return target;
|
||
}
|
||
|
||
ipcMain.handle('instance:deleteContent', (_e, id, sub, name) => {
|
||
const target = safeSub(id, sub, name);
|
||
if (target && fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle('instance:openSubdir', (_e, id, sub) => {
|
||
const p = path.join(store.gameDir(id), sub);
|
||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||
shell.openPath(p);
|
||
return true;
|
||
});
|
||
|
||
// ---- Screenshots ----
|
||
|
||
ipcMain.handle('instance:screenshots', (_e, id) =>
|
||
listDir(path.join(store.gameDir(id), 'screenshots'), false)
|
||
.filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f.name))
|
||
.sort((a, b) => b.name.localeCompare(a.name)));
|
||
|
||
ipcMain.handle('instance:screenshotData', (_e, id, name) => {
|
||
const target = safeSub(id, 'screenshots', name);
|
||
try {
|
||
if (!target || !fs.existsSync(target)) return null;
|
||
if (fs.statSync(target).size > 12 * 1024 * 1024) return null;
|
||
let ext = path.extname(target).slice(1).toLowerCase();
|
||
if (ext === 'jpg') ext = 'jpeg';
|
||
return `data:image/${ext};base64,` + fs.readFileSync(target).toString('base64');
|
||
} catch { return null; }
|
||
});
|
||
|
||
ipcMain.handle('instance:openScreenshot', (_e, id, name) => {
|
||
const target = safeSub(id, 'screenshots', name);
|
||
if (target && fs.existsSync(target)) shell.openPath(target);
|
||
return true;
|
||
});
|
||
|
||
// ---- Instanz Import/Export (ZIP über Windows-Bordmittel) ----
|
||
|
||
function runPwsh(command, env) {
|
||
return new Promise((resolve, reject) => {
|
||
execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command],
|
||
{ env: Object.assign({}, process.env, env), windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
||
(err, stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve(stdout)));
|
||
});
|
||
}
|
||
|
||
ipcMain.handle('instances:export', async (_e, id) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
|
||
const res = await dialog.showSaveDialog(mainWindow, {
|
||
title: 'Instanz exportieren',
|
||
defaultPath: safe + '.zip',
|
||
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
|
||
});
|
||
if (res.canceled) return { ok: false, canceled: true };
|
||
try {
|
||
if (fs.existsSync(res.filePath)) fs.unlinkSync(res.filePath);
|
||
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
|
||
{ VN_SRC: store.instancePath(id), VN_DST: res.filePath });
|
||
return { ok: true, path: res.filePath };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Export fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Schnellsicherung EINER Welt als ZIP – unabhängig vom globalen
|
||
* Backup-System, landet in einem eigenen Unterordner der Dokumente.
|
||
*/
|
||
ipcMain.handle('world:backup', async (_e, id, worldName) => {
|
||
const res = await sichereWelt(id, worldName, false);
|
||
if (res.ok) {
|
||
const inst = store.getInstance(id);
|
||
notify('Welt gesichert', `${worldName} (${inst ? inst.name : ''}) wurde gesichert.`);
|
||
}
|
||
return res;
|
||
});
|
||
|
||
// automatische Sicherungen: so viele Stände je Welt bleiben erhalten
|
||
const AUTO_WELT_STAENDE = 5;
|
||
|
||
/*
|
||
* Eine Welt als ZIP sichern. automatisch = true: Unterordner "Automatisch",
|
||
* es bleiben nur die letzten AUTO_WELT_STAENDE Stände dieser Welt.
|
||
*/
|
||
async function sichereWelt(id, worldName, automatisch) {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || ''));
|
||
if (path.dirname(worldDir) !== path.join(store.gameDir(id), 'saves') || !fs.existsSync(worldDir)) {
|
||
return { ok: false, message: 'Welt nicht gefunden.' };
|
||
}
|
||
|
||
try {
|
||
let zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen',
|
||
(inst.name || 'Instanz').replace(/[^\w.-]+/g, '_'));
|
||
if (automatisch) zielOrdner = path.join(zielOrdner, 'Automatisch');
|
||
fs.mkdirSync(zielOrdner, { recursive: true });
|
||
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||
const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_');
|
||
const zielDatei = path.join(zielOrdner, `${safeWorld}_${zeitstempel}.zip`);
|
||
if (fs.existsSync(zielDatei)) fs.unlinkSync(zielDatei);
|
||
|
||
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
|
||
{ VN_SRC: worldDir, VN_DST: zielDatei });
|
||
|
||
if (automatisch) {
|
||
// ältere automatische Stände dieser Welt wegräumen
|
||
const praefix = safeWorld + '_';
|
||
const staende = fs.readdirSync(zielOrdner)
|
||
.filter((n) => n.startsWith(praefix) && n.endsWith('.zip') && /^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}/.test(n.slice(praefix.length)))
|
||
.sort();
|
||
for (const alt of staende.slice(0, Math.max(0, staende.length - AUTO_WELT_STAENDE))) {
|
||
try { fs.unlinkSync(path.join(zielOrdner, alt)); } catch { /* egal */ }
|
||
}
|
||
}
|
||
return { ok: true, path: zielDatei };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message };
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Nach einer Spielsitzung: die Welt sichern, die während der Sitzung zuletzt
|
||
* gespeichert wurde (level.dat neuer als der Spielstart). Läuft im Hintergrund.
|
||
*/
|
||
async function sichereZuletztGespielteWelt(id, sitzungStart) {
|
||
const savesDir = path.join(store.gameDir(id), 'saves');
|
||
let beste = null;
|
||
try {
|
||
for (const e of fs.readdirSync(savesDir, { withFileTypes: true })) {
|
||
if (!e.isDirectory()) continue;
|
||
const level = path.join(savesDir, e.name, 'level.dat');
|
||
try {
|
||
const zeit = fs.statSync(level).mtimeMs;
|
||
if (zeit >= sitzungStart - 5000 && (!beste || zeit > beste.zeit)) beste = { name: e.name, zeit };
|
||
} catch { /* keine Welt */ }
|
||
}
|
||
} catch { return; }
|
||
if (!beste) return; // nur Mehrspieler gespielt
|
||
const res = await sichereWelt(id, beste.name, true);
|
||
if (res.ok) broadcast('ui:toast', { text: `Welt „${beste.name}“ automatisch gesichert (die letzten ${AUTO_WELT_STAENDE} Stände bleiben erhalten).` });
|
||
}
|
||
|
||
// Welt in eine andere Instanz kopieren
|
||
ipcMain.handle('world:copyTo', async (_e, id, worldName, zielId) => {
|
||
const quelle = store.getInstance(id);
|
||
const ziel = store.getInstance(zielId);
|
||
if (!quelle || !ziel) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
if (id === zielId) return { ok: false, message: 'Quelle und Ziel sind dieselbe Instanz.' };
|
||
const savesQuelle = path.join(store.gameDir(id), 'saves');
|
||
const worldDir = path.join(savesQuelle, String(worldName || ''));
|
||
if (path.dirname(worldDir) !== savesQuelle || !fs.existsSync(worldDir)) return { ok: false, message: 'Welt nicht gefunden.' };
|
||
if (laufendePid.has(id)) return { ok: false, message: 'Bitte erst Minecraft in dieser Instanz beenden – sonst wird die Welt mitten beim Speichern kopiert.' };
|
||
|
||
const savesZiel = path.join(store.gameDir(zielId), 'saves');
|
||
fs.mkdirSync(savesZiel, { recursive: true });
|
||
let name = String(worldName);
|
||
for (let n = 2; fs.existsSync(path.join(savesZiel, name)); n++) name = `${worldName} (${n})`;
|
||
try {
|
||
await fs.promises.cp(worldDir, path.join(savesZiel, name), {
|
||
recursive: true,
|
||
filter: (src) => path.basename(src) !== 'session.lock',
|
||
});
|
||
return { ok: true, name, ziel: ziel.name };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Kopieren fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// ---- Modpack-Import (Modrinth .mrpack) ----
|
||
|
||
async function importMrpack(buf, zip) {
|
||
const raw = forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, '');
|
||
const index = JSON.parse(raw);
|
||
const deps = index.dependencies || {};
|
||
let loader = 'vanilla', loaderVersion = '';
|
||
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
|
||
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
|
||
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
|
||
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
|
||
|
||
const created = store.createOrReplaceImportedInstance({
|
||
name: index.name || 'Modpack',
|
||
minecraft: { version: deps.minecraft || '', loader, loaderVersion },
|
||
});
|
||
const gameDir = store.gameDir(created.id);
|
||
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
||
|
||
const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false });
|
||
|
||
// Herkunft merken, damit sich das Paket später aktualisieren lässt
|
||
const herkunft = await modpackHerkunft(buf, index);
|
||
store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) });
|
||
|
||
return {
|
||
ok: true,
|
||
instance: store.getInstance(created.id),
|
||
fileCount: eingespielt.dateien.length,
|
||
};
|
||
}
|
||
|
||
/*
|
||
* Spielt ein .mrpack in ein Spielverzeichnis ein.
|
||
* schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos
|
||
* und die Optionen unangetastet – sonst wären die Einstellungen des Spielers weg.
|
||
* -> { dateien: [relative Pfade] }
|
||
*/
|
||
const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i];
|
||
|
||
async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) {
|
||
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
||
const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel));
|
||
const dateien = [];
|
||
|
||
const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported'));
|
||
let i = 0;
|
||
for (const f of files) {
|
||
i++;
|
||
const url = f.downloads && f.downloads[0];
|
||
if (!url || !f.path) continue;
|
||
const rel = String(f.path).replace(/\\/g, '/');
|
||
if (geschuetzt(rel)) continue;
|
||
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
|
||
if (!inside(dest)) continue;
|
||
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;
|
||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
||
dateien.push(rel);
|
||
} catch { /* einzelne Datei überspringen */ }
|
||
}
|
||
|
||
// overrides einspielen
|
||
for (const prefix of ['overrides/', 'client-overrides/']) {
|
||
for (const name of Object.keys(zip)) {
|
||
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
|
||
const rel = name.slice(prefix.length);
|
||
if (geschuetzt(rel)) continue;
|
||
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
|
||
if (!inside(dest)) continue;
|
||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
|
||
dateien.push(rel);
|
||
}
|
||
}
|
||
return { dateien };
|
||
}
|
||
|
||
/*
|
||
* Woher stammt das Paket? Modrinth kennt jede veröffentlichte Datei an ihrer
|
||
* Prüfsumme – darüber finden wir Projekt und Fassung, ohne den Nutzer zu fragen.
|
||
*/
|
||
async function modpackHerkunft(buf, index) {
|
||
const basis = {
|
||
quelle: 'modrinth',
|
||
name: index.name || '',
|
||
versionNummer: index.versionId || '',
|
||
projectId: '',
|
||
versionId: '',
|
||
};
|
||
try {
|
||
const sha1 = require('crypto').createHash('sha1').update(buf).digest('hex');
|
||
const res = await fetch(`https://api.modrinth.com/v2/version_file/${sha1}?algorithm=sha1`,
|
||
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
|
||
if (res.ok) {
|
||
const v = await res.json();
|
||
basis.projectId = v.project_id || '';
|
||
basis.versionId = v.id || '';
|
||
basis.versionNummer = v.version_number || basis.versionNummer;
|
||
}
|
||
} catch { /* offline oder unbekanntes Paket – dann eben ohne Herkunft */ }
|
||
return basis;
|
||
}
|
||
|
||
// ---- Modpack aktualisieren (Modrinth) ----
|
||
|
||
/*
|
||
* Sucht eine neuere Fassung des installierten Modpacks.
|
||
* -> { ok, verfuegbar, aktuelleNummer, neueNummer, ... }
|
||
*/
|
||
ipcMain.handle('modpack:check', async (_e, id) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const mp = inst.modpack;
|
||
if (!mp || !mp.projectId) {
|
||
return { ok: false, message: 'Für diese Instanz ist kein Modrinth-Modpack hinterlegt. '
|
||
+ 'Aktualisieren geht nur bei Instanzen, die aus einem .mrpack entstanden sind.' };
|
||
}
|
||
try {
|
||
const res = await fetch(`https://api.modrinth.com/v2/project/${mp.projectId}/version`,
|
||
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
|
||
if (!res.ok) return { ok: false, message: 'Modrinth antwortet mit HTTP ' + res.status };
|
||
const versionen = await res.json();
|
||
if (!Array.isArray(versionen) || !versionen.length) return { ok: true, verfuegbar: false };
|
||
|
||
const neueste = versionen[0];
|
||
const verfuegbar = neueste.id !== mp.versionId;
|
||
return {
|
||
ok: true,
|
||
verfuegbar,
|
||
name: mp.name,
|
||
aktuelleNummer: mp.versionNummer || '?',
|
||
neueNummer: neueste.version_number,
|
||
neueVersionId: neueste.id,
|
||
hinweise: neueste.changelog || '',
|
||
spielVersion: (neueste.game_versions || [])[0] || '',
|
||
};
|
||
} catch (err) {
|
||
return { ok: false, message: 'Suche fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Spielt eine neuere Fassung ein. Dateien der alten Fassung, die es nicht mehr
|
||
* gibt, werden entfernt; Welten, Bildschirmfotos und Optionen bleiben.
|
||
*/
|
||
ipcMain.handle('modpack:update', async (_e, id, versionId) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const mp = inst.modpack;
|
||
if (!mp || !mp.projectId) return { ok: false, message: 'Kein Modpack hinterlegt.' };
|
||
|
||
try {
|
||
const vres = await fetch(`https://api.modrinth.com/v2/version/${versionId}`,
|
||
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
|
||
if (!vres.ok) return { ok: false, message: 'Fassung nicht abrufbar (HTTP ' + vres.status + ').' };
|
||
const version = await vres.json();
|
||
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
|
||
if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' };
|
||
|
||
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());
|
||
|
||
const zip = forge.readZipIndex(buf);
|
||
if (!zip['modrinth.index.json']) return { ok: false, message: 'Die geladene Datei ist kein .mrpack.' };
|
||
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''));
|
||
|
||
const gameDir = store.gameDir(id);
|
||
const neu = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: true });
|
||
|
||
// Was die alte Fassung mitbrachte und die neue nicht mehr kennt, muss weg –
|
||
// sonst bleiben alte Mods liegen und der Loader stolpert darüber.
|
||
const behalten = new Set(neu.dateien.map((d) => d.toLowerCase()));
|
||
const entfernt = [];
|
||
for (const alt of mp.dateien || []) {
|
||
if (behalten.has(String(alt).toLowerCase())) continue;
|
||
if (SPIELERDATEN.some((r) => r.test(alt))) continue;
|
||
const p = path.join(gameDir, String(alt).replace(/\//g, path.sep));
|
||
if (!p.startsWith(gameDir + path.sep)) continue;
|
||
try {
|
||
if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); }
|
||
} catch { /* egal */ }
|
||
}
|
||
|
||
// Minecraft-Version und Loader können sich mit der neuen Fassung ändern
|
||
const deps = index.dependencies || {};
|
||
let loader = 'vanilla'; let loaderVersion = '';
|
||
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
|
||
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
|
||
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
|
||
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
|
||
|
||
store.updateInstance(id, {
|
||
minecraft: { version: deps.minecraft || inst.minecraft.version, loader, loaderVersion },
|
||
modpack: Object.assign({}, mp, {
|
||
versionId: version.id,
|
||
versionNummer: version.version_number,
|
||
dateien: neu.dateien,
|
||
}),
|
||
});
|
||
|
||
broadcast('instances:changed');
|
||
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
|
||
return {
|
||
ok: true,
|
||
neueNummer: version.version_number,
|
||
eingespielt: neu.dateien.length,
|
||
entfernt: entfernt.length,
|
||
spielVersion: deps.minecraft || '',
|
||
};
|
||
} catch (err) {
|
||
return { ok: false, message: 'Aktualisierung fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// ---- Modpack-Import (CurseForge .zip mit manifest.json) ----
|
||
|
||
// "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion }
|
||
function loaderAusManifest(manifest) {
|
||
const eintraege = (manifest.minecraft && manifest.minecraft.modLoaders) || [];
|
||
const primaer = eintraege.find((l) => l && l.primary) || eintraege[0];
|
||
const id = String((primaer && primaer.id) || '');
|
||
const m = id.match(/^(forge|neoforge|fabric|quilt)-(.+)$/i);
|
||
if (!m) return { loader: 'vanilla', loaderVersion: '' };
|
||
return { loader: m[1].toLowerCase(), loaderVersion: m[2] };
|
||
}
|
||
|
||
async function importCurseForge(buf, zip, zugang) {
|
||
const raw = forge.readZipEntry(buf, zip['manifest.json']).toString('utf8').replace(/^/, '');
|
||
const manifest = JSON.parse(raw);
|
||
const { loader, loaderVersion } = loaderAusManifest(manifest);
|
||
|
||
const created = store.createOrReplaceImportedInstance({
|
||
name: manifest.name || 'CurseForge-Modpack',
|
||
minecraft: {
|
||
version: (manifest.minecraft && manifest.minecraft.version) || '',
|
||
loader,
|
||
loaderVersion,
|
||
},
|
||
});
|
||
const gameDir = store.gameDir(created.id);
|
||
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
||
const melde = (detail, current, total) => {
|
||
broadcast('launch:progress', { phase: 'modpack', current, total, detail });
|
||
};
|
||
|
||
// 1) Dateiliste über die CurseForge-API auflösen
|
||
const dateien = (manifest.files || []).filter((f) => f && f.fileID);
|
||
melde('Dateiliste abrufen', 0, dateien.length);
|
||
const aufgeloest = await services.curseforgeResolveFiles(zugang, dateien,
|
||
(p) => melde('Dateiliste abrufen', p.aufgeloest, p.gesamt));
|
||
|
||
// 2) Mods herunterladen
|
||
const modsDir = path.join(gameDir, 'mods');
|
||
let geladen = 0;
|
||
const fehlend = [];
|
||
let i = 0;
|
||
for (const f of dateien) {
|
||
i++;
|
||
const info = aufgeloest.get(Number(f.fileID));
|
||
if (!info) { fehlend.push(f.fileID); continue; }
|
||
const dest = path.join(modsDir, path.basename(info.fileName));
|
||
if (!inside(dest)) continue;
|
||
melde('Mods laden', i, dateien.length);
|
||
try {
|
||
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
|
||
// einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server.
|
||
await services.downloadFile(info.url, dest, info.kopfzeilen);
|
||
geladen++;
|
||
} catch {
|
||
fehlend.push(f.fileID);
|
||
}
|
||
}
|
||
|
||
// 3) overrides einspielen
|
||
const prefix = (manifest.overrides || 'overrides').replace(/\/*$/, '') + '/';
|
||
for (const name of Object.keys(zip)) {
|
||
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
|
||
const dest = path.join(gameDir, name.slice(prefix.length).replace(/\//g, path.sep));
|
||
if (!inside(dest)) continue;
|
||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
instance: store.getInstance(created.id),
|
||
fileCount: dateien.length,
|
||
geladen,
|
||
fehlend: fehlend.length,
|
||
};
|
||
}
|
||
|
||
ipcMain.handle('modpack:search', async (_e, opts) => {
|
||
const o = opts || {};
|
||
try {
|
||
if (o.source === 'curseforge') {
|
||
const zugang = services.curseforgeZugang(store.getSettings());
|
||
return await services.curseforgeSearch(zugang, Object.assign({}, o, { art: 'modpack' }));
|
||
}
|
||
const q = Object.assign({}, o, { art: 'modpack' });
|
||
return await services.modrinthSearch(q);
|
||
} catch (err) {
|
||
return { total: 0, hits: [], error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
|
||
try {
|
||
const projectId = project && (project.projectId || project.id);
|
||
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
|
||
const gameVersion = (project && project.gameVersion) || '';
|
||
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
|
||
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
|
||
if (!file) {
|
||
return {
|
||
ok: false,
|
||
message: gameVersion
|
||
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
|
||
: 'Keine Modpack-Version gefunden.',
|
||
};
|
||
}
|
||
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']) {
|
||
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
|
||
}
|
||
const result = await importMrpack(buf, zip);
|
||
if (result && result.ok && result.instance) {
|
||
// Herkunft mit aktueller Version-ID ergänzen, falls der Index sie nicht trägt
|
||
const inst = result.instance;
|
||
const mp = Object.assign({}, inst.modpack || {}, {
|
||
projectId: projectId,
|
||
versionId: file.versionId,
|
||
versionNumber: file.versionNumber,
|
||
title: (project && project.title) || (inst.modpack && inst.modpack.title) || inst.name,
|
||
});
|
||
store.updateInstance(inst.id, { modpack: mp });
|
||
result.instance = store.getInstance(inst.id);
|
||
}
|
||
if (result && result.ok && result.instance) {
|
||
broadcast('instances:changed');
|
||
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
|
||
}
|
||
return result;
|
||
} catch (err) {
|
||
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
|
||
try {
|
||
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.' };
|
||
}
|
||
const rawId = String((project && (project.cfId || project.projectId || project.id)) || '').replace(/^cf-/, '');
|
||
const modId = Number(rawId);
|
||
if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' };
|
||
const gameVersion = (project && project.gameVersion) || '';
|
||
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
|
||
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
|
||
if (!file || !file.url) {
|
||
return {
|
||
ok: false,
|
||
message: gameVersion
|
||
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
|
||
: 'Keine passende Modpack-Datei gefunden.',
|
||
};
|
||
}
|
||
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);
|
||
if (!zip['manifest.json']) {
|
||
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' };
|
||
}
|
||
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;
|
||
} catch (err) {
|
||
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem
|
||
* Dateidialog oder per Drag&Drop aufs Fenster kommt.
|
||
* notModpack: true heißt "kein Modpack-Format erkannt" – der Aufrufer kann
|
||
* dann noch andere ZIP-Interpretationen probieren (z. B. Instanz-Export).
|
||
*/
|
||
async function importModpackFromPath(filePath) {
|
||
try {
|
||
const buf = fs.readFileSync(filePath);
|
||
const zip = forge.readZipIndex(buf);
|
||
let result = null;
|
||
if (zip['modrinth.index.json']) {
|
||
result = await importMrpack(buf, zip);
|
||
} else if (zip['manifest.json']) {
|
||
const zugang = services.curseforgeZugang(store.getSettings());
|
||
if (!zugang) {
|
||
return { ok: false, needsKey: true,
|
||
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
|
||
}
|
||
result = await importCurseForge(buf, zip, zugang);
|
||
} else {
|
||
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;
|
||
} catch (err) {
|
||
return { ok: false, notModpack: true, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
|
||
}
|
||
}
|
||
|
||
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'] }],
|
||
});
|
||
if (res.canceled) return { ok: false, canceled: true };
|
||
return importModpackFromPath(res.filePaths[0]);
|
||
});
|
||
|
||
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
|
||
// wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk)
|
||
ipcMain.handle('instances:shortcut', (_e, id, wunschName) => {
|
||
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 roh = (wunschName && wunschName.trim()) || inst.name || 'Instanz';
|
||
const name = String(roh).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz';
|
||
const datei = path.join(app.getPath('desktop'), name + '.lnk');
|
||
const ziel = verknuepfungsZiel();
|
||
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
|
||
target: ziel,
|
||
// Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium
|
||
// an ihrem Schalter bleibt (siehe instanzAusArgumenten)
|
||
args: verknuepfungsArgs([`--launch="${id}"`]),
|
||
cwd: path.dirname(ziel),
|
||
description: 'AeroMC – ' + inst.name,
|
||
icon: verknuepfungsIcon(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 };
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Desktop-Verknüpfung, die eine Instanz startet UND direkt mit einem
|
||
* bestimmten Server verbindet (Quick Play, ab Minecraft 1.20). Technisch
|
||
* dasselbe wie instances:shortcut, nur mit zusätzlichem "--server="-Argument.
|
||
* wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk)
|
||
*/
|
||
ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => {
|
||
const inst = store.getInstance(id);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
if (!ip) return { ok: false, message: 'Keine Serveradresse angegeben.' };
|
||
if (process.platform !== 'win32') {
|
||
return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' };
|
||
}
|
||
try {
|
||
const rohName = (wunschName && wunschName.trim())
|
||
|| `${inst.name || 'Instanz'} - ${name || ip}`;
|
||
const dateiName = String(rohName).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Server';
|
||
const datei = path.join(app.getPath('desktop'), `${dateiName}.lnk`);
|
||
const ziel = verknuepfungsZiel();
|
||
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
|
||
target: ziel,
|
||
// Gleichheitsform für beide Kennungen, damit sie beim Umsortieren durch
|
||
// Chromium zusammenbleiben (siehe instanzAusArgumenten)
|
||
args: verknuepfungsArgs([`--launch="${id}"`, `--server="${ip}"`]),
|
||
cwd: path.dirname(ziel),
|
||
description: `AeroMC – ${inst.name} → ${name || ip}`,
|
||
icon: verknuepfungsIcon(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 };
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Kern des ZIP-Instanz-Imports (AeroMC-Export oder einzelne MultiMC/Prism-
|
||
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
|
||
* Drag&Drop aufs Fenster kommt.
|
||
*/
|
||
async function importInstanceZipFromPath(filePath) {
|
||
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
|
||
try {
|
||
fs.mkdirSync(tmp, { recursive: true });
|
||
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
|
||
{ VN_SRC: filePath, VN_DST: tmp });
|
||
|
||
// instance.json finden (auch in Unterordner)
|
||
let base = tmp;
|
||
if (!fs.existsSync(path.join(base, 'instance.json'))) {
|
||
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
|
||
.find((p) => { try { return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'instance.json')); } catch { return false; } });
|
||
if (sub) base = sub;
|
||
}
|
||
// Kein eigener Export? Dann auf eine MultiMC/Prism-Instanz prüfen –
|
||
// die bringt statt instance.json eine instance.cfg mit.
|
||
if (!fs.existsSync(path.join(base, 'instance.json'))) {
|
||
const mmc = mmcimport.scanSingle(tmp);
|
||
if (mmc.ok) {
|
||
const done = mmcimport.importOne(store, mmc.instance, true);
|
||
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied };
|
||
}
|
||
throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).');
|
||
}
|
||
const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8'));
|
||
const created = store.createOrReplaceImportedInstance({
|
||
name: imported.name || 'Import',
|
||
icon: imported.icon || 'grass',
|
||
notes: imported.notes || '',
|
||
minecraft: imported.minecraft || {},
|
||
});
|
||
if (imported.java) store.updateInstance(created.id, { java: imported.java });
|
||
|
||
const mcSrc = path.join(base, 'minecraft');
|
||
if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true });
|
||
const modsSrc = path.join(base, 'mods.json');
|
||
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
|
||
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
|
||
fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage));
|
||
store.updateInstance(created.id, { customImage: imported.customImage });
|
||
}
|
||
return { ok: true, instance: store.getInstance(created.id) };
|
||
} catch (err) {
|
||
return { ok: false, message: 'Import fehlgeschlagen: ' + err.message };
|
||
} finally {
|
||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
|
||
}
|
||
}
|
||
|
||
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 };
|
||
const result = await importInstanceZipFromPath(res.filePaths[0]);
|
||
if (result && result.ok) broadcast('instances:changed');
|
||
return result;
|
||
});
|
||
|
||
/*
|
||
* Datei per Drag&Drop aufs Fenster gezogen. Anhand der Endung/des Inhalts
|
||
* wird entschieden, was damit passieren soll:
|
||
* .jar -> als Mod/Ressourcenpaket/Shader in die übergebene
|
||
* Instanz kopieren (targetId/targetArt vom Renderer,
|
||
* je nachdem was gerade offen bzw. ausgewählt ist)
|
||
* .mrpack / .zip -> zuerst als Modpack versuchen (Modrinth/CurseForge),
|
||
* sonst als AeroMC-/MultiMC-Instanz-Export
|
||
*/
|
||
ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
|
||
if (!filePath || !fs.existsSync(filePath)) return { ok: false, message: 'Datei nicht gefunden.' };
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
|
||
if (ext === '.jar') {
|
||
if (!targetId) {
|
||
return { ok: false, message: 'Bitte zuerst eine Instanz öffnen (Mods-Dialog), dann die Datei dort hineinziehen.' };
|
||
}
|
||
const inst = store.getInstance(targetId);
|
||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||
const art = targetArt || 'mod';
|
||
const dir = store.inhaltsDir(targetId, art);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
try {
|
||
const name = path.basename(filePath);
|
||
const ext2 = path.extname(name);
|
||
const base = name.slice(0, -ext2.length);
|
||
let dest = path.join(dir, name);
|
||
let n = 2;
|
||
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext2}`); n++; }
|
||
fs.copyFileSync(filePath, dest);
|
||
return { ok: true, kind: 'mod', added: [path.basename(dest)] };
|
||
} catch (err) {
|
||
return { ok: false, message: err.message };
|
||
}
|
||
}
|
||
|
||
if (ext === '.mrpack' || ext === '.zip') {
|
||
const modpack = await importModpackFromPath(filePath);
|
||
// .mrpack ist immer ein Modpack – bei Fehlschlag direkt die Meldung
|
||
// zeigen, statt es zusätzlich noch als Instanz-Export zu probieren
|
||
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);
|
||
}
|
||
|
||
return { ok: false, message: 'Nicht unterstützter Dateityp: ' + (ext || '(ohne Endung)') };
|
||
});
|
||
|
||
/*
|
||
* Windows-Benachrichtigung für abgeschlossene Hintergrund-Aktionen (Backup,
|
||
* größere Downloads) – nur wenn die Einstellung an ist UND das Fenster
|
||
* gerade nicht im Vordergrund ist (sonst sieht man's eh schon live).
|
||
*/
|
||
function notify(title, body) {
|
||
try {
|
||
if (store.getSettings().desktopNotifications === false) return;
|
||
if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return;
|
||
if (!Notification.isSupported()) return;
|
||
const n = new Notification({ title, body, silent: false, icon: benachrichtigungsLogo || undefined });
|
||
n.on('click', () => {
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||
mainWindow.focus();
|
||
}
|
||
});
|
||
n.show();
|
||
} catch { /* rein informativ – darf nie einen Ablauf stören */ }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sicherungen: von Hand und nach Zeitplan
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let backupLaeuft = false;
|
||
|
||
/*
|
||
* Erstellt eine Sicherung aller Instanzen.
|
||
* still = kein Fortschritt an die Oberfläche (für den Zeitplan im Hintergrund)
|
||
*/
|
||
async function erstelleBackup({ still } = {}) {
|
||
if (backupLaeuft) return { ok: false, message: 'Es läuft bereits eine Sicherung.' };
|
||
backupLaeuft = true;
|
||
const destinationPath = backup.resolveAutoBackupPath(app.getPath('documents'));
|
||
try {
|
||
const result = await backup.createGlobalBackup({
|
||
instancesDir: store.getInstancesDir(),
|
||
userDataDir: app.getPath('userData'),
|
||
includeSettings: true,
|
||
destinationPath,
|
||
onProgress: (progress) => {
|
||
if (!still && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
|
||
},
|
||
});
|
||
if (!still && mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
|
||
}
|
||
if (result && result.ok) notify('Backup abgeschlossen', 'Die Sicherung aller Instanzen ist fertig.');
|
||
return result;
|
||
} catch (err) {
|
||
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
|
||
} finally {
|
||
backupLaeuft = false;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Alte automatische Sicherungen wegräumen. Ohne das läuft die Platte voll –
|
||
* jede Sicherung enthält sämtliche Instanzen.
|
||
*/
|
||
function raeumeAlteBackups(behalten) {
|
||
const anzahl = Math.max(1, Number(behalten) || 5);
|
||
try {
|
||
const alle = backup.listGlobalBackups(app.getPath('documents')); // neueste zuerst
|
||
for (const b of alle.slice(anzahl)) {
|
||
try { fs.rmSync(b.path, { force: true }); } catch { /* egal */ }
|
||
}
|
||
return Math.max(0, alle.length - anzahl);
|
||
} catch { return 0; }
|
||
}
|
||
|
||
const BACKUP_ABSTAND = { daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 };
|
||
|
||
// Ist laut Einstellungen eine Sicherung fällig?
|
||
function backupFaellig(settings, jetzt = Date.now()) {
|
||
if (!settings.autoBackupEnabled) return false;
|
||
const abstand = BACKUP_ABSTAND[settings.autoBackupEvery] || BACKUP_ABSTAND.daily;
|
||
const zuletzt = Date.parse(settings.autoBackupLast || '') || 0;
|
||
return jetzt - zuletzt >= abstand;
|
||
}
|
||
|
||
async function backupNachZeitplan(anlass) {
|
||
const settings = store.getSettings();
|
||
if (!settings.autoBackupEnabled) return null;
|
||
if (anlass === 'zeitplan' && !backupFaellig(settings)) return null;
|
||
if (backupLaeuft) return null;
|
||
|
||
const res = await erstelleBackup({ still: true });
|
||
if (res && res.ok) {
|
||
store.saveSettings({ autoBackupLast: new Date().toISOString() });
|
||
const geloescht = raeumeAlteBackups(settings.autoBackupKeep);
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('backup:auto', {
|
||
ok: true, anlass, datei: res.path || res.destinationPath || '', aufgeraeumt: geloescht,
|
||
});
|
||
}
|
||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('backup:auto', { ok: false, anlass, message: res && res.message });
|
||
}
|
||
return res;
|
||
}
|
||
|
||
ipcMain.handle('backup:global', () => erstelleBackup({ still: false }));
|
||
|
||
ipcMain.handle('backup:list', async () => backup.listGlobalBackups(app.getPath('documents')));
|
||
|
||
ipcMain.handle('backup:restore', async (_e, sourcePath) => {
|
||
if (!sourcePath || !String(sourcePath).trim()) {
|
||
return { ok: false, message: 'Kein Backup ausgewählt.' };
|
||
}
|
||
try {
|
||
const currentSettings = store.getSettings();
|
||
const result = await backup.restoreGlobalBackup({
|
||
sourcePath,
|
||
instancesDir: store.getInstancesDir(),
|
||
userDataDir: app.getPath('userData'),
|
||
includeSettings: true,
|
||
instancesDirSetting: currentSettings.instancesDir || null,
|
||
onProgress: (progress) => {
|
||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
|
||
},
|
||
});
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('backup:progress', { mode: 'restore', percent: 100, detail: 'Wiederherstellen abgeschlossen' });
|
||
}
|
||
return result;
|
||
} catch (err) {
|
||
return { ok: false, message: 'Wiederherstellen fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden).
|
||
/*
|
||
* Welche Konten spielen gerade? Konto-ID -> Anzahl laufender Spiele.
|
||
* Wie bei MultiMC wird das Konto erst beim Start gelesen; ein laufendes Spiel
|
||
* behält seines. So kann man das aktive Konto wechseln und mit einem zweiten
|
||
* Konto parallel starten.
|
||
*/
|
||
const spielendeKonten = new Map();
|
||
|
||
function meldeKontenAenderung() {
|
||
broadcast('accounts:changed');
|
||
}
|
||
|
||
function kontoSpieltJetzt(kontoId) {
|
||
if (!kontoId) return;
|
||
spielendeKonten.set(kontoId, (spielendeKonten.get(kontoId) || 0) + 1);
|
||
meldeKontenAenderung();
|
||
}
|
||
|
||
function kontoSpieltNichtMehr(kontoId) {
|
||
if (!kontoId) return;
|
||
const rest = (spielendeKonten.get(kontoId) || 1) - 1;
|
||
if (rest > 0) spielendeKonten.set(kontoId, rest);
|
||
else spielendeKonten.delete(kontoId);
|
||
meldeKontenAenderung();
|
||
}
|
||
|
||
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||
// 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) {
|
||
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) {
|
||
broadcast('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
|
||
await backupNachZeitplan('vor-dem-start').catch(() => {});
|
||
}
|
||
|
||
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
|
||
let modUpdates = null;
|
||
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
|
||
|
||
const opts = {
|
||
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
|
||
versionId: inst.minecraft.version,
|
||
loader: inst.minecraft.loader || 'vanilla',
|
||
loaderVersion: inst.minecraft.loaderVersion || '',
|
||
gameDir: store.gameDir(id),
|
||
sharedDir: path.join(app.getPath('userData'), 'shared'),
|
||
userDataDir: app.getPath('userData'),
|
||
javaPath: inst.java.path || settings.javaPath || '',
|
||
minMemMb: inst.java.minMemMb || settings.defaultMinMemMb,
|
||
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
|
||
extraArgs: inst.java.extraArgs || '',
|
||
preLaunchCommand: inst.preLaunchCommand || settings.globalPreLaunch || '',
|
||
postExitCommand: inst.postExitCommand || settings.globalPostExit || '',
|
||
wrapperCommand: settings.wrapperCommand || '',
|
||
mcMaximized: !!settings.mcMaximized,
|
||
mcWidth: settings.mcWidth || 854,
|
||
mcHeight: settings.mcHeight || 480,
|
||
auth: null,
|
||
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
|
||
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
||
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
|
||
};
|
||
|
||
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
|
||
const clientId = settings.azureClientId || auth.DEFAULT_CLIENT_ID;
|
||
const account = accountId
|
||
? await auth.getValidAccountById(clientId, accountId)
|
||
: await auth.getValidAccount(clientId);
|
||
if (account && account.profile) {
|
||
opts.auth = {
|
||
name: account.profile.name,
|
||
uuid: account.profile.id,
|
||
accessToken: account.mcAccessToken,
|
||
userType: 'msa',
|
||
};
|
||
}
|
||
|
||
// Konto dieses Starts merken – nicht das aktive, denn das kann inzwischen
|
||
// gewechselt worden sein, während dieses Spiel noch läuft.
|
||
const kontoId = account && account.profile ? account.profile.id : null;
|
||
let gezaehlt = false;
|
||
let freundeHerzschlag = null;
|
||
let herzschlagJetzt = null; // sofort melden, z. B. sobald ein Server erkannt wurde
|
||
|
||
letzteAusgabe.set(id, '');
|
||
try {
|
||
let launchStart = 0;
|
||
const res = await launcher.prepareAndLaunch(opts, (evt) => {
|
||
if (evt.phase === 'launch') {
|
||
launchStart = Date.now();
|
||
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
|
||
discordpresence.setActivity({
|
||
details: 'Spielt Minecraft',
|
||
state: `${inst.name} · ${inst.minecraft.version}`,
|
||
startTimestamp: launchStart,
|
||
});
|
||
// Freundesystem: eigene Online-Meldung, nur wenn nicht in den
|
||
// Einstellungen ausgeschaltet (siehe friendsEnabled) - sonst bleibt
|
||
// man für niemanden auffindbar/sichtbar
|
||
if (account && account.profile && store.getSettings().friendsEnabled !== false) {
|
||
const sendeHerzschlag = () => {
|
||
friends.heartbeat({
|
||
uuid: account.profile.id,
|
||
username: account.profile.name,
|
||
instance: inst.name,
|
||
// nur, wenn man seinen Server zeigen möchte (Einstellung, live gelesen)
|
||
server: store.getSettings().friendsShareServer === false ? '' : (letzteQuickPlayServer.get(id) || ''),
|
||
// für "Beitreten": damit Freunde eine passende Instanz wählen können
|
||
version: inst.minecraft.version || '',
|
||
}).catch(() => {});
|
||
};
|
||
herzschlagJetzt = sendeHerzschlag;
|
||
laufendeHerzschlaege.add(sendeHerzschlag);
|
||
sendeHerzschlag();
|
||
freundeHerzschlag = setInterval(sendeHerzschlag, 60000);
|
||
}
|
||
}
|
||
if (evt.phase === 'exit') {
|
||
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
|
||
discordpresence.clearActivity();
|
||
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||
if (herzschlagJetzt) laufendeHerzschlaege.delete(herzschlagJetzt);
|
||
herzschlagJetzt = null;
|
||
if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId);
|
||
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
|
||
// beim nächsten Start dieses Kontos sollen bereits online befindliche
|
||
// Freunde wieder gemeldet werden
|
||
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
|
||
aktualisiereFreundeTastenkuerzel();
|
||
if (launchStart) {
|
||
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
|
||
const sitzungStart = launchStart;
|
||
launchStart = 0; // Spielzeit nur einmal gutschreiben
|
||
if (store.getSettings().autoWorldBackup !== false) {
|
||
// kurz warten, bis Minecraft die Weltdateien sicher geschlossen hat
|
||
setTimeout(() => { sichereZuletztGespielteWelt(id, sitzungStart).catch(() => {}); }, 3000);
|
||
}
|
||
const cur = store.getInstance(id);
|
||
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
|
||
}
|
||
}
|
||
if (evt.phase === 'log') {
|
||
const bisher = (letzteAusgabe.get(id) || '') + evt.line;
|
||
letzteAusgabe.set(id, bisher.length > AUSGABE_MAX ? bisher.slice(-AUSGABE_MAX) : bisher);
|
||
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 });
|
||
// Freunde sollen den Server gleich sehen (für "Beitreten"), nicht erst beim nächsten Herzschlag
|
||
if (herzschlagJetzt) herzschlagJetzt();
|
||
}
|
||
}
|
||
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 && freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||
if (!res.ok && herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
|
||
if (res.ok) {
|
||
const aktuell = store.getInstance(id);
|
||
store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 });
|
||
}
|
||
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
|
||
if (res.ok && res.pid) laufendePid.set(id, res.pid);
|
||
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
|
||
return res;
|
||
} catch (err) {
|
||
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
|
||
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
|
||
if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
|
||
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Dialoge
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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'],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('dialog:pickImage', async () => {
|
||
const res = await dialog.showOpenDialog(mainWindow, {
|
||
title: 'Instanz-Bild wählen',
|
||
properties: ['openFile'],
|
||
filters: [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
// eigenes Bild einer Instanz zuweisen / entfernen
|
||
ipcMain.handle('instances:setImage', (_e, id, sourcePath) => {
|
||
store.setCustomImage(id, sourcePath);
|
||
return { meta: store.getInstance(id), iconData: store.iconDataUri(id) };
|
||
});
|
||
ipcMain.handle('instances:clearImage', (_e, id) => store.clearCustomImage(id));
|
||
ipcMain.handle('instances:icon', (_e, id) => store.iconDataUri(id));
|
||
|
||
// beliebige Bilddatei als data:-URI (für Vorschau vor dem Speichern)
|
||
ipcMain.handle('file:dataUri', (_e, filePath) => {
|
||
try {
|
||
const stat = fs.statSync(filePath);
|
||
if (stat.size > 8 * 1024 * 1024) return null; // >8 MB nicht als Vorschau
|
||
let ext = path.extname(filePath).slice(1).toLowerCase();
|
||
if (ext === 'jpg') ext = 'jpeg';
|
||
const buf = fs.readFileSync(filePath);
|
||
return `data:image/${ext};base64,` + buf.toString('base64');
|
||
} catch { return null; }
|
||
});
|
||
|
||
ipcMain.handle('dialog:pickJava', async (e) => {
|
||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'java.exe wählen',
|
||
properties: ['openFile'],
|
||
filters: [{ name: 'Java', extensions: ['exe'] }],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('app:openExternal', (_e, url) => {
|
||
if (typeof url === 'string' && /^https?:\/\//.test(url)) shell.openExternal(url);
|
||
return true;
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Instanzen aus MultiMC/Prism übernehmen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('mmc:pickFolder', async (e) => {
|
||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)',
|
||
properties: ['openDirectory'],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('mmc:scan', (_e, folder) => {
|
||
try { return mmcimport.scan(folder); }
|
||
catch (err) { return { ok: false, reason: err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('mmc:import', (_e, entries, copyData) => {
|
||
const done = [];
|
||
const failed = [];
|
||
for (const entry of entries || []) {
|
||
try {
|
||
const imported = mmcimport.importOne(store, entry, copyData !== false);
|
||
done.push(imported);
|
||
}
|
||
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||
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 (e) => {
|
||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
|
||
properties: ['openDirectory'],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('cf:scan', (_e, folder) => {
|
||
try { return cfimport.scan(folder); }
|
||
catch (err) { return { ok: false, reason: err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('cf:import', (_e, entries, copyData) => {
|
||
const done = [];
|
||
const failed = [];
|
||
for (const entry of entries || []) {
|
||
try {
|
||
const imported = cfimport.importOne(store, entry, copyData !== false);
|
||
done.push(imported);
|
||
}
|
||
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||
broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
|
||
}
|
||
if (done.length) broadcast('instances:changed');
|
||
return { ok: true, imported: done, failed };
|
||
});
|
||
|
||
// ---------- Modrinth App ----------
|
||
ipcMain.handle('mr:pickFolder', async (e) => {
|
||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'Datenordner der Modrinth App wählen (meist %APPDATA%\\ModrinthApp)',
|
||
defaultPath: mrimport.standardOrdner(),
|
||
properties: ['openDirectory'],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('mr:scan', async (_e, folder) => {
|
||
try { return await mrimport.scan(folder); }
|
||
catch (err) { return { ok: false, reason: err.message }; }
|
||
});
|
||
|
||
/*
|
||
* War die Instanz in der Modrinth App mit einem Modrinth-Modpack verknüpft,
|
||
* übernehmen wir die Verknüpfung – samt der Liste der Dateien, die zum Paket
|
||
* gehören. Ohne diese Liste würde "Modpack aktualisieren" alte Mods liegen
|
||
* lassen; deshalb wird die Paketdatei einmal geladen und nur ihr Inhaltsverzeichnis
|
||
* gelesen. Klappt das nicht (offline …), bleibt die Instanz ohne Verknüpfung.
|
||
*/
|
||
async function verknuepfeModrinthModpack(instId, link) {
|
||
const kopf = { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } };
|
||
const vres = await fetch(`https://api.modrinth.com/v2/version/${encodeURIComponent(link.versionId)}`, kopf);
|
||
if (!vres.ok) return false;
|
||
const version = await vres.json();
|
||
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
|
||
if (!datei || !datei.url) return false;
|
||
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||
if (!dl.ok) return false;
|
||
const buf = Buffer.from(await dl.arrayBuffer());
|
||
const zip = forge.readZipIndex(buf);
|
||
if (!zip['modrinth.index.json']) return false;
|
||
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^\uFEFF/, ''));
|
||
const dateien = (index.files || [])
|
||
.filter((f) => f.path && !(f.env && f.env.client === 'unsupported'))
|
||
.map((f) => String(f.path).replace(/\\/g, '/'));
|
||
for (const prefix of ['overrides/', 'client-overrides/']) {
|
||
for (const name of Object.keys(zip)) if (name.startsWith(prefix) && !name.endsWith('/')) dateien.push(name.slice(prefix.length));
|
||
}
|
||
let titel = index.name || '';
|
||
try {
|
||
const pres = await fetch(`https://api.modrinth.com/v2/project/${encodeURIComponent(link.projectId)}`, kopf);
|
||
if (pres.ok) titel = (await pres.json()).title || titel;
|
||
} catch { /* Titel ist nur Zierde */ }
|
||
store.updateInstance(instId, {
|
||
modpack: {
|
||
quelle: 'modrinth',
|
||
name: index.name || titel,
|
||
title: titel,
|
||
projectId: link.projectId,
|
||
versionId: link.versionId,
|
||
versionNummer: version.version_number || '',
|
||
dateien,
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
ipcMain.handle('mr:import', async (_e, entries, copyData) => {
|
||
const done = [];
|
||
const failed = [];
|
||
const liste = entries || [];
|
||
for (const entry of liste) {
|
||
try {
|
||
const imported = mrimport.importOne(store, entry, copyData !== false);
|
||
if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) {
|
||
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name });
|
||
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
|
||
catch { imported.modpack = false; }
|
||
}
|
||
done.push(imported);
|
||
}
|
||
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length });
|
||
}
|
||
if (done.length) broadcast('instances:changed');
|
||
return { ok: true, imported: done, failed };
|
||
});
|
||
|
||
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'],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('mc:scan', (_e, folder) => {
|
||
try { return mcimport.scan(folder); }
|
||
catch (err) { return { ok: false, reason: err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('mc:import', (_e, entries, copyData) => {
|
||
const done = [];
|
||
const failed = [];
|
||
for (const entry of entries || []) {
|
||
try {
|
||
const imported = mcimport.importOne(store, entry, copyData !== false);
|
||
done.push(imported);
|
||
}
|
||
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||
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 (e) => {
|
||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
|
||
properties: ['openDirectory'],
|
||
});
|
||
return res.canceled ? null : res.filePaths[0];
|
||
});
|
||
|
||
ipcMain.handle('technic:scan', (_e, folder) => {
|
||
try { return technicimport.scan(folder); }
|
||
catch (err) { return { ok: false, reason: err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('technic:import', (_e, entries, copyData) => {
|
||
const done = [];
|
||
const failed = [];
|
||
for (const entry of entries || []) {
|
||
try {
|
||
const imported = technicimport.importOne(store, entry, copyData !== false);
|
||
done.push(imported);
|
||
}
|
||
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
|
||
}
|
||
if (done.length) broadcast('instances:changed');
|
||
return { ok: true, imported: done, failed };
|
||
});
|
||
|
||
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
|
||
|
||
ipcMain.handle('technicPlatform:search', async (_e, query) => {
|
||
try { return { ok: true, hits: await technicdiscover.search(query) }; }
|
||
catch (err) { return { ok: false, message: err.message }; }
|
||
});
|
||
|
||
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
|
||
try {
|
||
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
|
||
broadcast('technicPlatform:progress', p);
|
||
});
|
||
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 };
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Minecraft Bedrock (Windows-Store-App)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('bedrock:detect', () => bedrock.detect());
|
||
ipcMain.handle('bedrock:launch', () => bedrock.launch());
|
||
ipcMain.handle('bedrock:worlds', () => bedrock.listWorlds());
|
||
ipcMain.handle('bedrock:list', (_e, kind) => bedrock.listFolder(kind));
|
||
ipcMain.handle('bedrock:remove', (_e, kind, name) => bedrock.removeEntry(kind, name));
|
||
|
||
ipcMain.handle('bedrock:openFolder', (_e, kind) => {
|
||
const p = bedrock.folderPath(kind);
|
||
if (p) { fs.mkdirSync(p, { recursive: true }); shell.openPath(p); }
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle('bedrock:installAddon', async (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'] }],
|
||
});
|
||
if (res.canceled) return { ok: false, canceled: true };
|
||
return bedrock.installAddon(res.filePaths[0]);
|
||
});
|
||
|
||
// Weltordner im Explorer zeigen
|
||
ipcMain.handle('bedrock:openWorld', (_e, dir) => {
|
||
const base = bedrock.folderPath('worlds');
|
||
if (base && dir && dir.startsWith(base) && fs.existsSync(dir)) shell.openPath(dir);
|
||
return true;
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – echte Minecraft-Icons (aus den eigenen Spieldateien)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function iconDirs() {
|
||
const ud = app.getPath('userData');
|
||
const parent = path.dirname(ud);
|
||
// auch im Datenordner vor der Umbenennung suchen
|
||
const candidates = [path.join(ud, 'shared')];
|
||
for (const alt of ['viper-network', 'Viper-Network', 'aeromc', 'AeroMC']) {
|
||
const p = path.join(parent, alt, 'shared');
|
||
if (!candidates.includes(p)) candidates.push(p);
|
||
}
|
||
return { shared: candidates, out: path.join(ud, 'icons') };
|
||
}
|
||
|
||
ipcMain.handle('icons:list', () => {
|
||
const { out } = iconDirs();
|
||
return icons.listIcons(out);
|
||
});
|
||
|
||
ipcMain.handle('icons:extract', () => {
|
||
const { shared, out } = iconDirs();
|
||
const res = icons.extractIcons(shared, out);
|
||
if (!res.ok) return { ok: false, reason: res.reason };
|
||
return { ok: true, version: res.version, count: res.count, icons: icons.listIcons(out) };
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IPC – Automatische Updates
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ipcMain.handle('update:check', () => pruefeAufUpdates());
|
||
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
|
||
|
||
// 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) => {
|
||
broadcast('update:progress', p);
|
||
});
|
||
const res = updater.applyUpdate(file);
|
||
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
|
||
setTimeout(() => app.quit(), 1200);
|
||
return { ok: true, path: file.path, kind: file.kind, mode: res.started };
|
||
} catch (err) {
|
||
return { ok: false, message: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('app:info', () => ({
|
||
name: pkg.productName || pkg.build?.productName || pkg.name || 'AeroMC',
|
||
version: app.getVersion(),
|
||
author: pkg.author || '',
|
||
homepage: pkg.homepage || '',
|
||
launcherWebsite: pkg.launcherWebsite || '',
|
||
}));
|
||
|
||
// Lizenztext für die Info-Seite in den Einstellungen. Die LICENSE-Datei liegt
|
||
// im App-Root und wird über "files" im electron-builder-Config mitgepackt.
|
||
ipcMain.handle('app:license', () => {
|
||
try {
|
||
return fs.readFileSync(path.join(app.getAppPath(), 'LICENSE'), 'utf8');
|
||
} catch {
|
||
return 'Lizenztext konnte nicht geladen werden.';
|
||
}
|
||
});
|