From 377412bcbc1fd3e66dd6937f3771cdf39e26f6d2 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov <5411011+Aragas@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:43:37 +0300 Subject: [PATCH 1/2] Merge pull request #24510 from Nexus-Mods/task/LAZ-1391 Update orphaned extensions --- locales/en/extension_manager.json | 12 +- src/renderer/src/actions/app.ts | 2 + .../src/extensions/extension_manager/index.ts | 105 +++++++++++++++- .../extension_manager/queries.test.ts | 67 ++++++++++ .../extensions/extension_manager/queries.ts | 62 +++++++-- .../views/LinkExtensionsDialog.test.tsx | 66 ++++++++++ .../views/LinkExtensionsDialog.tsx | 118 ++++++++++++++++++ src/renderer/src/reducers/app.test.ts | 6 + src/renderer/src/reducers/app.ts | 2 + src/renderer/src/types/IState.ts | 2 + 10 files changed, 429 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.test.tsx create mode 100644 src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx diff --git a/locales/en/extension_manager.json b/locales/en/extension_manager.json index 905c313381..5268e4ec07 100644 --- a/locales/en/extension_manager.json +++ b/locales/en/extension_manager.json @@ -1,3 +1,13 @@ { - "_comment": "Extension manager strings - to be populated during migration" + "_comment": "Extension manager strings - to be populated during migration", + "linkDialog": { + "title": "Vortex extension updates available", + "intro": "Vortex found extensions installed without a known source and identified possible replacements on Nexus Mods.", + "select": "Select which extensions you'd like to replace. Unselected extensions will remain unchanged, and you won't be asked about them again.", + "replaceWith": "Replace with", + "buttons": { + "later": "Later", + "update": "Update selected" + } + } } diff --git a/src/renderer/src/actions/app.ts b/src/renderer/src/actions/app.ts index 7b2a60241a..60dc9548b6 100644 --- a/src/renderer/src/actions/app.ts +++ b/src/renderer/src/actions/app.ts @@ -35,6 +35,8 @@ export const setExtensionModId = createAction( (extensionId: string, modId: number) => ({ extensionId, modId }), ); +export const declineExtensionNexusLink = createAction("DECLINE_EXTENSION_NEXUS_LINK", id); + export const removeExtension = createAction("REMOVE_EXTENSION", id); export const forgetExtension = createAction("FORGET_EXTENSION", id); diff --git a/src/renderer/src/extensions/extension_manager/index.ts b/src/renderer/src/extensions/extension_manager/index.ts index 5a5f402d79..d9189ec276 100644 --- a/src/renderer/src/extensions/extension_manager/index.ts +++ b/src/renderer/src/extensions/extension_manager/index.ts @@ -1,9 +1,17 @@ +import * as path from "node:path"; + import { mdiPuzzleOutline } from "@mdi/js"; +import { getErrorMessageOrDefault } from "@vortex/shared"; import * as semver from "semver"; import { log } from "@/logging"; -import { setDialogVisible, setExtensionEnabled } from "../../actions"; +import { + declineExtensionNexusLink, + setDialogVisible, + setExtensionEnabled, + setExtensionModId, +} from "../../actions"; import { isExtSame } from "../../ExtensionManager"; import type { IAvailableExtension, IExtensionDownloadInfo } from "../../types/extensions"; import type { @@ -16,6 +24,8 @@ import { getGame } from "../../util/api"; import { relaunch } from "../../util/commandLine"; import { DataInvalid, ProcessCanceled } from "../../util/CustomErrors"; import makeReactive from "../../util/makeReactive"; +import { knownGames } from "../gamemode_management/selectors"; +import { nexusGameId } from "../nexus_integration/util/convertGameId"; import { setAvailableExtensions, setExtensionsUpdate } from "./actions"; import BrowseExtensions from "./BrowseExtensions"; import type { IBrowseExtensionsProps } from "./BrowseExtensions"; @@ -26,11 +36,17 @@ import { findInCatalog, findInstalled, findInstalledDependency, + findLinkCandidates, findUpdatableExtensions, getMissingOptionalExtensions, } from "./queries"; import sessionReducer from "./reducers"; import { downloadAndInstallExtension, fetchAvailableExtensions } from "./util"; +import { + type ILinkCandidate, + type ILinkExtensionsDialogProps, + LinkExtensionsDialog, +} from "./views/LinkExtensionsDialog"; declare module "../../types/IExtensionContext" { interface ApiEvents { @@ -53,8 +69,72 @@ async function checkForUpdates(api: IExtensionApi): Promise { const { available } = state.session.extensions; const installed = state.app.extensions ?? {}; - const updateable = findUpdatableExtensions(installed, available); + await updateExtensions(api, findUpdatableExtensions(installed, available)); +} + +// asked at most once per session, so refreshing the catalog doesn't repeat the question +const offeredLinks = new Set(); + +const LINK_DIALOG_ID = "link-extensions-dialog"; +// read by the dialog's props callback, which runs when the dialog is shown +let linkOffer: { + candidates: ILinkCandidate[]; + answer: (selected: string[] | undefined) => void; +} = { candidates: [], answer: () => undefined }; +async function offerNexusLinks(api: IExtensionApi): Promise { + const state = api.getState(); + const games = knownGames(state).map((game) => ({ + extensionPath: game.extensionPath, + domain: nexusGameId(game), + })); + const candidates = findLinkCandidates( + state.app.extensions ?? {}, + games, + state.session.extensions.available, + ).filter(({ key }) => !offeredLinks.has(key)); + + if (candidates.length === 0) return; + candidates.forEach(({ key }) => offeredLinks.add(key)); + + // the state entry's name is an identifier, often the folder name + const loaded = api.getLoadedExtensions(); + const samePath = (lhs: string, rhs: string) => + path.normalize(lhs).toLowerCase() === path.normalize(rhs).toLowerCase(); + const displayName = (installed: IExtensionState) => + loaded.find((ext) => samePath(ext.path, installed.path))?.info?.name ?? installed.name; + + const selected = await new Promise((resolve) => { + linkOffer = { + candidates: candidates.map(({ key, installed, available }) => ({ + key, + name: displayName(installed), + version: installed.version, + replacementName: available.name, + replacementVersion: available.version, + })), + answer: resolve, + }; + api.store.dispatch(setDialogVisible(LINK_DIALOG_ID)); + }); + if (selected === undefined) return; + + const accepted = candidates.filter(({ key }) => selected.includes(key)); + for (const { key, available } of candidates) { + api.store.dispatch( + accepted.some((candidate) => candidate.key === key) + ? setExtensionModId(key, available.modId) + : declineExtensionNexusLink(key), + ); + } + + await updateExtensions(api, accepted); +} + +async function updateExtensions( + api: IExtensionApi, + updateable: Array<{ installed: IExtensionState; available: IAvailableExtension }>, +): Promise { if (updateable.length === 0) return; api.sendNotification({ @@ -108,6 +188,12 @@ async function updateAvailableExtensions( api.store.dispatch(setExtensionsUpdate(Date.now())); api.store.dispatch(setAvailableExtensions(extensions)); await checkForUpdates(api); + // not awaited: extension installs wait for this fetch, not for the user's answer + offerNexusLinks(api).catch((err) => + log("warn", "failed to offer linking extensions", { + error: getErrorMessageOrDefault(err), + }), + ); } catch (err) { const allowReport = !(err instanceof DataInvalid); @@ -274,6 +360,21 @@ function init(context: IExtensionContext) { void updateAvailableExtensions(context.api, true); }; + context.registerDialog( + LINK_DIALOG_ID, + LinkExtensionsDialog, + () => + ({ + candidates: linkOffer.candidates, + onAnswer: (selected) => { + const { answer } = linkOffer; + // the dialog can close more than once, the offer is answered once + linkOffer = { ...linkOffer, answer: () => undefined }; + answer(selected); + }, + }) satisfies Partial, + ); + context.registerDialog( "browse-extensions", BrowseExtensions, diff --git a/src/renderer/src/extensions/extension_manager/queries.test.ts b/src/renderer/src/extensions/extension_manager/queries.test.ts index cc7e7285ed..8a022eeffa 100644 --- a/src/renderer/src/extensions/extension_manager/queries.test.ts +++ b/src/renderer/src/extensions/extension_manager/queries.test.ts @@ -13,6 +13,7 @@ import { findDependencyInCatalog, findInCatalog, findInstalled, + findLinkCandidates, findPreviousVersions, findUpdatableExtensions, getMissingOptionalExtensions, @@ -413,6 +414,72 @@ describe("getMissingOptionalExtensions", () => { }); }); +describe("findLinkCandidates", () => { + const extPath = "/plugins/Cyberpunk 2077 Support 0.13.0"; + const games = [{ extensionPath: extPath, domain: "cyberpunk2077" }]; + const unlinked = makeExtensionState({ path: extPath, version: "0.13.0" }); + const catalogEntry = makeAvailableExtension({ + modId: 196, + version: "0.13.1", + type: "game", + gameDomain: "cyberpunk2077", + }); + + it("matches an unlinked game extension through the domain of the game it registers", () => { + const result = findLinkCandidates({ "ext-1": unlinked }, games, [catalogEntry]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ key: "ext-1", available: { modId: 196 } }); + }); + + it("matches the game's extension path case-insensitively", () => { + const upper = [{ extensionPath: extPath.toUpperCase(), domain: "cyberpunk2077" }]; + expect(findLinkCandidates({ "ext-1": unlinked }, upper, [catalogEntry])).toHaveLength(1); + }); + + it("offers nothing when the catalog has no newer version", () => { + const current = makeExtensionState({ path: extPath, version: "0.13.1" }); + expect(findLinkCandidates({ "ext-1": current }, games, [catalogEntry])).toEqual([]); + }); + + it.each([ + ["is already linked", { modId: 196 }], + ["was declined", { nexusLinkDeclined: true }], + ["is bundled", { bundled: true }], + ["is pending removal", { remove: true }], + ])("skips an extension that %s", (_name, overrides) => { + const installed = { "ext-1": { ...unlinked, ...overrides } }; + expect(findLinkCandidates(installed, games, [catalogEntry])).toEqual([]); + }); + + it("skips an extension that registers no game", () => { + expect(findLinkCandidates({ "ext-1": unlinked }, [], [catalogEntry])).toEqual([]); + }); + + it("skips a match naming more than one mod page", () => { + const twoGames = [...games, { extensionPath: extPath, domain: "cyberpunk2077demo" }]; + const other = makeAvailableExtension({ + modId: 197, + version: "1.0.0", + type: "game", + gameDomain: "cyberpunk2077demo", + }); + expect(findLinkCandidates({ "ext-1": unlinked }, twoGames, [catalogEntry, other])).toEqual([]); + }); + + it("skips a match whose mod page another installed extension is linked to", () => { + const installed = { + "ext-1": unlinked, + "ext-2": makeExtensionState({ path: "/plugins/other", modId: 196, version: "0.13.1" }), + }; + expect(findLinkCandidates(installed, games, [catalogEntry])).toEqual([]); + }); + + it("ignores catalog entries that aren't game extensions", () => { + const tool = { ...catalogEntry, type: undefined }; + expect(findLinkCandidates({ "ext-1": unlinked }, games, [tool])).toEqual([]); + }); +}); + describe("findUpdatableExtensions", () => { it("finds extensions with newer versions available", () => { const installed = { diff --git a/src/renderer/src/extensions/extension_manager/queries.ts b/src/renderer/src/extensions/extension_manager/queries.ts index 4c2c6adea1..8bfe90d7fb 100644 --- a/src/renderer/src/extensions/extension_manager/queries.ts +++ b/src/renderer/src/extensions/extension_manager/queries.ts @@ -129,26 +129,68 @@ export function getMissingOptionalExtensions( ); } +type ExtensionUpdate = { key: string; installed: IExtensionState; available: IAvailableExtension }; + +function isNewer(installed: IExtensionState, available: IAvailableExtension): boolean { + const installedVersion = semver.coerce(installed.version); + const availableVersion = semver.coerce(available.version); + if (installedVersion === null || availableVersion === null) return false; + return semver.lt(installedVersion, availableVersion); +} + /** Find all installed extensions that have updates available. */ export function findUpdatableExtensions( installedExtensions: Record, catalog: IAvailableExtension[], -): Array<{ key: string; installed: IExtensionState; available: IAvailableExtension }> { - const result: Array<{ key: string; installed: IExtensionState; available: IAvailableExtension }> = - []; +): ExtensionUpdate[] { + const result: ExtensionUpdate[] = []; for (const [key, installed] of Object.entries(installedExtensions)) { const available = findInCatalog(catalog, { modId: installed.modId }); - if (available === undefined) continue; + if (available !== undefined && isNewer(installed, available)) { + result.push({ key, installed, available }); + } + } - const installedVersion = semver.coerce(installed.version); - const availableVersion = semver.coerce(available.version); + return result; +} - if (installedVersion === null || availableVersion === null) { - continue; - } +/** + * Find installed game extensions without a mod id that the catalog has a newer + * version of, matched through the Nexus Mods domain of the games each one + * registers. The match is a guess, so the user has to confirm it. + */ +export function findLinkCandidates( + installedExtensions: Record, + games: Array<{ extensionPath?: string; domain: string }>, + catalog: IAvailableExtension[], +): ExtensionUpdate[] { + const result: ExtensionUpdate[] = []; - if (semver.lt(installedVersion, availableVersion)) { + for (const [key, installed] of Object.entries(installedExtensions)) { + if (installed.modId !== undefined || installed.path === undefined) continue; + if (installed.bundled || installed.remove || installed.nexusLinkDeclined) continue; + + const installedPath = path.normalize(installed.path).toLowerCase(); + const domains = new Set( + games + .filter( + (game) => + game.extensionPath !== undefined && + path.normalize(game.extensionPath).toLowerCase() === installedPath, + ) + .map((game) => game.domain.toLowerCase()), + ); + const matches = catalog.filter( + (entry) => entry.type === "game" && domains.has(entry.gameDomain?.toLowerCase()), + ); + + // only a match naming exactly one mod page is worth offering + if (new Set(matches.map((entry) => entry.modId)).size !== 1) continue; + const available = matches[0]; + + if (findInstalled(installedExtensions, { modId: available.modId }) !== undefined) continue; + if (isNewer(installed, available)) { result.push({ key, installed, available }); } } diff --git a/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.test.tsx b/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.test.tsx new file mode 100644 index 0000000000..f971af2c1e --- /dev/null +++ b/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { type ILinkCandidate, LinkExtensionsDialog } from "./LinkExtensionsDialog"; + +const candidates: ILinkCandidate[] = [ + { + key: "ext-1", + name: "My weird custom extension", + version: "0.5", + replacementName: "Mount & Blade II: Bannerlord Support", + replacementVersion: "2.0.0", + }, + { + key: "ext-2", + name: "Cyberpunk 2077 Vortex Support", + version: "0.13.0", + replacementName: "Cyberpunk 2077 Vortex Support", + replacementVersion: "0.13.1", + }, +]; + +const renderDialog = () => { + const onAnswer = vi.fn(); + const onHide = vi.fn(); + render( + , + ); + return { onAnswer, onHide }; +}; + +describe("LinkExtensionsDialog", () => { + it("lists each extension with its replacement", () => { + renderDialog(); + expect(screen.getByText("My weird custom extension v0.5")).toBeInTheDocument(); + expect(screen.getByText("Mount & Blade II: Bannerlord Support v2.0.0")).toBeInTheDocument(); + }); + + it("selects every extension by default", async () => { + const { onAnswer, onHide } = renderDialog(); + await userEvent.click(screen.getByTestId("link-extensions-update")); + expect(onAnswer).toHaveBeenCalledWith(["ext-1", "ext-2"]); + expect(onHide).toHaveBeenCalled(); + }); + + it("leaves out the extensions the user unselected", async () => { + const { onAnswer } = renderDialog(); + await userEvent.click(screen.getByTestId("link-extension-ext-1")); + await userEvent.click(screen.getByTestId("link-extensions-update")); + expect(onAnswer).toHaveBeenCalledWith(["ext-2"]); + }); + + it("answers nothing when put off", async () => { + const { onAnswer, onHide } = renderDialog(); + await userEvent.click(screen.getByTestId("link-extensions-later")); + expect(onAnswer).toHaveBeenCalledWith(undefined); + expect(onHide).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx b/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx new file mode 100644 index 0000000000..5c051f0948 --- /dev/null +++ b/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/ui/components/button/Button"; +import { Checkbox } from "@/ui/components/form/checkbox/Checkbox"; +import { ModalPanel, ModalWrapper } from "@/ui/components/modal/Modal"; +import { Typography } from "@/ui/components/typography/Typography"; + +export interface ILinkCandidate { + key: string; + name: string; + version: string; + replacementName: string; + replacementVersion: string; +} + +export interface ILinkExtensionsDialogProps { + visible: boolean; + candidates: ILinkCandidate[]; + onHide: () => void; + /** The keys of the selected extensions, or undefined when the user put the question off. */ + onAnswer: (selected: string[] | undefined) => void; +} + +const withVersion = (name: string, version: string) => + `${name} ${version.startsWith("v") ? version : `v${version}`}`; + +export const LinkExtensionsDialog = ({ + visible, + candidates, + onHide, + onAnswer, +}: ILinkExtensionsDialogProps) => { + const { t } = useTranslation(["extension_manager", "common"]); + const [selected, setSelected] = React.useState>(new Set()); + + React.useEffect(() => { + setSelected(new Set(candidates.map((candidate) => candidate.key))); + }, [candidates]); + + const toggle = (key: string) => + setSelected((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }); + + const answer = (keys: string[] | undefined) => { + onAnswer(keys); + onHide(); + }; + + return ( + answer(undefined)}> + {/* only the list scrolls once the panel reaches its height cap */} + answer(undefined)} + > + + {t("linkDialog::intro")} + + + + {t("linkDialog::select")} + + +
    + {candidates.map((candidate) => ( +
  • + toggle(candidate.key)} + > + + + {withVersion(candidate.name, candidate.version)} + + + + {t("linkDialog::replaceWith")} + + + + {withVersion(candidate.replacementName, candidate.replacementVersion)} + + + +
  • + ))} +
