diff --git a/vortex-extension/README.md b/vortex-extension/README.md index 533cbe5..4852898 100644 --- a/vortex-extension/README.md +++ b/vortex-extension/README.md @@ -65,26 +65,30 @@ does anything for any other game. shows up here instead of as a confusing failure mid-deploy. Also offers a "Get wcc_lite from Nexus Mods" button - see the next section for exactly what that does. -### Known gaps in what's shipped so far - -- **No in-Vortex button triggers the *initial* WSM download yet.** `acquireWsmTool` (the - full download/verify/extract/register pipeline) is implemented and exported, but no - unit built so far has wired it to a UI action - it's only ever exercised by this - project's own tests. Until a later unit adds that trigger, a fresh install has two - options: wait for that action to land, or place an already-built - `WitcherScriptMerger.Headless.exe` yourself under - `\witcherscriptmerger-vortex\tool\` (Vortex's own `userData` folder is - typically `%APPDATA%\Vortex`) - the extension re-registers whatever it finds there as - a discovered tool on every load and every game-mode switch, with no network access - needed for that re-registration step. **Its `.dll.config` file needs to sit right next - to it too** - WSM reads settings via `ConfigurationManager` against that file, and its - `AppSettings` constructor calls `Environment.Exit(1)` with no further diagnostic if it - can't find one, so an exe copied there alone fails silently on launch. -- **No settings UI lets you point the extension at an existing WSM install** you already - have elsewhere - the design doc originally proposed a per-game settings-panel override - for this; it hasn't been built. Today, the only way this extension resolves a WSM path - is the acquisition/re-registration flow above (its own private storage directory) - - there is no override surface yet. +### First-run setup (both former "known gaps" are closed) + +- **The initial WSM download has an in-Vortex trigger now**: the status dashboard tile's + "Download WitcherScriptMerger v\" button (shown whenever no WSM build is + resolved yet) runs the full download/verify/extract/register pipeline + (`src/toolAcquisition.ts`) against this repository's own GitHub release, pinned to + the version in `src/githubRelease.ts`'s `DEFAULT_WSM_VERSION`. Downloads stay an + explicit user action - nothing downloads automatically at startup. +- **You can point the extension at an existing WSM install instead**: the same tile's + "Use an existing install..." button stores an override path + (`src/wsmToolPath.ts`; persisted as `tool-path-override.txt` in the extension's + private storage). The override must name a `WitcherScriptMerger*.exe` with `mcp` + support - either host from this fork works; the original 2016 Script Merger does not. + An override always wins over the extension-managed install; if its file later + disappears, the tile says so and offers to clear it (never a silent fallback to a + different binary than the one you chose). **The install's `.dll.config` file needs to + sit right next to whichever exe is used** - WSM reads settings via + `ConfigurationManager` against that file, and its `AppSettings` constructor calls + `Environment.Exit(1)` with no further diagnostic if it can't find one, so a bare exe + fails silently on launch. +- Manual placement still works too: an already-built `WitcherScriptMerger.Headless.exe` + (plus its `.dll.config`) under `\witcherscriptmerger-vortex\tool\` + (Vortex's `userData` is typically `%APPDATA%\Vortex`) is re-registered as a + discovered tool on every load and game-mode switch, network-free. ## Being transparent about what gets downloaded, from where, and by whom @@ -156,11 +160,11 @@ plus the root `info.json` into the same plugins subfolder yourself. ## Requirements - **A WSM build capable of `mcp` mode** - either the CLI/MCP-only - `WitcherScriptMerger.Headless.exe` this extension's own tool-acquisition pipeline - downloads (once wired to a UI trigger - see "Known gaps" above; in the meantime, see - that section's manual-placement workaround), or the full WinForms - `WitcherScriptMerger.exe`, which also supports `mcp` mode. Either way, this is a - Windows-only requirement today, matching Vortex itself being Windows-only. + `WitcherScriptMerger.Headless.exe` the status tile's download button acquires for you + (see "First-run setup" above, which also covers pointing at an existing install or + placing one manually), or the full WinForms `WitcherScriptMerger.exe`, which also + supports `mcp` mode. Either way, this is a Windows-only requirement today, matching + Vortex itself being Windows-only. - **QuickBMS and wcc_lite are only needed for `.bundle`-content (DLC/expansion) conflicts** - ordinary flat-file `.ws`/`.xml` conflicts merge with neither installed, via WSM's in-process DiffPlex-based merge engine. See "Being transparent..." above for diff --git a/vortex-extension/src/coexistenceGuard.ts b/vortex-extension/src/coexistenceGuard.ts index 45b70ec..602e697 100644 --- a/vortex-extension/src/coexistenceGuard.ts +++ b/vortex-extension/src/coexistenceGuard.ts @@ -479,7 +479,15 @@ export async function refreshCoexistenceState(api: types.IExtensionApi, deps: Re let client: WsmMcpClient | undefined; try { - client = await connect({ exePath: getWsmExePath(api), env, requestTimeoutMs: COEXISTENCE_CHECK_TIMEOUT_MS }); + const exePath = await getWsmExePath(api); + if (exePath === undefined) { + // isWsmToolAcquired above said yes, so this is the (documented) TOCTOU window + // or a just-broken override - skip this check cycle, same non-fatal shape as + // every other failure here. + log('debug', 'witcherscriptmerger-vortex: WSM exe no longer resolved - skipping coexistence-state check'); + return; + } + client = await connect({ exePath, env, requestTimeoutMs: COEXISTENCE_CHECK_TIMEOUT_MS }); const snapshot = await computeMergeStateSnapshot(client); checkCoexistenceDrift(api, snapshot); } finally { diff --git a/vortex-extension/src/conflictScan.test.ts b/vortex-extension/src/conflictScan.test.ts index 2d61abf..f4c709d 100644 --- a/vortex-extension/src/conflictScan.test.ts +++ b/vortex-extension/src/conflictScan.test.ts @@ -28,11 +28,43 @@ function fakeApi(userDataDir: string, discoveredGamePath?: string) { } describe('getWsmExePath', () => { - it('points at the acquired WSM Headless exe under the tool storage dir', () => { - const api = fakeApi(path.join('C:', 'fake', 'userData')); - expect(getWsmExePath(api)).toBe( - path.join('C:', 'fake', 'userData', 'witcherscriptmerger-vortex', 'tool', WSM_HEADLESS_EXE_NAME), + let userDataDir: string; + + beforeEach(() => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-exepath-test-')); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }); + + it('resolves to the managed install exe once it exists', async () => { + const api = fakeApi(userDataDir); + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + fs.writeFileSync(path.join(toolDir, WSM_HEADLESS_EXE_NAME), 'fake exe bytes', 'utf8'); + + await expect(getWsmExePath(api)).resolves.toBe(path.join(toolDir, WSM_HEADLESS_EXE_NAME)); + }); + + it('resolves to undefined when nothing usable exists', async () => { + await expect(getWsmExePath(fakeApi(userDataDir))).resolves.toBeUndefined(); + }); + + it('prefers a user override over the managed install (wsmToolPath.ts precedence)', async () => { + const api = fakeApi(userDataDir); + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + fs.writeFileSync(path.join(toolDir, WSM_HEADLESS_EXE_NAME), 'managed exe', 'utf8'); + const overrideExe = path.join(userDataDir, 'WitcherScriptMerger.Headless.exe'); + fs.writeFileSync(overrideExe, 'override exe', 'utf8'); + fs.writeFileSync( + path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool-path-override.txt'), + overrideExe, + 'utf8', ); + + await expect(getWsmExePath(api)).resolves.toBe(overrideExe); }); }); @@ -79,8 +111,24 @@ describe('isWsmToolAcquired', () => { }); describe('scanWsmConflicts', () => { + let userDataDir: string; + let stagedExePath: string; + + // scanWsmConflicts resolves the exe through wsmToolPath.ts now and refuses to spawn + // when nothing usable exists, so these orchestration fixtures stage a real (fake + // bytes) exe file in a real temp dir instead of handing it a path that was never + // checked before this unit. beforeEach(() => { connectMock.mockReset(); + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-scan-test-')); + const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); + fs.mkdirSync(toolDir, { recursive: true }); + stagedExePath = path.join(toolDir, WSM_HEADLESS_EXE_NAME); + fs.writeFileSync(stagedExePath, 'fake exe bytes', 'utf8'); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); }); it('connects with the acquired exe path and Witcher 3 discovered game directory, scans, then always closes', async () => { @@ -88,14 +136,14 @@ describe('scanWsmConflicts', () => { const scanConflictsMock = vi.fn().mockResolvedValue([{ relativePath: 'foo.ws' }]); connectMock.mockResolvedValue({ scanConflicts: scanConflictsMock, close: closeMock }); - const api = fakeApi(path.join('C:', 'fake', 'userData'), path.join('C:', 'Games', 'Witcher3')); + const api = fakeApi(userDataDir, path.join('C:', 'Games', 'Witcher3')); const result = await scanWsmConflicts(api); expect(result).toEqual([{ relativePath: 'foo.ws' }]); expect(connectMock).toHaveBeenCalledTimes(1); const connectArgs = connectMock.mock.calls[0][0] as { exePath: string; env: Record; requestTimeoutMs: number }; - expect(connectArgs.exePath).toBe(getWsmExePath(api)); + expect(connectArgs.exePath).toBe(stagedExePath); expect(connectArgs.env.WSM_GameDirectory).toBe(path.join('C:', 'Games', 'Witcher3')); expect(scanConflictsMock).toHaveBeenCalledTimes(1); expect(closeMock).toHaveBeenCalledTimes(1); @@ -110,7 +158,7 @@ describe('scanWsmConflicts', () => { const scanConflictsMock = vi.fn().mockResolvedValue([]); connectMock.mockResolvedValue({ scanConflicts: scanConflictsMock, close: closeMock }); - const api = fakeApi(path.join('C:', 'fake', 'userData')); + const api = fakeApi(userDataDir); await scanWsmConflicts(api); const connectArgs = connectMock.mock.calls[0][0] as { requestTimeoutMs?: number }; @@ -124,7 +172,7 @@ describe('scanWsmConflicts', () => { const scanConflictsMock = vi.fn().mockRejectedValue(new Error('boom')); connectMock.mockResolvedValue({ scanConflicts: scanConflictsMock, close: closeMock }); - const api = fakeApi(path.join('C:', 'fake', 'userData')); + const api = fakeApi(userDataDir); await expect(scanWsmConflicts(api)).rejects.toThrow('boom'); expect(closeMock).toHaveBeenCalledTimes(1); @@ -133,7 +181,7 @@ describe('scanWsmConflicts', () => { it('does not attempt to close when connect itself fails (nothing to close)', async () => { connectMock.mockRejectedValue(new Error('spawn failed')); - const api = fakeApi(path.join('C:', 'fake', 'userData')); + const api = fakeApi(userDataDir); await expect(scanWsmConflicts(api)).rejects.toThrow('spawn failed'); }); @@ -152,7 +200,7 @@ describe('scanWsmConflicts', () => { const closeMock = vi.fn().mockResolvedValue(undefined); connectMock.mockResolvedValue({ scanConflicts: scanConflictsMock, close: closeMock }); - const api = fakeApi(path.join('C:', 'fake', 'userData')); + const api = fakeApi(userDataDir); const first = scanWsmConflicts(api); const second = scanWsmConflicts(api); @@ -170,7 +218,7 @@ describe('scanWsmConflicts', () => { const scanConflictsMock = vi.fn().mockResolvedValue([]); connectMock.mockResolvedValue({ scanConflicts: scanConflictsMock, close: closeMock }); - const api = fakeApi(path.join('C:', 'fake', 'userData')); + const api = fakeApi(userDataDir); await scanWsmConflicts(api); await scanWsmConflicts(api); diff --git a/vortex-extension/src/conflictScan.ts b/vortex-extension/src/conflictScan.ts index 7cdd5a1..7baf0a9 100644 --- a/vortex-extension/src/conflictScan.ts +++ b/vortex-extension/src/conflictScan.ts @@ -1,10 +1,7 @@ -import * as fs from 'fs'; -import * as path from 'path'; import { selectors, types } from 'vortex-api'; import { WITCHER3_GAME_ID } from './gating'; import { ScanConflictsResult, WsmMcpClient } from './mcpClient'; -import { getWsmToolDir } from './storage'; -import { WSM_HEADLESS_EXE_NAME } from './toolAcquisition'; +import { resolveWsmExePathIfUsable } from './wsmToolPath'; import { buildWsmEnv, mergeWithProcessEnv } from './wsmEnv'; /** @@ -28,15 +25,13 @@ import { buildWsmEnv, mergeWithProcessEnv } from './wsmEnv'; * `gating.ts`'s own general rule. */ -/** Absolute path to the WSM Headless exe this extension would have acquired, per - * `storage.ts`'s layout convention - does not check whether it actually exists on - * disk (see `isWsmToolAcquired` below for that). */ -export function getWsmExePath(api: types.IExtensionApi): string { - return path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); -} - -function isEnoent(err: unknown): boolean { - return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT'; +/** Absolute path to the WSM Headless exe this extension should use - the central + * resolver's answer (user override first, then the managed install; see + * `wsmToolPath.ts`), or undefined when nothing usable is resolved. Kept as a named + * re-export here because this module's callers (coexistenceGuard.ts) already import + * it under this name. */ +export async function getWsmExePath(api: types.IExtensionApi): Promise { + return resolveWsmExePathIfUsable(api); } /** @@ -56,15 +51,7 @@ function isEnoent(err: unknown): boolean { * `pathExists`'s own doc comment calls out. */ export async function isWsmToolAcquired(api: types.IExtensionApi): Promise { - try { - await fs.promises.access(getWsmExePath(api)); - return true; - } catch (err) { - if (isEnoent(err)) { - return false; - } - throw err; - } + return (await resolveWsmExePathIfUsable(api)) !== undefined; } /** @@ -142,8 +129,15 @@ async function scanWsmConflictsUncoordinated(api: types.IExtensionApi): Promise< // rejects, and index.ts's own try/catch around this call already logs it as a // warning rather than crashing or hanging. Worth documenting, not worth adding // synchronization machinery for a rare, already-safely-handled race. + const exePath = await getWsmExePath(api); + if (exePath === undefined) { + throw new Error( + 'No usable WitcherScriptMerger executable is resolved (not acquired yet, or the ' + + 'configured override path no longer exists - see the WitcherScriptMerger Status dashlet).', + ); + } const client = await WsmMcpClient.connect({ - exePath: getWsmExePath(api), + exePath, env, requestTimeoutMs: POST_DEPLOY_SCAN_TIMEOUT_MS, }); diff --git a/vortex-extension/src/githubRelease.ts b/vortex-extension/src/githubRelease.ts index 7d4421f..d9a6133 100644 --- a/vortex-extension/src/githubRelease.ts +++ b/vortex-extension/src/githubRelease.ts @@ -18,6 +18,14 @@ import * as https from 'https'; export const DEFAULT_WSM_REPO = 'TheValiantOne/WitcherScriptMerger'; +/** + * The WSM version the status dashlet's "Download WitcherScriptMerger" action acquires - + * the single place this number lives in the extension. Matches the release tag + * (`v`) and `WitcherScriptMerger.Headless.csproj`'s own ``; bump it + * alongside a new WSM release once that release's assets are published. + */ +export const DEFAULT_WSM_VERSION = '0.6.2'; + /** * Windows-only for now, matching Vortex itself being Windows-only today (see * `docs/vortex-extension-design.md`, Open Question 8) - not a hardcoded assumption diff --git a/vortex-extension/src/mergeHistoryDashlet.test.ts b/vortex-extension/src/mergeHistoryDashlet.test.ts index 3154c11..c6a0681 100644 --- a/vortex-extension/src/mergeHistoryDashlet.test.ts +++ b/vortex-extension/src/mergeHistoryDashlet.test.ts @@ -33,17 +33,17 @@ describe('resolveWsmExePath', () => { fs.rmSync(userDataDir, { recursive: true, force: true }); }); - it('returns null when no WSM build has been acquired yet', () => { - expect(resolveWsmExePath(fakeApi(userDataDir))).toBeNull(); + it('returns null when no WSM build has been acquired yet', async () => { + await expect(resolveWsmExePath(fakeApi(userDataDir))).resolves.toBeNull(); }); - it('returns the exe path when a WSM build has been acquired', () => { + it('returns the exe path when a WSM build has been acquired', async () => { const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool'); fs.mkdirSync(toolDir, { recursive: true }); const exePath = path.join(toolDir, WSM_HEADLESS_EXE_NAME); fs.writeFileSync(exePath, 'fake exe bytes', 'utf8'); - expect(resolveWsmExePath(fakeApi(userDataDir))).toBe(exePath); + await expect(resolveWsmExePath(fakeApi(userDataDir))).resolves.toBe(exePath); }); }); diff --git a/vortex-extension/src/mergeHistoryDashlet.ts b/vortex-extension/src/mergeHistoryDashlet.ts index 41870bd..3fd6b20 100644 --- a/vortex-extension/src/mergeHistoryDashlet.ts +++ b/vortex-extension/src/mergeHistoryDashlet.ts @@ -1,11 +1,8 @@ -import * as fs from 'fs'; -import * as path from 'path'; import * as React from 'react'; import { Dashlet, types } from 'vortex-api'; import { isWitcher3Active } from './gating'; import { RecordedMerge, WsmMcpClient } from './mcpClient'; -import { getWsmToolDir } from './storage'; -import { WSM_HEADLESS_EXE_NAME } from './toolAcquisition'; +import { resolveWsmExePathIfUsable } from './wsmToolPath'; /** * Dashboard tile listing every merge already recorded in `MergeInventory.xml` (relative @@ -45,23 +42,16 @@ export type MergeHistoryResult = | { status: 'loaded'; merges: RecordedMerge[] }; /** - * Absolute path to the acquired WSM Headless exe, or `null` if none has been acquired - * yet. Same computation `toolAcquisition.ts`'s own `ensureWsmToolRegistered` (and - * `acquireWsmToolUncoordinated`) uses (`getWsmToolDir(api)` + `WSM_HEADLESS_EXE_NAME`) - - * deliberately not read from Vortex's discovered-tools Redux state instead, since that - * state's own `executable` field has an unverified persistence story (see - * `discoveredTool.ts`'s own doc comment), while this plain filesystem check is exactly as - * reliable as the acquisition path that produced it. - * - * **Known duplication, not an oversight**: this two-line computation now exists in three - * places (here and `toolAcquisition.ts`'s two call sites). Not factored into a shared - * helper in `storage.ts`/`toolAcquisition.ts` because this unit's own scope keeps both of - * those files read-only ("beyond reading them" - see this unit's own task description); - * a later unit touching either file is better positioned to extract one. + * Absolute path to the WSM Headless exe this extension should use, or `null` when + * nothing usable resolves. Now just the central resolver (`wsmToolPath.ts` - user + * override first, then the managed install); this module's former private copy of the + * managed-path computation was the "known duplication, not an oversight" its own + * comment promised a later unit would extract. Still deliberately not read from + * Vortex's discovered-tools Redux state - see `discoveredTool.ts` on that state's + * unverified persistence story. */ -export function resolveWsmExePath(api: types.IExtensionApi): string | null { - const exePath = path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); - return fs.existsSync(exePath) ? exePath : null; +export async function resolveWsmExePath(api: types.IExtensionApi): Promise { + return (await resolveWsmExePathIfUsable(api)) ?? null; } /** @@ -75,7 +65,7 @@ export async function fetchMergeHistory( api: types.IExtensionApi, deps: MergeHistoryFetchDeps = {}, ): Promise { - const exePath = resolveWsmExePath(api); + const exePath = await resolveWsmExePath(api); if (exePath === null) { return { status: 'not-installed' }; } diff --git a/vortex-extension/src/statusTile.ts b/vortex-extension/src/statusTile.ts index b90c339..dbb710c 100644 --- a/vortex-extension/src/statusTile.ts +++ b/vortex-extension/src/statusTile.ts @@ -1,8 +1,13 @@ +import * as fs from 'fs'; +import * as path from 'path'; 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 { DEFAULT_WSM_VERSION } from './githubRelease'; +import { acquireWsmTool, ensureWsmToolRegistered } from './toolAcquisition'; import { WCC_LITE_NEXUS_MOD_URL, acquireWccLite } from './wccLiteAcquisition'; +import { setWsmToolPathOverride } from './wsmToolPath'; import { WsmStatusSummary, getWsmStatusSummary } from './wsmStatusSummary'; /** @@ -86,6 +91,8 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea const [loading, setLoading] = React.useState(true); const [wccLiteError, setWccLiteError] = React.useState(undefined); const [fetchingWccLite, setFetchingWccLite] = React.useState(false); + const [wsmActionError, setWsmActionError] = React.useState(undefined); + const [fetchingWsm, setFetchingWsm] = 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* @@ -120,12 +127,22 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea setFetchingWccLite(value); } }, []); + const setWsmActionErrorIfMounted = React.useCallback((value: string | undefined) => { + if (mountedRef.current) { + setWsmActionError(value); + } + }, []); + const setFetchingWsmIfMounted = React.useCallback((value: boolean) => { + if (mountedRef.current) { + setFetchingWsm(value); + } + }, []); - // Combined "something is in flight" flag, used to disable *both* buttons regardless + // Combined "something is in flight" flag, used to disable *all* 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 + // (or the WSM download) is still running could fire two overlapping WSM process // spawns/MCP handshakes/mods-folder scans that race each other's setSummary() call. - const busy = loading || fetchingWccLite; + const busy = loading || fetchingWccLite || fetchingWsm; // Returns its own promise (rather than firing-and-forgetting internally) so // handleGetWccLite below can genuinely wait for the post-acquisition refresh to @@ -157,6 +174,71 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea .finally(() => setFetchingWccLiteIfMounted(false)); }, [api, refresh, setFetchingWccLiteIfMounted, setWccLiteErrorIfMounted]); + // The first-run download: the real GitHub-release acquisition pipeline + // (toolAcquisition.ts - download, extract, register as a discovered tool), pinned to + // DEFAULT_WSM_VERSION. This is the explicit user trigger index.ts's doc comment has + // referenced since Unit F - downloads stay a user action, never an automatic + // startup side effect. + const handleDownloadWsm = React.useCallback(() => { + setFetchingWsmIfMounted(true); + setWsmActionErrorIfMounted(undefined); + acquireWsmTool({ api, version: DEFAULT_WSM_VERSION }) + .then(() => refresh()) + .catch((err: unknown) => setWsmActionErrorIfMounted(err instanceof Error ? err.message : String(err))) + .finally(() => setFetchingWsmIfMounted(false)); + }, [api, refresh, setFetchingWsmIfMounted, setWsmActionErrorIfMounted]); + + // Points the extension at an existing WSM install instead of downloading one - the + // wsmToolPath.ts override. Validation lives here, next to the dialog that can + // explain a rejection: the path must exist and name a WitcherScriptMerger*.exe + // (either host works - the WinForms build also supports `mcp`), so a stray path to + // some unrelated executable can't be silently adopted. + const handleUseExisting = React.useCallback(() => { + setWsmActionErrorIfMounted(undefined); + const run = async (): Promise => { + const result = await api.showDialog?.( + 'question', + 'Use an existing WitcherScriptMerger install', + { + text: + 'Full path to a WitcherScriptMerger executable with MCP support ' + + '(WitcherScriptMerger.Headless.exe, or WitcherScriptMerger.exe from this ' + + "extension's own fork - the original 2016 Script Merger has no mcp mode " + + 'and will not work):', + input: [{ id: 'wsmPath', type: 'text', value: '', label: 'Path to WitcherScriptMerger executable' }], + }, + [{ label: 'Cancel' }, { label: 'Save', default: true }], + ); + if (result?.action !== 'Save') { + return; + } + const chosen = String(result.input?.['wsmPath'] ?? '').trim(); + const baseName = path.basename(chosen).toLowerCase(); + if (!baseName.startsWith('witcherscriptmerger') || !baseName.endsWith('.exe')) { + setWsmActionErrorIfMounted(`Not a WitcherScriptMerger executable: ${chosen || '(empty)'}`); + return; + } + if (!fs.existsSync(chosen)) { + setWsmActionErrorIfMounted(`File not found: ${chosen}`); + return; + } + await setWsmToolPathOverride(api, chosen); + await ensureWsmToolRegistered(api); + await refresh(); + }; + run().catch((err: unknown) => setWsmActionErrorIfMounted(err instanceof Error ? err.message : String(err))); + }, [api, refresh, setWsmActionErrorIfMounted]); + + // Clears a stale override (see wsmStatusSummary's 'override-missing' kind) - + // resolution falls back to the managed install, or to the not-acquired state. + const handleClearOverride = React.useCallback(() => { + setWsmActionErrorIfMounted(undefined); + setWsmToolPathOverride(api, null) + .then(() => ensureWsmToolRegistered(api)) + .then(() => refresh()) + .catch((err: unknown) => setWsmActionErrorIfMounted(err instanceof Error ? err.message : String(err))); + }, [api, refresh, setWsmActionErrorIfMounted]); + const children: React.ReactNode[] = [ React.createElement('h4', { key: 'title', style: { marginTop: 0 } }, 'WitcherScriptMerger Status'), ]; @@ -170,6 +252,42 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea { key: 'not-acquired' }, "WitcherScriptMerger hasn't been downloaded yet.", ), + React.createElement( + 'div', + { key: 'not-acquired-actions', style: { marginTop: '0.5em', display: 'flex', gap: '0.5em' } }, + React.createElement( + 'button', + { type: 'button', disabled: busy, onClick: handleDownloadWsm }, + fetchingWsm ? 'Downloading...' : `Download WitcherScriptMerger v${DEFAULT_WSM_VERSION}`, + ), + React.createElement( + 'button', + { type: 'button', disabled: busy, onClick: handleUseExisting }, + 'Use an existing install...', + ), + ), + ); + } else if (summary?.kind === 'override-missing') { + children.push( + React.createElement( + 'div', + { key: 'override-missing', style: { color: '#c0392b' } }, + `The configured WitcherScriptMerger path no longer exists: ${summary.overridePath}`, + ), + React.createElement( + 'div', + { key: 'override-missing-actions', style: { marginTop: '0.5em', display: 'flex', gap: '0.5em' } }, + React.createElement( + 'button', + { type: 'button', disabled: busy, onClick: handleClearOverride }, + 'Clear this path', + ), + React.createElement( + 'button', + { type: 'button', disabled: busy, onClick: handleUseExisting }, + 'Choose a different path...', + ), + ), ); } else if (summary?.kind === 'error') { children.push( @@ -220,6 +338,10 @@ export function WsmStatusDashletContent(props: WsmStatusDashletProps): React.Rea } } + if (wsmActionError) { + children.push(row('wsmActionError', 'WitcherScriptMerger setup failed:', wsmActionError)); + } + children.push( React.createElement( 'button', diff --git a/vortex-extension/src/storage.ts b/vortex-extension/src/storage.ts index fd7370a..3b2f27b 100644 --- a/vortex-extension/src/storage.ts +++ b/vortex-extension/src/storage.ts @@ -43,6 +43,11 @@ const BUNDLE_TOOLS_SUBDIR = 'bundle-tools'; * surrounding JSON/XML - deliberately trivial to read/write without a parser. */ export const INSTALLED_VERSION_FILENAME = 'installed-version.txt'; +/** The Headless host's executable name inside any WSM install this extension uses - + * lives here (rather than toolAcquisition.ts, its original home) so wsmToolPath.ts + * can import it without a toolAcquisition <-> wsmToolPath import cycle. */ +export const WSM_HEADLESS_EXE_NAME = 'WitcherScriptMerger.Headless.exe'; + export function getExtensionStorageDir(api: types.IExtensionApi): string { return path.join(api.getPath('userData'), EXTENSION_STORAGE_DIRNAME); } diff --git a/vortex-extension/src/toolAcquisition.ts b/vortex-extension/src/toolAcquisition.ts index 81740f9..4f627dc 100644 --- a/vortex-extension/src/toolAcquisition.ts +++ b/vortex-extension/src/toolAcquisition.ts @@ -5,7 +5,8 @@ import { ArchiveExtractor, createVortexArchiveExtractor } from './archiveExtract import { buildWsmDiscoveredTool, registerWsmDiscoveredTool } from './discoveredTool'; import { WITCHER3_GAME_ID } from './gating'; import { buildAssetFileName, DEFAULT_WSM_REPO, downloadReleaseAsset, HttpClient, resolveReleaseAsset } from './githubRelease'; -import { getDownloadCacheDir, getWsmToolDir, INSTALLED_VERSION_FILENAME } from './storage'; +import { getDownloadCacheDir, getWsmToolDir, INSTALLED_VERSION_FILENAME, WSM_HEADLESS_EXE_NAME } from './storage'; +import { resolveWsmExePathIfUsable } from './wsmToolPath'; import { buildWsmEnv } from './wsmEnv'; /** @@ -17,7 +18,9 @@ import { buildWsmEnv } from './wsmEnv'; * (the actual GitHub download - no release exists on this repo yet). */ -export const WSM_HEADLESS_EXE_NAME = 'WitcherScriptMerger.Headless.exe'; +// Moved to storage.ts (see its own comment on why); re-exported here so existing +// importers keep working unchanged. +export { WSM_HEADLESS_EXE_NAME } from './storage'; export interface AcquireWsmToolOptions { api: types.IExtensionApi; @@ -224,10 +227,13 @@ function registerAcquiredTool(api: types.IExtensionApi, exePath: string): void { * expected, normal state for as long as no GitHub Release exists. */ export async function ensureWsmToolRegistered(api: types.IExtensionApi): Promise { - const installDir = getWsmToolDir(api); - const exePath = path.join(installDir, WSM_HEADLESS_EXE_NAME); - - if (!(await pathExists(exePath))) { + // Central resolution (wsmToolPath.ts): a user-set override wins over the managed + // install, and a stale override deliberately does NOT fall back to the managed copy + // (see that module's doc comment). Whatever resolves is what gets registered as the + // discovered tool - so resolveAction.ts's getDiscoveredWsmTool(api).path and every + // other consumer of the registration follow the same answer automatically. + const exePath = await resolveWsmExePathIfUsable(api); + if (exePath === undefined) { return false; } diff --git a/vortex-extension/src/wsmStatusSummary.ts b/vortex-extension/src/wsmStatusSummary.ts index ccd28ac..dda93ee 100644 --- a/vortex-extension/src/wsmStatusSummary.ts +++ b/vortex-extension/src/wsmStatusSummary.ts @@ -1,10 +1,8 @@ -import * as path from 'path'; import { selectors, types } from 'vortex-api'; -import { DetectedBundleTools, detectBundleTools, fileExists } from './bundleTools'; +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 { resolveWsmExe } from './wsmToolPath'; import { WsmEnvConfig, buildWsmEnv, mergeWithProcessEnv } from './wsmEnv'; /** @@ -20,6 +18,11 @@ import { WsmEnvConfig, buildWsmEnv, mergeWithProcessEnv } from './wsmEnv'; export type WsmStatusSummary = | { kind: 'not-acquired' } + // The user pointed this extension at an existing WSM install (wsmToolPath.ts's + // override) but that path no longer exists - deliberately its own state, never + // silently folded into 'not-acquired' or a fallback to the managed install, so the + // dashlet can show the broken path and offer to clear it. + | { kind: 'override-missing'; overridePath: string } | { kind: 'error'; message: string } | { kind: 'ok'; status: GetStatusResult; bundleTools: DetectedBundleTools }; @@ -50,14 +53,18 @@ export async function getWsmStatusSummary( api: types.IExtensionApi, options: GetWsmStatusSummaryOptions = {}, ): Promise { - const exePath = path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); - + let exePath: string; let bundleTools: DetectedBundleTools; let env: NodeJS.ProcessEnv; try { - if (!(await fileExists(exePath))) { + const resolution = await resolveWsmExe(api); + if (resolution.kind === 'none') { return { kind: 'not-acquired' }; } + if (resolution.kind === 'override-missing') { + return { kind: 'override-missing', overridePath: resolution.overridePath }; + } + exePath = resolution.exePath; bundleTools = await detectBundleTools(api); const gameDirectory = selectors.discoveryByGame(api.getState(), WITCHER3_GAME_ID)?.path; diff --git a/vortex-extension/src/wsmToolPath.test.ts b/vortex-extension/src/wsmToolPath.test.ts new file mode 100644 index 0000000..f002fa3 --- /dev/null +++ b/vortex-extension/src/wsmToolPath.test.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { types } from 'vortex-api'; +import { WSM_HEADLESS_EXE_NAME } from './storage'; +import { + WSM_TOOL_PATH_OVERRIDE_FILENAME, + getWsmToolPathOverride, + resolveWsmExe, + resolveWsmExePathIfUsable, + setWsmToolPathOverride, +} from './wsmToolPath'; + +// Direct coverage for the central "which WSM executable?" resolver - the precedence +// (override > managed), the deliberate no-silent-fallback policy on a stale override, +// and the override file's persistence round trip. conflictScan.test.ts / +// mergeHistoryDashlet.test.ts cover their own delegating wrappers. +describe('wsmToolPath', () => { + let userDataDir: string; + + const api = (): types.IExtensionApi => + ({ + getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`), + }) as unknown as types.IExtensionApi; + + const storageDir = (): string => path.join(userDataDir, 'witcherscriptmerger-vortex'); + const managedExePath = (): string => path.join(storageDir(), 'tool', WSM_HEADLESS_EXE_NAME); + + beforeEach(() => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-toolpath-test-')); + }); + + afterEach(() => { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }); + + it('resolves to none when neither an override nor a managed install exists', async () => { + await expect(resolveWsmExe(api())).resolves.toEqual({ kind: 'none' }); + await expect(resolveWsmExePathIfUsable(api())).resolves.toBeUndefined(); + }); + + it('resolves to the managed install when it exists and no override is set', async () => { + fs.mkdirSync(path.dirname(managedExePath()), { recursive: true }); + fs.writeFileSync(managedExePath(), 'managed exe', 'utf8'); + + await expect(resolveWsmExe(api())).resolves.toEqual({ kind: 'managed', exePath: managedExePath() }); + }); + + it('prefers an existing override over an existing managed install', async () => { + fs.mkdirSync(path.dirname(managedExePath()), { recursive: true }); + fs.writeFileSync(managedExePath(), 'managed exe', 'utf8'); + const overrideExe = path.join(userDataDir, 'elsewhere', WSM_HEADLESS_EXE_NAME); + fs.mkdirSync(path.dirname(overrideExe), { recursive: true }); + fs.writeFileSync(overrideExe, 'override exe', 'utf8'); + await setWsmToolPathOverride(api(), overrideExe); + + await expect(resolveWsmExe(api())).resolves.toEqual({ kind: 'override', exePath: overrideExe }); + }); + + it('reports a stale override as override-missing rather than silently falling back to the managed install', async () => { + fs.mkdirSync(path.dirname(managedExePath()), { recursive: true }); + fs.writeFileSync(managedExePath(), 'managed exe', 'utf8'); + const goneExe = path.join(userDataDir, 'uninstalled', WSM_HEADLESS_EXE_NAME); + await setWsmToolPathOverride(api(), goneExe); + + await expect(resolveWsmExe(api())).resolves.toEqual({ kind: 'override-missing', overridePath: goneExe }); + // The convenience wrapper treats it as unusable - never the managed path. + await expect(resolveWsmExePathIfUsable(api())).resolves.toBeUndefined(); + }); + + it('round-trips the override through its persistence file, and clearing restores managed resolution', async () => { + fs.mkdirSync(path.dirname(managedExePath()), { recursive: true }); + fs.writeFileSync(managedExePath(), 'managed exe', 'utf8'); + const overrideExe = path.join(userDataDir, WSM_HEADLESS_EXE_NAME); + fs.writeFileSync(overrideExe, 'override exe', 'utf8'); + + await setWsmToolPathOverride(api(), overrideExe); + await expect(getWsmToolPathOverride(api())).resolves.toBe(overrideExe); + expect(fs.existsSync(path.join(storageDir(), WSM_TOOL_PATH_OVERRIDE_FILENAME))).toBe(true); + + await setWsmToolPathOverride(api(), null); + await expect(getWsmToolPathOverride(api())).resolves.toBeUndefined(); + await expect(resolveWsmExe(api())).resolves.toEqual({ kind: 'managed', exePath: managedExePath() }); + }); + + it('treats a blank override file as no override, and clearing an already-absent override is a no-op', async () => { + fs.mkdirSync(storageDir(), { recursive: true }); + fs.writeFileSync(path.join(storageDir(), WSM_TOOL_PATH_OVERRIDE_FILENAME), ' \r\n', 'utf8'); + + await expect(getWsmToolPathOverride(api())).resolves.toBeUndefined(); + await expect(resolveWsmExe(api())).resolves.toEqual({ kind: 'none' }); + await expect(setWsmToolPathOverride(api(), null)).resolves.toBeUndefined(); + await expect(setWsmToolPathOverride(api(), null)).resolves.toBeUndefined(); + }); +}); diff --git a/vortex-extension/src/wsmToolPath.ts b/vortex-extension/src/wsmToolPath.ts new file mode 100644 index 0000000..d9140cd --- /dev/null +++ b/vortex-extension/src/wsmToolPath.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { types } from 'vortex-api'; +import { getExtensionStorageDir, getWsmToolDir, WSM_HEADLESS_EXE_NAME } from './storage'; + +/** + * The single place the "which WSM executable should this extension use?" question is + * answered. Two sources, in precedence order: + * + * 1. **User override** - an absolute exe path the user pointed at an existing WSM + * install (the status dashlet's "Use an existing install..." flow), persisted as a + * one-line text file in this extension's private storage + * (`tool-path-override.txt`, same trivially-parseable single-value convention as + * `storage.ts`'s `installed-version.txt`). Deliberately a file rather than Vortex + * Redux state: the discovered-tools state's persistence story for this extension's + * own registrations is already unverified (see `discoveredTool.ts`), and a plain + * file is exactly as reliable as the acquisition markers this extension already + * trusts. + * 2. **Managed install** - the extension-private tool dir `acquireWsmTool` downloads + * into (`storage.ts`'s layout). + * + * An override that's set but whose file no longer exists is reported as its own + * distinct state (`override-missing`), never silently skipped in favor of the managed + * install: the user explicitly pointed elsewhere, and quietly using a different binary + * than the one they chose is the kind of surprise this extension exists to avoid. The + * fix is theirs to make (repair the path, or clear the override). + * + * Before this module, the managed-path computation + * (`getWsmToolDir(api) + WSM_HEADLESS_EXE_NAME`) was duplicated across + * `conflictScan.ts`, `mergeHistoryDashlet.ts`, `wsmStatusSummary.ts`, and + * `toolAcquisition.ts` - a documented, deliberate duplication awaiting "a later unit + * touching either file" (mergeHistoryDashlet.ts's own former comment). This is that + * unit. + */ + +export const WSM_TOOL_PATH_OVERRIDE_FILENAME = 'tool-path-override.txt'; + +export type WsmExeResolution = + | { kind: 'override'; exePath: string } + | { kind: 'managed'; exePath: string } + | { kind: 'override-missing'; overridePath: string } + | { kind: 'none' }; + +function overrideFilePath(api: types.IExtensionApi): string { + return path.join(getExtensionStorageDir(api), WSM_TOOL_PATH_OVERRIDE_FILENAME); +} + +function isEnoent(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** The persisted override exe path, or undefined when none is set. Only ENOENT (and a + * blank file) mean "not set" - any other read failure is a real problem the caller + * needs to see, same policy as `toolAcquisition.ts`'s `pathExists`. */ +export async function getWsmToolPathOverride(api: types.IExtensionApi): Promise { + let content: string; + try { + content = await fs.promises.readFile(overrideFilePath(api), 'utf8'); + } catch (err) { + if (isEnoent(err)) { + return undefined; + } + throw err; + } + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** Persists (or, with null, clears) the override exe path. Callers validate the path + * BEFORE setting it (exists, is a file, names a WSM executable) - this function only + * persists, so the validation logic lives next to the UI that can explain a rejection + * to the user. */ +export async function setWsmToolPathOverride(api: types.IExtensionApi, exePath: string | null): Promise { + const filePath = overrideFilePath(api); + if (exePath === null) { + try { + await fs.promises.unlink(filePath); + } catch (err) { + if (!isEnoent(err)) { + throw err; + } + } + return; + } + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, exePath, 'utf8'); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.promises.access(filePath); + return true; + } catch (err) { + if (isEnoent(err)) { + return false; + } + throw err; + } +} + +/** Resolves which WSM executable to use - see this module's own doc comment for the + * precedence and the deliberate no-silent-fallback policy on a stale override. */ +export async function resolveWsmExe(api: types.IExtensionApi): Promise { + const override = await getWsmToolPathOverride(api); + if (override !== undefined) { + return (await fileExists(override)) + ? { kind: 'override', exePath: override } + : { kind: 'override-missing', overridePath: override }; + } + + const managedPath = path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); + return (await fileExists(managedPath)) + ? { kind: 'managed', exePath: managedPath } + : { kind: 'none' }; +} + +/** Convenience for callers that only need "a usable exe path, or nothing" - the + * override-missing state maps to undefined here (unusable), with the richer + * distinction left to `resolveWsmExe` callers that can surface it (the status + * dashlet). */ +export async function resolveWsmExePathIfUsable(api: types.IExtensionApi): Promise { + const resolution = await resolveWsmExe(api); + return resolution.kind === 'override' || resolution.kind === 'managed' ? resolution.exePath : undefined; +}