diff --git a/package.json b/package.json index 980bb38..295f248 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "octo-launcher", - "version": "1.3.5", + "version": "1.3.6", "description": "An Electron application for launching and updating the OctoWoW client", "author": "OctoWoW", "copyright": "Copyright © 2026 OctoWoW", diff --git a/src/main/index.ts b/src/main/index.ts index dffa2ae..17315be 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import icon from '~build/icon.png?asset'; import { PreferencesSchema } from '~common/schemas'; import { appRouter } from './api/root'; +import { stopSyncing, stopSeeding } from './modules/aria2'; import Preferences from './modules/preferences'; import Updater from './modules/updater'; import Addons from './modules/addons'; @@ -201,6 +202,8 @@ if (!gotSingleInstanceLock) { let settingsFlushed = false; app.on('before-quit', event => { + stopSyncing(); + stopSeeding(); if (settingsFlushed) return; settingsFlushed = true; event.preventDefault(); diff --git a/src/main/modules/aria2.ts b/src/main/modules/aria2.ts index 6bca30b..86b53f9 100644 --- a/src/main/modules/aria2.ts +++ b/src/main/modules/aria2.ts @@ -84,6 +84,15 @@ const parseProgress = ( }; }; +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; @@ -120,6 +129,7 @@ export const syncClient = (opts: SyncOpts): Promise => ]; 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/)) { @@ -136,6 +146,7 @@ export const syncClient = (opts: SyncOpts): Promise => 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}`)); }); @@ -341,6 +352,7 @@ export const torrentDownloadSelection = async ( 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; @@ -348,6 +360,7 @@ export const torrentDownloadSelection = async ( const dest = path.join(clientDir, ...f.path); const st = await fs.stat(dest).catch(() => null); if (!st) { + missing = true; need.push(i + 1); continue; } @@ -357,6 +370,10 @@ export const torrentDownloadSelection = async ( 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); diff --git a/src/main/modules/mods.ts b/src/main/modules/mods.ts index c92d92a..9d912ee 100644 --- a/src/main/modules/mods.ts +++ b/src/main/modules/mods.ts @@ -58,6 +58,20 @@ const KNOWN_DLLS = new Set( const AV_ERROR = 'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.'; +// pinned dxvk-gplasync v2.7.1-1 x32 d3d9.dll (same build the client ships) +const DXVK_DLL_SHA256 = + 'a2cd6841e102f37189527c118ec416fa5071ac4d3120762973d9a0c6c5fd067e'; + +const fileSha256 = async (p: string): Promise => { + try { + return createHash('sha256') + .update(await fs.readFile(p)) + .digest('hex'); + } catch { + return null; + } +}; + const looksLikeAvBlock = (msg: string) => /windows defender|virus|potentially unwanted/i.test(msg); @@ -278,6 +292,7 @@ class ModsClass extends Observable { } const missing: string[] = []; + let dxvkRepair = false; for (const m of MODS) { // disabled mods: leave dlls.txt and installed state untouched if (m.disabled) continue; @@ -288,30 +303,41 @@ class ModsClass extends Observable { // torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty if (isTorrentMode()) { const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id); - // dxvk loads by file presence, not dlls.txt; disabled parks - // d3d9.dll as .off, enabled restores it + // dxvk loads by file presence and torrent piece spillover can + // corrupt it; hash-verify every state: park/restore verified + // copies only, delete junk, re-download the pin when needed if (m.id === 'dxvk' && clientDir) { const live = path.join(clientDir, 'd3d9.dll'); const off = path.join(clientDir, 'd3d9.dll.off'); - const liveStat = await fs.stat(live).catch(() => null); - if (!enabled && liveStat && liveStat.size === 0) { - // 0-byte aria2 stub, never park it over a real copy - await fs.remove(live).catch(() => undefined); - } else if (!enabled && liveStat) { - await fs.remove(off).catch(() => undefined); - await fs - .move(live, off) - .then(() => Logger.info('dxvk disabled: parked d3d9.dll')) - .catch(e => Logger.warn('Could not park d3d9.dll', e)); - } else if ( - enabled && - !(await fs.pathExists(live)) && - (await fs.pathExists(off)) - ) { - await fs - .move(off, live) - .then(() => Logger.info('dxvk enabled: restored d3d9.dll')) - .catch(e => Logger.warn('Could not restore d3d9.dll', e)); + const liveSha = await fileSha256(live); + if (!enabled) { + if ( + liveSha === DXVK_DLL_SHA256 && + !(await fs.pathExists(off)) + ) { + await fs + .move(live, off) + .then(() => Logger.info('dxvk disabled: parked d3d9.dll')) + .catch(e => Logger.warn('Could not park d3d9.dll', e)); + } else if (liveSha !== null) { + await fs.remove(live).catch(() => undefined); + } + } else if (liveSha !== DXVK_DLL_SHA256) { + if (liveSha !== null) { + Logger.warn('dxvk: d3d9.dll failed verification; replacing'); + await fs.remove(live).catch(() => undefined); + } + const offSha = await fileSha256(off); + if (offSha === DXVK_DLL_SHA256) { + await fs + .move(off, live) + .then(() => Logger.info('dxvk enabled: restored d3d9.dll')) + .catch(e => Logger.warn('Could not restore d3d9.dll', e)); + } else { + if (offSha !== null) + await fs.remove(off).catch(() => undefined); + dxvkRepair = true; + } } } const files = modTargetFiles(m); @@ -371,6 +397,14 @@ class ModsClass extends Observable { }); } + if (dxvkRepair) { + const dm = getMod('dxvk'); + if (dm) + await this.#install(dm).catch(e => + Logger.warn('dxvk repair download failed', e) + ); + } + this._value = { ...this._value, state: 'idle', diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts index dd9453c..cd5fa54 100644 --- a/src/main/modules/updater.ts +++ b/src/main/modules/updater.ts @@ -456,9 +456,6 @@ class UpdaterClass extends Observable { } }); 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', @@ -474,15 +471,12 @@ class UpdaterClass extends Observable { ); 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); - } - } + // a sync can recreate d3d9.dll (clean pass, or piece spillover); + // drop it while dxvk is off, the mods verify owns park/restore + if (Preferences.data.mods?.dxvk?.enabled === false) + await fs + .remove(path.join(clientPath, 'd3d9.dll')) + .catch(() => undefined); await this.#reconcileClientPatch(clientPath); await this.#reconcileRaidVisuals(clientPath); Preferences.data = {