'use strict'; /* * bedrock.js – Unterstützung für Minecraft Bedrock (Windows-Store-App) * -------------------------------------------------------------------- * Bedrock ist keine Java-Anwendung, sondern eine UWP-App. Der Launcher kann * sie daher nicht herunterladen oder in Instanzen aufteilen – wohl aber * starten und die Spieldaten (Welten, Add-ons, Pakete, Bildschirmfotos) * verwalten. Die Installation selbst bleibt Sache des Microsoft Store. */ const fs = require('fs'); const path = require('path'); const { execFile, spawn } = require('child_process'); const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe'; // Der Teil nach "!" (Application Id) ist NICHT stabil – Microsoft hat ihn // schon mindestens einmal geändert (früher "App", inzwischen "Game"). // Deshalb zur Laufzeit über Get-StartApps auflösen statt fest zu verdrahten; // dieser Wert dient nur als letzter Rückfallwert, falls die Auflösung // fehlschlägt (z. B. PowerShell blockiert). const FALLBACK_APP_ID = PKG_FAMILY + '!Game'; function comMojangDir() { return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY, 'LocalState', 'games', 'com.mojang'); } // --------------------------------------------------------------------------- // Erkennung // --------------------------------------------------------------------------- function runPwsh(cmd) { return new Promise((resolve) => { execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', cmd], { windowsHide: true, timeout: 15000 }, (err, stdout) => resolve(err ? '' : String(stdout || '').trim())); }); } async function detect() { const dir = comMojangDir(); const version = await runPwsh( "(Get-AppxPackage -Name 'Microsoft.MinecraftUWP' -ErrorAction SilentlyContinue).Version"); const installed = !!version; return { installed, version: version || null, dataDir: fs.existsSync(dir) ? dir : null, }; } // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- let cachedAppId = null; /* * Ermittelt die aktuell gültige AUMID über Get-StartApps – das ist derselbe * Weg, den auch das Startmenü nutzt, und bildet daher immer die Application * Id ab, die Windows gerade tatsächlich zum Starten akzeptiert. */ async function resolveAppId() { if (cachedAppId) return cachedAppId; const out = await runPwsh( `(Get-StartApps | Where-Object { $_.AppID -like '${PKG_FAMILY}*' } ` + `| Select-Object -First 1 -ExpandProperty AppID)`); cachedAppId = out || FALLBACK_APP_ID; return cachedAppId; } /* * Startet Bedrock direkt. * Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen – sonst * öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments. */ async function launch() { const appId = await resolveAppId(); const target = 'shell:appsFolder\\' + appId; // Wichtig: Es darf pro Klick nur EINE Aktivierung tatsächlich lostreten. // Die App-Aktivierung lässt sich nicht abbrechen – wenn wir bei einer // Verzögerung (Xbox-Dienste, Kaltstart) automatisch einen zweiten Weg // probieren, laufen im Zweifel beide an und Bedrock öffnet sich doppelt. // Die anderen Wege dienen daher nur als Rückfall, falls das Spawnen // selbst scheitert (Programm nicht gefunden o. Ä.), nicht als // Reaktion auf einen bloß langsamen Start. const versuche = [ // 1) explorer direkt, ohne zusätzliche Anführungszeichen – entspricht // exakt dem manuellen "explorer.exe shell:appsFolder\...", der // nachweislich zuverlässig funktioniert ['explorer.exe', [target], { windowsVerbatimArguments: true }], // 2) über die Eingabeaufforderung ['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }], // 3) Rückfallweg über eine versteckte PowerShell – aus einem // windowless/detached Kindprozess heraus offenbar nicht überall // zuverlässig, daher nur letzter Versuch ['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', `Start-Process explorer.exe -ArgumentList '${target}'`], {}], ]; let methode = null; for (const [cmd, args, extra] of versuche) { try { const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra)); p.unref(); methode = cmd; break; // nur diesen einen Weg tatsächlich auslösen } catch { /* Spawnen selbst fehlgeschlagen – nächsten Weg versuchen */ } } if (!methode) return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' }; // Aktivierung kann dauern (v. a. beim ersten Start nach längerer Pause) – // hier nur zur ehrlichen Rückmeldung abwarten, OHNE bei Zeitüberschreitung // einen weiteren Versuch zu starten (siehe Hinweis oben). const bestaetigt = await waitForBedrockProcess(15000); return { ok: true, method: methode, confirmed: bestaetigt }; } function isBedrockRunning() { return new Promise((resolve) => { execFile('tasklist', ['/FI', 'IMAGENAME eq Minecraft.Windows.exe', '/FO', 'CSV', '/NH'], { windowsHide: true, timeout: 5000 }, (err, stdout) => resolve(!err && /Minecraft\.Windows\.exe/i.test(String(stdout || '')))); }); } function warten(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function waitForBedrockProcess(timeoutMs) { const start = Date.now(); // sofort prüfen (falls schon ein Fenster lief) und danach im Kurztakt for (;;) { if (await isBedrockRunning()) return true; if (Date.now() - start >= timeoutMs) return false; await warten(500); } } // --------------------------------------------------------------------------- // Welten / Pakete / Bildschirmfotos // --------------------------------------------------------------------------- function readLevelName(worldDir) { // levelname.txt enthält den im Spiel angezeigten Namen try { const p = path.join(worldDir, 'levelname.txt'); if (fs.existsSync(p)) return fs.readFileSync(p, 'utf8').trim(); } catch { /* Ordnername verwenden */ } return path.basename(worldDir); } function dirSize(dir, depth = 3) { let total = 0; if (depth < 0) return 0; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; } for (const e of entries) { const p = path.join(dir, e.name); try { if (e.isDirectory()) total += dirSize(p, depth - 1); else total += fs.statSync(p).size; } catch { /* überspringen */ } } return total; } function listWorlds() { const base = path.join(comMojangDir(), 'minecraftWorlds'); if (!fs.existsSync(base)) return []; return fs.readdirSync(base, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => { const dir = path.join(base, e.name); let modified = null; try { modified = fs.statSync(dir).mtime.toISOString(); } catch { /* egal */ } return { id: e.name, name: readLevelName(dir), dir, modified, bytes: dirSize(dir) }; }) .sort((a, b) => String(b.modified).localeCompare(String(a.modified))); } // Ordnerarten, die verwaltet werden können const FOLDERS = { worlds: 'minecraftWorlds', resource_packs: 'resource_packs', behavior_packs: 'behavior_packs', skin_packs: 'skin_packs', screenshots: 'Screenshots', }; function listFolder(kind) { const sub = FOLDERS[kind]; if (!sub) return []; const base = path.join(comMojangDir(), sub); if (!fs.existsSync(base)) return []; return fs.readdirSync(base, { withFileTypes: true }) .filter((e) => !e.name.startsWith('.')) .map((e) => { const p = path.join(base, e.name); let bytes = 0, modified = null; try { const st = fs.statSync(p); modified = st.mtime.toISOString(); bytes = e.isDirectory() ? dirSize(p, 2) : st.size; } catch { /* egal */ } return { name: e.name, isDir: e.isDirectory(), path: p, bytes, modified }; }); } function folderPath(kind) { const sub = FOLDERS[kind]; return sub ? path.join(comMojangDir(), sub) : null; } function removeEntry(kind, name) { const base = folderPath(kind); if (!base) return false; const target = path.join(base, name); if (!target.startsWith(base + path.sep)) return false; // Pfad-Ausbruch verhindern if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); return true; } /* * Add-on installieren: .mcpack/.mcaddon/.mcworld sind ZIP-Dateien. * Der Doppelklick-Weg über die Shell ist der zuverlässigste, weil Minecraft * die Pakete selbst einsortiert (inkl. Abhängigkeiten). */ function installAddon(filePath) { if (!fs.existsSync(filePath)) return { ok: false, message: 'Datei nicht gefunden.' }; 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(); return { ok: true }; } module.exports = { detect, launch, listWorlds, listFolder, folderPath, removeEntry, installAddon, comMojangDir, FOLDERS, };