Upload via GUI (324 Dateien)
This commit is contained in:
+2
-2
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,14 @@
|
||||
<div class="s-text"><b>Windows-Benachrichtigungen</b><small>Meldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.</small></div>
|
||||
<label class="schalter"><input id="s-notify" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Stream Deck erlauben</b><small>Öffnet eine Schnittstelle, die nur dieser PC erreicht, damit das AeroMC-Plugin Instanzen starten, Clips speichern und die Aufnahme steuern kann.</small></div>
|
||||
<label class="schalter"><input id="s-streamdeck" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Stream-Deck-Plugin</b><small>Das 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“.</small></div>
|
||||
<button id="s-streamdeck-plugin" class="btn btn-mini" type="button">Installieren</button>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bedrock-Bereich anzeigen</b><small id="s-bedrock-hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small></div>
|
||||
<label class="schalter"><input id="s-bedrock" type="checkbox" /><i></i></label>
|
||||
|
||||
+406
-8
@@ -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(`<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="margin:0;overflow:hidden;background:transparent;">
|
||||
<div style="font-family:Segoe UI,sans-serif;font-size:13px;font-weight:600;color:#fff;background:rgba(20,20,25,0.82);border-radius:14px;
|
||||
height:${H}px;box-sizing:border-box;padding:0 12px;display:flex;align-items:center;gap:7px;white-space:nowrap;">
|
||||
<span id="p" style="width:9px;height:9px;border-radius:50%;background:#ef4444;box-shadow:0 0 6px #ef4444;"></span>
|
||||
<span>REC</span><span id="t" style="opacity:.85;font-variant-numeric:tabular-nums;">00:00</span>
|
||||
</div>
|
||||
<script>
|
||||
const start = ${Date.now()};
|
||||
const t = document.getElementById('t'), p = document.getElementById('p');
|
||||
setInterval(() => {
|
||||
const s = Math.floor((Date.now() - start) / 1000);
|
||||
t.textContent = (s >= 3600 ? Math.floor(s / 3600) + ':' : '') + String(Math.floor(s % 3600 / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||||
p.style.opacity = s % 2 ? '0.35' : '1';
|
||||
}, 500);
|
||||
</script></body></html>`)}`;
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
+9
-1
@@ -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);
|
||||
|
||||
+53
-1
@@ -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) {
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+20
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user