Upload via GUI (93 Dateien)

This commit is contained in:
2026-10-02 19:39:47 +00:00
parent 0595b7ab1a
commit 41c63ef0ed
9 changed files with 2486 additions and 887 deletions
+581 -20
View File
@@ -39,6 +39,7 @@ 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');
@@ -240,6 +241,10 @@ const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss a
// (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);
@@ -560,6 +565,62 @@ ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
// 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();
@@ -712,6 +773,15 @@ function createWindow(startHidden) {
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
@@ -755,15 +825,47 @@ function createTray() {
const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty();
tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 }));
tray.setToolTip('AeroMC');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => showMainWindow() },
{ type: 'separator' },
{ label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } },
]));
// 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; }
}
@@ -782,12 +884,13 @@ function applyAutostart(enabled) {
: [];
app.setLoginItemSettings({ openAtLogin: !!enabled, path: verknuepfungsZiel(), args });
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
if (enabled) createTray();
else destroyTray();
// 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
@@ -846,6 +949,8 @@ app.whenReady().then(() => {
// 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);
@@ -1027,6 +1132,7 @@ async function installiereInhalt(id, project, art) {
} 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;
@@ -1272,6 +1378,164 @@ ipcMain.handle('crash:latest', (_e, id, seit) => {
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) => {
@@ -1454,7 +1718,9 @@ ipcMain.handle('auth:setActive', (_e, id) => {
// 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;
});
@@ -1589,10 +1855,12 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
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) };
return { text: z.text, kopf: await spielerKopf(z.uuid), umbruch: !!z.umbruch };
}));
const breite = 320;
const hoehe = 44 + zeilen.reduce((summe, z) => summe + (z.kopf ? 24 : 18), 0);
// 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({
@@ -1621,9 +1889,9 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
<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:center;gap:7px;font-size:12px;margin-top:${z.kopf ? 4 : 2}px;">
${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;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(z.text)}</span>
<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>`)}`;
@@ -1815,18 +2083,228 @@ ipcMain.handle('friends:dismissInvite', async (_e, inviteId) => {
return friends.dismissInvite(konto.uuid, inviteId);
});
// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
let letzteAnfragenZahl = 0;
// ---------- 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 zahl = 0;
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
zahl = (res.data || []).length;
anfragen = (res.data || []).length;
}
letzteAnfragenZahl = zahl;
broadcast('friends:requestCount', zahl);
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);
@@ -2085,6 +2563,22 @@ ipcMain.handle('instances:export', async (_e, id) => {
* 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 || ''));
@@ -2093,8 +2587,9 @@ ipcMain.handle('world:backup', async (_e, id, worldName) => {
}
try {
const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen',
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, '_');
@@ -2103,11 +2598,69 @@ ipcMain.handle('world:backup', async (_e, id, worldName) => {
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
{ VN_SRC: worldDir, VN_DST: zielDatei });
notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`);
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) ----
@@ -2740,7 +3293,7 @@ function notify(title, body) {
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 });
const n = new Notification({ title, body, silent: false, icon: benachrichtigungsLogo || undefined });
n.on('click', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
@@ -2970,6 +3523,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
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) => {
@@ -3016,12 +3570,19 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
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]}`;