243 lines
9.0 KiB
JavaScript
243 lines
9.0 KiB
JavaScript
'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,
|
||
};
|