+ +
+ + + +
+
+
+ ); +}; diff --git a/src/renderer/src/reducers/app.test.ts b/src/renderer/src/reducers/app.test.ts index 108fc06f0f..5077a06072 100644 --- a/src/renderer/src/reducers/app.test.ts +++ b/src/renderer/src/reducers/app.test.ts @@ -14,6 +14,11 @@ describe("app reducer extension field writes", () => { expect(result.extensions.abc123.enabled).toBe(false); }); + it("applies declineExtensionNexusLink to an existing entry", () => { + const result = apply(actions.declineExtensionNexusLink, "abc123"); + expect(result.extensions.abc123.nexusLinkDeclined).toBe(true); + }); + it("applies setExtensionModId to an existing entry", () => { const result = apply(actions.setExtensionModId, { extensionId: "abc123", modId: 196 }); expect(result.extensions.abc123.modId).toBe(196); @@ -33,6 +38,7 @@ describe("app reducer extension field writes", () => { { extensionId: "missing", endorsed: "Endorsed" }, ], ["setExtensionModId", actions.setExtensionModId, { extensionId: "missing", modId: 196 }], + ["declineExtensionNexusLink", actions.declineExtensionNexusLink, "missing"], ["removeExtension", actions.removeExtension, "missing"], ])("ignores %s for an unknown key", (_name, action, payload) => { expect(apply(action, payload).extensions).toEqual(base.extensions); diff --git a/src/renderer/src/reducers/app.ts b/src/renderer/src/reducers/app.ts index 3ca698b654..c36ff6b3b6 100644 --- a/src/renderer/src/reducers/app.ts +++ b/src/renderer/src/reducers/app.ts @@ -59,6 +59,8 @@ export const appReducer = actionsToReducerSpec( updateExtension(state, payload.extensionId, { endorsed: payload.endorsed }), setExtensionModId: (state, payload) => updateExtension(state, payload.extensionId, { modId: payload.modId }), + declineExtensionNexusLink: (state, payload) => + updateExtension(state, payload, { nexusLinkDeclined: true }), removeExtension: (state, payload) => updateExtension(state, payload, { remove: true }), forgetExtension: (state, payload) => { const { [payload]: _, ...extensions } = state.extensions; diff --git a/src/renderer/src/types/IState.ts b/src/renderer/src/types/IState.ts index 658235c7a7..572517331e 100644 --- a/src/renderer/src/types/IState.ts +++ b/src/renderer/src/types/IState.ts @@ -165,6 +165,8 @@ export interface IExtensionState { fileId?: number; /** Nexus Mods endorsed status of the extension mod page. */ endorsed: EndorsedStatus; + /** Set when the user declined linking this unlinked extension to its Nexus Mods page. */ + nexusLinkDeclined?: boolean; } /** From f14572af6b964793459b509826caf97a8f2b67d7 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 14 Oct 2026 12:30:25 +0300 Subject: [PATCH 2/2] Fixed the cherry pick --- .../views/LinkExtensionsDialog.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx b/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx index 5c051f0948..0de27fb70e 100644 --- a/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx +++ b/src/renderer/src/extensions/extension_manager/views/LinkExtensionsDialog.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/ui/components/button/Button"; -import { Checkbox } from "@/ui/components/form/checkbox/Checkbox"; +import { CheckboxField } from "@/ui/components/form/checkbox_field/CheckboxField"; import { ModalPanel, ModalWrapper } from "@/ui/components/modal/Modal"; import { Typography } from "@/ui/components/typography/Typography"; @@ -69,26 +69,26 @@ export const LinkExtensionsDialog = ({
    {candidates.map((candidate) => (
  • - toggle(candidate.key)} - > - - - {withVersion(candidate.name, candidate.version)} - + label={ + + + {withVersion(candidate.name, candidate.version)} + - - {t("linkDialog::replaceWith")} - + + {t("linkDialog::replaceWith")} + - - {withVersion(candidate.replacementName, candidate.replacementVersion)} - - - + + {withVersion(candidate.replacementName, candidate.replacementVersion)} + + + } + onChange={() => toggle(candidate.key)} + />
  • ))}