Upload via GUI (324 Dateien)

This commit is contained in:
2026-10-30 05:54:21 +00:00
parent f97ad783fb
commit e8b5d6b6e0
7 changed files with 503 additions and 17 deletions
+2 -2
View File
@@ -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 };
}
+8
View File
@@ -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
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}