From e8b5d6b6e0459f66dac648ebbde99c4dbdc10679 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 30 Oct 2026 05:54:21 +0000 Subject: [PATCH] Upload via GUI (324 Dateien) --- src/bedrock.js | 4 +- src/index.html | 8 + src/main.js | 414 +++++++++++++++++++++++++++++++++++++++++++++++- src/preload.js | 10 +- src/renderer.js | 54 ++++++- src/services.js | 7 +- src/store.js | 23 ++- 7 files changed, 503 insertions(+), 17 deletions(-) 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); }