From 642b1d8c15e0637a5eb175d634ec695dbaf0f0f6 Mon Sep 17 00:00:00 2001 From: wbottle Date: Sun, 6 Sep 2026 21:52:52 +0400 Subject: [PATCH 1/2] feat: replace local VPKs without duplicating mods --- electron/main/ipc/mods.ts | 110 +++++++++- .../services/localReplacementProfiles.test.ts | 43 ++++ .../main/services/localReplacementProfiles.ts | 18 ++ .../main/services/localVpkReplacement.test.ts | 130 ++++++++++++ electron/main/services/localVpkReplacement.ts | 122 +++++++++++ electron/main/services/profiles.ts | 2 +- electron/preload/index.ts | 1 + ...ImportCustomModsModal.replacement.test.tsx | 130 ++++++++++++ src/components/ImportCustomModsModal.tsx | 192 +++++++++++++++--- src/components/VariantPickerModal.tsx | 13 ++ src/lib/api.ts | 4 + src/lib/localModReplacement.test.ts | 44 ++++ src/lib/localModReplacement.ts | 31 +++ src/locales/en/translation.json | 24 ++- src/locales/manifest.json | 8 +- src/pages/Installed.tsx | 34 +++- src/types/electron.ts | 9 + 17 files changed, 880 insertions(+), 35 deletions(-) create mode 100644 electron/main/services/localReplacementProfiles.test.ts create mode 100644 electron/main/services/localReplacementProfiles.ts create mode 100644 electron/main/services/localVpkReplacement.test.ts create mode 100644 electron/main/services/localVpkReplacement.ts create mode 100644 src/components/ImportCustomModsModal.replacement.test.tsx create mode 100644 src/lib/localModReplacement.test.ts create mode 100644 src/lib/localModReplacement.ts diff --git a/electron/main/ipc/mods.ts b/electron/main/ipc/mods.ts index 6d4ed97f..e7085b03 100644 --- a/electron/main/ipc/mods.ts +++ b/electron/main/ipc/mods.ts @@ -20,13 +20,17 @@ import { type Mod, } from '../services/mods'; import { metaKeyFor, isValidDeadlockPath } from '../services/deadlock'; -import { getModMetadata, setModMetadata, setModMetadataWithHash, removeModMetadata, pruneOrphanMetadata, type ModMetadata } from '../services/metadata'; +import { getModMetadata, loadMetadata, saveMetadata, setModMetadata, setModMetadataWithHash, removeModMetadata, pruneOrphanMetadata, type ModMetadata } from '../services/metadata'; import { inferHeroFromTitle } from '@grimoire/social-types/heroes'; -import { inferHeroFromVpk, classifyGlobalModFromVpk, GLOBAL_CLASSIFIER_VERSION, parseVpkDirectory, parseVpkDirectoriesAsync } from '../services/vpk'; +import { inferHeroFromVpk, classifyGlobalModFromVpk, GLOBAL_CLASSIFIER_VERSION, parseVpkDirectory, parseVpkDirectoriesAsync, invalidateVpkParseCache } from '../services/vpk'; import { classifyAbilitySoundsFromVpk } from '../services/abilitySounds'; import { migrateIgnoredConflictKeysForMods } from '../services/conflicts'; import { isLockerManaged } from '../services/lockerVpk'; -import { retargetProfileModSha } from '../services/profiles'; +import { retargetProfileModSha, loadProfiles, saveProfiles } from '../services/profiles'; +import { assertCanMoveLoadedGameMod, syncRunningGameModSnapshotFromMods, syncKnownRunningGameModSnapshot } from '../services/gameSessionMods'; +import { isDeadlockRunning } from '../services/launch'; +import { replaceLocalVpkFile, validateReplacementVpk, replacementFileSha256, assertReplacementFileUnchanged } from '../services/localVpkReplacement'; +import { retargetLocalReplacementProfiles } from '../services/localReplacementProfiles'; import { detectUnknownModCacheMatches, detectUnknownModFilters, @@ -60,7 +64,7 @@ import { hasAdoptionFields, hasAnyImprint, } from '../services/imprintMods'; -import { parseAddonInfo, readEmbeddedAddonInfoText, readEmbeddedAddonInfo, carryForwardOriginalIdentity } from '../services/vpkIdentity'; +import { parseAddonInfo, readEmbeddedAddonInfoText, readEmbeddedAddonInfo, carryForwardOriginalIdentity, resolveVpkIdentity } from '../services/vpkIdentity'; import { readEmbeddedModinfo, readLegacyGrimoireMergeMeta, hasLegacyGrimoireMergeMetaEntry } from '../services/modinfoFormat'; import { buildHeroSoundSwapVpk, cleanupHeroSoundSwapBuild } from '../services/foundryCatalog'; import { buildSoulContainerVpk, cleanupSoulContainerBuild, previewSoulContainerGlb } from '../services/soulContainerImport'; @@ -1392,12 +1396,110 @@ async function installedLocalVariantGroupProfile( }; } +function assertReplaceableLocalVpk(target: Mod | undefined, metadata: ModMetadata | undefined): asserts target is Mod { + if (!target || !metadata?.modName || !metadata.sha256 || metadata.gameBananaId || metadata.gameBananaFileId || + metadata.merged || metadata.forgeInstall || metadata.soulImport || metadata.urnImport || metadata.soundSwap || + metadata.lockerCosmetics || metadata.lockerSounds || metadata.lockerColors || metadata.lockerTrippySkins || + isLockerManaged(target.metaKey)) { + throw new Error('This item cannot be replaced here. Select an installed local VPK'); + } +} + +ipcMain.handle('prepare-local-vpk-replacement', async (_, metaKey: string) => { + const deadlockPath = getActiveDeadlockPath(); + if (!deadlockPath) throw new Error('No Deadlock path configured'); + return runExclusiveModMutation(async () => { + const target = (await scanMods(deadlockPath)).find((mod) => mod.metaKey === metaKey); + const metadata = target && getModMetadata(target.metaKey); + assertReplaceableLocalVpk(target, metadata); + return { + metaKey: target.metaKey, + expectedSha256: metadata!.sha256!, + expectedFileSha256: await replacementFileSha256(target.path), + }; + }); +}); + +async function replaceCustomModSource(deadlockPath: string, args: ImportCustomModArgs): Promise { + const requested = args.replacement!; + if (!requested.metaKey || !/^[0-9a-f]{64}$/i.test(requested.expectedSha256 ?? '') || + !/^[0-9a-f]{64}$/i.test(requested.expectedFileSha256 ?? '')) { + throw new Error('Select the installed VPK again before replacing it'); + } + if (!args.vpkPath?.toLowerCase().endsWith('.vpk') || args.localGroupId || args.localGroupBatchKey) { + throw new Error('Replacement supports one standalone VPK at a time'); + } + const mods = await scanMods(deadlockPath); + const target = mods.find((mod) => mod.metaKey === requested.metaKey); + const previous = target && getModMetadata(target.metaKey); + assertReplaceableLocalVpk(target, previous); + await syncRunningGameModSnapshotFromMods(mods); + assertCanMoveLoadedGameMod(target); + const expectedSha = requested.expectedSha256.toLowerCase(); + if (previous!.sha256!.toLowerCase() !== expectedSha) { + throw new Error('The installed VPK changed. Select it again before replacing it'); + } + await assertReplacementFileUnchanged(target.path, requested.expectedFileSha256); + const oldMetadata = { ...previous }; + let replacementMetadata: ModMetadata; + await replaceLocalVpkFile(args.vpkPath, target.path, { + validate: async (staged) => { + await validateReplacementVpk(staged); + const embedded = readEmbeddedAddonInfo(staged); + const record = readEmbeddedModinfo(staged); + if (embedded?.gamebananaId || embedded?.gamebananaFileId || record?.source?.gamebananaId || + record?.source?.gamebananaFileId || record?.kind === 'merge' || hasLegacyGrimoireMergeMetaEntry(staged)) { + throw new Error('This VPK carries GameBanana or merged-mod identity. Import it separately'); + } + const classification = classifyImportedLocalVariant(staged, undefined); + if (oldMetadata.localGroupId) { + const profile = await installedLocalVariantGroupProfile(deadlockPath, oldMetadata.localGroupId, true); + if (profile?.classification) assertCompatibleLocalVariantClassifications(profile.classification, classification); + } + replacementMetadata = { + ...oldMetadata, + ...(!oldMetadata.localGroupId && oldMetadata.lockerHeroSource !== 'manual' && !oldMetadata.globalType + ? classification : {}), + sha256: (await resolveVpkIdentity(staged)).sha256, + abilitySounds: classifyAbilitySoundsFromVpk(staged), + imprinted: hasAnyImprint(staged), + imprintStale: false, + }; + if (replacementMetadata.imprinted) { + replacementMetadata.imprintStale = classifyEmbedFreshnessAt(staged, oldMetadata.modName!, replacementMetadata) === 'stale'; + } + }, + beforeSwap: async () => { + await assertReplacementFileUnchanged(target.path, requested.expectedFileSha256); + // The mutation scope caches running state. Large source copies can outlive + // that snapshot, so query the process again at the actual swap boundary. + syncKnownRunningGameModSnapshot(await isDeadlockRunning(), mods); + assertCanMoveLoadedGameMod(target); + }, + commit: () => { + const metadata = loadMetadata(); + const profiles = loadProfiles(); + const retargeted = retargetLocalReplacementProfiles(profiles, mods, + (key) => key === target.metaKey ? oldMetadata : metadata[key], target.metaKey, replacementMetadata.sha256!); + saveMetadata({ ...metadata, [target.metaKey]: replacementMetadata }); + if (retargeted.some((profile, index) => profile.mods.some((entry, j) => entry !== profiles[index].mods[j]))) { + saveProfiles(retargeted); + } + }, + rollback: () => saveMetadata({ ...loadMetadata(), [target.metaKey]: oldMetadata }), + }); + invalidateVpkParseCache(target.path); + await clearSoulModelCache(target.metaKey).catch((error) => console.warn('[mods] Replacement preview cleanup failed:', error)); + return 1; +} + async function importCustomModSource( deadlockPath: string, args: ImportCustomModArgs, thumbnailFetchTargets: AdoptedThumbnailTarget[], requireExistingGroup = false ): Promise { + if (args.replacement) return replaceCustomModSource(deadlockPath, args); const { vpkPath, name, diff --git a/electron/main/services/localReplacementProfiles.test.ts b/electron/main/services/localReplacementProfiles.test.ts new file mode 100644 index 00000000..0d1050c2 --- /dev/null +++ b/electron/main/services/localReplacementProfiles.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { Mod } from '../../../src/types/mod'; +import type { Profile, ProfileMod } from '../../../src/types/electron'; +import { retargetLocalReplacementProfiles } from './localReplacementProfiles'; + +const oldSha = 'a'.repeat(64); +const newSha = 'b'.repeat(64); +const mod = (fileName: string, sha256 = oldSha): Mod => ({ + id: fileName, metaKey: fileName, fileName, path: `/mods/${fileName}`, name: 'Local mod', + enabled: true, priority: 1, size: 10, installedAt: '', sha256, +}); +const entry = (fileName: string, sha256: string | undefined = oldSha): ProfileMod => ({ fileName, sha256, enabled: true, priority: 1 }); +const profile = (mods: ProfileMod[]): Profile => ({ id: 'p', name: 'Profile', mods, createdAt: '', updatedAt: '' }); + +describe('profile ownership during local replacement', () => { + it('retargets a local mod after its slot changed without mutating the saved input', () => { + const target = mod('pak02_dir.vpk'); + const saved = [profile([entry('pak01_dir.vpk')])]; + const updated = retargetLocalReplacementProfiles(saved, [target], () => target, target.metaKey, newSha); + expect(updated[0].mods[0].sha256).toBe(newSha); + expect(saved[0].mods[0].sha256).toBe(oldSha); + }); + it('preserves an identical twin and updates only the resolved target', () => { + const target = mod('pak01_dir.vpk'); + const twin = mod('pak02_dir.vpk'); + const mods = [target, twin]; + const updated = retargetLocalReplacementProfiles([profile([entry(twin.fileName), entry(target.fileName)])], + mods, (key) => mods.find((m) => m.metaKey === key), target.metaKey, newSha); + expect(updated[0].mods.map((m) => m.sha256)).toEqual([oldSha, newSha]); + }); + it('does not retarget a different local mod that reused a saved filename', () => { + const target = mod('pak01_dir.vpk', 'c'.repeat(64)); + const saved = [profile([entry(target.fileName)])]; + const updated = retargetLocalReplacementProfiles(saved, [target], () => target, target.metaKey, newSha); + expect(updated[0].mods[0].sha256).toBe(oldSha); + }); + it('upgrades a legacy filename-only profile that resolves to the target', () => { + const target = mod('pak01_dir.vpk'); + const updated = retargetLocalReplacementProfiles([profile([{ fileName: target.fileName, enabled: false, priority: 5 }])], + [target], () => target, target.metaKey, newSha); + expect(updated[0].mods[0]).toMatchObject({ sha256: newSha, enabled: false, priority: 5 }); + }); +}); diff --git a/electron/main/services/localReplacementProfiles.ts b/electron/main/services/localReplacementProfiles.ts new file mode 100644 index 00000000..b1768ab3 --- /dev/null +++ b/electron/main/services/localReplacementProfiles.ts @@ -0,0 +1,18 @@ +import type { Profile } from '../../../src/types/electron'; +import type { Mod } from '../../../src/types/mod'; +import { buildProfileModResolver, type MetaLookup } from './profileResolver'; + +/** Follow the profile resolver's pre-replacement ownership, including identical twins. */ +export function retargetLocalReplacementProfiles( + profiles: Profile[], mods: Mod[], getMetadata: MetaLookup, targetMetaKey: string, newSha: string, +): Profile[] { + return profiles.map((profile) => { + const resolve = buildProfileModResolver(mods, getMetadata); + return { + ...profile, + mods: profile.mods.map((entry) => resolve(entry).mod?.metaKey === targetMetaKey + ? { ...entry, sha256: newSha } + : entry), + }; + }); +} diff --git a/electron/main/services/localVpkReplacement.test.ts b/electron/main/services/localVpkReplacement.test.ts new file mode 100644 index 00000000..c667f84a --- /dev/null +++ b/electron/main/services/localVpkReplacement.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { replaceLocalVpkFile, validateReplacementVpk, replacementFileSha256, assertReplacementFileUnchanged } from './localVpkReplacement'; + +const directories: string[] = []; +async function fixture() { + const directory = await fs.mkdtemp(join(tmpdir(), 'grimoire-replacement-test-')); + directories.push(directory); + const source = join(directory, 'new.vpk'); + const destination = join(directory, 'pak01_dir.vpk'); + await fs.writeFile(source, vpk()); + await fs.writeFile(destination, 'original'); + return { directory, source, destination }; +} + +function vpk(options: { version?: number; archive?: number; length?: number; terminator?: number; payload?: string; name?: string } = {}) { + const payload = Buffer.from(options.payload ?? 'new'); + const entry = Buffer.alloc(18); + entry.writeUInt16LE(options.archive ?? 0x7fff, 6); + entry.writeUInt32LE(options.length ?? payload.length, 12); + entry.writeUInt16LE(options.terminator ?? 0xffff, 16); + const tree = Buffer.concat([Buffer.from(`txt\0 \0${options.name ?? 'asset'}\0`), entry, Buffer.from([0, 0, 0])]); + const version = options.version ?? 2; + const header = Buffer.alloc(version === 2 ? 28 : 12); + header.writeUInt32LE(0x55aa1234); + header.writeUInt32LE(version, 4); + header.writeUInt32LE(tree.length, 8); + if (version === 2) header.writeUInt32LE(payload.length, 12); + return Buffer.concat([header, tree, payload]); +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('standalone replacement validation', () => { + it.each([1, 2])('accepts a complete inline v%s VPK', async (version) => { + const { source } = await fixture(); + await fs.writeFile(source, vpk({ version })); + await expect(validateReplacementVpk(source)).resolves.toBeUndefined(); + }); + it.each([ + ['unsupported version', vpk({ version: 9 })], + ['external chunks', vpk({ archive: 0 })], + ['missing payload', vpk({ length: 100 })], + ['invalid entry terminator', vpk({ terminator: 0 })], + ['truncated file', vpk().subarray(0, 42)], + ['HTML response', Buffer.from('not a VPK')], + ])('rejects %s', async (_name, bytes) => { + const { source } = await fixture(); + await fs.writeFile(source, bytes); + await expect(validateReplacementVpk(source)).rejects.toThrow(); + }); +}); + +describe('replacement transaction', () => { + it('detects changed physical bytes even when the embedded original hash is unchanged', async () => { + const { destination } = await fixture(); + const originalHash = 'a'.repeat(64); + const payload = `AddonInfo { "originalSha256" "${originalHash}" "note" "old" }`; + await fs.writeFile(destination, vpk({ name: 'addoninfo', payload })); + const reviewedHash = await replacementFileSha256(destination); + await fs.writeFile(destination, vpk({ name: 'addoninfo', payload: payload.replace('old', 'new') })); + await expect(assertReplacementFileUnchanged(destination, reviewedHash)).rejects.toThrow('changed'); + }); + it('rechecks game state after source preparation and leaves the original in place', async () => { + const { source, destination } = await fixture(); + let gameRunning = false; + const commit = vi.fn(); + const rollback = vi.fn(); + await expect(replaceLocalVpkFile(source, destination, { + validate: async (staged) => { await validateReplacementVpk(staged); gameRunning = true; }, + beforeSwap: async () => { if (gameRunning) throw new Error('Game is running'); }, + commit, + rollback, + })).rejects.toThrow('Game is running'); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + expect(commit).not.toHaveBeenCalled(); + expect(rollback).not.toHaveBeenCalled(); + }); + it('rechecks reviewed bytes after staging instead of overwriting an externally changed target', async () => { + const { source, destination } = await fixture(); + const reviewedHash = await replacementFileSha256(destination); + await expect(replaceLocalVpkFile(source, destination, { + validate: async (staged) => { await validateReplacementVpk(staged); await fs.writeFile(destination, 'external edit'); }, + beforeSwap: () => assertReplacementFileUnchanged(destination, reviewedHash), + commit: vi.fn(), rollback: vi.fn(), + })).rejects.toThrow('changed'); + expect(await fs.readFile(destination, 'utf8')).toBe('external edit'); + }); + it('commits one VPK in the existing slot and cleans staging files', async () => { + const { source, destination, directory } = await fixture(); + const commit = vi.fn(); + const rollback = vi.fn(); + await replaceLocalVpkFile(source, destination, { validate: validateReplacementVpk, commit, rollback }); + expect(await fs.readFile(destination)).toEqual(vpk()); + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect((await fs.readdir(directory)).sort()).toEqual(['new.vpk', 'pak01_dir.vpk']); + }); + it('restores old bytes and metadata when profile or metadata commit fails', async () => { + const { source, destination } = await fixture(); + let metadata = 'old'; + await expect(replaceLocalVpkFile(source, destination, { + validate: validateReplacementVpk, + commit: () => { metadata = 'new'; throw new Error('profile save denied'); }, + rollback: () => { metadata = 'old'; }, + })).rejects.toThrow('profile save denied'); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + expect(metadata).toBe('old'); + }); + it('never touches the original when validation or source copy fails', async () => { + const { source, destination } = await fixture(); + const commit = vi.fn(); + const rollback = vi.fn(); + await fs.writeFile(source, 'invalid'); + await expect(replaceLocalVpkFile(source, destination, { validate: validateReplacementVpk, commit, rollback })).rejects.toThrow(); + await expect(replaceLocalVpkFile(`${source}.missing`, destination, { validate: validateReplacementVpk, commit, rollback })).rejects.toThrow(); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + expect(commit).not.toHaveBeenCalled(); + expect(rollback).not.toHaveBeenCalled(); + }); + it('can replace from the same source path because staging precedes the swap', async () => { + const { source } = await fixture(); + await replaceLocalVpkFile(source, source, { validate: validateReplacementVpk, commit: () => {}, rollback: () => {} }); + expect(await fs.readFile(source)).toEqual(vpk()); + }); +}); diff --git a/electron/main/services/localVpkReplacement.ts b/electron/main/services/localVpkReplacement.ts new file mode 100644 index 00000000..d982689b --- /dev/null +++ b/electron/main/services/localVpkReplacement.ts @@ -0,0 +1,122 @@ +import { promises as fs, createReadStream } from 'fs'; +import { createHash } from 'crypto'; +import { dirname, join } from 'path'; + +export async function replacementFileSha256(path: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +export async function assertReplacementFileUnchanged(path: string, expectedFileSha256: string): Promise { + if (!/^[0-9a-f]{64}$/i.test(expectedFileSha256) || + (await replacementFileSha256(path)) !== expectedFileSha256.toLowerCase()) { + throw new Error('The installed VPK changed. Select it again before replacing it'); + } +} + +/** Strict standalone-file validation before replacing a known-good install. */ +export async function validateReplacementVpk(path: string): Promise { + const file = await fs.open(path, 'r'); + try { + const stat = await file.stat(); + if (!stat.isFile()) throw new Error('Select a VPK file'); + const header = Buffer.alloc(28); + const { bytesRead } = await file.read(header, 0, 28, 0); + const version = header.readUInt32LE(4); + const headerSize = version === 2 ? 28 : 12; + const treeSize = header.readUInt32LE(8); + if (bytesRead < headerSize || header.readUInt32LE(0) !== 0x55aa1234 || + (version !== 1 && version !== 2) || treeSize < 1 || treeSize > 64 * 1024 * 1024 || + headerSize + treeSize > stat.size) throw new Error('The replacement is not a valid VPK'); + const dataSize = version === 2 ? header.readUInt32LE(12) : stat.size - headerSize - treeSize; + const declaredSize = headerSize + treeSize + dataSize + (version === 2 + ? header.readUInt32LE(16) + header.readUInt32LE(20) + header.readUInt32LE(24) : 0); + if (declaredSize > stat.size) throw new Error('The replacement VPK is truncated'); + const tree = Buffer.alloc(treeSize); + if ((await file.read(tree, 0, treeSize, headerSize)).bytesRead !== treeSize) { + throw new Error('The replacement VPK is truncated'); + } + let offset = 0; + let entries = 0; + const readString = (): string => { + const end = tree.indexOf(0, offset); + if (end < 0) throw new Error('The replacement VPK has an incomplete directory'); + const value = tree.toString('utf8', offset, end); + offset = end + 1; + return value; + }; + while (readString()) { + while (readString()) { + while (readString()) { + if (offset + 18 > tree.length) throw new Error('The replacement VPK has an incomplete entry'); + const preload = tree.readUInt16LE(offset + 4); + const archive = tree.readUInt16LE(offset + 6); + const start = tree.readUInt32LE(offset + 8); + const length = tree.readUInt32LE(offset + 12); + if (tree.readUInt16LE(offset + 16) !== 0xffff || offset + 18 + preload > tree.length) { + throw new Error('The replacement VPK has an invalid entry'); + } + if (length > 0 && archive !== 0x7fff) { + throw new Error('Replacement supports standalone VPKs only; this file needs separate archive chunks'); + } + if (length > 0 && start + length > dataSize) throw new Error('The replacement VPK is missing file data'); + offset += 18 + preload; + entries++; + } + } + } + if (!entries || tree.subarray(offset).some((byte) => byte !== 0)) { + throw new Error('The replacement VPK has an invalid or empty directory'); + } + } finally { + await file.close(); + } +} + +/** Caller holds the mod mutation lock. The original survives every failed commit. */ +export async function replaceLocalVpkFile( + source: string, + destination: string, + operations: { + validate: (staged: string) => Promise; + beforeSwap?: () => Promise; + commit: () => void; + rollback: () => void; + }, +): Promise { + const temporary = await fs.mkdtemp(join(dirname(destination), '.grimoire-replace-')); + const staged = join(temporary, 'replacement.tmp'); + const backup = join(temporary, 'original.backup'); + let movedOriginal = false; + let committed = false; + let preserveBackup = false; + try { + await fs.copyFile(source, staged); + await operations.validate(staged); + await operations.beforeSwap?.(); + await fs.rename(destination, backup); + movedOriginal = true; + await fs.rename(staged, destination); + operations.commit(); + committed = true; + } catch (error) { + if (movedOriginal) { + const failures: string[] = []; + try { await fs.rename(backup, destination); } + catch (restoreError) { failures.push(String(restoreError)); preserveBackup = true; } + try { operations.rollback(); } + catch (restoreError) { failures.push(String(restoreError)); preserveBackup = true; } + if (failures.length) { + throw new Error(`${String(error)}. Recovery incomplete; check ${destination} and recovery folder ${temporary}. ${failures.join('; ')}`); + } + } + throw error; + } finally { + if (!preserveBackup) { + await fs.rm(temporary, { recursive: true, force: true }).catch((error) => { + console.warn(`[mods] Replacement ${committed ? 'completed' : 'cancelled'}; temporary cleanup failed:`, error); + }); + } + } +} diff --git a/electron/main/services/profiles.ts b/electron/main/services/profiles.ts index 843c56a8..5c93a9a7 100644 --- a/electron/main/services/profiles.ts +++ b/electron/main/services/profiles.ts @@ -78,7 +78,7 @@ export function loadProfiles(): Profile[] { * Save profiles to disk atomically (P1 fix #8, #10) * Uses write-to-temp-then-rename pattern to prevent corruption on crash */ -function saveProfiles(profiles: Profile[]): void { +export function saveProfiles(profiles: Profile[]): void { const path = getProfilesPath(); const tempPath = `${path}.tmp`; const dir = dirname(path); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b977552f..1d92ea30 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -270,6 +270,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('swap-mod-priority', modIdA, modIdB), importCustomMods: (args: ImportCustomModsBatchArgs) => ipcRenderer.invoke('import-custom-mods', args), + prepareLocalVpkReplacement: (metaKey: string) => ipcRenderer.invoke('prepare-local-vpk-replacement', metaKey), onImportCustomModsProgress: (callback: (progress: ImportCustomModsProgress) => void) => { const handler = (_event: Electron.IpcRendererEvent, progress: ImportCustomModsProgress) => callback(progress); diff --git a/src/components/ImportCustomModsModal.replacement.test.tsx b/src/components/ImportCustomModsModal.replacement.test.tsx new file mode 100644 index 00000000..81e9a98a --- /dev/null +++ b/src/components/ImportCustomModsModal.replacement.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mod } from '../types/mod'; +import type { ImportCustomModArgs, ImportCustomModResult } from '../lib/api'; +import ImportCustomModsModal from './ImportCustomModsModal'; + +const mocks = vi.hoisted(() => ({ + mods: [] as Mod[], + prepare: vi.fn(), + pick: vi.fn(), + pickMulti: vi.fn(), +})); +vi.mock('../stores/appStore', () => ({ useAppStore: (selector: (state: { mods: Mod[] }) => unknown) => selector({ mods: mocks.mods }) })); +vi.mock('../lib/api', () => ({ + prepareLocalVpkReplacement: mocks.prepare, + showOpenDialog: mocks.pick, + showOpenDialogMulti: mocks.pickMulti, + onImportCustomModsProgress: () => () => {}, + peekImprint: async () => null, + readImageDataUrl: vi.fn(), +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, values?: Record) => String(values?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(values?.[name] ?? '')) }), + Trans: () => Drop files, +})); +vi.mock('./common/Modal', () => ({ Modal: ({ children }: { children: ReactNode }) =>
{children}
})); +vi.mock('./common/ui', () => ({ + Button: ({ children, disabled, onClick }: { children: ReactNode; disabled?: boolean; onClick?: () => void }) => , + IconButton: ({ label, disabled, onClick }: { label: string; disabled?: boolean; onClick?: () => void }) => , + ModalHeader: ({ title }: { title: ReactNode }) =>

{title}

, + Tag: ({ children }: { children: ReactNode }) => {children}, + CheckboxMark: () => null, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const target: Mod = { + id: 'a', name: 'Blue Ivy', metaKey: 'pak01_dir.vpk', fileName: 'pak01_dir.vpk', + path: '/addons/pak01_dir.vpk', enabled: true, priority: 1, size: 100, + installedAt: '', sha256: 'canonical-a', +}; +const fingerprint = (mod: Mod, physical: string) => ({ metaKey: mod.metaKey, expectedSha256: mod.sha256!, expectedFileSha256: physical }); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe('local replacement review', () => { + let host: HTMLDivElement; + let root: Root; + let onImport: ReturnType Promise>>; + beforeEach(() => { + vi.clearAllMocks(); + mocks.mods = [target]; + mocks.pick.mockResolvedValue('/download/blue_ivy.vpk'); + mocks.pickMulti.mockResolvedValue(['/download/blue_ivy.vpk']); + window.electronAPI = { platform: 'win32' } as Window['electronAPI']; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + onImport = vi.fn<(items: ImportCustomModArgs[]) => Promise>().mockResolvedValue([{ vpkPath: '/download/blue_ivy.vpk', ok: true, imported: 1 }]); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + const footer = () => host.querySelectorAll('button')[host.querySelectorAll('button').length - 1]; + async function render(explicit = false) { + await act(async () => root.render()); + await act(async () => (host.querySelector('[role="button"]') as HTMLElement).click()); + } + async function choose(metaKey: string) { + await act(async () => { + const select = host.querySelector('select')!; + select.value = metaKey; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + it('requires preparation and submits the physical fingerprint reviewed when the explicit dialog opened', async () => { + const preparation = deferred>(); + mocks.prepare.mockReturnValue(preparation.promise); + await render(true); + expect(footer().disabled).toBe(true); + expect(host.textContent).toContain('Checking the installed VPK'); + await act(async () => preparation.resolve(fingerprint(target, 'physical-original'))); + expect(footer().disabled).toBe(false); + await act(async () => footer().click()); + expect(onImport.mock.calls[0][0][0].replacement).toEqual(fingerprint(target, 'physical-original')); + expect(mocks.prepare).toHaveBeenCalledTimes(1); + }); + + it('keeps name collisions as separate imports until the user chooses replacement', async () => { + await render(); + expect(host.querySelector('select')!.value).toBe(''); + expect(mocks.prepare).not.toHaveBeenCalled(); + await act(async () => footer().click()); + expect(onImport.mock.calls[0][0][0].replacement).toBeUndefined(); + }); + + it('discards an older preparation response after a different target is selected', async () => { + const other = { ...target, id: 'b', metaKey: 'pak02_dir.vpk', sha256: 'canonical-b' }; + mocks.mods = [target, other]; + const first = deferred>(); + const second = deferred>(); + mocks.prepare.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + await render(); + await choose(target.metaKey); + await choose(other.metaKey); + expect(footer().disabled).toBe(true); + await act(async () => second.resolve(fingerprint(other, 'physical-b'))); + await act(async () => first.resolve(fingerprint(target, 'physical-a'))); + await act(async () => footer().click()); + expect(onImport.mock.calls[0][0][0].replacement).toEqual(fingerprint(other, 'physical-b')); + }); + + it('shows preparation failure and never silently falls back to adding a duplicate', async () => { + mocks.prepare.mockRejectedValue(new Error('Installed VPK changed')); + await render(); + await choose(target.metaKey); + expect(host.textContent).toContain('Installed VPK changed'); + expect(footer().disabled).toBe(true); + expect(host.querySelector('select')!.value).toBe(target.metaKey); + expect(onImport).not.toHaveBeenCalled(); + await choose(''); + expect(footer().disabled).toBe(false); + }); +}); diff --git a/src/components/ImportCustomModsModal.tsx b/src/components/ImportCustomModsModal.tsx index c8b5a0b8..6069bc69 100644 --- a/src/components/ImportCustomModsModal.tsx +++ b/src/components/ImportCustomModsModal.tsx @@ -16,9 +16,13 @@ import { import { Modal } from './common/Modal'; import { Button, CheckboxMark, IconButton, ModalHeader, Tag } from './common/ui'; import { Input } from './common/forms'; +import type { Mod } from '../types/mod'; +import { useAppStore } from '../stores/appStore'; +import { localReplacementCandidates, replacementTargetIsCurrent } from '../lib/localModReplacement'; import { onImportCustomModsProgress, peekImprint, + prepareLocalVpkReplacement, readImageDataUrl, showOpenDialog, showOpenDialogMulti, @@ -54,6 +58,10 @@ interface ImportRow { recognized: PeekImprintResult | null; status: RowStatus; imported: number; + replacement?: Mod; + replacementFingerprint?: NonNullable; + preparingReplacement?: boolean; + replacementError?: string; error?: string; } @@ -71,6 +79,7 @@ interface ImportCustomModsModalProps { * the group when the target is still a standalone mod), which keeps uuid * minting out of this dialog. */ addToGroup?: { modName: string }; + replacementTarget?: Mod; } const newRow = (path: string): ImportRow => ({ @@ -113,9 +122,11 @@ export default function ImportCustomModsModal({ onImport, onFinished, addToGroup, + replacementTarget, }: ImportCustomModsModalProps) { const { t } = useTranslation(); const platform = window.electronAPI.platform; + const installedMods = useAppStore((state) => state.mods); const [rows, setRows] = useState([]); const [dragActive, setDragActive] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -127,11 +138,53 @@ export default function ImportCustomModsModal({ const [variantGroupId, setVariantGroupId] = useState(null); // Row paths in submit order, so a progress event's index maps back to a row. const submittedPathsRef = useRef([]); + const replacementRequestsRef = useRef(new Map()); + const nextReplacementRequestRef = useRef(0); + const [explicitFingerprint, setExplicitFingerprint] = useState | null>(null); + const [explicitPreparationError, setExplicitPreparationError] = useState(null); + const [explicitReviewTarget] = useState(replacementTarget); + const staleTargetMessageRef = useRef(t('installed.replace.stale', { defaultValue: 'An installed file changed or was removed. Close this dialog and select the target again.' })); + + useEffect(() => { + if (!explicitReviewTarget) return; + let cancelled = false; + void prepareLocalVpkReplacement(explicitReviewTarget.metaKey).then((fingerprint) => { + if (cancelled) return; + if (fingerprint.expectedSha256 !== explicitReviewTarget.sha256) { + throw new Error(staleTargetMessageRef.current); + } + setExplicitFingerprint(fingerprint); + }).catch((reason: unknown) => { + if (!cancelled) setExplicitPreparationError(String(reason)); + }); + return () => { cancelled = true; }; + }, [explicitReviewTarget]); const patchRow = useCallback((path: string, patch: Partial) => { setRows((prev) => prev.map((row) => (row.path === path ? { ...row, ...patch } : row))); }, []); + const selectReplacement = async (path: string, target: Mod | undefined) => { + const request = ++nextReplacementRequestRef.current; + replacementRequestsRef.current.set(path, request); + patchRow(path, { + replacement: target, replacementFingerprint: undefined, + preparingReplacement: !!target, replacementError: undefined, + }); + if (!target) return; + try { + const fingerprint = await prepareLocalVpkReplacement(target.metaKey); + if (replacementRequestsRef.current.get(path) !== request) return; + if (fingerprint.expectedSha256 !== target.sha256) { + throw new Error(t('installed.replace.stale', { defaultValue: 'An installed file changed or was removed. Close this dialog and select the target again.' })); + } + patchRow(path, { replacementFingerprint: fingerprint, preparingReplacement: false }); + } catch (reason) { + if (replacementRequestsRef.current.get(path) !== request) return; + patchRow(path, { preparingReplacement: false, replacementError: String(reason) }); + } + }; + // Progress is streamed while the batch runs so a long copy shows movement // instead of one frozen spinner. Subscribed for the modal's whole life: the // main process only emits during our own invoke. @@ -193,6 +246,14 @@ export default function ImportCustomModsModal({ const addPaths = useCallback( (paths: string[]) => { if (paths.length === 0) return; + if (replacementTarget) { + if (paths.length !== 1 || !/\.vpk$/i.test(paths[0])) { + setError(t('installed.replace.singleVpk', { defaultValue: 'Choose one .vpk file to replace this mod. Archives cannot be used for replacement.' })); + return; + } + setRows([{ ...newRow(paths[0]), replacement: replacementTarget }]); + return; + } setRows((prev) => { const seen = new Set(prev.map((row) => pathDedupeKey(row.path, platform))); const fresh: ImportRow[] = []; @@ -205,11 +266,20 @@ export default function ImportCustomModsModal({ return fresh.length > 0 ? [...prev, ...fresh] : prev; }); }, - [platform] + [platform, replacementTarget, t] ); const pickFiles = async () => { if (submitting) return; + if (replacementTarget) { + const picked = await showOpenDialog({ + title: t('installed.replace.chooseFile', { defaultValue: 'Choose replacement VPK' }), + filters: [{ name: 'VPK', extensions: ['vpk'] }], + }); + setError(null); + if (picked) addPaths([picked]); + return; + } const picked = await showOpenDialogMulti({ title: t('installed.batchImport.selectFiles'), filters: [{ name: 'VPK or archive', extensions: VPK_IMPORT_EXTS }], @@ -248,7 +318,7 @@ export default function ImportCustomModsModal({ }; const pickThumbnail = async (row: ImportRow) => { - if (submitting) return; + if (submitting || row.replacement) return; const picked = await showOpenDialog({ title: t('installed.imageField.selectImage'), filters: [{ name: 'Images', extensions: IMAGE_EXTS }], @@ -267,7 +337,7 @@ export default function ImportCustomModsModal({ const dropThumbnail = async (row: ImportRow, e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - if (submitting) return; + if (submitting || row.replacement) return; const file = e.dataTransfer.files?.[0]; if (!file) return; const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; @@ -292,6 +362,8 @@ export default function ImportCustomModsModal({ const allNsfw = rows.length > 0 && rows.every((row) => row.nsfw); const toggleVariantImport = () => { if (submitting || addToGroup) return; + replacementRequestsRef.current.clear(); + setRows((prev) => prev.map((row) => ({ ...row, replacement: undefined, replacementFingerprint: undefined, preparingReplacement: false, replacementError: undefined }))); setGroupAsVariants((enabled) => { const next = !enabled; if (next && !variantGroupName.trim()) { @@ -301,6 +373,7 @@ export default function ImportCustomModsModal({ }); }; const removeRow = (path: string) => { + replacementRequestsRef.current.delete(path); const remaining = rows.filter((row) => row.path !== path); setRows(remaining); // Before anything has landed, one source does not need an explicit batch @@ -309,6 +382,7 @@ export default function ImportCustomModsModal({ if (remaining.length < 2 && !variantGroupId) setGroupAsVariants(false); }; const clearRows = () => { + replacementRequestsRef.current.clear(); setRows([]); setError(null); if (!variantGroupId) setGroupAsVariants(false); @@ -320,8 +394,12 @@ export default function ImportCustomModsModal({ ? 0 : groupAsVariants ? (variantGroupName.trim().length === 0 ? 1 : 0) - : rows.filter((row) => row.name.trim().length === 0).length; - const canSubmit = rows.length > 0 && unnamedCount === 0 && !submitting; + : rows.filter((row) => !row.replacement && row.name.trim().length === 0).length; + const replacementRows = rows.filter((row) => row.replacement); + const staleReplacement = replacementRows.some((row) => !replacementTargetIsCurrent(row.replacement!, installedMods)); + const duplicateReplacement = new Set(replacementRows.map((row) => row.replacement!.metaKey)).size !== replacementRows.length; + const unpreparedReplacement = replacementRows.some((row) => replacementTarget ? !explicitFingerprint : !row.replacementFingerprint); + const canSubmit = rows.length > 0 && unnamedCount === 0 && !submitting && !staleReplacement && !duplicateReplacement && !unpreparedReplacement; const handleSubmit = async () => { if (!canSubmit) return; @@ -335,7 +413,7 @@ export default function ImportCustomModsModal({ const results = await onImport( batch.map((row) => ({ vpkPath: row.path, - name: addToGroup + name: row.replacement ? row.replacement.name : addToGroup ? addToGroup.modName : groupAsVariants ? variantGroupName.trim() @@ -343,6 +421,7 @@ export default function ImportCustomModsModal({ variantLabel: usesSharedName ? row.variantLabel?.trim() || undefined : undefined, thumbnailDataUrl: row.thumbnailDataUrl || undefined, nsfw: row.nsfw, + replacement: row.replacement ? (replacementTarget ? explicitFingerprint! : row.replacementFingerprint!) : undefined, localGroupId: groupAsVariants ? variantGroupId ?? undefined : undefined, localGroupBatchKey: groupAsVariants && !variantGroupId ? 'import-modal-variant-group' : undefined, @@ -408,7 +487,9 @@ export default function ImportCustomModsModal({ >

- {addToGroup + {replacementTarget + ? t('installed.replace.help', { defaultValue: 'Choose one VPK, review the installed file below, then replace it. Its name, thumbnail, enabled state, load order, Global status, variant group and saved profiles are preserved.' }) + : addToGroup ? t('installed.batchImport.addVariantsHelp', { name: addToGroup.modName }) : t('installed.batchImport.help')}

@@ -444,7 +527,9 @@ export default function ImportCustomModsModal({
{ if (e.key === 'Enter' || e.key === ' ') { @@ -460,10 +545,10 @@ export default function ImportCustomModsModal({ > - }} - /> + />} {t('installed.import.orClickToBrowse')}
@@ -471,13 +556,15 @@ export default function ImportCustomModsModal({ <>
- {usesSharedName + {replacementTarget + ? t('installed.replace.review', { defaultValue: 'Review the source and installed file before replacing.' }) + : usesSharedName ? t('installed.batchImport.variantNamesHint') : t('installed.batchImport.namesHint')}
{rows.length > 1 && (
- {!addToGroup && (rows.length > 1 || groupAsVariants) && ( + {!addToGroup && !replacementTarget && (rows.length > 1 || groupAsVariants) && (
+ {onReplaceVariant && canReplaceLocalMod(v) && ( + + )} {onDetachVariant && (