import path from 'node:path'; import crypto from 'node:crypto'; import { exec } from 'node:child_process'; import os from 'node:os'; import { app } from 'electron'; import fetch from 'node-fetch'; import fs from 'fs-extra'; import Logger from 'electron-log/main'; import { nestedGet, nestedSet } from '~common/utils'; import { mainWindow } from '~main/index'; import { getClientVersion } from '~main/utils'; import { torrentUrl, fetchTorrentSha, syncClient, refreshPristineWow, clearTorrentResumeState, raidVisualsUrl, clientPatchUrl, pruneStaleArchives, torrentTreeIntact, torrentDownloadSelection, startSeeding, stopSeeding } from '~main/modules/aria2'; import { healRealmlist } from '~main/modules/patcher'; import Preferences from './preferences'; import Observable from './observable'; type FolderTags = 'allowExtra'; type FileTags = 'vanillaFixes' | 'raidVisuals'; type FileManifest = { name: string } & ( | { type: 'del' } | { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] } | { type: 'mpq'; files: FileManifest[]; hash: string; size: number } | { type: 'file'; hash: string; version?: number; size: number; tags?: FileTags[]; } ); type CacheEntry = [hash: string, mtime: number]; type CacheTree = { [key: string]: CacheTree & CacheEntry }; const getManifestItem = ( m?: FileManifest, p?: string[] ): FileManifest | undefined => { if (!p?.length) return m; if (m?.type === 'file' || m?.type === 'del') throw Error(`Can't access ${p.join('.')} from file ${m.name}`); const [next, ...rest] = p; return getManifestItem( m?.files.find(f => f.name === next), rest ); }; const ownedDataArchives = async (): Promise> => { try { const j = await fs.readJSON( path.join(Preferences.userDataDir, 'manifest.json') ); const data = getManifestItem(j?.root ?? j, ['Data']); if (!data || data.type === 'file' || data.type === 'del') return new Set(); return new Set( data.files .filter(f => f.type !== 'dir' && /\.mpq$/i.test(f.name)) .map(f => f.name.toLowerCase()) ); } catch { return new Set(); } }; export const isGameRunning = (executablePath: string) => os.platform() === 'win32' ? new Promise(resolve => { const exeName = path.basename(executablePath); exec( `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`, (error, stdout) => { if (error) { Logger.warn( `tasklist probe for "${exeName}" failed; assuming game ` + `is not running. Error: ${error.message}` ); resolve(false); return; } resolve( stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`) ); } ); }) : false; type UpdaterState = | 'verifying' | 'serverUnreachable' | 'noClient' | 'updateAvailable' | 'updating' | 'upToDate' | 'failed'; export type UpdaterStatus = { state: UpdaterState; progress?: number; message?: string; bytesDone?: number; bytesTotal?: number; bytesPerSecond?: number; etaSeconds?: number; }; const SIDECAR_TIMEOUT_MS = 30_000; class UpdaterClass extends Observable { #cachePath = path.join(Preferences.userDataDir, 'cache.json'); #cache: CacheTree = this.#readCache(); #readCache(): CacheTree { try { return fs.existsSync(this.#cachePath) ? fs.readJSONSync(this.#cachePath) : {}; } catch { return {}; } } async #saveCache() { await fs.writeJSON(this.#cachePath, this.#cache); } async #getHash(clientPath: string, ...filePath: string[]) { if (!(await fs.exists(path.join(clientPath, ...filePath)))) { nestedSet(this.#cache, filePath, undefined); return undefined; } const stats = await fs.stat(path.join(clientPath, ...filePath)); if (stats.isDirectory()) throw Error(`Tried to get hash of directory ${path.join(...filePath)}`); const c = nestedGet(this.#cache, filePath); if (c?.[0] && c[1] === stats.mtimeMs) return c[0]; const newHash = crypto .createHash('sha1') .update(await fs.readFile(path.join(clientPath, ...filePath))) .digest('hex') .toLocaleUpperCase(); nestedSet(this.#cache, filePath, { ...c, [0]: newHash, [1]: stats.mtimeMs }); return newHash; } protected _value: UpdaterStatus = { state: 'failed' }; get status() { return this._value; } private set status(v: UpdaterStatus) { this._value = v; this._notifyObservers(v); if (this.status.state === 'failed') { mainWindow?.setProgressBar(1, { mode: 'error' }); } else if (this.status.progress === 1) { mainWindow?.setProgressBar(0); } else { mainWindow?.setProgressBar(this.status.progress ?? 0, { mode: this.status.progress === -1 ? 'indeterminate' : 'normal' }); } } async refreshSeeding() { // no seeding while dxvk is off: aria2 would recreate the parked // d3d9.dll as a 0-byte stub and break the game's d3d9 import const dxvkOff = Preferences.data.mods?.dxvk?.enabled === false; if ( Preferences.data.shareDownloads !== false && Preferences.data.clientDir && this.status.state === 'upToDate' && !dxvkOff ) await startSeeding(Preferences.data.clientDir); else stopSeeding(); } async #torrentVerify(clientPath: string) { const url = torrentUrl(); if (!url) { this.status = { state: 'serverUnreachable' }; return; } this.status = { state: 'verifying', progress: -1, message: 'Checking for updates...' }; // an interrupted sync can leave realmlist.wtf as a 0-byte placeholder await healRealmlist(clientPath).catch(e => Logger.warn('realmlist heal failed', e) ); try { const sha = await fetchTorrentSha(url); await this.#reconcileClientPatch(clientPath); await this.#reconcileRaidVisuals(clientPath); const haveExe = await fs.pathExists(path.join(clientPath, 'WoW.exe')); if ( haveExe && sha !== Preferences.data.syncedTorrentHash && (await this.#torrentFastForward(clientPath, sha, url)) ) { this.status = { state: 'upToDate', progress: 1 }; await this.refreshSeeding(); return; } const upToDate = haveExe && sha === Preferences.data.syncedTorrentHash && (await torrentTreeIntact(clientPath, url)); if (upToDate) { const removed = await pruneStaleArchives(clientPath, url, new Set()); if (removed.length) Logger.log(`Removed stale archives: ${removed.join(', ')}`); } this.status = upToDate ? { state: 'upToDate', progress: 1 } : { state: 'updateAvailable' }; await this.refreshSeeding(); } catch (e) { Logger.error('Torrent verify failed', e); this.status = { state: 'serverUnreachable' }; } } async #fetchSidecarFile( url: string, dest: string, storedHash: string | undefined, busyMessage: string ): Promise { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), SIDECAR_TIMEOUT_MS); const sha = await fetch(`${url}.sha256`, { signal: ac.signal }) .then(r => { if (!r.ok) throw new Error(`sha256 HTTP ${r.status}`); return r.text(); }) .then(s => s.trim()) .finally(() => clearTimeout(t)); if ((await fs.pathExists(dest)) && storedHash === sha) return undefined; this.status = { state: 'updating', progress: -1, message: busyMessage }; const buf = await this.#downloadWithIdleTimeout(url); if (crypto.createHash('sha256').update(buf).digest('hex') !== sha) throw new Error('checksum mismatch'); await fs.ensureDir(path.dirname(dest)); await fs.writeFile(`${dest}.part`, buf); await fs.move(`${dest}.part`, dest, { overwrite: true }); return sha; } async #downloadWithIdleTimeout(url: string): Promise { const ac = new AbortController(); let idle: ReturnType | undefined; const arm = () => { if (idle) clearTimeout(idle); idle = setTimeout(() => ac.abort(), SIDECAR_TIMEOUT_MS); }; arm(); try { const res = await fetch(url, { signal: ac.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = res.body as NodeJS.ReadableStream | null; if (!body) throw new Error('empty response body'); const chunks: Buffer[] = []; for await (const chunk of body) { arm(); chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); } finally { if (idle) clearTimeout(idle); } } async #reconcileClientPatch(clientPath: string) { const url = clientPatchUrl(); if (!url) return; const dest = path.join(clientPath, 'Data', 'patch-5.mpq'); try { const sha = await this.#fetchSidecarFile( url, dest, Preferences.data.clientPatchHash, 'Updating game files...' ); if (sha) Preferences.data = { clientPatchHash: sha }; } catch (e) { Logger.warn('Content patch reconcile failed', e); if (!(await fs.pathExists(dest))) throw new Error( 'Could not download core game content. Please check your connection and try again.' ); } } async #reconcileRaidVisuals(clientPath: string) { const url = raidVisualsUrl(); if (!url) return; const dest = path.join(clientPath, 'Data', 'patch-O.mpq'); try { if (!Preferences.data.config?.raidVisuals) { if (await fs.pathExists(dest)) { await fs.remove(dest); Preferences.data = { raidVisualsHash: undefined }; } return; } const sha = await this.#fetchSidecarFile( url, dest, Preferences.data.raidVisualsHash, 'Updating raid visuals...' ); if (sha) Preferences.data = { raidVisualsHash: sha }; } catch (e) { Logger.warn('Raid visuals reconcile failed', e); } } async syncRaidVisuals() { if (this.status?.state === 'verifying' || this.status?.state === 'updating') return; const clientPath = Preferences.data.clientDir; if (!clientPath) return; if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) { this.status = { state: 'failed', message: 'Please close WoW first, before updating.' }; return; } if (!torrentUrl()) return this.verify(); await this.#reconcileRaidVisuals(clientPath); this.status = { state: 'upToDate', progress: 1 }; } async #torrentFastForward( clientPath: string, sha: string, url: string ): Promise { if (!(await torrentTreeIntact(clientPath, url))) return false; // adopt a complete tree even on a fresh profile; empty delta selection // would otherwise fall through to a full re-download await clearTorrentResumeState().catch(() => {}); await refreshPristineWow(clientPath); const removed = await pruneStaleArchives( clientPath, url, await ownedDataArchives() ); if (removed.length) Logger.log(`Removed stale archives: ${removed.join(', ')}`); Preferences.data = { syncedTorrentHash: sha, version: await getClientVersion() }; return true; } async #torrentUpdate(clientPath: string, clean?: boolean) { const url = torrentUrl(); if (!url) { this.status = { state: 'serverUnreachable' }; return; } try { stopSeeding(); const sha = await fetchTorrentSha(url); if (!clean && (await this.#torrentFastForward(clientPath, sha, url))) { await this.#reconcileClientPatch(clientPath); await this.#reconcileRaidVisuals(clientPath); this.status = { state: 'upToDate', progress: 1 }; await this.refreshSeeding(); return; } const isUpdate = await fs.pathExists(path.join(clientPath, 'WoW.exe')); const staleContext = Preferences.data.activeTorrentHash !== sha || Preferences.data.activeClientDir !== clientPath; if (staleContext) { await clearTorrentResumeState(); Preferences.data = { activeTorrentHash: sha, activeClientDir: clientPath }; } const selection = clean ? null : await torrentDownloadSelection(clientPath, url, staleContext); const selectFiles = selection && selection.length > 0 ? selection : undefined; this.status = { state: 'updating', progress: -1, message: clean ? 'Verifying game files...' : isUpdate ? 'Connecting...' : 'Preparing download...' }; let downloading = false; await syncClient({ torrentUrl: url, clientDir: clientPath, checkIntegrity: !!clean, selectFiles, seedTime: 0, onProgress: p => { if (p.bytesPerSecond > 0) downloading = true; const phase = downloading ? 'Downloading' : clean ? 'Verifying game files' : isUpdate ? 'Connecting' : 'Preparing'; this.status = { state: 'updating', progress: p.progress, bytesDone: p.bytesDone, bytesTotal: p.bytesTotal, bytesPerSecond: p.bytesPerSecond, message: `${phase}...` }; } }); if (!(await torrentTreeIntact(clientPath, url))) { // stale resume state makes aria2 skip pieces it thinks are // done; drop it so the next attempt starts from the real files await clearTorrentResumeState().catch(() => undefined); await healRealmlist(clientPath).catch(() => undefined); this.status = { state: 'updateAvailable', message: 'Download incomplete. Click update to finish.' }; return; } await refreshPristineWow(clientPath); const removed = await pruneStaleArchives( clientPath, url, await ownedDataArchives() ); if (removed.length) Logger.log(`Removed stale archives: ${removed.join(', ')}`); // a clean sync restores d3d9.dll; re-park it if dxvk is off if (Preferences.data.mods?.dxvk?.enabled === false) { const live = path.join(clientPath, 'd3d9.dll'); const off = path.join(clientPath, 'd3d9.dll.off'); if (await fs.pathExists(live)) { await fs.remove(off).catch(() => undefined); await fs.move(live, off).catch(() => undefined); } } await this.#reconcileClientPatch(clientPath); await this.#reconcileRaidVisuals(clientPath); Preferences.data = { syncedTorrentHash: sha, version: await getClientVersion() }; this.status = { state: 'upToDate', progress: 1 }; await this.refreshSeeding(); } catch (e) { Logger.error('Torrent update failed', e); await healRealmlist(clientPath).catch(() => undefined); this.status = { state: 'failed', message: e instanceof Error ? e.message : 'Download failed' }; } } async verify() { if (this.status?.state === 'verifying' || this.status?.state === 'updating') return; const clientPath = Preferences.data.clientDir; if (!clientPath) { this.status = { state: 'noClient' }; return; } if (os.platform() === 'win32' && clientPath.length > 220) { this.status = { state: 'failed', message: 'Path to current install location is too long and may cause issues.' }; return; } if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) { this.status = { state: 'failed', message: 'Please close WoW first, before updating.' }; return; } return this.#torrentVerify(clientPath); } async update(clean?: boolean) { if (this.status?.state === 'verifying' || this.status?.state === 'updating') return; const clientPath = Preferences.data.clientDir; if (!clientPath) { this.status = { state: 'noClient' }; return; } if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) { this.status = { state: 'failed', message: 'Please close WoW first, before updating.' }; return; } return this.#torrentUpdate(clientPath, clean); } async recordPatchedWow() { const clientPath = Preferences.data.clientDir; if (!clientPath) return; const patchedWowHash = await this.#getHash(clientPath, 'WoW.exe'); await this.#saveCache(); Preferences.data = { lastPatchedLauncherVersion: app.getVersion(), expectedPatchedWowHash: patchedWowHash }; } } const Updater = new UpdaterClass(); export default Updater;