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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion locales/en/extension_manager.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
2 changes: 2 additions & 0 deletions src/renderer/src/actions/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const setExtensionModId = createAction(
(extensionId: string, modId: number) => ({ extensionId, modId }),
);

export const declineExtensionNexusLink = createAction("DECLINE_EXTENSION_NEXUS_LINK", id<string>);

export const removeExtension = createAction("REMOVE_EXTENSION", id<string>);

export const forgetExtension = createAction("FORGET_EXTENSION", id<string>);
Expand Down
105 changes: 103 additions & 2 deletions src/renderer/src/extensions/extension_manager/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand All @@ -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 {
Expand All @@ -53,8 +69,72 @@ async function checkForUpdates(api: IExtensionApi): Promise<void> {
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<string>();

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<void> {
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<string[] | undefined>((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<void> {
if (updateable.length === 0) return;

api.sendNotification({
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<ILinkExtensionsDialogProps>,
);

context.registerDialog(
"browse-extensions",
BrowseExtensions,
Expand Down
67 changes: 67 additions & 0 deletions src/renderer/src/extensions/extension_manager/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
findDependencyInCatalog,
findInCatalog,
findInstalled,
findLinkCandidates,
findPreviousVersions,
findUpdatableExtensions,
getMissingOptionalExtensions,
Expand Down Expand Up @@ -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 = {
Expand Down
62 changes: 52 additions & 10 deletions src/renderer/src/extensions/extension_manager/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IExtensionState>,
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<string, IExtensionState>,
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 });
}
}
Expand Down
Loading
Loading