import crypto from 'crypto'; import path from 'path'; import { spawn, type ChildProcess } from 'child_process'; import { app } from 'electron'; import fs from 'fs-extra'; import Logger from 'electron-log/main'; import { mapPort, type PortMapping } from './upnp'; const TORRENT_NAME = 'client'; const bin = () => app.isPackaged ? path.join(process.resourcesPath, 'aria2c.exe') : path.join(app.getAppPath(), 'resources', 'aria2c.exe'); type SyncOpts = { torrentUrl: string; clientDir: string; totalBytes?: number; checkIntegrity?: boolean; seedTime?: number; selectFiles?: number[]; onProgress?: (p: SyncProgress) => void; signal?: AbortSignal; }; export type SyncProgress = { progress: number; bytesDone: number; bytesTotal: number; bytesPerSecond: number; }; const ensureJunction = async (clientDir: string): Promise => { await fs.ensureDir(clientDir); const staging = path.join(app.getPath('userData'), 'torrent-root'); await fs.ensureDir(staging); const link = path.join(staging, TORRENT_NAME); const target = path.resolve(clientDir); try { const cur = await fs.lstat(link); if (cur.isSymbolicLink() || cur.isDirectory()) { const resolved = await fs.realpath(link).catch(() => ''); if (path.resolve(resolved) === target) return staging; } await fs.remove(link); } catch {} await fs.symlink(target, link, 'junction'); return staging; }; const SIZE_UNITS: Record = { B: 1, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3, TiB: 1024 ** 4 }; const toBytes = (s: string): number => { const m = /^([\d.]+)(B|KiB|MiB|GiB|TiB)$/.exec(s.trim()); if (!m) return 0; return parseFloat(m[1]) * (SIZE_UNITS[m[2]] ?? 1); }; const parseProgress = ( line: string, totalHint: number ): SyncProgress | undefined => { const frac = /([\d.]+(?:B|KiB|MiB|GiB|TiB))\/([\d.]+(?:B|KiB|MiB|GiB|TiB))\((\d+)%\)/.exec( line ); if (!frac) return undefined; const dl = /DL:([\d.]+(?:B|KiB|MiB|GiB|TiB))/.exec(line); return { progress: parseInt(frac[3], 10) / 100, bytesDone: toBytes(frac[1]), bytesTotal: toBytes(frac[2]) || totalHint, bytesPerSecond: dl ? toBytes(dl[1]) : 0 }; }; let syncChild: ChildProcess | undefined; // aria2's --stop-with-process is unreliable on Windows; kill the download // child explicitly on quit or it keeps running headless export const stopSyncing = (): void => { syncChild?.kill(); syncChild = undefined; }; export const syncClient = (opts: SyncOpts): Promise => new Promise((resolve, reject) => { let child: ChildProcess | undefined; ensureJunction(opts.clientDir) .then(dir => { const args = [ `--dir=${dir}`, `--seed-time=${opts.seedTime ?? 0}`, `--check-integrity=${opts.checkIntegrity ? 'true' : 'false'}`, '--bt-save-metadata=true', '--bt-remove-unselected-file=false', '--continue=true', '--allow-overwrite=true', '--auto-file-renaming=false', '--file-allocation=none', '--disk-cache=128M', '--stream-piece-selector=inorder', '--max-tries=0', '--retry-wait=5', '--bt-stop-timeout=120', '--auto-save-interval=15', '--summary-interval=1', '--console-log-level=warn', '--enable-dht=true', '--bt-enable-lpd=true', '--max-connection-per-server=8', '--split=16', '--min-split-size=1M', ...(opts.selectFiles?.length ? [`--select-file=${opts.selectFiles.join(',')}`] : []), '--stop-with-process=' + process.pid, opts.torrentUrl ]; Logger.log(`aria2c ${args.join(' ')}`); child = spawn(bin(), args, { windowsHide: true }); syncChild = child; const onLine = (buf: Buffer) => { for (const line of buf.toString().split(/\r?\n/)) { if (!line.trim()) continue; const p = parseProgress(line, opts.totalBytes ?? 0); if (p) opts.onProgress?.(p); else Logger.log(`[aria2] ${line}`); } }; child.stdout?.on('data', onLine); child.stderr?.on('data', onLine); opts.signal?.addEventListener('abort', () => child?.kill()); child.on('error', reject); child.on('close', code => { if (syncChild === child) syncChild = undefined; if (code === 0) resolve(); else reject(new Error(`aria2c exited with code ${code}`)); }); }) .catch(reject); }); export const aria2Available = () => fs.pathExists(bin()); export const downloadIsComplete = async (): Promise => { const control = path.join( app.getPath('userData'), 'torrent-root', `${TORRENT_NAME}.aria2` ); return !(await fs.pathExists(control)); }; export const clearTorrentResumeState = async (): Promise => { const dir = path.join(app.getPath('userData'), 'torrent-root'); await Promise.all([ fs.remove(path.join(dir, `${TORRENT_NAME}.aria2`)), fs.remove(path.join(dir, `${TORRENT_NAME}.torrent`)) ]); }; export const torrentUrl = (): string | undefined => import.meta.env.MAIN_VITE_CLIENT_TORRENT_URL || undefined; export const isTorrentMode = (): boolean => !!torrentUrl(); export const raidVisualsUrl = (): string | undefined => import.meta.env.MAIN_VITE_RAID_VISUALS_URL || undefined; export const clientPatchUrl = (): string | undefined => import.meta.env.MAIN_VITE_CLIENT_PATCH_URL || undefined; export const fetchTorrentSha = async (url: string): Promise => { const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); const buf = Buffer.from(await r.arrayBuffer()); return crypto.createHash('sha1').update(buf).digest('hex'); }; const bdecode = (buf: Buffer, pos = 0): [unknown, number] => { if (pos >= buf.length) throw new Error('bencode: unexpected end of data'); const ch = buf[pos]; if (ch === 0x69) { const end = buf.indexOf(0x65, pos); if (end === -1) throw new Error('bencode: unterminated integer'); return [parseInt(buf.toString('latin1', pos + 1, end), 10), end + 1]; } if (ch === 0x6c) { const list: unknown[] = []; let p = pos + 1; while (buf[p] !== 0x65) { if (p >= buf.length) throw new Error('bencode: unterminated list'); const [v, np] = bdecode(buf, p); list.push(v); p = np; } return [list, p + 1]; } if (ch === 0x64) { const dict: Record = {}; let p = pos + 1; while (buf[p] !== 0x65) { if (p >= buf.length) throw new Error('bencode: unterminated dict'); const [k, kp] = bdecode(buf, p); const [v, vp] = bdecode(buf, kp); dict[k as string] = v; p = vp; } return [dict, p + 1]; } const colon = buf.indexOf(0x3a, pos); if (colon === -1) throw new Error('bencode: unterminated string length'); const len = parseInt(buf.toString('latin1', pos, colon), 10); if (!Number.isInteger(len) || len < 0 || colon + 1 + len > buf.length) throw new Error('bencode: invalid string length'); const start = colon + 1; return [buf.toString('latin1', start, start + len), start + len]; }; const torrentDataArchives = (torrentBytes: Buffer): Set => { const [torrent] = bdecode(torrentBytes) as [ { info?: { files?: { path?: string[] }[] } }, number ]; const files = torrent?.info?.files ?? []; return new Set( files .filter( f => f.path?.length === 2 && f.path[0] === 'Data' && /\.mpq$/i.test(f.path[1]) ) .map(f => f.path![1].toLowerCase()) ); }; const LOCALE_DIRS = new Set([ 'enus', 'engb', 'encn', 'entw', 'kokr', 'frfr', 'dede', 'zhcn', 'zhtw', 'eses', 'esmx', 'ruru', 'ptbr', 'ptpt', 'itit' ]); const torrentDataDirs = (torrentBytes: Buffer): Set => { const [torrent] = bdecode(torrentBytes) as [ { info?: { files?: { path?: string[] }[] } }, number ]; const files = torrent?.info?.files ?? []; return new Set( files .filter(f => (f.path?.length ?? 0) >= 3 && f.path![0] === 'Data') .map(f => f.path![1].toLowerCase()) ); }; // archives the old client shipped under names the current one no longer uses; // matched by name AND exact size so player mods reusing a name are never touched const LEGACY_ARCHIVES: Record = { 'patch-6.mpq': 451195806, 'patch-7.mpq': 175256564, 'patch-8.mpq': 484649870, 'patch-9.mpq': 506808141, 'patch-a.mpq': 241751337 }; export const pruneStaleArchives = async ( clientDir: string, url: string, _owned: Set ): Promise => { try { const r = await fetch(url); if (!r.ok) return []; const bytes = Buffer.from(await r.arrayBuffer()); const expected = torrentDataArchives(bytes); if (!expected.size) return []; for (const u of [clientPatchUrl(), raidVisualsUrl()]) if (u) expected.add(path.basename(u).toLowerCase()); const usedDirs = torrentDataDirs(bytes); const dataDir = path.join(clientDir, 'Data'); const onDisk = await fs.readdir(dataDir).catch(() => []); const removed: string[] = []; for (const name of onDisk) { const lc = name.toLowerCase(); const full = path.join(dataDir, name); const st = await fs.stat(full).catch(() => null); if (!st) continue; if (st.isDirectory()) { if (LOCALE_DIRS.has(lc) && !usedDirs.has(lc)) { await fs.remove(full); removed.push(name + '/'); } continue; } if (!/\.mpq$/i.test(name) || expected.has(lc)) continue; if (LEGACY_ARCHIVES[lc] !== st.size) continue; await fs.remove(full); removed.push(name); } return removed; } catch (e) { Logger.warn('Prune of stale archives failed', e); return []; } }; // files the torrent ships but a mod toggle owns; the sync must not re-add // them or count their absence as an incomplete tree const LAUNCHER_OWNED_FILES = new Set(['d3d9.dll']); const isLauncherOwned = (parts: string[]) => parts.length === 1 && LAUNCHER_OWNED_FILES.has(parts[0].toLowerCase()); export const torrentDownloadSelection = async ( clientDir: string, url: string, dropMismatched = false ): Promise => { try { const r = await fetch(url); if (!r.ok) return null; const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [ { info?: { files?: { path?: string[]; length?: number }[] } }, number ]; const files = torrent?.info?.files ?? []; if (!files.length) return null; const need: number[] = []; let missing = false; for (let i = 0; i < files.length; i++) { const f = files[i]; if (!f.path?.length || typeof f.length !== 'number') return null; if (isLauncherOwned(f.path)) continue; const dest = path.join(clientDir, ...f.path); const st = await fs.stat(dest).catch(() => null); if (!st) { missing = true; need.push(i + 1); continue; } if (st.size !== f.length) { if (dropMismatched || st.size > f.length) await fs.remove(dest).catch(() => {}); need.push(i + 1); } } // a deleted file poisons the resume state (its pieces are marked // done, so aria2 skips them forever); partial files keep it so an // interrupted download resumes instead of restarting if (missing) await clearTorrentResumeState().catch(() => undefined); return need; } catch (e) { Logger.warn('Torrent selection computation failed', e); return null; } }; export const torrentTreeIntact = async ( clientDir: string, url: string ): Promise => { try { const r = await fetch(url); if (!r.ok) return false; const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [ { info?: { files?: { path?: string[]; length?: number }[] } }, number ]; const files = torrent?.info?.files ?? []; if (!files.length) return false; for (const f of files) { if (!f.path?.length || typeof f.length !== 'number') return false; if (isLauncherOwned(f.path)) continue; const st = await fs .stat(path.join(clientDir, ...f.path)) .catch(() => null); if (!st || st.size !== f.length) return false; } return true; } catch (e) { Logger.warn('Torrent tree check failed', e); return false; } }; const LOCALE_ASSERT_OFFSET = 0x1b2115; const pristineWowPath = () => path.join(app.getPath('userData'), 'base-WoW.exe'); export const refreshPristineWow = async (clientDir: string): Promise => { const exe = path.join(clientDir, 'WoW.exe'); if (!(await fs.pathExists(exe))) return; const fd = await fs.open(exe, 'r'); try { const b = Buffer.alloc(1); await fs.read(fd, b, 0, 1, LOCALE_ASSERT_OFFSET); if (b[0] === 0xa1) { await fs.copy(exe, pristineWowPath(), { overwrite: true }); Logger.log('Cached pristine WoW.exe base'); } } finally { await fs.close(fd); } }; export const readPristineWow = async (clientDir: string): Promise => { const cache = pristineWowPath(); if (await fs.pathExists(cache)) return fs.readFile(cache); return fs.readFile(path.join(clientDir, 'WoW.exe')); }; let seeder: ChildProcess | undefined; let mapping: Promise | undefined; let wantSeeding = false; let starting = false; const SEED_PORT = 6881; const SEED_TIME_MINUTES = 525600; export const isSeeding = (): boolean => !!seeder; const releaseMapping = (): void => { const m = mapping; mapping = undefined; if (m) void m.then(x => x.stop()).catch(() => {}); }; export const stopSeeding = (): void => { wantSeeding = false; seeder?.kill(); seeder = undefined; releaseMapping(); }; export const startSeeding = async ( clientDir: string, uploadLimit = '2M' ): Promise => { wantSeeding = true; if (seeder || starting) return; starting = true; try { const url = torrentUrl(); if (!url) return; const dir = await ensureJunction(clientDir); if (!wantSeeding || seeder) return; const args = [ `--dir=${dir}`, '--bt-seed-unverified=true', `--seed-time=${SEED_TIME_MINUTES}`, '--check-integrity=false', // no prealloc: the seeder must not recreate missing files as zeros '--file-allocation=none', '--continue=true', '--bt-save-metadata=true', '--enable-dht=true', '--bt-enable-lpd=true', `--listen-port=${SEED_PORT}`, `--dht-listen-port=${SEED_PORT}`, `--max-overall-upload-limit=${uploadLimit}`, '--summary-interval=0', '--console-log-level=warn', '--stop-with-process=' + process.pid, url ]; Logger.log('aria2c (seed) ' + args.join(' ')); const child = spawn(bin(), args, { windowsHide: true }); seeder = child; const onExit = () => { if (seeder === child) { seeder = undefined; releaseMapping(); } }; child.on('close', onExit); child.on('error', e => { Logger.warn('Seeder failed', e); onExit(); }); mapping = mapPort(SEED_PORT, { description: 'OctoWoW' }); void mapping.catch(() => undefined); } catch (e) { Logger.warn('startSeeding failed', e); } finally { starting = false; } };