f88a303327
- 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>
250 lines
7.6 KiB
JavaScript
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,
|
|
};
|