'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: `.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, };