diff --git a/src/bedrock.js b/src/bedrock.js
index eaca930..323b6af 100644
--- a/src/bedrock.js
+++ b/src/bedrock.js
@@ -231,8 +231,8 @@ function installAddon(filePath) {
if (!/\.(mcpack|mcaddon|mcworld|mctemplate)$/i.test(filePath)) {
return { ok: false, message: 'Nur .mcpack, .mcaddon, .mcworld oder .mctemplate möglich.' };
}
- const child = spawn('cmd', ['/c', 'start', '', filePath], { detached: true, stdio: 'ignore' });
- child.unref();
+ // über die Shell öffnen, nicht über "cmd /c start": Zeichen wie & im Dateinamen wären dort Befehlstrenner
+ require('electron').shell.openPath(filePath).catch(() => {});
return { ok: true };
}
diff --git a/src/index.html b/src/index.html
index 882aac3..51e93f5 100644
--- a/src/index.html
+++ b/src/index.html
@@ -280,6 +280,14 @@
Windows-BenachrichtigungenMeldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.
+
+
Stream Deck erlaubenÖffnet eine Schnittstelle, die nur dieser PC erreicht, damit das AeroMC-Plugin Instanzen starten, Clips speichern und die Aufnahme steuern kann.
+
+
+
+
Stream-Deck-PluginDas Plugin liegt in AeroMC bei und wird beim Einschalten selbst in die Stream-Deck-Software kopiert. Danach die Stream-Deck-Software einmal neu starten – die Tasten stehen dann unter „AeroMC“.
+
+
Bedrock-Bereich anzeigenWird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.
diff --git a/src/main.js b/src/main.js
index 671d15f..7ce0f06 100644
--- a/src/main.js
+++ b/src/main.js
@@ -127,6 +127,8 @@ const skins = require('./skins');
const servers = require('./servers');
const mcping = require('./mcping');
const discordpresence = require('./discordpresence');
+const streamdeck = require('./streamdeck');
+const streamdeckplugin = require('./streamdeckplugin');
const friends = require('./friends');
const crashhelp = require('./crashhelp');
@@ -245,7 +247,8 @@ function broadcast(channel, payload) {
// von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
-function openPopout(modalId, params, von) {
+// still: Fenster erscheint, ohne dem Spiel den Fokus zu nehmen (Stream Deck: Aufnahme/Clip-Modus)
+function openPopout(modalId, params, von, still) {
const wachsen = !!von && store.getSettings().uiAnimationen !== false;
let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) {
@@ -282,7 +285,7 @@ function openPopout(modalId, params, von) {
});
popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query });
- win.once('ready-to-show', () => { if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
+ win.once('ready-to-show', () => { if (still) { win.showInactive(); return; } if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
});
@@ -302,7 +305,11 @@ function openPopout(modalId, params, von) {
}
let recordingGuardActive = false;
-ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
+ipcMain.on('recording:guard', (_e, active, instanceId) => {
+ recordingGuardActive = !!active;
+ if (active) zeigeAufnahmeHinweis(instanceId).catch(() => {});
+ else stoppeAufnahmeHinweis();
+});
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (e, modalId, params, knopf) => {
@@ -556,6 +563,7 @@ let clipInstanz = null;
ipcMain.handle('clip:setActive', (_e, aktiv, instanceId) => {
if (clipKuerzel) { globalShortcut.unregister(clipKuerzel); clipKuerzel = null; }
clipInstanz = aktiv ? instanceId : null;
+ if (aktiv) sdClipStartetBis = 0;
if (!aktiv) return null;
for (const kuerzel of CLIP_TASTENKUERZEL) {
const ok = globalShortcut.register(kuerzel, () => {
@@ -639,8 +647,11 @@ ipcMain.handle('recording:list', (_e, instanceName) => {
} catch { return []; }
});
+// nur ein schlichter Dateiname - nie ein Pfad, der aus dem Aufnahmen-Ordner hinausführt
+const istDateiname = (n) => typeof n === 'string' && !!n && n === path.basename(n) && n !== '.' && n !== '..';
+
ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => {
- if (!ffmpegPfad) return null;
+ if (!ffmpegPfad || !istDateiname(dateiname)) return null;
const ordner = aufzeichnungsOrdner(instanceName);
const quelle = path.join(ordner, dateiname);
if (!fs.existsSync(quelle)) return null;
@@ -661,6 +672,7 @@ ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => {
});
ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => {
+ if (!istDateiname(dateiname)) return false;
const ordner = aufzeichnungsOrdner(instanceName);
try {
fs.rmSync(path.join(ordner, dateiname), { force: true });
@@ -670,6 +682,7 @@ ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => {
});
ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
+ if (!istDateiname(dateiname)) return false;
const ziel = path.join(aufzeichnungsOrdner(instanceName), dateiname);
if (fs.existsSync(ziel)) shell.openPath(ziel);
return true;
@@ -1092,6 +1105,7 @@ app.whenReady().then(() => {
store.setzeTresor(tresor);
applyProxy(store.getSettings());
discordpresence.configure(store.getSettings());
+ initStreamdeck();
const autostartAn = !!store.getSettings().autostart;
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update)
@@ -1178,7 +1192,7 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
-app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); });
+app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); streamdeck.shutdown(); });
app.on('will-quit', () => { globalShortcut.unregisterAll(); });
// ---------------------------------------------------------------------------
@@ -1208,6 +1222,10 @@ ipcMain.handle('settings:save', (_e, patch) => {
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'streamdeckEnabled')) {
+ streamdeck.configure(s);
+ if (s.streamdeckEnabled === true) installiereStreamdeckPlugin('schalter');
+ }
// Beta-Teilnahme umgeschaltet -> gleich neu nach Updates suchen
if (patch && Object.prototype.hasOwnProperty.call(patch, 'betaTeilnahme') && !!patch.betaTeilnahme !== betaStand) {
betaStand = !!patch.betaTeilnahme;
@@ -2044,8 +2062,9 @@ ipcMain.handle('auth:recheck', () => checkAuthServices());
// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal
ipcMain.handle('auth:accounts', () => auth.listAccounts()
.map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) })));
-ipcMain.handle('auth:setActive', (_e, id) => {
+function setzeAktivesKonto(id) {
const res = auth.setActive(id);
+ sdFreunde = null; // Freundesliste gehört zum Konto -> neu holen
// Ohne das hier bleibt z. B. ein separates Konten- oder Aufzeichnungs-
// Fenster (eigener Renderer-Prozess, siehe cross-window state) auf dem
// alten Konto stehen, bis irgendein anderes Ereignis zufällig ein Neuladen
@@ -2057,7 +2076,8 @@ ipcMain.handle('auth:setActive', (_e, id) => {
aktualisiereAnfragenZahl().catch(() => {});
pruefeNachrichten().catch(() => {});
return res;
-});
+}
+ipcMain.handle('auth:setActive', (_e, id) => setzeAktivesKonto(id));
// ---- Skin des aktiven Kontos ----
@@ -2886,6 +2906,8 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
});
overlayFenster.setIgnoreMouseEvents(true);
overlayFenster.setAlwaysOnTop(true, 'screen-saver');
+ // Hinweise gehören nicht ins aufgenommene Video (siehe zeigeAufnahmeHinweis)
+ if (ausblendenAusAufnahmeMoeglich()) overlayFenster.setContentProtection(true);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// charset=utf-8 nicht vergessen: ohne das interpretiert Chromium die
@@ -2910,6 +2932,67 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
return true;
}
+// ---------- Aufnahme-Hinweis im Spiel ----------
+// Kleine "● REC 00:25"-Anzeige oben rechts im Minecraft-Fenster, solange eine Aufnahme läuft.
+// setContentProtection nimmt das Fenster aus Bildschirmaufnahmen heraus (Windows 10 ab 2004:
+// WDA_EXCLUDEFROMCAPTURE) - die Anzeige ist also nie im Video. Auf älteren Windows-Fassungen
+// würde daraus ein schwarzes Rechteck im Video, dort gibt es die Anzeige deshalb nicht.
+let aufnahmeAnzeige = null; // { win, timer }
+
+function ausblendenAusAufnahmeMoeglich() {
+ const build = Number(String(os.release()).split('.')[2]);
+ return process.platform === 'win32' && build >= 19041;
+}
+
+function stoppeAufnahmeHinweis() {
+ if (!aufnahmeAnzeige) return;
+ clearInterval(aufnahmeAnzeige.timer);
+ if (!aufnahmeAnzeige.win.isDestroyed()) aufnahmeAnzeige.win.destroy();
+ aufnahmeAnzeige = null;
+}
+
+async function zeigeAufnahmeHinweis(instanceId) {
+ stoppeAufnahmeHinweis();
+ if (!ausblendenAusAufnahmeMoeglich()) return;
+ const pid = laufendePid.get(instanceId) || [...laufendePid.values()].pop();
+ const fenster = await findeMinecraftFenster(pid);
+ if (!fenster || !recordingGuardActive || aufnahmeAnzeige) return; // inzwischen beendet bzw. schon eine Anzeige
+ const B = 118, H = 28;
+ const ort = (b) => ({ x: Math.round(b.x + b.width - B - 20), y: Math.round(b.y + 40), width: B, height: H });
+ const win = new BrowserWindow(Object.assign(ort(fenster.getBounds()), {
+ frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, focusable: false,
+ resizable: false, hasShadow: false, show: false,
+ webPreferences: { contextIsolation: true, sandbox: true },
+ }));
+ win.setContentProtection(true);
+ win.setIgnoreMouseEvents(true);
+ win.setAlwaysOnTop(true, 'screen-saver');
+ const html = `data:text/html;charset=utf-8,${encodeURIComponent(`
+
+
+ REC00:00
+
+ `)}`;
+ win.loadURL(html);
+ win.once('ready-to-show', () => { if (!win.isDestroyed()) win.showInactive(); });
+ // mit dem Spielfenster mitwandern; ist das Fenster weg (Spiel beendet), verschwindet die Anzeige
+ const timer = setInterval(async () => {
+ const f = await findeMinecraftFenster(pid);
+ if (!f || win.isDestroyed()) { stoppeAufnahmeHinweis(); return; }
+ win.setBounds(ort(f.getBounds()));
+ }, 1000);
+ aufnahmeAnzeige = { win, timer };
+}
+
// ---------- Tastenkürzel: Online-Freunde ingame anzeigen ----------
// Nur registriert, solange über diesen Launcher ein Spiel läuft - ein globales
// Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme
@@ -3543,7 +3626,11 @@ ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
+// Unterordner (saves, mods, screenshots …) ist immer genau ein Ordnername - nie ein Pfad mit ".."
+const istOrdnerName = (s) => typeof s === 'string' && /^[\w.-]+$/.test(s) && s !== '.' && s !== '..';
+
function safeSub(id, sub, name) {
+ if (!istOrdnerName(sub) || typeof name !== 'string' || !name) return null;
const base = path.join(store.gameDir(id), sub);
const target = path.join(base, name);
// Pfad-Ausbruch verhindern
@@ -3558,6 +3645,7 @@ ipcMain.handle('instance:deleteContent', (_e, id, sub, name) => {
});
ipcMain.handle('instance:openSubdir', (_e, id, sub) => {
+ if (!istOrdnerName(sub) || !store.getInstance(id)) return false;
const p = path.join(store.gameDir(id), sub);
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
shell.openPath(p);
@@ -4262,9 +4350,25 @@ async function verschiebeInhalt(quelle, ziel, nur) {
}
}
+/*
+ * Vor dem Entpacken alle Pfade im Archiv prüfen: ".."-Teile, absolute Pfade und Laufwerksangaben
+ * könnten sonst Dateien außerhalb des Zielordners anlegen ("Zip Slip"). Gelesen wird nur das
+ * Inhaltsverzeichnis, nicht das ganze Archiv.
+ */
+async function pruefeZipPfade(zipPfad) {
+ const verzeichnis = await require('unzipper').Open.file(zipPfad);
+ for (const eintrag of verzeichnis.files || []) {
+ const teile = String(eintrag.path || '').replace(/\\/g, '/').split('/');
+ if (teile.includes('..') || String(eintrag.path || '').startsWith('/') || /^[a-z]:/i.test(String(eintrag.path || '')) || /[\0]/.test(String(eintrag.path || ''))) {
+ throw new Error('Das Archiv enthält ungültige Pfade und wurde nicht entpackt.');
+ }
+ }
+}
+
async function importInstanceZipFromPath(filePath) {
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
try {
+ await pruefeZipPfade(filePath);
fs.mkdirSync(tmp, { recursive: true });
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
{ VN_SRC: filePath, VN_DST: tmp });
@@ -4781,6 +4885,297 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
}
});
+// ---------------------------------------------------------------------------
+// Stream Deck (lokale Schnittstelle, siehe streamdeck.js – nur auf Wunsch aktiv)
+// ---------------------------------------------------------------------------
+
+function aufnahmeFenster() {
+ const win = popoutWindows.get('record');
+ return win && !win.isDestroyed() ? win : null;
+}
+
+/*
+ * Aufnahme bzw. Clip-Modus per Stream Deck umschalten. Gibt es noch kein
+ * Aufnahme-Fenster, wird es für die laufende Instanz geöffnet; der
+ * Renderer sucht das Spielfenster, wählt es und führt die Aktion dann selbst aus
+ * (Parameter "sd"). Das Fenster erscheint ohne Fokus und verschwindet nach dem Start wieder
+ * (sonst läge es über dem Spiel und würde mit aufgenommen). Rückmeldung zusätzlich im Spiel.
+ */
+let sdBackup = { laeuft: false, zeit: 0, ok: null }; // Ergebnis der letzten Sicherung per Stream Deck
+
+// Freunde online: höchstens alle 45 s vom Server holen, nicht bei jeder Stream-Deck-Abfrage
+let sdFreunde = null;
+let sdFreundeLaedt = false;
+function sdFreundeStand() {
+ const konto = aktivesFreundeKonto();
+ if (!konto || store.getSettings().friendsEnabled === false) return null;
+ const veraltet = !sdFreunde || sdFreunde.uuid !== konto.uuid || Date.now() - sdFreunde.zeit > 45000;
+ if (veraltet && !sdFreundeLaedt) {
+ sdFreundeLaedt = true;
+ friends.getFriends(konto.uuid)
+ .then(async (res) => {
+ if (!res.ok || !Array.isArray(res.data)) return;
+ const liste = await mitNamen(res.data);
+ const online = liste.filter((f) => f.online);
+ sdFreunde = { uuid: konto.uuid, zeit: Date.now(), gesamt: liste.length, online: online.length, namen: online.slice(0, 6).map((f) => f.username) };
+ })
+ .catch(() => {})
+ .finally(() => { sdFreundeLaedt = false; });
+ }
+ return sdFreunde && sdFreunde.uuid === konto.uuid ? { gesamt: sdFreunde.gesamt, online: sdFreunde.online, namen: sdFreunde.namen } : null;
+}
+
+let sdClipStartetBis = 0; // Clip-Modus wird gerade per Stream Deck gestartet (dauert einige Sekunden)
+
+async function aufnahmeAktion(art) {
+ const win = aufnahmeFenster();
+ const id = [...laufendePid.keys()].pop(); // zuletzt gestartete laufende Instanz
+ if (!id && !recordingGuardActive && !clipInstanz) return { ok: false, message: 'Minecraft läuft nicht.' };
+ if (art === 'clipmodus' && !clipInstanz) sdClipStartetBis = Date.now() + 60000;
+ if (!win) {
+ openPopout('record', { instanceId: id, sd: art }, null, true);
+ return { ok: true };
+ }
+ win.webContents.send('streamdeck:aktion', { art, instanceId: id || null });
+ return { ok: true };
+}
+
+ipcMain.on('streamdeck:fensterZeigen', (e) => {
+ const win = BrowserWindow.fromWebContents(e.sender);
+ if (win && !win.isDestroyed()) { win.show(); win.focus(); }
+});
+
+ipcMain.on('streamdeck:meldung', (_e, titel, text, fehler) => {
+ if (fehler) { sdClipStartetBis = 0; broadcast('ui:toast', { text: String(titel || '') + ': ' + String(text || ''), error: true }); }
+ const pid = [...laufendePid.values()].pop();
+ if (pid) zeigeIngameOverlay(String(titel || ''), String(text || ''), pid, 2500).catch(() => {});
+});
+
+/*
+ * Screenshot vom Spielfenster: Bildschirm erfassen und auf das Minecraft-Fenster zuschneiden
+ * (derselbe Weg wie bei der Aufnahme, siehe findeFensterUndBildschirm). Landet im
+ * Screenshot-Ordner der Instanz wie ein Screenshot aus dem Spiel (F2).
+ */
+async function macheScreenshot() {
+ const id = [...laufendePid.keys()].pop();
+ if (!id) return { ok: false, message: 'Minecraft läuft nicht.' };
+ const pid = laufendePid.get(id);
+ const info = await findeFensterUndBildschirm(pid, null);
+ if (!info) return { ok: false, message: 'Spielfenster nicht gefunden.' };
+ const anzeige = screen.getDisplayMatching(info.fensterBounds);
+ const px = { width: Math.round(anzeige.bounds.width * anzeige.scaleFactor), height: Math.round(anzeige.bounds.height * anzeige.scaleFactor) };
+ const quellen = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: px });
+ const quelle = quellen.find((q) => q.id === info.screenSourceId) || quellen[0];
+ if (!quelle || quelle.thumbnail.isEmpty()) return { ok: false, message: 'Bildschirm nicht erfassbar.' };
+ const g = quelle.thumbnail.getSize();
+ const sx = g.width / info.bildschirmBounds.width;
+ const sy = g.height / info.bildschirmBounds.height;
+ const b = info.fensterBounds;
+ // unsichtbarer Fensterrahmen (siehe Aufnahme) - nur bei Fenstern, die nicht den ganzen Bildschirm füllen
+ const rand = (b.width >= info.bildschirmBounds.width && b.height >= info.bildschirmBounds.height) ? 0 : 8;
+ const x = Math.max(0, Math.round((b.x - info.bildschirmBounds.x + rand) * sx));
+ const y = Math.max(0, Math.round((b.y - info.bildschirmBounds.y + rand) * sy));
+ const w = Math.min(g.width - x, Math.round((b.width - rand * 2) * sx));
+ const h = Math.min(g.height - y, Math.round((b.height - rand * 2) * sy));
+ if (w < 16 || h < 16) return { ok: false, message: 'Spielfenster nicht sichtbar.' };
+ const png = quelle.thumbnail.crop({ x, y, width: w, height: h }).toPNG();
+ const ordner = path.join(store.gameDir(id), 'screenshots');
+ fs.mkdirSync(ordner, { recursive: true });
+ const d = new Date();
+ const p2 = (n) => String(n).padStart(2, '0');
+ const basis = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}_${p2(d.getHours())}.${p2(d.getMinutes())}.${p2(d.getSeconds())}`;
+ let datei = path.join(ordner, basis + '.png');
+ for (let n = 1; fs.existsSync(datei); n++) datei = path.join(ordner, `${basis}_${n}.png`);
+ fs.writeFileSync(datei, png);
+ zeigeIngameOverlay('Screenshot', 'Gespeichert', pid, 2500).catch(() => {});
+ return { ok: true };
+}
+
+/*
+ * Das mitgelieferte Plugin in die Stream-Deck-Software kopieren (siehe streamdeckplugin.js).
+ * modus: 'start' = beim Programmstart (Fehler still), 'schalter' = beim Einschalten der Option,
+ * 'knopf' = per Schaltfläche (zusätzlich "schon aktuell" melden)
+ */
+function installiereStreamdeckPlugin(modus) {
+ const res = streamdeckplugin.installiere({ quelle: streamdeckplugin.quelle(app.isPackaged, process.resourcesPath) });
+ if (res.ok && res.neustart) {
+ broadcast('ui:toast', { text: `Stream-Deck-Plugin ${res.status === 'installiert' ? 'installiert' : 'aktualisiert'} – bitte die Stream-Deck-Software einmal neu starten.` });
+ } else if (res.ok && modus === 'knopf') {
+ broadcast('ui:toast', { text: 'Das Stream-Deck-Plugin ist schon auf dem neuesten Stand.' });
+ } else if (!res.ok && modus !== 'start') {
+ broadcast('ui:toast', { text: res.message, error: true });
+ }
+ return res;
+}
+
+ipcMain.handle('streamdeck:installPlugin', () => installiereStreamdeckPlugin('knopf'));
+
+function initStreamdeck() {
+ streamdeck.init({
+ userDataDir: app.getPath('userData'),
+ zustand: (opt) => {
+ // Instanzen einmal lesen, ohne die Bilddateien selbst (der Stream Deck fragt alle 2 Sekunden)
+ const alle = store.listInstances({ ohneBild: true });
+ const bildId = (i) => {
+ if (!i.customImage) return 'k:' + (i.icon || 'grass');
+ try { return 'c:' + i.customImage + ':' + fs.statSync(path.join(store.instancePath(i.id), i.customImage)).mtimeMs; } catch { return 'c:' + i.customImage; }
+ };
+ const zuletzt = alle.filter((i) => i.lastPlayed).sort((x, y) => String(y.lastPlayed).localeCompare(String(x.lastPlayed)))[0];
+ return {
+ instanzen: alle.map((i) => ({
+ id: i.id,
+ name: i.name,
+ laeuft: laufendeInstanzen.has(i.id),
+ gesamt: i.totalPlaySeconds || 0,
+ bildId: bildId(i),
+ zuletzt: i.lastPlayed || null,
+ seit: laufendeInstanzen.has(i.id) && sitzungsInfo.get(i.id) ? sitzungsInfo.get(i.id).start : null,
+ })),
+ aufnahme: recordingGuardActive,
+ clip: !!clipInstanz,
+ clipStartet: !clipInstanz && Date.now() < sdClipStartetBis,
+ schnellstart: store.getSettings().schnellstart || {},
+ konten: auth.listAccounts().map((a) => ({ id: a.id, name: a.name, aktiv: !!a.active, spielt: spielendeKonten.has(a.id) })),
+ // Freunde nur holen, wenn eine Taste sie anzeigt (kostet eine Anfrage an den Server)
+ freunde: opt && opt.freunde ? sdFreundeStand() : null,
+ backup: { laeuft: backupLaeuft, zeit: sdBackup.zeit, ok: sdBackup.ok },
+ zuletzt: zuletzt ? zuletzt.id : null,
+ };
+ },
+ starte: async (id, server) => {
+ if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
+ if (laufendeInstanzen.has(id)) return { ok: false, message: 'Läuft bereits.' };
+ if (!mainWindow || mainWindow.isDestroyed()) return { ok: false, message: 'Launcher-Fenster fehlt.' };
+ starteInstanzImFenster(id, server || null); // gleicher Weg wie die Desktop-Verknüpfung
+ return { ok: true };
+ },
+ neustart: async (id) => {
+ const pid = laufendePid.get(id);
+ if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
+ require('child_process').spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
+ // warten, bis Minecraft wirklich beendet ist (Speichern kann dauern), dann neu starten
+ const server = letzteQuickPlayServer.get(id) || null;
+ const bis = Date.now() + 90000;
+ const t = setInterval(() => {
+ if (laufendeInstanzen.has(id) && Date.now() < bis) return;
+ clearInterval(t);
+ if (!laufendeInstanzen.has(id)) setTimeout(() => starteInstanzImFenster(id, server), 1500);
+ }, 500);
+ return { ok: true };
+ },
+ fenster: async () => {
+ if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible() || mainWindow.isMinimized() || !mainWindow.isFocused()) showMainWindow();
+ else mainWindow.minimize();
+ return { ok: true };
+ },
+ clipModus: () => aufnahmeAktion('clipmodus'),
+ ordner: async (id) => {
+ const inst = id ? store.getInstance(id) : null;
+ const dir = inst ? aufzeichnungsOrdner(inst.name) : path.dirname(aufzeichnungsOrdner('x'));
+ fs.mkdirSync(dir, { recursive: true });
+ shell.openPath(dir);
+ return { ok: true };
+ },
+ screenshot: () => macheScreenshot(),
+ kopf: async (uuid) => {
+ if (!/^[0-9a-f-]{32,36}$/i.test(String(uuid || ''))) return { ok: false };
+ const uri = await spielerKopf(String(uuid || ''));
+ return uri ? { ok: true, pixel: false, bild: uri } : { ok: false };
+ },
+ absturz: async (id) => {
+ const inst = store.getInstance(id);
+ if (!inst) return { ok: false };
+ const r = crashreports.lies(store.gameDir(id), 0);
+ if (!r.ok) return { ok: true, bericht: false };
+ const zeit = Date.parse(r.zeitpunkt);
+ // "frisch" = der Bericht stammt aus dem letzten Spielstart (nicht von einem früheren Absturz)
+ const frisch = !inst.lastPlayed || zeit >= Date.parse(inst.lastPlayed) - 5000;
+ return { ok: true, bericht: true, zeit, frisch };
+ },
+ konto: async (id) => {
+ if (!auth.listAccounts().some((a) => a.id === id)) return { ok: false, message: 'Konto nicht gefunden.' };
+ setzeAktivesKonto(id);
+ return { ok: true };
+ },
+ backup: async () => {
+ if (backupLaeuft) return { ok: false, message: 'Läuft bereits.' };
+ sdBackup = { laeuft: true, zeit: 0, ok: null };
+ erstelleBackup({ still: true })
+ .then((r) => { sdBackup = { laeuft: false, zeit: Date.now(), ok: !!(r && r.ok) }; })
+ .catch(() => { sdBackup = { laeuft: false, zeit: Date.now(), ok: false }; });
+ return { ok: true };
+ },
+ oeffne: async (art, id) => {
+ const instanz = (id && store.getInstance(id) ? id : null) || [...laufendePid.keys()].pop()
+ || (store.listInstances().filter((i) => i.lastPlayed).sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed)))[0] || {}).id;
+ if (art === 'friends') { openPopout('friends', {}); return { ok: true }; }
+ if (art === 'console') { openPopout('console', {}); return { ok: true }; }
+ if (!instanz) return { ok: false, message: 'Keine Instanz.' };
+ if (art === 'logs') { openPopout('logs', { instanceId: instanz }); return { ok: true }; }
+ if (art === 'crash') {
+ if (!crashreports.lies(store.gameDir(instanz), 0).ok) return { ok: false, message: 'Kein Absturzbericht.' };
+ openPopout('crash', { instanceId: instanz, seit: 0 });
+ return { ok: true };
+ }
+ return { ok: false, message: 'Unbekannt.' };
+ },
+ symbol: async (id) => {
+ const inst = store.getInstance(id);
+ if (!inst) return { ok: false };
+ // eigenes Bild verkleinert (die Taste ist nur 144 px groß), sonst das Minecraft-Icon des Schlüssels
+ if (inst.customImage) {
+ try {
+ const d = store.iconDataUri(id);
+ const bild = d ? nativeImage.createFromDataURL(d) : null;
+ if (bild && !bild.isEmpty()) {
+ const g = bild.getSize();
+ const k = Math.min(1, 96 / Math.max(g.width, g.height));
+ return { ok: true, pixel: false, bild: bild.resize({ width: Math.max(1, Math.round(g.width * k)), height: Math.max(1, Math.round(g.height * k)) }).toDataURL() };
+ }
+ } catch { /* dann Minecraft-Icon */ }
+ }
+ try {
+ const schluessel = String(inst.icon || 'grass');
+ if (!/^[\w-]+$/.test(schluessel)) return { ok: false };
+ const datei = path.join(iconDirs().out, schluessel + '.png');
+ return { ok: true, pixel: true, bild: 'data:image/png;base64,' + fs.readFileSync(datei).toString('base64') };
+ } catch { return { ok: false }; }
+ },
+ serverListe: async (id) => {
+ if (!store.getInstance(id)) return { ok: false, server: [] };
+ const r = servers.lies(store.gameDir(id));
+ return { ok: !!r.ok, server: (r.server || []).map((x) => ({ name: x.name, ip: x.ip })) };
+ },
+ ping: async (ip) => {
+ const m = String(ip || '').trim().match(/^([^:]+)(?::(d+))?$/);
+ if (!m) return { ok: false };
+ try {
+ const r = await mcping.pingServer(m[1], m[2] ? Number(m[2]) : 25565);
+ return { ok: !!r.ok, online: r.online, max: r.max, latencyMs: r.latencyMs };
+ } catch { return { ok: false }; }
+ },
+ beende: async (id) => {
+ const pid = laufendePid.get(id);
+ if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
+ // ohne /F: Minecraft bekommt die Bitte zum Schließen und speichert sauber
+ require('child_process').spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
+ return { ok: true };
+ },
+ clipSpeichern: async () => {
+ const win = aufnahmeFenster();
+ if (!clipInstanz || !win) return { ok: false, message: 'Clip-Modus ist nicht aktiv.' };
+ const pid = laufendePid.get(clipInstanz);
+ if (pid) zeigeIngameOverlay('Clip', 'Wird gespeichert …', pid, 2500).catch(() => {});
+ win.webContents.send('clip:hotkey');
+ return { ok: true };
+ },
+ aufnahmeUmschalten: () => aufnahmeAktion('aufnahme'),
+ });
+ streamdeck.configure(store.getSettings());
+ // nach einem Launcher-Update bekommt auch das Plugin die neue Fassung
+ if (store.getSettings().streamdeckEnabled === true) setTimeout(() => installiereStreamdeckPlugin('start'), 6000);
+}
+
// ---------------------------------------------------------------------------
// IPC – Dialoge
// ---------------------------------------------------------------------------
@@ -4813,6 +5208,8 @@ ipcMain.handle('instances:icon', (_e, id) => store.iconDataUri(id));
// beliebige Bilddatei als data:-URI (für Vorschau vor dem Speichern)
ipcMain.handle('file:dataUri', (_e, filePath) => {
try {
+ // nur Bilddateien - sonst ließe sich damit jede beliebige Datei auslesen
+ if (typeof filePath !== 'string' || !/\.(png|jpe?g|gif|webp|bmp)$/i.test(filePath)) return null;
const stat = fs.statSync(filePath);
if (stat.size > 8 * 1024 * 1024) return null; // >8 MB nicht als Vorschau
let ext = path.extname(filePath).slice(1).toLowerCase();
@@ -5102,7 +5499,8 @@ ipcMain.handle('bedrock:installAddon', async (e) => {
// Weltordner im Explorer zeigen
ipcMain.handle('bedrock:openWorld', (_e, dir) => {
const base = bedrock.folderPath('worlds');
- if (base && dir && dir.startsWith(base) && fs.existsSync(dir)) shell.openPath(dir);
+ const voll = typeof dir === 'string' ? path.resolve(dir) : '';
+ if (base && voll.startsWith(path.resolve(base) + path.sep) && fs.existsSync(voll)) shell.openPath(voll);
return true;
});
diff --git a/src/preload.js b/src/preload.js
index 786c113..3b19e98 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -409,6 +409,14 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('clip:hotkey', l);
return () => ipcRenderer.removeListener('clip:hotkey', l);
},
+ onStreamdeckAktion: (cb) => {
+ const l = (_e, d) => cb(d);
+ ipcRenderer.on('streamdeck:aktion', l);
+ return () => ipcRenderer.removeListener('streamdeck:aktion', l);
+ },
+ streamdeckInstalliere: () => ipcRenderer.invoke('streamdeck:installPlugin'),
+ streamdeckMeldung: (titel, text, fehler) => ipcRenderer.send('streamdeck:meldung', titel, text, !!fehler),
+ streamdeckFensterZeigen: () => ipcRenderer.send('streamdeck:fensterZeigen'),
onUiToast: (cb) => {
const l = (_e, t) => cb(t);
ipcRenderer.on('ui:toast', l);
@@ -446,7 +454,7 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('recording:gameAudioChunk', l);
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
},
- recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
+ recordingSetGuard: (active, instanceId) => ipcRenderer.send('recording:guard', active, instanceId),
onRecordCloseAttempt: (cb) => {
const l = () => cb();
ipcRenderer.on('record:closeAttempt', l);
diff --git a/src/renderer.js b/src/renderer.js
index 5978ac9..9c8e57c 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -4100,12 +4100,15 @@ async function pollForMinecraftWindow() {
} else if (sources.length > 1) {
clearInterval(mcSearchTimer);
showRecordingPicker(sources);
+ // per Stream Deck gestartet: das Fenster ist unsichtbar, die Auswahl braucht aber dich
+ if (sdAusstehend) { window.api.streamdeckFensterZeigen(); sdMeldung('Aufnahme', 'Bitte Spielfenster wählen'); }
} else {
mcSearchElapsed += 1000;
if (mcSearchElapsed >= 30000) {
clearInterval(mcSearchTimer);
el('record-search-text').textContent = 'Kein Spielfenster gefunden. Läuft Minecraft und ist es nicht minimiert?';
el('record-search-retry').classList.remove('hidden');
+ if (sdAusstehend) { sdAusstehend = null; sdMeldung('Aufnahme', 'Kein Spielfenster gefunden', true); }
}
}
}
@@ -4137,11 +4140,47 @@ async function selectRecordingSource(source) {
await Promise.all([populateMicList(), checkGameAudioAvailability()]);
updateAudioSummary();
updateVideoSummary();
+ if (sdAusstehend) { const art = sdAusstehend; sdAusstehend = null; fuehreSdAktionAus(art); }
} catch (err) {
hide('record-stage');
show('record-search');
el('record-search-text').textContent = 'Vorschau fehlgeschlagen: ' + err.message;
el('record-search-retry').classList.remove('hidden');
+ if (sdAusstehend) { sdAusstehend = null; sdMeldung('Aufnahme', 'Spielfenster nicht erfasst', true); }
+ }
+}
+
+// ---------- Stream-Deck-Aktionen ----------
+// Nach erfolgreichem Start verschwindet das Fenster wieder (Clip-Modus und Aufnahme laufen im Hintergrund weiter);
+// bei einem Fehler bleibt es offen, damit man die Meldung sieht
+let sdAusstehend = null; // 'aufnahme' | 'clipmodus': nach der Fenstersuche noch auszuführen
+
+function sdMeldung(titel, text, fehler) {
+ window.api.streamdeckMeldung(titel, text, fehler);
+ // Fehler zeigt der Hauptprozess im Hauptfenster - das Aufnahme-Fenster selbst hätte ohne offenes Modal nur eine leere Fläche
+
+}
+
+function sdFensterWegNachErfolg() { setTimeout(() => window.api.popoutHide(), 2000); }
+
+async function fuehreSdAktionAus(art) {
+ try {
+ if (art === 'clipmodus') {
+ if (clipAktiv) { stoppeClipModus(); sdMeldung('Clip-Modus', 'Aus'); }
+ else if (recordingActive()) sdMeldung('Clip-Modus', 'Aufnahme läuft', true);
+ else { await starteClipModus(); if (clipAktiv) { sdMeldung('Clip-Modus', 'An'); sdFensterWegNachErfolg(); } }
+ } else if (recordingActive()) {
+ sdMeldung('Aufnahme', 'Wird gespeichert …');
+ await stopMcRecording();
+ } else {
+ // Aufnahme und Clip-Modus teilen sich Bildquelle und Ton -> läuft der Clip-Modus, wird er für die Aufnahme beendet
+ const clipWarAn = clipAktiv;
+ if (clipAktiv) stoppeClipModus();
+ await startMcRecording();
+ if (recordingActive()) { sdMeldung('Aufnahme', clipWarAn ? 'Läuft (Clip-Modus aus)' : 'Läuft'); sdFensterWegNachErfolg(); }
+ }
+ } catch (err) {
+ sdMeldung(art === 'clipmodus' ? 'Clip-Modus' : 'Aufnahme', 'Fehler: ' + err.message, true);
}
}
@@ -4553,7 +4592,7 @@ async function startMcRecording() {
mcRecorder.onstop = onMcRecordingStopped;
mcRecorder.start(1000);
- window.api.recordingSetGuard(true);
+ window.api.recordingSetGuard(true, currentDetailId);
mcStartedAt = Date.now();
el('record-live-badge').classList.remove('hidden');
el('record-toggle').textContent = 'Aufnahme stoppen';
@@ -6136,6 +6175,7 @@ async function openSettings(pane) {
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-beta').checked = s.betaTeilnahme === true;
el('s-notify').checked = s.desktopNotifications !== false;
+ el('s-streamdeck').checked = s.streamdeckEnabled === true;
el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false;
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
@@ -6275,6 +6315,7 @@ async function saveSettings() {
bedrockEnabled: el('s-bedrock').checked,
betaTeilnahme: el('s-beta').checked,
desktopNotifications: el('s-notify').checked,
+ streamdeckEnabled: el('s-streamdeck').checked,
friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked,
@@ -7494,6 +7535,9 @@ function wire() {
});
}
+ // Stream-Deck-Plugin von Hand (neu) installieren; die Rückmeldung kommt als Hinweis aus dem Hauptprozess
+ if (el('s-streamdeck-plugin')) el('s-streamdeck-plugin').addEventListener('click', () => { window.api.streamdeckInstalliere(); });
+
// Microsoft-Login / Kontoverwaltung
el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
el('login-add').addEventListener('click', addAccount);
@@ -7693,6 +7737,7 @@ const POPOUT_OPENERS = {
function popoutBoot(params) {
const p = params || {};
if (p.instanceId) currentDetailId = p.instanceId;
+ if (POPOUT === 'record' && (p.sd === 'aufnahme' || p.sd === 'clipmodus')) sdAusstehend = p.sd;
const opener = POPOUT_OPENERS[POPOUT];
if (opener) opener(p);
}
@@ -7718,6 +7763,13 @@ window.addEventListener('DOMContentLoaded', async () => {
if (POPOUT === 'record') window.api.onRecordCloseAttempt(() => confirmStopAndCloseRecording());
if (POPOUT === 'record') {
window.api.onClipHotkey(() => speichereClip());
+ // Stream-Deck-Tasten "Aufnahme" und "Clip-Modus": ist das Spielfenster schon erfasst, gleich
+ // ausführen, sonst erst die Suche starten (siehe sdAusstehend in selectRecordingSource)
+ window.api.onStreamdeckAktion((d) => {
+ if (d && d.instanceId && !clipAktiv && !recordingActive()) currentDetailId = d.instanceId;
+ if (mcVideoStream || clipAktiv) fuehreSdAktionAus(d.art);
+ else { sdAusstehend = d.art; openRecording(); }
+ });
// Spiel beendet -> Clip-Modus ist ohne Bildquelle sinnlos
window.api.onLaunchProgress((evt) => {
if (evt.phase === 'exit' && evt.id === currentDetailId && clipAktiv) {
diff --git a/src/services.js b/src/services.js
index 989339b..a1d94fa 100644
--- a/src/services.js
+++ b/src/services.js
@@ -419,7 +419,10 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
? `Keine passende Version für ${gameVersion} / ${loader} gefunden.`
: `Keine passende Version für ${gameVersion} gefunden.` };
}
- const dest = path.join(modsDir, file.filename);
+ // Dateiname aus der Antwort der Quelle: nur der Name, nie ein Pfad
+ const dateiname = path.basename(String(file.filename || ''));
+ if (!dateiname || dateiname === '.' || dateiname === '..') return { ok: false, reason: 'bad-name', message: 'Ungültiger Dateiname in der Antwort der Quelle.' };
+ const dest = path.join(modsDir, dateiname);
const bytes = await downloadFile(file.url, dest, null, file.hashes);
return {
ok: true,
@@ -427,7 +430,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
entry: {
projectId,
title: title || projectId,
- filename: file.filename,
+ filename: dateiname,
versionId: file.versionId,
versionNumber: file.versionNumber,
gameVersion,
diff --git a/src/store.js b/src/store.js
index 2ddffdd..5727111 100644
--- a/src/store.js
+++ b/src/store.js
@@ -78,6 +78,7 @@ const DEFAULT_SETTINGS = {
betaTeilnahme: false, // Vorabfassungen (Beta, RC) als Update bekommen
letzteVersionVorab: false, // lief zuletzt eine Vorabfassung? (siehe main.js: Beta-Teilnahme)
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
+ streamdeckEnabled: false, // lokale Schnittstelle für das Stream-Deck-Plugin (siehe streamdeck.js)
friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false,
@@ -189,10 +190,22 @@ function getInstancesDir() {
: path.join(USER_DATA, 'instances');
}
+// Kennungen kommen teils aus der Oberfläche: ".." oder Pfadtrenner dürfen nie aus dem Instanzordner hinausführen
+function istGueltigeId(id) {
+ const s = typeof id === 'string' ? id : '';
+ return !!s && s !== '.' && s !== '..' && !/[\\/:*?"<>|\0]/.test(s) && s === s.trim();
+}
+
function instancePath(id) {
+ if (!istGueltigeId(id)) throw new Error('Ungültige Instanz-Kennung.');
return path.join(getInstancesDir(), id);
}
+// Dateiname eines eigenen Instanz-Bildes: nur ein schlichter Bildname direkt im Instanzordner
+function istBildName(name) {
+ return typeof name === 'string' && name === path.basename(name) && /^[\w.\- ]+\.(png|jpe?g|gif|webp)$/i.test(name);
+}
+
function instanceFile(id) {
return path.join(instancePath(id), 'instance.json');
}
@@ -332,6 +345,8 @@ function findDependents(id, filename, art = 'mod') {
}
function removeMod(id, filename, art = 'mod') {
+ // nur ein schlichter Dateiname im Inhaltsordner - nie ein Pfad, der daraus hinausführt
+ if (typeof filename !== 'string' || !filename || filename !== path.basename(filename)) return listMods(id, art);
const list = listMods(id, art).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id, art), list);
// zugehörige Datei löschen – auch, wenn sie gerade abgeschaltet ist
@@ -374,7 +389,7 @@ function defaultInstance(name) {
// liest ein eigenes Instanz-Bild als data:-URI (für die Anzeige im Renderer)
function iconDataUri(id) {
const meta = readJson(instanceFile(id), null);
- if (!meta || !meta.customImage) return null;
+ if (!meta || !meta.customImage || !istBildName(meta.customImage)) return null;
try {
const buf = fs.readFileSync(path.join(instancePath(id), meta.customImage));
let ext = path.extname(meta.customImage).slice(1).toLowerCase();
@@ -399,7 +414,8 @@ function clearCustomImage(id) {
return updateInstance(id, { customImage: null });
}
-function listInstances() {
+// opts.ohneBild: eigene Bilder nicht mitlesen (z. B. für häufige Abfragen vom Stream Deck)
+function listInstances(opts) {
const dir = getInstancesDir();
ensureDir(dir);
const result = [];
@@ -408,7 +424,7 @@ function listInstances() {
const meta = readJson(path.join(dir, entry.name, 'instance.json'), null);
if (meta && meta.id) {
// eigenes Bild direkt für die Anzeige mitliefern (nicht persistiert)
- if (meta.customImage) meta.iconData = iconDataUri(meta.id);
+ if (meta.customImage && !(opts && opts.ohneBild)) meta.iconData = iconDataUri(meta.id);
result.push(meta);
}
}
@@ -425,6 +441,7 @@ function listInstances() {
}
function getInstance(id) {
+ if (!istGueltigeId(id)) return null;
return readJson(instanceFile(id), null);
}