Upload via GUI (50 Dateien)

This commit is contained in:
2026-09-29 07:09:26 +00:00
parent ee0e6dec32
commit d268e548f5
11 changed files with 1436 additions and 49 deletions
+448 -6
View File
@@ -6,11 +6,16 @@
* (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 } = require('electron');
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');
@@ -33,6 +38,7 @@ const skins = require('./skins');
const servers = require('./servers');
const mcping = require('./mcping');
const discordpresence = require('./discordpresence');
const friends = require('./friends');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -111,6 +117,7 @@ const POPOUT_SPEC = {
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 },
};
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
@@ -204,6 +211,10 @@ 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)
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
@@ -329,19 +340,61 @@ function aufzeichnungsOrdner(instanceName) {
String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_'));
}
ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel) => {
// 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}.webm`);
let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.mp4`);
let n = 2;
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.webm`); n++; }
fs.writeFileSync(zielDatei, Buffer.from(buffer));
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 };
}
});
@@ -353,6 +406,57 @@ ipcMain.handle('recording:openFolder', (_e, instanceName) => {
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
// ---------------------------------------------------------------------------
@@ -609,6 +713,16 @@ app.whenReady().then(() => {
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);
// Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf
setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000);
setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000);
// automatisch auf Updates prüfen (kurz nach dem Start)
setTimeout(async () => {
try {
@@ -646,6 +760,7 @@ app.on('window-all-closed', () => {
});
app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); });
app.on('will-quit', () => { globalShortcut.unregisterAll(); });
// ---------------------------------------------------------------------------
// Java-Erkennung (für Anzeige in den Einstellungen)
@@ -674,6 +789,10 @@ ipcMain.handle('settings:save', (_e, patch) => {
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsEnabled')) {
aktualisiereFreundeTastenkuerzel();
aktualisiereAnfragenZahl().catch(() => {});
}
// 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
@@ -1136,7 +1255,18 @@ 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) => auth.setActive(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
aktualisiereAnfragenZahl().catch(() => {});
return res;
});
// ---- Skin des aktiven Kontos ----
@@ -1197,6 +1327,294 @@ ipcMain.handle('skin:reset', async () => {
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) };
}));
const breite = 320;
const hoehe = 44 + zeilen.reduce((summe, z) => summe + (z.kopf ? 24 : 18), 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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: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>
</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';
let freundeTastenkuerzelAktiv = false;
function aktualisiereFreundeTastenkuerzel() {
const soll = kontoPid.size > 0 && store.getSettings().friendsEnabled !== false;
if (soll && !freundeTastenkuerzelAktiv) {
freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => {
zeigeOnlineFreundeIngame().catch(() => {});
});
} else if (!soll && freundeTastenkuerzelAktiv) {
globalShortcut.unregister(FREUNDE_TASTENKUERZEL);
freundeTastenkuerzelAktiv = false;
}
}
// 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() {
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];
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,
);
}
// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
let letzteAnfragenZahl = 0;
async function aktualisiereAnfragenZahl() {
let zahl = 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;
}
letzteAnfragenZahl = zahl;
broadcast('friends:requestCount', zahl);
}
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 {
@@ -2225,6 +2643,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// 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;
try {
let launchStart = 0;
@@ -2237,11 +2656,31 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
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,
server: letzteQuickPlayServer.get(id) || '',
}).catch(() => {});
};
sendeHerzschlag();
freundeHerzschlag = setInterval(sendeHerzschlag, 60000);
}
}
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
discordpresence.clearActivity();
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
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));
launchStart = 0; // Spielzeit nur einmal gutschreiben
@@ -2264,11 +2703,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// 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) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
if (res.ok && res.pid) laufendePid.set(id, res.pid);
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); aktualisiereFreundeTastenkuerzel(); }
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
}
});