From e7ea4db9917b11e29fc2fe72e3f5f7aa08ea7ea9 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 19 Oct 2026 22:10:52 +0200 Subject: [PATCH 1/5] Update Vortex to 2.9.0 Vortex now ships node-loot with a Linux libloot, so patch 0020 only maps plugin and local paths for it and the Rust libloot build is gone. --- .SRCINFO | 11 +- CHANGELOG.md | 5 + PKGBUILD | 39 +- README.md | 4 +- .../0001-linux-runtime-compatibility.patch | 41 +- ...-linux-path-and-executable-discovery.patch | 79 +- .../0016-linux-gamebryo-archive-support.patch | 6 +- patches/0020-linux-native-loot.patch | 575 +++---------- patches/0031-linux-deployment-preflight.patch | 753 +++++++++--------- scripts/build-test-libloot.sh | 20 - scripts/run-upstream-tests.sh | 22 +- scripts/test-loot.cjs | 37 +- scripts/test-runtime.cjs | 2 +- 13 files changed, 594 insertions(+), 1000 deletions(-) delete mode 100755 scripts/build-test-libloot.sh diff --git a/.SRCINFO b/.SRCINFO index e33e454..27a7f8b 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 @@ -105,7 +103,6 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bb57187..17eaab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ 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. + ## 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..0172c6e 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' @@ -143,7 +140,6 @@ _patches=( 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 +166,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' @@ -269,21 +264,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 +333,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 +384,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..f738305 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,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 +146,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/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); From c66413a5ca0ac7ceda5955356d4fee8ca3e4fc5c Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 19 Oct 2026 22:11:39 +0200 Subject: [PATCH 2/5] Split backslash paths in extracted archives --- .SRCINFO | 2 + CHANGELOG.md | 3 + PKGBUILD | 2 + .../0035-linux-backslash-archive-paths.patch | 164 ++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 patches/0035-linux-backslash-archive-paths.patch diff --git a/.SRCINFO b/.SRCINFO index 27a7f8b..1f58273 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -91,6 +91,7 @@ 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 = vortex.sh source = vortex.desktop source = README.md @@ -140,6 +141,7 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/CHANGELOG.md b/CHANGELOG.md index 17eaab6..005add2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ publishes a new Vortex version, taking any pending notes with it. - 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) ## 2.8.2-4 - 2026-10-16 diff --git a/PKGBUILD b/PKGBUILD index 0172c6e..1e5de3e 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -135,6 +135,7 @@ _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' ) source=( @@ -205,6 +206,7 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' 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); ++} From e53a8fec9b3994ea40f50b1f7db168e14cbcda1c Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 19 Oct 2026 22:12:54 +0200 Subject: [PATCH 3/5] Register the AppImage as the nxm:// handler --- .SRCINFO | 2 + CHANGELOG.md | 3 + PKGBUILD | 2 + README.md | 2 +- patches/0036-linux-appimage-nxm-links.patch | 186 ++++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 patches/0036-linux-appimage-nxm-links.patch diff --git a/.SRCINFO b/.SRCINFO index 1f58273..ec6841e 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -92,6 +92,7 @@ pkgbase = vortex-linux 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 @@ -142,6 +143,7 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/CHANGELOG.md b/CHANGELOG.md index 005add2..055629f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ publishes a new Vortex version, taking any pending notes with it. - 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 diff --git a/PKGBUILD b/PKGBUILD index 1e5de3e..cccea52 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -136,6 +136,7 @@ _patches=( '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=( @@ -207,6 +208,7 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/README.md b/README.md index f738305..31b2c42 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,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. 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; ++} From 2bbfaa2dc056c63d1b32cf72b287a55d8c22412e Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 19 Oct 2026 22:30:21 +0200 Subject: [PATCH 4/5] Keep LOOT plugin paths out of the Wine folder mapping --- .../0034-linux-case-only-platform-paths.patch | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) 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 From c88ad2ce5e9517524ef58d18ede42f3905a6a80a Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Mon, 19 Oct 2026 22:39:35 +0200 Subject: [PATCH 5/5] Mention what Vortex 2.9.0 does for Linux in the README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31b2c42..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)