Upload via GUI (50 Dateien)
This commit is contained in:
+448
-6
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user