Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions vortex-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<Vortex userData>\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\<version\>" 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 `<Vortex userData>\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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion vortex-extension/src/coexistenceGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 59 additions & 11 deletions vortex-extension/src/conflictScan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -79,23 +111,39 @@ 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 () => {
const closeMock = vi.fn().mockResolvedValue(undefined);
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<string, string>; 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);
Expand All @@ -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 };
Expand All @@ -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);
Expand All @@ -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');
});
Expand All @@ -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);
Expand All @@ -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);
Expand Down
40 changes: 17 additions & 23 deletions vortex-extension/src/conflictScan.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<string | undefined> {
return resolveWsmExePathIfUsable(api);
}

/**
Expand All @@ -56,15 +51,7 @@ function isEnoent(err: unknown): boolean {
* `pathExists`'s own doc comment calls out.
*/
export async function isWsmToolAcquired(api: types.IExtensionApi): Promise<boolean> {
try {
await fs.promises.access(getWsmExePath(api));
return true;
} catch (err) {
if (isEnoent(err)) {
return false;
}
throw err;
}
return (await resolveWsmExePathIfUsable(api)) !== undefined;
}

/**
Expand Down Expand Up @@ -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,
});
Expand Down
8 changes: 8 additions & 0 deletions vortex-extension/src/githubRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>`) and `WitcherScriptMerger.Headless.csproj`'s own `<Version>`; 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
Expand Down
8 changes: 4 additions & 4 deletions vortex-extension/src/mergeHistoryDashlet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
32 changes: 11 additions & 21 deletions vortex-extension/src/mergeHistoryDashlet.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string | null> {
return (await resolveWsmExePathIfUsable(api)) ?? null;
}

/**
Expand All @@ -75,7 +65,7 @@ export async function fetchMergeHistory(
api: types.IExtensionApi,
deps: MergeHistoryFetchDeps = {},
): Promise<MergeHistoryResult> {
const exePath = resolveWsmExePath(api);
const exePath = await resolveWsmExePath(api);
if (exePath === null) {
return { status: 'not-installed' };
}
Expand Down
Loading
Loading