Upload via GUI (90 Dateien)

This commit is contained in:
2026-10-02 06:22:55 +00:00
parent 906ac75443
commit 47b8b88f6e
14 changed files with 12117 additions and 11013 deletions
+109
View File
@@ -0,0 +1,109 @@
# Changelog
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.1.4] - 2026-10-02
### Neu
- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen
- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an
- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu
- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt
- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse)
- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen
- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4
- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren
- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods)
- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick
### Geändert
- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an
- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20
### Behoben
- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte
## [1.1.3] - 2026-09-29
### Neu
- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status
- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster
- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server)
- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf
- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet
- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen
### Geändert
- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player
- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen
### Behoben
- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate
- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen
## [1.1.2] - 2026-09-27
### Neu
- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße
- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft
- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten
### Geändert
- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild
- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)")
### Behoben
- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein
- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst
## [1.0.9] - 2026-08-09
### Behoben
- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus
- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad
- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde
## [1.0.8] - 2026-08-08
### Neu
- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren
- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key
- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen
- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric)
- Lizenztext direkt in den Einstellungen unter Info einsehbar
### Geändert
- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE)
## [1.0.7] - 2026-08-06
### Neu
- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20)
- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben
### Behoben
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos
- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren
## [1.0.6] - 2026-08-05
### Neu
- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren
- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche
- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen
- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen)
- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird
- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version)
- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten
- Einzelne Welten lassen sich separat schnellsichern
- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht
- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln
- Optionaler Autostart mit Windows (im Infobereich statt als Fenster)
### Geändert
- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist
### Behoben
- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt
- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aeromc",
"version": "1.1.3",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.1.3",
"version": "1.1.4",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
+3 -7
View File
@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.1.3",
"version": "1.1.4",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
@@ -13,7 +13,7 @@
"start": "electron .",
"prebuild": "node scripts/write-build-config.js",
"prebuild:portable": "node scripts/write-build-config.js",
"build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"",
"build": "electron-builder --win dir && node scripts/build-setup.js",
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
},
"build": {
@@ -32,12 +32,8 @@
"node_modules/ffmpeg-static/**/*"
],
"win": {
"target": "nsis",
"target": "dir",
"icon": "src/assets/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
},
"devDependencies": {
+17 -2
View File
@@ -33,8 +33,8 @@ async function anfrage(pfad, { method = 'GET', body } = {}) {
}
}
function heartbeat({ uuid, username, instance, server }) {
return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server } });
function heartbeat({ uuid, username, instance, server, version }) {
return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server, version } });
}
function getFriends(uuid) {
@@ -65,6 +65,21 @@ function removeFriend(uuid, otherUuid) {
return anfrage('/friends/remove', { method: 'POST', body: { uuid, otherUuid } });
}
// Einladung "Komm auf meinen Server" (Adresse + Minecraft-Version des Einladenden)
function sendInvite(uuid, targetUuid, server, version) {
return anfrage('/friends/invite', { method: 'POST', body: { uuid, targetUuid, server, version } });
}
// an mich gerichtete, noch gültige Einladungen
function getInvites(uuid) {
return anfrage('/friends/invites', { method: 'GET', body: { uuid } });
}
function dismissInvite(uuid, id) {
return anfrage('/friends/invites/dismiss', { method: 'POST', body: { uuid, id } });
}
module.exports = {
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
sendInvite, getInvites, dismissInvite,
};
+58
View File
@@ -17,6 +17,7 @@
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
<button id="btn-console" class="tbtn" data-ic="terminal" title="Konsole / Log">Konsole</button>
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
<button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</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">
@@ -142,6 +143,13 @@
</label>
</div>
<!-- nur beim Anlegen einer Fabric-/Quilt-/NeoForge-Instanz -->
<label id="f-perf-row" class="checkbox-field hidden" style="margin:-2px 0 10px">
<input id="f-perf" type="checkbox" />
<span>Performance-Paket mitinstallieren
<small class="hint">Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling, ModernFix – für mehr FPS und kürzere Ladezeiten</small></span>
</label>
<div id="f-optifine-tools" class="field-row hidden" style="align-items:flex-start">
<div class="field" style="flex:1">
<span>OptiFine einrichten</span>
@@ -292,6 +300,10 @@
<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>
<label class="checkbox-field" style="margin-top:10px">
<input id="s-friends-server" type="checkbox" /> <span>Server, auf dem ich spiele, für Freunde sichtbar</span>
</label>
<small class="hint">Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.</small>
</section>
<section class="settings-card">
@@ -385,6 +397,14 @@
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
</div>
</section>
<section class="settings-card">
<h5>Mods</h5>
<label class="checkbox-field">
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span>
</label>
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
</section>
</div>
</div>
@@ -840,6 +860,24 @@
<span class="hint">Audio: <span id="record-audio-summary">Systemton</span></span>
<button id="record-audio-edit" class="btn btn-mini">Audioquellen wählen…</button>
</div>
<!-- Clip-Modus: läuft im Hintergrund mit, Tastenkürzel speichert die letzten Sekunden -->
<div id="clip-bar" class="clip-bar">
<label class="clip-switch" title="Nimmt im Hintergrund laufend auf und behält nur die letzten Sekunden">
<input id="clip-toggle" type="checkbox" />
<span class="clip-switch-track" aria-hidden="true"><span class="clip-switch-knob"></span></span>
<span class="clip-switch-label">Clip-Modus</span>
</label>
<label class="clip-length">
<span>Länge</span>
<select id="clip-length" title="Länge eines Clips">
<option value="30">30 Sek.</option>
<option value="60" selected>60 Sek.</option>
<option value="90">90 Sek.</option>
</select>
</label>
<span id="clip-status" class="clip-status">Speichert auf Tastendruck die letzten Sekunden – auch bei geschlossenem Fenster.</span>
<button id="clip-save" class="btn btn-mini" disabled>Clip speichern</button>
</div>
<div class="record-toolbar">
<div id="record-live-badge" class="record-live-badge hidden">
<span class="rec-dot"></span>
@@ -973,6 +1011,26 @@
</div>
</div>
<!-- ===================== Dialog: Spielzeit-Statistik ===================== -->
<div id="modal-stats" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<h3>Spielzeit-Statistik</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<div id="stats-tiles" class="stats-tiles"></div>
<div class="friends-section-title">Spielzeit pro Instanz</div>
<div id="stats-list" class="stats-list" role="list"></div>
<p id="stats-unplayed" class="hint"></p>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Freunde ===================== -->
<div id="modal-friends" class="modal hidden">
<div class="modal-card modal-sm">
+17 -2
View File
@@ -357,6 +357,13 @@ function buildArgs(vj, ctx) {
* auth: null (kein Login) ODER { name, uuid, accessToken, userType }
* onProgress: (evt) => void
*/
// true für Release-Versionen vor 1.20 (kein Quick Play). Snapshots und
// Unbekanntes gelten als neu – die meisten heutigen Snapshots sind > 1.20.
function vorQuickPlay(versionId) {
const m = /^1\.(\d+)(?:\.\d+)?(?:-(?:pre|rc)\d+)?$/.exec(String(versionId || '').trim());
return !!m && Number(m[1]) < 20;
}
async function prepareAndLaunch(opts, onProgress) {
const emit = (phase, extra) => onProgress && onProgress(Object.assign({ phase }, extra));
@@ -526,9 +533,17 @@ async function prepareAndLaunch(opts, onProgress) {
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
if (opts.mcMaximized) args.push('--fullscreen');
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
// Ab 1.20: direkt einem Server beitreten (Quick Play)
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
if (opts.quickPlayServer) {
args.push('--quickPlayMultiplayer', String(opts.quickPlayServer));
const adresse = String(opts.quickPlayServer).trim();
if (vorQuickPlay(opts.versionId)) {
const m = /^(.*?)(?::(\d+))?$/.exec(adresse);
args.push('--server', m[1]);
if (m[2]) args.push('--port', m[2]);
} else {
args.push('--quickPlayMultiplayer', adresse);
}
}
ensureDir(opts.gameDir);
+368 -32
View File
@@ -118,9 +118,30 @@ const POPOUT_SPEC = {
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 },
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, 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
// Ergebnis der letzten Update-Suche (auch "läuft gerade" oder Fehler) – Fenster, die
// erst später aufgehen (z. B. Einstellungen → Info), holen es sich hierüber ab
let letzterUpdateCheck = { checking: true };
// Hauptfenster hat seine Empfänger registriert – erst dann den Update-Hinweis schicken
const hauptfensterBereit = new Promise((resolve) => ipcMain.once('renderer:ready', resolve));
async function pruefeAufUpdates() {
letzterUpdateCheck = { checking: true };
broadcast('update:checked', letzterUpdateCheck);
try {
const info = await updater.checkForUpdate(app.getVersion());
pendingUpdateInfo = info;
letzterUpdateCheck = info;
} catch (err) {
// offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen
letzterUpdateCheck = { available: false, error: (err && err.message) || String(err) };
}
broadcast('update:checked', letzterUpdateCheck);
return letzterUpdateCheck;
}
// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt
// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen)
@@ -158,6 +179,9 @@ function openPopout(modalId, params) {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
// Clip-Modus nimmt auch bei geschlossenem (verstecktem) Fenster weiter auf –
// ohne das würde Chromium Zeichenschleife und Zeitgeber dort anhalten
backgroundThrottling: modalId !== 'record',
},
});
popoutWindows.set(modalId, win);
@@ -215,6 +239,9 @@ const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss a
// 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)
const kontoInstanz = new Map(); // kontoId -> instanceId des gerade laufenden Spiels (für "Einladen": eigener Server)
// Herzschläge aller laufenden Spiele – um eine geänderte Sichtbarkeit sofort zu melden
const laufendeHerzschlaege = new Set();
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
@@ -268,7 +295,6 @@ async function findeFensterUndBildschirm(pid, fensterTitel) {
const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0];
if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; }
console.log('[Aufzeichnung] gefunden:', JSON.stringify({ fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id }));
return {
fensterBounds,
bildschirmBounds: anzeige.bounds,
@@ -399,6 +425,77 @@ ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel, f
}
});
// ---------- Clip-Modus ----------
// Tastenkürzel wie bei ShadowPlay; ist es schon vergeben (z. B. von ShadowPlay
// selbst), weicht AeroMC auf Strg+Alt+C aus.
const CLIP_TASTENKUERZEL = ['Alt+F10', 'Control+Alt+C'];
let clipKuerzel = null;
let clipInstanz = null;
ipcMain.handle('clip:setActive', (_e, aktiv, instanceId) => {
if (clipKuerzel) { globalShortcut.unregister(clipKuerzel); clipKuerzel = null; }
clipInstanz = aktiv ? instanceId : null;
if (!aktiv) return null;
for (const kuerzel of CLIP_TASTENKUERZEL) {
const ok = globalShortcut.register(kuerzel, () => {
const pid = clipInstanz && laufendePid.get(clipInstanz);
if (pid) zeigeIngameOverlay('Clip', 'Wird gespeichert …', pid, 2500).catch(() => {});
const win = popoutWindows.get('record');
if (win && !win.isDestroyed()) win.webContents.send('clip:hotkey');
});
if (ok) { clipKuerzel = kuerzel; break; }
}
return clipKuerzel;
});
// Abschnitte zusammensetzen, vorne auf die Clip-Länge kürzen, als MP4 speichern
ipcMain.handle('clip:save', async (_e, daten) => {
const d = daten || {};
const abschnitte = Array.isArray(d.abschnitte) ? d.abschnitte : [];
if (!abschnitte.length) return { ok: false, message: 'Keine Clip-Daten.' };
if (!ffmpegPfad) return { ok: false, message: 'ffmpeg fehlt – Clips können nicht gespeichert werden.' };
const arbeitsOrdner = fs.mkdtempSync(path.join(app.getPath('temp'), 'aeromc-clip-'));
try {
const liste = abschnitte.map((buf, i) => {
const datei = path.join(arbeitsOrdner, `teil${i}.webm`);
fs.writeFileSync(datei, Buffer.from(buf));
return `file '${datei.replace(/\\/g, '/').replace(/'/g, "'\\''")}'`;
});
const listenDatei = path.join(arbeitsOrdner, 'liste.txt');
fs.writeFileSync(listenDatei, liste.join('\n'), 'utf8');
const zielOrdner = aufzeichnungsOrdner(d.instanceName);
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const serverTeil = d.serverLabel ? String(d.serverLabel).replace(/[^\w.-]+/g, '_') + '_' : '';
let zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}.mp4`);
let n = 2;
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}_${n}.mp4`); n++; }
await new Promise((resolve, reject) => {
execFile(ffmpegPfad, [
'-y', '-f', 'concat', '-safe', '0', '-i', listenDatei,
'-ss', String(Math.max(0, Number(d.vorneAbschneiden) || 0).toFixed(2)),
'-r', String(d.fps || 30), '-vsync', 'cfr',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '192k',
zielDatei,
], { maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message));
else resolve();
});
});
const pid = d.instanceId && laufendePid.get(d.instanceId);
if (pid) zeigeIngameOverlay('Clip gespeichert', `${d.laenge || ''} Sek. · ${path.basename(zielDatei)}`, pid, 5000).catch(() => {});
return { ok: true, path: zielDatei };
} catch (err) {
return { ok: false, message: 'Clip konnte nicht gespeichert werden: ' + err.message };
} finally {
fs.rmSync(arbeitsOrdner, { recursive: true, force: true });
}
});
ipcMain.handle('recording:openFolder', (_e, instanceName) => {
const dir = aufzeichnungsOrdner(instanceName);
fs.mkdirSync(dir, { recursive: true });
@@ -612,6 +709,33 @@ function createWindow(startHidden) {
if (startId) {
ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
}
ipcMain.once('renderer:ready', () => oeffneVorbereitetenImport());
}
/*
* Das Setup kann optional andere Launcher übernehmen lassen (installer/Installer.cs):
* es legt dazu pending-import.json mit den angekreuzten Launchern ab. Beim ersten
* sichtbaren Start öffnet sich der Import-Dialog damit schon fertig durchsucht –
* übernommen wird erst, was man dort bestätigt. Die Datei gilt nur einmal.
*/
function oeffneVorbereitetenImport() {
const datei = path.join(app.getPath('userData'), 'pending-import.json');
if (!fs.existsSync(datei)) return;
// im Autostart unsichtbar gestartet: erst anbieten, wenn das Fenster gezeigt wird
if (mainWindow && !mainWindow.isVisible()) {
mainWindow.once('show', () => oeffneVorbereitetenImport());
return;
}
let quellen = [];
try {
const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, ''));
quellen = (Array.isArray(daten.sources) ? daten.sources : [])
.filter((q) => q && ['mc', 'cf', 'mmc', 'technic'].includes(q.kind))
.map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' }));
} catch { /* beschädigt -> verwerfen */ }
try { fs.unlinkSync(datei); } catch { /* egal */ }
if (!quellen.length) return;
openPopout('import', { kind: quellen[0].kind, vorauswahl: JSON.stringify(quellen) });
}
let tray = null;
@@ -719,24 +843,20 @@ app.whenReady().then(() => {
// befindliche Freund beim Start fälschlich als "gerade online gegangen" gelten).
setTimeout(() => { pruefeFreundeOnline().catch(() => {}); }, 15000);
setInterval(() => { pruefeFreundeOnline().catch(() => {}); }, 60000);
// Einladungen ("Komm auf meinen Server") öfter prüfen – sie gelten nur ein paar Minuten
setTimeout(() => { pruefeEinladungen().catch(() => {}); }, 8000);
setInterval(() => { pruefeEinladungen().catch(() => {}); }, 15000);
// 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 {
const info = await updater.checkForUpdate(app.getVersion());
pendingUpdateInfo = info;
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
broadcast('update:checked', info);
if (info.available) broadcast('update:available', info);
} catch (err) {
// offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen
broadcast('update:checked', { error: (err && err.message) || String(err) });
}
}, 4000);
// gleich beim Start auf Updates prüfen; den Hinweis-Dialog aber erst zeigen,
// wenn das Hauptfenster bereit ist (sonst ginge er verloren)
pruefeAufUpdates().then(async (info) => {
if (!info.available) return;
await hauptfensterBereit;
broadcast('update:available', info);
}).catch(() => {});
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
setTimeout(() => checkAuthServices(), 2500);
@@ -793,6 +913,10 @@ ipcMain.handle('settings:save', (_e, patch) => {
aktualisiereFreundeTastenkuerzel();
aktualisiereAnfragenZahl().catch(() => {});
}
// Server ein-/ausgeblendet -> Freunden gleich melden, nicht erst beim nächsten Herzschlag
if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsShareServer')) {
for (const herzschlag of laufendeHerzschlaege) herzschlag();
}
// 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
@@ -850,7 +974,11 @@ ipcMain.handle('mods:search', async (_e, opts) => {
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
ipcMain.handle('mods:install', async (_e, id, project, art) => {
ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art));
// installiert ein Projekt (Modrinth oder CurseForge) samt Pflicht-Abhängigkeiten –
// auch vom Performance-Paket beim Anlegen einer Instanz genutzt
async function installiereInhalt(id, project, art) {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
@@ -923,6 +1051,44 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
await installOne(project, false);
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
}
/*
* Performance-Paket: bewährte Optimierungs-Mods, per Häkchen beim Anlegen einer
* Instanz mitinstalliert. Feste Modrinth-IDs statt Namen, damit sie sich mit den
* automatisch nachgezogenen Abhängigkeiten nicht doppeln. Was es für Version
* oder Loader (noch) nicht gibt, wird einfach übersprungen.
*/
const PERFORMANCE_MODS = [
{ projectId: 'AANobbMI', title: 'Sodium' }, // Grafik/FPS
{ projectId: 'gvQqBUqZ', title: 'Lithium' }, // Spiellogik
{ projectId: 'uXXizFIs', title: 'FerriteCore' }, // Arbeitsspeicher
{ projectId: '5ZwdcRci', title: 'ImmediatelyFast' }, // Darstellung (HUD, Text)
{ projectId: 'NNAgCjsB', title: 'Entity Culling' }, // verdeckte Objekte nicht zeichnen
{ projectId: 'nmDcB62a', title: 'ModernFix' }, // Ladezeit/Speicher
];
const PERFORMANCE_LOADER = new Set(['fabric', 'quilt', 'neoforge']);
ipcMain.handle('instances:installPerformancePack', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!PERFORMANCE_LOADER.has(inst.minecraft.loader)) {
return { ok: false, message: 'Das Performance-Paket gibt es für Fabric, Quilt und NeoForge.' };
}
const installiert = [];
const fehlend = [];
broadcast('ui:toast', { text: `Performance-Paket für „${inst.name}" wird installiert …` });
for (const mod of PERFORMANCE_MODS) {
const res = await installiereInhalt(id, { projectId: mod.projectId, title: mod.title }, 'mod').catch((err) => ({ ok: false, message: err.message }));
if (res.ok) installiert.push(mod.title);
else fehlend.push(mod.title);
}
broadcast('mods:changed', { id });
broadcast('ui:toast', installiert.length
? { text: `Performance-Paket: ${installiert.length} von ${PERFORMANCE_MODS.length} Mods installiert`
+ (fehlend.length ? ` (für diese Version nicht verfügbar: ${fehlend.join(', ')})` : '') + '.' }
: { text: 'Performance-Paket: Für diese Version/diesen Loader gibt es die Mods (noch) nicht.', error: true });
return { ok: true, installiert, fehlend };
});
/*
@@ -1214,7 +1380,9 @@ ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
});
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
ipcMain.handle('mods:applyUpdates', (_e, id, projectIds, art) => wendeModUpdatesAn(id, projectIds, art));
async function wendeModUpdatesAn(id, projectIds, art) {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
@@ -1243,7 +1411,29 @@ ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
}
}
return { ok: true, erneuert, fehler };
});
}
/*
* Automatische Mod-Updates vor dem Start (Einstellung autoModUpdates): sucht
* für die über Modrinth installierten Mods nach neueren Fassungen und spielt
* sie ein. Begrenzt auf eine Höchstdauer – ein langsamer Server soll den
* Spielstart nicht minutenlang aufhalten; dann wird eben ohne Update gestartet.
*/
async function modUpdatesVorDemStart(id) {
const inst = store.getInstance(id);
if (!inst || !inst.minecraft.version || !inst.minecraft.loader || inst.minecraft.loader === 'vanilla') return null;
const mods = store.listMods(id, 'mod');
if (!mods.some((m) => m.projectId)) return null;
broadcast('launch:progress', { id, phase: 'mod-updates', detail: 'Suche nach Mod-Updates …' });
const suche = services.modrinthFindUpdates(mods, inst.minecraft.version, inst.minecraft.loader, null, 'mod');
const zeitlimit = new Promise((resolve) => setTimeout(() => resolve(null), 20000));
const gefunden = await Promise.race([suche, zeitlimit]).catch(() => null);
if (!gefunden || !gefunden.treffer || !gefunden.treffer.length) return null;
broadcast('launch:progress', { id, phase: 'mod-updates', detail: `${gefunden.treffer.length} Mod-Update(s) werden installiert …` });
const res = await wendeModUpdatesAn(id, gefunden.treffer.map((t) => t.projectId), 'mod');
broadcast('mods:changed', { id });
return res;
}
// ---------------------------------------------------------------------------
// IPC – Microsoft-Login (Phase 3)
@@ -1451,6 +1641,7 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
// 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';
const EINLADUNG_TASTENKUERZEL = 'Control+Alt+J';
let freundeTastenkuerzelAktiv = false;
function aktualisiereFreundeTastenkuerzel() {
@@ -1459,21 +1650,31 @@ function aktualisiereFreundeTastenkuerzel() {
freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => {
zeigeOnlineFreundeIngame().catch(() => {});
});
globalShortcut.register(EINLADUNG_TASTENKUERZEL, () => {
nimmEinladungIngameAn().catch(() => {});
});
} else if (!soll && freundeTastenkuerzelAktiv) {
globalShortcut.unregister(FREUNDE_TASTENKUERZEL);
globalShortcut.unregister(EINLADUNG_TASTENKUERZEL);
freundeTastenkuerzelAktiv = false;
}
}
// Konto + Fenster-PID des Spiels im Vordergrund (bei nur einem laufenden Spiel: dieses)
function vordergrundKonto() {
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];
return eintrag || null;
}
// 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];
const eintrag = vordergrundKonto();
if (!eintrag) return;
const [kontoUuid, pid] = eintrag;
@@ -1495,6 +1696,125 @@ async function zeigeOnlineFreundeIngame() {
);
}
// ---------- Einladungen: "Komm auf meinen Server" ----------
// Eine Einladung enthält Server-Adresse und Minecraft-Version des Einladenden.
// Im Launcher: Hinweis + "Beitreten" im Freunde-Fenster (startet eine passende
// Instanz per Quick Play). Im Spiel: Toast auf dem richtigen Fenster; ein
// laufendes Minecraft lässt sich von außen nicht auf einen anderen Server
// umlenken – Strg+Alt+J kopiert deshalb die Adresse für "Direkt verbinden".
const gezeigteEinladungen = new Set(); // Einladungs-IDs, die schon gemeldet wurden
const letzteEinladung = new Map(); // kontoUuid -> zuletzt erhaltene Einladung (für Strg+Alt+J)
async function pruefeEinladungen() {
if (store.getSettings().friendsEnabled === false) return;
const aktiv = aktivesFreundeKonto();
const konten = new Set([...kontoPid.keys()]);
if (aktiv) konten.add(aktiv.uuid);
for (const kontoUuid of konten) {
const res = await friends.getInvites(kontoUuid);
if (!res.ok) continue;
for (const einl of res.data || []) {
if (gezeigteEinladungen.has(einl.id)) continue;
gezeigteEinladungen.add(einl.id);
letzteEinladung.set(kontoUuid, einl);
const pid = kontoPid.get(kontoUuid);
if (pid) {
await zeigeIngameOverlay('Einladung', [
{ uuid: einl.fromUuid, text: `${einl.fromUsername || 'Ein Freund'} lädt dich ein · ${einl.server}` },
'Strg+Alt+J: Adresse kopieren',
], pid, 10000);
}
if (aktiv && aktiv.uuid === kontoUuid) broadcast('friends:invite', einl);
}
}
}
// Strg+Alt+J im Spiel: Adresse der letzten Einladung in die Zwischenablage
async function nimmEinladungIngameAn() {
const eintrag = vordergrundKonto();
if (!eintrag) return;
const [kontoUuid, pid] = eintrag;
const einl = letzteEinladung.get(kontoUuid);
if (!einl) { await zeigeIngameOverlay('Einladung', 'Gerade keine offene Einladung', pid, 4000); return; }
clipboard.writeText(einl.server);
await zeigeIngameOverlay('Adresse kopiert', [
einl.server,
'Mehrspieler → Direkt verbinden → Strg+V',
], pid, 9000);
}
// Passende eigene Instanz für einen Server: gleiche Version und den Server schon
// in der Liste > gleiche Version > (Version unbekannt) Server in der Liste.
// Jeweils die zuletzt gespielte.
function instanzFuerServer(server, version) {
const norm = (a) => String(a || '').trim().toLowerCase().replace(/:25565$/, '');
const ziel = norm(server);
const neuesteZuerst = (liste) => [...liste].sort((a, b) => String(b.lastPlayed || '').localeCompare(String(a.lastPlayed || '')));
const kenntServer = (inst) => {
try {
const r = servers.lies(store.gameDir(inst.id));
return r.ok && r.server.some((x) => norm(x.ip) === ziel);
} catch { return false; }
};
const alle = store.listInstances().filter((i) => i.minecraft && i.minecraft.version);
const passend = version ? alle.filter((i) => i.minecraft.version === version) : alle;
return neuesteZuerst(passend.filter(kenntServer))[0]
|| (version ? neuesteZuerst(passend)[0] : null)
|| null;
}
ipcMain.handle('friends:join', (_e, server, version) => {
if (!server) return { ok: false, message: 'Kein Server bekannt.' };
const inst = instanzFuerServer(server, version);
if (!inst) return { ok: false, noInstance: true, version: version || '' };
showMainWindow();
starteInstanzImFenster(inst.id, server);
return { ok: true, instanceName: inst.name };
});
// keine passende Instanz: neue Vanilla-Instanz mit der Version des Freundes anlegen und beitreten
ipcMain.handle('friends:joinNew', (_e, server, version) => {
if (!server || !version) return { ok: false, message: 'Server oder Version unbekannt.' };
const inst = store.createInstance({ name: `${server} (${version})`, minecraft: { version, loader: 'vanilla' } });
broadcast('instances:changed');
showMainWindow();
starteInstanzImFenster(inst.id, server);
return { ok: true, instanceName: inst.name };
});
// eigener aktueller Server (des aktiven Kontos), falls es gerade auf einem spielt
function eigenerServer() {
const konto = aktivesFreundeKonto();
const instId = konto && kontoInstanz.get(konto.uuid);
const server = instId && letzteQuickPlayServer.get(instId);
if (!server) return null;
const inst = store.getInstance(instId);
return { konto, server, version: (inst && inst.minecraft.version) || '' };
}
ipcMain.handle('friends:myServer', () => {
const e = eigenerServer();
return e ? { server: e.server, version: e.version } : null;
});
ipcMain.handle('friends:invite', async (_e, targetUuid) => {
const e = eigenerServer();
if (!e) return { ok: false, message: 'Du bist gerade auf keinem Server – erst einem Server beitreten.' };
return friends.sendInvite(e.konto.uuid, targetUuid, e.server, e.version);
});
ipcMain.handle('friends:invites', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getInvites(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res;
});
ipcMain.handle('friends:dismissInvite', async (_e, inviteId) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false };
return friends.dismissInvite(konto.uuid, inviteId);
});
// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
let letzteAnfragenZahl = 0;
async function aktualisiereAnfragenZahl() {
@@ -2602,6 +2922,10 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
await backupNachZeitplan('vor-dem-start').catch(() => {});
}
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
let modUpdates = null;
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
const opts = {
versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla',
@@ -2644,6 +2968,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
const kontoId = account && account.profile ? account.profile.id : null;
let gezaehlt = false;
let freundeHerzschlag = null;
let herzschlagJetzt = null; // sofort melden, z. B. sobald ein Server erkannt wurde
try {
let launchStart = 0;
@@ -2665,9 +2990,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
uuid: account.profile.id,
username: account.profile.name,
instance: inst.name,
server: letzteQuickPlayServer.get(id) || '',
// nur, wenn man seinen Server zeigen möchte (Einstellung, live gelesen)
server: store.getSettings().friendsShareServer === false ? '' : (letzteQuickPlayServer.get(id) || ''),
// für "Beitreten": damit Freunde eine passende Instanz wählen können
version: inst.minecraft.version || '',
}).catch(() => {});
};
herzschlagJetzt = sendeHerzschlag;
laufendeHerzschlaege.add(sendeHerzschlag);
sendeHerzschlag();
freundeHerzschlag = setInterval(sendeHerzschlag, 60000);
}
@@ -2676,6 +3006,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
discordpresence.clearActivity();
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
if (herzschlagJetzt) laufendeHerzschlaege.delete(herzschlagJetzt);
herzschlagJetzt = null;
if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId);
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
@@ -2696,6 +3029,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
broadcast('recording:serverDetected', { id, server });
// Freunde sollen den Server gleich sehen (für "Beitreten"), nicht erst beim nächsten Herzschlag
if (herzschlagJetzt) herzschlagJetzt();
}
}
broadcast('launch:progress', Object.assign({ id }, evt));
@@ -2704,13 +3039,19 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// 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 && herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
if (res.ok) {
const aktuell = store.getInstance(id);
store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 });
}
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
if (res.ok && res.pid) laufendePid.set(id, res.pid);
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); aktualisiereFreundeTastenkuerzel(); }
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
}
});
@@ -2973,13 +3314,8 @@ ipcMain.handle('icons:extract', () => {
// IPC – Automatische Updates
// ---------------------------------------------------------------------------
ipcMain.handle('update:check', async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
pendingUpdateInfo = info;
return info;
} catch (err) { return { available: false, error: err.message }; }
});
ipcMain.handle('update:check', () => pruefeAufUpdates());
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => {
+25
View File
@@ -72,6 +72,7 @@ contextBridge.exposeInMainWorld('api', {
listInstances: () => ipcRenderer.invoke('instances:list'),
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
@@ -225,6 +226,7 @@ contextBridge.exposeInMainWorld('api', {
// Updates
updateCheck: () => ipcRenderer.invoke('update:check'),
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
onUpdateAvailable: (cb) => {
const l = (_e, d) => cb(d);
@@ -297,6 +299,29 @@ contextBridge.exposeInMainWorld('api', {
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
onClipHotkey: (cb) => {
const l = () => cb();
ipcRenderer.on('clip:hotkey', l);
return () => ipcRenderer.removeListener('clip:hotkey', l);
},
onUiToast: (cb) => {
const l = (_e, t) => cb(t);
ipcRenderer.on('ui:toast', l);
return () => ipcRenderer.removeListener('ui:toast', l);
},
friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version),
friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version),
friendsMyServer: () => ipcRenderer.invoke('friends:myServer'),
friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid),
friendsInvites: () => ipcRenderer.invoke('friends:invites'),
friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id),
onFriendsInvite: (cb) => {
const l = (_e, einladung) => cb(einladung);
ipcRenderer.on('friends:invite', l);
return () => ipcRenderer.removeListener('friends:invite', l);
},
onFriendsRequestCount: (cb) => {
const l = (_e, zahl) => cb(zahl);
ipcRenderer.on('friends:requestCount', l);
+361 -13
View File
@@ -547,6 +547,18 @@ let cfFound = [];
let mcFound = [];
let technicFound = [];
let importKind = 'zip';
// Nach der Installation: im Setup ausgewählte Launcher, die noch angeboten werden
let importWarteschlange = [];
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
function importAbgeschlossen(kind) {
importWarteschlange = importWarteschlange.filter((k) => k !== kind);
if (importWarteschlange.length) {
setImportKind(importWarteschlange[0]);
return;
}
hide('modal-import');
}
async function ensureDefaultMcScan() {
if (importKind !== 'mc') return;
@@ -573,8 +585,9 @@ function setImportKind(kind) {
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
}
function openImportDialog(kind = 'zip') {
function openImportDialog(kind = 'zip', vorauswahl = null) {
if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; }
importWarteschlange = [];
mcFound = [];
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
@@ -599,6 +612,25 @@ function openImportDialog(kind = 'zip') {
}
if (importKind === 'cf') el('cf-path').focus();
if (importKind === 'mmc') el('mmc-path').focus();
if (Array.isArray(vorauswahl) && vorauswahl.length) uebernehmeSetupVorauswahl(vorauswahl);
}
/*
* Vom Setup vorbereitet: die dort angekreuzten Launcher (Art + Ordner) gleich
* eintragen und durchsuchen, damit man beim ersten Start nur noch auswählt.
*/
function uebernehmeSetupVorauswahl(quellen) {
const scanner = { mc: scanMc, cf: scanCf, mmc: scanMmc, technic: scanTechnic };
importWarteschlange = quellen.map((q) => q && q.kind).filter((k) => scanner[k]);
for (const q of quellen) {
if (!q || !scanner[q.kind]) continue;
if (q.path) el(`${q.kind}-path`).value = q.path;
scanner[q.kind]().catch(() => { /* Status zeigt den Fehler im jeweiligen Reiter */ });
}
if (importWarteschlange.length) {
setImportKind(importWarteschlange[0]);
toast('Aus deinen anderen Launchern gefunden – wähle aus, was AeroMC übernehmen soll.');
}
}
async function runZipImport() {
@@ -812,7 +844,7 @@ async function runMcImport() {
const res = await window.api.mcImport(entries, copy);
await reload();
hide('modal-import');
importAbgeschlossen('mc');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
@@ -882,7 +914,7 @@ async function runCfImport() {
const res = await window.api.cfImport(entries, copy);
await reload();
hide('modal-import');
importAbgeschlossen('cf');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
@@ -952,7 +984,7 @@ async function runMmcImport() {
const res = await window.api.mmcImport(entries, copy);
await reload();
hide('modal-import');
importAbgeschlossen('mmc');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
@@ -1078,7 +1110,7 @@ async function runTechnicImport() {
const res = await window.api.technicImport(entries, copy);
await reload();
hide('modal-import');
importAbgeschlossen('technic');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
@@ -1517,6 +1549,7 @@ const ICONS_SVG = {
copy: '<path d="M9 9h10v10H9zM5 15V5h10"/>',
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
};
function svgIcon(name) {
const p = ICONS_SVG[name];
@@ -1943,6 +1976,8 @@ async function openCreate() {
el('f-loader').value = 'vanilla';
el('f-loader-version').value = '';
el('f-notes').value = '';
el('f-perf').checked = false;
aktualisierePerformanceZeile();
fillIconSelect('grass');
resetIconControls();
el('f-advanced').classList.add('hidden');
@@ -1966,6 +2001,7 @@ async function openEdit(inst) {
el('f-loader').value = inst.minecraft.loader || 'vanilla';
el('f-loader-version').value = inst.minecraft.loaderVersion || '';
el('f-notes').value = inst.notes || '';
aktualisierePerformanceZeile();
fillIconSelect(inst.icon);
resetIconControls();
if (inst.customImage) {
@@ -1990,6 +2026,12 @@ async function openEdit(inst) {
await updateLoaderVersions(inst.minecraft.loaderVersion);
}
// Performance-Paket nur beim Anlegen und nur für Loader, für die es die Mods gibt
const PERFORMANCE_LOADER = ['fabric', 'quilt', 'neoforge'];
function aktualisierePerformanceZeile() {
el('f-perf-row').classList.toggle('hidden', !!editingId || !PERFORMANCE_LOADER.includes(el('f-loader').value));
}
async function saveInstance() {
const name = el('f-name').value.trim();
if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; }
@@ -2029,6 +2071,11 @@ async function saveInstance() {
} else {
const created = await window.api.createInstance(payload);
targetId = created.id;
// läuft im Hauptprozess weiter, auch wenn dieses Fenster gleich zugeht –
// das Ergebnis meldet das Hauptfenster
if (el('f-perf').checked && PERFORMANCE_LOADER.includes(payload.minecraft.loader)) {
window.api.installPerformancePack(targetId).catch(() => {});
}
}
// eigenes Bild anwenden bzw. entfernen
if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath);
@@ -2060,7 +2107,7 @@ async function launch(id, accountId, launchOpts) {
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
if (res.ok) {
status('Läuft (PID ' + res.pid + ')');
toast('Minecraft gestartet.');
toast(res.modUpdates ? `Minecraft gestartet – ${res.modUpdates} Mod${res.modUpdates === 1 ? '' : 's'} vorher aktualisiert.` : 'Minecraft gestartet.');
const s = await window.api.getSettings();
if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht
} else if (res.needAuth) {
@@ -2585,7 +2632,7 @@ async function zeigeAbsturzbericht(instanzId) {
// Fortschrittsmeldungen der Download-/Start-Pipeline
function onLaunchEvt(evt) {
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' };
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung', 'mod-updates': 'Mod-Updates' };
if (evt.phase === 'log') {
appendConsole(evt.line);
return;
@@ -2805,6 +2852,8 @@ function speichereRecordEinstellungen() {
async function openRecording() {
if (POPOUT !== 'record') { window.api.popoutOpen('record', { instanceId: currentDetailId }); return; }
// Clip-Modus läuft im Hintergrund weiter -> Fenster nur wieder zeigen, Erfassung nicht neu starten
if (clipAktiv) { show('modal-record'); return; }
await ladeRecordEinstellungen();
await aktualisiereRecordTitel();
// beim erneuten Öffnen (popout:reopen) eine evtl. noch offene Vorschau
@@ -3047,7 +3096,7 @@ function merkeTatsaechlicheGroesse(stream) {
// Bild mit den neuen Grenzwerten neu erfassen (nur möglich, solange nicht
// aufgenommen wird, siehe Sperre der Auswahlfelder in updateVideoSummary)
async function wendeVideoEinstellungenAn() {
if (!mcCurrentSource || !mcVideoStream || recordingActive()) return;
if (!mcCurrentSource || !mcVideoStream || recordingActive() || clipAktiv) return;
stoppeVideoErfassung();
mcVideoStream = await erfasseFensterVideo(mcCurrentSource);
merkeTatsaechlicheGroesse(mcVideoStream);
@@ -3059,7 +3108,7 @@ function updateVideoSummary() {
const groesse = mcActualSize ? ` (${mcActualSize.width}×${mcActualSize.height})` : '';
el('record-video-summary').textContent =
RESOLUTION_LABEL[mcResolution] + groesse + ' · ' + QUALITAET_LABEL[mcQuality] + ' · ' + mcFramerate + ' fps';
const aktiv = recordingActive();
const aktiv = recordingActive() || clipAktiv;
el('record-resolution').disabled = aktiv;
el('record-quality').disabled = aktiv;
el('record-fps').disabled = aktiv;
@@ -3335,6 +3384,138 @@ async function onMcRecordingStopped() {
mcRecorder = null;
}
// ---------- Clip-Modus ----------
// Nimmt laufend in kurzen, jeweils eigenständigen WebM-Abschnitten auf (jeder
// beginnt mit Kopfdaten + Schlüsselbild, lässt sich also einzeln abspielen) und
// behält nur so viele, wie für die gewählte Clip-Länge nötig sind. Beim
// Speichern setzt ffmpeg die Abschnitte zusammen und schneidet vorne auf die
// gewünschte Länge zu. Normale Aufnahme und Clip-Modus schließen sich aus
// (beide teilen sich Tonmischung und Bildquelle).
const CLIP_ABSCHNITT_MS = 10000;
let clipAktiv = false;
let clipStream = null;
let clipAbschnitte = []; // fertige Abschnitte { start, teile, fertig }
let clipLaufend = null; // gerade aufnehmender Abschnitt
let clipWechsel = null;
let clipSpeichert = false;
function clipLaenge() { return Number(el('clip-length').value) || 60; }
function neuerClipAbschnitt(mime, bitrate) {
const teile = [];
const rec = new MediaRecorder(clipStream, { mimeType: mime, videoBitsPerSecond: bitrate });
const abschnitt = { start: Date.now(), teile, rec, ende: null };
abschnitt.fertig = new Promise((resolve) => { rec.onstop = () => { abschnitt.ende = Date.now(); resolve(); }; });
rec.ondataavailable = (e) => { if (e.data && e.data.size) teile.push(e.data); };
rec.start(1000);
return abschnitt;
}
async function starteClipModus() {
if (!mcVideoStream || recordingActive() || clipAktiv) return;
mcAudioTrack = await buildCombinedAudioTrack();
clipStream = new MediaStream([
...mcVideoStream.getVideoTracks(),
...(mcAudioTrack ? [mcAudioTrack] : []),
]);
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus' : 'video/webm;codecs=vp8,opus';
const bitrate = QUALITAETEN[mcQuality];
clipAbschnitte = [];
clipLaufend = neuerClipAbschnitt(mime, bitrate);
clipWechsel = setInterval(() => {
// erst den neuen Abschnitt starten, dann den alten beenden -> keine Lücke
const alt = clipLaufend;
clipLaufend = neuerClipAbschnitt(mime, bitrate);
alt.rec.stop();
clipAbschnitte.push(alt);
// nur behalten, was für die längste mögliche Clip-Länge noch gebraucht wird
const grenze = Date.now() - (clipLaenge() * 1000 + CLIP_ABSCHNITT_MS * 2);
clipAbschnitte = clipAbschnitte.filter((a) => (a.ende || Date.now()) >= grenze);
}, CLIP_ABSCHNITT_MS);
clipAktiv = true;
const kuerzel = await window.api.clipSetActive(true, currentDetailId);
el('clip-toggle').checked = true;
el('clip-save').disabled = false;
el('clip-length').disabled = true;
el('record-toggle').disabled = true;
el('record-toggle').title = 'Während der Clip-Modus läuft, ist keine normale Aufnahme möglich.';
el('record-audio-edit').disabled = true;
el('record-video-edit').disabled = true;
el('clip-status').textContent = kuerzel
? `Läuft · ${kuerzel.replace('Control', 'Strg')} speichert die letzten ${clipLaenge()} Sek. – auch im Spiel`
: `Läuft · „Clip speichern“ sichert die letzten ${clipLaenge()} Sek. (kein Tastenkürzel frei)`;
el('clip-bar').classList.add('aktiv');
updateVideoSummary();
}
function stoppeClipModus() {
if (!clipAktiv) return;
clearInterval(clipWechsel);
clipWechsel = null;
try { if (clipLaufend && clipLaufend.rec.state !== 'inactive') clipLaufend.rec.stop(); } catch { /* egal */ }
clipLaufend = null;
clipAbschnitte = [];
clipStream = null;
cleanupAudioMix();
mcAudioTrack = null;
clipAktiv = false;
window.api.clipSetActive(false, currentDetailId);
el('clip-toggle').checked = false;
el('clip-save').disabled = true;
el('clip-length').disabled = false;
el('record-toggle').disabled = false;
el('record-toggle').title = '';
el('record-audio-edit').disabled = false;
el('record-video-edit').disabled = false;
el('clip-status').textContent = 'Speichert auf Tastendruck die letzten Sekunden – auch bei geschlossenem Fenster.';
el('clip-bar').classList.remove('aktiv');
updateVideoSummary();
}
async function speichereClip() {
if (!clipAktiv || clipSpeichert || !clipLaufend) return;
clipSpeichert = true;
el('clip-save').disabled = true;
el('clip-save').textContent = 'Wird gespeichert…';
try {
const jetzt = Date.now();
const laenge = clipLaenge();
const laufend = clipLaufend;
// Daten des laufenden Abschnitts bis jetzt einsammeln
await new Promise((resolve) => {
laufend.rec.addEventListener('dataavailable', () => resolve(), { once: true });
try { laufend.rec.requestData(); } catch { resolve(); }
setTimeout(resolve, 1500);
});
const fertige = clipAbschnitte.filter((a) => (a.ende || jetzt) > jetzt - laenge * 1000);
await Promise.all(fertige.map((a) => a.fertig)); // gerade beendeter Abschnitt liefert evtl. noch Daten nach
const abschnitte = [...fertige, laufend].filter((a) => a.teile.length);
if (!abschnitte.length) throw new Error('Noch nichts aufgenommen.');
const daten = await Promise.all(abschnitte.map((a) => new Blob(a.teile, { type: 'video/webm' }).arrayBuffer()));
const gesamt = (jetzt - abschnitte[0].start) / 1000;
const inst = await window.api.getInstance(currentDetailId);
const res = await window.api.clipSave({
instanceName: inst ? inst.name : 'Instanz',
instanceId: currentDetailId,
abschnitte: daten,
serverLabel: await aktuellerAufnahmeServer(),
fps: mcFramerate,
vorneAbschneiden: Math.max(0, gesamt - laenge),
laenge: Math.min(laenge, Math.round(gesamt)),
});
if (res.ok) toast(`Clip gespeichert (${Math.min(laenge, Math.round(gesamt))} Sek.).`);
else toast(res.message || 'Clip konnte nicht gespeichert werden.', true);
} catch (err) {
toast('Clip konnte nicht gespeichert werden: ' + err.message, true);
} finally {
clipSpeichert = false;
el('clip-save').textContent = 'Clip speichern';
el('clip-save').disabled = !clipAktiv;
}
}
function updateMcTimer() {
const s = Math.floor((Date.now() - mcStartedAt) / 1000);
el('record-timer').textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
@@ -3544,6 +3725,59 @@ async function deleteSelectedRecords() {
// ---------- Freunde ----------
let friendsAutoRefresh = null;
// ---------- Spielzeit-Statistik ----------
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
async function openStats() {
if (POPOUT !== 'stats') { window.api.popoutOpen('stats'); return; }
show('modal-stats');
await ladeStatistik();
}
async function ladeStatistik() {
const alle = await window.api.listInstances();
const gespielt = alle.filter((i) => (i.totalPlaySeconds || 0) > 0)
.sort((a, b) => (b.totalPlaySeconds || 0) - (a.totalPlaySeconds || 0));
const gesamt = gespielt.reduce((s, i) => s + (i.totalPlaySeconds || 0), 0);
const starts = alle.reduce((s, i) => s + (i.launchCount || 0), 0);
const zuletzt = alle.filter((i) => i.lastPlayed).sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed)))[0];
const top = gespielt[0];
const kachel = (label, wert, sub) => `<div class="stat-tile"><div class="stat-label">${escapeHtml(label)}</div>
<div class="stat-value" title="${escapeHtml(wert)}">${escapeHtml(wert)}</div>${sub ? `<div class="stat-sub" title="${escapeHtml(sub)}">${escapeHtml(sub)}</div>` : ''}</div>`;
el('stats-tiles').innerHTML =
kachel('Spielzeit gesamt', fmtDuration(gesamt), `${gespielt.length} von ${alle.length} Instanzen`)
+ kachel('Spielstarts', starts ? String(starts) : '–', 'seit diesem Update')
+ kachel('Meistgespielt', top ? top.name : '–', top ? fmtDuration(top.totalPlaySeconds) : '')
+ kachel('Zuletzt gespielt', zuletzt ? zuletzt.name : '–', zuletzt ? fmtDate(zuletzt.lastPlayed) : '');
const box = el('stats-list');
box.innerHTML = '';
if (!gespielt.length) {
box.innerHTML = '<div class="friends-empty">Noch keine Spielzeit erfasst – starte eine Instanz.</div>';
}
const max = top ? top.totalPlaySeconds : 1;
for (const inst of gespielt) {
const sek = inst.totalPlaySeconds || 0;
const anteil = gesamt ? Math.round((sek / gesamt) * 100) : 0;
const loader = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : 'Vanilla';
const row = document.createElement('div');
row.className = 'stat-row';
row.setAttribute('role', 'listitem');
// Tooltip mit allen Details (Balken allein trägt nur die Länge)
row.title = `${inst.name}\nSpielzeit: ${fmtDuration(sek)} (${anteil} % der Gesamtzeit)`
+ (inst.launchCount ? `\nStarts: ${inst.launchCount}` : '')
+ `\nZuletzt gespielt: ${fmtDate(inst.lastPlayed)}`;
row.innerHTML = `<div class="stat-name"><b>${escapeHtml(inst.name)}</b><span>${escapeHtml(inst.minecraft.version || '?')} · ${escapeHtml(loader)}</span></div>
<div class="stat-track"><div class="stat-bar" style="width:${Math.max(1, (sek / max) * 100).toFixed(1)}%"></div></div>
<div class="stat-value-cell">${escapeHtml(fmtDuration(sek))}</div>`;
box.appendChild(row);
}
const nie = alle.length - gespielt.length;
el('stats-unplayed').textContent = nie ? `${nie} Instanz${nie === 1 ? '' : 'en'} noch nie gespielt.` : '';
}
async function openFriends() {
if (POPOUT !== 'friends') { window.api.popoutOpen('friends'); return; }
show('modal-friends');
@@ -3558,16 +3792,40 @@ async function ladeFreunde() {
el('friends-content').classList.toggle('hidden', !aktiv);
if (!aktiv) return;
const [anfragenRes, gesendetRes, freundeRes, einstellungen] = await Promise.all([
const [anfragenRes, gesendetRes, freundeRes, einstellungen, einladungenRes, meinServer] = await Promise.all([
window.api.friendsRequests(),
window.api.friendsSentRequests(),
window.api.friendsList(),
window.api.getSettings(),
window.api.friendsInvites(),
window.api.friendsMyServer(),
]);
const spitznamen = einstellungen.friendNicknames || {};
const reqBox = el('friends-requests-box');
reqBox.innerHTML = '';
if (einladungenRes.ok && einladungenRes.list.length) {
const titel = document.createElement('div');
titel.className = 'friends-section-title';
titel.textContent = 'Einladungen';
reqBox.appendChild(titel);
for (const inv of einladungenRes.list) {
const row = document.createElement('div');
row.className = 'friend-row';
const name = spitznamen[inv.fromUuid] || inv.fromUsername || inv.fromUuid;
row.innerHTML = `<img class="friend-avatar" src="https://crafthead.net/cube/${escapeHtml(inv.fromUuid)}/64" alt="" />
<div class="friend-info"><div class="friend-name">${escapeHtml(name)}</div><div class="friend-status">lädt dich ein · ${escapeHtml(inv.server)}${inv.version ? ' · ' + escapeHtml(inv.version) : ''}</div></div>
<button class="btn btn-mini btn-primary" data-act="join">Beitreten</button>
<button class="btn btn-mini" data-act="dismiss" title="Ablehnen">✕</button>`;
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
row.querySelector('[data-act="join"]').addEventListener('click', () => freundBeitreten(inv.server, inv.version, inv.id));
row.querySelector('[data-act="dismiss"]').addEventListener('click', async () => {
await window.api.friendsDismissInvite(inv.id);
ladeFreunde();
});
reqBox.appendChild(row);
}
}
if (anfragenRes.ok && anfragenRes.list.length) {
const titel = document.createElement('div');
titel.className = 'friends-section-title';
@@ -3630,15 +3888,35 @@ async function ladeFreunde() {
const row = document.createElement('div');
row.className = 'friend-row';
const anzeigeName = spitznamen[f.uuid] || f.username || f.uuid;
const spielt = f.version || f.instance;
const status = f.online
? (f.instance ? `spielt ${f.instance}${f.server ? ' · ' + f.server : ''}` : 'online')
? (f.server ? `auf ${f.server}${f.version ? ' · ' + f.version : ''}` : (spielt ? `spielt ${spielt}` : 'online'))
: 'offline';
const kannBeitreten = f.online && f.server && (!meinServer || meinServer.server !== f.server);
const kannEinladen = f.online && meinServer && meinServer.server !== f.server;
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>
${kannBeitreten ? '<button class="btn btn-mini btn-primary" data-act="join" title="Passende Instanz starten und diesem Server beitreten">Beitreten</button>' : ''}
${kannEinladen ? `<button class="btn btn-mini" data-act="invite" title="Auf ${escapeHtml(meinServer.server)} einladen">Einladen</button>` : ''}
<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(); });
if (kannBeitreten) row.querySelector('[data-act="join"]').addEventListener('click', () => freundBeitreten(f.server, f.version));
if (kannEinladen) {
row.querySelector('[data-act="invite"]').addEventListener('click', async (ev) => {
const btn = ev.currentTarget;
btn.disabled = true;
const res = await window.api.friendsInvite(f.uuid);
if (res.ok) {
toast(`${anzeigeName} wurde auf ${meinServer.server} eingeladen.`);
btn.textContent = 'Eingeladen';
} else {
toast(res.message || 'Einladung fehlgeschlagen.', true);
btn.disabled = false;
}
});
}
row.querySelector('[data-act="nick"]').addEventListener('click', async () => {
const wert = await askPrompt({
title: 'Spitzname',
@@ -3660,6 +3938,36 @@ async function ladeFreunde() {
}
}
/*
* Einem Freund auf seinen Server folgen: passende eigene Instanz (gleiche
* Version, Server am besten schon in der Liste) per Quick Play starten.
* Gibt es keine, wird angeboten, eine Vanilla-Instanz mit seiner Version anzulegen.
*/
async function freundBeitreten(server, version, einladungId) {
const res = await window.api.friendsJoin(server, version);
if (res.ok) {
toast(`Starte „${res.instanceName}" und verbinde mit ${server} …`);
if (einladungId) { await window.api.friendsDismissInvite(einladungId); ladeFreunde(); }
return;
}
if (res.noInstance) {
if (!version) {
toast('Keine passende Instanz gefunden – die Minecraft-Version deines Freundes ist unbekannt.', true);
return;
}
askConfirm('Keine passende Instanz',
`Für Minecraft ${version} gibt es noch keine Instanz. Eine neue Vanilla-Instanz anlegen und ${server} beitreten?`,
'Anlegen & beitreten', async () => {
const neu = await window.api.friendsJoinNew(server, version);
if (!neu.ok) { toast(neu.message || 'Beitreten fehlgeschlagen.', true); return; }
toast(`„${neu.instanceName}" angelegt – Minecraft wird gestartet …`);
if (einladungId) { await window.api.friendsDismissInvite(einladungId); ladeFreunde(); }
});
return;
}
toast(res.message || 'Beitreten fehlgeschlagen.', true);
}
async function sendeFreundschaftsanfrage() {
const input = el('friends-add-input');
const name = input.value.trim();
@@ -4093,6 +4401,8 @@ function setSettingsPane(paneId) {
async function openSettings(pane) {
if (POPOUT !== 'settings') { window.api.popoutOpen('settings', { pane }); return; }
setSettingsPane(pane || new URLSearchParams(location.search).get('pane') || 'pane-general');
// Ergebnis der Update-Suche vom Start übernehmen (dieses Fenster gab es da noch nicht)
window.api.updateLastCheck().then(renderUpdateStatus).catch(() => {});
const s = await window.api.getSettings();
el('s-theme').value = s.theme || 'dark';
@@ -4119,6 +4429,8 @@ async function openSettings(pane) {
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false;
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -4248,6 +4560,8 @@ async function saveSettings() {
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
@@ -4905,6 +5219,7 @@ function wire() {
// Freunde
el('btn-friends').addEventListener('click', openFriends);
el('btn-stats').addEventListener('click', openStats);
el('friends-add-btn').addEventListener('click', sendeFreundschaftsanfrage);
el('friends-add-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendeFreundschaftsanfrage();
@@ -4924,7 +5239,7 @@ function wire() {
// Instanz-Dialog
el('btn-save-instance').addEventListener('click', saveInstance);
el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value));
el('f-loader').addEventListener('change', () => updateLoaderVersions());
el('f-loader').addEventListener('change', () => { updateLoaderVersions(); aktualisierePerformanceZeile(); });
el('f-version').addEventListener('change', () => updateLoaderVersions());
el('f-optifine-install').addEventListener('click', async () => {
@@ -5008,9 +5323,19 @@ function wire() {
el('p-record').addEventListener('click', openRecording);
el('record-search-retry').addEventListener('click', startWindowSearch);
el('record-toggle').addEventListener('click', () => {
if (clipAktiv) return;
if (!recordingActive()) startMcRecording();
else stopMcRecording();
});
el('clip-toggle').addEventListener('change', async (e) => {
if (e.target.checked) {
if (recordingActive()) { e.target.checked = false; toast('Bitte erst die laufende Aufnahme beenden.', true); return; }
await starteClipModus();
} else {
stoppeClipModus();
}
});
el('clip-save').addEventListener('click', speichereClip);
el('record-open-folder').addEventListener('click', async () => {
const inst = await window.api.getInstance(currentDetailId);
window.api.recordingOpenFolder(inst ? inst.name : mcLastSavedInstanceName);
@@ -5465,7 +5790,11 @@ const POPOUT_OPENERS = {
skin: () => openSkin(),
bedrock: () => openBedrock(),
login: (p) => openLogin({ addAccount: p.addAccount === true || p.addAccount === 'true' }),
import: (p) => openImportDialog(p.kind || new URLSearchParams(location.search).get('kind') || 'zip'),
import: (p) => {
let vorauswahl = null;
try { vorauswahl = p.vorauswahl ? JSON.parse(p.vorauswahl) : null; } catch { /* ungültig -> normal öffnen */ }
openImportDialog(p.kind || new URLSearchParams(location.search).get('kind') || 'zip', vorauswahl);
},
update: async () => { try { showUpdateDialog(await window.api.updateGetPending()); } catch { /* egal */ } },
instance: async (p) => {
if (p.instanceId) {
@@ -5477,6 +5806,7 @@ const POPOUT_OPENERS = {
},
record: () => openRecording(),
friends: () => openFriends(),
stats: () => openStats(),
};
function popoutBoot(params) {
@@ -5505,6 +5835,16 @@ window.addEventListener('DOMContentLoaded', async () => {
// bisher gesammelte Konsolentext weg)
window.api.onPopoutReopen((params) => popoutBoot(params));
if (POPOUT === 'record') window.api.onRecordCloseAttempt(() => confirmStopAndCloseRecording());
if (POPOUT === 'record') {
window.api.onClipHotkey(() => speichereClip());
// Spiel beendet -> Clip-Modus ist ohne Bildquelle sinnlos
window.api.onLaunchProgress((evt) => {
if (evt.phase === 'exit' && evt.id === currentDetailId && clipAktiv) {
stoppeClipModus();
toast('Clip-Modus beendet – Minecraft wurde geschlossen.');
}
});
}
}
wire();
@@ -5529,7 +5869,15 @@ window.addEventListener('DOMContentLoaded', async () => {
};
window.api.onFriendsRequestCount(zeigeAnfragenZahl);
window.api.friendsRequestCount().then(zeigeAnfragenZahl).catch(() => {});
// Meldungen aus dem Hauptprozess, deren Fenster schon zu ist (z. B. Performance-Paket)
window.api.onUiToast((t) => toast(t.text, !!t.error));
// Einladung "Komm auf meinen Server" (im Spiel kommt zusätzlich ein Toast)
window.api.onFriendsInvite((inv) => {
toast(`${inv.fromUsername || 'Ein Freund'} lädt dich auf ${inv.server} ein – im Freunde-Fenster beitreten.`);
});
}
// offenes Freunde-Fenster bei neuer Einladung gleich aktualisieren
if (POPOUT === 'friends') window.api.onFriendsInvite(() => ladeFreunde());
await refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
+6 -1
View File
@@ -76,7 +76,9 @@ 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
friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren // 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
@@ -295,6 +297,7 @@ function defaultInstance(name) {
created: now,
lastPlayed: null,
totalPlaySeconds: 0,
launchCount: 0, // Anzahl Spielstarts (Spielzeit-Statistik)
minecraft: {
version: '', // z. B. "1.21.4" – wird in Phase 2 aus dem Mojang-Manifest befüllt
loader: 'vanilla', // vanilla | fabric | forge | neoforge | quilt
@@ -426,6 +429,7 @@ function createOrReplaceImportedInstance(data) {
inst.created = existing.created || inst.created;
inst.lastPlayed = existing.lastPlayed || null;
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
inst.launchCount = existing.launchCount || 0;
const p = instancePath(existing.id);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
@@ -458,6 +462,7 @@ function duplicateInstance(id) {
copy.created = new Date().toISOString();
copy.lastPlayed = null;
copy.totalPlaySeconds = 0;
copy.launchCount = 0;
ensureDir(gameDir(copy.id));
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
try {
+36
View File
@@ -391,6 +391,42 @@ img.account-av{object-fit:cover;image-rendering:auto}
.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}
/* ---- Spielzeit-Statistik: Kennzahlen + Balken pro Instanz ---- */
:root{--stat-bar:#3a6ea5}
:root[data-theme="dark"]{--stat-bar:#3d8fe0}
.stats-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:6px}
.stat-tile{border:1px solid var(--border);border-radius:4px;padding:8px 10px;min-width:0}
.stat-tile .stat-label{font-size:11px;color:var(--dim)}
.stat-tile .stat-value{font-size:18px;font-weight:600;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.stat-tile .stat-sub{font-size:11px;color:var(--dim);line-height:1.35}
.stats-list{display:flex;flex-direction:column;gap:2px}
.stat-row{display:grid;grid-template-columns:minmax(0,38%) 1fr auto;align-items:center;gap:10px;padding:5px 4px;border-radius:3px}
.stat-row:hover{background:var(--sel)}
.stat-name{min-width:0}
.stat-name b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.stat-name span{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
.stat-track{height:8px}
.stat-bar{height:8px;background:var(--stat-bar);border-radius:0 4px 4px 0;min-width:2px}
.stat-value-cell{font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:92px}
.popout-host .stats-list{max-height:none}
/* Clip-Modus-Leiste im Aufzeichnungsfenster */
.clip-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
.clip-bar.aktiv{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
.clip-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
.clip-switch input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
.clip-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--border);transition:background .15s}
.clip-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:left .15s}
.clip-switch input:checked + .clip-switch-track{background:var(--accent)}
.clip-switch input:checked + .clip-switch-track .clip-switch-knob{left:16px}
.clip-switch input:focus-visible + .clip-switch-track{outline:2px solid var(--accent);outline-offset:2px}
.clip-length{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
.clip-length select{padding:3px 6px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
.clip-length select:disabled{opacity:.6}
.clip-status{flex:1;min-width:200px;font-size:12px;color:var(--dim)}
.clip-bar.aktiv .clip-status{color:var(--text)}
.clip-bar.aktiv .clip-status::before{content:"";display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--danger);vertical-align:1px;animation:rec-pulse 1.6s infinite}
.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))}
+4 -3
View File
@@ -42,7 +42,7 @@ function computeInstallKind() {
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
if (/portable/i.test(path.basename(exe))) return 'portable';
// 3. Setup: NSIS legt neben der EXE immer einen Uninstaller ab. Nur wenn der
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
// da ist, ist es wirklich eine Installation.
if (hasUninstaller(path.dirname(exe))) return 'setup';
@@ -319,7 +319,7 @@ exit /b 1
/*
* Wendet das geladene Update an.
* setup -> NSIS-Installer starten, er überschreibt die Installation selbst
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
* Der Launcher muss sich danach beenden.
*/
@@ -331,7 +331,8 @@ function applyUpdate(file) {
const kind = installKind;
if (kind !== 'portable') {
const child = spawn(file.path, [], { detached: true, stdio: 'ignore' });
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
child.unref();
return { started: 'installer' };
}
@@ -2,7 +2,7 @@
/**
* 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
* Version: 1.1.0
* Author: M_Viper
*/
@@ -10,9 +10,10 @@ if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei
}
define('AEROMC_FRIENDS_VERSION', '1.0.0');
define('AEROMC_FRIENDS_VERSION', '1.1.0');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
/**
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
@@ -23,6 +24,7 @@ function aeromc_friends_install() {
$charset_collate = $wpdb->get_charset_collate();
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
@@ -34,6 +36,7 @@ function aeromc_friends_install() {
username varchar(32) NOT NULL,
instance_name varchar(128) DEFAULT NULL,
server_label varchar(128) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL,
last_seen datetime NOT NULL,
PRIMARY KEY (uuid)
) $charset_collate;";
@@ -50,7 +53,31 @@ function aeromc_friends_install() {
KEY uuid_b (uuid_b)
) $charset_collate;";
dbDelta($sql_friends);
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
$sql_invites = "CREATE TABLE $invites_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
from_uuid varchar(32) NOT NULL,
to_uuid varchar(32) NOT NULL,
server varchar(128) NOT NULL,
mc_version varchar(32) DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY to_uuid (to_uuid)
) $charset_collate;";
dbDelta($sql_invites);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
}
register_activation_hook(__FILE__, 'aeromc_friends_install');
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
add_action('plugins_loaded', function () {
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
aeromc_friends_install();
}
});
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
@@ -20,7 +20,38 @@ if (!defined('ABSPATH')) {
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.
// Ausnahme: hat sich der Server geändert, wird sofort gespeichert ("Beitreten").
define('AEROMC_FRIENDS_HEARTBEAT_MIN_GAP', 20);
// So lange ist eine Einladung "Komm auf meinen Server" gültig.
define('AEROMC_FRIENDS_INVITE_SECONDS', 600);
// Frühestens nach so vielen Sekunden darf derselbe Freund erneut eingeladen werden.
define('AEROMC_FRIENDS_INVITE_MIN_GAP', 30);
function aeromc_friends_invites_table() {
global $wpdb;
return $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
}
/** Server-Adresse (Host oder IP, optional :Port) - nur harmlose Zeichen. */
function aeromc_friends_valid_server($server) {
return is_string($server) && preg_match('/^[A-Za-z0-9.\-_:\[\]]{1,128}$/', $server);
}
/** Minecraft-Version wie "1.21.1", "24w14a" oder "1.20.1-pre2". */
function aeromc_friends_clean_version($version) {
$version = (string) $version;
return preg_match('/^[A-Za-z0-9.\-_ ]{1,32}$/', $version) ? $version : '';
}
/** Sind die beiden befreundet (Anfrage angenommen)? */
function aeromc_friends_are_friends($a, $b) {
global $wpdb;
$friends_table = aeromc_friends_friends_table();
return (bool) $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)) AND status = 'accepted'",
$a, $b, $b, $a
));
}
function aeromc_friends_presence_table() {
global $wpdb;
@@ -89,9 +120,24 @@ add_action('rest_api_init', function () {
'callback' => 'aeromc_friends_route_remove',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/invite', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_invite',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/invites', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_invites',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/friends/invites/dismiss', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_invite_dismiss',
'permission_callback' => '__return_true',
]);
});
/** POST /presence {uuid, username, instance, server} */
/** POST /presence {uuid, username, instance, server, version} */
function aeromc_friends_route_presence($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
@@ -101,12 +147,14 @@ function aeromc_friends_route_presence($request) {
}
$instance = sanitize_text_field((string) $request->get_param('instance'));
$server = sanitize_text_field((string) $request->get_param('server'));
$version = aeromc_friends_clean_version($request->get_param('version'));
$table = aeromc_friends_presence_table();
$letzte = $wpdb->get_var($wpdb->prepare(
"SELECT last_seen FROM $table WHERE uuid = %s", $uuid
$letzte = $wpdb->get_row($wpdb->prepare(
"SELECT last_seen, server_label FROM $table WHERE uuid = %s", $uuid
));
if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) {
$server_gleich = $letzte && (string) $letzte->server_label === (string) $server;
if ($letzte && $server_gleich && (time() - strtotime($letzte->last_seen)) < AEROMC_FRIENDS_HEARTBEAT_MIN_GAP) {
return ['ok' => true]; // zu häufig - stillschweigend ignoriert
}
@@ -118,11 +166,11 @@ function aeromc_friends_route_presence($request) {
// 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
"INSERT INTO $table (uuid, username, instance_name, server_label, mc_version, last_seen)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE username = %s, instance_name = %s, server_label = %s, mc_version = %s, last_seen = %s",
$uuid, $username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc,
$username, $instance ?: null, $server ?: null, $version ?: null, $jetzt_utc
));
return ['ok' => true];
}
@@ -147,7 +195,7 @@ function aeromc_friends_route_list($request) {
$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",
"SELECT username, instance_name, server_label, mc_version, last_seen FROM $presence_table WHERE uuid = %s",
$row->other_uuid
));
$online = $p && (time() - strtotime($p->last_seen)) < AEROMC_FRIENDS_ONLINE_SECONDS;
@@ -157,6 +205,7 @@ function aeromc_friends_route_list($request) {
'online' => (bool) $online,
'instance' => $online ? $p->instance_name : null,
'server' => $online ? $p->server_label : null,
'version' => $online ? $p->mc_version : null,
];
}
return $ergebnis;
@@ -301,3 +350,87 @@ function aeromc_friends_route_remove($request) {
));
return ['ok' => true];
}
/** POST /friends/invite {uuid, targetUuid, server, version} - "Komm auf meinen Server" */
function aeromc_friends_route_invite($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$target = aeromc_friends_normalize_uuid($request->get_param('targetUuid'));
$server = (string) $request->get_param('server');
$version = aeromc_friends_clean_version($request->get_param('version'));
if (!$uuid || !$target || !aeromc_friends_valid_server($server)) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/targetUuid/server.', ['status' => 400]);
}
if (!aeromc_friends_are_friends($uuid, $target)) {
return new WP_Error('aeromc_not_friends', 'Ihr seid nicht befreundet.', ['status' => 403]);
}
$table = aeromc_friends_invites_table();
$letzte = $wpdb->get_var($wpdb->prepare(
"SELECT created_at FROM $table WHERE from_uuid = %s AND to_uuid = %s ORDER BY id DESC LIMIT 1",
$uuid, $target
));
if ($letzte && (time() - strtotime($letzte)) < AEROMC_FRIENDS_INVITE_MIN_GAP) {
return new WP_Error('aeromc_too_fast', 'Du hast diesen Freund gerade erst eingeladen.', ['status' => 429]);
}
// ältere Einladung an denselben Freund ersetzen
$wpdb->query($wpdb->prepare(
"DELETE FROM $table WHERE from_uuid = %s AND to_uuid = %s", $uuid, $target
));
$wpdb->insert($table, [
'from_uuid' => $uuid,
'to_uuid' => $target,
'server' => $server,
'mc_version' => $version ?: null,
'created_at' => current_time('mysql', true),
]);
return ['ok' => true];
}
/** GET /friends/invites?uuid= - an mich gerichtete, noch gültige Einladungen */
function aeromc_friends_route_invites($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]);
}
$table = aeromc_friends_invites_table();
$presence_table = aeromc_friends_presence_table();
// abgelaufene Einladungen (aller Spieler) wegräumen
$grenze = gmdate('Y-m-d H:i:s', time() - AEROMC_FRIENDS_INVITE_SECONDS);
$wpdb->query($wpdb->prepare("DELETE FROM $table WHERE created_at < %s", $grenze));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT i.id, i.from_uuid, p.username AS from_username, i.server, i.mc_version, i.created_at
FROM $table i
LEFT JOIN $presence_table p ON p.uuid = i.from_uuid
WHERE i.to_uuid = %s
ORDER BY i.id DESC",
$uuid
));
return array_map(function ($r) {
return [
'id' => (int) $r->id,
'fromUuid' => $r->from_uuid,
'fromUsername' => $r->from_username,
'server' => $r->server,
'version' => $r->mc_version,
'createdAt' => $r->created_at,
];
}, $rows);
}
/** POST /friends/invites/dismiss {uuid, id} - Einladung erledigt/abgelehnt */
function aeromc_friends_route_invite_dismiss($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
$id = (int) $request->get_param('id');
if (!$uuid || $id <= 0) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid/id.', ['status' => 400]);
}
$table = aeromc_friends_invites_table();
$wpdb->query($wpdb->prepare("DELETE FROM $table WHERE id = %d AND to_uuid = %s", $id, $uuid));
return ['ok' => true];
}