Files
Fractured/tools/fractured-launcher-electron/lib/patch.js
T
Docker Build f88a303327 feat(launcher): Linux play wrapper, patch UX, Gitea sync cleanup
- Play on Linux: use launch.linux_wrapper (wine) or linux_steam_uri; chmod .exe after install
- Windows: retry EBUSY on MPQ replace; download to .new before rename
- After successful sync: remove .bak-* backups; realmlist only Data/enUS (ignore enGB)
- Gitea/distro merge: skip Fractured-Launcher* from GitHub assets (CI default-branch build wins)
- Omit blockmap and builder-debug from staged artifacts and Gitea uploads; upload script validates before clearing attachments
- README and launcher version 1.0.12

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 23:20:22 -05:00

250 lines
7.6 KiB
JavaScript

'use strict';
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
const { normalizeWinGameDir } = require('./win-game-dir');
const { loadManifest } = require('./patch-manifest');
const { buildResolvedReleaseFiles } = require('./release-sync');
function pad2(n) {
return String(n).padStart(2, '0');
}
function backupSuffix() {
const d = new Date();
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** Windows often returns EBUSY/EPERM when WoW or AV still has an MPQ open. */
function isRetryableFsLockError(e) {
const c = e && e.code;
if (!c) return false;
if (c === 'EBUSY' || c === 'EPERM' || c === 'EACCES') return true;
if (process.platform === 'win32' && (c === 'UNKNOWN' || c === 'EUNKNOWN')) return true;
return false;
}
async function retryFsLock(op, opts) {
const attempts = (opts && opts.attempts) || (process.platform === 'win32' ? 30 : 10);
const delayMs = (opts && opts.delayMs) || 500;
let last;
for (let i = 0; i < attempts; i++) {
try {
return await op();
} catch (e) {
last = e;
if (!isRetryableFsLockError(e)) throw e;
if (i === attempts - 1) break;
await sleep(delayMs);
}
}
const hint =
process.platform === 'win32'
? ' Close World of Warcraft and any launcher using this folder, then try again.'
: ' Close programs using this file, then try again.';
const err = new Error(String((last && last.message) || last) + hint);
err.code = last && last.code;
throw err;
}
function wowExePath(cfg) {
const gd = normalizeWinGameDir(cfg.game_dir || '');
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
const primary = path.join(gd, ...parts);
if (process.platform === 'win32' && gd && fsSync.existsSync(primary)) return primary;
if (process.platform === 'win32' && gd) {
try {
const base = path.basename(primary);
const dir = path.dirname(primary);
const names = fsSync.readdirSync(dir);
const hit = names.find((n) => n.toLowerCase() === base.toLowerCase());
if (hit) {
const alt = path.join(dir, hit);
if (fsSync.statSync(alt).isFile()) return alt;
}
} catch (_) {
/* ignore */
}
}
return primary;
}
function wowInstallValid(cfg) {
if (!cfg.game_dir) return false;
const p = wowExePath(cfg);
return fsSync.existsSync(p) && fsSync.statSync(p).isFile();
}
/** WoW expects patch MPQ names with a literal .MPQ extension (case-sensitive clients). */
function normalizeMpqDestinationPath(absPath) {
const s = String(absPath || '');
return /\.mpq$/i.test(s) ? s.replace(/\.mpq$/i, '.MPQ') : s;
}
/** Matches backup names from installFile: `<orig>.bak-YYYYMMDD-HHmmss`. */
const LAUNCHER_BACKUP_BASENAME_RE = /\.bak-\d{8}-\d{6}$/;
async function removeLauncherBackupFiles(gameDir) {
const root = normalizeWinGameDir(gameDir || '');
if (!root) return;
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (_) {
continue;
}
for (const d of entries) {
const abs = path.join(dir, d.name);
if (d.isDirectory()) {
stack.push(abs);
} else if (d.isFile() && LAUNCHER_BACKUP_BASENAME_RE.test(d.name)) {
try {
await fs.unlink(abs);
} catch (e) {
if (e.code !== 'ENOENT') {
/* best effort: sync already succeeded */
}
}
}
}
}
}
function isEnUsRealmlistPath(rel) {
const n = String(rel || '')
.trim()
.replace(/\\/g, '/')
.toLowerCase();
return n.endsWith('/enus/realmlist.wtf') || n === 'enus/realmlist.wtf';
}
async function installFile(cfg, entry) {
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const root = normalizeWinGameDir(cfg.game_dir || '');
const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts));
const tmp = destAbs + '.new';
if (entry.from_release) {
await downloadReleaseAsset(cfg, entry.source, tmp);
} else {
await downloadGitHubRepoFile(cfg, entry.source, tmp);
}
async function removeOrBackupExisting() {
if (entry.backup) {
try {
const st = await fs.stat(destAbs);
if (st.isFile()) {
const bak = `${destAbs}.bak-${backupSuffix()}`;
await fs.rename(destAbs, bak);
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
} else {
try {
await fs.unlink(destAbs);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
}
await retryFsLock(() => removeOrBackupExisting());
await retryFsLock(() => fs.rename(tmp, destAbs));
if (process.platform === 'linux' && /\.exe$/i.test(destAbs)) {
try {
await fs.chmod(destAbs, 0o755);
} catch (_) {
/* non-fatal */
}
}
}
async function applyRealmlist(cfg) {
if (!cfg.realmlist || !cfg.realmlist.enabled) return;
let line = String(cfg.realmlist.line || '').trim();
if (!line) throw new Error('realmlist.line empty');
if (!line.toLowerCase().startsWith('set realmlist ')) {
line = `set realmlist ${line}`;
}
const content = line + '\n';
let paths = cfg.realmlist.paths;
if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf'];
paths = paths.filter(isEnUsRealmlistPath);
if (!paths.length) paths = ['Data/enUS/realmlist.wtf'];
for (const rel of paths) {
const r = String(rel).trim().replace(/\\/g, '/');
if (!r) continue;
const segs = r.split('/').filter(Boolean);
const abs = path.join(normalizeWinGameDir(cfg.game_dir || ''), ...segs);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content, 'utf8');
}
}
async function applyPatches(cfg, onStatus) {
let manifest = null;
if (cfg.patch_manifest && cfg.patch_manifest.enabled) {
manifest = await loadManifest(cfg);
}
const entries = await buildResolvedReleaseFiles(cfg, manifest);
for (const f of entries) {
if (onStatus) onStatus(`Updating ${f.dest}`);
try {
await installFile(cfg, f);
} catch (e) {
throw new Error(`sync ${f.dest}: ${e.message || e}`);
}
}
if (cfg.realmlist && cfg.realmlist.enabled) {
if (onStatus) onStatus('Applying realmlist …');
await applyRealmlist(cfg);
}
if (onStatus) onStatus('Removing old backup copies …');
try {
await removeLauncherBackupFiles(cfg.game_dir);
} catch (_) {
/* Patches and realmlist already applied; leave .bak files if cleanup cannot run. */
}
if (onStatus) onStatus('All patches applied.');
}
async function doAuth(cfg, username, password) {
if (!cfg.auth || !cfg.auth.enabled) return;
const u = String(username || '').trim();
const p = String(password || '');
if (!u || !p) throw new Error('username and password required');
const body = {
[cfg.auth.username_field || 'username']: u,
[cfg.auth.password_field || 'password']: p,
};
const res = await fetch(cfg.auth.url, {
method: cfg.auth.method || 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
const t = await res.text();
if (res.status < 200 || res.status >= 300) {
throw new Error(`login failed ${res.status}: ${t.slice(0, 400)}`);
}
}
module.exports = {
applyPatches,
applyRealmlist,
wowExePath,
wowInstallValid,
doAuth,
};