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
+95 -9
View File
@@ -1,16 +1,17 @@
{
"name": "aeromc",
"version": "1.1.2",
"version": "1.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.1.2",
"version": "1.1.3",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1",
"ffmpeg-static": "^5.3.0",
"loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2",
@@ -22,6 +23,21 @@
"electron-builder": "^24.0.0"
}
},
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"license": "MIT",
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -793,7 +809,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "4"
@@ -1350,7 +1365,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/builder-util": {
@@ -1473,6 +1487,12 @@
"node": ">= 0.4"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1643,6 +1663,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/config-file-ts": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz",
@@ -1762,7 +1811,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2488,7 +2536,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2693,6 +2740,22 @@
"integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
"license": "MIT"
},
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -3116,6 +3179,21 @@
"node": ">= 6"
}
},
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"license": "MIT",
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/http-response-object/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT"
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
@@ -3134,7 +3212,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "6",
@@ -3677,7 +3754,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
@@ -3782,6 +3858,11 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -3871,7 +3952,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -4530,6 +4610,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.1.2",
"version": "1.1.3",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
@@ -28,7 +28,8 @@
],
"asarUnpack": [
"node_modules/loopback-capture/**/*",
"node_modules/node-window-manager/**/*"
"node_modules/node-window-manager/**/*",
"node_modules/ffmpeg-static/**/*"
],
"win": {
"target": "nsis",
@@ -46,6 +47,7 @@
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1",
"ffmpeg-static": "^5.3.0",
"loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2",
+70
View File
@@ -0,0 +1,70 @@
'use strict';
/*
* friends.js – Anbindung an das AeroMC-Freundesystem (WordPress-Plugin)
* -----------------------------------------------------------------------------
* Schlanke Fetch-Wrapper gegen die REST-Routen des "AeroMC Freunde"-Plugins
* (siehe wordpress-plugin/aeromc-friends/). Keine eigene Anmeldung - jeder
* Aufruf trägt einfach die Minecraft-UUID des aktiven Kontos.
*
* Genau wie bei discordpresence.js: jeder Fehler (Plugin nicht installiert,
* kein Internet, Seite down) wird abgefangen und nach oben als { ok:false,
* message } gereicht statt den Launcher zu stören - ein soziales Extra darf
* nie den Spielstart oder die Bedienung blockieren.
*/
const BASIS_URL = 'https://viper-network.de/wp-json/aeromc/v1';
async function anfrage(pfad, { method = 'GET', body } = {}) {
try {
const url = method === 'GET' && body
? `${BASIS_URL}${pfad}?${new URLSearchParams(body).toString()}`
: `${BASIS_URL}${pfad}`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'GET' ? undefined : JSON.stringify(body || {}),
});
const daten = await res.json().catch(() => null);
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { ok: true, data: daten };
} catch (err) {
return { ok: false, message: 'Freundesystem nicht erreichbar: ' + err.message };
}
}
function heartbeat({ uuid, username, instance, server }) {
return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server } });
}
function getFriends(uuid) {
return anfrage('/friends', { method: 'GET', body: { uuid } });
}
function getRequests(uuid) {
return anfrage('/friends/requests', { method: 'GET', body: { uuid } });
}
function getSentRequests(uuid) {
return anfrage('/friends/sent', { method: 'GET', body: { uuid } });
}
function sendRequest(uuid, username, targetUsername) {
return anfrage('/friends/request', { method: 'POST', body: { uuid, username, targetUsername } });
}
function respond(uuid, otherUuid, accept) {
return anfrage('/friends/respond', { method: 'POST', body: { uuid, otherUuid, accept } });
}
function cancelRequest(uuid, otherUuid) {
return anfrage('/friends/cancel', { method: 'POST', body: { uuid, otherUuid } });
}
function removeFriend(uuid, otherUuid) {
return anfrage('/friends/remove', { method: 'POST', body: { uuid, otherUuid } });
}
module.exports = {
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
};
+51
View File
@@ -17,6 +17,7 @@
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
<button id="btn-console" class="tbtn" data-ic="terminal" title="Konsole / Log">Konsole</button>
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
<button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button>
<span class="tbgrow"></span>
<div class="search-wrap">
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" />
@@ -285,6 +286,14 @@
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
</section>
<section class="settings-card">
<h5>Freundesystem</h5>
<label class="checkbox-field">
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
</label>
<small class="hint">Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.</small>
</section>
<section class="settings-card">
<h5>Autostart</h5>
<label class="checkbox-field">
@@ -931,6 +940,12 @@
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<div class="mods-tabs">
<button id="screens-tab-photos" class="mtab on">Bildschirmfotos</button>
<button id="screens-tab-records" class="mtab">Aufzeichnungen</button>
</div>
<div id="screens-pane-photos">
<div class="screens-toolbar">
<button id="screens-select-all" class="btn btn-mini">Alle auswählen</button>
<button id="screens-select-none" class="btn btn-mini">Auswahl aufheben</button>
@@ -939,6 +954,17 @@
</div>
<div id="screens-grid" class="screens-grid"></div>
</div>
<div id="screens-pane-records" class="hidden">
<div class="screens-toolbar">
<button id="records-select-all" class="btn btn-mini">Alle auswählen</button>
<button id="records-select-none" class="btn btn-mini">Auswahl aufheben</button>
<button id="records-delete-sel" class="btn btn-danger btn-mini" disabled>Auswahl löschen</button>
<span id="records-sel-hint" class="hint">Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen</span>
</div>
<div id="records-grid" class="screens-grid"></div>
</div>
</div>
<div class="modal-foot">
<button id="screens-open" class="btn" data-ic="folder">Ordner</button>
<div class="spacer"></div>
@@ -947,6 +973,31 @@
</div>
</div>
<!-- ===================== Dialog: Freunde ===================== -->
<div id="modal-friends" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Freunde</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<p id="friends-disabled-hint" class="hint hidden">Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.</p>
<div id="friends-content">
<div class="field-row">
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
</div>
<div id="friends-requests-box"></div>
<div id="friends-list-box"></div>
</div>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Modpack aktualisieren ===================== -->
<div id="modal-modpack" class="modal hidden">
<div class="modal-card modal-sm">
+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 };
}
});
+22 -1
View File
@@ -279,8 +279,29 @@ contextBridge.exposeInMainWorld('api', {
// Spielaufzeichnung
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
recordingSave: (instanceName, buffer, serverLabel) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel),
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
// Freundesystem
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
friendsList: () => ipcRenderer.invoke('friends:list'),
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
onFriendsRequestCount: (cb) => {
const l = (_e, zahl) => cb(zahl);
ipcRenderer.on('friends:requestCount', l);
return () => ipcRenderer.removeListener('friends:requestCount', l);
},
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
onRecordingServerDetected: (cb) => {
const l = (_e, payload) => cb(payload);
+342 -11
View File
@@ -1507,6 +1507,7 @@ const ICONS_SVG = {
folder: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>',
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
play: '<path d="M7 5l12 7-12 7z"/>',
blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
@@ -2974,7 +2975,22 @@ function erzeugeSkaliertenStream(quellStream, zielBreite, zielHoehe, fps) {
// unsichtbare Rahmen für die Größenänderung per Maus) - sonst wäre am Rand
// ein dünner Streifen Desktop/Nachbarfenster im Zuschnitt zu sehen.
const UNSICHTBARER_RAND = 8;
const zeichnen = () => {
// captureStream(fps) allein reicht nicht: requestAnimationFrame läuft mit
// der Bildwiederholrate des Monitors (z. B. 144Hz), nicht mit der
// gewünschten Aufnahme-Bildrate - ungebremst wurden dadurch weit mehr
// Bilder gezeichnet/geliefert als angefragt (führte zu einer kaputten
// Zeitachse im aufgenommenen Video und sichtbarem Ruckeln). Deshalb
// captureStream(0) (kein automatisches Intervall) und Bilder nur exakt im
// Takt der Ziel-Bildrate selbst per requestFrame() ausliefern.
const canvasStream = canvas.captureStream(0);
const spurAusgabe = canvasStream.getVideoTracks()[0];
const intervallMs = 1000 / fps;
let letzteZeichnung = 0;
const zeichnen = (jetzt) => {
if (!letzteZeichnung || jetzt - letzteZeichnung >= intervallMs) {
letzteZeichnung = jetzt;
if (video.readyState >= 2) {
if (mcFensterBounds && mcBildschirmBounds && video.videoWidth && mcBildschirmBounds.width) {
const skalX = video.videoWidth / mcBildschirmBounds.width;
@@ -2989,11 +3005,13 @@ function erzeugeSkaliertenStream(quellStream, zielBreite, zielHoehe, fps) {
} else {
ctx.drawImage(video, 0, 0, zielBreite, zielHoehe); // Übergang, bis Fenstergrenzen bekannt sind
}
spurAusgabe.requestFrame();
}
}
mcCanvasRAF = requestAnimationFrame(zeichnen);
};
zeichnen();
return canvas.captureStream(fps);
mcCanvasRAF = requestAnimationFrame(zeichnen);
return canvasStream;
}
async function erfasseFensterVideoMitCursor(source, grenzen) {
@@ -3039,9 +3057,8 @@ async function wendeVideoEinstellungenAn() {
function updateVideoSummary() {
const groesse = mcActualSize ? ` (${mcActualSize.width}×${mcActualSize.height})` : '';
const cursorHinweis = mcVideoStream ? (mcCursorAusgeblendet ? ' · ohne Mauszeiger' : ' · mit Mauszeiger') : '';
el('record-video-summary').textContent =
RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps' + cursorHinweis;
RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps';
const aktiv = recordingActive();
el('record-resolution').disabled = aktiv;
el('record-quality').disabled = aktiv;
@@ -3290,7 +3307,6 @@ async function onMcRecordingStopped() {
clearInterval(mcTimerInterval);
window.api.recordingSetGuard(false);
el('record-live-badge').classList.add('hidden');
el('record-toggle').textContent = 'Aufnahme starten';
el('record-timer').textContent = '00:00';
updateVideoSummary();
cleanupAudioMix();
@@ -3301,7 +3317,16 @@ async function onMcRecordingStopped() {
const buffer = await blob.arrayBuffer();
const inst = await window.api.getInstance(currentDetailId);
mcLastSavedInstanceName = inst ? inst.name : 'Instanz';
const res = await window.api.recordingSave(mcLastSavedInstanceName, buffer, await aktuellerAufnahmeServer());
// Die Umwandlung nach MP4 (für Abspielbarkeit in jedem Player) läuft nicht
// sofort durch - kurz sichtbar machen, damit der Knopf währenddessen nicht
// wie eingefroren wirkt
const knopf = el('record-toggle');
knopf.disabled = true;
knopf.textContent = 'Wird verarbeitet…';
const res = await window.api.recordingSave(mcLastSavedInstanceName, buffer, await aktuellerAufnahmeServer(), mcFramerate);
knopf.disabled = false;
knopf.textContent = 'Aufnahme starten';
if (res.ok) {
toast('Aufzeichnung gespeichert.');
} else {
@@ -3338,6 +3363,8 @@ async function openScreens() {
grid.innerHTML = '<div class="screens-empty">Lade …</div>';
screenSelected = new Set();
updateScreensSelectionUi();
recordsGeladen = false;
switchScreensTab('photos');
show('modal-screens');
const shots = await window.api.screenshots(currentDetailId);
@@ -3402,6 +3429,251 @@ async function deleteSelectedScreenshots() {
});
}
// ---------- Aufzeichnungen-Galerie (im selben Dialog, zweiter Tab) ----------
let screensAktiverTab = 'photos';
let recordSelected = new Set();
let recordsGeladen = false; // nur beim ersten Wechsel auf den Tab laden, nicht bei jedem Öffnen
function switchScreensTab(which) {
screensAktiverTab = which;
const photos = which === 'photos';
el('screens-tab-photos').classList.toggle('on', photos);
el('screens-tab-records').classList.toggle('on', !photos);
el('screens-pane-photos').classList.toggle('hidden', !photos);
el('screens-pane-records').classList.toggle('hidden', photos);
if (!photos && !recordsGeladen) { recordsGeladen = true; openRecordsList(); }
}
function updateRecordsSelectionUi() {
const n = recordSelected.size;
const btn = el('records-delete-sel');
if (btn) {
btn.disabled = n === 0;
btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
}
const hint = el('records-sel-hint');
if (hint) hint.textContent = n ? (n + ' ausgewählt – Doppelklick spielt ab') : 'Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen';
document.querySelectorAll('#records-grid .screen-thumb').forEach((t) => {
const name = t.dataset.name;
t.classList.toggle('selected', !!(name && recordSelected.has(name)));
const cb = t.querySelector('.screen-check');
if (cb) cb.checked = !!(name && recordSelected.has(name));
});
}
function formatDateigroesse(bytes) {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return Math.round(bytes / 1024) + ' KB';
}
async function openRecordsList() {
const grid = el('records-grid');
grid.innerHTML = '<div class="screens-empty">Lade …</div>';
recordSelected = new Set();
updateRecordsSelectionUi();
const inst = await window.api.getInstance(currentDetailId);
const instanceName = inst ? inst.name : null;
if (!instanceName) { grid.innerHTML = '<div class="screens-empty">Keine Instanz ausgewählt.</div>'; return; }
const aufnahmen = await window.api.recordingList(instanceName);
grid.innerHTML = '';
if (!aufnahmen.length) {
grid.innerHTML = '<div class="screens-empty">Keine Aufzeichnungen vorhanden.</div>';
updateRecordsSelectionUi();
return;
}
for (const a of aufnahmen) {
const thumb = document.createElement('div');
thumb.className = 'screen-thumb record-thumb';
thumb.dataset.name = a.name;
thumb.innerHTML =
`<input type="checkbox" class="screen-check" title="Auswählen" />
<div class="record-thumb-img-wrap"><img alt="" /><span class="record-thumb-play">▶</span></div>
<div class="screen-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
<div class="record-thumb-size">${formatDateigroesse(a.size)}</div>
<button class="screen-del" title="Löschen">🗑</button>`;
const img = thumb.querySelector('img');
window.api.recordingThumbnail(instanceName, a.name).then((d) => { if (d) img.src = d; });
const toggle = () => {
if (recordSelected.has(a.name)) recordSelected.delete(a.name);
else recordSelected.add(a.name);
updateRecordsSelectionUi();
};
thumb.querySelector('.screen-check').addEventListener('click', (e) => { e.stopPropagation(); toggle(); });
thumb.addEventListener('click', (e) => {
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
toggle();
});
thumb.addEventListener('dblclick', (e) => {
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
window.api.recordingOpen(instanceName, a.name);
});
thumb.querySelector('.screen-del').addEventListener('click', (e) => {
e.stopPropagation();
askConfirm('Aufzeichnung löschen', `„${a.name}" löschen?`, '🗑 Löschen', async () => {
await window.api.recordingDelete(instanceName, a.name);
openRecordsList();
});
});
grid.appendChild(thumb);
}
updateRecordsSelectionUi();
}
async function deleteSelectedRecords() {
const names = [...recordSelected];
if (!names.length || !currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
const instanceName = inst ? inst.name : null;
if (!instanceName) return;
const label = names.length === 1
? `„${names[0]}" löschen?`
: `${names.length} Aufzeichnungen unwiderruflich löschen?`;
askConfirm('Aufzeichnungen löschen', label, '🗑 Löschen', async () => {
for (const name of names) {
await window.api.recordingDelete(instanceName, name);
}
toast(names.length === 1 ? 'Aufzeichnung gelöscht.' : names.length + ' Aufzeichnungen gelöscht.');
openRecordsList();
});
}
// ---------- Freunde ----------
let friendsAutoRefresh = null;
async function openFriends() {
if (POPOUT !== 'friends') { window.api.popoutOpen('friends'); return; }
show('modal-friends');
await ladeFreunde();
clearInterval(friendsAutoRefresh);
friendsAutoRefresh = setInterval(ladeFreunde, 30000);
}
async function ladeFreunde() {
const aktiv = await window.api.friendsEnabled();
el('friends-disabled-hint').classList.toggle('hidden', aktiv);
el('friends-content').classList.toggle('hidden', !aktiv);
if (!aktiv) return;
const [anfragenRes, gesendetRes, freundeRes, einstellungen] = await Promise.all([
window.api.friendsRequests(),
window.api.friendsSentRequests(),
window.api.friendsList(),
window.api.getSettings(),
]);
const spitznamen = einstellungen.friendNicknames || {};
const reqBox = el('friends-requests-box');
reqBox.innerHTML = '';
if (anfragenRes.ok && anfragenRes.list.length) {
const titel = document.createElement('div');
titel.className = 'friends-section-title';
titel.textContent = 'Anfragen';
reqBox.appendChild(titel);
for (const r of anfragenRes.list) {
const row = document.createElement('div');
row.className = 'friend-row';
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(r.uuid)}/64" alt="" />
<div class="friend-info"><div class="friend-name">${escapeHtml(r.username || r.uuid)}</div></div>
<button class="btn btn-mini" data-act="accept">Annehmen</button>
<button class="btn btn-mini btn-danger" data-act="decline">Ablehnen</button>`;
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
row.querySelector('[data-act="accept"]').addEventListener('click', async () => {
await window.api.friendsRespond(r.uuid, true);
ladeFreunde();
});
row.querySelector('[data-act="decline"]').addEventListener('click', async () => {
await window.api.friendsRespond(r.uuid, false);
ladeFreunde();
});
reqBox.appendChild(row);
}
}
if (gesendetRes.ok && gesendetRes.list.length) {
const titel = document.createElement('div');
titel.className = 'friends-section-title';
titel.textContent = 'Gesendet';
reqBox.appendChild(titel);
for (const r of gesendetRes.list) {
const row = document.createElement('div');
row.className = 'friend-row';
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(r.uuid)}/64" alt="" />
<div class="friend-info"><div class="friend-name">${escapeHtml(r.username || r.uuid)}</div><div class="friend-status">wartet auf Bestätigung</div></div>
<button class="btn btn-mini btn-danger" data-act="cancel">Zurückziehen</button>`;
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
row.querySelector('[data-act="cancel"]').addEventListener('click', async () => {
await window.api.friendsCancel(r.uuid);
ladeFreunde();
});
reqBox.appendChild(row);
}
}
const listBox = el('friends-list-box');
listBox.innerHTML = '<div class="friends-section-title">Freunde</div>';
if (!freundeRes.ok) {
listBox.innerHTML += `<div class="friends-empty">${escapeHtml(freundeRes.message || 'Nicht erreichbar.')}</div>`;
return;
}
if (!freundeRes.list.length) {
listBox.innerHTML += '<div class="friends-empty">Noch keine Freunde. Namen oben eingeben, um eine Anfrage zu senden.</div>';
return;
}
const sortiert = [...freundeRes.list].sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return (a.username || '').localeCompare(b.username || '');
});
for (const f of sortiert) {
const row = document.createElement('div');
row.className = 'friend-row';
const anzeigeName = spitznamen[f.uuid] || f.username || f.uuid;
const status = f.online
? (f.instance ? `spielt ${f.instance}${f.server ? ' · ' + f.server : ''}` : 'online')
: 'offline';
row.innerHTML = `<span class="friend-dot${f.online ? ' online' : ''}"></span>
<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(f.uuid)}/64" alt="" />
<div class="friend-info"><div class="friend-name">${escapeHtml(anzeigeName)}</div><div class="friend-status">${escapeHtml(status)}</div></div>
<button class="btn btn-mini" data-act="nick" title="Spitzname">✎</button>
<button class="btn btn-mini btn-danger" data-act="remove" title="Entfernen">🗑</button>`;
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
row.querySelector('[data-act="nick"]').addEventListener('click', async () => {
const wert = await askPrompt({
title: 'Spitzname',
label: `Eigener Spitzname für ${f.username || f.uuid} (leer lassen zum Entfernen)`,
value: spitznamen[f.uuid] || '',
okLabel: 'Speichern',
});
if (wert === null) return; // Abgebrochen
await window.api.friendsSetNickname(f.uuid, wert.trim());
ladeFreunde();
});
row.querySelector('[data-act="remove"]').addEventListener('click', () => {
askConfirm('Freund entfernen', `„${anzeigeName}" aus der Freundesliste entfernen?`, '🗑 Entfernen', async () => {
await window.api.friendsRemove(f.uuid);
ladeFreunde();
});
});
listBox.appendChild(row);
}
}
async function sendeFreundschaftsanfrage() {
const input = el('friends-add-input');
const name = input.value.trim();
if (!name) return;
const res = await window.api.friendsAdd(name);
if (res.ok) {
toast('Anfrage gesendet.');
input.value = '';
ladeFreunde();
} else {
toast(res.message || 'Anfrage fehlgeschlagen.', true);
}
}
function renderContentList(boxId, items, sub, emoji) {
const box = el(boxId);
box.innerHTML = '';
@@ -3846,6 +4118,7 @@ async function openSettings(pane) {
el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-friends').checked = s.friendsEnabled !== false;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -3974,6 +4247,7 @@ async function saveSettings() {
iso3dIcons: el('s-iso3d').checked,
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
friendsEnabled: el('s-friends').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
@@ -4629,6 +4903,16 @@ function wire() {
document.querySelectorAll('.btab').forEach((t) =>
t.addEventListener('click', () => switchBedrockTab(t.dataset.kind)));
// Freunde
el('btn-friends').addEventListener('click', openFriends);
el('friends-add-btn').addEventListener('click', sendeFreundschaftsanfrage);
el('friends-add-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendeFreundschaftsanfrage();
});
new MutationObserver(() => {
if (el('modal-friends').classList.contains('hidden')) { clearInterval(friendsAutoRefresh); friendsAutoRefresh = null; }
}).observe(el('modal-friends'), { attributes: true, attributeFilter: ['class'] });
// Konsole
el('btn-console').addEventListener('click', openConsole);
el('console-clear').addEventListener('click', () => { consoleBuffer = ''; el('console-out').textContent = ''; });
@@ -4846,7 +5130,14 @@ function wire() {
// Ordner-Buttons in den Inhalte-/Screenshots-Dialogen
el('content-open-worlds').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'saves'));
el('content-open-rp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'resourcepacks'));
el('screens-open').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'screenshots'));
el('screens-open').addEventListener('click', async () => {
if (screensAktiverTab === 'records') {
const inst = await window.api.getInstance(currentDetailId);
if (inst) window.api.recordingOpenFolder(inst.name);
} else {
window.api.openSubdir(currentDetailId, 'screenshots');
}
});
el('screens-select-all').addEventListener('click', () => {
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
if (t.dataset.name) screenSelected.add(t.dataset.name);
@@ -4858,6 +5149,19 @@ function wire() {
updateScreensSelectionUi();
});
el('screens-delete-sel').addEventListener('click', () => deleteSelectedScreenshots());
el('screens-tab-photos').addEventListener('click', () => switchScreensTab('photos'));
el('screens-tab-records').addEventListener('click', () => switchScreensTab('records'));
el('records-select-all').addEventListener('click', () => {
document.querySelectorAll('#records-grid .screen-thumb').forEach((t) => {
if (t.dataset.name) recordSelected.add(t.dataset.name);
});
updateRecordsSelectionUi();
});
el('records-select-none').addEventListener('click', () => {
recordSelected.clear();
updateRecordsSelectionUi();
});
el('records-delete-sel').addEventListener('click', () => deleteSelectedRecords());
el('content-open-dp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'datapacks'));
// Mod-Dialog
@@ -5172,6 +5476,7 @@ const POPOUT_OPENERS = {
}
},
record: () => openRecording(),
friends: () => openFriends(),
};
function popoutBoot(params) {
@@ -5209,7 +5514,22 @@ window.addEventListener('DOMContentLoaded', async () => {
applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
el('btn-friends').classList.toggle('hidden', s.friendsEnabled === false);
} catch { applyTheme('dark'); }
// Zahl offener Freundschaftsanfragen am "Freunde"-Knopf (nur Hauptfenster
// zeigt die Werkzeugleiste)
if (!POPOUT) {
const zeigeAnfragenZahl = (zahl) => {
const badge = el('friends-badge');
badge.textContent = zahl > 99 ? '99+' : String(zahl);
badge.classList.toggle('hidden', !zahl);
el('btn-friends').title = zahl
? `Freunde – ${zahl} offene Anfrage${zahl === 1 ? '' : 'n'} (ingame: Strg+Alt+F zeigt, wer online ist)`
: 'Freunde (ingame: Strg+Alt+F zeigt, wer online ist)';
};
window.api.onFriendsRequestCount(zeigeAnfragenZahl);
window.api.friendsRequestCount().then(zeigeAnfragenZahl).catch(() => {});
}
await refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
@@ -5221,8 +5541,14 @@ window.addEventListener('DOMContentLoaded', async () => {
window.api.onAuthCode(onAuthCode);
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await refreshSkinBackground(); });
window.api.onAuthHealth(showAuthHealth);
// Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen
window.api.onAccountsChanged(() => { refreshAllAccountLists(); });
// Spiel gestartet/beendet ODER das aktive Konto in einem ANDEREN Fenster
// gewechselt (separater Renderer-Prozess, siehe cross-window state) ->
// Kontenlisten, Kopfzeile und Skin-Hintergrund hier ebenfalls nachziehen
window.api.onAccountsChanged(async () => {
await refreshAllAccountLists();
await refreshAccountButton();
await refreshSkinBackground();
});
// über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen
// (bei einer Server-Verknüpfung zusätzlich direkt mit dem Server verbinden)
window.api.onLaunchRequest(async (payload) => {
@@ -5247,7 +5573,12 @@ window.addEventListener('DOMContentLoaded', async () => {
window.api.onSettingsChanged((s) => {
applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
if (!POPOUT) { applyBedrockVisibility(); refreshSkinBackground(); reload(); }
if (!POPOUT) {
applyBedrockVisibility();
refreshSkinBackground();
reload();
el('btn-friends').classList.toggle('hidden', s.friendsEnabled === false);
}
});
// Ein Skin-Wechsel oder eine neue/aktualisierte Instanz aus einem anderen
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
+2
View File
@@ -76,6 +76,8 @@ const DEFAULT_SETTINGS = {
iso3dIcons: true, // Block-Symbole räumlich darstellen
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
friendsEnabled: true, // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
discordClientId: '', // optional, nur per Hand in settings.json; leer => eingebaute AeroMC-App-ID
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
+24 -1
View File
@@ -208,9 +208,13 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
#modal-record.popout-host .modal-body{display:flex;flex-direction:column}
.popout-host .console-out,
#modal-logs.popout-host .crash-voll,
.popout-host .screens-grid,
.popout-host .mods-list,
.popout-host .record-stage{flex:1 1 auto;height:auto;max-height:none;min-height:0}
/* Screenshots/Aufzeichnungen NICHT auf die volle Fensterhöhe zwingen (Grid-
Zeilen strecken sich sonst nicht mit, es bliebe bei wenigen Einträgen ein
großer sichtbar leerer Kasten stehen) - nur die feste 460px-Deckelung
aufheben, damit bei VIELEN Einträgen trotzdem mehr Platz genutzt wird */
.popout-host .screens-grid{max-height:none}
/* Mods-Fenster: die aktive Ansicht (Katalog/Installiert) muss selbst ein
Flex-Container werden, damit ihre eigene Liste (.mods-list) die Regel oben
nutzen und den restlichen Platz füllen kann */
@@ -378,6 +382,25 @@ img.account-av{object-fit:cover;image-rendering:auto}
.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
.screens-toolbar .hint{font-size:11px}
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
.record-thumb-img-wrap{position:relative}
.record-thumb-img-wrap img{background:#000}
.record-thumb-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;text-shadow:0 1px 4px rgba(0,0,0,.7);pointer-events:none}
.record-thumb-size{font-size:10px;color:var(--dim);padding:0 6px 4px}
/* Freunde */
.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box}
#modal-friends .field-row{margin-bottom:14px}
#modal-friends .field-row input{flex:1}
.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto}
.friend-dot.online{background:#3bb54a}
.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain}
.friend-info{flex:1;min-width:0}
.friend-name{font-size:13px}
.friend-status{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.friends-empty{color:var(--dim);font-size:12px;padding:8px 4px}
/* Spielaufzeichnung */
.record-search{display:flex;flex-direction:column;align-items:center;gap:10px;padding:48px 0;color:var(--dim)}
@@ -0,0 +1,56 @@
<?php
/**
* Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, und verwaltet Freundschaftsanfragen. Keine eigene Anmeldung: der Launcher meldet sich direkt mit der Minecraft-UUID des jeweiligen Kontos.
* Version: 1.0.0
* Author: M_Viper
*/
if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei
}
define('AEROMC_FRIENDS_VERSION', '1.0.0');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
/**
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
*/
function aeromc_friends_install() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
// in includes/routes.php) - vermeidet doppelte Zeilen durch
// unterschiedliche Schreibweisen derselben UUID
$sql_presence = "CREATE TABLE $presence_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
instance_name varchar(128) DEFAULT NULL,
server_label varchar(128) DEFAULT NULL,
last_seen datetime NOT NULL,
PRIMARY KEY (uuid)
) $charset_collate;";
dbDelta($sql_presence);
$sql_friends = "CREATE TABLE $friends_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
uuid_a varchar(32) NOT NULL,
uuid_b varchar(32) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY uuid_a (uuid_a),
KEY uuid_b (uuid_b)
) $charset_collate;";
dbDelta($sql_friends);
}
register_activation_hook(__FILE__, 'aeromc_friends_install');
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
@@ -0,0 +1,303 @@
<?php
/**
* REST-Routen unter /wp-json/aeromc/v1/... für das Freundesystem.
*
* Bewusst OHNE echte Authentifizierung (Absprache mit dem Nutzer): der
* Launcher meldet sich einfach mit der Minecraft-UUID des aktiven Kontos an.
* Wer eine fremde UUID kennt, könnte sich theoretisch als dieser Spieler
* ausgeben - für den privaten Rahmen dieses Projekts akzeptiert. Eingaben
* werden trotzdem serverseitig geprüft (Format von UUID/Name) und jede
* Datenbankabfrage läuft ausschließlich über $wpdb->prepare, um SQL-Injection
* auszuschließen.
*/
if (!defined('ABSPATH')) {
exit;
}
// Konten gelten als "online", solange ihr letzter Herzschlag (der Launcher
// sendet alle 60s einen) nicht älter als das hier ist.
define('AEROMC_FRIENDS_ONLINE_SECONDS', 180);
// Häufigere Herzschläge als das werden ignoriert (Schutz gegen Missbrauch/
// versehentliche Anfrageflut) - der Launcher selbst sendet ohnehin nur alle 60s.
define('AEROMC_FRIENDS_HEARTBEAT_MIN_GAP', 20);
function aeromc_friends_presence_table() {
global $wpdb;
return $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
}
function aeromc_friends_friends_table() {
global $wpdb;
return $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
}
/** Minecraft-UUIDs kommen mal mit, mal ohne Bindestriche an - einheitlich
* ohne Bindestriche, klein geschrieben speichern/vergleichen. Gibt false
* zurück, wenn es keine gültige UUID (32 Hex-Zeichen) ist. */
function aeromc_friends_normalize_uuid($uuid) {
$stripped = strtolower(str_replace('-', '', (string) $uuid));
if (!preg_match('/^[a-f0-9]{32}$/', $stripped)) {
return false;
}
return $stripped;
}
/** Minecraft-Namensregeln: 1-16 Zeichen, Buchstaben/Ziffern/Unterstrich. */
function aeromc_friends_valid_username($name) {
return is_string($name) && preg_match('/^[A-Za-z0-9_]{1,16}$/', $name);
}
add_action('rest_api_init', function () {
register_rest_route('aeromc/v1', '/presence', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_presence',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_list',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/requests', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_requests',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/sent', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_sent',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/request', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_request',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/respond', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_respond',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/cancel', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cancel',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/remove', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_remove',
'permission_callback' => '__return_true',
]);
});
/** POST /presence {uuid, username, instance, server} */
function aeromc_friends_route_presence($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$username = $request->get_param('username');
if (!$uuid || !aeromc_friends_valid_username($username)) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/username.', ['status' => 400]);
}
$instance = sanitize_text_field((string) $request->get_param('instance'));
$server = sanitize_text_field((string) $request->get_param('server'));
$table = aeromc_friends_presence_table();
$letzte = $wpdb->get_var($wpdb->prepare(
"SELECT last_seen FROM $table WHERE uuid = %s", $uuid
));
if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) {
return ['ok' => true]; // zu häufig - stillschweigend ignoriert
}
// current_time('mysql', true) statt current_time('mysql'): das zweite
// Argument erzwingt GMT/UTC. Ohne das liefert current_time() die an die
// WordPress-Zeitzone angepasste ORTSZEIT (Einstellungen > Allgemein) -
// das online/offline-Fenster unten vergleicht last_seen aber per
// strtotime()/time() als UTC, wodurch ein Freund bei abweichender
// Zeitzone praktisch nie als offline erkannt worden wäre.
$jetzt_utc = current_time('mysql', true);
$wpdb->query($wpdb->prepare(
"INSERT INTO $table (uuid, username, instance_name, server_label, last_seen)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE username = %s, instance_name = %s, server_label = %s, last_seen = %s",
$uuid, $username, $instance ?: null, $server ?: null, $jetzt_utc,
$username, $instance ?: null, $server ?: null, $jetzt_utc
));
return ['ok' => true];
}
/** GET /friends?uuid= - akzeptierte Freunde inkl. Online-Status */
function aeromc_friends_route_list($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$friends_table = aeromc_friends_friends_table();
$presence_table = aeromc_friends_presence_table();
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT IF(f.uuid_a = %s, f.uuid_b, f.uuid_a) AS other_uuid
FROM $friends_table f
WHERE (f.uuid_a = %s OR f.uuid_b = %s) AND f.status = 'accepted'",
$uuid, $uuid, $uuid
));
$ergebnis = [];
foreach ($rows as $row) {
$p = $wpdb->get_row($wpdb->prepare(
"SELECT username, instance_name, server_label, last_seen FROM $presence_table WHERE uuid = %s",
$row->other_uuid
));
$online = $p && (time() - strtotime($p->last_seen)) < AEROMC_FRIENDS_ONLINE_SECONDS;
$ergebnis[] = [
'uuid' => $row->other_uuid,
'username' => $p ? $p->username : null,
'online' => (bool) $online,
'instance' => $online ? $p->instance_name : null,
'server' => $online ? $p->server_label : null,
];
}
return $ergebnis;
}
/** GET /friends/requests?uuid= - offene, an mich gerichtete Anfragen */
function aeromc_friends_route_requests($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$friends_table = aeromc_friends_friends_table();
$presence_table = aeromc_friends_presence_table();
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT f.uuid_a AS from_uuid, p.username AS from_username
FROM $friends_table f
LEFT JOIN $presence_table p ON p.uuid = f.uuid_a
WHERE f.uuid_b = %s AND f.status = 'pending'",
$uuid
));
return array_map(function ($r) {
return ['uuid' => $r->from_uuid, 'username' => $r->from_username];
}, $rows);
}
/** GET /friends/sent?uuid= - offene, von mir SELBST gesendete Anfragen */
function aeromc_friends_route_sent($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$friends_table = aeromc_friends_friends_table();
$presence_table = aeromc_friends_presence_table();
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT f.uuid_b AS to_uuid, p.username AS to_username
FROM $friends_table f
LEFT JOIN $presence_table p ON p.uuid = f.uuid_b
WHERE f.uuid_a = %s AND f.status = 'pending'",
$uuid
));
return array_map(function ($r) {
return ['uuid' => $r->to_uuid, 'username' => $r->to_username];
}, $rows);
}
/** POST /friends/request {uuid, username, targetUsername} */
function aeromc_friends_route_request($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$targetUsername = $request->get_param('targetUsername');
if (!$uuid || !aeromc_friends_valid_username($targetUsername)) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUsername.', ['status' => 400]);
}
$presence_table = aeromc_friends_presence_table();
$friends_table = aeromc_friends_friends_table();
$ziel = $wpdb->get_row($wpdb->prepare(
"SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername
));
if (!$ziel) {
return new WP_Error('aeromc_not_found', 'Dieser Name wurde noch nie im Launcher gesehen.', ['status' => 404]);
}
$ziel_uuid = $ziel->uuid;
if ($ziel_uuid === $uuid) {
return new WP_Error('aeromc_self', 'Du kannst dich nicht selbst hinzufügen.', ['status' => 400]);
}
$vorhanden = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $friends_table WHERE (uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)",
$uuid, $ziel_uuid, $ziel_uuid, $uuid
));
if ($vorhanden) {
return new WP_Error('aeromc_exists', 'Es gibt schon eine Anfrage oder Freundschaft.', ['status' => 409]);
}
$wpdb->insert($friends_table, [
'uuid_a' => $uuid,
'uuid_b' => $ziel_uuid,
'status' => 'pending',
'created_at' => current_time('mysql'),
]);
return ['ok' => true];
}
/** POST /friends/respond {uuid, otherUuid, accept} */
function aeromc_friends_route_respond($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$other = aeromc_friends_normalize_uuid($request->get_param('otherUuid'));
$accept = (bool) $request->get_param('accept');
if (!$uuid || !$other) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]);
}
$friends_table = aeromc_friends_friends_table();
if ($accept) {
$wpdb->query($wpdb->prepare(
"UPDATE $friends_table SET status = 'accepted' WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'",
$other, $uuid
));
} else {
$wpdb->query($wpdb->prepare(
"DELETE FROM $friends_table WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'",
$other, $uuid
));
}
return ['ok' => true];
}
/** POST /friends/cancel {uuid, otherUuid} - eine SELBST gesendete Anfrage zurückziehen */
function aeromc_friends_route_cancel($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$other = aeromc_friends_normalize_uuid($request->get_param('otherUuid'));
if (!$uuid || !$other) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]);
}
$friends_table = aeromc_friends_friends_table();
$wpdb->query($wpdb->prepare(
"DELETE FROM $friends_table WHERE uuid_a = %s AND uuid_b = %s AND status = 'pending'",
$uuid, $other
));
return ['ok' => true];
}
/** POST /friends/remove {uuid, otherUuid} */
function aeromc_friends_route_remove($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$other = aeromc_friends_normalize_uuid($request->get_param('otherUuid'));
if (!$uuid || !$other) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/otherUuid.', ['status' => 400]);
}
$friends_table = aeromc_friends_friends_table();
$wpdb->query($wpdb->prepare(
"DELETE FROM $friends_table WHERE ((uuid_a = %s AND uuid_b = %s) OR (uuid_a = %s AND uuid_b = %s)) AND status = 'accepted'",
$uuid, $other, $other, $uuid
));
return ['ok' => true];
}