diff --git a/electron/main/ipc/mods.ts b/electron/main/ipc/mods.ts index 9a8e2ccc..80d07758 100644 --- a/electron/main/ipc/mods.ts +++ b/electron/main/ipc/mods.ts @@ -1,5 +1,6 @@ import { ipcMain, shell } from 'electron'; import { modSafetySnapshot } from '../services/modSafety'; +import { checkOutdatedVdata, outdatedVdataSnapshot } from '../services/vdataCheck'; import { importDisabledVpk } from '../services/importDisabledVpk'; import { randomUUID } from 'node:crypto'; import { promises as fs, existsSync } from 'fs'; @@ -231,6 +232,7 @@ function enrichMod(mod: Mod): WireMod { return { ...mod, safety: modSafetySnapshot(mod.path), + outdatedVdata: outdatedVdataSnapshot(mod.path), // Use the stored mod name from GameBanana if available name: metadata.modName || mod.name, thumbnailUrl: metadata.thumbnailUrl, @@ -268,7 +270,7 @@ function enrichMod(mod: Mod): WireMod { // No metadata row (a VPK dropped straight into addons): still file-tree tag // the hero so unknown skins get their Locker chip like downloaded mods. const { lockerHero, lockerHeroSource } = resolveUnknownLockerHero(mod, metadata, isUnknown, globalType); - return { ...mod, safety: modSafetySnapshot(mod.path), isUnknown, globalType: globalType ?? undefined, lockerHero, lockerHeroSource }; + return { ...mod, safety: modSafetySnapshot(mod.path), outdatedVdata: outdatedVdataSnapshot(mod.path), isUnknown, globalType: globalType ?? undefined, lockerHero, lockerHeroSource }; } /** @@ -368,6 +370,7 @@ ipcMain.handle('get-mods', async (): Promise => { await parseVpkDirectoriesAsync(warmPaths); } const enriched = visible.map(enrichMod); + void checkOutdatedVdata(deadlockPath, visible.map((m) => m.path)); if (settings.verboseModTrace) { const hidden = mods.length - visible.length; // The renderer (Installed.tsx visibleMods) also hides disabled source diff --git a/electron/main/services/launch.ts b/electron/main/services/launch.ts index ddc0410e..5b4be12e 100644 --- a/electron/main/services/launch.ts +++ b/electron/main/services/launch.ts @@ -3,6 +3,7 @@ import { join, basename, dirname } from 'path'; import { spawn } from 'child_process'; import { shell } from 'electron'; import { inspectVpkSafety, isModSafetyTrusted, moveSafetySnapshot } from './modSafety'; +import { moveVdataSnapshot } from './vdataCheck'; import { assertActiveModsSafety, moveToDisabledLibrary } from './modSafetyAudit'; import { runExclusiveModMutation } from './mods'; import { migrateModMetadata } from './metadata'; @@ -294,6 +295,7 @@ export async function stashEnabledMods(deadlockPath: string): Promise { throw err; } moveSafetySnapshot(from, to); + moveVdataSnapshot(from, to); } /** @@ -142,6 +144,7 @@ async function deleteVpkSet(path: string): Promise { const chunks = findChunkSiblingNames(basename(path), await fs.readdir(dirname(path))); await fs.unlink(path); forgetSafetySnapshot(path); + forgetVdataSnapshot(path); for (const chunk of chunks) await fs.unlink(join(dirname(path), chunk)); } diff --git a/electron/main/services/vdataCheck.test.ts b/electron/main/services/vdataCheck.test.ts new file mode 100644 index 00000000..aee0c641 --- /dev/null +++ b/electron/main/services/vdataCheck.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const h = vi.hoisted(() => ({ send: vi.fn(), run: vi.fn(), trees: new Map() })); +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [{ webContents: { send: h.send } }] } })); +vi.mock('./modMerger', () => ({ runVpkmergeStdout: h.run })); +vi.mock('./vpk', () => ({ + parseVpkDirectoriesAsync: async (paths: string[]) => new Map(paths.map((p) => [p, h.trees.get(p) ?? []])), +})); +vi.mock('./deadlock', () => ({ getCitadelPath: (deadlockPath: string) => deadlockPath })); + +const HEROES = 'scripts/heroes.vdata_c'; + +let dir: string; +let vdata: typeof import('./vdataCheck'); + +async function vpk(name: string, entries: string[], bytes = 'vpk'): Promise { + const path = join(dir, 'addons', name); + await fs.writeFile(path, bytes); + h.trees.set(path, entries); + return path; +} + +function report(missing: string[], extra: string[] = []) { + return { entry: HEROES, status: 'outdated', missing, extra, changed: [] }; +} + +/** Answers every spawn with these reports per mod path (none for unlisted mods). */ +function respond(reports: Record[]>) { + h.run.mockImplementation(async (args: string[]) => + JSON.stringify(args.slice(4).map((mod) => ({ mod, reports: reports[mod] ?? [] }))) + ); +} + +beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'vdata-check-')); + await fs.mkdir(join(dir, 'addons')); + await fs.writeFile(join(dir, 'pak01_dir.vpk'), 'game'); + h.send.mockReset(); + h.run.mockReset(); + h.trees.clear(); + vi.resetModules(); + vdata = await import('./vdataCheck'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe('checkOutdatedVdata', () => { + it('never spawns vpkmerge for a library without vdata', async () => { + const skin = await vpk('pak01_dir.vpk', ['models/heroes/skin.vmdl_c']); + await vdata.checkOutdatedVdata(dir, [skin]); + expect(h.run).not.toHaveBeenCalled(); + expect(h.send).not.toHaveBeenCalled(); + }); + + it('flags a mod that deletes game fields, once', async () => { + const stale = await vpk('pak01_dir.vpk', [HEROES]); + const current = await vpk('pak02_dir.vpk', [HEROES, 'panorama/image_compiler.vdata_c']); + respond({ [stale]: [report(['hero_ratking', 'hero_base/m_vecStats[=EMaxHealth]'])] }); + + await vdata.checkOutdatedVdata(dir, [stale, current]); + expect(vdata.outdatedVdataSnapshot(stale)).toEqual([ + { entry: HEROES, missing: 2, sample: ['hero_ratking', 'hero_base/m_vecStats[=EMaxHealth]'] }, + ]); + expect(vdata.outdatedVdataSnapshot(current)).toBeUndefined(); + expect(h.send).toHaveBeenCalledTimes(1); + + await vdata.checkOutdatedVdata(dir, [stale, current]); + expect(h.run).toHaveBeenCalledTimes(1); + expect(h.send).toHaveBeenCalledTimes(1); + }); + + it('does not flag a mod that only has fields the game dropped', async () => { + const mod = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [mod]: [report([], ['hero_base/m_bOld'])] }); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(vdata.outdatedVdataSnapshot(mod)).toBeUndefined(); + expect(h.send).not.toHaveBeenCalled(); + }); + + it('keeps other results when one mod fails to open', async () => { + const broken = await vpk('pak01_dir.vpk', [HEROES]); + const stale = await vpk('pak02_dir.vpk', [HEROES]); + h.run.mockResolvedValue(JSON.stringify([ + { mod: stale, reports: [report(['hero_ratking'])] }, + { mod: broken, error: 'opening mod: Failed to read VPK header' }, + ])); + await vdata.checkOutdatedVdata(dir, [broken, stale]); + expect(vdata.outdatedVdataSnapshot(stale)?.[0].missing).toBe(1); + expect(vdata.outdatedVdataSnapshot(broken)).toBeUndefined(); + }); + + it('does not respawn after a failed run until the file changes', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mod = await vpk('pak01_dir.vpk', [HEROES]); + h.run.mockRejectedValue(new Error("unrecognized subcommand 'vdata-check'")); + await vdata.checkOutdatedVdata(dir, [mod]); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.run).toHaveBeenCalledTimes(1); + + await fs.writeFile(mod, 'updated vpk'); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.run).toHaveBeenCalledTimes(2); + }); + + it('drops a warning as soon as the mod file changes', async () => { + const mod = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [mod]: [report(['hero_ratking'])] }); + await vdata.checkOutdatedVdata(dir, [mod]); + await fs.writeFile(mod, 'rebuilt vpk'); + expect(vdata.outdatedVdataSnapshot(mod)).toBeUndefined(); + }); + + it('keeps the warning through a rename without respawning', async () => { + const from = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [from]: [report(['hero_ratking'])] }); + await vdata.checkOutdatedVdata(dir, [from]); + + const to = join(dir, 'addons', 'pak05_dir.vpk'); + await fs.rename(from, to); + vdata.moveVdataSnapshot(from, to); + h.trees.set(to, [HEROES]); + expect(vdata.outdatedVdataSnapshot(to)?.[0].missing).toBe(1); + + await vdata.checkOutdatedVdata(dir, [to]); + expect(h.run).toHaveBeenCalledTimes(1); + expect(h.send).toHaveBeenCalledTimes(1); + }); + + it('rechecks after a game update and clears a warning that no longer applies', async () => { + const mod = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [mod]: [report(['hero_ratking'])] }); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.send).toHaveBeenCalledTimes(1); + + await fs.writeFile(join(dir, 'pak01_dir.vpk'), 'game after update'); + respond({}); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.run).toHaveBeenCalledTimes(2); + expect(vdata.outdatedVdataSnapshot(mod)).toBeUndefined(); + expect(h.send).toHaveBeenCalledTimes(2); + }); + + it('splits large libraries across spawns', async () => { + const mods = await Promise.all( + Array.from({ length: 60 }, (_, i) => vpk(`pak${String(i).padStart(2, '0')}_dir.vpk`, [HEROES])) + ); + respond({}); + await vdata.checkOutdatedVdata(dir, mods); + expect(h.run).toHaveBeenCalledTimes(2); + expect(h.run.mock.calls.map(([args]) => (args as string[]).length - 4)).toEqual([50, 10]); + }); +}); diff --git a/electron/main/services/vdataCheck.ts b/electron/main/services/vdataCheck.ts new file mode 100644 index 00000000..81bc7872 --- /dev/null +++ b/electron/main/services/vdataCheck.ts @@ -0,0 +1,116 @@ +import { BrowserWindow } from 'electron'; +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { OutdatedVdata } from '../../../src/types/mod'; +import { getCitadelPath } from './deadlock'; +import { runVpkmergeStdout } from './modMerger'; +import { parseVpkDirectoriesAsync } from './vpk'; + +/** + * Flags mods that ship a stale copy of a game `.vdata_c` (`scripts/heroes.vdata_c` + * and friends). The shipped copy replaces the whole file in game, so anything + * Valve added after the mod was built goes missing. Results are keyed on the + * mod's path, size and mtime, follow the file through renames, and are dropped + * wholesale when pak01 changes (a game update). + */ + +const SAMPLE_SIZE = 8; +// Keeps one spawn's argv far below the ~32K character Windows command line cap. +const BATCH_SIZE = 50; + +interface FileStat { size: number; mtimeMs: number } +interface Snapshot extends FileStat { outdated: OutdatedVdata[] } +interface CliResult { + mod: string; + reports?: Array<{ entry: string; missing: string[] }>; +} + +const snapshots = new Map(); +let baseKey = ''; +let queue: Promise = Promise.resolve(); + +export function outdatedVdataSnapshot(path: string): OutdatedVdata[] | undefined { + const entry = snapshots.get(path); + if (!entry?.outdated.length) return undefined; + const stat = statSync(path, { throwIfNoEntry: false }); + return stat?.size === entry.size && stat.mtimeMs === entry.mtimeMs ? entry.outdated : undefined; +} + +export function moveVdataSnapshot(from: string, to: string): void { + const value = snapshots.get(from); + snapshots.delete(from); + if (value) snapshots.set(to, value); + else snapshots.delete(to); +} + +export function forgetVdataSnapshot(path: string): void { snapshots.delete(path); } + +/** Checks mods with no current result in the background. Sends + * `mod-vdata-checked` when that changes what any of their cards show. */ +export function checkOutdatedVdata(deadlockPath: string, modPaths: string[]): Promise { + queue = queue + .then(() => check(deadlockPath, modPaths)) + .catch((err) => console.warn('[vdata-check] Check failed:', err)); + return queue; +} + +function shown(modPaths: string[]): string { + return JSON.stringify(modPaths.map((path) => outdatedVdataSnapshot(path) ?? null)); +} + +async function check(deadlockPath: string, modPaths: string[]): Promise { + const base = join(getCitadelPath(deadlockPath), 'pak01_dir.vpk'); + const baseStat = statSync(base, { throwIfNoEntry: false }); + if (!baseStat) return; + const before = shown(modPaths); + const key = `${base}:${baseStat.size}:${baseStat.mtimeMs}`; + if (key !== baseKey) { + snapshots.clear(); + baseKey = key; + } + + const pending = new Map(); + for (const path of modPaths) { + const stat = statSync(path, { throwIfNoEntry: false }); + if (!stat) continue; + const entry = snapshots.get(path); + if (entry?.size === stat.size && entry.mtimeMs === stat.mtimeMs) continue; + pending.set(path, { size: stat.size, mtimeMs: stat.mtimeMs }); + } + if (pending.size === 0) return; + + const trees = await parseVpkDirectoriesAsync([...pending.keys()]); + const candidates: string[] = []; + for (const [path, stat] of pending) { + if (trees.get(path)?.some((entry) => entry.endsWith('.vdata_c'))) candidates.push(path); + else snapshots.set(path, { ...stat, outdated: [] }); + } + for (let i = 0; i < candidates.length; i += BATCH_SIZE) { + await checkBatch(base, candidates.slice(i, i + BATCH_SIZE), pending); + } + + if (shown(modPaths) !== before) { + for (const win of BrowserWindow.getAllWindows()) win.webContents.send('mod-vdata-checked'); + } +} + +async function checkBatch(base: string, paths: string[], stats: Map): Promise { + let results: CliResult[] = []; + try { + results = JSON.parse(await runVpkmergeStdout(['vdata-check', '--base', base, '--json', ...paths], 60000)); + } catch (err) { + // Includes bundled binaries that predate the subcommand. Marked as checked + // so a failure costs one spawn per file version, not one per scan. + console.warn('[vdata-check] vpkmerge failed:', err); + } + // A mod that failed to open comes back with an `error` and no reports. + // Only fields the mod deletes are flagged: fields it has that the game + // dropped are left alone. + const byMod = new Map(results.map((result) => [result.mod, result])); + for (const path of paths) { + const outdated = (byMod.get(path)?.reports ?? []) + .filter((r) => r.missing.length > 0) + .map((r) => ({ entry: r.entry, missing: r.missing.length, sample: r.missing.slice(0, SAMPLE_SIZE) })); + snapshots.set(path, { ...stats.get(path)!, outdated }); + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 93f9bd83..eb7b1420 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -129,6 +129,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('mod-safety-changed', listener); return () => ipcRenderer.removeListener('mod-safety-changed', listener); }, + onModVdataChecked: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on('mod-vdata-checked', listener); + return () => ipcRenderer.removeListener('mod-vdata-checked', listener); + }, getMods: () => ipcRenderer.invoke('get-mods'), enableMod: (modId: string) => ipcRenderer.invoke('enable-mod', modId), disableMod: (modId: string) => ipcRenderer.invoke('disable-mod', modId), diff --git a/scripts/fetch-vpkmerge.mjs b/scripts/fetch-vpkmerge.mjs index 5aa29483..ce71d560 100644 --- a/scripts/fetch-vpkmerge.mjs +++ b/scripts/fetch-vpkmerge.mjs @@ -16,12 +16,12 @@ import { fileURLToPath } from 'node:url'; import { get as httpsGet } from 'node:https'; import { pipeline } from 'node:stream/promises'; -const VPKMERGE_VERSION = 'v0.19.1'; +const VPKMERGE_VERSION = 'v0.20.0'; const ASSETS = { - 'linux-x64': { name: 'vpkmerge-linux-x86_64', sha256: '22c302e4a9ddf75712b8d65708ac291c9b2016ea828a7f5b4ed96e93b0b24b39' }, - 'darwin-arm64': { name: 'vpkmerge-macos-aarch64', sha256: '94ac1e4078a2bebe7a5445e91882bbdb97c10fcf02b8073b515f6e9e03ba66f9' }, - 'win32-x64': { name: 'vpkmerge-windows-x86_64.exe', sha256: '7358f2c1c1bf769fa3192a163d3fece4cfc52c736cdcf244bf802d213cba5de1' }, + 'linux-x64': { name: 'vpkmerge-linux-x86_64', sha256: '2687f033d55da43c4b7db418404c44688bbe90b19585b2948c78412dff7d3e1d' }, + 'darwin-arm64': { name: 'vpkmerge-macos-aarch64', sha256: '72d75afdafc30aa80dd3c5a4fcafce9efe0fbbb6185d59d250606a6e276e3e6e' }, + 'win32-x64': { name: 'vpkmerge-windows-x86_64.exe', sha256: 'b7a6e400f6bab1e876444bf2dbc411b9fc62b05e3885d2385bcb0aed7b5430cc' }, }; const here = dirname(fileURLToPath(import.meta.url)); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index baf447a2..0c0546d2 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -185,6 +185,13 @@ export default function Layout() { return unsubscribe; }, []); + useEffect( + () => window.electronAPI.onModVdataChecked(() => { + void useAppStore.getState().loadMods({ force: true, silent: true }); + }), + [] + ); + // Surface GameBanana rate limiting app-wide. The heavy "Fix Unknown" // auto-detect is the usual trigger, but any tab can hit it, so the warning // lives here rather than inside one page. diff --git a/src/components/installed/ModCard.tsx b/src/components/installed/ModCard.tsx index acb3b9d8..ff99c3ba 100644 --- a/src/components/installed/ModCard.tsx +++ b/src/components/installed/ModCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader2, Trash2, AlertTriangle, FolderOpen, FilePlus, Files, ImagePlus, Download, Info, Check, Wrench, Layers, Scissors, Share2, Beaker, PowerOff, Tag as TagIcon, Pencil, MoreHorizontal, Link2, Banana, Fingerprint, ExternalLink, Star, ArrowUpToLine, ImageDown, Link, Unlink } from 'lucide-react'; +import { Loader2, Trash2, AlertTriangle, FolderOpen, FilePlus, Files, ImagePlus, Download, Info, Check, Wrench, Layers, Scissors, Share2, Beaker, PowerOff, Tag as TagIcon, Pencil, MoreHorizontal, Link2, Banana, Fingerprint, ExternalLink, Star, ArrowUpToLine, ImageDown, Link, Unlink, FileWarning } from 'lucide-react'; import { MenuContent, MenuItem, MenuLabel, MenuRadioGroup, MenuRadioItem, MenuRoot, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger } from '../common/menu'; import { showToast } from '../../stores/toastStore'; import { revealModInFolder } from '../../lib/api'; @@ -27,6 +27,9 @@ interface ModCardProps { mod: { safety?: Mod['safety']; safetyTarget?: Pick; + outdatedVdata?: Mod['outdatedVdata']; + /** Group cards: whether the flagged variant is the enabled one. Defaults to `enabled`. */ + outdatedVdataEnabled?: boolean; id: string; name: string; fileName: string; @@ -523,6 +526,19 @@ function EditableModTitle({ ); } +function OutdatedVdataTag({ outdated, enabled, variant }: { outdated?: Mod['outdatedVdata']; enabled: boolean; variant: 'overlay' | 'inline' }) { + const { t } = useTranslation(); + if (!outdated?.length) return null; + const title = outdated + .map((v) => t('installed.card.outdatedDataTitle', { count: v.missing, file: v.entry, sample: v.sample.join(', ') })) + .join('\n\n'); + return ( + + {t('installed.card.outdatedData')} + + ); +} + function ModListRowContent({ mod, taxonomy, @@ -630,6 +646,7 @@ function ModListRowContent({ />
+
{!mod.enabled && mod.priorityMod && ( @@ -1391,6 +1408,7 @@ export function ModCard({ {t('installed.card.conflict')} )} + {mod.isUnknown && ( void; +} + +/** Enabled mods whose shipped `.vdata_c` deletes game fields, with a way to turn them off. */ +export function OutdatedVdataModal({ mods, open, onClose }: OutdatedVdataModalProps) { + const { t } = useTranslation(); + const toggleMod = useAppStore((s) => s.toggleMod); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open && mods.length === 0) onClose(); + }, [open, mods.length, onClose]); + + const disable = async (ids: string[]) => { + setBusy(true); + for (const id of ids) await toggleMod(id); + setBusy(false); + }; + + return ( + 0} + title={t('installed.outdatedData.title', { count: mods.length })} + message={t('installed.outdatedData.message')} + confirmLabel={t('installed.outdatedData.disableAll', { count: mods.length })} + cancelLabel={t('common.actions.close')} + busy={busy} + onConfirm={() => void disable(mods.map((m) => m.id))} + onCancel={onClose} + > +
    + {mods.map((mod) => ( +
  • +
    +

    {mod.name}

    + {mod.outdatedVdata?.map((v) => ( +
    +

    {t('installed.outdatedData.removes', { count: v.missing, file: v.entry })}

    +

    + {v.sample.slice(0, SAMPLE_PATHS).join(', ')} +

    +
    + ))} +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9e05f2bf..8fd76b4f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1333,6 +1333,21 @@ "conflictCount_one": "{{count}} conflict", "conflictCount_other": "{{count}} conflicts" }, + "outdatedData": { + "button_one": "{{count}} outdated mod", + "button_other": "{{count}} outdated mods", + "buttonHint": "Enabled mods that ship an old copy of a game data file and remove what Valve added since", + "title_one": "{{count}} enabled mod ships outdated game data", + "title_other": "{{count}} enabled mods ship outdated game data", + "message": "Each of these replaces a whole game data file with an old copy, so anything Valve added after the mod was built is gone in game: new heroes, abilities, stats. Disable them until the author updates.", + "removes_one": "Removes {{count}} game field from {{file}}", + "removes_other": "Removes {{count}} game fields from {{file}}", + "disable": "Disable", + "disableAll_one": "Disable it", + "disableAll_other": "Disable all {{count}}", + "enabledToast_one": "{{name}} ships an old {{file}}. While it is enabled, {{count}} game field Valve added since is removed in game.", + "enabledToast_other": "{{name}} ships an old {{file}}. While it is enabled, {{count}} game fields Valve added since are removed in game." + }, "unknown": { "fixHiddenHint": "Fix unknown is hidden. Right-click to bring it back.", "fixButtonHint": "Find GameBanana matches or add custom metadata for unknown local mods. Right-click to hide this button.", @@ -1533,6 +1548,9 @@ "deselectNamed": "Deselect {{name}}", "selectNamed": "Select {{name}}", "conflict": "Conflict", + "outdatedData": "Outdated data", + "outdatedDataTitle_one": "Ships an old copy of {{file}}. It replaces the game's copy, so {{count}} field added to Deadlock since the mod was built is removed in game: {{sample}}", + "outdatedDataTitle_other": "Ships an old copy of {{file}}. It replaces the game's copy, so {{count}} fields added to Deadlock since the mod was built are removed in game, starting with: {{sample}}", "unknown": "Unknown", "unknownTitle": "This local mod has no saved GameBanana or custom metadata", "updateAvailableTitle": "A newer version is available on GameBanana", diff --git a/src/locales/manifest.json b/src/locales/manifest.json index d6222b97..6f6ccef5 100644 --- a/src/locales/manifest.json +++ b/src/locales/manifest.json @@ -1,24 +1,24 @@ { "sourceLanguage": "en", - "totalKeys": 2523, + "totalKeys": 2539, "languages": [ { "code": "bg", "name": "български", "translatedKeys": 1915, - "pct": 76 + "pct": 75 }, { "code": "en", "name": "English", - "translatedKeys": 2523, + "translatedKeys": 2539, "pct": 100 }, { "code": "fr", "name": "français", "translatedKeys": 2114, - "pct": 84 + "pct": 83 }, { "code": "ru", diff --git a/src/pages/Installed.tsx b/src/pages/Installed.tsx index 4d6fd592..d8a9213c 100644 --- a/src/pages/Installed.tsx +++ b/src/pages/Installed.tsx @@ -50,6 +50,7 @@ import { GripVertical, ClipboardList, Fingerprint, + FileWarning, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { showToast } from '../stores/toastStore'; @@ -134,6 +135,7 @@ import { HeroTagLabel } from '../components/installed/chips'; import { ModCard } from '../components/installed/ModCard'; import { EMPTY_LIST_IDS } from '../components/installed/emptyIds'; import { EditLocalModModal } from '../components/installed/EditLocalModModal'; +import { OutdatedVdataModal } from '../components/installed/OutdatedVdataModal'; import { DeleteModsModal, type DeleteModsTarget } from '../components/installed/DeleteModsModal'; import { MakeCustomModModal } from '../components/installed/MakeCustomModModal'; import type { FoundUnknownMatch } from '../components/installed/unknown/foundMatch'; @@ -433,11 +435,14 @@ const InstalledEntryCard = memo(function InstalledEntryCard({ const safetyTarget = entry.variants.find(v => v.safety?.report.verdict === 'blocked' || v.safety?.report.verdict === 'incomplete') ?? entry.variants.find(v => v.safety?.report.verdict === 'requires-trust' && !v.safety.trusted) ?? entry.variants.find(v => v.safety?.report.verdict === 'requires-trust'); + const outdatedVariant = entry.enabledVariants.find(v => v.outdatedVdata) ?? entry.variants.find(v => v.outdatedVdata); return ( 0, @@ -1213,6 +1218,7 @@ export default function Installed() { // { done, total } so the button can render "Updating 2/5…" and stay disabled // for the duration of the run. const [updateAllConfirmOpen, setUpdateAllConfirmOpen] = useState(false); + const [outdatedVdataOpen, setOutdatedVdataOpen] = useState(false); const [updateAllProgress, setUpdateAllProgress] = useState<{ done: number; total: number } | null>(null); const [updateAllError, setUpdateAllError] = useState(null); // Mods whose file the author deleted with no confident successor, found @@ -3546,6 +3552,7 @@ export default function Installed() { const unknownMods = mods .filter((mod) => mod.isUnknown) .sort((a, b) => a.priority - b.priority); + const outdatedVdataMods = mods.filter((mod) => mod.enabled && mod.outdatedVdata?.length); const unknownFilterCacheById: Record = {}; for (const mod of unknownMods) { const cached = getUnknownCache(mod); @@ -4048,9 +4055,20 @@ export default function Installed() { // action bar's right cluster (next to Fix Order). The right cluster wraps when // cramped, so there's no need to relocate them to a section header. const hasStatusButtons = - conflictCount > 0 || updatesAvailable.size > 0 || !!updateAllProgress || unknownMods.length > 0; + outdatedVdataMods.length > 0 || conflictCount > 0 || updatesAvailable.size > 0 || !!updateAllProgress || unknownMods.length > 0; const statusButtons = hasStatusButtons ? (
+ {outdatedVdataMods.length > 0 && ( + + )} {conflictCount > 0 && (