Upload via GUI (50 Dateien)
This commit is contained in:
Generated
+95
-9
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
+22
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user