1 Commits
Author SHA1 Message Date
M_Viper ee0e6dec32 Upload via GUI (47 Dateien) 2026-09-27 18:17:44 +00:00
9 changed files with 1706 additions and 163 deletions
+83 -2
View File
@@ -1,16 +1,18 @@
{
"name": "aeromc",
"version": "1.1.0",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.1.0",
"version": "1.1.2",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1",
"loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2",
"undici": "^6.28.0",
"unzipper": "^0.12.5"
@@ -1224,6 +1226,15 @@
],
"license": "MIT"
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -2598,6 +2609,22 @@
"bare-events": "^2.7.0"
}
},
"node_modules/extract-file-icon": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/extract-file-icon/-/extract-file-icon-0.3.2.tgz",
"integrity": "sha512-We5/HaMDlioOx3c9zDjit8RtKWEvT94sJF8jTujaKVOyWL8Kkqu4QGnwDr5fID6XsbKvWT0JJNuWuV2Tmr7yiw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "1.7.1"
}
},
"node_modules/extract-file-icon/node_modules/node-addon-api": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.1.tgz",
"integrity": "sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ==",
"license": "MIT"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -2666,6 +2693,12 @@
"integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -3445,6 +3478,25 @@
"license": "MIT",
"peer": true
},
"node_modules/loopback-capture": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/loopback-capture/-/loopback-capture-3.0.2.tgz",
"integrity": "sha512-65qyF9L7e+InnbEZP3Ic4C4J+SszoxEq84XpIZWJYH3/IgUpTKqBzJ4wCF+ShL3bvqV7I2wOEgQv9VfpaO2AMw==",
"cpu": [
"x64"
],
"license": "MIT",
"os": [
"linux",
"win32"
],
"dependencies": {
"bindings": "^1.5.0"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -3636,12 +3688,41 @@
"license": "MIT",
"optional": true
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-window-manager": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/node-window-manager/-/node-window-manager-2.2.4.tgz",
"integrity": "sha512-Dgq9a7qx6+Gi+BifWM79leteNz76QUtwSsdiqEe7SjvoJsc8TYQsRl1IA5uxqz5iSLeHS1iA/8YI/snoq0DblA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"extract-file-icon": "^0.3.2",
"node-addon-api": "2.0.0",
"node-gyp-build": "^4.2.1"
}
},
"node_modules/node-window-manager/node_modules/node-addon-api": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz",
"integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==",
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+7 -1
View File
@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.1.0",
"version": "1.1.2",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
@@ -26,6 +26,10 @@
"package.json",
"LICENSE"
],
"asarUnpack": [
"node_modules/loopback-capture/**/*",
"node_modules/node-window-manager/**/*"
],
"win": {
"target": "nsis",
"icon": "src/assets/icon.ico"
@@ -42,6 +46,8 @@
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1",
"loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2",
"undici": "^6.28.0",
"unzipper": "^0.12.5"
+128 -4
View File
@@ -51,6 +51,7 @@
<button id="p-mods" class="sbtn">Mods anzeigen</button>
<button id="p-worlds" class="sbtn">Welten verwalten</button>
<button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button>
<button id="p-record" class="sbtn">Spiel aufzeichnen</button>
<button id="p-modpack" class="sbtn hidden">Modpack aktualisieren</button>
<button id="p-logs" class="sbtn">Logdateien ansehen</button>
<button id="p-servers" class="sbtn">Server verwalten</button>
@@ -709,6 +710,11 @@
<button id="m-add-file" class="btn btn-mini" data-ic="plus">Datei hinzufügen…</button>
<button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button>
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
<select id="m-installed-sort" title="Sortierung">
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="status">Status (aktiv zuerst)</option>
</select>
<span id="m-update-status" class="hint"></span>
</div>
<div id="m-installed" class="mods-list"><div class="mods-empty">Noch keine Mods.</div></div>
@@ -800,6 +806,123 @@
</div>
</div>
<!-- ===================== Dialog: Spielaufzeichnung ===================== -->
<div id="modal-record" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3 id="record-title">Spielaufzeichnung</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<div id="record-search" class="record-search">
<p id="record-search-text">Suche Spielfenster …</p>
<button id="record-search-retry" class="btn hidden">Erneut suchen</button>
</div>
<div id="record-picker" class="screens-grid hidden"></div>
<div id="record-stage" class="record-stage hidden">
<video id="record-preview" class="record-video" autoplay muted></video>
<div class="record-audio-bar">
<span class="hint">Video: <span id="record-video-summary">Nativ · Mittel</span></span>
<button id="record-video-edit" class="btn btn-mini">Video-Einstellungen…</button>
</div>
<div class="record-audio-bar">
<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>
<div class="record-toolbar">
<div id="record-live-badge" class="record-live-badge hidden">
<span class="rec-dot"></span>
<span>Aufnahme läuft · <span id="record-timer">00:00</span></span>
</div>
<button id="record-toggle" class="btn btn-primary">Aufnahme starten</button>
</div>
</div>
</div>
<div class="modal-foot">
<button id="record-open-folder" class="btn" data-ic="folder">Ordner öffnen</button>
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Video-Einstellungen der Aufzeichnung ===================== -->
<div id="modal-record-video" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Video-Einstellungen</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<div class="field-row">
<label class="field">
<span>Auflösung</span>
<select id="record-resolution">
<option value="native">Nativ (Fenstergröße)</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
</label>
<label class="field">
<span>Qualität</span>
<select id="record-quality">
<option value="low">Niedrig (kleinere Datei)</option>
<option value="medium" selected>Mittel</option>
<option value="high">Hoch (größere Datei)</option>
</select>
</label>
<label class="field">
<span>Bildrate</span>
<select id="record-fps">
<option value="30" selected>30 fps</option>
<option value="60">60 fps (größere Datei)</option>
</select>
</label>
</div>
<p id="record-video-hint" class="hint hidden">Gilt erst für die nächste Aufnahme – während einer laufenden Aufnahme nicht änderbar.</p>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn btn-primary" data-close>Fertig</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Audioquellen der Aufzeichnung ===================== -->
<div id="modal-record-audio" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Audioquellen wählen</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<p class="hint">Mehrfachauswahl möglich – alle angehakten Quellen werden zu einer Tonspur gemischt, Lautstärke je Quelle einstellbar.</p>
<div class="record-source-row">
<label class="checkbox-field">
<input id="record-src-system" type="checkbox" checked /> <span>Systemton</span>
</label>
<input id="record-vol-system" class="record-vol" type="range" min="0" max="100" value="100" title="Lautstärke" />
</div>
<div class="record-source-row">
<label class="checkbox-field">
<input id="record-src-game" type="checkbox" disabled /> <span>Nur Minecraft (isoliert)</span>
</label>
<input id="record-vol-game" class="record-vol" type="range" min="0" max="100" value="100" title="Lautstärke" />
</div>
<p id="record-src-game-hint" class="hint hidden">Kein laufender Minecraft-Prozess erkannt – bitte über den Launcher starten.</p>
<div id="record-mic-list" class="record-mic-list"></div>
<p class="hint" style="margin-top:10px">Systemton ist immer der komplette Mix (Spiel, Discord, Musik, Benachrichtigungen usw.). "Nur Minecraft" nimmt ausschließlich den Ton des Spiels auf (z. B. wenn nebenbei Musik läuft, die nicht mit rein soll) – beides lässt sich auch kombiniert mit einem Mikrofon anhaken.</p>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn btn-primary" data-close>Fertig</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Screenshots ===================== -->
<div id="modal-screens" class="modal hidden">
<div class="modal-card modal-lg">
@@ -856,7 +979,7 @@
<div class="field-row" style="margin-top:12px">
<label class="field"><span>Name</span><input id="srv-name" type="text" placeholder="z. B. Mein Server" /></label>
<label class="field"><span>Adresse</span><input id="srv-ip" type="text" placeholder="mc.example.de oder 192.168.1.50:25566" /></label>
<button id="srv-add" class="btn btn-primary" style="align-self:flex-end">Hinzufügen</button>
<div class="field" style="flex:0 0 auto"><span>&nbsp;</span><button id="srv-add" class="btn btn-primary">Hinzufügen</button></div>
</div>
<div id="srv-list" class="mods-list" style="margin-top:12px"></div>
</div>
@@ -899,19 +1022,19 @@
<div class="skin-spalte">
<div class="skin-label">Aktuell</div>
<div class="skin-bild">
<img id="skin-aktuell" alt="" class="hidden" />
<canvas id="skin-aktuell" class="hidden"></canvas>
<span id="skin-aktuell-leer" class="hint">wird geladen …</span>
</div>
</div>
<div class="skin-spalte">
<div class="skin-label">Neu</div>
<div class="skin-bild">
<img id="skin-neu" alt="" class="hidden" />
<canvas id="skin-neu" class="hidden"></canvas>
<span id="skin-neu-leer" class="hint">noch keine Datei gewählt</span>
</div>
</div>
</div>
<small class="hint">Die Vorschau zeigt die Figur so, wie sie im Spiel aussieht. Die Armform wirkt sich auf die Darstellung aus.</small>
<small class="hint">Mit der Maus ziehen dreht die Figur, Mausrad zoomt. Die Armform wirkt sich auf die Darstellung aus.</small>
<div class="path-row" style="margin-top:12px">
<input id="skin-pfad" type="text" readonly placeholder="PNG mit 64×64 Pixeln" />
<button id="skin-pick" class="btn">Datei wählen…</button>
@@ -1275,6 +1398,7 @@
<button class="ctx-item" data-act="p-mods">Mods anzeigen</button>
<button class="ctx-item" data-act="p-worlds">Welten verwalten</button>
<button class="ctx-item" data-act="p-screens">Bildschirmfotos verwalten</button>
<button class="ctx-item" data-act="p-record">Spiel aufzeichnen</button>
<button class="ctx-item" data-act="p-logs">Logdateien ansehen</button>
<button class="ctx-item" data-act="p-servers">Server verwalten</button>
<div class="ctx-sep"></div>
+402 -96
View File
@@ -6,7 +6,7 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard } = require('electron');
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
@@ -85,6 +85,274 @@ function applyProxy(s) {
let mainWindow = null;
// ---------------------------------------------------------------------------
// Popup-Fenster: alle großen Dialoge als eigene, frei verschiebbare Fenster
// (native Titelleiste ersetzt durch den eigenen dunklen Kopfbereich) statt
// als Modal im Hauptfenster. Laden dieselbe index.html mit z. B.
// "?popout=mods&instanceId=..." – der Renderer blendet dann nur diesen einen
// Dialog vollflächig ein.
// ---------------------------------------------------------------------------
const popoutWindows = new Map(); // modalId -> BrowserWindow
const POPOUT_SPEC = {
settings: { title: 'AeroMC – Einstellungen', width: 980, height: 720, minWidth: 700, minHeight: 500 },
console: { title: 'AeroMC – Konsole / Log', width: 860, height: 680, minWidth: 640, minHeight: 480 },
mods: { title: 'AeroMC – Mods', width: 900, height: 700, minWidth: 640, minHeight: 480 },
crash: { title: 'AeroMC – Absturzbericht', width: 860, height: 680, minWidth: 640, minHeight: 480 },
logs: { title: 'AeroMC – Logdateien', width: 860, height: 680, minWidth: 640, minHeight: 480 },
content: { title: 'AeroMC – Welten & Pakete', width: 860, height: 680, minWidth: 640, minHeight: 480 },
screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 },
servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 },
skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 },
bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 },
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 },
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
};
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
// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt
// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen)
function broadcast(channel, payload) {
for (const w of [mainWindow, ...popoutWindows.values()]) {
if (w && !w.isDestroyed()) w.webContents.send(channel, payload);
}
}
function openPopout(modalId, params) {
let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) {
win.show();
win.focus();
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
win.webContents.send('popout:reopen', params || {});
return;
}
const query = { popout: modalId };
for (const [k, v] of Object.entries(params || {})) if (v !== undefined && v !== null) query[k] = String(v);
const spec = POPOUT_SPEC[modalId] || POPOUT_DEFAULT;
win = new BrowserWindow({
width: spec.width,
height: spec.height,
minWidth: spec.minWidth,
minHeight: spec.minHeight,
title: spec.title || POPOUT_DEFAULT.title,
backgroundColor: '#1b1e24',
icon: windowIcon(),
frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query });
win.once('ready-to-show', () => win.show());
win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
});
win.on('close', (e) => {
if (appIsQuitting) return;
e.preventDefault();
// Läuft gerade eine Aufzeichnung, darf ein natives Schließen (Alt+F4,
// Taskleiste) sie nicht stillschweigend verstecken -> der Renderer
// entscheidet selbst (Stoppen+Speichern oder Abbrechen)
if (modalId === 'record' && recordingGuardActive) {
win.webContents.send('record:closeAttempt');
return;
}
win.hide();
});
win.on('closed', () => { popoutWindows.delete(modalId); });
}
let recordingGuardActive = false;
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
ipcMain.on('popout:hide', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w && !w.isDestroyed()) w.hide();
});
ipcMain.handle('update:getPending', () => pendingUpdateInfo);
// ---------------------------------------------------------------------------
// Spielaufzeichnung: Fenster-Erkennung per Fenstertitel (kein PID-Tracking
// nötig dafür - funktioniert unabhängig von Konto/settings.wrapperCommand).
// Video kommt vom Fenster, Systemton separat vom Bildschirm (Chromium erlaubt
// keine Pro-Fenster-Tonaufnahme) - siehe renderer.js für den Aufnahme-Teil.
// Die PID wird NUR für die optionale isolierte "Nur Minecraft"-Tonspur
// gebraucht (per-Prozess-Aufnahme via loopback-capture, siehe unten).
// ---------------------------------------------------------------------------
const letzteQuickPlayServer = new Map(); // instanceId -> Server-Adresse
// Minecraft schreibt diese Zeile bei JEDER Verbindung zu einem Server ins Log
// - egal ob per Quick Play oder von Hand im Spiel verbunden, und egal ob das
// Aufzeichnungs-Fenster dabei schon offen war. Hier (statt im Renderer)
// verfolgt, weil das hier zentral für alle Fenster gilt, auch für ein erst
// SPÄTER geöffnetes Aufzeichnungs-Popup, das die Zeile sonst verpasst hätte.
const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i;
ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null);
const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
// soll - vom Renderer kurz vor dem eigentlichen Aufruf gesetzt (siehe
// setDisplayMediaRequestHandler weiter unten). Für den cursorfreien
// Erfassungsversuch: ganzer Bildschirm ohne Mauszeiger, der Zuschnitt aufs
// Minecraft-Fenster passiert danach per Canvas im Renderer (siehe
// recording:getWindowBounds). Schlägt das fehl, läuft die Erfassung über die
// alte, zuverlässige chromeMediaSource-Fenster-Methode (Mauszeiger sichtbar).
let ausstehendeErfassungsQuelle = null;
ipcMain.handle('recording:setCaptureTarget', (_e, sourceId) => { ausstehendeErfassungsQuelle = sourceId; });
// Sucht das Fenster des laufenden Minecraft-Prozesses (über die PID aus
// laufendePid, robuster als ein Titel-Abgleich) und ermittelt daraus:
// - die aktuellen Fenstergrenzen in Bildschirmkoordinaten
// - den Bildschirm, auf dem das Fenster gerade sitzt (Mehrschirm-fähig)
// - die zu diesem Bildschirm gehörende Erfassungsquelle für getDisplayMedia
// Liefert null, wenn das Fenster nicht gefunden wird (z. B. Minecraft nicht
// über den Launcher gestartet, oder bereits beendet) - der Renderer fällt in
// dem Fall auf die alte Fenster-Erfassung zurück statt abzustürzen.
async function findeFensterUndBildschirm(pid, fensterTitel) {
let windowManager;
try { ({ windowManager } = require('node-window-manager')); }
catch (err) { console.log('[Aufzeichnung] node-window-manager Fehler:', err.message); return null; }
let alleFenster;
try { alleFenster = windowManager.getWindows(); }
catch (err) { console.log('[Aufzeichnung] getWindows Fehler:', err.message); return null; }
// Die PID kennt der Launcher nur für Prozesse, die er in DIESER laufenden
// Sitzung selbst gestartet hat - nach einem Neustart des Launchers (z. B.
// während Minecraft weiterläuft) ist laufendePid leer, obwohl das Spiel
// noch läuft. Dann über den EXAKTEN Fenstertitel suchen, den der Renderer
// schon über recording:listWindows (dort per /Minecraft/i gefiltert und
// vom Nutzer im Suchdialog bestätigt) positiv identifiziert hat - NICHT
// erneut breit nach /Minecraft/i suchen, das kann auf einem Rechner mit
// vielen offenen Fenstern (Editor, Browser-Tabs usw.) ein völlig falsches,
// unbeteiligtes Fenster treffen und dessen Inhalt zeigen/aufnehmen.
let fenster = pid ? alleFenster.find((w) => w.processId === pid) : null;
if (!fenster && fensterTitel) {
fenster = alleFenster.find((w) => { try { return w.getTitle() === fensterTitel; } catch { return false; } });
}
if (!fenster) { console.log('[Aufzeichnung] kein Minecraft-Fenster gefunden (PID', pid, ', Titel', fensterTitel, ')'); return null; }
const fensterBounds = fenster.getBounds();
if (!fensterBounds || !fensterBounds.width || !fensterBounds.height) {
console.log('[Aufzeichnung] ungültige Fenstergrenzen:', JSON.stringify(fensterBounds));
return null;
}
const anzeige = screen.getDisplayMatching(fensterBounds);
const screenSources = await desktopCapturer.getSources({ types: ['screen'] });
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,
screenSourceId: quelle.id,
};
}
ipcMain.handle('recording:getWindowBounds', async (_e, instanceId, fensterTitel) => {
const pid = laufendePid.get(instanceId);
return findeFensterUndBildschirm(pid, fensterTitel);
});
// Isolierte Ton-Aufnahme nur eines Prozesses (inkl. Kindprozesse) über
// Windows' Process-Loopback-API. Nur eine Aufnahme gleichzeitig aktiv.
let aktiveSpielAudioCapture = null;
ipcMain.handle('recording:startGameAudio', (event, pid) => {
if (aktiveSpielAudioCapture) return { ok: false, message: 'Läuft bereits.' };
let loopback;
try {
loopback = require('loopback-capture');
} catch (err) {
return { ok: false, message: 'Spielton-Isolierung nicht verfügbar: ' + err.message };
}
const win = BrowserWindow.fromWebContents(event.sender);
try {
const capture = new loopback.LoopbackCapture();
capture.start(pid, true, (chunk) => {
if (win && !win.isDestroyed()) win.webContents.send('recording:gameAudioChunk', new Uint8Array(chunk));
});
aktiveSpielAudioCapture = { capture };
return { ok: true };
} catch (err) {
return { ok: false, message: 'Spielton-Aufnahme fehlgeschlagen (Windows 10 Version 2004+ erforderlich, oder Spiel bereits beendet): ' + err.message };
}
});
ipcMain.handle('recording:stopGameAudio', () => {
if (aktiveSpielAudioCapture) {
try { aktiveSpielAudioCapture.capture.stop(); } catch { /* egal, wird verworfen */ }
aktiveSpielAudioCapture = null;
}
return true;
});
ipcMain.handle('recording:listWindows', async () => {
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 200, height: 120 },
});
return sources
.filter((s) => /Minecraft/i.test(s.name))
.map((s) => ({
id: s.id,
name: s.name,
thumbnail: s.thumbnail && !s.thumbnail.isEmpty() ? s.thumbnail.toDataURL() : null,
}));
});
// Für den Systemton reicht irgendeine Bildschirmquelle als Pflichtfeld für
// getUserMedia -- der Ton ist ohnehin nicht pro Bildschirm getrennt.
ipcMain.handle('recording:getAudioSourceId', async () => {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
return (sources[0] && sources[0].id) || null;
});
function aufzeichnungsOrdner(instanceName) {
return path.join(app.getPath('videos'), 'AeroMC Launcher',
String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_'));
}
ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel) => {
try {
const zielOrdner = aufzeichnungsOrdner(instanceName);
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const serverTeil = serverLabel ? String(serverLabel).replace(/[^\w.-]+/g, '_') + '_' : '';
let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.webm`);
let n = 2;
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.webm`); n++; }
fs.writeFileSync(zielDatei, Buffer.from(buffer));
notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`);
return { ok: true, path: zielDatei };
} catch (err) {
return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message };
}
});
ipcMain.handle('recording:openFolder', (_e, instanceName) => {
const dir = aufzeichnungsOrdner(instanceName);
fs.mkdirSync(dir, { recursive: true });
shell.openPath(dir);
return true;
});
// ---------------------------------------------------------------------------
// Verknüpfungen: "AeroMC.exe --launch <id>" startet eine Instanz direkt
// ---------------------------------------------------------------------------
@@ -217,7 +485,12 @@ function createWindow(startHidden) {
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
// Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist.
mainWindow.on('close', (e) => {
if (appIsQuitting || !store.getSettings().autostart) return;
if (appIsQuitting || !store.getSettings().autostart) {
// Hauptfenster schließt wirklich -> keine nur versteckten Popup-Fenster
// zurücklassen (die sonst window-all-closed nie erreichen ließen)
for (const w of popoutWindows.values()) if (!w.isDestroyed()) w.destroy();
return;
}
e.preventDefault();
mainWindow.hide();
});
@@ -306,6 +579,17 @@ app.whenReady().then(() => {
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
// Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
// hier direkt zurückgegeben, statt den Nutzer nochmal wählen zu lassen
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
const quelle = sources.find((s) => s.id === ausstehendeErfassungsQuelle) || sources[0];
console.log('[Aufzeichnung] setDisplayMediaRequestHandler: angefordert=', ausstehendeErfassungsQuelle, 'gewählt=', quelle && quelle.id);
callback(quelle ? { video: quelle } : {});
});
}, { useSystemPicker: false });
// Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung
setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800);
@@ -329,17 +613,14 @@ app.whenReady().then(() => {
setTimeout(async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
if (mainWindow && !mainWindow.isDestroyed()) {
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
mainWindow.webContents.send('update:checked', info);
if (info.available) mainWindow.webContents.send('update:available', info);
}
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
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) });
}
broadcast('update:checked', { error: (err && err.message) || String(err) });
}
}, 4000);
@@ -351,7 +632,7 @@ app.whenReady().then(() => {
const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
auth.getValidAccount(cid)
.then((acc) => {
if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed');
if (acc) broadcast('auth:refreshed');
})
.catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ });
@@ -393,6 +674,15 @@ ipcMain.handle('settings:save', (_e, patch) => {
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
// 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
// (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das
// Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert.
const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId');
if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('settings:changed', s);
}
return s;
});
@@ -522,14 +812,14 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
* per Hand zugeschickt). Die Datei taucht danach ganz normal in der
* "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt.
*/
ipcMain.handle('mods:addFile', async (_e, id, art) => {
ipcMain.handle('mods:addFile', async (e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const filters = (art || 'mod') === 'mod'
? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }]
: [{ name: typ.label || 'Datei', extensions: ['zip'] }];
const res = await dialog.showOpenDialog(mainWindow, {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Datei hinzufügen',
properties: ['openFile', 'multiSelections'],
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
@@ -629,7 +919,7 @@ ipcMain.handle('optifine:setup', async (_e, id) => {
let prep;
try {
prep = await launcher.prepareAndLaunch(opts, (evt) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
broadcast('launch:progress', Object.assign({ id }, evt));
});
} catch (err) {
return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message };
@@ -796,7 +1086,7 @@ ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
try {
const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
broadcast('mods:updateProgress', p);
}, art || 'mod');
return { ok: true, ...res };
} catch (err) {
@@ -864,8 +1154,8 @@ ipcMain.handle('skin:profile', async () => {
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
});
ipcMain.handle('skin:choose', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('skin:choose', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Skin auswählen',
properties: ['openFile'],
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
@@ -886,14 +1176,22 @@ ipcMain.handle('skin:choose', async () => {
ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.setzeSkin(token, dateiPfad, variante); }
try {
const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) broadcast('skin:changed');
return res;
}
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
});
ipcMain.handle('skin:reset', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.setzeSkinZurueck(token); }
try {
const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) broadcast('skin:changed');
return res;
}
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
@@ -905,7 +1203,7 @@ ipcMain.handle('auth:login', async () => {
const account = await auth.login(
clientId,
(code) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code);
broadcast('auth:code', code);
},
() => {},
);
@@ -923,9 +1221,17 @@ ipcMain.handle('instances:list', () => store.listInstances());
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
ipcMain.handle('instances:create', (_e, data) => store.createInstance(data));
ipcMain.handle('instances:create', (_e, data) => {
const res = store.createInstance(data);
broadcast('instances:changed');
return res;
});
ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch));
ipcMain.handle('instances:update', (_e, id, patch) => {
const res = store.updateInstance(id, patch);
broadcast('instances:changed');
return res;
});
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
@@ -1121,9 +1427,7 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten })
if (geschuetzt(rel)) continue;
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
}
broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
try {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) continue;
@@ -1231,9 +1535,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' };
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
}
broadcast('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' };
const buf = Buffer.from(await dl.arrayBuffer());
@@ -1276,6 +1578,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
}),
});
broadcast('instances:changed');
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
return {
ok: true,
@@ -1317,9 +1620,7 @@ async function importCurseForge(buf, zip, zugang) {
const gameDir = store.gameDir(created.id);
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const melde = (detail, current, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current, total, detail });
}
broadcast('launch:progress', { phase: 'modpack', current, total, detail });
};
// 1) Dateiliste über die CurseForge-API auflösen
@@ -1388,11 +1689,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
const projectId = project && (project.projectId || project.id);
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
if (!file) {
return {
@@ -1402,12 +1699,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
: 'Keine Modpack-Version gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack',
detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' });
const buf = await services.downloadBuffer(file.url);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
@@ -1427,6 +1719,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
result.instance = store.getInstance(inst.id);
}
if (result && result.ok && result.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1446,11 +1739,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
const modId = Number(rawId);
if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
if (!file || !file.url) {
return {
@@ -1460,11 +1749,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
: 'Keine passende Modpack-Datei gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Lade ' + file.filename + ' …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
const zip = forge.readZipIndex(buf);
@@ -1473,6 +1758,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
}
const result = await importCurseForge(buf, zip, zugang);
if (result && result.ok && result.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1505,6 +1791,7 @@ async function importModpackFromPath(filePath) {
return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
}
if (result && result.ok && result.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1513,8 +1800,8 @@ async function importModpackFromPath(filePath) {
}
}
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('modpack:import', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Modpack importieren',
properties: ['openFile'],
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
@@ -1643,14 +1930,16 @@ async function importInstanceZipFromPath(filePath) {
}
}
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('instances:import', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return importInstanceZipFromPath(res.filePaths[0]);
const result = await importInstanceZipFromPath(res.filePaths[0]);
if (result && result.ok) broadcast('instances:changed');
return result;
});
/*
@@ -1696,6 +1985,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack);
// .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen
const instanceRes = await importInstanceZipFromPath(filePath);
if (instanceRes && instanceRes.ok) broadcast('instances:changed');
return Object.assign({ kind: 'instance' }, instanceRes);
}
@@ -1710,7 +2000,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
function notify(title, body) {
try {
if (store.getSettings().desktopNotifications === false) return;
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return;
if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
n.on('click', () => {
@@ -1844,7 +2134,7 @@ ipcMain.handle('backup:restore', async (_e, sourcePath) => {
const spielendeKonten = new Map();
function meldeKontenAenderung() {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed');
broadcast('accounts:changed');
}
function kontoSpieltJetzt(kontoId) {
@@ -1862,6 +2152,12 @@ function kontoSpieltNichtMehr(kontoId) {
}
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// für die Spielaufzeichnung merken, mit welchem Server (Quick Play) diese
// Instanz zuletzt gestartet wurde -- muss im Hauptprozess liegen, weil
// Serverliste und Aufzeichnung zwei getrennte Popup-Fenster mit jeweils
// eigenem Renderer-Speicher sind
if (launchOpts && launchOpts.quickPlayServer) letzteQuickPlayServer.set(id, launchOpts.quickPlayServer);
else letzteQuickPlayServer.delete(id);
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) {
@@ -1884,9 +2180,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// die nebenher läuft, während Minecraft dieselben Dateien anfasst, wäre
// nicht verlässlich.
if (settings.autoBackupEnabled && settings.autoBackupBeforeLaunch) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
}
broadcast('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
await backupNachZeitplan('vor-dem-start').catch(() => {});
}
@@ -1947,6 +2241,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
discordpresence.clearActivity();
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
launchStart = 0; // Spielzeit nur einmal gutschreiben
@@ -1954,14 +2249,23 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
}
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
if (evt.phase === 'log') {
const m = VERBINDUNGS_ZEILE.exec(evt.line);
if (m) {
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
letzteQuickPlayServer.set(id, server);
// 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 });
}
}
broadcast('launch:progress', Object.assign({ id }, evt));
});
// Start fehlgeschlagen, obwohl schon gezählt -> Zählung zurücknehmen,
// sonst bliebe das Konto für immer als "spielt" stehen
if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
if (res.ok && res.pid) laufendePid.set(id, res.pid);
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
@@ -1973,8 +2277,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// IPC – Dialoge
// ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFolder', async (_e, title) => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('dialog:pickFolder', async (e, title) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: title || 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
});
@@ -2010,8 +2314,8 @@ ipcMain.handle('file:dataUri', (_e, filePath) => {
} catch { return null; }
});
ipcMain.handle('dialog:pickJava', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('dialog:pickJava', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'java.exe wählen',
properties: ['openFile'],
filters: [{ name: 'Java', extensions: ['exe'] }],
@@ -2028,8 +2332,8 @@ ipcMain.handle('app:openExternal', (_e, url) => {
// IPC – Instanzen aus MultiMC/Prism übernehmen
// ---------------------------------------------------------------------------
ipcMain.handle('mmc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('mmc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)',
properties: ['openDirectory'],
});
@@ -2050,15 +2354,14 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('cf:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('cf:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
properties: ['openDirectory'],
});
@@ -2079,15 +2382,14 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
properties: ['openDirectory'],
});
@@ -2108,15 +2410,14 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('technic:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
properties: ['openDirectory'],
});
@@ -2137,10 +2438,9 @@ ipcMain.handle('technic:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
@@ -2154,9 +2454,12 @@ ipcMain.handle('technicPlatform:search', async (_e, query) => {
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
try {
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p);
broadcast('technicPlatform:progress', p);
});
if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
if (res.ok && res.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
}
return res;
} catch (err) {
return { ok: false, message: err.message };
@@ -2179,8 +2482,8 @@ ipcMain.handle('bedrock:openFolder', (_e, kind) => {
return true;
});
ipcMain.handle('bedrock:installAddon', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('bedrock:installAddon', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Add-on oder Welt importieren',
properties: ['openFile'],
filters: [{ name: 'Bedrock-Pakete', extensions: ['mcpack', 'mcaddon', 'mcworld', 'mctemplate'] }],
@@ -2229,15 +2532,18 @@ ipcMain.handle('icons:extract', () => {
// ---------------------------------------------------------------------------
ipcMain.handle('update:check', async () => {
try { return await updater.checkForUpdate(app.getVersion()); }
catch (err) { return { available: false, error: err.message }; }
try {
const info = await updater.checkForUpdate(app.getVersion());
pendingUpdateInfo = info;
return info;
} catch (err) { return { available: false, error: err.message }; }
});
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => {
try {
const file = await updater.downloadAndInstall(asset, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p);
broadcast('update:progress', p);
});
const res = updater.applyUpdate(file);
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
+50
View File
@@ -0,0 +1,50 @@
// AudioWorklet-Prozessor für die isolierte "Nur Minecraft"-Tonaufnahme.
//
// loopback-capture liefert PCM-Häppchen unregelmäßig getaktet über IPC (mal
// etwas früher, mal etwas später). Würde man sie direkt nacheinander als
// einzelne AudioBufferSourceNode-Aufrufe abspielen, würde jede kleine
// Verzögerung als hörbares Knacksen/Aussetzer auffallen. Stattdessen landen
// ankommende Häppchen hier in einem Ringpuffer; der Audio-Rendering-Thread
// liest in gleichmäßigem Takt (process() läuft alle 128 Samples) daraus.
// Kleine Zeitschwankungen bei der Anlieferung werden so unhörbar
// abgefedert. Läuft der Puffer doch mal leer, gibt es kurz Stille statt
// eines Knacksens.
class PcmRingBufferProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.capacity = sampleRate * 2; // 2 Sekunden Puffer je Kanal
this.ring = [new Float32Array(this.capacity), new Float32Array(this.capacity)];
this.writeIdx = 0;
this.readIdx = 0;
this.available = 0; // gepufferte Frames (nicht Samples)
this.port.onmessage = (e) => this._enqueue(e.data);
}
_enqueue([left, right]) {
const n = left.length;
for (let i = 0; i < n; i++) {
const idx = (this.writeIdx + i) % this.capacity;
this.ring[0][idx] = left[i];
this.ring[1][idx] = right[i];
}
this.writeIdx = (this.writeIdx + n) % this.capacity;
this.available = Math.min(this.capacity, this.available + n);
}
process(_inputs, outputs) {
const output = outputs[0];
const frames = output[0].length;
const playable = Math.min(frames, this.available);
for (let i = 0; i < playable; i++) {
const idx = (this.readIdx + i) % this.capacity;
output[0][i] = this.ring[0][idx];
output[1][i] = this.ring[1][idx];
}
for (let i = playable; i < frames; i++) { output[0][i] = 0; output[1][i] = 0; }
this.readIdx = (this.readIdx + playable) % this.capacity;
this.available -= playable;
return true; // Prozessor am Leben halten, auch wenn gerade nichts ankommt
}
}
registerProcessor('pcm-player', PcmRingBufferProcessor);
+62
View File
@@ -7,6 +7,15 @@
const { contextBridge, ipcRenderer } = require('electron');
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
// damit man sie auch ohne offene DevTools sieht
window.addEventListener('error', (e) => {
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
});
window.addEventListener('unhandledrejection', (e) => {
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
});
contextBridge.exposeInMainWorld('api', {
// Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -241,4 +250,57 @@ contextBridge.exposeInMainWorld('api', {
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
appInfo: () => ipcRenderer.invoke('app:info'),
appLicense: () => ipcRenderer.invoke('app:license'),
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
popoutHide: () => ipcRenderer.send('popout:hide'),
onPopoutReopen: (cb) => {
const l = (_e, params) => cb(params);
ipcRenderer.on('popout:reopen', l);
return () => ipcRenderer.removeListener('popout:reopen', l);
},
onSettingsChanged: (cb) => {
const l = (_e, s) => cb(s);
ipcRenderer.on('settings:changed', l);
return () => ipcRenderer.removeListener('settings:changed', l);
},
onInstancesChanged: (cb) => {
const l = () => cb();
ipcRenderer.on('instances:changed', l);
return () => ipcRenderer.removeListener('instances:changed', l);
},
onSkinChanged: (cb) => {
const l = () => cb();
ipcRenderer.on('skin:changed', l);
return () => ipcRenderer.removeListener('skin:changed', l);
},
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
// Spielaufzeichnung
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
recordingSave: (instanceName, buffer, serverLabel) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel),
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
onRecordingServerDetected: (cb) => {
const l = (_e, payload) => cb(payload);
ipcRenderer.on('recording:serverDetected', l);
return () => ipcRenderer.removeListener('recording:serverDetected', l);
},
recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId),
recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId),
recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel),
recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid),
recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'),
onGameAudioChunk: (cb) => {
const l = (_e, chunk) => cb(chunk);
ipcRenderer.on('recording:gameAudioChunk', l);
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
},
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
onRecordCloseAttempt: (cb) => {
const l = () => cb();
ipcRenderer.on('record:closeAttempt', l);
return () => ipcRenderer.removeListener('record:closeAttempt', l);
},
});
+902 -58
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -90,6 +90,16 @@ const DEFAULT_SETTINGS = {
proxyUser: '',
proxyPass: '',
theme: 'dark',
// Spielaufzeichnung - bleibt über Neustarts erhalten (letzte Wahl je Feld)
recordingResolution: 'native',
recordingQuality: 'medium',
recordingFramerate: 30,
recordingAudioSystem: true,
recordingAudioGame: false,
recordingVolSystem: 100,
recordingVolGame: 100,
recordingMicSelected: [], // Liste gewählter Mikrofon-deviceIds
recordingMicVolumes: {}, // deviceId -> Lautstärke (0-100)
};
function settingsFile() {
+62 -2
View File
@@ -32,6 +32,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
:root[data-theme="dark"] .field select,
:root[data-theme="dark"] .field textarea,
:root[data-theme="dark"] .mods-search input,
:root[data-theme="dark"] .path-row input,
:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)}
:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618}
:root[data-theme="dark"] .modal-head{background:#333337}
@@ -67,7 +68,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
:root[data-theme="dark"] .mods-filters select,
:root[data-theme="dark"] .mods-search select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
:root[data-theme="dark"] .mods-search select,
:root[data-theme="dark"] .mmc-bar select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
:root[data-theme="dark"] .modal-close{color:#aaa}
:root[data-theme="dark"] .modal-close:hover{color:#fff}
@@ -185,6 +187,38 @@ body.file-drop-active::after{
.modal-body{padding:16px;overflow:auto;background:#f6f6f6}
.modal-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:#efefef}
/* Popup-Fenster (Einstellungen/Konsole): Haupt-UI ausblenden, das eine Modal
füllt das ganze (rahmenlose) Fenster; der eigene Kopfbereich ersetzt die
native Titelleiste – per -webkit-app-region per Maus verschiebbar */
body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>footer.statusbar{display:none}
.popout-host{position:static;inset:auto;background:none;display:block}
/* !important: schlägt auch die spezifischere #modal-settings-Breitenregel oben */
.popout-host .modal-card{width:100%!important;max-width:none!important;height:100vh!important;max-height:none!important;border:none;border-radius:0;box-shadow:none;margin:0}
/* Inhalt soll die restliche Fensterhöhe füllen (scrollen statt Leerraum unter dem Fuß) */
.popout-host .modal-body{flex:1 1 auto;min-height:0}
/* Konsole/Logs/Screenshots haben einen einzelnen scrollbaren Kasten mit
fester Höhe (für den normalen Dialog gedacht) – im Popup-Fenster soll der
stattdessen die volle verbleibende Höhe ausfüllen statt Leerraum darunter
zu lassen */
#modal-console.popout-host .modal-body,
#modal-logs.popout-host .modal-body,
#modal-screens.popout-host .modal-body,
#modal-servers.popout-host .modal-body,
#modal-mods.popout-host .modal-body,
#modal-record.popout-host .modal-body{display:flex;flex-direction:column}
.popout-host .console-out,
#modal-logs.popout-host .crash-voll,
.popout-host .screens-grid,
.popout-host .mods-list,
.popout-host .record-stage{flex:1 1 auto;height:auto;max-height:none;min-height:0}
/* Mods-Fenster: die aktive Ansicht (Katalog/Installiert) muss selbst ein
Flex-Container werden, damit ihre eigene Liste (.mods-list) die Regel oben
nutzen und den restlichen Platz füllen kann */
#modal-mods.popout-host #m-pane-catalog,
#modal-mods.popout-host #m-pane-installed{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}
.popout-host .modal-head{-webkit-app-region:drag;cursor:default}
.popout-host .modal-head button,.popout-host .modal-head input,.popout-host .modal-head select{-webkit-app-region:no-drag}
/* Formular */
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
.field>span{font-size:12px;color:#333}
@@ -196,7 +230,8 @@ body.file-drop-active::after{
.backup-select-field{margin-top:14px;margin-bottom:0}
.backup-select-field select{width:100%}
.hint{font-size:12px}
.path-row{display:flex;gap:8px}.path-row input{flex:1}
.path-row{display:flex;gap:8px}
.path-row input{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
.advanced legend{color:var(--dim);font-size:12px;padding:0 6px}
.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1}
@@ -286,6 +321,8 @@ body.file-drop-active::after{
/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird
ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */
.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
.skin-bild canvas:active{cursor:grabbing}
.crash-details{margin-top:12px}
.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
@@ -299,6 +336,8 @@ body.file-drop-active::after{
/* MultiMC-Import */
.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.mmc-bar select{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
.mmc-bar select:focus{outline:none;border-color:var(--accent)}
.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
.mmc-item+.mmc-item{border-top:1px solid var(--border2)}
.mmc-item:hover{background:rgba(128,128,128,.10)}
@@ -340,6 +379,21 @@ img.account-av{object-fit:cover;image-rendering:auto}
.screens-toolbar .hint{font-size:11px}
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
/* Spielaufzeichnung */
.record-search{display:flex;flex-direction:column;align-items:center;gap:10px;padding:48px 0;color:var(--dim)}
.record-stage{display:flex;flex-direction:column;gap:10px}
.record-video{width:100%;flex:1 1 auto;min-height:360px;background:#000;border-radius:4px;object-fit:contain}
.record-toolbar{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
.record-live-badge{display:flex;align-items:center;gap:8px;color:var(--danger);font-size:13px;font-weight:600}
#record-timer{font-variant-numeric:tabular-nums}
.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--danger);animation:rec-pulse 1s infinite}
@keyframes rec-pulse{0%,100%{opacity:1}50%{opacity:.25}}
.record-audio-bar{display:flex;align-items:center;gap:10px;padding:2px 2px}
.record-mic-list{display:flex;flex-direction:column;gap:8px;margin-top:8px}
.record-source-row{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.record-source-row .checkbox-field{margin-bottom:0;flex:1;min-width:0;font-size:13px;gap:6px}
.record-vol{width:110px}
/* Konsole (dunkel wie ein Log) */
.console-out{height:440px;overflow:auto;margin:0;background:#1e1e1e;border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:12px;color:#d4d4d4;white-space:pre-wrap;word-break:break-word;line-height:1.5}
.log-line{white-space:pre-wrap}
@@ -413,6 +467,12 @@ img.account-av{object-fit:cover;image-rendering:auto}
.path-row{flex-direction:column}
}
/* Popup-Fenster sind eigenständige, meist schmalere Fenster -- die
Zeilenumbruch-Regel oben ist für die (deutlich breitere) Haupt-App gedacht
und soll hier nicht greifen (sonst z. B. Version/Icon im Instanz-Fenster
untereinander statt nebeneinander) */
.is-popout .field-row{flex-direction:row}
/* Konto-Button mit 3D-Skin-Kopf */
.acct-btn{gap:8px}
.acct-av{display:inline-flex;align-items:center;justify-content:center}