diff --git a/.SRCINFO b/.SRCINFO index e33e454..ec6841e 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches - pkgver = 2.8.2 - pkgrel = 4 + pkgver = 2.9.0 + pkgrel = 1 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 @@ -14,7 +14,6 @@ pkgbase = vortex-linux makedepends = patchelf makedepends = python makedepends = python-setuptools - makedepends = rust makedepends = yarn depends = alsa-lib depends = at-spi2-core @@ -44,7 +43,7 @@ pkgbase = vortex-linux depends = xdg-utils optdepends = kde-cli-tools: native trash support on KDE Plasma optdepends = pipewire: screen sharing under Wayland - provides = vortex=2.8.2 + provides = vortex=2.9.0 conflicts = vortex conflicts = vortex-bin conflicts = vortex-git @@ -57,9 +56,8 @@ pkgbase = vortex-linux noextract = windowsdesktop-runtime-10.0.10-win-x64.zip options = !debug options = !strip - source = vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit=e136d9c83492580b6a9b5d9e723964fa3dd56bfd + source = vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit=e04093000d0f6ccf78994670599f7a42b361a038 source = pnpm-11.10.0.tgz::https://registry.npmjs.org/pnpm/-/pnpm-11.10.0.tgz - source = libloot-136f3983c3eec7d377f83a7e7e0b0129aa5c8fe1.tar.gz::https://codeload.github.com/loot/libloot/tar.gz/136f3983c3eec7d377f83a7e7e0b0129aa5c8fe1 source = 0001-linux-runtime-compatibility.patch source = 0002-linux-path-and-executable-discovery.patch source = 0003-linux-filesystem-and-deployment.patch @@ -93,6 +91,8 @@ pkgbase = vortex-linux source = 0032-linux-deployment-target-paths.patch source = 0033-linux-symlinked-extension-paths.patch source = 0034-linux-case-only-platform-paths.patch + source = 0035-linux-backslash-archive-paths.patch + source = 0036-linux-appimage-nxm-links.patch source = vortex.sh source = vortex.desktop source = README.md @@ -105,7 +105,8 @@ pkgbase = vortex-linux source = windowsdesktop-runtime-10.0.10-win-x64.zip::https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.10/windowsdesktop-runtime-10.0.10-win-x64.zip sha512sums = SKIP sha512sums = 0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8 - sha512sums = 4de893736611130d7b360e89ead5d27fb15b6ced9113a7d55604370c4db9db55cb8dca170d212b15be7f51bd40aca876dbea042e5f2a86e114b57cb905aaad99 + sha512sums = SKIP + sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP diff --git a/CHANGELOG.md b/CHANGELOG.md index bb57187..055629f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- Vortex 2.9.0. +- LOOT uses the native binding that now comes with Vortex itself instead of + one built from libloot's sources, so building the package no longer needs + Rust. It is the same libloot version, 0.29.6. +- Mods whose archives use Windows backslashes between folders, such as + Appearance Runtime Plugin for Cyberpunk 2077, install into those folders + instead of as one file named after the whole path. (#16) +- The AppImage handles "Mod Manager Download" links from the Nexus Mods + website. With "Handle Nexus Links" on, it adds its own hidden desktop entry + for nxm:// links, since an AppImage installs none. (#18) + ## 2.8.2-4 - 2026-10-16 - Extensions you install work when the Vortex settings folder is reached diff --git a/PKGBUILD b/PKGBUILD index 544fcb4..cccea52 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,8 +1,8 @@ # Maintainer: Arkazzae pkgname=vortex-linux -pkgver=2.8.2 -pkgrel=4 +pkgver=2.9.0 +pkgrel=1 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" @@ -45,7 +45,6 @@ makedepends=( 'patchelf' 'python' 'python-setuptools' - 'rust' 'yarn' ) @@ -97,10 +96,8 @@ conflicts=('vortex' 'vortex-bin' 'vortex-git') options=('!debug' '!strip') install=vortex.install -_upstream_commit='e136d9c83492580b6a9b5d9e723964fa3dd56bfd' +_upstream_commit='e04093000d0f6ccf78994670599f7a42b361a038' _pnpm='11.10.0' -_libloot_commit='136f3983c3eec7d377f83a7e7e0b0129aa5c8fe1' -_libloot_sha512='4de893736611130d7b360e89ead5d27fb15b6ced9113a7d55604370c4db9db55cb8dca170d212b15be7f51bd40aca876dbea042e5f2a86e114b57cb905aaad99' _dotnet6='6.0.36' _dotnet8='8.0.29' _dotnet10='10.0.10' @@ -138,12 +135,13 @@ _patches=( '0032-linux-deployment-target-paths.patch' '0033-linux-symlinked-extension-paths.patch' '0034-linux-case-only-platform-paths.patch' + '0035-linux-backslash-archive-paths.patch' + '0036-linux-appimage-nxm-links.patch' ) source=( "vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit=${_upstream_commit}" "pnpm-${_pnpm}.tgz::https://registry.npmjs.org/pnpm/-/pnpm-${_pnpm}.tgz" - "libloot-${_libloot_commit}.tar.gz::https://codeload.github.com/loot/libloot/tar.gz/${_libloot_commit}" "${_patches[@]}" 'vortex.sh' 'vortex.desktop' @@ -170,7 +168,6 @@ sha512sums=( 'SKIP' # pnpm is executed during the build, so keep its upstream archive verifiable. '0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8' - "$_libloot_sha512" # All following local files are versioned together with this PKGBUILD, so # hashing them only duplicates Git integrity. 'SKIP' @@ -210,6 +207,8 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' @@ -269,21 +268,10 @@ build() { export npm_config_target='44.2.0' export npm_config_disturl='https://electronjs.org/headers' - ( - cd "$srcdir/libloot-$_libloot_commit" - LIBLOOT_REVISION="${_libloot_commit:0:8}" \ - RUSTFLAGS='-C target-cpu=x86-64' \ - cargo build --release --locked -p libloot-nodejs - ) - export LIBLOOT_NODE_PATH="$srcdir/libloot-$_libloot_commit/target/release/libloot.node" - install -m755 "$srcdir/libloot-$_libloot_commit/target/release/liblibloot_nodejs.so" \ - "$LIBLOOT_NODE_PATH" - strip --strip-unneeded "$LIBLOOT_NODE_PATH" - pnpm run build local gamebryo_plugin - for gamebryo_plugin in gamebryo-archive-support gamebryo-savegame-management gamebryo-plugin-management; do + for gamebryo_plugin in gamebryo-archive-support gamebryo-savegame-management; do if [[ ! -s "$srcdir/vortex/extensions/$gamebryo_plugin/dist/index.cjs" ]]; then printf 'Gamebryo extension was not built for Linux: %s\n' "$gamebryo_plugin" >&2 return 1 @@ -349,6 +337,17 @@ build() { --linux dir \ --x64 \ -c.compression=store + + # copy-assets skips LOOT silently when node-loot failed to build. + local loot_release="$srcdir/vortex/dist/linux-unpacked/resources/app.asar.unpacked/assets/loot/build/Release" + if [[ ! -f "$loot_release/node-loot.node" || ! -f "$loot_release/libloot.so.0" ]]; then + printf 'LOOT is missing from the packaged app\n' >&2 + return 1 + fi + if ldd "$loot_release/node-loot.node" | grep -Fq 'not found'; then + printf 'LOOT has unresolved libraries\n' >&2 + return 1 + fi } package() { @@ -389,8 +388,8 @@ package() { "$pkgdir/usr/share/applications/com.nexusmods.vortex.desktop" install -Dm644 assets/images/vortex.png \ "$pkgdir/usr/share/icons/hicolor/256x256/apps/vortex.png" - install -Dm644 "$srcdir/libloot-$_libloot_commit/LICENSE" \ - "$pkgdir/usr/share/licenses/$pkgname/LIBLOOT-LICENSE.txt" + install -Dm644 src/renderer/node_modules/loot/LICENSE.md \ + "$pkgdir/usr/share/licenses/$pkgname/LOOT-LICENSE.md" install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/VORTEX-LICENSE.md" install -Dm644 "$srcdir/LICENSE" \ "$pkgdir/usr/share/licenses/$pkgname/PATCHES-GPL-3.0.txt" diff --git a/README.md b/README.md index 843125e..76d8211 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ These are general Linux fixes, not per-game hacks. Game-specific Vortex extensio Vortex itself is slowly becoming more Linux-friendly. Since 2.7.1, the extension API's `util.epicGamesLauncher` is a shim over `GameStoreHelper` instead of `undefined` everywhere except Windows, so our Epic API fallback patch is gone 🎊. - Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. +2.9.0 goes further: Vortex now builds LOOT on Linux itself, with a libloot made for Linux, so we no longer compile libloot from source and our LOOT patch shrank to fixing file name casing and the Proton folder 🎉. Reading game versions from `.exe` files is plain TypeScript now too, so it works the same on every platform. + +Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. ## Games tested (confirmed working) @@ -66,7 +68,7 @@ No FUSE 2 on your system? Run it as: ./Vortex-*.AppImage --appimage-extract-and-run ``` -AppImages don't add themselves to your app menu or register as the `nxm://` handler. Gear Lever or AppImageLauncher handle that, or write a desktop entry yourself. +AppImages don't add themselves to your app menu; Gear Lever or AppImageLauncher handle that. For Nexus Mods download links, turn on "Handle Nexus Links" in Vortex: the AppImage then registers itself as the `nxm://` handler and keeps pointing at wherever the AppImage file is. This uses `xdg-settings` from xdg-utils, which desktop distributions ship with their browsers. The AppImage embeds [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) information pointing at this repository's latest release, so Gear Lever, AppImageLauncher and `appimageupdatetool` can check for and apply updates in place. Vortex also shows a notification when a newer Linux release is published; the older builds from `recipes/` stay pinned and never update. @@ -102,7 +104,7 @@ cd dist/arch-build makepkg -si ``` -The build pins both Vortex and libloot to exact upstream commits and downloads the source, Node dependencies, Electron and the Microsoft runtime files. First build is a big one. +The build pins Vortex to an exact upstream commit and downloads the source, Node dependencies, Electron and the Microsoft runtime files. First build is a big one. Patches live in `patches/`, in the order listed by `_patches` in `PKGBUILD`. `prepare-arch-build.sh` copies the packaging files and patches into @@ -146,7 +148,7 @@ What this does not cover: launching a real game, Steam login, Flatpak permission ```sh scripts/test-appimage.sh dist/Vortex-*.AppImage # the AppImage check scripts/check-upstream-compatibility.sh --ref latest --keep # patches against the newest Vortex -scripts/run-upstream-tests.sh /path/to/patched-vortex # the test suite (needs Rust for libloot) +scripts/run-upstream-tests.sh /path/to/patched-vortex # the test suite ``` `--ref pinned` checks the patches against the pinned version instead, and `--keep` keeps the prepared source for the test suite. diff --git a/patches/0001-linux-runtime-compatibility.patch b/patches/0001-linux-runtime-compatibility.patch index d773ef9..25dc6a4 100644 --- a/patches/0001-linux-runtime-compatibility.patch +++ b/patches/0001-linux-runtime-compatibility.patch @@ -50,45 +50,12 @@ index 000000000..891c1c20a + expect(getProductVersionLocalized("/games/Missing.exe")).toBe(""); + }); +}); -diff --git a/packages/exe-version/src/index.ts b/packages/exe-version/src/index.ts -index a290954d1..54a814ac3 100644 ---- a/packages/exe-version/src/index.ts -+++ b/packages/exe-version/src/index.ts -@@ -8,28 +8,24 @@ - import { readVersionInfo } from "./peVersion"; - - export function getFileVersion(exeFile: string): string { -- if (process.platform !== "win32") return ""; - const info = readVersionInfo(exeFile); - if (info === undefined) return ""; - return info.fileVersion.join("."); - } - - export function getProductVersion(exeFile: string): string { -- if (process.platform !== "win32") return ""; - const info = readVersionInfo(exeFile); - if (info === undefined) return ""; - return info.productVersion.join("."); - } - - export function getFileVersionLocalized(exeFile: string): string { -- if (process.platform !== "win32") return getFileVersion(exeFile); - const info = readVersionInfo(exeFile); - if (info === undefined) return ""; - return info.fileVersionString; - } - - export function getProductVersionLocalized(exeFile: string): string { -- if (process.platform !== "win32") return getProductVersion(exeFile); - const info = readVersionInfo(exeFile); - if (info === undefined) return ""; - return info.productVersionString; diff --git a/src/main/electron-builder.config.json b/src/main/electron-builder.config.json -index 080a0afb5..f1d0a19cf 100644 +index 41318379e..eff9d3fb9 100644 --- a/src/main/electron-builder.config.json +++ b/src/main/electron-builder.config.json -@@ -74,7 +74,6 @@ - "assets/dotnetprobe*", +@@ -76,7 +76,6 @@ + "assets/loot/**", "assets/*.exe", "assets/css/**", - "**/*.node", @@ -96,7 +63,7 @@ index 080a0afb5..f1d0a19cf 100644 ], "buildDependenciesFromSource": false, diff --git a/src/main/package.json b/src/main/package.json -index f0f5b8bee..9e26507c3 100644 +index f2d2269c5..cc855bc2a 100644 --- a/src/main/package.json +++ b/src/main/package.json @@ -3,7 +3,7 @@ diff --git a/patches/0002-linux-path-and-executable-discovery.patch b/patches/0002-linux-path-and-executable-discovery.patch index f45d241..92869bd 100644 --- a/patches/0002-linux-path-and-executable-discovery.patch +++ b/patches/0002-linux-path-and-executable-discovery.patch @@ -44,7 +44,7 @@ index cec6708..cb110fa 100644 return t(""); } diff --git a/src/renderer/src/extensions/gamemode_management/index.ts b/src/renderer/src/extensions/gamemode_management/index.ts -index 14c83e5..62a3e9a 100644 +index 06e95bf..df00918 100644 --- a/src/renderer/src/extensions/gamemode_management/index.ts +++ b/src/renderer/src/extensions/gamemode_management/index.ts @@ -30,6 +30,7 @@ import GameStoreHelperInstance from "../../util/GameStoreHelper"; @@ -55,7 +55,7 @@ index 14c83e5..62a3e9a 100644 import opn from "../../util/opn"; import { activeGameId, activeProfile } from "../../util/selectors"; import { getSafe } from "../../util/storeHelper"; -@@ -180,7 +181,7 @@ function refreshGameInfo(store: Redux.Store, gameId: string): PromiseBB< +@@ -187,7 +188,7 @@ function refreshGameInfo(store: Redux.Store, gameId: string): PromiseBB< function verifyGamePath(game: IGame, gamePath: string): PromiseBB { return PromiseBB.map(game.requiredFiles || [], (file) => @@ -64,16 +64,16 @@ index 14c83e5..62a3e9a 100644 ) .then(() => undefined) .catch((err) => { -@@ -314,7 +315,7 @@ function browseGameLocation(api: IExtensionApi, gameId: string): PromiseBB - return manualGameStoreSelection(api, corrected); - }) - .then(({ corrected, store }) => { -- let executable = game.executable(corrected); -+ let executable = resolveNativeExecutable(corrected, game.executable(corrected)); - if (executable === game.executable()) { - executable = undefined; - } -@@ -488,7 +489,7 @@ function removeDisappearedGames( +@@ -330,7 +331,7 @@ async function browseGameLocation(api: IExtensionApi, gameId: string): Promise @@ -83,21 +83,21 @@ index 14c83e5..62a3e9a 100644 .then(() => undefined) .catch((err) => { diff --git a/src/renderer/src/extensions/gamemode_management/util/discovery.ts b/src/renderer/src/extensions/gamemode_management/util/discovery.ts -index ed4c17c..7a866b4 100644 +index 90f5c70..4b193a7 100644 --- a/src/renderer/src/extensions/gamemode_management/util/discovery.ts +++ b/src/renderer/src/extensions/gamemode_management/util/discovery.ts -@@ -4,7 +4,6 @@ import { getErrorCode, getErrorMessageOrDefault, unknownToError } from "@vortex/ +@@ -5,7 +5,6 @@ import { getErrorCode, getErrorMessageOrDefault, unknownToError } from "@vortex/ import Bluebird from "bluebird"; import * as fsExtra from "fs-extra"; import turbowalk from "turbowalk"; -import * as winapi from "winapi-bindings"; - import type { IDiscoveredTool } from "../../../types/IDiscoveredTool"; - import type { IExtensionApi } from "../../../types/IExtensionContext"; -@@ -19,10 +18,12 @@ import type { Normalize } from "../../../util/getNormalizeFunc"; + import { log } from "@/logging"; + +@@ -21,10 +20,12 @@ import * as fs from "../../../util/fs"; + import type { Normalize } from "../../../util/getNormalizeFunc"; import getNormalizeFunc from "../../../util/getNormalizeFunc"; import getVortexPath from "../../../util/getVortexPath"; - import { log } from "../../../util/log"; +import { resolveNativeExecutable, resolvePlatformPath } from "../../../util/nativeExecutable"; import StarterInfo from "../../../util/StarterInfo"; import { getSafe } from "../../../util/storeHelper"; @@ -107,34 +107,25 @@ index ed4c17c..7a866b4 100644 import { modPathsForGame } from "../../mod_management/selectors"; import type { IDiscoveryResult } from "../types/IDiscoveryResult"; import type { IToolStored } from "../types/IToolStored"; -@@ -60,7 +61,7 @@ export function quickDiscoveryTools( - return autoGenIcon(tool, toolPath, gameId).then(() => { - onDiscoveredTool(gameId, { - ...tool, -- path: path.join(toolPath, tool.executable(toolPath)), -+ path: resolvePlatformPath(toolPath, tool.executable(toolPath)), - hidden: false, - parameters: tool.parameters || [], - custom: false, -@@ -81,7 +82,7 @@ export function quickDiscoveryTools( - return autoGenIcon(tool, resolvedPath, gameId).then(() => { - onDiscoveredTool(gameId, { - ...tool, -- path: path.join(resolvedPath, tool.executable(resolvedPath)), -+ path: resolvePlatformPath(resolvedPath, tool.executable(resolvedPath)), - hidden: false, - parameters: tool.parameters || [], - custom: false, -@@ -253,7 +254,7 @@ function handleDiscoveredGame( - return undefined; - } +@@ -72,7 +73,7 @@ export async function quickDiscoveryTools( + + onDiscoveredTool(gameId, { + ...tool, +- path: path.join(toolPath, tool.executable(toolPath)), ++ path: resolvePlatformPath(toolPath, tool.executable(toolPath)), + hidden: false, + parameters: tool.parameters || [], + custom: false, +@@ -222,7 +223,7 @@ async function handleDiscoveredGame( + if (!resolvedPath) return undefined; log("info", "found game", { name: game.name, location: resolvedPath, store }); + - const exe = game.executable(resolvedPath); + const exe = resolveNativeExecutable(resolvedPath, game.executable(resolvedPath)); - const disco: IDiscoveryResult = { + const discovery: IDiscoveryResult = { path: resolvedPath, executable: exe !== game.executable() ? exe : undefined, -@@ -463,7 +464,7 @@ function verifyToolDir(tool: ITool, testPath: string): Bluebird { +@@ -423,7 +424,7 @@ function verifyToolDir(tool: ITool, testPath: string): Bluebird { // is not something we want at this point because we don't even know yet if the user // wants to manage the game at all. (fileName: string) => @@ -143,7 +134,7 @@ index ed4c17c..7a866b4 100644 return Bluebird.reject(err); }), ).then(() => undefined); -@@ -572,7 +573,7 @@ function testApplicationDirValid( +@@ -532,7 +533,7 @@ function testApplicationDirValid( .then(() => { const game = application as IGame; if (game.queryModPath !== undefined) { @@ -152,7 +143,7 @@ index ed4c17c..7a866b4 100644 const disco: IDiscoveryResult = { path: testPath, executable: exe !== game.executable() ? exe : undefined, -@@ -581,7 +582,7 @@ function testApplicationDirValid( +@@ -541,7 +542,7 @@ function testApplicationDirValid( return discoverRelativeTools(game, testPath, discoveredGames, onDiscoveredTool, normalize); } else { @@ -161,7 +152,7 @@ index ed4c17c..7a866b4 100644 return autoGenIcon(application, exePath, gameId).then(() => { onDiscoveredTool(gameId, { ...application, -@@ -769,12 +770,12 @@ export async function suggestStagingPath(api: IExtensionApi, gameId: string): Pr +@@ -729,12 +730,12 @@ export async function suggestStagingPath(api: IExtensionApi, gameId: string): Pr let suggestion: string; diff --git a/patches/0016-linux-gamebryo-archive-support.patch b/patches/0016-linux-gamebryo-archive-support.patch index ff433ab..2744fc8 100644 --- a/patches/0016-linux-gamebryo-archive-support.patch +++ b/patches/0016-linux-gamebryo-archive-support.patch @@ -1,5 +1,5 @@ diff --git a/extensions/gamebryo-archive-support/package.json b/extensions/gamebryo-archive-support/package.json -index e683caf..c28e757 100644 +index e065fc9..3dac972 100644 --- a/extensions/gamebryo-archive-support/package.json +++ b/extensions/gamebryo-archive-support/package.json @@ -4,7 +4,7 @@ @@ -9,8 +9,8 @@ index e683caf..c28e757 100644 - "build": "node -e \"if(process.platform==='win32')process.exit(1)\" || (pnpm run _build)", + "build": "pnpm run _build", "typecheck": "pnpm tsc", - "test": "pnpm exec vitest run" - }, + "test": "pnpm exec vitest run", + "lint:strict": "pnpm exec node ../../scripts/strict-lint.mjs" diff --git a/extensions/gamebryo-archive-support/src/archivePath.ts b/extensions/gamebryo-archive-support/src/archivePath.ts new file mode 100644 index 0000000..fb8dbc5 diff --git a/patches/0020-linux-native-loot.patch b/patches/0020-linux-native-loot.patch index 4c16afe..1c34636 100644 --- a/patches/0020-linux-native-loot.patch +++ b/patches/0020-linux-native-loot.patch @@ -1,463 +1,136 @@ -diff --git a/extensions/gamebryo-plugin-management/build.mjs b/extensions/gamebryo-plugin-management/build.mjs -index acdb1fe..d089b40 100644 ---- a/extensions/gamebryo-plugin-management/build.mjs -+++ b/extensions/gamebryo-plugin-management/build.mjs -@@ -1,3 +1,4 @@ -+import { copyFileSync } from "node:fs"; - import * as path from "node:path"; - - import { createConfig, bundle, nativeRemapPlugin } from "../../scripts/extensions-rolldown.mjs"; -@@ -8,7 +9,24 @@ const output = path.resolve(extensionPath, "dist", "index.cjs"); - - const remapPlugin = nativeRemapPlugin({ - "./build/Release/node-loot": "./node-loot.node", -+ ...(process.platform === "linux" ? { loot: "./libloot-adapter.cjs" } : {}), - }); - - const config = createConfig(entryPoint, output, [remapPlugin]); - await bundle(config); -+ -+if (process.platform === "linux") { -+ if (!process.env.LIBLOOT_NODE_PATH) -+ throw new Error("LIBLOOT_NODE_PATH must point to the built Linux libloot addon"); -+ await bundle( -+ createConfig( -+ path.join(extensionPath, "src/native/libloot-adapter.ts"), -+ path.join(extensionPath, "dist/libloot-adapter.cjs"), -+ ), -+ ); -+ copyFileSync( -+ path.join(extensionPath, "src/native/libloot-worker.cjs"), -+ path.join(extensionPath, "dist/libloot-worker.cjs"), -+ ); -+ copyFileSync(process.env.LIBLOOT_NODE_PATH, path.join(extensionPath, "dist/libloot.node")); -+} -diff --git a/extensions/gamebryo-plugin-management/package.json b/extensions/gamebryo-plugin-management/package.json -index a70b365..029bc09 100644 ---- a/extensions/gamebryo-plugin-management/package.json -+++ b/extensions/gamebryo-plugin-management/package.json -@@ -4,11 +4,12 @@ - "description": "Management for Gamebryo plugins", - "scripts": { - "_assets": "copyfiles -u 1 -f ./src/stylesheets/plugin_management.scss ./src/language.json ./src/loot_icon.png dist", -- "_native": "node ../copy-native.mjs ./node_modules/loot/build/Release/node-loot.node ./node_modules/loot/loot_api/libloot.dll ./node_modules/loot/async.js && node -e \"const fs=require('fs');const f='dist/async.js';let c=fs.readFileSync(f,'utf8');c=c.replace('./build/Release/node-loot','./node-loot');fs.writeFileSync(f,c);\"", -+ "_native": "node -e \"if(process.platform==='win32')process.exit(1)\" || (pnpm run _native_windows)", - "_build": "node build.mjs && pnpm run _assets && pnpm run _native && pnpm extractInfo", -- "build": "node -e \"if(process.platform==='win32')process.exit(1)\" || (pnpm run _build)", -+ "build": "pnpm run _build", - "typecheck": "pnpm tsc", -- "test": "pnpm exec vitest run" -+ "test": "pnpm exec vitest run", -+ "_native_windows": "node ../copy-native.mjs ./node_modules/loot/build/Release/node-loot.node ./node_modules/loot/loot_api/libloot.dll ./node_modules/loot/async.js && node -e \"const fs=require('fs');const f='dist/async.js';let c=fs.readFileSync(f,'utf8');c=c.replace('./build/Release/node-loot','./node-loot');fs.writeFileSync(f,c);\"" - }, - "author": "Black Tree Gaming Ltd.", - "license": "GPL-3.0", -diff --git a/extensions/gamebryo-plugin-management/src/autosort.ts b/extensions/gamebryo-plugin-management/src/autosort.ts -index 8a5a0d2..96da12e 100644 ---- a/extensions/gamebryo-plugin-management/src/autosort.ts -+++ b/extensions/gamebryo-plugin-management/src/autosort.ts -@@ -844,7 +844,7 @@ class LootInterface { - await LootProm.createAsync( - this.convertGameId(gameMode, false), - this.gamePath, -- localPath, -+ process.platform === "linux" ? fs.realpathSync(localPath) : localPath, - "en", - this.logCB, - this.fork, -diff --git a/extensions/gamebryo-plugin-management/src/native/libloot-adapter.ts b/extensions/gamebryo-plugin-management/src/native/libloot-adapter.ts +diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts b/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts +index b37fb28..e9a144e 100644 +--- a/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts ++++ b/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts +@@ -16,6 +16,7 @@ import type { IExtensionApi } from "../../types/IExtensionContext"; + import { fileMD5 } from "../../util/checksum"; + import * as fs from "../../util/fs"; + import getVortexPath from "../../util/getVortexPath"; ++import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; + import { getSafe } from "../../util/storeHelper"; + import { batchDispatch } from "../../util/util"; + import { webpackRequireHack } from "../../util/webpack-hacks"; +@@ -39,6 +40,7 @@ import { + } from "./util/gameSupport"; + import { isGhosted } from "./util/ghost"; + import { missingGroupFixes } from "./util/groups"; ++import { adaptLootForLinux } from "./util/linuxLoot"; + import { lootErrorReporter, LootPhase } from "./util/LootErrorReporter"; + import { toLootError } from "./util/lootErrors"; + import { downloadMasterlist, downloadPrelude } from "./util/masterlist"; +@@ -853,11 +855,15 @@ class LootInterface { + loot = await getLootAsync().create( + this.convertGameId(gameMode, false), + gamePath, +- localPath, ++ // libloot reads plugins.txt itself, so it needs the folder inside the Proton prefix. ++ process.platform === "linux" ? resolveCaseInsensitiveSync(localPath) : localPath, + "en", + this.logCB, + (modulePath, args) => this.fork(modulePath, args, () => loot?.isClosed() === true), + ); ++ if (process.platform === "linux") { ++ loot = adaptLootForLinux(loot, () => gameDataPath(gameMode)); ++ } + } catch (rawErr) { + const err = toLootError(rawErr); + log("error", "failed to initialize LOOT", { kind: err.data.kind, error: err.message }); +diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts new file mode 100644 -index 0000000..144d615 +index 0000000..9bd1743 --- /dev/null -+++ b/extensions/gamebryo-plugin-management/src/native/libloot-adapter.ts -@@ -0,0 +1,122 @@ -+import { fork, type ChildProcess } from "node:child_process"; ++++ b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts +@@ -0,0 +1,51 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; +import * as path from "node:path"; + -+type Callback = (error: Error | null, result?: any) => void; -+interface IWorkerError { -+ message: string; -+ cycle?: Array<{ name: string; typeOfEdgeToNextVertex: string }>; -+} -+ -+export class LootAsync { -+ private worker: ChildProcess; -+ private pending = new Map(); -+ private sequence = 0; -+ private closed = false; -+ -+ private constructor() { -+ this.worker = fork(path.join(__dirname, "libloot-worker.cjs"), [], { -+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, -+ execArgv: [], -+ stdio: ["ignore", "ignore", "pipe", "ipc"], -+ }); -+ let stderr = ""; -+ this.worker.stderr?.on("data", (chunk) => { -+ stderr = (stderr + chunk.toString()).slice(-8192); -+ }); -+ this.worker.on("message", (message: { id: number; error?: IWorkerError; result?: any }) => { -+ const callback = this.pending.get(message.id); -+ if (callback === undefined) return; -+ this.pending.delete(message.id); -+ if (message.error === undefined) { -+ callback(null, message.result); -+ } else { -+ const { message: text, ...details } = message.error; -+ callback(Object.assign(new Error(text), details)); -+ } -+ }); -+ this.worker.on("error", (error) => this.close(error)); -+ this.worker.on("exit", (code, signal) => { -+ const error = new Error(`LOOT exited (${signal ?? code}): ${stderr.trim()}`); -+ error.name = "RemoteDied"; -+ this.close(error); -+ }); -+ } -+ -+ public static create( -+ gameId: string, -+ gamePath: string, -+ localPath: string, -+ language: string, -+ logCallback: unknown, -+ onFork: unknown, -+ callback: Callback, -+ ): void { -+ let instance: LootAsync; -+ try { -+ instance = new LootAsync(); -+ instance.call("init", [gameId, gamePath, localPath, language], (error) => { -+ if (error) instance.close(error); -+ callback(error, error ? undefined : instance); -+ }); -+ } catch (error) { -+ callback(error as Error); -+ } -+ } -+ -+ public isClosed(): boolean { -+ return this.closed; -+ } -+ -+ public close(error = new Error("already closed")): void { -+ if (this.closed) return; -+ this.closed = true; -+ this.worker.kill(); -+ const pending = [...this.pending.values()]; -+ this.pending.clear(); -+ for (const callback of pending) { -+ callback(error); -+ } -+ } -+ -+ private call(method: string, args: unknown[], callback: Callback): void { -+ if (this.closed) { -+ callback(new Error("already closed")); -+ return; -+ } -+ const id = ++this.sequence; -+ this.pending.set(id, callback); -+ this.worker.send({ id, method, args }, (error) => { -+ if (error) this.close(error); -+ }); -+ } -+ -+ public sortPlugins(names: string[], callback: Callback): void { -+ this.call("sortPlugins", [names], callback); -+ } -+ public loadPlugins(names: string[], headersOnly: boolean, callback: Callback): void { -+ this.call("loadPlugins", [names, headersOnly], callback); -+ } -+ public getPlugin(name: string, callback: Callback): void { -+ this.call("getPlugin", [name], callback); -+ } -+ public getPluginMetadata(name: string, callback: Callback): void { -+ this.call("getPluginMetadata", [name], callback); -+ } -+ public loadLists( -+ masterlist: string, -+ userlist: string, -+ prelude: string, -+ callback: Callback, -+ ): void { -+ this.call("loadLists", [masterlist, userlist, prelude], callback); -+ } -+ public loadCurrentLoadOrderState(callback: Callback): void { -+ this.call("loadCurrentLoadOrderState", [], callback); -+ } -+ public clearConditionCache(callback: Callback): void { -+ this.call("clearConditionCache", [], callback); -+ } -+ public getGroupsPath(from: string, to: string, callback: Callback): void { -+ this.call("getGroupsPath", [from, to], callback); -+ } -+} -diff --git a/extensions/gamebryo-plugin-management/src/native/libloot-worker.cjs b/extensions/gamebryo-plugin-management/src/native/libloot-worker.cjs ++import type { LootAsync } from "loot"; ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; ++ ++import { adaptLootForLinux, resolvePluginPaths } from "./linuxLoot"; ++ ++describe.runIf(process.platform === "linux")("LOOT plugin paths on Linux", () => { ++ let game: string; ++ let data: string; ++ beforeEach(() => { ++ game = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-linux-loot-")); ++ data = path.join(game, "data"); ++ fs.mkdirSync(data); ++ for (const name of ["Skyrim.esm", "Alpha.esp"]) fs.writeFileSync(path.join(data, name), ""); ++ fs.writeFileSync(path.join(data, "Beta.esp.ghost"), ""); ++ }); ++ afterEach(() => fs.rmSync(game, { recursive: true, force: true })); ++ ++ it("finds plugin IDs and paths whatever their casing, including ghosted plugins", () => { ++ // gameDataPath spells the folder "Data"; this game's folder is "data". ++ const dataPath = path.join(game, "Data"); ++ expect( ++ resolvePluginPaths(dataPath, ["skyrim.esm", path.join(dataPath, "ALPHA.ESP"), "beta.esp"]), ++ ).toEqual([ ++ path.join(data, "Skyrim.esm"), ++ path.join(data, "Alpha.esp"), ++ path.join(data, "Beta.esp.ghost"), ++ ]); ++ }); ++ ++ it("passes a missing plugin on as given so libloot reports it", () => { ++ expect(resolvePluginPaths(data, ["missing.esp", "/elsewhere/Gone.esp"])).toEqual([ ++ "missing.esp", ++ "/elsewhere/Gone.esp", ++ ]); ++ }); ++ ++ it("resolves only what is loaded and forwards everything else", async () => { ++ const loadPlugins = vi.fn(async () => undefined); ++ const sortPlugins = vi.fn(async () => ["Alpha.esp"]); ++ const loot = { loadPlugins, sortPlugins } as unknown as LootAsync; ++ const adapted = adaptLootForLinux(loot, () => data); ++ await adapted.loadPlugins(["alpha.esp"], true); ++ expect(loadPlugins).toHaveBeenCalledWith([path.join(data, "Alpha.esp")], true); ++ await adapted.sortPlugins(["alpha.esp"]); ++ expect(sortPlugins).toHaveBeenCalledWith(["alpha.esp"]); ++ }); ++}); +diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts new file mode 100644 -index 0000000..774a913 +index 0000000..e54d6f7 --- /dev/null -+++ b/extensions/gamebryo-plugin-management/src/native/libloot-worker.cjs -@@ -0,0 +1,249 @@ -+const fs = require("node:fs"); -+const path = require("node:path"); -+const binding = require("./libloot.node"); -+ -+const gameTypes = { -+ oblivion: binding.GameType.Oblivion, -+ skyrim: binding.GameType.Skyrim, -+ fallout3: binding.GameType.Fallout3, -+ falloutnv: binding.GameType.FalloutNV, -+ fallout4: binding.GameType.Fallout4, -+ skyrimse: binding.GameType.SkyrimSE, -+ fallout4vr: binding.GameType.Fallout4VR, -+ skyrimvr: binding.GameType.SkyrimVR, -+ morrowind: binding.GameType.Morrowind, -+ starfield: binding.GameType.Starfield, -+ oblivionremastered: binding.GameType.OblivionRemastered, -+}; -+const edgeTypes = [ -+ "hardcoded", -+ "masterFlag", -+ "master", -+ "masterlistRequirement", -+ "userlistRequirement", -+ "masterlistLoadAfter", -+ "userlistLoadAfter", -+ "masterlistGroup", -+ "userGroup", -+ "recordOverlap", -+ "assetOverlap", -+ "tieBreak", -+ "blueprintMaster", -+]; -+let game; -+let database; -+let dataPath; -+let language; -+let names = new Map(); -+ -+function resolvePath(input) { -+ let current = path.parse(input).root; -+ for (const part of input.slice(current.length).split(path.sep).filter(Boolean)) { -+ const next = path.join(current, part); -+ if (fs.existsSync(next)) { -+ current = next; -+ } else { -+ const matches = fs -+ .readdirSync(current) -+ .filter((entry) => entry.toLowerCase() === part.toLowerCase()); -+ if (matches.length !== 1) -+ throw new Error(`Cannot resolve an unambiguous path: ${next}`); -+ current = path.join(current, matches[0]); -+ } -+ } -+ return current; -+} ++++ b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts +@@ -0,0 +1,36 @@ ++import * as fs from "node:fs"; ++import * as path from "node:path"; + -+function resolveNames(input) { -+ names = new Map(); -+ for (const entry of fs.readdirSync(dataPath)) { -+ const name = entry.replace(/\.ghost$/i, ""); -+ if (!/\.(esm|esp|esl)$/i.test(name)) continue; -+ const key = name.toLowerCase(); -+ if (names.has(key)) throw new Error(`Several plugin files have the same name: ${name}`); -+ names.set(key, name); ++import type { LootAsync } from "loot"; ++ ++import { resolveCaseInsensitiveSync } from "../../../util/linuxCompatibility"; ++ ++/** ++ * libloot opens plugins at exactly the paths it is given. Vortex passes plugin IDs, which are ++ * lower case, and on a case-sensitive filesystem neither they nor a "data" folder spelled ++ * differently from "Data" would be found. Resolve each one to the file on disk, ghosted or not. ++ * A plugin that isn't there is passed on as given, so libloot reports it. ++ */ ++export function resolvePluginPaths(dataPath: string, plugins: string[]): string[] { ++ return plugins.map((plugin) => { ++ const requested = path.isAbsolute(plugin) ? plugin : path.join(dataPath, plugin); ++ for (const candidate of [requested, `${requested}.ghost`]) { ++ const resolved = resolveCaseInsensitiveSync(candidate); ++ if (fs.existsSync(resolved)) return resolved; + } -+ return input.map((name) => { -+ if (/[\\/]/.test(name)) throw new Error(`Expected a plugin filename: ${name}`); -+ return names.get(name.toLowerCase()) ?? name; -+ }); -+} -+ -+function fields(value, keys) { -+ return Object.fromEntries(keys.map((key) => [key, value[key]])); -+} -+function content(entries) { -+ return entries.map((entry) => fields(entry, ["text", "language"])); -+} -+function file(value) { -+ return { name: value.name.asStr(), displayName: value.displayName }; -+} -+function cleaning(value) { -+ return { -+ ...fields(value, [ -+ "itmCount", -+ "deletedReferenceCount", -+ "deletedNavmeshCount", -+ "cleaningUtility", -+ ]), -+ CRC: value.crc, -+ info: content(value.detail), -+ }; -+} -+function metadata(value) { -+ if (!value) return null; -+ return { -+ name: value.name, -+ group: value.group, -+ requirements: value.requirements.map(file), -+ incompatibilities: value.incompatibilities.map(file), -+ loadAfterFiles: value.loadAfterFiles.map(file), -+ tags: value.tags.map((tag) => fields(tag, ["name", "condition", "isAddition"])), -+ cleanInfo: value.cleanInfo.map(cleaning), -+ dirtyInfo: value.dirtyInfo.map(cleaning), -+ locations: value.locations.map((location) => ({ URL: location.url, name: location.name })), -+ messages: value.messages.map((message) => ({ -+ type: message.messageType, -+ content: binding.selectMessageContent(message.content, language)?.text ?? "", -+ condition: message.condition, -+ })), -+ }; ++ return plugin; ++ }); +} + -+const methods = { -+ init(gameId, gamePath, localPath, selectedLanguage) { -+ const gameType = gameTypes[gameId]; -+ if (gameType === undefined) throw new Error(`Unsupported LOOT game: ${gameId}`); -+ game = new binding.Game(gameType, resolvePath(gamePath), resolvePath(localPath)); -+ database = game.database(); -+ language = selectedLanguage; -+ const relative = -+ gameId === "morrowind" -+ ? "Data Files" -+ : gameId === "oblivionremastered" -+ ? "OblivionRemastered/Content/Dev/ObvData/Data" -+ : "Data"; -+ dataPath = resolvePath(path.join(gamePath, relative)); -+ game.setAdditionalDataPaths([dataPath]); -+ return binding.liblootVersion(); -+ }, -+ loadPlugins(input, headersOnly) { -+ const resolved = resolveNames(input).map((name) => { -+ const file = path.join(dataPath, name); -+ return fs.existsSync(file) ? file : `${file}.ghost`; -+ }); -+ if (headersOnly) game.loadPluginHeaders(resolved); -+ else game.loadPlugins(resolved); -+ }, -+ sortPlugins(input) { -+ return game.sortPlugins(resolveNames(input)); -+ }, -+ loadCurrentLoadOrderState() { -+ game.loadCurrentLoadOrderState(); -+ }, -+ clearConditionCache() { -+ database.clearConditionCache(); -+ }, -+ getPlugin(name) { -+ const plugin = game.plugin(names.get(name.toLowerCase()) ?? name); -+ if (!plugin) return null; -+ return { -+ ...Object.fromEntries( -+ [ -+ "name", -+ "version", -+ "crc", -+ "isMaster", -+ "isLightPlugin", -+ "isEmpty", -+ "loadsArchive", -+ ].map((key) => [key, plugin[key]()]), -+ ), -+ masters: plugin.masters(), -+ bashTags: plugin.bashTags(), -+ isValidAsLightPlugin: plugin.isValidAsLightPlugin(), -+ }; -+ }, -+ getPluginMetadata(name) { -+ return metadata( -+ database.pluginMetadata( -+ name, -+ binding.MergeMode.WithUserMetadata, -+ binding.EvalMode.Evaluate, -+ ), -+ ); -+ }, -+ loadLists(masterlist, userlist, prelude) { -+ if (prelude) database.loadMasterlistWithPrelude(masterlist, prelude); -+ else database.loadMasterlist(masterlist); -+ database.discardAllUserMetadata(); -+ if (userlist) database.loadUserlist(userlist); -+ }, -+ getGroupsPath(from, to) { -+ return database.groupsPath(from, to).map((vertex) => ({ -+ name: vertex.name, -+ typeOfEdgeToNextVertex: edgeTypes[vertex.outEdgeType], -+ })); ++/** Once loaded, libloot looks plugins up by name regardless of case, so only loading needs this. */ ++export function adaptLootForLinux(loot: LootAsync, dataPath: () => string): LootAsync { ++ return new Proxy(loot, { ++ get(target, key, receiver) { ++ if (key === "loadPlugins") { ++ return (plugins: string[], headersOnly: boolean) => ++ target.loadPlugins(resolvePluginPaths(dataPath(), plugins), headersOnly); ++ } ++ return Reflect.get(target, key, receiver); + }, -+}; -+ -+// libloot's Node binding only reports messages, so rebuild the cycle that -+// Vortex needs to offer solutions from the vertices printed in the message. -+const edgeTypeNames = new Map( -+ [ -+ "Hardcoded", -+ "Master Flag", -+ "Master", -+ "Masterlist Requirement", -+ "User Requirement", -+ "Masterlist Load After", -+ "User Load After", -+ "Masterlist Group", -+ "User Group", -+ "Record Overlap", -+ "Asset Overlap", -+ "Tie Break", -+ "Blueprint Master", -+ ].map((name, index) => [name, edgeTypes[index]]), -+); -+ -+function parseCycle(description) { -+ const cycle = []; -+ const edge = /(.+?) --\[([^\]]+)\]-> /y; -+ let end = 0; -+ let match; -+ while ((match = edge.exec(description)) !== null) { -+ const typeOfEdgeToNextVertex = edgeTypeNames.get(match[2]); -+ if (typeOfEdgeToNextVertex === undefined) return undefined; -+ cycle.push({ name: match[1], typeOfEdgeToNextVertex }); -+ end = edge.lastIndex; -+ } -+ return cycle.length > 0 && description.slice(end) === cycle[0].name -+ ? cycle -+ : undefined; ++ }); +} -+ -+function serializeError(error) { -+ const message = String(error?.message ?? error); -+ // Sorting reports "found a cycle: ..."; group paths use "cyclic interaction detected: ...". -+ const match = /(?:found a cycle|cyclic interaction detected): (.*)$/is.exec(message); -+ if (match !== null) { -+ const description = match[1]; -+ const cycle = parseCycle(description); -+ if (cycle !== undefined) { -+ return { message: `Cyclic interaction detected: ${description}`, cycle }; -+ } -+ } -+ return { message }; -+} -+ -+process.on("disconnect", () => process.exit(0)); -+process.on("message", ({ id, method, args }) => { -+ try { -+ if (!Object.hasOwn(methods, method)) throw new Error(`Unknown LOOT operation: ${method}`); -+ const result = methods[method](...args); -+ process.send({ id, result }); -+ } catch (error) { -+ process.send({ id, error: serializeError(error) }); -+ } -+}); -diff --git a/src/renderer/src/util/fs.ts b/src/renderer/src/util/fs.ts -index 88b7832..f1e0a36 100644 ---- a/src/renderer/src/util/fs.ts -+++ b/src/renderer/src/util/fs.ts -@@ -98,6 +98,8 @@ export const openSync = resolveSyncPathArguments(originalFs.openSync, [0]); - /** @deprecated use node:fs directly */ - export const readdirSync = resolveSyncPathArguments(originalFs.readdirSync, [0]); - /** @deprecated use node:fs directly */ -+export const realpathSync = resolveSyncPathArguments(originalFs.realpathSync, [0]); -+/** @deprecated use node:fs directly */ - export const readFileSync = resolveSyncPathArguments(originalFs.readFileSync, [0]); - /** @deprecated use node:fs directly */ - export const statSync = resolveSyncPathArguments(originalFs.statSync, [0]); diff --git a/patches/0031-linux-deployment-preflight.patch b/patches/0031-linux-deployment-preflight.patch index b2c5945..eccfcd9 100644 --- a/patches/0031-linux-deployment-preflight.patch +++ b/patches/0031-linux-deployment-preflight.patch @@ -1,78 +1,3 @@ -diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts ---- a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts -+++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts -@@ -348,8 +348,11 @@ - deployPath: string, - blackList: BlacklistSet, - ): Promise { -- return Promise.resolve( -- fs -+ return Promise.resolve().then(() => { -+ // turbowalk bypasses the filesystem compatibility wrapper. Resolve before -+ // its missing-directory handler so an unavailable prefix still rejects. -+ sourcePath = resolveCaseInsensitiveSync(sourcePath); -+ return fs - .statAsync(sourcePath) - .then(() => - turbowalk( -@@ -386,32 +389,35 @@ - return null; - } - throw err; -- }), -- ); -+ }); -+ }); - } - - public deactivate(sourcePath: string, dataPath: string, sourceName: string): PromiseLike { -- return turbowalk( -- sourcePath, -- (entries) => { -- if (this.mContext === undefined) { -- return; -- } -- entries.forEach((entry) => { -- if (!entry.isDirectory) { -- const relPath: string = path.relative(sourcePath, entry.filePath); -- const normPath = this.mNormalize(path.join(dataPath, relPath)); -- if ( -- this.mContext.newDeployment[normPath] !== undefined && -- this.mContext.newDeployment[normPath].source === sourceName -- ) { -- delete this.mContext.newDeployment[normPath]; -+ return Promise.resolve().then(() => { -+ sourcePath = resolveCaseInsensitiveSync(sourcePath); -+ return turbowalk( -+ sourcePath, -+ (entries) => { -+ if (this.mContext === undefined) { -+ return; -+ } -+ entries.forEach((entry) => { -+ if (!entry.isDirectory) { -+ const relPath: string = path.relative(sourcePath, entry.filePath); -+ const normPath = this.mNormalize(path.join(dataPath, relPath)); -+ if ( -+ this.mContext.newDeployment[normPath] !== undefined && -+ this.mContext.newDeployment[normPath].source === sourceName -+ ) { -+ delete this.mContext.newDeployment[normPath]; -+ } - } -- } -- }); -- }, -- { skipHidden: false }, -- ); -+ }); -+ }, -+ { skipHidden: false }, -+ ); -+ }); - } - - public prePurge(): Promise { diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.staging.test.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.staging.test.ts new file mode 100644 --- /dev/null @@ -209,243 +134,101 @@ new file mode 100644 + }, + ); +} -diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/src/extensions/mod_management/index.ts ---- a/src/renderer/src/extensions/mod_management/index.ts -+++ b/src/renderer/src/extensions/mod_management/index.ts -@@ -136,6 +136,10 @@ - import { getModSources, registerModSource } from "./util/modSource"; - import sortMods from "./util/sort"; - import { setResolvedCB } from "./util/testModReference"; -+import { -+ validateDeploymentTargets, -+ validateProfileDeploymentTargets, -+} from "./util/validateDeploymentTargets"; - import ActivationButton from "./views/ActivationButton"; - import DeactivationButton from "./views/DeactivationButton"; - import type { IDuplicatesMap, IRemoveDuplicateMap } from "./views/DuplicatesDialog"; -@@ -364,30 +368,6 @@ - - if (activator.noRedundancy !== true) { - return reportRedundant(api, profile.id, overwritten); -- } --} -- --async function validateDeploymentTarget(api: IExtensionApi, undiscovered: string[]) { -- if (undiscovered.length === 0) { -- return; -- } -- const result = await api.showDialog( -- "error", -- "Deployment target unknown", -- { -- text: -- "The deployment directory for some mod type(s) ({{ types }}) " + -- "is unknown. Mods of these types will not be deployed. " + -- "Maybe this/these type(s) require further configuration or " + -- "external tools.", -- parameters: { -- types: undiscovered.join(", "), -- }, -- }, -- [{ label: "Cancel" }, { label: "Ignore" }], -- ); -- if (result.action === "Cancel") { -- throw new UserCanceled(); +diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts +--- a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts ++++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts +@@ -348,8 +348,11 @@ abstract class LinkingActivator implements IDeploymentMethod { + deployPath: string, + blackList: BlacklistSet, + ): Promise { +- return Promise.resolve( +- fs ++ return Promise.resolve().then(() => { ++ // turbowalk bypasses the filesystem compatibility wrapper. Resolve before ++ // its missing-directory handler so an unavailable prefix still rejects. ++ sourcePath = resolveCaseInsensitiveSync(sourcePath); ++ return fs + .statAsync(sourcePath) + .then(() => + turbowalk( +@@ -386,32 +389,35 @@ abstract class LinkingActivator implements IDeploymentMethod { + return null; + } + throw err; +- }), +- ); ++ }); ++ }); } - } - -@@ -630,7 +610,7 @@ - return Object.keys(modPaths).filter((typeId) => truthy(modPaths[typeId])); - } - --function genUpdateModDeployment(installManager: InstallManager) { -+export function genUpdateModDeployment(installManager: InstallManager) { - return ( - api: IExtensionApi, - manual: boolean, -@@ -683,7 +663,7 @@ - } - const stagingPath = installPathForGame(state, gameId); - -- const modPaths = game.getModPaths(gameDiscovery.path); -+ let modPaths = game.getModPaths(gameDiscovery.path); - const activator = getCurrentActivator(state, gameId, true); - - if (activator === undefined) { -@@ -774,15 +754,18 @@ - await installManager.waitForIdle(); - } -- // Consume the set of installation paths whose mods finished -- // installing or were removed since the last deployment. Their -- // external changes (refchange / srcdeleted) are expected and -- // will be auto-resolved per-mod. -- const recentChanges = installManager.consumeRecentChanges(); -+ // Installations may have changed both the mod list and its enabled state. -+ const currentState = api.getState(); -+ profile = currentState.persistent.profiles[profile.id]; -+ if (profile === undefined) { -+ throw new ProcessCanceled("The profile is no longer available."); + public deactivate(sourcePath: string, dataPath: string, sourceName: string): PromiseLike { +- return turbowalk( +- sourcePath, +- (entries) => { +- if (this.mContext === undefined) { +- return; +- } +- entries.forEach((entry) => { +- if (!entry.isDirectory) { +- const relPath: string = path.relative(sourcePath, entry.filePath); +- const normPath = this.mNormalize(path.join(dataPath, relPath)); +- if ( +- this.mContext.newDeployment[normPath] !== undefined && +- this.mContext.newDeployment[normPath].source === sourceName +- ) { +- delete this.mContext.newDeployment[normPath]; +- } ++ return Promise.resolve().then(() => { ++ sourcePath = resolveCaseInsensitiveSync(sourcePath); ++ return turbowalk( ++ sourcePath, ++ (entries) => { ++ if (this.mContext === undefined) { ++ return; + } +- }); +- }, +- { skipHidden: false }, +- ); ++ entries.forEach((entry) => { ++ if (!entry.isDirectory) { ++ const relPath: string = path.relative(sourcePath, entry.filePath); ++ const normPath = this.mNormalize(path.join(dataPath, relPath)); ++ if ( ++ this.mContext.newDeployment[normPath] !== undefined && ++ this.mContext.newDeployment[normPath].source === sourceName ++ ) { ++ delete this.mContext.newDeployment[normPath]; + } -+ let mods: Record = currentState.persistent.mods?.[gameId] ?? {}; -+ modPaths = game.getModPaths(gameDiscovery.path); -+ validateProfileDeploymentTargets(api, game, profile, mods, modPaths); - - let mergeResult: { [modType: string]: IMergeResultByType }; - const lastDeployment: { [typeId: string]: IDeployedFile[] } = {}; -- const mods: Record = state.persistent.mods?.[profile?.gameId] ?? {}; - notification.message = t("Deploying mods"); - api.sendNotification(notification); - api.store.dispatch(startActivity("mods", "deployment")); -@@ -816,6 +799,23 @@ - log("warn", "profile no longer found?", profileId); - } ++ } ++ }); ++ }, ++ { skipHidden: false }, ++ ); ++ }); + } -+ mods = updatedState.persistent.mods?.[gameId] ?? {}; -+ const updatedModPaths = game.getModPaths(gameDiscovery.path); -+ validateProfileDeploymentTargets(api, game, profile, mods, updatedModPaths); -+ if (!_.isEqual(modPaths, updatedModPaths)) { -+ // The loaded manifests belong to the original targets. Retry with -+ // a consistent set instead of applying those manifests elsewhere. -+ throw new ProcessCanceled( -+ t("Deployment folders changed during preparation. Please deploy again."), -+ ); -+ } + public prePurge(): Promise { +diff --git a/src/renderer/src/extensions/mod_management/index.deploymentTargets.test.ts b/src/renderer/src/extensions/mod_management/index.deploymentTargets.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/extensions/mod_management/index.deploymentTargets.test.ts +@@ -0,0 +1,276 @@ ++import Bluebird from "bluebird"; ++import { beforeEach, describe, expect, it, vi } from "vitest"; + -+ // Consume the set of installation paths whose mods finished -+ // installing or were removed since the last deployment. Their -+ // external changes (refchange / srcdeleted) are expected and -+ // will be auto-resolved per-mod. -+ const recentChanges = installManager.consumeRecentChanges(); -+ - progress(t("Checking for external changes"), 5); - await dealWithExternalChanges( - api, -@@ -849,10 +849,6 @@ - const deployProgress = (name, percent) => - progress(t("Deploying: ") + name, 50 + percent / 2); - -- const undiscovered = Object.keys(modPaths).filter( -- (typeId) => !truthy(modPaths[typeId]), -- ); -- await validateDeploymentTarget(api, undiscovered); - await deployAllModTypes( - api, - activator, -@@ -901,6 +897,7 @@ - if (err instanceof UserCanceled) { - // nop - } else if (err instanceof ProcessCanceled) { -+ api.store.dispatch(setDeploymentNecessary(gameId, true)); - api.sendNotification({ - type: "warning", - title: "Deployment interrupted", -@@ -1223,7 +1220,7 @@ - }; - } - --function onDeploySingleMod(api: IExtensionApi) { -+export function onDeploySingleMod(api: IExtensionApi) { - return async (gameId: string, modId: string, enable?: boolean) => { - const state: IState = api.store.getState(); - const game = getGame(gameId); -@@ -1241,8 +1238,19 @@ - return; - } - -- const dataPath = game.getModPaths(discovery.path)[mod.type || ""]; -+ const modPaths = game.getModPaths(discovery.path); -+ const dataPath = modPaths[mod.type || ""]; - if (!truthy(dataPath)) { -+ try { -+ validateDeploymentTargets(api, game, [mod], modPaths); -+ } catch (err) { -+ api.sendNotification({ -+ type: "warning", -+ title: "Deployment interrupted", -+ message: unknownToError(err).message, -+ }); -+ api.store.dispatch(setDeploymentNecessary(gameId, true)); -+ } - return; - } - const stagingPath: string = installPathForGame(state, gameId); -@@ -1262,7 +1270,33 @@ - - const subdir = genSubDirFunc(game, getModType(mod.type)); - await withActivationLock(async () => { -+ let deploymentPrepared = false; - try { -+ // Another deployment or extension setup may have changed the target -+ // while this request was waiting for the activation lock. -+ const currentState = api.getState(); -+ const currentGame = getGame(gameId); -+ const currentDiscovery = currentState.settings.gameMode.discovered?.[gameId]; -+ const currentMod = currentState.persistent.mods?.[gameId]?.[modId]; -+ if ( -+ currentGame === undefined || -+ currentDiscovery?.path === undefined || -+ currentMod === undefined -+ ) { -+ throw new ProcessCanceled(api.translate("The game or mod is no longer available.")); -+ } -+ const currentModPaths = currentGame.getModPaths(currentDiscovery.path); -+ validateDeploymentTargets(api, currentGame, [currentMod], currentModPaths); -+ if ( -+ currentModPaths[currentMod.type || ""] !== dataPath || -+ currentMod.type !== mod.type || -+ currentMod.installationPath !== mod.installationPath || -+ installPathForGame(currentState, gameId) !== stagingPath -+ ) { -+ throw new ProcessCanceled( -+ api.translate("Deployment folders changed while waiting. Please deploy again."), -+ ); -+ } - const normalize = await getDeploymentNormalizeFunc(game, discovery.path, dataPath); - const lastActivation = await loadActivation( - api, -@@ -1273,6 +1307,7 @@ - activator, - ); - resetDeploymentFailures(api, gameId); -+ deploymentPrepared = true; - await activator.prepare(dataPath, false, lastActivation, normalize); - if (mod !== undefined) { - if (enable !== false) { -@@ -1298,11 +1333,12 @@ - ); - reportRecordedFailures(api, gameId); - } catch (unknownErr) { -- if (activator.cancel !== undefined) { -+ if (deploymentPrepared && activator.cancel !== undefined) { - activator.cancel(gameId, dataPath, stagingPath); - } - const err = unknownToError(unknownErr); - if (err instanceof ProcessCanceled) { -+ api.store.dispatch(setDeploymentNecessary(gameId, true)); - api.sendNotification({ - type: "warning", - title: "Deployment interrupted", -diff --git a/src/renderer/src/extensions/mod_management/index.deploymentTargets.test.ts b/src/renderer/src/extensions/mod_management/index.deploymentTargets.test.ts -new file mode 100644 ---- /dev/null -+++ b/src/renderer/src/extensions/mod_management/index.deploymentTargets.test.ts -@@ -0,0 +1,275 @@ -+import Bluebird from "bluebird"; -+import { beforeEach, describe, expect, it, vi } from "vitest"; -+ -+import type { IExtensionApi } from "../../types/IExtensionContext"; -+import type { IGame } from "../../types/IGame"; -+import type { IState } from "../../types/IState"; -+import { getGame } from "../gamemode_management/util/getGame"; -+import { getModType } from "../gamemode_management/util/modTypeExtensions"; -+import { setDeploymentNecessary } from "./actions/deployment"; -+import { genUpdateModDeployment, onDeploySingleMod } from "./index"; -+import type InstallManager from "./InstallManager"; -+import type { IDeploymentMethod } from "./types/IDeploymentMethod"; -+import { loadActivation, withActivationLock } from "./util/activationStore"; -+import { getCurrentActivator } from "./util/deploymentMethods"; -+import { dealWithExternalChanges } from "./util/externalChanges"; ++import type { IExtensionApi } from "../../types/IExtensionContext"; ++import type { IGame } from "../../types/IGame"; ++import type { IState } from "../../types/IState"; ++import { getGame } from "../gamemode_management/util/getGame"; ++import { getModType } from "../gamemode_management/util/modTypeExtensions"; ++import { setDeploymentNecessary } from "./actions/deployment"; ++import { genUpdateModDeployment, onDeploySingleMod } from "./index"; ++import type InstallManager from "./InstallManager"; ++import type { IDeploymentMethod } from "./types/IDeploymentMethod"; ++import { loadActivation, withActivationLock } from "./util/activationStore"; ++import { getCurrentActivator } from "./util/deploymentMethods"; ++import { dealWithExternalChanges } from "./util/externalChanges"; + +vi.mock("../gamemode_management/util/getGame", () => ({ getGame: vi.fn() })); +vi.mock("../gamemode_management/util/modTypeExtensions", () => ({ getModType: vi.fn() })); @@ -474,6 +257,7 @@ new file mode 100644 + settings: { + gameMode: { discovered: { [gameId]: { path: "/game" } } }, + mods: { installPath: { [gameId]: "/staging" } }, ++ window: { useModernLayout: false }, + }, + persistent: { + profiles: { profile: { id: "profile", gameId, modState: { config: { enabled: true } } } }, @@ -706,75 +490,223 @@ new file mode 100644 + expect(api.sendNotification).not.toHaveBeenCalled(); + }); +}); -diff --git a/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.ts b/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.ts -new file mode 100644 ---- /dev/null -+++ b/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.ts -@@ -0,0 +1,64 @@ -+import type { IExtensionApi } from "../../../types/IExtensionContext"; -+import type { IGame } from "../../../types/IGame"; -+import { ProcessCanceled } from "../../../util/CustomErrors"; -+import { getModType } from "../../gamemode_management/util/modTypeExtensions"; -+import type { IProfile } from "../../profile_management/types/IProfile"; -+import type { IMod } from "../types/IMod"; +diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/src/extensions/mod_management/index.ts +--- a/src/renderer/src/extensions/mod_management/index.ts ++++ b/src/renderer/src/extensions/mod_management/index.ts +@@ -133,6 +133,10 @@ import renderModName from "./util/modName"; + import { getModSources, registerModSource } from "./util/modSource"; + import sortMods from "./util/sort"; + import { setResolvedCB } from "./util/testModReference"; ++import { ++ validateDeploymentTargets, ++ validateProfileDeploymentTargets, ++} from "./util/validateDeploymentTargets"; + import ActivationButton from "./views/ActivationButton"; + import DeactivationButton from "./views/DeactivationButton"; + import type { IDuplicatesMap, IRemoveDuplicateMap } from "./views/DuplicatesDialog"; +@@ -364,30 +368,6 @@ async function deployAllModTypes( + } + } + +-async function validateDeploymentTarget(api: IExtensionApi, undiscovered: string[]) { +- if (undiscovered.length === 0) { +- return; +- } +- const result = await api.showDialog( +- "error", +- "Deployment target unknown", +- { +- text: +- "The deployment directory for some mod type(s) ({{ types }}) " + +- "is unknown. Mods of these types will not be deployed. " + +- "Maybe this/these type(s) require further configuration or " + +- "external tools.", +- parameters: { +- types: undiscovered.join(", "), +- }, +- }, +- [{ label: "Cancel" }, { label: "Ignore" }], +- ); +- if (result.action === "Cancel") { +- throw new UserCanceled(); +- } +-} +- + function checkIncompatibilities( + api: IExtensionApi, + profile: IProfile, +@@ -627,7 +607,7 @@ function deployableModTypes(modPaths: { [typeId: string]: string }) { + return Object.keys(modPaths).filter((typeId) => truthy(modPaths[typeId])); + } + +-function genUpdateModDeployment(installManager: InstallManager) { ++export function genUpdateModDeployment(installManager: InstallManager) { + return ( + api: IExtensionApi, + manual: boolean, +@@ -685,7 +665,7 @@ function genUpdateModDeployment(installManager: InstallManager) { + } + const stagingPath = installPathForGame(state, gameId); + +- const modPaths = game.getModPaths(gameDiscovery.path); ++ let modPaths = game.getModPaths(gameDiscovery.path); + const activator = getCurrentActivator(state, gameId, true); + + if (activator === undefined) { +@@ -778,15 +758,18 @@ function genUpdateModDeployment(installManager: InstallManager) { + await installManager.waitForIdle(); + } + +- // Consume the set of installation paths whose mods finished +- // installing or were removed since the last deployment. Their +- // external changes (refchange / srcdeleted) are expected and +- // will be auto-resolved per-mod. +- const recentChanges = installManager.consumeRecentChanges(); ++ // Installations may have changed both the mod list and its enabled state. ++ const currentState = api.getState(); ++ profile = currentState.persistent.profiles[profile.id]; ++ if (profile === undefined) { ++ throw new ProcessCanceled("The profile is no longer available."); ++ } ++ let mods: Record = currentState.persistent.mods?.[gameId] ?? {}; ++ modPaths = game.getModPaths(gameDiscovery.path); ++ validateProfileDeploymentTargets(api, game, profile, mods, modPaths); + + let mergeResult: { [modType: string]: IMergeResultByType }; + const lastDeployment: { [typeId: string]: IDeployedFile[] } = {}; +- const mods: Record = state.persistent.mods?.[profile?.gameId] ?? {}; + notification.message = t("Deploying mods"); + if (showNotification) { + api.sendNotification(notification); +@@ -822,6 +805,23 @@ function genUpdateModDeployment(installManager: InstallManager) { + log("warn", "profile no longer found?", profileId); + } + ++ mods = updatedState.persistent.mods?.[gameId] ?? {}; ++ const updatedModPaths = game.getModPaths(gameDiscovery.path); ++ validateProfileDeploymentTargets(api, game, profile, mods, updatedModPaths); ++ if (!_.isEqual(modPaths, updatedModPaths)) { ++ // The loaded manifests belong to the original targets. Retry with ++ // a consistent set instead of applying those manifests elsewhere. ++ throw new ProcessCanceled( ++ t("Deployment folders changed during preparation. Please deploy again."), ++ ); ++ } + -+/** Validate only mods being deployed; supported types returning undefined intentionally do not deploy. */ -+export function validateDeploymentTargets( -+ api: IExtensionApi, -+ game: IGame, -+ mods: IMod[], -+ modPaths: { [typeId: string]: string }, -+): void { -+ const missing = new Map(); -+ for (const mod of mods) { -+ if (mod.state !== "installed") { -+ continue; -+ } -+ const typeId = mod.type || ""; -+ if (Object.prototype.hasOwnProperty.call(modPaths, typeId) && modPaths[typeId]) { -+ continue; -+ } -+ const type = getModType(typeId); -+ if (type?.isSupported(game.id) && type.getPath(game) === undefined) { -+ // Collections and externally managed installers (for example UMM). -+ continue; -+ } -+ const names = missing.get(typeId) ?? []; -+ names.push(mod.attributes?.customFileName || mod.attributes?.name || mod.id); -+ missing.set(typeId, names); -+ } -+ if (missing.size > 0) { -+ throw new ProcessCanceled( -+ api.translate( -+ "Deployment cannot continue because these mods have no supported target folder: {{mods}}. " + -+ "Check the game extension and its configuration, or disable these mods before deploying again.", -+ { -+ replace: { -+ mods: Array.from( -+ missing, -+ ([typeId, names]) => `${names.join(", ")} (${typeId || api.translate("Default")})`, -+ ).join("; "), -+ }, -+ }, -+ ), -+ ); -+ } -+} ++ // Consume the set of installation paths whose mods finished ++ // installing or were removed since the last deployment. Their ++ // external changes (refchange / srcdeleted) are expected and ++ // will be auto-resolved per-mod. ++ const recentChanges = installManager.consumeRecentChanges(); + -+export function validateProfileDeploymentTargets( -+ api: IExtensionApi, -+ game: IGame, -+ profile: IProfile, -+ mods: Record, -+ modPaths: { [typeId: string]: string }, -+): void { -+ validateDeploymentTargets( -+ api, -+ game, -+ Object.values(mods).filter((mod) => profile.modState?.[mod.id]?.enabled), -+ modPaths, -+ ); -+} + progress(t("Checking for external changes"), 5); + await dealWithExternalChanges( + api, +@@ -855,10 +855,6 @@ function genUpdateModDeployment(installManager: InstallManager) { + const deployProgress = (name, percent) => + progress(t("Deploying: ") + name, 50 + percent / 2, name); + +- const undiscovered = Object.keys(modPaths).filter( +- (typeId) => !truthy(modPaths[typeId]), +- ); +- await validateDeploymentTarget(api, undiscovered); + await deployAllModTypes( + api, + activator, +@@ -907,6 +903,7 @@ function genUpdateModDeployment(installManager: InstallManager) { + if (err instanceof UserCanceled) { + // nop + } else if (err instanceof ProcessCanceled) { ++ api.store.dispatch(setDeploymentNecessary(gameId, true)); + api.sendNotification({ + type: "warning", + title: "Deployment interrupted", +@@ -1235,7 +1232,7 @@ function onModsEnabled(api: IExtensionApi, deploymentTimer: Debouncer) { + }; + } + +-function onDeploySingleMod(api: IExtensionApi) { ++export function onDeploySingleMod(api: IExtensionApi) { + return async (gameId: string, modId: string, enable?: boolean) => { + const state: IState = api.store.getState(); + const game = getGame(gameId); +@@ -1253,8 +1250,19 @@ function onDeploySingleMod(api: IExtensionApi) { + return; + } + +- const dataPath = game.getModPaths(discovery.path)[mod.type || ""]; ++ const modPaths = game.getModPaths(discovery.path); ++ const dataPath = modPaths[mod.type || ""]; + if (!truthy(dataPath)) { ++ try { ++ validateDeploymentTargets(api, game, [mod], modPaths); ++ } catch (err) { ++ api.sendNotification({ ++ type: "warning", ++ title: "Deployment interrupted", ++ message: unknownToError(err).message, ++ }); ++ api.store.dispatch(setDeploymentNecessary(gameId, true)); ++ } + return; + } + const stagingPath: string = installPathForGame(state, gameId); +@@ -1274,7 +1282,33 @@ function onDeploySingleMod(api: IExtensionApi) { + + const subdir = genSubDirFunc(game, getModType(mod.type)); + await withActivationLock(async () => { ++ let deploymentPrepared = false; + try { ++ // Another deployment or extension setup may have changed the target ++ // while this request was waiting for the activation lock. ++ const currentState = api.getState(); ++ const currentGame = getGame(gameId); ++ const currentDiscovery = currentState.settings.gameMode.discovered?.[gameId]; ++ const currentMod = currentState.persistent.mods?.[gameId]?.[modId]; ++ if ( ++ currentGame === undefined || ++ currentDiscovery?.path === undefined || ++ currentMod === undefined ++ ) { ++ throw new ProcessCanceled(api.translate("The game or mod is no longer available.")); ++ } ++ const currentModPaths = currentGame.getModPaths(currentDiscovery.path); ++ validateDeploymentTargets(api, currentGame, [currentMod], currentModPaths); ++ if ( ++ currentModPaths[currentMod.type || ""] !== dataPath || ++ currentMod.type !== mod.type || ++ currentMod.installationPath !== mod.installationPath || ++ installPathForGame(currentState, gameId) !== stagingPath ++ ) { ++ throw new ProcessCanceled( ++ api.translate("Deployment folders changed while waiting. Please deploy again."), ++ ); ++ } + const normalize = await getDeploymentNormalizeFunc(game, discovery.path, dataPath); + const lastActivation = await loadActivation( + api, +@@ -1285,6 +1319,7 @@ function onDeploySingleMod(api: IExtensionApi) { + activator, + ); + resetDeploymentFailures(api, gameId); ++ deploymentPrepared = true; + await activator.prepare(dataPath, false, lastActivation, normalize); + if (mod !== undefined) { + if (enable !== false) { +@@ -1310,11 +1345,12 @@ function onDeploySingleMod(api: IExtensionApi) { + ); + reportRecordedFailures(api, gameId); + } catch (unknownErr) { +- if (activator.cancel !== undefined) { ++ if (deploymentPrepared && activator.cancel !== undefined) { + activator.cancel(gameId, dataPath, stagingPath); + } + const err = unknownToError(unknownErr); + if (err instanceof ProcessCanceled) { ++ api.store.dispatch(setDeploymentNecessary(gameId, true)); + api.sendNotification({ + type: "warning", + title: "Deployment interrupted", diff --git a/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.test.ts b/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.test.ts new file mode 100644 --- /dev/null @@ -880,10 +812,79 @@ new file mode 100644 + ).toThrow(ProcessCanceled); + }); +}); +diff --git a/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.ts b/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.ts +@@ -0,0 +1,64 @@ ++import type { IExtensionApi } from "../../../types/IExtensionContext"; ++import type { IGame } from "../../../types/IGame"; ++import { ProcessCanceled } from "../../../util/CustomErrors"; ++import { getModType } from "../../gamemode_management/util/modTypeExtensions"; ++import type { IProfile } from "../../profile_management/types/IProfile"; ++import type { IMod } from "../types/IMod"; ++ ++/** Validate only mods being deployed; supported types returning undefined intentionally do not deploy. */ ++export function validateDeploymentTargets( ++ api: IExtensionApi, ++ game: IGame, ++ mods: IMod[], ++ modPaths: { [typeId: string]: string }, ++): void { ++ const missing = new Map(); ++ for (const mod of mods) { ++ if (mod.state !== "installed") { ++ continue; ++ } ++ const typeId = mod.type || ""; ++ if (Object.prototype.hasOwnProperty.call(modPaths, typeId) && modPaths[typeId]) { ++ continue; ++ } ++ const type = getModType(typeId); ++ if (type?.isSupported(game.id) && type.getPath(game) === undefined) { ++ // Collections and externally managed installers (for example UMM). ++ continue; ++ } ++ const names = missing.get(typeId) ?? []; ++ names.push(mod.attributes?.customFileName || mod.attributes?.name || mod.id); ++ missing.set(typeId, names); ++ } ++ if (missing.size > 0) { ++ throw new ProcessCanceled( ++ api.translate( ++ "Deployment cannot continue because these mods have no supported target folder: {{mods}}. " + ++ "Check the game extension and its configuration, or disable these mods before deploying again.", ++ { ++ replace: { ++ mods: Array.from( ++ missing, ++ ([typeId, names]) => `${names.join(", ")} (${typeId || api.translate("Default")})`, ++ ).join("; "), ++ }, ++ }, ++ ), ++ ); ++ } ++} ++ ++export function validateProfileDeploymentTargets( ++ api: IExtensionApi, ++ game: IGame, ++ profile: IProfile, ++ mods: Record, ++ modPaths: { [typeId: string]: string }, ++): void { ++ validateDeploymentTargets( ++ api, ++ game, ++ Object.values(mods).filter((mod) => profile.modState?.[mod.id]?.enabled), ++ modPaths, ++ ); ++} diff --git a/src/renderer/src/extensions/symlink_activator/index.ts b/src/renderer/src/extensions/symlink_activator/index.ts --- a/src/renderer/src/extensions/symlink_activator/index.ts +++ b/src/renderer/src/extensions/symlink_activator/index.ts -@@ -9,6 +9,7 @@ +@@ -9,6 +9,7 @@ import { UserCanceled } from "../../util/CustomErrors"; import * as fs from "../../util/fs"; import getVortexPath from "../../util/getVortexPath"; import type { TFunction } from "../../util/i18n"; @@ -891,7 +892,7 @@ diff --git a/src/renderer/src/extensions/symlink_activator/index.ts b/src/render import { log } from "../../util/log"; import { activeGameId, gameName } from "../../util/selectors"; import walk from "../../util/walk"; -@@ -222,7 +223,7 @@ +@@ -222,7 +223,7 @@ class DeploymendMethod extends LinkingDeployment { return ( fs .readlinkAsync(linkPath) diff --git a/patches/0034-linux-case-only-platform-paths.patch b/patches/0034-linux-case-only-platform-paths.patch index 376b6a7..11ed072 100644 --- a/patches/0034-linux-case-only-platform-paths.patch +++ b/patches/0034-linux-case-only-platform-paths.patch @@ -1,3 +1,69 @@ +diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts +index 9bd1743..a97d31e 100644 +--- a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts ++++ b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts +@@ -5,6 +5,7 @@ import * as path from "node:path"; + import type { LootAsync } from "loot"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + ++import { setCompatibilityGameProvider } from "../../../util/compatibilityPaths"; + import { adaptLootForLinux, resolvePluginPaths } from "./linuxLoot"; + + describe.runIf(process.platform === "linux")("LOOT plugin paths on Linux", () => { +@@ -31,6 +32,25 @@ describe.runIf(process.platform === "linux")("LOOT plugin paths on Linux", () => + ]); + }); + ++ it("keeps a game installed under Documents on the host, not in the game's prefix", () => { ++ const documents = path.join(game, "Documents"); ++ const host = path.join(documents, "Games", "Skyrim", "Data"); ++ const prefix = path.join(game, "prefix"); ++ const stale = path.join(prefix, "drive_c/users/steamuser/Documents/Games/Skyrim/Data"); ++ for (const directory of [host, stale]) { ++ fs.mkdirSync(directory, { recursive: true }); ++ fs.writeFileSync(path.join(directory, "Skyrim.esm"), ""); ++ } ++ vi.stubEnv("VORTEX_LINUX_DOCUMENTS", documents); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ try { ++ expect(resolvePluginPaths(host, ["skyrim.esm"])).toEqual([path.join(host, "Skyrim.esm")]); ++ } finally { ++ setCompatibilityGameProvider(undefined); ++ vi.unstubAllEnvs(); ++ } ++ }); ++ + it("passes a missing plugin on as given so libloot reports it", () => { + expect(resolvePluginPaths(data, ["missing.esp", "/elsewhere/Gone.esp"])).toEqual([ + "missing.esp", +diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts +index e54d6f7..7d8d6e4 100644 +--- a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts ++++ b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.ts +@@ -3,19 +3,20 @@ import * as path from "node:path"; + + import type { LootAsync } from "loot"; + +-import { resolveCaseInsensitiveSync } from "../../../util/linuxCompatibility"; ++import { matchExistingCaseSync } from "../../../util/linuxCompatibility"; + + /** + * libloot opens plugins at exactly the paths it is given. Vortex passes plugin IDs, which are + * lower case, and on a case-sensitive filesystem neither they nor a "data" folder spelled + * differently from "Data" would be found. Resolve each one to the file on disk, ghosted or not. +- * A plugin that isn't there is passed on as given, so libloot reports it. ++ * A plugin that isn't there is passed on as given, so libloot reports it. Game files never go ++ * through the Wine user-folder mapping, so a game installed under ~/Documents stays where it is. + */ + export function resolvePluginPaths(dataPath: string, plugins: string[]): string[] { + return plugins.map((plugin) => { + const requested = path.isAbsolute(plugin) ? plugin : path.join(dataPath, plugin); + for (const candidate of [requested, `${requested}.ghost`]) { +- const resolved = resolveCaseInsensitiveSync(candidate); ++ const resolved = matchExistingCaseSync(candidate); + if (fs.existsSync(resolved)) return resolved; + } + return plugin; diff --git a/src/renderer/src/util/linuxCompatibility.test.ts b/src/renderer/src/util/linuxCompatibility.test.ts index 4821f76..12f94eb 100644 --- a/src/renderer/src/util/linuxCompatibility.test.ts diff --git a/patches/0035-linux-backslash-archive-paths.patch b/patches/0035-linux-backslash-archive-paths.patch new file mode 100644 index 0000000..25ae101 --- /dev/null +++ b/patches/0035-linux-backslash-archive-paths.patch @@ -0,0 +1,164 @@ +diff --git a/src/renderer/src/extensions/mod_management/InstallManager.ts b/src/renderer/src/extensions/mod_management/InstallManager.ts +--- a/src/renderer/src/extensions/mod_management/InstallManager.ts ++++ b/src/renderer/src/extensions/mod_management/InstallManager.ts +@@ -187,6 +187,7 @@ import type { IModInstaller, ISupportedInstaller } from "./types/IModInstaller"; + import type { IInstallationDetails, InstallFunc } from "./types/InstallFunc"; + import type { IReplaceChoice, ReplaceChoice } from "./types/IReplaceChoice"; + import type { ISupportedResult, ITestSupportedDetails, TestSupported } from "./types/TestSupported"; ++import { splitBackslashNames } from "./util/archivePaths"; + import { getCSharpScriptAllowListForGame } from "./util/cSharpScriptAllowList"; + import gatherDependencies, { + findDownloadByRef, +@@ -254,6 +255,9 @@ class InstructionGroups { + } + + async function buildFileList(basePath: string): Promise { ++ if (process.platform === "linux") { ++ await splitBackslashNames(basePath); ++ } + const fileList: string[] = []; + await walk(basePath, (iterPath, stats) => { + const relPath = path.normalize(path.relative(basePath, iterPath)); +diff --git a/src/renderer/src/extensions/mod_management/util/archivePaths.test.ts b/src/renderer/src/extensions/mod_management/util/archivePaths.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/extensions/mod_management/util/archivePaths.test.ts +@@ -0,0 +1,92 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { afterEach, beforeEach, describe, expect, it } from "vitest"; ++ ++import { splitBackslashNames } from "./archivePaths"; ++ ++function tree(root: string): string[] { ++ const result: string[] = []; ++ const walk = (directory: string) => { ++ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { ++ const full = path.join(directory, entry.name); ++ const relative = path.relative(root, full); ++ if (entry.isDirectory()) { ++ result.push(`${relative}/`); ++ walk(full); ++ } else { ++ result.push(`${relative}=${fs.readFileSync(full, "utf8")}`); ++ } ++ } ++ }; ++ walk(root); ++ return result.sort(); ++} ++ ++describe.runIf(process.platform === "linux")("archive entries with backslash separators", () => { ++ let root: string; ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-archive-paths-")); ++ }); ++ afterEach(() => fs.rmSync(root, { recursive: true, force: true })); ++ ++ it("turns the names 7-Zip extracts from a Windows-style archive into folders", async () => { ++ // What 7-Zip on Linux extracts from the Appearance Runtime Plugin archive. ++ fs.mkdirSync(path.join(root, "red4ext\\plugins\\")); ++ fs.writeFileSync( ++ path.join(root, "red4ext\\plugins\\AppearanceRuntimePlugin\\AppearanceRuntimePlugin.dll"), ++ "dll", ++ ); ++ fs.mkdirSync(path.join(root, "normal")); ++ fs.writeFileSync(path.join(root, "normal", "readme.txt"), "readme"); ++ ++ await splitBackslashNames(root); ++ ++ expect(tree(root)).toEqual([ ++ "normal/", ++ "normal/readme.txt=readme", ++ "red4ext/", ++ "red4ext/plugins/", ++ "red4ext/plugins/AppearanceRuntimePlugin/", ++ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll", ++ ]); ++ }); ++ ++ it("merges into folders that already exist, also below a renamed folder", async () => { ++ fs.mkdirSync(path.join(root, "Data", "Scripts"), { recursive: true }); ++ fs.writeFileSync(path.join(root, "Data", "Scripts", "kept.pex"), "kept"); ++ fs.mkdirSync(path.join(root, "Data\\Scripts")); ++ fs.writeFileSync(path.join(root, "Data\\Scripts", "added.pex"), "added"); ++ fs.writeFileSync(path.join(root, "Data\\Scripts", "Source\\added.psc"), "source"); ++ ++ await splitBackslashNames(root); ++ ++ expect(tree(root)).toEqual([ ++ "Data/", ++ "Data/Scripts/", ++ "Data/Scripts/Source/", ++ "Data/Scripts/Source/added.psc=source", ++ "Data/Scripts/added.pex=added", ++ "Data/Scripts/kept.pex=kept", ++ ]); ++ }); ++ ++ it("keeps entries inside the extraction folder", async () => { ++ fs.writeFileSync(path.join(root, "..\\..\\escape.txt"), "x"); ++ fs.writeFileSync(path.join(root, "\\absolute.txt"), "y"); ++ ++ await splitBackslashNames(root); ++ ++ expect(tree(root)).toEqual(["absolute.txt=y", "escape.txt=x"]); ++ }); ++ ++ it("leaves archives without backslashes untouched", async () => { ++ fs.mkdirSync(path.join(root, "Data")); ++ fs.writeFileSync(path.join(root, "Data", "plugin.esp"), "esp"); ++ ++ await splitBackslashNames(root); ++ ++ expect(tree(root)).toEqual(["Data/", "Data/plugin.esp=esp"]); ++ }); ++}); +diff --git a/src/renderer/src/extensions/mod_management/util/archivePaths.ts b/src/renderer/src/extensions/mod_management/util/archivePaths.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/extensions/mod_management/util/archivePaths.ts +@@ -0,0 +1,41 @@ ++import * as fs from "node:fs/promises"; ++import * as path from "node:path"; ++ ++/** ++ * Some archives made on Windows separate folders with "\" instead of "/". 7-Zip on Linux keeps ++ * the backslashes as part of the name, so "red4ext\plugins\Mod.dll" is extracted as one file. ++ * Move every such entry to the folders its name describes, merging with folders that already ++ * exist. "." and ".." segments are dropped, so nothing can leave the extraction folder. ++ */ ++export async function splitBackslashNames(root: string): Promise { ++ const visit = async (directory: string): Promise => { ++ for (const entry of await fs.readdir(directory, { withFileTypes: true })) { ++ const source = path.join(directory, entry.name); ++ if (!entry.name.includes("\\")) { ++ if (entry.isDirectory()) await visit(source); ++ continue; ++ } ++ const parts = entry.name ++ .split("\\") ++ .filter((part) => part.length > 0 && part !== "." && part !== ".."); ++ if (parts.length === 0) continue; ++ const target = path.join(directory, ...parts); ++ await move(source, target, entry.isDirectory()); ++ if (entry.isDirectory()) await visit(target); ++ } ++ }; ++ await visit(root); ++} ++ ++async function move(source: string, target: string, isDirectory: boolean): Promise { ++ const existing = await fs.stat(target).catch(() => undefined); ++ if (isDirectory && existing?.isDirectory()) { ++ for (const child of await fs.readdir(source, { withFileTypes: true })) { ++ await move(path.join(source, child.name), path.join(target, child.name), child.isDirectory()); ++ } ++ await fs.rmdir(source); ++ return; ++ } ++ await fs.mkdir(path.dirname(target), { recursive: true }); ++ await fs.rename(source, target); ++} diff --git a/patches/0036-linux-appimage-nxm-links.patch b/patches/0036-linux-appimage-nxm-links.patch new file mode 100644 index 0000000..87bda74 --- /dev/null +++ b/patches/0036-linux-appimage-nxm-links.patch @@ -0,0 +1,186 @@ +diff --git a/src/renderer/src/util/protocolRegistration/linux/nxm.appimage.test.ts b/src/renderer/src/util/protocolRegistration/linux/nxm.appimage.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/protocolRegistration/linux/nxm.appimage.test.ts +@@ -0,0 +1,90 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; ++ ++const common = vi.hoisted(() => ({ ++ applications: "", ++ refreshDesktopDatabase: vi.fn(), ++ getDefaultUrlSchemeHandler: vi.fn(), ++ setDefaultUrlSchemeHandler: vi.fn(), ++})); ++ ++vi.mock("../../log", () => ({ log: vi.fn() })); ++vi.mock("./common", () => ({ ++ applicationsDirectory: () => common.applications, ++ refreshDesktopDatabase: common.refreshDesktopDatabase, ++ getDefaultUrlSchemeHandler: common.getDefaultUrlSchemeHandler, ++ setDefaultUrlSchemeHandler: common.setDefaultUrlSchemeHandler, ++})); ++ ++import { registerLinuxNxmProtocolHandler } from "./nxm"; ++ ++const options = { setAsDefault: true, executablePath: "/tmp/.mount_x/vortex", appPath: "" }; ++ ++describe.runIf(process.platform === "linux")("nxm links from an AppImage", () => { ++ let root: string; ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-nxm-")); ++ common.applications = path.join(root, "applications"); ++ vi.stubEnv("NODE_ENV", "production"); ++ vi.stubEnv("IS_FLATPAK", ""); ++ vi.clearAllMocks(); ++ }); ++ afterEach(() => { ++ vi.unstubAllEnvs(); ++ fs.rmSync(root, { recursive: true, force: true }); ++ }); ++ ++ it("registers a desktop entry that starts the AppImage file itself", () => { ++ const appImage = path.join(root, "My Apps", "Vortex-2.9.0-x86_64.AppImage"); ++ vi.stubEnv("APPIMAGE", appImage); ++ common.getDefaultUrlSchemeHandler.mockReturnValue("org.mo2.desktop"); ++ ++ expect(registerLinuxNxmProtocolHandler(options)).toBe(true); ++ ++ const desktop = fs.readFileSync( ++ path.join(common.applications, "com.nexusmods.vortex.appimage.desktop"), ++ "utf8", ++ ); ++ const wrapper = path.join(common.applications, "com.nexusmods.vortex.appimage.sh"); ++ expect(desktop).toContain(`Exec=${wrapper} %u\n`); ++ expect(desktop).toContain("MimeType=x-scheme-handler/nxm;\n"); ++ expect(desktop).toContain("NoDisplay=true\n"); ++ expect(fs.readFileSync(wrapper, "utf8")).toContain(`exec "${appImage}" "$@"\n`); ++ expect(fs.statSync(wrapper).mode & 0o111).not.toBe(0); ++ expect(common.refreshDesktopDatabase).toHaveBeenCalledWith(common.applications); ++ expect(common.setDefaultUrlSchemeHandler).toHaveBeenCalledWith( ++ "nxm", ++ "com.nexusmods.vortex.appimage.desktop", ++ ); ++ }); ++ ++ it("follows the AppImage when it was moved or replaced", () => { ++ vi.stubEnv("APPIMAGE", path.join(root, "old.AppImage")); ++ registerLinuxNxmProtocolHandler(options); ++ common.refreshDesktopDatabase.mockClear(); ++ ++ vi.stubEnv("APPIMAGE", path.join(root, "new.AppImage")); ++ registerLinuxNxmProtocolHandler(options); ++ ++ const wrapper = path.join(common.applications, "com.nexusmods.vortex.appimage.sh"); ++ expect(fs.readFileSync(wrapper, "utf8")).toContain(`exec "${path.join(root, "new.AppImage")}"`); ++ expect(common.refreshDesktopDatabase).toHaveBeenCalledOnce(); ++ ++ common.refreshDesktopDatabase.mockClear(); ++ registerLinuxNxmProtocolHandler(options); ++ expect(common.refreshDesktopDatabase).not.toHaveBeenCalled(); ++ }); ++ ++ it("leaves package installs on the packaged desktop file", () => { ++ vi.stubEnv("APPIMAGE", ""); ++ registerLinuxNxmProtocolHandler(options); ++ expect(fs.existsSync(common.applications)).toBe(false); ++ expect(common.setDefaultUrlSchemeHandler).toHaveBeenCalledWith( ++ "nxm", ++ "com.nexusmods.vortex.desktop", ++ ); ++ }); ++}); +diff --git a/src/renderer/src/util/protocolRegistration/linux/nxm.ts b/src/renderer/src/util/protocolRegistration/linux/nxm.ts +--- a/src/renderer/src/util/protocolRegistration/linux/nxm.ts ++++ b/src/renderer/src/util/protocolRegistration/linux/nxm.ts +@@ -28,6 +28,9 @@ const NXM_PROTOCOL = "nxm"; + const PACKAGE_DESKTOP_ID = "com.nexusmods.vortex.desktop"; + const DEV_DESKTOP_ID = "com.nexusmods.vortex.dev.desktop"; + const DEV_WRAPPER_FILE_NAME = "com.nexusmods.vortex.dev.sh"; ++// An AppImage installs no desktop file, so it registers its own entry pointing at the image. ++const APPIMAGE_DESKTOP_ID = "com.nexusmods.vortex.appimage.desktop"; ++const APPIMAGE_WRAPPER_FILE_NAME = "com.nexusmods.vortex.appimage.sh"; + + /** + * Required registration inputs for Linux `nxm` routing. +@@ -60,6 +63,8 @@ export function registerLinuxNxmProtocolHandler( + options.executablePath, + options.appPath, + ); ++ } else if (desktopId === APPIMAGE_DESKTOP_ID) { ++ didChangeDesktopFiles = ensureAppImageDesktopEntry(applicationsDir, appImagePath()!); + } + + if (didChangeDesktopFiles) { +@@ -94,6 +99,12 @@ function isFlatpakBuild(): boolean { + return process.env.IS_FLATPAK === "true"; + } + ++/** The AppImage file itself; the mounted directory Vortex runs from changes every launch. */ ++function appImagePath(): string | undefined { ++ const appImage = process.env.APPIMAGE; ++ return appImage !== undefined && path.isAbsolute(appImage) ? appImage : undefined; ++} ++ + function isDevelopmentBuild(): boolean { + return process.defaultApp === true || process.env.NODE_ENV === "development"; + } +@@ -103,6 +114,10 @@ function desktopIdForCurrentBuild(): string { + return PACKAGE_DESKTOP_ID; + } + ++ if (appImagePath() !== undefined) { ++ return APPIMAGE_DESKTOP_ID; ++ } ++ + if (isDevelopmentBuild()) { + return DEV_DESKTOP_ID; + } +@@ -251,3 +266,44 @@ function ensureDevDesktopEntry( + + return wrapperChanged || desktopChanged; + } ++ ++/** ++ * Write the desktop entry an AppImage uses for nxm:// links. The wrapper runs the AppImage ++ * file, whose AppRun adds --download for nxm:// arguments. Vortex rewrites both on every ++ * registration, so moving or replacing the AppImage only needs one start of the new file. ++ */ ++function ensureAppImageDesktopEntry(applicationsDir: string, appImage: string): boolean { ++ const wrapperPath = path.join(applicationsDir, APPIMAGE_WRAPPER_FILE_NAME); ++ const desktopFilePath = path.join(applicationsDir, APPIMAGE_DESKTOP_ID); ++ ++ warnIfApplicationsPathNeedsEscaping(applicationsDir); ++ ++ const wrapperChanged = writeFileIfChanged( ++ wrapperPath, ++ "#!/bin/sh\n" + ++ "unset LD_LIBRARY_PATH\n" + ++ "unset LD_PRELOAD\n" + ++ `exec "${escapeShellScriptArgument(appImage)}" "$@"\n`, ++ 0o755, ++ ); ++ ++ // Hidden from menus: integrating the AppImage itself is left to AppImageLauncher or Gear Lever. ++ const desktopChanged = writeFileIfChanged( ++ desktopFilePath, ++ "[Desktop Entry]\n" + ++ "Type=Application\n" + ++ "Name=Vortex (AppImage)\n" + ++ "GenericName=Mod Manager\n" + ++ "Comment=Handles Nexus Mods download links for the Vortex AppImage\n" + ++ "NoDisplay=true\n" + ++ `Exec=${escapeDesktopExecFilePath(wrapperPath)} %u\n` + ++ `TryExec=${escapeDesktopFilePath(wrapperPath)}\n` + ++ "Icon=vortex\n" + ++ "Terminal=false\n" + ++ "MimeType=x-scheme-handler/nxm;\n" + ++ "StartupWMClass=Vortex\n", ++ 0o755, ++ ); ++ ++ return wrapperChanged || desktopChanged; ++} diff --git a/scripts/build-test-libloot.sh b/scripts/build-test-libloot.sh deleted file mode 100755 index c828db8..0000000 --- a/scripts/build-test-libloot.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -readonly WORK_DIRECTORY="${1:?Usage: build-test-libloot.sh WORK_DIRECTORY}" -readonly commit="$(sed -n "s/^_libloot_commit='\([^']*\)'$/\1/p" "$REPOSITORY_ROOT/PKGBUILD")" -readonly checksum="$(sed -n "s/^_libloot_sha512='\([^']*\)'$/\1/p" "$REPOSITORY_ROOT/PKGBUILD")" -[[ "$commit" =~ ^[0-9a-f]{40}$ && "$checksum" =~ ^[0-9a-f]{128}$ ]] -mkdir -p "$WORK_DIRECTORY" -curl --fail --silent --show-error --location \ - "https://codeload.github.com/loot/libloot/tar.gz/$commit" \ - --output "$WORK_DIRECTORY/libloot.tar.gz" -printf '%s %s\n' "$checksum" "$WORK_DIRECTORY/libloot.tar.gz" | sha512sum --check - -tar -xzf "$WORK_DIRECTORY/libloot.tar.gz" -C "$WORK_DIRECTORY" -cd "$WORK_DIRECTORY/libloot-$commit" -LIBLOOT_REVISION="${commit:0:8}" RUSTFLAGS='-C target-cpu=x86-64' \ - cargo build --release --locked -p libloot-nodejs -install -m755 target/release/liblibloot_nodejs.so "$WORK_DIRECTORY/libloot.node" -strip --strip-unneeded "$WORK_DIRECTORY/libloot.node" diff --git a/scripts/run-upstream-tests.sh b/scripts/run-upstream-tests.sh index 40da199..a100436 100755 --- a/scripts/run-upstream-tests.sh +++ b/scripts/run-upstream-tests.sh @@ -86,20 +86,8 @@ for test_config in "${test_configs[@]}"; do ) done -libloot_work_directory='' -cleanup_libloot() { - if [[ -n "$libloot_work_directory" ]]; then - rm -rf -- "$libloot_work_directory" - fi -} -trap cleanup_libloot EXIT -if [[ -z "${LIBLOOT_NODE_PATH:-}" ]]; then - libloot_work_directory="$(mktemp -d -t vortex-libloot-check.XXXXXXXX)" - "$REPOSITORY_ROOT/scripts/build-test-libloot.sh" "$libloot_work_directory" - export LIBLOOT_NODE_PATH="$libloot_work_directory/libloot.node" -fi -pnpm_config_verify_deps_before_run='' pnpm --filter gamebryo-plugin-management run build -pnpm_config_verify_deps_before_run='' pnpm --filter gamebryo-plugin-management run test -pnpm_config_verify_deps_before_run='' "$NX_BIN" run gamebryo-plugin-management:typecheck -node "$REPOSITORY_ROOT/scripts/test-loot.cjs" \ - "$UPSTREAM_WORKTREE/extensions/gamebryo-plugin-management/dist" +# Dependencies are installed without their install scripts, so build node-loot, +# which links the libloot it ships, and sort fixture plugins with it. +pnpm_config_verify_deps_before_run='' pnpm --filter @vortex/renderer rebuild loot +loot_path="$(cd src/renderer && node -p 'require("node:path").dirname(require.resolve("loot/package.json"))')" +node "$REPOSITORY_ROOT/scripts/test-loot.cjs" "$loot_path" diff --git a/scripts/test-loot.cjs b/scripts/test-loot.cjs index 5ff56a2..39fd396 100644 --- a/scripts/test-loot.cjs +++ b/scripts/test-loot.cjs @@ -26,25 +26,23 @@ function plugin(master = false, dependencies = []) { return Buffer.concat([header, body]); } -async function testLoot(extensionPath) { - const { LootAsync } = require(path.join(extensionPath, 'libloot-adapter.cjs')); +// lootPath is a node-loot package: the one in the renderer's dependencies or assets/loot of a +// packaged Vortex. Plugins are loaded by absolute path, as Vortex resolves them on Linux. +async function testLoot(lootPath) { + const { LootAsync } = require(path.join(lootPath, 'index.js')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vortex-loot-test-')); const data = path.join(root, 'Game', 'data'); const local = path.join(root, 'Local'); fs.mkdirSync(data, { recursive: true }); fs.mkdirSync(local); let loot; - const call = (method, ...args) => new Promise((resolve, reject) => { - loot[method](...args, (error, value) => error ? reject(error) : resolve(value)); - }); + const call = (method, ...args) => loot[method](...args); + const plugins = (...names) => names.map((name) => path.join(data, name)); try { for (const name of ['Skyrim.esm', 'Alpha.esp', 'Beta.esp']) { fs.writeFileSync(path.join(data, name), plugin(name.endsWith('.esm'), name.endsWith('.esp') ? ['Skyrim.esm'] : [])); } - loot = await new Promise((resolve, reject) => { - LootAsync.create('skyrimse', path.join(root, 'Game'), local, 'en', () => {}, undefined, - (error, value) => error ? reject(error) : resolve(value)); - }); + loot = await LootAsync.create('skyrimse', path.join(root, 'Game'), local, 'en', () => {}); const masterlist = path.join(root, 'masterlist.yaml'); const userlist = path.join(root, 'userlist.yaml'); fs.writeFileSync(masterlist, `groups: @@ -63,7 +61,7 @@ plugins: `); await call('loadLists', masterlist, '', ''); await call('loadCurrentLoadOrderState'); - await call('loadPlugins', ['alpha.esp', 'beta.esp', 'skyrim.esm'], false); + await call('loadPlugins', plugins('Alpha.esp', 'Beta.esp', 'Skyrim.esm'), false); assert.deepEqual(await call('sortPlugins', ['alpha.esp', 'beta.esp', 'skyrim.esm']), ['Skyrim.esm', 'Beta.esp', 'Alpha.esp']); const info = await call('getPlugin', 'alpha.esp'); assert.equal(info.name, 'Alpha.esp'); @@ -71,14 +69,15 @@ plugins: assert.equal(typeof info.isEmpty, 'boolean'); const metadata = await call('getPluginMetadata', 'Alpha.esp'); assert.equal(metadata.group, 'late'); - assert.equal(metadata.messages[0].content, 'Zażółć — metadata from native LOOT'); + assert.equal(metadata.messages[0].content[0].text, 'Zażółć — metadata from native LOOT'); assert.equal(metadata.requirements[0].name, 'Skyrim.esm'); assert.equal(metadata.tags[0].name, 'Relev'); assert.deepEqual((await call('getGroupsPath', 'default', 'late')).map((entry) => entry.name), ['default', 'late']); fs.writeFileSync(userlist, 'plugins:\n - name: Alpha.esp\n group: default\n'); await call('loadLists', masterlist, userlist, ''); assert.equal((await call('getPluginMetadata', 'Alpha.esp')).group, 'default'); - await call('loadLists', masterlist, '', ''); + fs.writeFileSync(userlist, 'plugins: []\n'); + await call('loadLists', masterlist, userlist, ''); assert.equal((await call('getPluginMetadata', 'Alpha.esp')).group, 'late'); fs.writeFileSync(userlist, 'plugins:\n - name: Beta.esp\n after: [Alpha.esp]\n'); await call('loadLists', masterlist, userlist, ''); @@ -87,18 +86,16 @@ plugins: assert.match(cycleError.message, /^Cyclic interaction/); assert.deepEqual(new Set(cycleError.cycle.map((vertex) => vertex.name)), new Set(['Alpha.esp', 'Beta.esp'])); assert.ok(cycleError.cycle.some((vertex) => vertex.typeOfEdgeToNextVertex === 'userlistLoadAfter'), JSON.stringify(cycleError.cycle)); - await call('loadLists', masterlist, '', ''); + fs.writeFileSync(userlist, 'plugins: []\n'); + await call('loadLists', masterlist, userlist, ''); fs.writeFileSync(path.join(data, 'New.esp.ghost'), plugin()); - await call('loadPlugins', ['new.esp'], true); + await call('loadPlugins', plugins('New.esp.ghost'), true); assert.equal((await call('getPlugin', 'new.esp')).name, 'New.esp'); - await assert.rejects(call('loadPlugins', ['missing.esp'], true), /missing/i); - fs.writeFileSync(path.join(data, 'ALPHA.esp'), plugin()); - await assert.rejects(call('sortPlugins', ['alpha.esp']), /same name/i); - const pending = call('clearConditionCache'); + await assert.rejects(call('loadPlugins', plugins('Missing.esp'), true)); loot.close(); - await assert.rejects(pending, /closed/); + assert.equal(loot.isClosed(), true); await assert.rejects(call('clearConditionCache'), /closed/); - console.log('Native LOOT: sorting, metadata, cycles, casing, ghost plugins and worker shutdown passed'); + console.log('Native LOOT: sorting, metadata, cycles, ghost plugins and worker shutdown passed'); } finally { loot?.close(); fs.rmSync(root, { recursive: true, force: true }); diff --git a/scripts/test-runtime.cjs b/scripts/test-runtime.cjs index dd41d8e..bceeda0 100644 --- a/scripts/test-runtime.cjs +++ b/scripts/test-runtime.cjs @@ -9,7 +9,7 @@ const { testLoot } = require('./test-loot.cjs'); async function testRuntime(appPath) { await require(path.join(appPath, 'assets/linux-self-test.cjs')).runLinuxSelfTest(); - await testLoot(path.join(`${appPath}.unpacked`, 'bundledPlugins/gamebryo-plugin-management')); + await testLoot(path.join(`${appPath}.unpacked`, 'assets/loot')); const modulePath = path.join(appPath, 'node_modules/@nexusmods/fomod-installer-native/dist'); const native = require(path.join(modulePath, 'resolve-native.js')).addon; assert.equal(native.ModInstaller.testSupported(['fomod/ModuleConfig.xml'], ['XmlScript']).supported, true);