From d9e8fd4801833e377408415c2d562cfdb1cf8657 Mon Sep 17 00:00:00 2001 From: Chris Knight Date: Mon, 10 Aug 2026 16:15:10 -0400 Subject: [PATCH 1/2] Add Vortex dependency/status dashlet and wcc_lite auto-acquisition (Unit J) Adds a status dashlet showing WSM's dependency/status snapshot (text-merge and bundle dependency validity, mods directory, conflict count, detected QuickBMS/wcc_lite paths), and bundle-tooling detection/acquisition: QuickBMS is detected only (never downloaded, per its murkier licensing); wcc_lite is auto-fetched from its Nexus Mods "Official ModKit" page via Vortex's own Nexus-download mechanism, reusing archiveExtractor.ts's existing extract plumbing. Populates wsmEnv.ts's previously-unpopulated quickBmsPath/quickBmsPluginPath/wccLitePath fields. See the PR description for the licensing/EULA caveat this introduces and what was/wasn't verified against a real Vortex host. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah --- vortex-extension/package-lock.json | 25 +- vortex-extension/package.json | 3 + vortex-extension/src/bundleTools.test.ts | 232 +++++++++++++++++ vortex-extension/src/bundleTools.ts | 242 ++++++++++++++++++ vortex-extension/src/index.test.ts | 25 +- vortex-extension/src/index.ts | 7 + vortex-extension/src/nexusDownloader.test.ts | 124 +++++++++ vortex-extension/src/nexusDownloader.ts | 129 ++++++++++ vortex-extension/src/statusTile.test.ts | 54 ++++ vortex-extension/src/statusTile.ts | 219 ++++++++++++++++ vortex-extension/src/storage.ts | 18 +- .../src/wccLiteAcquisition.test.ts | 129 ++++++++++ vortex-extension/src/wccLiteAcquisition.ts | 183 +++++++++++++ vortex-extension/src/wsmEnv.ts | 8 +- vortex-extension/src/wsmStatusSummary.test.ts | 128 +++++++++ vortex-extension/src/wsmStatusSummary.ts | 107 ++++++++ .../test/bundleTools.integration.test.ts | 123 +++++++++ .../test/testUtils/vortexApiStub.ts | 18 ++ 18 files changed, 1758 insertions(+), 16 deletions(-) create mode 100644 vortex-extension/src/bundleTools.test.ts create mode 100644 vortex-extension/src/bundleTools.ts create mode 100644 vortex-extension/src/nexusDownloader.test.ts create mode 100644 vortex-extension/src/nexusDownloader.ts create mode 100644 vortex-extension/src/statusTile.test.ts create mode 100644 vortex-extension/src/statusTile.ts create mode 100644 vortex-extension/src/wccLiteAcquisition.test.ts create mode 100644 vortex-extension/src/wccLiteAcquisition.ts create mode 100644 vortex-extension/src/wsmStatusSummary.test.ts create mode 100644 vortex-extension/src/wsmStatusSummary.ts create mode 100644 vortex-extension/test/bundleTools.integration.test.ts diff --git a/vortex-extension/package-lock.json b/vortex-extension/package-lock.json index d9b379d..df70d0d 100644 --- a/vortex-extension/package-lock.json +++ b/vortex-extension/package-lock.json @@ -10,7 +10,10 @@ "devDependencies": { "@nexusmods/vortex-api": "^2.4.2", "@types/node": "^22.10.0", + "@types/react": "^16.14.66", "eslint": "^9.15.0", + "react": "16.14.0", + "react-dom": "16.14.0", "ts-loader": "^9.5.1", "typescript": "^5.7.2", "typescript-eslint": "^8.18.0", @@ -1147,14 +1150,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "16.14.70", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.70.tgz", + "integrity": "sha512-DM5Q7rSx9G6QYcVvMgxvEurL5P06OxcDNUXrLxlpBzG4ccUewcBCmsztYbxJBobzO8RIwwmjoaD5OsKqdHDuYQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", "csstype": "^3.2.2" } }, @@ -1171,6 +1183,13 @@ "redux": "^4.0.0" } }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.66.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", diff --git a/vortex-extension/package.json b/vortex-extension/package.json index b7413dd..b2519df 100644 --- a/vortex-extension/package.json +++ b/vortex-extension/package.json @@ -19,7 +19,10 @@ "devDependencies": { "@nexusmods/vortex-api": "^2.4.2", "@types/node": "^22.10.0", + "@types/react": "^16.14.66", "eslint": "^9.15.0", + "react": "16.14.0", + "react-dom": "16.14.0", "ts-loader": "^9.5.1", "typescript": "^5.7.2", "typescript-eslint": "^8.18.0", diff --git a/vortex-extension/src/bundleTools.test.ts b/vortex-extension/src/bundleTools.test.ts new file mode 100644 index 0000000..a3155d3 --- /dev/null +++ b/vortex-extension/src/bundleTools.test.ts @@ -0,0 +1,232 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { detectBundleTools, detectQuickBms, detectWccLite, findFileByNameBounded } from './bundleTools'; + +function fakeApi(userDataDir: string, discoveryByGameState: Record }> = {}) { + return { + getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`), + getState: () => ({ discoveryByGame: discoveryByGameState }), + } as unknown as Parameters[0]; +} + +describe('bundleTools', () => { + let userDataDir: string; + + beforeEach(() => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-bundletools-test-')); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }); + + describe('detectQuickBms', () => { + it('returns undefined when nothing is installed anywhere', async () => { + const api = fakeApi(userDataDir); + await expect(detectQuickBms(api)).resolves.toBeUndefined(); + }); + + it('finds an install under this extension\'s own bundle-tools directory', async () => { + const api = fakeApi(userDataDir); + const quickBmsDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'QuickBMS'); + fs.mkdirSync(quickBmsDir, { recursive: true }); + fs.writeFileSync(path.join(quickBmsDir, 'quickbms.exe'), 'exe', 'utf8'); + fs.writeFileSync(path.join(quickBmsDir, 'witcher3.bms'), 'plugin', 'utf8'); + + const result = await detectQuickBms(api); + expect(result?.exePath).toBe(path.join(quickBmsDir, 'quickbms.exe')); + expect(result?.pluginPath).toBe(path.join(quickBmsDir, 'witcher3.bms')); + }); + + it('requires both the exe and the plugin to count as found', async () => { + const api = fakeApi(userDataDir); + const quickBmsDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'QuickBMS'); + fs.mkdirSync(quickBmsDir, { recursive: true }); + fs.writeFileSync(path.join(quickBmsDir, 'quickbms.exe'), 'exe', 'utf8'); + // No witcher3.bms written. + + await expect(detectQuickBms(api)).resolves.toBeUndefined(); + }); + + it('never pairs an exe from one root with a plugin from a different root', async () => { + // Regression test: an earlier version resolved exePath/pluginPath via two + // independent scans over the same root list, which could report a "found" + // result mixing an exe-only install in one location with a plugin-only install + // in another - two files that were never actually installed together. + const ownDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'QuickBMS'); + fs.mkdirSync(ownDir, { recursive: true }); + fs.writeFileSync(path.join(ownDir, 'quickbms.exe'), 'exe-only', 'utf8'); + // No witcher3.bms in ownDir. + + const idcsExeDir = path.join(userDataDir, 'idcs-fork-install'); + const idcsToolsDir = path.join(idcsExeDir, 'Tools', 'QuickBMS'); + fs.mkdirSync(idcsToolsDir, { recursive: true }); + fs.writeFileSync(path.join(idcsToolsDir, 'witcher3.bms'), 'plugin-only', 'utf8'); + // No quickbms.exe in idcsToolsDir. + + const api = fakeApi(userDataDir, { + witcher3: { tools: { W3ScriptMerger: { path: path.join(idcsExeDir, 'WitcherScriptMerger.exe') } } }, + }); + + await expect(detectQuickBms(api)).resolves.toBeUndefined(); + }); + + it('falls back to a prior IDCs-fork WitcherScriptMerger install\'s own Tools\\ folder', async () => { + const idcsExeDir = path.join(userDataDir, 'idcs-fork-install'); + fs.mkdirSync(idcsExeDir, { recursive: true }); + const idcsToolsDir = path.join(idcsExeDir, 'Tools', 'QuickBMS'); + fs.mkdirSync(idcsToolsDir, { recursive: true }); + fs.writeFileSync(path.join(idcsToolsDir, 'quickbms.exe'), 'exe', 'utf8'); + fs.writeFileSync(path.join(idcsToolsDir, 'witcher3.bms'), 'plugin', 'utf8'); + + const api = fakeApi(userDataDir, { + witcher3: { tools: { W3ScriptMerger: { path: path.join(idcsExeDir, 'WitcherScriptMerger.exe') } } }, + }); + + const result = await detectQuickBms(api); + expect(result?.exePath).toBe(path.join(idcsToolsDir, 'quickbms.exe')); + }); + + it('prefers this extension\'s own bundle-tools directory over the IDCs-fork fallback', async () => { + const ownDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'QuickBMS'); + fs.mkdirSync(ownDir, { recursive: true }); + fs.writeFileSync(path.join(ownDir, 'quickbms.exe'), 'exe', 'utf8'); + fs.writeFileSync(path.join(ownDir, 'witcher3.bms'), 'plugin', 'utf8'); + + const idcsExeDir = path.join(userDataDir, 'idcs-fork-install'); + const idcsToolsDir = path.join(idcsExeDir, 'Tools', 'QuickBMS'); + fs.mkdirSync(idcsToolsDir, { recursive: true }); + fs.writeFileSync(path.join(idcsToolsDir, 'quickbms.exe'), 'exe', 'utf8'); + fs.writeFileSync(path.join(idcsToolsDir, 'witcher3.bms'), 'plugin', 'utf8'); + + const api = fakeApi(userDataDir, { + witcher3: { tools: { W3ScriptMerger: { path: path.join(idcsExeDir, 'WitcherScriptMerger.exe') } } }, + }); + + const result = await detectQuickBms(api); + expect(result?.exePath).toBe(path.join(ownDir, 'quickbms.exe')); + }); + }); + + describe('detectWccLite', () => { + it('returns undefined when nothing is installed anywhere', async () => { + const api = fakeApi(userDataDir); + await expect(detectWccLite(api)).resolves.toBeUndefined(); + }); + + it('finds an install at the canonical bin\\x64\\wcc_lite.exe layout', async () => { + const api = fakeApi(userDataDir); + const wccLiteDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + fs.writeFileSync(path.join(wccLiteDir, 'wcc_lite.exe'), 'exe', 'utf8'); + + await expect(detectWccLite(api)).resolves.toBe(path.join(wccLiteDir, 'wcc_lite.exe')); + }); + + it('falls back to a prior IDCs-fork install\'s own Tools\\ folder', async () => { + const idcsExeDir = path.join(userDataDir, 'idcs-fork-install'); + const idcsWccLiteDir = path.join(idcsExeDir, 'Tools', 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(idcsWccLiteDir, { recursive: true }); + fs.writeFileSync(path.join(idcsWccLiteDir, 'wcc_lite.exe'), 'exe', 'utf8'); + + const api = fakeApi(userDataDir, { + witcher3: { tools: { W3ScriptMerger: { path: path.join(idcsExeDir, 'WitcherScriptMerger.exe') } } }, + }); + + await expect(detectWccLite(api)).resolves.toBe(path.join(idcsWccLiteDir, 'wcc_lite.exe')); + }); + + it('falls back to a bounded search under its own wcc_lite/ subfolder when the canonical layout does not match', async () => { + const api = fakeApi(userDataDir); + // Simulates an extracted "Official ModKit" archive whose internal layout differs + // from the canonical bin\x64\wcc_lite.exe path - see wccLiteAcquisition.ts's own + // "archive-layout caveat". + const nestedDir = path.join( + userDataDir, + 'witcherscriptmerger-vortex', + 'bundle-tools', + 'wcc_lite', + 'Modkit', + 'bin', + 'x64', + ); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(path.join(nestedDir, 'wcc_lite.exe'), 'exe', 'utf8'); + + await expect(detectWccLite(api)).resolves.toBe(path.join(nestedDir, 'wcc_lite.exe')); + }); + + it('does not find a wcc_lite.exe buried deeper than the bounded search depth', async () => { + const api = fakeApi(userDataDir); + const tooDeepDir = path.join( + userDataDir, + 'witcherscriptmerger-vortex', + 'bundle-tools', + 'wcc_lite', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + ); + fs.mkdirSync(tooDeepDir, { recursive: true }); + fs.writeFileSync(path.join(tooDeepDir, 'wcc_lite.exe'), 'exe', 'utf8'); + + await expect(detectWccLite(api)).resolves.toBeUndefined(); + }); + }); + + describe('detectBundleTools', () => { + it('combines QuickBMS and wcc_lite detection', async () => { + const api = fakeApi(userDataDir); + const bundleToolsDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools'); + const quickBmsDir = path.join(bundleToolsDir, 'QuickBMS'); + fs.mkdirSync(quickBmsDir, { recursive: true }); + fs.writeFileSync(path.join(quickBmsDir, 'quickbms.exe'), 'exe', 'utf8'); + fs.writeFileSync(path.join(quickBmsDir, 'witcher3.bms'), 'plugin', 'utf8'); + const wccLiteDir = path.join(bundleToolsDir, 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + fs.writeFileSync(path.join(wccLiteDir, 'wcc_lite.exe'), 'exe', 'utf8'); + + const result = await detectBundleTools(api); + expect(result).toEqual({ + quickBmsPath: path.join(quickBmsDir, 'quickbms.exe'), + quickBmsPluginPath: path.join(quickBmsDir, 'witcher3.bms'), + wccLitePath: path.join(wccLiteDir, 'wcc_lite.exe'), + }); + }); + + it('returns an all-undefined result when nothing is installed', async () => { + const api = fakeApi(userDataDir); + await expect(detectBundleTools(api)).resolves.toEqual({ + quickBmsPath: undefined, + quickBmsPluginPath: undefined, + wccLitePath: undefined, + }); + }); + }); + + describe('findFileByNameBounded', () => { + it('is case-insensitive and returns undefined for a missing root directory', async () => { + await expect( + findFileByNameBounded(path.join(userDataDir, 'does-not-exist'), 'wcc_lite.exe', 6), + ).resolves.toBeUndefined(); + }); + + it('finds a shallower match before a deeper one', async () => { + const shallowDir = path.join(userDataDir, 'shallow'); + const deepDir = path.join(userDataDir, 'a', 'b', 'deep'); + fs.mkdirSync(shallowDir, { recursive: true }); + fs.mkdirSync(deepDir, { recursive: true }); + fs.writeFileSync(path.join(shallowDir, 'WCC_LITE.EXE'), 'shallow', 'utf8'); + fs.writeFileSync(path.join(deepDir, 'wcc_lite.exe'), 'deep', 'utf8'); + + const found = await findFileByNameBounded(userDataDir, 'wcc_lite.exe', 6); + expect(found).toBe(path.join(shallowDir, 'WCC_LITE.EXE')); + }); + }); +}); diff --git a/vortex-extension/src/bundleTools.ts b/vortex-extension/src/bundleTools.ts new file mode 100644 index 0000000..4f4824f --- /dev/null +++ b/vortex-extension/src/bundleTools.ts @@ -0,0 +1,242 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { selectors, types } from 'vortex-api'; +import { WITCHER3_GAME_ID } from './gating'; +import { getBundleToolsDir } from './storage'; + +/** + * Local-only (no network) detection for WSM's two bundle-content dependencies - + * QuickBMS (quickbms.exe + witcher3.bms) and wcc_lite (wcc_lite.exe), neither of which + * is committed to this repo's own source control (see the root CLAUDE.md's "External + * tool dependencies & licensing"). Checks two locations, matching what WSM's own GUI + * `DependencyForm`/this repo's own `App.config` default paths already encode as the + * conventional on-disk layout for these tools: + * + * 1. This extension's own managed install, under `getBundleToolsDir(api)` (storage.ts) + * - `wcc_lite/bin/x64/wcc_lite.exe`, `QuickBMS/quickbms.exe` + + * `QuickBMS/witcher3.bms` - the exact relative layout + * `WitcherScriptMerger/App.config`'s own `WccLitePath`/`QuickBmsPath`/ + * `QuickBmsPluginPath` defaults already assume (`Tools\wcc_lite\bin\x64\wcc_lite.exe`, + * `Tools\QuickBMS\quickbms.exe`, `Tools\QuickBMS\witcher3.bms`). + * 2. A prior install of the separate `IDCs/WitcherScriptMerger` fork - the one + * Vortex's own built-in `game-witcher3` extension already downloads as its + * `W3ScriptMerger` discovered tool (see `discoveredTool.ts`'s own doc comment, and + * `docs/vortex-extension-design.md` §0/§2.2 Open Question 2's "detect and reuse + * whatever game-witcher3 already fetched" suggestion). That fork's own `Tools\` + * subfolder, sitting beside whichever `WitcherScriptMerger.exe` `game-witcher3` + * downloaded, uses this exact same relative layout - not a guess, it's this + * repo's own `App.config` default paths, inherited from the same WSM lineage. + * + * wcc_lite additionally falls back to a depth-bounded search under this extension's own + * `wcc_lite/` subfolder (see `BUNDLE_TOOL_SEARCH_MAX_DEPTH` below) - `wccLiteAcquisition.ts` + * downloads wcc_lite from its real Nexus Mods "Official ModKit" release, whose exact + * internal zip layout was never verified against a live download (no Nexus API key or + * scraping access in this environment - see that module's own doc comment), so it may + * not land at the exact canonical relative path above. QuickBMS is never auto-downloaded + * by this extension at all (see `QUICKBMS_HOMEPAGE_URL` below), so there's no + * "unknown archive layout" case to hedge against for it - only the two exact, + * known-convention locations are checked. + */ + +const IDCS_SCRIPT_MERGER_TOOL_ID = 'W3ScriptMerger'; + +const QUICKBMS_EXE_RELATIVE = path.join('QuickBMS', 'quickbms.exe'); +const QUICKBMS_PLUGIN_RELATIVE = path.join('QuickBMS', 'witcher3.bms'); +const WCC_LITE_EXE_RELATIVE = path.join('wcc_lite', 'bin', 'x64', 'wcc_lite.exe'); +export const WCC_LITE_EXE_FILENAME = 'wcc_lite.exe'; +export const WCC_LITE_SUBDIR = 'wcc_lite'; + +/** How many directory levels deep the wcc_lite fallback search descends - bounded so a + * large extracted ModKit tree (likely far more than just wcc_lite.exe - see + * `wccLiteAcquisition.ts`) can't make every detection call slow. */ +export const BUNDLE_TOOL_SEARCH_MAX_DEPTH = 6; + +/** QuickBMS's own homepage - never redistributed by this extension (see this module's + * own doc comment); `WitcherScriptMerger/Forms/DependencyForm.cs` (the WinForms host's + * own GUI) links here too, verbatim. */ +export const QUICKBMS_HOMEPAGE_URL = 'http://aluigi.altervista.org/quickbms.htm'; + +export interface DetectedBundleTools { + quickBmsPath?: string; + quickBmsPluginPath?: string; + wccLitePath?: string; +} + +function isEnoent(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** Mirrors `toolAcquisition.ts`'s own `pathExists` exactly (including the rationale for + * rethrowing anything other than ENOENT - a permission/lock error must not be + * silently treated as "not installed", which would both hide the real problem and + * risk offering to re-download/re-extract a tool that's actually already present). + * Duplicated rather than imported since `toolAcquisition.ts` doesn't export it and + * this unit's own instructions treat that file's *existing* logic as off-limits to + * modify. */ +async function fileExists(target: string): Promise { + try { + await fs.promises.access(target); + return true; + } catch (err) { + if (isEnoent(err)) { + return false; + } + throw err; + } +} + +/** + * Depth-bounded, level-order (breadth-first) search for a file named + * (case-insensitively) `targetFileName` under `rootDir`. Every directory at the + * current depth is checked for a direct match before any directory at the next depth + * is examined at all, so a shallower match always wins over a deeper one regardless of + * sibling-directory iteration order (which `fs.readdir` does not guarantee is + * alphabetical). Returns `undefined` (rather than throwing) when `rootDir` doesn't + * exist yet or nothing matches within `maxDepth` levels. + */ +export async function findFileByNameBounded( + rootDir: string, + targetFileName: string, + maxDepth: number, +): Promise { + const targetLower = targetFileName.toLowerCase(); + let currentLevelDirs = [rootDir]; + + for (let depth = 0; depth <= maxDepth && currentLevelDirs.length > 0; depth++) { + const nextLevelDirs: string[] = []; + + for (const dir of currentLevelDirs) { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + + const match = entries.find((entry) => entry.isFile() && entry.name.toLowerCase() === targetLower); + if (match) { + return path.join(dir, match.name); + } + + for (const entry of entries) { + if (entry.isDirectory()) { + nextLevelDirs.push(path.join(dir, entry.name)); + } + } + } + + currentLevelDirs = nextLevelDirs; + } + + return undefined; +} + +/** Sibling `Tools\` folder for a prior `IDCs/WitcherScriptMerger` install that Vortex's + * own `game-witcher3` extension may have already downloaded as its `W3ScriptMerger` + * discovered tool - `undefined` if no such tool has been discovered. + * + * Loosely typed rather than `types.IDiscoveryResult` on purpose: the runtime `vitest` + * stub (`test/testUtils/vortexApiStub.ts`) backing `selectors.discoveryByGame` in + * tests returns a deliberately simplified fake shape (see that stub's own doc + * comment) with no `tools` field at all - reading `.tools` off it at runtime is still + * safe (a plain missing-property read, not a type error), so this only needs a shape + * loose enough that both the real and fake return values satisfy it. */ +function getIdcsForkToolsDir(api: types.IExtensionApi): string | undefined { + const discovery = selectors.discoveryByGame(api.getState(), WITCHER3_GAME_ID) as + | { tools?: Record } + | undefined; + const toolPath = discovery?.tools?.[IDCS_SCRIPT_MERGER_TOOL_ID]?.path; + return toolPath ? path.join(path.dirname(toolPath), 'Tools') : undefined; +} + +function candidateRoots(api: types.IExtensionApi): string[] { + const roots = [getBundleToolsDir(api)]; + const idcsToolsDir = getIdcsForkToolsDir(api); + if (idcsToolsDir) { + roots.push(idcsToolsDir); + } + return roots; +} + +/** + * Detects an already-installed QuickBMS (exe + plugin, both required to count as + * "found") - never downloads anything. Per the root CLAUDE.md, QuickBMS's + * redistribution terms are murkier than wcc_lite's (no canonical Nexus-hosted release + * found), so this extension only ever detects an existing install or points the user at + * QuickBMS's own homepage (`QUICKBMS_HOMEPAGE_URL`) to source it themselves - mirroring + * `WitcherScriptMerger/Forms/DependencyForm.cs`'s own behavior for this exact + * dependency. + */ +export async function detectQuickBms( + api: types.IExtensionApi, +): Promise<{ exePath: string; pluginPath: string } | undefined> { + // Checked as a pair *within the same root*, not independently across roots - an + // earlier version resolved exePath/pluginPath via two separate firstExisting() scans + // over the same root list, which could report a "found" result pairing one root's + // exe with a *different* root's plugin (e.g. an exe-only own-install alongside a + // plugin-only IDCs-fork install) - a mismatched pair that was never actually + // installed together. Iterating roots in priority order and requiring both files to + // exist in the *same* root avoids that. + for (const root of candidateRoots(api)) { + const exePath = path.join(root, QUICKBMS_EXE_RELATIVE); + const pluginPath = path.join(root, QUICKBMS_PLUGIN_RELATIVE); + const [exeFound, pluginFound] = await Promise.all([fileExists(exePath), fileExists(pluginPath)]); + if (exeFound && pluginFound) { + return { exePath, pluginPath }; + } + } + return undefined; +} + +/** Detects an already-installed wcc_lite - see `wccLiteAcquisition.ts` for the + * auto-download path that populates `getBundleToolsDir(api)`'s `wcc_lite/` subfolder + * when this returns `undefined`. */ +export async function detectWccLite(api: types.IExtensionApi): Promise { + const bundleToolsDir = getBundleToolsDir(api); + + const canonicalPath = path.join(bundleToolsDir, WCC_LITE_EXE_RELATIVE); + if (await fileExists(canonicalPath)) { + return canonicalPath; + } + + // The IDCs-fork fallback root always uses the exact, well-known layout (this repo's + // own App.config defaults *are* that layout) - no search needed there, unlike the + // freshly-downloaded-by-us case below. + const idcsToolsDir = getIdcsForkToolsDir(api); + if (idcsToolsDir) { + const idcsPath = path.join(idcsToolsDir, WCC_LITE_EXE_RELATIVE); + if (await fileExists(idcsPath)) { + return idcsPath; + } + } + + // Fallback: a previously-downloaded wcc_lite Nexus archive (see wccLiteAcquisition.ts) + // may not lay wcc_lite.exe out at the exact canonical relative path above - see this + // module's own top comment. + const wccLiteRoot = path.join(bundleToolsDir, WCC_LITE_SUBDIR); + return findFileByNameBounded(wccLiteRoot, WCC_LITE_EXE_FILENAME, BUNDLE_TOOL_SEARCH_MAX_DEPTH); +} + +/** + * Combines both detections into the shape `wsmEnv.ts`'s `buildWsmEnv` expects - + * `WsmEnvConfig`'s `quickBmsPath`/`quickBmsPluginPath`/`wccLitePath` fields. Local-only, + * no network - safe to call unconditionally (mirrors `toolAcquisition.ts`'s + * `ensureWsmToolRegistered`'s own "safe on every load" property). + * + * **Consumers**: this unit's own `wsmStatusSummary.ts` (feeding `statusTile.ts`'s + * dashlet) is the only caller wired up so far - it's the only WSM-process spawn site + * this unit is allowed to touch (`toolAcquisition.ts`'s own `registerAcquiredTool` is + * explicitly off-limits per this unit's task instructions, and this unit doesn't build + * the merge panel/resolve action). The sibling units that actually drive + * bundle-content merges (units G/H/I) are the spawn sites that most need these three + * env vars populated - they should call this function too, rather than re-deriving the + * same two detection locations independently. + */ +export async function detectBundleTools(api: types.IExtensionApi): Promise { + const [quickBms, wccLitePath] = await Promise.all([detectQuickBms(api), detectWccLite(api)]); + return { + quickBmsPath: quickBms?.exePath, + quickBmsPluginPath: quickBms?.pluginPath, + wccLitePath, + }; +} diff --git a/vortex-extension/src/index.test.ts b/vortex-extension/src/index.test.ts index 84f1454..8e13250 100644 --- a/vortex-extension/src/index.test.ts +++ b/vortex-extension/src/index.test.ts @@ -4,14 +4,22 @@ import { describe, expect, it, vi } from 'vitest'; // from `vi.hoisted` rather than an ordinary outer-scope `const` - this isolates index.ts's // own wiring (what this file actually tests) from toolAcquisition.ts's real behavior // (already thoroughly covered by toolAcquisition.test.ts). -const { ensureWsmToolRegisteredMock } = vi.hoisted(() => ({ +const { ensureWsmToolRegisteredMock, registerWsmStatusDashletMock } = vi.hoisted(() => ({ ensureWsmToolRegisteredMock: vi.fn(), + registerWsmStatusDashletMock: vi.fn(), })); vi.mock('./toolAcquisition', () => ({ ensureWsmToolRegistered: ensureWsmToolRegisteredMock, })); +// Mocked for the same reason as './toolAcquisition' above - isolates index.ts's own +// wiring from statusTile.ts's real behavior (its own registerDashlet call shape is +// covered directly by statusTile.test.ts instead). +vi.mock('./statusTile', () => ({ + registerWsmStatusDashlet: registerWsmStatusDashletMock, +})); + import main from './index'; import { WITCHER3_GAME_ID } from './gating'; @@ -115,4 +123,19 @@ describe('main (index.ts)', () => { // Let the rejected promise's .catch() handler actually run before the test ends. await new Promise((resolve) => setTimeout(resolve, 0)); }); + + it('registers the WSM status dashlet once, at context.once time, regardless of the active game', () => { + // Unlike tryRegisterWsmTool, this registration isn't gated on isWitcher3Active here + // - statusTile.ts's own registerDashlet call supplies a live isVisible callback + // instead (covered by statusTile.test.ts), so index.ts always registers it once. + ensureWsmToolRegisteredMock.mockClear().mockResolvedValue(false); + registerWsmStatusDashletMock.mockClear(); + const { context, fireOnce } = fakeContext('skyrimse'); + + main(context); + fireOnce(); + + expect(registerWsmStatusDashletMock).toHaveBeenCalledTimes(1); + expect(registerWsmStatusDashletMock).toHaveBeenCalledWith(context); + }); }); diff --git a/vortex-extension/src/index.ts b/vortex-extension/src/index.ts index 773c254..3649fea 100644 --- a/vortex-extension/src/index.ts +++ b/vortex-extension/src/index.ts @@ -1,5 +1,6 @@ import { log, types } from 'vortex-api'; import { isWitcher3Active } from './gating'; +import { registerWsmStatusDashlet } from './statusTile'; import { ensureWsmToolRegistered } from './toolAcquisition'; /** @@ -66,6 +67,12 @@ function main(context: types.IExtensionContext): boolean { context.once(() => { tryRegisterWsmTool(); context.api.events.on('gamemode-activated', tryRegisterWsmTool); + + // Unit J: the dependency/status dashlet - registered once here, unlike + // tryRegisterWsmTool above, since registerDashlet's own `isVisible` callback (see + // statusTile.ts) is re-evaluated by Vortex live on every game-mode switch, so no + // 'gamemode-activated' re-check is needed for this registration itself. + registerWsmStatusDashlet(context); }); return true; diff --git a/vortex-extension/src/nexusDownloader.test.ts b/vortex-extension/src/nexusDownloader.test.ts new file mode 100644 index 0000000..ccb6a0c --- /dev/null +++ b/vortex-extension/src/nexusDownloader.test.ts @@ -0,0 +1,124 @@ +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createVortexNexusDownloader } from './nexusDownloader'; + +interface FakeDownloadEntry { + state?: string; + localPath?: string; +} + +function fakeApi(options: { + nexusDownload?: (...args: unknown[]) => PromiseLike; + downloadsDir?: string; + downloads?: Record; +}) { + const downloads = options.downloads ?? {}; + return { + ext: options.nexusDownload ? { nexusDownload: options.nexusDownload } : {}, + // Wires `options.downloadsDir` into the vitest stub's own fake + // `selectors.downloadPathForGame` state shape (see + // test/testUtils/vortexApiStub.ts) - keyed by 'witcher3' since every test in this + // file passes that as `gameId`. Without this, downloadModFile's own + // `path.join(downloadsDir, localPath)` step would silently join onto the stub's + // fallback sentinel directory instead, and no assertion here would actually cover + // that this module joins the two together correctly. + getState: () => ({ + persistent: { downloads: { files: downloads } }, + downloadPathForGame: options.downloadsDir ? { witcher3: options.downloadsDir } : undefined, + }), + } as unknown as Parameters[0]; +} + +describe('createVortexNexusDownloader', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('throws a clear error when api.ext.nexusDownload is unavailable', async () => { + const api = fakeApi({}); + const downloader = createVortexNexusDownloader(api); + + await expect( + downloader.downloadModFile({ gameId: 'witcher3', modId: 3173, fileId: 1 }), + ).rejects.toThrow(/nexusDownload is unavailable/); + }); + + it('calls nexusDownload with allowInstall: false and resolves to downloadsDir joined with localPath', async () => { + const downloads: Record = { + 'download-1': { state: 'started' }, + }; + const nexusDownload = vi.fn(async () => 'download-1'); + const downloadsDir = path.join('C:', 'fake-vortex-downloads', 'witcher3'); + const api = fakeApi({ nexusDownload, downloads, downloadsDir }); + + // Simulate the download finishing shortly after being queued. + setTimeout(() => { + downloads['download-1'] = { state: 'finished', localPath: 'wcc_lite_modkit.zip' }; + }, 2500); + + const downloader = createVortexNexusDownloader(api, { pollIntervalMs: 1000 }); + const resultPromise = downloader.downloadModFile({ + gameId: 'witcher3', + modId: 3173, + fileId: 42, + fileName: 'wcc_lite_modkit.zip', + }); + + await vi.advanceTimersByTimeAsync(5000); + const result = await resultPromise; + + expect(nexusDownload).toHaveBeenCalledWith('witcher3', 3173, 42, 'wcc_lite_modkit.zip', false); + // The real assertion this test exists for: downloadsDir (from + // selectors.downloadPathForGame) actually gets joined with the download's own + // localPath, not just "the result happens to end with the right filename". + expect(result).toBe(path.join(downloadsDir, 'wcc_lite_modkit.zip')); + }); + + it('rejects when the download reports a failed state', async () => { + const downloads: Record = { + 'download-1': { state: 'failed' }, + }; + const nexusDownload = vi.fn(async () => 'download-1'); + const api = fakeApi({ nexusDownload, downloads }); + + const downloader = createVortexNexusDownloader(api, { pollIntervalMs: 1000 }); + await expect( + downloader.downloadModFile({ gameId: 'witcher3', modId: 3173, fileId: 42 }), + ).rejects.toThrow(/failed/); + }); + + it('rejects once the timeout elapses without the download finishing', async () => { + const downloads: Record = { + 'download-1': { state: 'started' }, + }; + const nexusDownload = vi.fn(async () => 'download-1'); + const api = fakeApi({ nexusDownload, downloads }); + + const downloader = createVortexNexusDownloader(api, { pollIntervalMs: 1000, downloadTimeoutMs: 3000 }); + const resultPromise = downloader.downloadModFile({ gameId: 'witcher3', modId: 3173, fileId: 42 }); + const assertion = expect(resultPromise).rejects.toThrow(/did not finish within/); + + await vi.advanceTimersByTimeAsync(5000); + await assertion; + }); + + it('treats an entry not yet present in downloads state as still in progress, not a failure', async () => { + const downloads: Record = {}; + const nexusDownload = vi.fn(async () => 'download-1'); + const api = fakeApi({ nexusDownload, downloads }); + + setTimeout(() => { + downloads['download-1'] = { state: 'finished', localPath: 'file.zip' }; + }, 2000); + + const downloader = createVortexNexusDownloader(api, { pollIntervalMs: 500 }); + const resultPromise = downloader.downloadModFile({ gameId: 'witcher3', modId: 3173, fileId: 1 }); + + await vi.advanceTimersByTimeAsync(3000); + await expect(resultPromise).resolves.toContain('file.zip'); + }); +}); diff --git a/vortex-extension/src/nexusDownloader.ts b/vortex-extension/src/nexusDownloader.ts new file mode 100644 index 0000000..e9e3e43 --- /dev/null +++ b/vortex-extension/src/nexusDownloader.ts @@ -0,0 +1,129 @@ +import * as path from 'path'; +import { selectors, types } from 'vortex-api'; + +/** + * Downloads a file from a Nexus Mods mod page through Vortex's own Nexus integration - + * the pattern a Vortex extension normally uses to fetch a Nexus-hosted dependency by + * mod id, per this unit's own task instructions. `api.ext.nexusDownload` (a real, + * optional cross-extension API surface - `INexusAPIExtension` in + * `@nexusmods/vortex-api`'s own `lib/api.d.ts`) is provided by Vortex's own built-in + * Nexus integration extension at runtime; there is no single "download from Nexus and + * give me a local file path" call in that surface, so this module composes one out of + * the primitives that do exist: `nexusDownload` itself (queues/starts the download, + * resolving to a download id - `lib/api.d.ts` documents no guarantee about whether that + * promise resolves before or after the download actually finishes, so this doesn't + * assume either), then polling `state.persistent.downloads.files[id]` (an `IDownload`) + * until that download actually finishes, then `selectors.downloadPathForGame` to + * resolve the download's `localPath` (relative, per `IDownload.localPath`'s own doc + * comment) to an absolute path. + * + * Injectable (mirrors `githubRelease.ts`'s `HttpClient`/`archiveExtractor.ts`'s + * `ArchiveExtractor` seams) so `wccLiteAcquisition.ts` stays unit-testable without a + * real Vortex host or a real Nexus download - **this deliberately deviates from this + * unit's own instruction to mock at `githubRelease.ts`'s `nodeHttpsClient` boundary**: + * wcc_lite is fetched through Vortex's Nexus-download mechanism, not a plain HTTPS GET + * (Nexus doesn't serve unauthenticated direct-download links, so there is no URL for a + * raw `HttpClient` to hit even in principle) - see this unit's PR description for why. + * `createVortexNexusDownloader`'s real implementation below is, like + * `archiveExtractor.ts`'s real `createVortexArchiveExtractor`, never exercised by any + * test in this repo - there is no real Vortex host to run it against. See this unit's + * PR description for exactly what was/wasn't verified. + */ +export interface NexusDownloadOptions { + gameId: string; + modId: number; + fileId: number; + fileName?: string; +} + +export interface NexusDownloader { + /** Resolves to the absolute local path of the fully-downloaded file. Rejects if the + * download fails, or doesn't finish before this call's own timeout. */ + downloadModFile(options: NexusDownloadOptions): Promise; +} + +const DEFAULT_POLL_INTERVAL_MS = 1000; +/** A full "Witcher 3 modding tools" ModKit archive can be large (it's CD Projekt Red's + * whole toolkit, not just wcc_lite - see `wccLiteAcquisition.ts`), so this is generous + * compared to `githubRelease.ts`'s own per-request 30s timeout. */ +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000; + +export interface VortexNexusDownloaderOptions { + pollIntervalMs?: number; + downloadTimeoutMs?: number; +} + +export function createVortexNexusDownloader( + api: types.IExtensionApi, + options: VortexNexusDownloaderOptions = {}, +): NexusDownloader { + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; + + return { + async downloadModFile({ gameId, modId, fileId, fileName }: NexusDownloadOptions): Promise { + const nexusDownload = api.ext?.nexusDownload; + if (typeof nexusDownload !== 'function') { + throw new Error( + "Cannot download from Nexus Mods: api.ext.nexusDownload is unavailable. Vortex's own Nexus " + + 'integration extension (which provides this) may not be loaded.', + ); + } + + // allowInstall: false - load-bearing, not a default left at its default value. + // This download is a build dependency for WSM's own bundle-content merging, not a + // Witcher 3 mod in its own right; letting Vortex auto-install it would deploy the + // whole ModKit archive into the game's Mods folder and load-order it like a mod, + // which is not what this needs. + const downloadId = await nexusDownload(gameId, modId, fileId, fileName, false); + + const download = await waitForDownloadToFinish(api, downloadId, pollIntervalMs, downloadTimeoutMs); + if (!download.localPath) { + throw new Error( + `Nexus download '${downloadId}' (mod ${modId}, file ${fileId}) finished but reported no localPath.`, + ); + } + + const downloadsDir = selectors.downloadPathForGame(api.getState(), gameId); + return path.join(downloadsDir, download.localPath); + }, + }; +} + +interface DownloadStateLike { + state?: string; + localPath?: string; +} + +async function waitForDownloadToFinish( + api: types.IExtensionApi, + downloadId: string, + pollIntervalMs: number, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + + for (;;) { + const state = api.getState() as { + persistent?: { downloads?: { files?: Record } }; + }; + const download = state.persistent?.downloads?.files?.[downloadId]; + + if (download?.state === 'finished') { + return download; + } + if (download?.state === 'failed') { + throw new Error(`Nexus download '${downloadId}' failed.`); + } + // Any other observed state (undefined/not-yet-registered, 'init', 'started', + // 'paused', 'finalizing', 'redirect' - the full `DownloadState` union per + // `lib/api.d.ts` minus the two handled above) just means "still in progress" - + // keep polling rather than treating it as success or failure. + + if (Date.now() >= deadline) { + throw new Error(`Nexus download '${downloadId}' did not finish within ${timeoutMs}ms.`); + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } +} diff --git a/vortex-extension/src/statusTile.test.ts b/vortex-extension/src/statusTile.test.ts new file mode 100644 index 0000000..98d08ed --- /dev/null +++ b/vortex-extension/src/statusTile.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WITCHER3_GAME_ID } from './gating'; +import { registerWsmStatusDashlet } from './statusTile'; + +/** A minimal stand-in for IExtensionContext, matching index.test.ts's own fakeContext + * philosophy - just enough surface for registerWsmStatusDashlet's own logic. */ +function fakeContext() { + const registerDashlet = vi.fn(); + return { + context: { api: { fake: 'api' }, registerDashlet } as unknown as Parameters[0], + registerDashlet, + }; +} + +describe('registerWsmStatusDashlet', () => { + it('calls context.registerDashlet with the real 8-argument signature', () => { + const { context, registerDashlet } = fakeContext(); + + registerWsmStatusDashlet(context); + + expect(registerDashlet).toHaveBeenCalledTimes(1); + const [title, width, height, position, component, isVisible, propsCallback, options] = + registerDashlet.mock.calls[0]; + + expect(typeof title).toBe('string'); + expect(width).toBeGreaterThanOrEqual(1); + expect(width).toBeLessThanOrEqual(3); + expect(height).toBeGreaterThanOrEqual(1); + expect(height).toBeLessThanOrEqual(6); + expect(typeof position).toBe('number'); + expect(typeof component).toBe('function'); + expect(typeof isVisible).toBe('function'); + expect(typeof propsCallback).toBe('function'); + expect(typeof options).toBe('object'); + }); + + it('isVisible is true only when witcher3 is the active game', () => { + const { context, registerDashlet } = fakeContext(); + registerWsmStatusDashlet(context); + + const isVisible = registerDashlet.mock.calls[0][5] as (state: unknown) => boolean; + + expect(isVisible({ activeGameId: WITCHER3_GAME_ID })).toBe(true); + expect(isVisible({ activeGameId: 'skyrimse' })).toBe(false); + }); + + it('the props callback supplies the extension api', () => { + const { context, registerDashlet } = fakeContext(); + registerWsmStatusDashlet(context); + + const propsCallback = registerDashlet.mock.calls[0][6] as () => { api: unknown }; + expect(propsCallback()).toEqual({ api: context.api }); + }); +}); diff --git a/vortex-extension/src/statusTile.ts b/vortex-extension/src/statusTile.ts new file mode 100644 index 0000000..5f146b6 --- /dev/null +++ b/vortex-extension/src/statusTile.ts @@ -0,0 +1,219 @@ +import * as React from 'react'; +import { selectors, types, util } from 'vortex-api'; +import { QUICKBMS_HOMEPAGE_URL } from './bundleTools'; +import { WITCHER3_GAME_ID } from './gating'; +import { WCC_LITE_NEXUS_MOD_URL, acquireWccLite } from './wccLiteAcquisition'; +import { WsmStatusSummary, getWsmStatusSummary } from './wsmStatusSummary'; + +/** + * Dashboard tile showing WSM's dependency/status snapshot - `docs/vortex-extension-design.md` + * §5's "dependency/status tile" ("whether QuickBMS/wcc_lite are found ..., resolved + * game/mods directories, ... live conflict count. Useful as a single place to tell the + * user 'your script-merge tooling isn't set up' before they hit a confusing failure + * mid-deploy."). Data-fetching itself lives in `wsmStatusSummary.ts` (kept separate so + * it's unit-testable without a React/DOM harness - see that module's own doc comment); + * this file is just the `React.ComponentClass`/`FunctionComponent` `context. + * registerDashlet` needs, plus the registration call itself. + * + * Deliberately built with `React.createElement` rather than JSX syntax: this project's + * `tsconfig.json` `include` only covers `src/**\/*.ts` (no `*.tsx`), and introducing a + * `.tsx` file/JSX pipeline would be a shared build-config change (`tsconfig.json`, + * `webpack.config.cjs`'s `ts-loader` rule) that risks colliding with the three sibling + * units (G/H/I) touching this same extension in parallel - see this unit's own task + * instructions on keeping shared-file changes additive/minimal. Plain + * `React.createElement` calls need neither. + * + * Deliberately does **not** import `Dashlet` (the `vortex-api`-provided panel chrome + * component) for the same reason `vortex-api` itself is never imported as a *value* by + * any earlier unit beyond `actions`/`selectors`/`util`/`log`: `test/testUtils/ + * vortexApiStub.ts` (the module vitest resolves the bare `'vortex-api'` specifier to) + * doesn't export it, so it would be `undefined` at module-evaluation time in any test + * that transitively imports this file, including `index.test.ts`'s `main()` smoke + * tests. A plain wrapper `
` keeps this component's own chrome self-contained and + * keeps that shared test stub untouched. + */ + +export interface WsmStatusDashletProps { + api: types.IExtensionApi; +} + +function row(key: string, label: string, value: React.ReactNode): React.ReactElement { + return React.createElement( + 'div', + { key, style: { display: 'flex', gap: '0.5em', padding: '2px 0' } }, + React.createElement('span', { style: { fontWeight: 'bold', minWidth: '14em' } }, label), + React.createElement('span', undefined, value), + ); +} + +function yesNo(value: boolean): string { + return value ? 'Yes' : 'No'; +} + +/** + * Opens `url` in the OS's default browser via `vortex-api`'s own `util.opn` (confirmed + * against `lib/api.d.ts`: `open_2` exported as `opn`, `(target: string, wait?: boolean) + * => Promise`) rather than a plain `` - a bare `target="_blank"` + * anchor inside Vortex's Electron renderer does not reliably open the user's actual + * system browser (Electron either blocks the navigation or opens a chrome-less + * `BrowserWindow` unless the host app registers its own new-window handler for it); + * `opn` is the mechanism Vortex extensions use for exactly this. + */ +function externalLink(url: string, text: string): React.ReactElement { + const handleClick = (event: React.MouseEvent): void => { + event.preventDefault(); + util.opn(url).catch(() => { + // Best-effort - opn() failing (e.g. no default browser configured on this + // machine) isn't something this click handler can usefully recover from beyond + // not crashing. + }); + }; + return React.createElement('a', { href: url, onClick: handleClick }, text); +} + +function renderBundleToolRow( + key: string, + label: string, + detectedPath: string | undefined, + fallback: React.ReactNode, +): React.ReactElement { + return row(key, label, detectedPath ?? fallback); +} + +export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.ReactElement { + const { api } = props; + const [summary, setSummary] = React.useState(undefined); + const [loading, setLoading] = React.useState(true); + const [wccLiteError, setWccLiteError] = React.useState(undefined); + const [fetchingWccLite, setFetchingWccLite] = React.useState(false); + + // Combined "something is in flight" flag, used to disable *both* buttons regardless + // of which action started it - without this, clicking "Refresh" while "Get wcc_lite" + // is still running (or vice versa) could fire two overlapping WSM process + // spawns/MCP handshakes/mods-folder scans that race each other's setSummary() call. + const busy = loading || fetchingWccLite; + + // Returns its own promise (rather than firing-and-forgetting internally) so + // handleGetWccLite below can genuinely wait for the post-acquisition refresh to + // finish before clearing fetchingWccLite - otherwise the "Downloading..." button + // label would revert to idle as soon as refresh() was merely *invoked*, not once the + // new status (a full WSM process spawn + MCP handshake) actually finished loading. + const refresh = React.useCallback((): Promise => { + setLoading(true); + // A refresh is a "start over" action - any stale wcc_lite-download error from a + // previous attempt shouldn't keep rendering once the user has asked for a fresh + // status check (e.g. after installing wcc_lite by hand and clicking Refresh). + setWccLiteError(undefined); + return getWsmStatusSummary(api) + .then((result) => setSummary(result)) + .catch((err: unknown) => setSummary({ kind: 'error', message: err instanceof Error ? err.message : String(err) })) + .finally(() => setLoading(false)); + }, [api]); + + React.useEffect(() => { + refresh(); + }, [refresh]); + + const handleGetWccLite = React.useCallback(() => { + setFetchingWccLite(true); + setWccLiteError(undefined); + acquireWccLite({ api }) + .then(() => refresh()) + .catch((err: unknown) => setWccLiteError(err instanceof Error ? err.message : String(err))) + .finally(() => setFetchingWccLite(false)); + }, [api, refresh]); + + const children: React.ReactNode[] = [ + React.createElement('h4', { key: 'title', style: { marginTop: 0 } }, 'WitcherScriptMerger Status'), + ]; + + if (loading && summary === undefined) { + children.push(React.createElement('div', { key: 'loading' }, 'Checking WitcherScriptMerger status...')); + } else if (summary?.kind === 'not-acquired') { + children.push( + React.createElement( + 'div', + { key: 'not-acquired' }, + "WitcherScriptMerger hasn't been downloaded yet.", + ), + ); + } else if (summary?.kind === 'error') { + children.push( + React.createElement('div', { key: 'error', style: { color: '#c0392b' } }, `Unable to check status: ${summary.message}`), + ); + } else if (summary?.kind === 'ok') { + const { status, bundleTools } = summary; + children.push( + row('textMergeDeps', 'Text-merge engine ready:', yesNo(status.textMergeDependenciesValid)), + row('bundleDeps', 'Bundle tooling ready:', yesNo(status.bundleDependenciesValid)), + row('modsDir', 'Mods directory:', status.modsDirectory || '(not configured)'), + row('modsDirExists', 'Mods directory exists:', yesNo(status.modsDirectoryExists)), + row('conflictCount', 'Detected conflicts:', String(status.conflictCount)), + row('mergedModName', 'Merged mod name:', status.mergedModName || '(not configured)'), + renderBundleToolRow( + 'quickBms', + 'QuickBMS:', + bundleTools.quickBmsPath && bundleTools.quickBmsPluginPath ? bundleTools.quickBmsPath : undefined, + React.createElement( + React.Fragment, + undefined, + 'Not found - ', + externalLink(QUICKBMS_HOMEPAGE_URL, 'get it yourself (QuickBMS homepage)'), + '. Licensing terms are unclear, so this extension never downloads it automatically.', + ), + ), + renderBundleToolRow( + 'wccLite', + 'wcc_lite:', + bundleTools.wccLitePath, + React.createElement( + React.Fragment, + undefined, + 'Not found - ', + React.createElement( + 'button', + { type: 'button', disabled: busy, onClick: handleGetWccLite }, + fetchingWccLite ? 'Downloading...' : 'Get wcc_lite from Nexus Mods', + ), + ' (', + externalLink(WCC_LITE_NEXUS_MOD_URL, 'mod page'), + ')', + ), + ), + ); + if (wccLiteError) { + children.push(row('wccLiteError', 'wcc_lite download failed:', wccLiteError)); + } + } + + children.push( + React.createElement( + 'button', + { key: 'refresh', type: 'button', disabled: busy, onClick: refresh, style: { marginTop: '0.5em' } }, + loading ? 'Refreshing...' : 'Refresh', + ), + ); + + return React.createElement('div', { className: 'wsm-status-dashlet' }, ...children); +} + +/** Registers the dashlet - see this module's own doc comment for why it's a plain + * `
`-wrapped component rather than one using `vortex-api`'s `Dashlet` chrome. + * Called once from `index.ts`'s own `context.once`; visibility itself is re-evaluated + * live by Vortex on every game-mode switch via `isVisible`, the same dynamic-gating + * shape every other registration in this extension uses (see `gating.ts`'s own doc + * comment) - no restart needed to show/hide this tile when switching in or out of + * Witcher 3. */ +export function registerWsmStatusDashlet(context: types.IExtensionContext): void { + context.registerDashlet( + 'WitcherScriptMerger Status', + 2, + 2, + 250, + WsmStatusDashletContent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state: any) => selectors.activeGameId(state) === WITCHER3_GAME_ID, + () => ({ api: context.api }), + {}, + ); +} diff --git a/vortex-extension/src/storage.ts b/vortex-extension/src/storage.ts index 62f4361..5dea7bb 100644 --- a/vortex-extension/src/storage.ts +++ b/vortex-extension/src/storage.ts @@ -21,15 +21,17 @@ import { types } from 'vortex-api'; * downloads/ <- scratch .zip downloads before extraction; safe to delete * entirely at any time (toolAcquisition.ts treats it as a * cache, not a source of truth). - * bundle-tools/ <- CONVENTION for a later unit (bundle-tooling acquisition, - * not implemented here - see this unit's PR description): - * QuickBMS (quickbms.exe + witcher3.bms) and wcc_lite should - * land under here once that unit exists, and - * WSM_QuickBmsPath/WSM_QuickBmsPluginPath/WSM_WccLitePath - * (see wsmEnv.ts) should point inside it, e.g. + * bundle-tools/ <- QuickBMS (quickbms.exe + witcher3.bms) and wcc_lite + * (see bundleTools.ts's detection logic and + * wccLiteAcquisition.ts's wcc_lite auto-download) land + * under here, e.g. * path.join(getBundleToolsDir(api), 'QuickBMS', 'quickbms.exe'). - * Exported now, specifically so that later unit doesn't have - * to re-derive where this extension keeps its own files. + * WSM_QuickBmsPath/WSM_QuickBmsPluginPath/WSM_WccLitePath + * (see wsmEnv.ts) are populated from these paths by + * wsmStatusSummary.ts today; the sibling units driving + * actual bundle-content merges should reuse + * bundleTools.ts's detectBundleTools(api) too, rather than + * re-deriving this layout independently. */ const EXTENSION_STORAGE_DIRNAME = 'witcherscriptmerger-vortex'; const TOOL_SUBDIR = 'tool'; diff --git a/vortex-extension/src/wccLiteAcquisition.test.ts b/vortex-extension/src/wccLiteAcquisition.test.ts new file mode 100644 index 0000000..979528f --- /dev/null +++ b/vortex-extension/src/wccLiteAcquisition.test.ts @@ -0,0 +1,129 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ArchiveExtractor } from './archiveExtractor'; +import { NexusDownloader } from './nexusDownloader'; +import { WCC_LITE_NEXUS_MOD_ID, acquireWccLite } from './wccLiteAcquisition'; + +function fakeApi(userDataDir: string, nexusGetModFiles?: (...args: unknown[]) => PromiseLike) { + return { + getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`), + getState: () => ({}), + ext: nexusGetModFiles ? { nexusGetModFiles } : {}, + } as unknown as Parameters[0]['api']; +} + +function fakeDownloaderReturning(archivePath: string): NexusDownloader { + return { + downloadModFile: vi.fn(async () => archivePath), + }; +} + +function fakeExtractorThatProducesExe(exeRelativePath: string[]): ArchiveExtractor { + return { + extractAll: async (_archivePath: string, destDir: string) => { + const exeDir = path.join(destDir, ...exeRelativePath.slice(0, -1)); + await fs.promises.mkdir(exeDir, { recursive: true }); + await fs.promises.writeFile(path.join(destDir, ...exeRelativePath), 'fake wcc_lite bytes', 'utf8'); + }, + }; +} + +describe('acquireWccLite', () => { + let userDataDir: string; + + beforeEach(() => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-wcclite-test-')); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }); + + it('detects an already-installed wcc_lite and does no network/extraction work at all', async () => { + const wccLiteDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + const exePath = path.join(wccLiteDir, 'wcc_lite.exe'); + fs.writeFileSync(exePath, 'existing exe', 'utf8'); + + const api = fakeApi(userDataDir); + const downloader = fakeDownloaderReturning('/should-not-be-used.zip'); + const extractor = fakeExtractorThatProducesExe(['bin', 'x64', 'wcc_lite.exe']); + const downloadSpy = vi.spyOn(downloader, 'downloadModFile'); + const extractSpy = vi.spyOn(extractor, 'extractAll'); + + const result = await acquireWccLite({ api, downloader, extractor, fileId: 99 }); + + expect(result).toBe(exePath); + expect(downloadSpy).not.toHaveBeenCalled(); + expect(extractSpy).not.toHaveBeenCalled(); + }); + + it('downloads and extracts wcc_lite when not already present, using an explicit fileId', async () => { + const api = fakeApi(userDataDir); + const downloader = fakeDownloaderReturning(path.join(userDataDir, 'downloaded-modkit.zip')); + const downloadSpy = vi.spyOn(downloader, 'downloadModFile'); + const extractor = fakeExtractorThatProducesExe(['Modkit', 'bin', 'x64', 'wcc_lite.exe']); + + const result = await acquireWccLite({ api, downloader, extractor, fileId: 12345 }); + + expect(downloadSpy).toHaveBeenCalledWith( + expect.objectContaining({ modId: WCC_LITE_NEXUS_MOD_ID, fileId: 12345 }), + ); + expect(fs.existsSync(result)).toBe(true); + expect(path.basename(result).toLowerCase()).toBe('wcc_lite.exe'); + }); + + it('resolves the fileId via nexusGetModFiles, preferring the file Nexus marks is_primary', async () => { + const api = fakeApi(userDataDir, async () => [ + { file_id: 111, file_name: 'old.zip', is_primary: false }, + { file_id: 222, file_name: 'modkit-current.zip', is_primary: true }, + ]); + const downloader = fakeDownloaderReturning(path.join(userDataDir, 'modkit-current.zip')); + const downloadSpy = vi.spyOn(downloader, 'downloadModFile'); + const extractor = fakeExtractorThatProducesExe(['bin', 'x64', 'wcc_lite.exe']); + + await acquireWccLite({ api, downloader, extractor }); + + expect(downloadSpy).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 222, fileName: 'modkit-current.zip' }), + ); + }); + + it('falls back to the first listed file when none is marked is_primary', async () => { + const api = fakeApi(userDataDir, async () => [ + { file_id: 333, file_name: 'only.zip', is_primary: false }, + ]); + const downloader = fakeDownloaderReturning(path.join(userDataDir, 'only.zip')); + const downloadSpy = vi.spyOn(downloader, 'downloadModFile'); + const extractor = fakeExtractorThatProducesExe(['bin', 'x64', 'wcc_lite.exe']); + + await acquireWccLite({ api, downloader, extractor }); + + expect(downloadSpy).toHaveBeenCalledWith(expect.objectContaining({ fileId: 333 })); + }); + + it('throws when nexusGetModFiles is unavailable and no explicit fileId was supplied', async () => { + const api = fakeApi(userDataDir); + const downloader = fakeDownloaderReturning('/unused.zip'); + const extractor = fakeExtractorThatProducesExe(['bin', 'x64', 'wcc_lite.exe']); + + await expect(acquireWccLite({ api, downloader, extractor })).rejects.toThrow(/nexusGetModFiles is unavailable/); + }); + + it('throws a clear error when extraction succeeds but no wcc_lite.exe is found anywhere inside it', async () => { + const api = fakeApi(userDataDir); + const downloader = fakeDownloaderReturning(path.join(userDataDir, 'modkit.zip')); + const noOpExtractor: ArchiveExtractor = { + extractAll: async (_archivePath, destDir) => { + await fs.promises.mkdir(destDir, { recursive: true }); + await fs.promises.writeFile(path.join(destDir, 'readme.txt'), 'no exe here', 'utf8'); + }, + }; + + await expect(acquireWccLite({ api, downloader, extractor: noOpExtractor, fileId: 1 })).rejects.toThrow( + /no 'wcc_lite\.exe' was found/, + ); + }); +}); diff --git a/vortex-extension/src/wccLiteAcquisition.ts b/vortex-extension/src/wccLiteAcquisition.ts new file mode 100644 index 0000000..8a32bb9 --- /dev/null +++ b/vortex-extension/src/wccLiteAcquisition.ts @@ -0,0 +1,183 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { types } from 'vortex-api'; +import { ArchiveExtractor, createVortexArchiveExtractor } from './archiveExtractor'; +import { BUNDLE_TOOL_SEARCH_MAX_DEPTH, WCC_LITE_EXE_FILENAME, WCC_LITE_SUBDIR, detectWccLite, findFileByNameBounded } from './bundleTools'; +import { WITCHER3_GAME_ID } from './gating'; +import { NexusDownloader, createVortexNexusDownloader } from './nexusDownloader'; +import { getBundleToolsDir } from './storage'; + +/** + * Auto-fetches wcc_lite - the Windows-only official CD Projekt Red "Witcher 3 modding + * tools" / ModKit binary WSM needs for bundle-content (DLC/expansion) conflicts, see + * the root CLAUDE.md's "External tool dependencies & licensing" - from its Nexus Mods + * page, via Vortex's own Nexus-download mechanism (`nexusDownloader.ts`). Detects an + * existing install first (`bundleTools.ts`'s `detectWccLite` - both this extension's + * own prior download and a prior `IDCs/WitcherScriptMerger` fork install) and does no + * network activity at all when one is already present, mirroring `toolAcquisition.ts`'s + * `acquireWsmTool`'s own idempotent-download shape. + * + * **Licensing/EULA caveat - read before enabling this broadly.** wcc_lite is an + * official CD Projekt Red tool distributed via Nexus Mods, not a WSM-authored artifact, + * and this extension redistributing/auto-fetching it at runtime (into a + * Vortex-managed location, never into this repo's own source control) has not been + * independently confirmed against Nexus Mods' / CD Projekt Red's own redistribution + * terms beyond "it's an official tool hosted on an official Nexus mod page". The root + * CLAUDE.md already treats QuickBMS/wcc_lite licensing as an explicitly open decision + * requiring the repo owner's sign-off before changing the "not bundled in source + * control" policy - auto-fetching at runtime into a separate, Vortex-managed location + * is a different question from bundling in source control, but still needs the repo + * owner's explicit go/no-go before this ships broadly. See this unit's PR description. + * + * **Mod id caveat.** `WCC_LITE_NEXUS_MOD_ID` below (3173, "Official ModKit") was + * corroborated via two independent web searches and cross-referenced against + * `WitcherScriptMerger/Forms/DependencyForm.cs`'s own wcc_lite link (a Nexus *news* + * post announcing a ModKit update, itself pointing at this same mod page) - not + * verified against a live, authenticated Nexus session (this environment has no Nexus + * API key, and nexusmods.com returns HTTP 403 for unauthenticated scraping). The exact + * **file id** within that mod is deliberately *not* hardcoded - `resolveFileId` below + * looks it up live via `api.ext.nexusGetModFiles`, picking the file Nexus itself marks + * `is_primary`, so this doesn't go stale as the ModKit is updated over time. + * + * **Archive-layout caveat.** The "Official ModKit" download may not even be a plain + * tools archive (it could be an installer, or a much larger archive than just + * wcc_lite) - if `wcc_lite.exe` isn't found anywhere inside it after extraction, this + * throws a clear error naming what it looked for and where, the same "degrades to a + * clear failure, not a silent wrong result" shape as `acquireWsmTool`'s own "expected + * executable was not found" check. + */ + +export const WCC_LITE_NEXUS_GAME_ID = WITCHER3_GAME_ID; +/** "Official ModKit" on the Witcher 3 Nexus, published by CD Projekt RED - see this + * module's own "Mod id caveat" above. */ +export const WCC_LITE_NEXUS_MOD_ID = 3173; +/** For display/manual-fallback purposes (e.g. a "get it yourself" link in the status + * tile if auto-fetch fails) - the mod page this extension downloads from. */ +export const WCC_LITE_NEXUS_MOD_URL = `https://www.nexusmods.com/witcher3/mods/${WCC_LITE_NEXUS_MOD_ID}`; + +export interface AcquireWccLiteOptions { + api: types.IExtensionApi; + /** Test-only seam / manual override - skips `nexusGetModFiles` resolution entirely + * when set. */ + fileId?: number; + /** Test-only seam - see `nexusDownloader.ts`'s `NexusDownloader`. */ + downloader?: NexusDownloader; + /** Test-only seam - see `archiveExtractor.ts`'s `ArchiveExtractor`. */ + extractor?: ArchiveExtractor; +} + +/** Minimal local shape for the fields this module actually reads off a Nexus file + * listing - deliberately not importing `IFileInfo` from `@nexusmods/nexus-api` (a + * transitive, types-only dependency of `@nexusmods/vortex-api` that isn't itself + * installed in this project - see `package.json`), which would tie this file to a + * package this project has no direct dependency on for zero benefit over this narrow + * local interface. */ +interface NexusFileInfoLike { + file_id: number; + file_name?: string; + is_primary?: boolean; +} + +async function resolveFileId( + api: types.IExtensionApi, + explicitFileId: number | undefined, +): Promise<{ fileId: number; fileName?: string }> { + if (explicitFileId !== undefined) { + return { fileId: explicitFileId }; + } + + const nexusGetModFiles = api.ext?.nexusGetModFiles; + if (typeof nexusGetModFiles !== 'function') { + throw new Error( + 'Cannot resolve which wcc_lite file to download: api.ext.nexusGetModFiles is unavailable and no ' + + 'explicit fileId was supplied.', + ); + } + + const files = (await nexusGetModFiles( + WCC_LITE_NEXUS_GAME_ID, + WCC_LITE_NEXUS_MOD_ID, + )) as unknown as NexusFileInfoLike[]; + const primary = files.find((f) => f.is_primary) ?? files[0]; + if (!primary) { + throw new Error(`Nexus reported no files at all for mod ${WCC_LITE_NEXUS_MOD_ID} ('Official ModKit').`); + } + return { fileId: primary.file_id, fileName: primary.file_name }; +} + +/** Keyed by extractDir - mirrors `toolAcquisition.ts`'s own `inFlightAcquisitions`: + * overlapping calls sharing the same target directory coalesce onto whichever call + * started first, rather than racing two concurrent downloads/extractions into the + * same directory (e.g. two dashlet instances both offering a "Get wcc_lite" button). + * Same deliberate simplification `toolAcquisition.ts` documents - not a full + * per-argument dedup. */ +const inFlightAcquisitions = new Map>(); + +/** + * Downloads (if not already present locally - see `bundleTools.ts`'s `detectWccLite`), + * extracts, and returns the absolute path to `wcc_lite.exe`. + */ +export async function acquireWccLite(options: AcquireWccLiteOptions): Promise { + const extractDir = path.join(getBundleToolsDir(options.api), WCC_LITE_SUBDIR); + + const existing = inFlightAcquisitions.get(extractDir); + if (existing) { + return existing; + } + + const promise = acquireWccLiteUncoordinated(options, extractDir); + inFlightAcquisitions.set(extractDir, promise); + try { + return await promise; + } finally { + inFlightAcquisitions.delete(extractDir); + } +} + +async function acquireWccLiteUncoordinated(options: AcquireWccLiteOptions, extractDir: string): Promise { + const { api } = options; + + const existing = await detectWccLite(api); + if (existing) { + return existing; + } + + const { fileId, fileName } = await resolveFileId(api, options.fileId); + const downloader = options.downloader ?? createVortexNexusDownloader(api); + + const archivePath = await downloader.downloadModFile({ + gameId: WCC_LITE_NEXUS_GAME_ID, + modId: WCC_LITE_NEXUS_MOD_ID, + fileId, + fileName, + }); + + // Wipe whatever's currently in extractDir before extracting - a prior + // interrupted/failed extraction's debris shouldn't survive into this attempt and mix + // with a fresh one (mirrors toolAcquisition.ts's acquireWsmToolUncoordinated's own + // rm-then-mkdir before extracting, for the identical reason). + await fs.promises.rm(extractDir, { recursive: true, force: true }); + await fs.promises.mkdir(extractDir, { recursive: true }); + + const extractor = options.extractor ?? createVortexArchiveExtractor(api); + await extractor.extractAll(archivePath, extractDir); + + const exePath = await findFileByNameBounded(extractDir, WCC_LITE_EXE_FILENAME, BUNDLE_TOOL_SEARCH_MAX_DEPTH); + if (!exePath) { + throw new Error( + `Downloaded and extracted the wcc_lite Nexus mod file into '${extractDir}' but no ` + + `'${WCC_LITE_EXE_FILENAME}' was found there (searched up to ${BUNDLE_TOOL_SEARCH_MAX_DEPTH} directory ` + + "levels deep). The mod's internal layout may not match what this extension expects - see this unit's " + + 'PR description for the "archive-layout caveat".', + ); + } + + // Deliberately does *not* delete the downloaded archive afterward (unlike + // toolAcquisition.ts's own best-effort cleanup of its scratch download-cache zip): + // that archive lives in *Vortex's own* downloads store (see nexusDownloader.ts), + // which Vortex itself tracks as a real, user-visible download entry - deleting the + // backing file out from under Vortex's own bookkeeping would leave a dangling + // "missing file" entry in Vortex's Downloads page, unlike the disposable, + // this-extension-only scratch cache toolAcquisition.ts cleans up. + return exePath; +} diff --git a/vortex-extension/src/wsmEnv.ts b/vortex-extension/src/wsmEnv.ts index 09e5ba7..edcb249 100644 --- a/vortex-extension/src/wsmEnv.ts +++ b/vortex-extension/src/wsmEnv.ts @@ -25,10 +25,10 @@ export interface WsmEnvConfig { modsDirectory?: string; mergedModName?: string; /** - * Not consumed by anything in this unit (bundle-tooling acquisition hasn't landed - * yet - see `storage.ts`'s `getBundleToolsDir` doc comment for the storage - * convention a later unit should use to produce these three paths). Accepted here - * now so that later unit only has to supply values, not invent the env-var mapping. + * Populated from `bundleTools.ts`'s `detectBundleTools(api)` - see `storage.ts`'s + * `getBundleToolsDir` doc comment for the storage convention these three paths come + * from, and `wccLiteAcquisition.ts` for wcc_lite's own auto-download path (QuickBMS + * is detection-only - never auto-downloaded, see that module's own doc comment). */ quickBmsPath?: string; quickBmsPluginPath?: string; diff --git a/vortex-extension/src/wsmStatusSummary.test.ts b/vortex-extension/src/wsmStatusSummary.test.ts new file mode 100644 index 0000000..bfcba89 --- /dev/null +++ b/vortex-extension/src/wsmStatusSummary.test.ts @@ -0,0 +1,128 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WsmMcpClient, WsmMcpClientOptions } from './mcpClient'; +import { WSM_HEADLESS_EXE_NAME } from './toolAcquisition'; +import { getWsmStatusSummary } from './wsmStatusSummary'; + +function fakeApi(userDataDir: string) { + return { + getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`), + getState: () => ({}), + } as unknown as Parameters[0]; +} + +describe('getWsmStatusSummary', () => { + let userDataDir: string; + + beforeEach(() => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-statussummary-test-')); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }); + + it("reports 'not-acquired' when no WSM build has been downloaded yet", async () => { + const api = fakeApi(userDataDir); + await expect(getWsmStatusSummary(api)).resolves.toEqual({ kind: 'not-acquired' }); + }); + + it("reports 'error' when the WSM process fails to spawn/connect", async () => { + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + fs.writeFileSync(path.join(toolDir, WSM_HEADLESS_EXE_NAME), 'fake exe', 'utf8'); + + const api = fakeApi(userDataDir); + const connect = vi.fn(async (_options: WsmMcpClientOptions) => { + throw new Error('spawn failed'); + }) as unknown as typeof WsmMcpClient.connect; + + const result = await getWsmStatusSummary(api, { connect }); + expect(result).toEqual({ kind: 'error', message: 'spawn failed' }); + }); + + it("reports 'ok' with the status and detected bundle tools when the WSM process responds", async () => { + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + fs.writeFileSync(path.join(toolDir, WSM_HEADLESS_EXE_NAME), 'fake exe', 'utf8'); + + const wccLiteDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + fs.writeFileSync(path.join(wccLiteDir, 'wcc_lite.exe'), 'exe', 'utf8'); + + const closeSpy = vi.fn(async () => undefined); + const getStatusSpy = vi.fn(async () => ({ + gameDirectory: 'C:\\Game', + modsDirectory: 'C:\\Game\\Mods', + dependenciesValid: false, + textMergeDependenciesValid: true, + bundleDependenciesValid: false, + modsDirectoryExists: true, + mergedModName: 'mod0000_MergedFiles', + conflictCount: 3, + })); + const fakeClient = { getStatus: getStatusSpy, close: closeSpy } as unknown as WsmMcpClient; + const connect = vi.fn(async (_options: WsmMcpClientOptions) => fakeClient) as unknown as typeof WsmMcpClient.connect; + + const api = fakeApi(userDataDir); + const result = await getWsmStatusSummary(api, { connect }); + + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.status.conflictCount).toBe(3); + expect(result.bundleTools.wccLitePath).toBe(path.join(wccLiteDir, 'wcc_lite.exe')); + } + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("reports 'error' (not a thrown exception) when getStatus() itself rejects, and still closes the client", async () => { + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + fs.writeFileSync(path.join(toolDir, WSM_HEADLESS_EXE_NAME), 'fake exe', 'utf8'); + + const closeSpy = vi.fn(async () => undefined); + const fakeClient = { + getStatus: vi.fn(async () => { + throw new Error('get_status failed'); + }), + close: closeSpy, + } as unknown as WsmMcpClient; + const connect = vi.fn(async (_options: WsmMcpClientOptions) => fakeClient) as unknown as typeof WsmMcpClient.connect; + + const api = fakeApi(userDataDir); + const result = await getWsmStatusSummary(api, { connect }); + + expect(result).toEqual({ kind: 'error', message: 'get_status failed' }); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it('does not let a close() failure shadow a successful getStatus() result', async () => { + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + fs.writeFileSync(path.join(toolDir, WSM_HEADLESS_EXE_NAME), 'fake exe', 'utf8'); + + const fakeClient = { + getStatus: vi.fn(async () => ({ + gameDirectory: '', + modsDirectory: '', + dependenciesValid: true, + textMergeDependenciesValid: true, + bundleDependenciesValid: true, + modsDirectoryExists: false, + mergedModName: '', + conflictCount: 0, + })), + close: vi.fn(async () => { + throw new Error('close failed'); + }), + } as unknown as WsmMcpClient; + const connect = vi.fn(async (_options: WsmMcpClientOptions) => fakeClient) as unknown as typeof WsmMcpClient.connect; + + const api = fakeApi(userDataDir); + const result = await getWsmStatusSummary(api, { connect }); + + expect(result.kind).toBe('ok'); + }); +}); diff --git a/vortex-extension/src/wsmStatusSummary.ts b/vortex-extension/src/wsmStatusSummary.ts new file mode 100644 index 0000000..0a84040 --- /dev/null +++ b/vortex-extension/src/wsmStatusSummary.ts @@ -0,0 +1,107 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { selectors, types } from 'vortex-api'; +import { DetectedBundleTools, detectBundleTools } from './bundleTools'; +import { WITCHER3_GAME_ID } from './gating'; +import { GetStatusResult, WsmMcpClient } from './mcpClient'; +import { getWsmToolDir } from './storage'; +import { WSM_HEADLESS_EXE_NAME } from './toolAcquisition'; +import { WsmEnvConfig, buildWsmEnv, mergeWithProcessEnv } from './wsmEnv'; + +/** + * Pure(ish) data-fetching logic behind `statusTile.ts`'s dashlet - kept separate from + * the React component so it's directly unit-testable (`WsmMcpClient.connect` spawns a + * real child process, so it's injected here the same way `client`/`extractor` are + * injected in `toolAcquisition.ts`) without needing any React/DOM test harness. + * + * Spawns a short-lived `WsmMcpClient` per `mcpClient.ts`'s own documented lifecycle + * policy ("spawn per user-initiated workflow, tear down when the caller is done with + * it") - one spawn per dashlet refresh, closed in a `finally` regardless of outcome. + */ + +export type WsmStatusSummary = + | { kind: 'not-acquired' } + | { kind: 'error'; message: string } + | { kind: 'ok'; status: GetStatusResult; bundleTools: DetectedBundleTools }; + +export interface GetWsmStatusSummaryOptions { + /** Test-only seam - defaults to the real `WsmMcpClient.connect`. */ + connect?: typeof WsmMcpClient.connect; +} + +function isEnoent(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** Mirrors `toolAcquisition.ts`'s own `pathExists` / `bundleTools.ts`'s own + * `fileExists` - see either's doc comment for why a non-ENOENT error must propagate + * rather than being treated as "not acquired". */ +async function fileExists(target: string): Promise { + try { + await fs.promises.access(target); + return true; + } catch (err) { + if (isEnoent(err)) { + return false; + } + throw err; + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Reports the WSM tool's dependency/status snapshot for the dashlet, or a clear reason + * it couldn't be obtained (`not-acquired` when no WSM build has been downloaded yet at + * all - see `toolAcquisition.ts` - versus `error` for every other failure, e.g. the + * spawned process crashing or `get_status` itself failing). + * + * Builds the spawned process's environment from this unit's own bundle-tool detection + * (`bundleTools.ts`'s `detectBundleTools`) plus the currently-discovered Witcher 3 game + * directory, via `wsmEnv.ts`'s `buildWsmEnv`/`mergeWithProcessEnv` - the same + * `WSM_` mechanism `toolAcquisition.integration.test.ts` already proves reaches + * a real spawned WSM process, so `status.bundleDependenciesValid` reflects what this + * extension itself has detected/acquired, not whatever `WitcherScriptMerger.Headless. + * dll.config`'s own on-disk defaults happen to be. + */ +export async function getWsmStatusSummary( + api: types.IExtensionApi, + options: GetWsmStatusSummaryOptions = {}, +): Promise { + const exePath = path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); + if (!(await fileExists(exePath))) { + return { kind: 'not-acquired' }; + } + + const bundleTools = await detectBundleTools(api); + const gameDirectory = selectors.discoveryByGame(api.getState(), WITCHER3_GAME_ID)?.path; + + const envConfig: WsmEnvConfig = { gameDirectory, ...bundleTools }; + const env = mergeWithProcessEnv(buildWsmEnv(envConfig)); + + const connect = options.connect ?? WsmMcpClient.connect; + + let client: WsmMcpClient; + try { + client = await connect({ exePath, env }); + } catch (err) { + return { kind: 'error', message: errorMessage(err) }; + } + + try { + const status = await client.getStatus(); + return { kind: 'ok', status, bundleTools }; + } catch (err) { + return { kind: 'error', message: errorMessage(err) }; + } finally { + try { + await client.close(); + } catch { + // A close failure must never shadow a successful getStatus() result above (a + // `finally` block that throws replaces whatever the `try` was about to + // return/throw) - best-effort cleanup only. + } + } +} diff --git a/vortex-extension/test/bundleTools.integration.test.ts b/vortex-extension/test/bundleTools.integration.test.ts new file mode 100644 index 0000000..061e609 --- /dev/null +++ b/vortex-extension/test/bundleTools.integration.test.ts @@ -0,0 +1,123 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { detectBundleTools, detectQuickBms, detectWccLite } from '../src/bundleTools'; +import { NexusDownloader } from '../src/nexusDownloader'; +import { ArchiveExtractor } from '../src/archiveExtractor'; +import { acquireWccLite } from '../src/wccLiteAcquisition'; + +// Real, no-mock local-detection tests for this unit's bundle-tooling acquisition +// (Unit J) - the closest existing precedent is test/toolAcquisition.integration.test.ts, +// which proves registration/env-var-config against a real (locally-published) WSM +// binary rather than a mocked one. This file does the equivalent for QuickBMS/wcc_lite +// *detection*: real fs operations against a real scratch getBundleToolsDir(api)-shaped +// path, no mocks, no network - proving detectQuickBms/detectWccLite/detectBundleTools +// actually find a real file on a real filesystem, not just a fake fs stub. +// +// **Deviation from this unit's own instructions, disclosed here rather than silently**: +// the instructions ask to "mock the download response at the HTTP-client boundary +// (however src/githubRelease.ts's nodeHttpsClient is structured...)". wcc_lite is +// fetched through Vortex's own Nexus-download mechanism (api.ext.nexusDownload), not a +// plain HTTPS GET - Nexus doesn't serve an unauthenticated direct-download URL for a +// raw HttpClient to hit even in principle, so there is no nodeHttpsClient-shaped seam +// to reuse here. src/wccLiteAcquisition.test.ts already covers the mocked-download +// pipeline (download -> extract -> locate wcc_lite.exe) at the fast-unit-test tier, +// injecting a fake NexusDownloader/ArchiveExtractor, mirroring how +// src/toolAcquisition.test.ts covers acquireWsmTool's own mocked pipeline. Real Nexus +// API access is never attempted by any test in this repo. +// +// This repo's real archive-extraction implementation (archiveExtractor.ts's +// createVortexArchiveExtractor, backed by api.openArchive) has no meaningful behavior +// outside a real Vortex host either, per that module's own doc comment - so, like +// test/toolAcquisition.integration.test.ts sidesteps a real GitHub-Releases download, +// this file sidesteps real Nexus download/extraction and instead proves what it +// actually can for real: local detection, and that a pre-existing install short-circuits +// acquisition before any network/extraction attempt at all. + +function fakeApi(userDataDir: string) { + return { + getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`), + getState: () => ({}), + ext: {}, + } as unknown as Parameters[0]; +} + +describe('bundle-tool detection end-to-end (real filesystem, no mocks)', () => { + let userDataDir: string; + + beforeEach(() => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-bundletools-integration-')); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }); + + it('detectWccLite finds a real wcc_lite.exe placed in the real getBundleToolsDir(api)-shaped path', async () => { + const wccLiteDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + const exePath = path.join(wccLiteDir, 'wcc_lite.exe'); + fs.writeFileSync(exePath, Buffer.from('not a real PE, just a marker file'), 'binary'); + + const api = fakeApi(userDataDir); + await expect(detectWccLite(api)).resolves.toBe(exePath); + }); + + it('detectQuickBms finds a real quickbms.exe + witcher3.bms pair on a real filesystem', async () => { + const quickBmsDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'QuickBMS'); + fs.mkdirSync(quickBmsDir, { recursive: true }); + fs.writeFileSync(path.join(quickBmsDir, 'quickbms.exe'), Buffer.from('marker'), 'binary'); + fs.writeFileSync(path.join(quickBmsDir, 'witcher3.bms'), Buffer.from('marker'), 'binary'); + + const api = fakeApi(userDataDir); + const result = await detectQuickBms(api); + expect(result?.exePath).toBe(path.join(quickBmsDir, 'quickbms.exe')); + expect(result?.pluginPath).toBe(path.join(quickBmsDir, 'witcher3.bms')); + }); + + it('detectBundleTools reports both tools found together, real fs only', async () => { + const bundleToolsDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools'); + const quickBmsDir = path.join(bundleToolsDir, 'QuickBMS'); + fs.mkdirSync(quickBmsDir, { recursive: true }); + fs.writeFileSync(path.join(quickBmsDir, 'quickbms.exe'), 'marker', 'utf8'); + fs.writeFileSync(path.join(quickBmsDir, 'witcher3.bms'), 'marker', 'utf8'); + const wccLiteDir = path.join(bundleToolsDir, 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + fs.writeFileSync(path.join(wccLiteDir, 'wcc_lite.exe'), 'marker', 'utf8'); + + const api = fakeApi(userDataDir); + const detected = await detectBundleTools(api); + + expect(detected.quickBmsPath).toBe(path.join(quickBmsDir, 'quickbms.exe')); + expect(detected.quickBmsPluginPath).toBe(path.join(quickBmsDir, 'witcher3.bms')); + expect(detected.wccLitePath).toBe(path.join(wccLiteDir, 'wcc_lite.exe')); + }); + + it('acquireWccLite finds a pre-existing real install and attempts no download/extraction at all', async () => { + const wccLiteDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'bundle-tools', 'wcc_lite', 'bin', 'x64'); + fs.mkdirSync(wccLiteDir, { recursive: true }); + const exePath = path.join(wccLiteDir, 'wcc_lite.exe'); + fs.writeFileSync(exePath, 'marker', 'utf8'); + + const api = fakeApi(userDataDir); + // These would reject the test (not just the acquisition) if actually invoked - + // proves detection genuinely short-circuits before any network/extraction attempt. + const downloader: NexusDownloader = { + downloadModFile: vi.fn(async () => { + throw new Error('downloadModFile should not have been called - a real install was already present'); + }), + }; + const extractor: ArchiveExtractor = { + extractAll: vi.fn(async () => { + throw new Error('extractAll should not have been called - a real install was already present'); + }), + }; + + const result = await acquireWccLite({ api, downloader, extractor, fileId: 1 }); + + expect(result).toBe(exePath); + expect(downloader.downloadModFile).not.toHaveBeenCalled(); + expect(extractor.extractAll).not.toHaveBeenCalled(); + }); +}); diff --git a/vortex-extension/test/testUtils/vortexApiStub.ts b/vortex-extension/test/testUtils/vortexApiStub.ts index 8e150a7..077362f 100644 --- a/vortex-extension/test/testUtils/vortexApiStub.ts +++ b/vortex-extension/test/testUtils/vortexApiStub.ts @@ -25,6 +25,15 @@ export const selectors = { state: { discoveryByGame?: Record }, gameId: string, ): { path?: string } | undefined => state?.discoveryByGame?.[gameId], + // Added alongside nexusDownloader.ts (bundle-tooling acquisition): real signature + // `(state: IState, gameId?: string) => string`, per @nexusmods/vortex-api's own + // `lib/api.d.ts`. Deliberately keyed by an explicit fake `downloadPathForGame` map + // (same simplified-fake-state philosophy as `discoveryByGame` above) rather than + // Vortex's real `state.settings.downloads.path` + per-game-override resolution + // logic - nexusDownloader.ts's own tests only need a deterministic directory to join + // `IDownload.localPath` onto, not a faithful reimplementation of that resolution. + downloadPathForGame: (state: { downloadPathForGame?: Record }, gameId?: string): string => + state?.downloadPathForGame?.[gameId ?? ''] ?? '/unexpected/downloadPathForGame', }; // `actions.addDiscoveredTool` needs a real (if simplified) implementation, not just a @@ -53,6 +62,15 @@ export const util = { writeFileAtomic: async (filePath: string, input: string | Buffer): Promise => { await fs.promises.writeFile(filePath, input); }, + // `util.opn` needs a real (no-op) implementation for the same reason as + // `writeFileAtomic` above - `statusTile.ts` calls it as a value from a click handler + // to open an external link (QuickBMS's homepage, wcc_lite's Nexus mod page) via + // Vortex's own "open with the OS default browser" mechanism rather than a bare + // `` (see that module's own doc comment for why). No test in this + // repo actually clicks that link (no React/DOM rendering harness - see + // statusTile.test.ts's own doc comment), so this is currently unexercised, but is + // still needed for the module to *load* without an undefined `util.opn`. + opn: async (_target: string, _wait?: boolean): Promise => undefined, }; // `log` needs a real (no-op) implementation because `index.ts` calls it as a value at From c5426204decf0c13dee1b9266560b99114dc4c93 Mon Sep 17 00:00:00 2001 From: Chris Knight Date: Mon, 10 Aug 2026 16:43:19 -0400 Subject: [PATCH 2/2] Fix register-call timing bug and second-round review findings (Unit J) Move registerWsmStatusDashlet(context) out of context.once and into main()'s own body: @nexusmods/vortex-api's own lib/api.d.ts documents register functions as needing to be called immediately inside init, not deferred into once, so the dashlet registration likely never took effect against a real Vortex host. Also corrects a stale index.ts doc comment (predating this unit) that told future units to register from inside once, which would have propagated the same bug into later units. Also fixes: findFileByNameBounded now propagates non-ENOENT readdir errors instead of silently treating them as "nothing here" (previously risked a false-negative detection triggering a destructive wipe + re-download of a working install) and now prefers an x64-path match when an archive ships both architectures; getWsmStatusSummary wraps its pre-connect steps in their own try/catch so a filesystem error produces the documented {kind:'error'} result instead of an unhandled rejection; the status tile guards every async state setter against firing after unmount; a duplicate fileExists/isEnoent implementation was removed in favor of bundleTools.ts's now-exported copy; nexusDownloader.ts's timeout error now names the last-observed download state; wccLiteAcquisition.ts's "not found" error now names the exact mod/file id tried; and two stale doc comments were corrected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah --- vortex-extension/src/bundleTools.test.ts | 30 +++++++++- vortex-extension/src/bundleTools.ts | 50 ++++++++++++----- vortex-extension/src/index.test.ts | 19 +++++-- vortex-extension/src/index.ts | 40 +++++++++---- vortex-extension/src/nexusDownloader.test.ts | 15 +++++ vortex-extension/src/nexusDownloader.ts | 11 +++- vortex-extension/src/statusTile.ts | 56 +++++++++++++++---- vortex-extension/src/storage.ts | 3 +- .../src/wccLiteAcquisition.test.ts | 14 +++++ vortex-extension/src/wccLiteAcquisition.ts | 20 +++++-- vortex-extension/src/wsmEnv.ts | 13 +++++ vortex-extension/src/wsmStatusSummary.ts | 50 ++++++++--------- 12 files changed, 243 insertions(+), 78 deletions(-) diff --git a/vortex-extension/src/bundleTools.test.ts b/vortex-extension/src/bundleTools.test.ts index a3155d3..1fa698c 100644 --- a/vortex-extension/src/bundleTools.test.ts +++ b/vortex-extension/src/bundleTools.test.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { detectBundleTools, detectQuickBms, detectWccLite, findFileByNameBounded } from './bundleTools'; function fakeApi(userDataDir: string, discoveryByGameState: Record }> = {}) { @@ -228,5 +228,33 @@ describe('bundleTools', () => { const found = await findFileByNameBounded(userDataDir, 'wcc_lite.exe', 6); expect(found).toBe(path.join(shallowDir, 'WCC_LITE.EXE')); }); + + it('prefers a match under an x64 path segment when multiple matches exist at the same depth', async () => { + // Simulates a general-purpose modding-tools archive shipping both x86 and x64 + // builds side by side - this repo's own App.config default specifically wants + // the x64 one (Tools\wcc_lite\bin\x64\wcc_lite.exe). + const x86Dir = path.join(userDataDir, 'bin', 'x86'); + const x64Dir = path.join(userDataDir, 'bin', 'x64'); + fs.mkdirSync(x86Dir, { recursive: true }); + fs.mkdirSync(x64Dir, { recursive: true }); + fs.writeFileSync(path.join(x86Dir, 'wcc_lite.exe'), 'x86 build', 'utf8'); + fs.writeFileSync(path.join(x64Dir, 'wcc_lite.exe'), 'x64 build', 'utf8'); + + const found = await findFileByNameBounded(userDataDir, 'wcc_lite.exe', 6); + expect(found).toBe(path.join(x64Dir, 'wcc_lite.exe')); + }); + + it('propagates a non-ENOENT readdir error rather than silently treating it as "nothing here"', async () => { + const targetDir = path.join(userDataDir, 'locked'); + fs.mkdirSync(targetDir, { recursive: true }); + const permissionError = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockRejectedValueOnce(permissionError); + + try { + await expect(findFileByNameBounded(targetDir, 'wcc_lite.exe', 6)).rejects.toThrow(/EACCES/); + } finally { + readdirSpy.mockRestore(); + } + }); }); }); diff --git a/vortex-extension/src/bundleTools.ts b/vortex-extension/src/bundleTools.ts index 4f4824f..60e3abb 100644 --- a/vortex-extension/src/bundleTools.ts +++ b/vortex-extension/src/bundleTools.ts @@ -62,7 +62,7 @@ export interface DetectedBundleTools { wccLitePath?: string; } -function isEnoent(err: unknown): boolean { +export function isEnoent(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT'; } @@ -70,10 +70,11 @@ function isEnoent(err: unknown): boolean { * rethrowing anything other than ENOENT - a permission/lock error must not be * silently treated as "not installed", which would both hide the real problem and * risk offering to re-download/re-extract a tool that's actually already present). - * Duplicated rather than imported since `toolAcquisition.ts` doesn't export it and - * this unit's own instructions treat that file's *existing* logic as off-limits to - * modify. */ -async function fileExists(target: string): Promise { + * Not imported from `toolAcquisition.ts` since that file doesn't export it and this + * unit's own instructions treat that file's *existing* logic as off-limits to modify - + * but exported from here (unlike that file's private copy) specifically so + * `wsmStatusSummary.ts` can reuse *this* one instead of adding a third duplicate. */ +export async function fileExists(target: string): Promise { try { await fs.promises.access(target); return true; @@ -92,7 +93,22 @@ async function fileExists(target: string): Promise { * is examined at all, so a shallower match always wins over a deeper one regardless of * sibling-directory iteration order (which `fs.readdir` does not guarantee is * alphabetical). Returns `undefined` (rather than throwing) when `rootDir` doesn't - * exist yet or nothing matches within `maxDepth` levels. + * exist yet (or a subdirectory disappears mid-search - a benign race, not a real + * problem) or nothing matches within `maxDepth` levels. Any *other* `readdir` failure + * (EACCES/EPERM/EBUSY - e.g. an antivirus lock on a freshly-extracted tree) propagates + * rather than being silently treated as "nothing here": a caller like + * `wccLiteAcquisition.ts` that gets a false "not found" from a transient error, rather + * than a real one, could go on to wipe and re-download a perfectly good install (see + * that module's own `fs.promises.rm` call after this function reports no existing + * install). + * + * When more than one match exists at the same (shallowest) depth level, prefers a path + * containing an `x64` path segment - this repo's own `App.config` default + * (`Tools\wcc_lite\bin\x64\wcc_lite.exe`) specifically targets the x64 build, and a + * general-purpose Witcher 3 modding-tools archive could plausibly ship both x86 and + * x64 builds side by side at the same depth, where directory-listing order alone + * (`fs.readdir` gives no ordering guarantee) would otherwise pick between them + * arbitrarily. */ export async function findFileByNameBounded( rootDir: string, @@ -104,27 +120,33 @@ export async function findFileByNameBounded( for (let depth = 0; depth <= maxDepth && currentLevelDirs.length > 0; depth++) { const nextLevelDirs: string[] = []; + const matchesAtThisLevel: string[] = []; for (const dir of currentLevelDirs) { let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); - } catch { - continue; - } - - const match = entries.find((entry) => entry.isFile() && entry.name.toLowerCase() === targetLower); - if (match) { - return path.join(dir, match.name); + } catch (err) { + if (isEnoent(err)) { + continue; + } + throw err; } for (const entry of entries) { - if (entry.isDirectory()) { + if (entry.isFile() && entry.name.toLowerCase() === targetLower) { + matchesAtThisLevel.push(path.join(dir, entry.name)); + } else if (entry.isDirectory()) { nextLevelDirs.push(path.join(dir, entry.name)); } } } + if (matchesAtThisLevel.length > 0) { + const x64Match = matchesAtThisLevel.find((match) => /(^|[\\/])x64([\\/]|$)/i.test(match)); + return x64Match ?? matchesAtThisLevel[0]; + } + currentLevelDirs = nextLevelDirs; } diff --git a/vortex-extension/src/index.test.ts b/vortex-extension/src/index.test.ts index 8e13250..5059aa4 100644 --- a/vortex-extension/src/index.test.ts +++ b/vortex-extension/src/index.test.ts @@ -124,18 +124,25 @@ describe('main (index.ts)', () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - it('registers the WSM status dashlet once, at context.once time, regardless of the active game', () => { - // Unlike tryRegisterWsmTool, this registration isn't gated on isWitcher3Active here - // - statusTile.ts's own registerDashlet call supplies a live isVisible callback - // instead (covered by statusTile.test.ts), so index.ts always registers it once. + it('registers the WSM status dashlet synchronously inside main() itself, not deferred into context.once', () => { + // Per @nexusmods/vortex-api's own lib/api.d.ts doc comment on IExtensionContext: + // register functions "must be called immediately inside the init function," and + // once is documented as being for extension setup "except for the register calls." + // An earlier version of this test (and of index.ts itself) got this backwards - + // asserting registerWsmStatusDashlet only fired after fireOnce() - which would have + // meant the dashlet's registerDashlet call never actually took effect against a + // real Vortex host. This test now asserts the call happens from main(context) + // alone, with context.once never fired at all. ensureWsmToolRegisteredMock.mockClear().mockResolvedValue(false); registerWsmStatusDashletMock.mockClear(); - const { context, fireOnce } = fakeContext('skyrimse'); + const { context } = fakeContext('skyrimse'); main(context); - fireOnce(); expect(registerWsmStatusDashletMock).toHaveBeenCalledTimes(1); expect(registerWsmStatusDashletMock).toHaveBeenCalledWith(context); + // Still isn't gated on isWitcher3Active - statusTile.ts's own registerDashlet call + // supplies a live isVisible callback instead (covered by statusTile.test.ts). + expect(ensureWsmToolRegisteredMock).not.toHaveBeenCalled(); }); }); diff --git a/vortex-extension/src/index.ts b/vortex-extension/src/index.ts index 3649fea..1b8f5ac 100644 --- a/vortex-extension/src/index.ts +++ b/vortex-extension/src/index.ts @@ -24,18 +24,40 @@ import { ensureWsmToolRegistered } from './toolAcquisition'; * and without this, registering a previously-acquired tool would only ever happen if * Witcher 3 already happened to be active the moment Vortex loaded this extension. * - * Later units (conflict scanning, the merge panel, dashlets) each add their own - * `context.register*` calls inside the `context.once(...)` callback below, gated on - * `isWitcher3Active` (imported from `./gating`) - preferably via each registration API's - * own `condition` callback, so a live game-mode switch is honored without requiring a - * Vortex restart, the same way `tryRegisterWsmTool` below re-checks it on every - * `'gamemode-activated'` event rather than only once. + * **Correction (found while building Unit J, applies to every future unit too):** an + * earlier version of this comment said later units should add their own + * `context.register*` calls *inside* the `context.once(...)` callback below. That's + * wrong, and matters for real Vortex behavior, not just style - `@nexusmods/vortex-api`'s + * own `lib/api.d.ts` doc comment on `IExtensionContext` is explicit: register functions + * "must be called immediately inside the init function," calls to them are "stored and + * evaluated once all extensions have been initialised," and `once` (part (c) of that same + * doc comment) is documented as being for "all your extension setup *except* for the + * register calls (i.e. installing event handlers, doing startup calculations)" - not a + * valid place to call a `context.register*` function at all. `registerWsmStatusDashlet` + * below is therefore called directly in `main`'s own body, synchronously, before + * `context.once(...)` - not deferred into it. Every future unit adding a + * `context.register*` call (an action, a main page, a settings page, another dashlet) + * must do the same: call it directly here, gating *visibility* (not the registration call + * itself) on `isWitcher3Active` via that API's own live `condition`/`isVisible` callback + * instead, exactly like `registerWsmStatusDashlet` does (see `statusTile.ts`). Only + * non-register work - event handlers, one-time startup calculations, anything that reads + * `context.api`'s fully-initialized state - belongs inside `context.once`, which is + * exactly what `tryRegisterWsmTool` below is (it dispatches a Redux action via + * `api.store.dispatch`, not a `context.register*` call, so `context.once` is the right + * place for it). * * This extension must never call `context.registerGame('witcher3', ...)` - Vortex's own * built-in `game-witcher3` extension already owns that registration; this extension is a * companion to it, not a replacement. */ function main(context: types.IExtensionContext): boolean { + // Unit J: the dependency/status dashlet. Called here, synchronously and + // unconditionally, per the register-function contract explained above - never + // deferred into context.once. Visibility itself is still gated on Witcher 3 being the + // active game, via registerWsmStatusDashlet's own live `isVisible` callback + // (statusTile.ts), so this unconditional call doesn't show the tile for other games. + registerWsmStatusDashlet(context); + function tryRegisterWsmTool(): void { if (!isWitcher3Active(context.api)) { log('debug', 'witcherscriptmerger-vortex: active game is not witcher3, extension is idle'); @@ -67,12 +89,6 @@ function main(context: types.IExtensionContext): boolean { context.once(() => { tryRegisterWsmTool(); context.api.events.on('gamemode-activated', tryRegisterWsmTool); - - // Unit J: the dependency/status dashlet - registered once here, unlike - // tryRegisterWsmTool above, since registerDashlet's own `isVisible` callback (see - // statusTile.ts) is re-evaluated by Vortex live on every game-mode switch, so no - // 'gamemode-activated' re-check is needed for this registration itself. - registerWsmStatusDashlet(context); }); return true; diff --git a/vortex-extension/src/nexusDownloader.test.ts b/vortex-extension/src/nexusDownloader.test.ts index ccb6a0c..8c278bb 100644 --- a/vortex-extension/src/nexusDownloader.test.ts +++ b/vortex-extension/src/nexusDownloader.test.ts @@ -106,6 +106,21 @@ describe('createVortexNexusDownloader', () => { await assertion; }); + it('includes the last observed download state in the timeout error, so a paused download reads as paused rather than generically slow', async () => { + const downloads: Record = { + 'download-1': { state: 'paused' }, + }; + const nexusDownload = vi.fn(async () => 'download-1'); + const api = fakeApi({ nexusDownload, downloads }); + + const downloader = createVortexNexusDownloader(api, { pollIntervalMs: 1000, downloadTimeoutMs: 3000 }); + const resultPromise = downloader.downloadModFile({ gameId: 'witcher3', modId: 3173, fileId: 42 }); + const assertion = expect(resultPromise).rejects.toThrow(/last observed state: 'paused'/); + + await vi.advanceTimersByTimeAsync(5000); + await assertion; + }); + it('treats an entry not yet present in downloads state as still in progress, not a failure', async () => { const downloads: Record = {}; const nexusDownload = vi.fn(async () => 'download-1'); diff --git a/vortex-extension/src/nexusDownloader.ts b/vortex-extension/src/nexusDownloader.ts index e9e3e43..e0e349c 100644 --- a/vortex-extension/src/nexusDownloader.ts +++ b/vortex-extension/src/nexusDownloader.ts @@ -102,12 +102,19 @@ async function waitForDownloadToFinish( timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; + // Surfaced in the timeout error message below - 'paused' (a user-initiated pause + // from Vortex's own Downloads pane, a real reachable `DownloadState` per + // `lib/api.d.ts`) left sitting until the deadline would otherwise produce the exact + // same generic "did not finish" message as a download that's merely slow, giving the + // user no hint that *they* paused it and need to resume it themselves. + let lastObservedState: string | undefined; for (;;) { const state = api.getState() as { persistent?: { downloads?: { files?: Record } }; }; const download = state.persistent?.downloads?.files?.[downloadId]; + lastObservedState = download?.state; if (download?.state === 'finished') { return download; @@ -121,7 +128,9 @@ async function waitForDownloadToFinish( // keep polling rather than treating it as success or failure. if (Date.now() >= deadline) { - throw new Error(`Nexus download '${downloadId}' did not finish within ${timeoutMs}ms.`); + const stateNote = + lastObservedState !== undefined ? ` (last observed state: '${lastObservedState}')` : ' (never observed in downloads state)'; + throw new Error(`Nexus download '${downloadId}' did not finish within ${timeoutMs}ms${stateNote}.`); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); diff --git a/vortex-extension/src/statusTile.ts b/vortex-extension/src/statusTile.ts index 5f146b6..b90c339 100644 --- a/vortex-extension/src/statusTile.ts +++ b/vortex-extension/src/statusTile.ts @@ -87,6 +87,40 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea const [wccLiteError, setWccLiteError] = React.useState(undefined); const [fetchingWccLite, setFetchingWccLite] = React.useState(false); + // Guards every state setter below against firing after unmount. This dashlet's own + // visibility (registerWsmStatusDashlet's isVisible, below) is tied to a *live* + // game-mode switch, so a user can switch away from Witcher 3 - unmounting this + // component - while a wcc_lite download (up to DEFAULT_DOWNLOAD_TIMEOUT_MS, 10 + // minutes, see nexusDownloader.ts) or a status refresh is still in flight; without + // this, that promise's eventual .then/.catch/.finally would call setState on an + // already-unmounted component. + const mountedRef = React.useRef(true); + React.useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + const setSummaryIfMounted = React.useCallback((value: WsmStatusSummary) => { + if (mountedRef.current) { + setSummary(value); + } + }, []); + const setLoadingIfMounted = React.useCallback((value: boolean) => { + if (mountedRef.current) { + setLoading(value); + } + }, []); + const setWccLiteErrorIfMounted = React.useCallback((value: string | undefined) => { + if (mountedRef.current) { + setWccLiteError(value); + } + }, []); + const setFetchingWccLiteIfMounted = React.useCallback((value: boolean) => { + if (mountedRef.current) { + setFetchingWccLite(value); + } + }, []); + // Combined "something is in flight" flag, used to disable *both* buttons regardless // of which action started it - without this, clicking "Refresh" while "Get wcc_lite" // is still running (or vice versa) could fire two overlapping WSM process @@ -99,29 +133,29 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea // label would revert to idle as soon as refresh() was merely *invoked*, not once the // new status (a full WSM process spawn + MCP handshake) actually finished loading. const refresh = React.useCallback((): Promise => { - setLoading(true); + setLoadingIfMounted(true); // A refresh is a "start over" action - any stale wcc_lite-download error from a // previous attempt shouldn't keep rendering once the user has asked for a fresh // status check (e.g. after installing wcc_lite by hand and clicking Refresh). - setWccLiteError(undefined); + setWccLiteErrorIfMounted(undefined); return getWsmStatusSummary(api) - .then((result) => setSummary(result)) - .catch((err: unknown) => setSummary({ kind: 'error', message: err instanceof Error ? err.message : String(err) })) - .finally(() => setLoading(false)); - }, [api]); + .then((result) => setSummaryIfMounted(result)) + .catch((err: unknown) => setSummaryIfMounted({ kind: 'error', message: err instanceof Error ? err.message : String(err) })) + .finally(() => setLoadingIfMounted(false)); + }, [api, setLoadingIfMounted, setSummaryIfMounted, setWccLiteErrorIfMounted]); React.useEffect(() => { refresh(); }, [refresh]); const handleGetWccLite = React.useCallback(() => { - setFetchingWccLite(true); - setWccLiteError(undefined); + setFetchingWccLiteIfMounted(true); + setWccLiteErrorIfMounted(undefined); acquireWccLite({ api }) .then(() => refresh()) - .catch((err: unknown) => setWccLiteError(err instanceof Error ? err.message : String(err))) - .finally(() => setFetchingWccLite(false)); - }, [api, refresh]); + .catch((err: unknown) => setWccLiteErrorIfMounted(err instanceof Error ? err.message : String(err))) + .finally(() => setFetchingWccLiteIfMounted(false)); + }, [api, refresh, setFetchingWccLiteIfMounted, setWccLiteErrorIfMounted]); const children: React.ReactNode[] = [ React.createElement('h4', { key: 'title', style: { marginTop: 0 } }, 'WitcherScriptMerger Status'), diff --git a/vortex-extension/src/storage.ts b/vortex-extension/src/storage.ts index 5dea7bb..fd7370a 100644 --- a/vortex-extension/src/storage.ts +++ b/vortex-extension/src/storage.ts @@ -57,7 +57,8 @@ export function getDownloadCacheDir(api: types.IExtensionApi): string { return path.join(getExtensionStorageDir(api), DOWNLOAD_CACHE_SUBDIR); } -/** See this module's own doc comment above - convention for a later unit, unused here. */ +/** See this module's own doc comment above - QuickBMS/wcc_lite land under here (used by + * `bundleTools.ts`'s detection and `wccLiteAcquisition.ts`'s auto-download). */ export function getBundleToolsDir(api: types.IExtensionApi): string { return path.join(getExtensionStorageDir(api), BUNDLE_TOOLS_SUBDIR); } diff --git a/vortex-extension/src/wccLiteAcquisition.test.ts b/vortex-extension/src/wccLiteAcquisition.test.ts index 979528f..78433d4 100644 --- a/vortex-extension/src/wccLiteAcquisition.test.ts +++ b/vortex-extension/src/wccLiteAcquisition.test.ts @@ -126,4 +126,18 @@ describe('acquireWccLite', () => { /no 'wcc_lite\.exe' was found/, ); }); + + it('names the exact mod/file id tried in the "not found" error, for diagnosing a wrong-file-selected problem separately from an archive-layout mismatch', async () => { + const api = fakeApi(userDataDir); + const downloader = fakeDownloaderReturning(path.join(userDataDir, 'modkit.zip')); + const noOpExtractor: ArchiveExtractor = { + extractAll: async (_archivePath, destDir) => { + await fs.promises.mkdir(destDir, { recursive: true }); + }, + }; + + await expect( + acquireWccLite({ api, downloader, extractor: noOpExtractor, fileId: 98765 }), + ).rejects.toThrow(new RegExp(`mod ${WCC_LITE_NEXUS_MOD_ID} file 98765`)); + }); }); diff --git a/vortex-extension/src/wccLiteAcquisition.ts b/vortex-extension/src/wccLiteAcquisition.ts index 8a32bb9..e03f871 100644 --- a/vortex-extension/src/wccLiteAcquisition.ts +++ b/vortex-extension/src/wccLiteAcquisition.ts @@ -78,6 +78,17 @@ interface NexusFileInfoLike { is_primary?: boolean; } +/** + * Picks Nexus's own `is_primary`-flagged file, falling back to the first listed file + * if nothing is marked primary (a mod page state that's possible, if unusual). That + * fallback is a best-effort heuristic, not a guarantee of picking the *current* + * release - a mod page listing an old/archived version alongside the current one with + * nothing marked primary could pick the wrong one. If the resulting download's archive + * doesn't contain `wcc_lite.exe`, `acquireWccLiteUncoordinated`'s own error message + * names exactly which mod/file id was tried, specifically so that failure mode is + * distinguishable from a genuine archive-layout mismatch rather than silently + * conflated with it. + */ async function resolveFileId( api: types.IExtensionApi, explicitFileId: number | undefined, @@ -165,10 +176,11 @@ async function acquireWccLiteUncoordinated(options: AcquireWccLiteOptions, extra const exePath = await findFileByNameBounded(extractDir, WCC_LITE_EXE_FILENAME, BUNDLE_TOOL_SEARCH_MAX_DEPTH); if (!exePath) { throw new Error( - `Downloaded and extracted the wcc_lite Nexus mod file into '${extractDir}' but no ` + - `'${WCC_LITE_EXE_FILENAME}' was found there (searched up to ${BUNDLE_TOOL_SEARCH_MAX_DEPTH} directory ` + - "levels deep). The mod's internal layout may not match what this extension expects - see this unit's " + - 'PR description for the "archive-layout caveat".', + `Downloaded Nexus mod ${WCC_LITE_NEXUS_MOD_ID} file ${fileId}${fileName ? ` ('${fileName}')` : ''} and ` + + `extracted it into '${extractDir}' but no '${WCC_LITE_EXE_FILENAME}' was found there (searched up to ` + + `${BUNDLE_TOOL_SEARCH_MAX_DEPTH} directory levels deep). Naming the exact file tried here so this is ` + + "distinguishable from a wrong-file-selected problem (see resolveFileId's own doc comment) versus a " + + 'genuine archive-layout mismatch (see this unit\'s PR description for the "archive-layout caveat").', ); } diff --git a/vortex-extension/src/wsmEnv.ts b/vortex-extension/src/wsmEnv.ts index edcb249..7622fa2 100644 --- a/vortex-extension/src/wsmEnv.ts +++ b/vortex-extension/src/wsmEnv.ts @@ -29,6 +29,19 @@ export interface WsmEnvConfig { * `getBundleToolsDir` doc comment for the storage convention these three paths come * from, and `wccLiteAcquisition.ts` for wcc_lite's own auto-download path (QuickBMS * is detection-only - never auto-downloaded, see that module's own doc comment). + * + * **Not universally populated yet - only `wsmStatusSummary.ts`'s own ephemeral, + * dashlet-only `WsmMcpClient` spawn builds its env this way today.** The WSM instance + * a user actually launches from Vortex's Tools dashboard is registered by + * `toolAcquisition.ts`'s `registerAcquiredTool`, which builds its `environment` via + * `buildWsmEnv({ gameDirectory })` only (no bundle-tool paths) - that file's *existing* + * logic is off-limits for this unit to modify (see this unit's own task + * instructions), so a "Bundle tooling ready: Yes" the status tile reports describes + * only the tile's own status-check spawn, not (yet) the Tools-dashboard-launched + * process, which still falls back to on-disk `.dll.config`/`.exe.config` defaults for + * these three settings specifically. A later unit wiring bundle-content merges should + * close this gap by having whatever spawns *that* process call `detectBundleTools` + * too. */ quickBmsPath?: string; quickBmsPluginPath?: string; diff --git a/vortex-extension/src/wsmStatusSummary.ts b/vortex-extension/src/wsmStatusSummary.ts index 0a84040..ccd28ac 100644 --- a/vortex-extension/src/wsmStatusSummary.ts +++ b/vortex-extension/src/wsmStatusSummary.ts @@ -1,7 +1,6 @@ -import * as fs from 'fs'; import * as path from 'path'; import { selectors, types } from 'vortex-api'; -import { DetectedBundleTools, detectBundleTools } from './bundleTools'; +import { DetectedBundleTools, detectBundleTools, fileExists } from './bundleTools'; import { WITCHER3_GAME_ID } from './gating'; import { GetStatusResult, WsmMcpClient } from './mcpClient'; import { getWsmToolDir } from './storage'; @@ -29,25 +28,6 @@ export interface GetWsmStatusSummaryOptions { connect?: typeof WsmMcpClient.connect; } -function isEnoent(err: unknown): boolean { - return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT'; -} - -/** Mirrors `toolAcquisition.ts`'s own `pathExists` / `bundleTools.ts`'s own - * `fileExists` - see either's doc comment for why a non-ENOENT error must propagate - * rather than being treated as "not acquired". */ -async function fileExists(target: string): Promise { - try { - await fs.promises.access(target); - return true; - } catch (err) { - if (isEnoent(err)) { - return false; - } - throw err; - } -} - function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -71,15 +51,29 @@ export async function getWsmStatusSummary( options: GetWsmStatusSummaryOptions = {}, ): Promise { const exePath = path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); - if (!(await fileExists(exePath))) { - return { kind: 'not-acquired' }; - } - const bundleTools = await detectBundleTools(api); - const gameDirectory = selectors.discoveryByGame(api.getState(), WITCHER3_GAME_ID)?.path; + let bundleTools: DetectedBundleTools; + let env: NodeJS.ProcessEnv; + try { + if (!(await fileExists(exePath))) { + return { kind: 'not-acquired' }; + } + + bundleTools = await detectBundleTools(api); + const gameDirectory = selectors.discoveryByGame(api.getState(), WITCHER3_GAME_ID)?.path; - const envConfig: WsmEnvConfig = { gameDirectory, ...bundleTools }; - const env = mergeWithProcessEnv(buildWsmEnv(envConfig)); + const envConfig: WsmEnvConfig = { gameDirectory, ...bundleTools }; + env = mergeWithProcessEnv(buildWsmEnv(envConfig)); + } catch (err) { + // Covers fileExists/detectBundleTools above - this function's own doc comment + // promises every caller a WsmStatusSummary, `not-acquired` vs `error` for every + // *other* failure; without this, a permission/lock error scanning + // getBundleToolsDir(api) (e.g. an AV lock on a freshly-extracted wcc_lite tree) + // would surface as an unhandled rejection instead of the documented contract - + // currently masked only because this module's sole wired-up caller + // (statusTile.ts's own refresh()) happens to add its own .catch() on top. + return { kind: 'error', message: errorMessage(err) }; + } const connect = options.connect ?? WsmMcpClient.connect;