diff --git a/CHANGELOG.md b/CHANGELOG.md index 2332e27..fdb5666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- The 2.6.2 recipe carries the Linux fixes from 2.8.2-3 through 2.9.0-3: + extension config paths, deployment preflight and target logging, extensions + behind a symlinked home, differently cased game paths, backslash archive + paths, nxm:// links from the AppImage, mixed-case deployment folders and the + default Wine prefix. The 1.16.9 recipe gets the last three. (#16, #20, #22) - Building the package, also with the 1.16.9 and 2.6.2 recipes, no longer downloads QuickBMS from altervista.org: the server refuses the download and on some networks the request hung until the build was interrupted. The diff --git a/recipes/1.16.9/.SRCINFO b/recipes/1.16.9/.SRCINFO index 6c95399..7b02238 100644 --- a/recipes/1.16.9/.SRCINFO +++ b/recipes/1.16.9/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Vortex 1.16.9 with Linux compatibility patches pkgver = 1.16.9 - pkgrel = 1 + pkgrel = 2 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 @@ -68,6 +68,9 @@ pkgbase = vortex-linux source = 0009-linux-fomod.patch source = 0010-linux-mod-conflicts.patch source = 0011-linux-arc-assets.patch + source = 0012-linux-backslash-archive-paths.patch + source = 0013-linux-case-consistent-folders.patch + source = 0014-linux-default-wine-prefix.patch source = vortex.sh source = vortex.desktop source = README.md @@ -97,6 +100,9 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/recipes/1.16.9/PKGBUILD b/recipes/1.16.9/PKGBUILD index e423523..bedb88c 100644 --- a/recipes/1.16.9/PKGBUILD +++ b/recipes/1.16.9/PKGBUILD @@ -2,7 +2,7 @@ pkgname=vortex-linux pkgver=1.16.9 -pkgrel=1 +pkgrel=2 pkgdesc="Vortex 1.16.9 with Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" @@ -75,6 +75,9 @@ _patches=( '0009-linux-fomod.patch' '0010-linux-mod-conflicts.patch' '0011-linux-arc-assets.patch' + '0012-linux-backslash-archive-paths.patch' + '0013-linux-case-consistent-folders.patch' + '0014-linux-default-wine-prefix.patch' ) source=( @@ -122,6 +125,9 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/recipes/1.16.9/patches/0012-linux-backslash-archive-paths.patch b/recipes/1.16.9/patches/0012-linux-backslash-archive-paths.patch new file mode 100644 index 0000000..54758fb --- /dev/null +++ b/recipes/1.16.9/patches/0012-linux-backslash-archive-paths.patch @@ -0,0 +1,313 @@ +diff --git a/src/extensions/mod_management/InstallManager.ts b/src/extensions/mod_management/InstallManager.ts +index 55faf41..75c5cf3 100644 +--- a/src/extensions/mod_management/InstallManager.ts ++++ b/src/extensions/mod_management/InstallManager.ts +@@ -99,6 +99,7 @@ import { + truthy, + } from "../../util/util"; + import walk from "../../util/walk"; ++import { splitBackslashNames } from "./util/archivePaths"; + + import calculateFolderSize from "../../util/calculateFolderSize"; + +@@ -1137,6 +1138,7 @@ class InstallManager { + return Bluebird.resolve(); + } + }) ++ .then(() => (process.platform === "linux" ? splitBackslashNames(tempPath) : undefined)) + .then(() => + walk(tempPath, (iterPath, stats) => { + if (stats.isFile()) { +diff --git a/src/extensions/mod_management/util/archivePaths.test.ts b/src/extensions/mod_management/util/archivePaths.test.ts +new file mode 100644 +index 0000000..018a115 +--- /dev/null ++++ b/src/extensions/mod_management/util/archivePaths.test.ts +@@ -0,0 +1,120 @@ ++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 { withPatchedPath } from "../../../test-utils/patchedPath"; ++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(); ++} ++ ++for (const patchedPath of [false, true]) { ++ const split = (root: string) => ++ patchedPath ? withPatchedPath(() => splitBackslashNames(root)) : splitBackslashNames(root); ++ ++ describe.runIf(process.platform === "linux")( ++ `archive entries with backslash separators${patchedPath ? " (renderer path patch)" : ""}`, ++ () => { ++ 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 split(root); ++ ++ expect(tree(root)).toEqual([ ++ "normal/", ++ "normal/readme.txt=readme", ++ "red4ext/", ++ "red4ext/plugins/", ++ "red4ext/plugins/AppearanceRuntimePlugin/", ++ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll", ++ ]); ++ }); ++ ++ it("turns empty entries that end in a backslash into folders", async () => { ++ // 7-Zip extracts folder entries as empty files when the archive doesn't mark them as ++ // folders, e.g. when it says it was made on Unix. ++ fs.writeFileSync(path.join(root, "bin\\x64\\"), ""); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\"), ""); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\Mod.dll"), "dll"); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\Empty\\"), ""); ++ ++ await split(root); ++ ++ expect(tree(root)).toEqual([ ++ "bin/", ++ "bin/x64/", ++ "bin/x64/plugins/", ++ "bin/x64/plugins/Empty/", ++ "bin/x64/plugins/Mod.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 split(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 split(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 split(root); ++ ++ expect(tree(root)).toEqual(["Data/", "Data/plugin.esp=esp"]); ++ }); ++ }, ++ ); ++} +diff --git a/src/extensions/mod_management/util/archivePaths.ts b/src/extensions/mod_management/util/archivePaths.ts +new file mode 100644 +index 0000000..6a2c195 +--- /dev/null ++++ b/src/extensions/mod_management/util/archivePaths.ts +@@ -0,0 +1,55 @@ ++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 = child(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 = parts.reduce(child, directory); ++ if (entry.isFile() && entry.name.endsWith("\\") && (await fs.stat(source)).size === 0) { ++ // A folder entry the archive doesn't mark as one, which 7-Zip extracts as an empty file. ++ await fs.mkdir(target, { recursive: true }); ++ await fs.unlink(source); ++ continue; ++ } ++ await move(source, target, entry.isDirectory()); ++ if (entry.isDirectory()) await visit(target); ++ } ++ }; ++ await visit(root); ++} ++ ++/** ++ * The renderer patches path.join to turn backslashes into slashes for extensions ++ * (util/pathCompatibility.ts), which would rewrite the very names this file moves. ++ */ ++function child(directory: string, name: string): string { ++ return `${directory}/${name}`; ++} ++ ++async function move(source: string, target: string, isDirectory: boolean): Promise { ++ const existing = await fs.stat(target).catch(() => undefined); ++ if (isDirectory && existing?.isDirectory()) { ++ for (const entry of await fs.readdir(source, { withFileTypes: true })) { ++ await move(child(source, entry.name), child(target, entry.name), entry.isDirectory()); ++ } ++ await fs.rmdir(source); ++ return; ++ } ++ await fs.mkdir(path.dirname(target), { recursive: true }); ++ await fs.rename(source, target); ++} +diff --git a/src/test-utils/patchedPath.ts b/src/test-utils/patchedPath.ts +new file mode 100644 +index 0000000..2e28467 +--- /dev/null ++++ b/src/test-utils/patchedPath.ts +@@ -0,0 +1,21 @@ ++import { createRequire, syncBuiltinESMExports } from "node:module"; ++import type * as path from "node:path"; ++ ++import { patchPathCompatibility } from "../util/pathCompatibility"; ++ ++/** ++ * Runs fn with path.join, normalize and resolve turning backslashes into slashes, the way the ++ * renderer patches them at startup (util/monkeyPatching.ts). ++ */ ++export async function withPatchedPath(fn: () => PromiseLike): Promise { ++ const hostPath: typeof path = createRequire(__filename)("node:path"); ++ const { join, normalize, resolve } = hostPath; ++ patchPathCompatibility(hostPath, "linux"); ++ syncBuiltinESMExports(); ++ try { ++ return await fn(); ++ } finally { ++ Object.assign(hostPath, { join, normalize, resolve }); ++ syncBuiltinESMExports(); ++ } ++} +diff --git a/src/util/fs.linuxCompatibility.test.ts b/src/util/fs.linuxCompatibility.test.ts +index def3591..3d90a66 100644 +--- a/src/util/fs.linuxCompatibility.test.ts ++++ b/src/util/fs.linuxCompatibility.test.ts +@@ -8,7 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + vi.mock("original-fs", async () => import("node:fs"), { virtual: true }); + + import { resetCompatibilityPathCache } from "./compatibilityPaths"; +-import { copyAsync, readFileSync, statAsync, statSync, writeFileAsync } from "./fs"; ++import { withPatchedPath } from "../test-utils/patchedPath"; ++import { copyAsync, readFileSync, removeAsync, statAsync, statSync, writeFileAsync } from "./fs"; + + describe.runIf(process.platform === "linux")("filesystem compatibility profile paths", () => { + let tempDir: string; +@@ -91,3 +92,23 @@ describe.runIf(process.platform === "linux")("filesystem compatibility profile p + process.env[key] = value; + } + }); ++ ++describe.runIf(process.platform === "linux")("removing names with backslashes", () => { ++ let tempDir: string; ++ beforeEach(() => { ++ tempDir = nodeFs.mkdtempSync(path.join(os.tmpdir(), "vortex-fs-remove-")); ++ }); ++ afterEach(() => nodeFs.rmSync(tempDir, { force: true, recursive: true })); ++ ++ it("removes a mod that was installed with Windows separators in its file names", async () => { ++ const modPath = path.join(tempDir, "Appearance Runtime Plugin"); ++ nodeFs.mkdirSync(path.join(modPath, "red4ext\\plugins\\"), { recursive: true }); ++ nodeFs.writeFileSync(path.join(modPath, "red4ext\\plugins\\Mod\\Mod.dll"), "dll"); ++ nodeFs.mkdirSync(path.join(modPath, "red4ext", "plugins"), { recursive: true }); ++ nodeFs.writeFileSync(path.join(modPath, "red4ext", "plugins", "Other.dll"), "dll"); ++ ++ await withPatchedPath(() => removeAsync(modPath, { showDialogCallback: () => false })); ++ ++ expect(nodeFs.existsSync(modPath)).toBe(false); ++ }); ++}); +diff --git a/src/util/fs.ts b/src/util/fs.ts +index fe70c32..e867f52 100644 +--- a/src/util/fs.ts ++++ b/src/util/fs.ts +@@ -1000,6 +1000,9 @@ function removeInt( + } + + function rimrafAsync(remPath: string): PromiseBB { ++ if (process.platform === "linux") { ++ return PromiseBB.resolve(removeTree(remPath)); ++ } + return new PromiseBB((resolve, reject) => { + // don't use the rimraf implementation of busy retries because it's f*cked: + // https://github.com/isaacs/rimraf/issues/187 +@@ -1019,6 +1022,26 @@ function rimrafAsync(remPath: string): PromiseBB { + }); + } + ++/** ++ * rimraf builds child paths with path.join, which the renderer patches to turn backslashes into ++ * slashes (pathCompatibility.ts). Names extracted from Windows archives, such as ++ * "red4ext\plugins\Mod.dll", then look already gone and their folder can't be removed. ++ */ ++async function removeTree(remPath: string): Promise { ++ const ignoreMissing = (err: unknown) => { ++ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err; ++ }; ++ const stats = await fs.lstat(remPath).catch(ignoreMissing); ++ if (!stats) return; ++ if (stats.isDirectory()) { ++ const names = await fs.readdir(remPath); ++ await Promise.all(names.map((name) => removeTree(`${remPath}/${name}`))); ++ await fs.rmdir(remPath).catch(ignoreMissing); ++ } else { ++ await fs.unlink(remPath).catch(ignoreMissing); ++ } ++} ++ + export function readlinkAsync(linkPath: string): PromiseBB { + linkPath = resolveCaseInsensitiveSync(linkPath); + const stackErr = new Error(); diff --git a/recipes/1.16.9/patches/0013-linux-case-consistent-folders.patch b/recipes/1.16.9/patches/0013-linux-case-consistent-folders.patch new file mode 100644 index 0000000..73b73ae --- /dev/null +++ b/recipes/1.16.9/patches/0013-linux-case-consistent-folders.patch @@ -0,0 +1,114 @@ +diff --git a/src/extensions/mod_management/LinkingDeployment.linux.test.ts b/src/extensions/mod_management/LinkingDeployment.linux.test.ts +index 6db23d7..6e9cf11 100644 +--- a/src/extensions/mod_management/LinkingDeployment.linux.test.ts ++++ b/src/extensions/mod_management/LinkingDeployment.linux.test.ts +@@ -161,3 +161,63 @@ test.runIf(process.platform === "linux")( + ); + }, + ); ++ ++test.runIf(process.platform === "linux")( ++ "deployment puts folders that mods spell in different case into one folder", ++ async ({ makeDeployment }) => { ++ // Fallout 4 has no loose Sound or Textures folders, so the mods create them while their ++ // files are linked in parallel. ++ const lowercase: Record = {}; ++ const capitalized: Record = {}; ++ for (let idx = 0; idx < 40; idx++) { ++ lowercase[`sound/fx/npc/creature${idx % 7}/a${idx}.wav`] = "a"; ++ lowercase[`textures/actors/character/hair${idx % 5}/a${idx}.dds`] = "a"; ++ capitalized[`Sound/FX/NPC/Creature${idx % 7}/b${idx}.wav`] = "b"; ++ capitalized[`Textures/Actors/Character/Hair${idx % 5}/b${idx}.dds`] = "b"; ++ } ++ const h = makeDeployment({ modName: "Lowercase", files: lowercase }); ++ for (const [relPath, content] of Object.entries(capitalized)) { ++ const source = path.join(h.stagingDir, "Capitalized", relPath); ++ nodeFs.mkdirSync(path.dirname(source), { recursive: true }); ++ nodeFs.writeFileSync(source, content); ++ } ++ const blacklist = new BlacklistSet([], { details: {} } as IGame, h.normalize); ++ ++ await h.method.prepare(h.gameDir, false, [], h.normalize); ++ for (const modName of ["Lowercase", "Capitalized"]) { ++ await h.method.activate(path.join(h.stagingDir, modName), modName, "", blacklist); ++ } ++ await h.method.finalize("skyrimse", h.gameDir, h.stagingDir); ++ ++ const deployed = nodeFs.readdirSync(h.gameDir, { recursive: true, withFileTypes: true }); ++ const relative = (entry: nodeFs.Dirent) => ++ path.relative(h.gameDir, path.join(entry.parentPath, entry.name)).toLowerCase(); ++ expect( ++ deployed ++ .filter((entry) => entry.isDirectory()) ++ .map(relative) ++ .sort(), ++ ).toEqual( ++ [ ++ "sound", ++ "sound/fx", ++ "sound/fx/npc", ++ ...[0, 1, 2, 3, 4, 5, 6].map((idx) => `sound/fx/npc/creature${idx}`), ++ "textures", ++ "textures/actors", ++ "textures/actors/character", ++ ...[0, 1, 2, 3, 4].map((idx) => `textures/actors/character/hair${idx}`), ++ ].sort(), ++ ); ++ expect( ++ deployed ++ .filter((entry) => !entry.isDirectory() && !entry.name.startsWith(".")) ++ .map(relative) ++ .sort(), ++ ).toEqual( ++ [...Object.keys(lowercase), ...Object.keys(capitalized)] ++ .map((relPath) => relPath.toLowerCase()) ++ .sort(), ++ ); ++ }, ++); +diff --git a/src/extensions/mod_management/LinkingDeployment.ts b/src/extensions/mod_management/LinkingDeployment.ts +index 5ea1e7c..2f60766 100644 +--- a/src/extensions/mod_management/LinkingDeployment.ts ++++ b/src/extensions/mod_management/LinkingDeployment.ts +@@ -68,6 +68,7 @@ abstract class LinkingActivator implements IDeploymentMethod { + private mQueue: Promise = Promise.resolve(); + private mContext: IDeploymentContext; + private mDirCache: Set; ++ private mDirQueue: Promise = Promise.resolve(); + + constructor( + id: string, +@@ -665,16 +666,23 @@ abstract class LinkingActivator implements IDeploymentMethod { + } + }; + +- return ( +- this.mDirCache === undefined || !this.mDirCache.has(dirPath) +- ? fs.ensureDirAsync(dirPath, onDirCreated).then(() => { +- if (this.mDirCache === undefined) { +- this.mDirCache = new Set(); +- } +- this.mDirCache.add(dirPath); +- }) +- : Promise.resolve() +- ).then(() => didCreate); ++ const key = this.mNormalize !== undefined ? this.mNormalize(dirPath) : dirPath; ++ if (this.mDirCache?.has(key)) { ++ return Promise.resolve(false); ++ } ++ // Create one directory at a time. ensureDirAsync reuses the casing of a directory that ++ // exists, but links deployed in parallel to new "textures" and "Textures" folders would ++ // each find nothing and create both, and a Windows game sees only one of them. ++ const created = this.mDirQueue ++ .then(() => fs.ensureDirAsync(dirPath, onDirCreated)) ++ .then(() => { ++ if (this.mDirCache === undefined) { ++ this.mDirCache = new Set(); ++ } ++ this.mDirCache.add(key); ++ }); ++ this.mDirQueue = created.catch(() => undefined); ++ return created.then(() => didCreate); + } + + private deduplicate(input: IFileChange[]): IFileChange[] { diff --git a/recipes/1.16.9/patches/0014-linux-default-wine-prefix.patch b/recipes/1.16.9/patches/0014-linux-default-wine-prefix.patch new file mode 100644 index 0000000..77f57cf --- /dev/null +++ b/recipes/1.16.9/patches/0014-linux-default-wine-prefix.patch @@ -0,0 +1,107 @@ +diff --git a/src/util/compatibilityPaths.ts b/src/util/compatibilityPaths.ts +index f4d5dc6..a137e99 100644 +--- a/src/util/compatibilityPaths.ts ++++ b/src/util/compatibilityPaths.ts +@@ -85,9 +85,21 @@ function resolveGamePrefix(game: CompatibilityGame): string | null | undefined { + return installation.prefix; + } + } ++ if (!game.store) return defaultWinePrefix(); + return undefined; + } + ++/** ++ * A game added by hand has no store to ask, so it lives where plain Wine runs ++ * everything: $WINEPREFIX, or ~/.wine when that isn't set. ++ */ ++function defaultWinePrefix(): string | undefined { ++ const prefix = path.resolve( ++ expandHome(process.env.WINEPREFIX || path.join(os.homedir(), ".wine")), ++ ); ++ return fs.existsSync(path.join(prefix, "drive_c")) ? prefix : undefined; ++} ++ + interface IUserPathMapping { + linuxRoot: string; + windowsRoot: string[]; +@@ -322,7 +334,7 @@ export function resolveWindowsUserPathSync(input: string): string { + if (!gameData) return []; + throw Object.assign( + new Error( +- "The selected game's Wine/Proton prefix is unavailable. Launch the game once, or configure its WINEPREFIX.", ++ "The selected game's Wine/Proton prefix is unavailable. Launch the game once, or edit the game on the Dashboard and set WINEPREFIX under Environment Variables.", + ), + { code: "ENOENT" }, + ); +diff --git a/src/util/compatibilitySelection.test.ts b/src/util/compatibilitySelection.test.ts +index ee058f2..0a62828 100644 +--- a/src/util/compatibilitySelection.test.ts ++++ b/src/util/compatibilitySelection.test.ts +@@ -171,6 +171,30 @@ describe.runIf(process.platform === "linux")("selected compatibility prefix", () + expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe(expected); + }); + ++ it("uses Wine's default prefix for a game added by hand", () => { ++ vi.stubEnv("WINEPREFIX", ""); ++ const wine = path.join(home, ".wine"); ++ const user = path.join(wine, "drive_c/users", os.userInfo().username); ++ fs.mkdirSync(path.join(user, "Documents"), { recursive: true }); ++ const game = { path: path.join(home, "Games/Example"), executable: "Example.exe" }; ++ setCompatibilityGameProvider(() => game); ++ expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe( ++ path.join(user, "Documents", relative), ++ ); ++ ++ const exported = path.join(home, "exported-prefix"); ++ fs.mkdirSync(path.join(exported, "drive_c/users/steamuser/Documents"), { recursive: true }); ++ vi.stubEnv("WINEPREFIX", exported); ++ resetCompatibilityPathCache(); ++ expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe( ++ path.join(exported, "drive_c/users/steamuser/Documents", relative), ++ ); ++ ++ // A store game that wasn't launched yet keeps asking for its own prefix. ++ setCompatibilityGameProvider(() => ({ ...game, store: "steam" })); ++ expect(() => resolveWindowsUserPathSync(path.join(documents, relative))).toThrow(/unavailable/); ++ }); ++ + it("keeps native game paths and Vortex state outside Proton", () => { + const gamePath = path.join(home, "native"); + fs.mkdirSync(gamePath); +diff --git a/src/util/linux/gameTools.test.ts b/src/util/linux/gameTools.test.ts +index e18dcf4..4a310b3 100644 +--- a/src/util/linux/gameTools.test.ts ++++ b/src/util/linux/gameTools.test.ts +@@ -171,6 +171,19 @@ describe.runIf(process.platform === "linux")("game tool runner", () => { + resolveGameTool("Tool.exe", [], { env: { WINEPREFIX: path.join(root, "missing") } }), + ).rejects.toThrow(/prefix is unavailable/); + }); ++ it("runs a game added by hand in Wine's default prefix", async () => { ++ vi.stubEnv("WINEPREFIX", ""); ++ const manual = { path: path.join(root, "Games/Example"), executable: "Example.exe" }; ++ const game = path.join(manual.path, manual.executable); ++ write(game, "MZ"); ++ await expect(resolveGameTool(game, [], {}, manual)).rejects.toThrow(/Environment Variables/); ++ fs.mkdirSync(path.join(root, ".wine/drive_c"), { recursive: true }); ++ resetCompatibilityPathCache(); ++ const command = await resolveGameTool(game, [], {}, manual); ++ expect(command.executable).toBe("wine"); ++ expect(command.args).toEqual([game]); ++ expect(command.options.env.WINEPREFIX).toBe(path.join(root, ".wine")); ++ }); + it("does not launch a Steam tool in a different Wine prefix", async () => { + proton("Proton 10.0"); + const other = path.join(root, "other"); +diff --git a/src/util/linux/gameTools.ts b/src/util/linux/gameTools.ts +index fd35aa1..f1e17db 100644 +--- a/src/util/linux/gameTools.ts ++++ b/src/util/linux/gameTools.ts +@@ -47,7 +47,7 @@ export async function resolveGameTool( + const prefix = options.env?.WINEPREFIX ?? selectedGamePrefix(game); + if (!prefix || !fs.existsSync(path.join(prefix, "drive_c"))) { + throw new Error( +- "The game's Wine prefix is unavailable. Launch the game once or configure WINEPREFIX for this tool.", ++ "The game's Wine prefix is unavailable. Launch the game once, or edit the game or tool on the Dashboard and set WINEPREFIX under Environment Variables.", + ); + } + if (game?.store === "steam") { diff --git a/recipes/2.6.2/.SRCINFO b/recipes/2.6.2/.SRCINFO index ba3002d..a403980 100644 --- a/recipes/2.6.2/.SRCINFO +++ b/recipes/2.6.2/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches pkgver = 2.6.2 - pkgrel = 4 + pkgrel = 5 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 @@ -89,6 +89,15 @@ pkgbase = vortex-linux source = 0027-linux-release-notifications.patch source = 0028-linux-atomic-config-writes.patch source = 0029-linux-prefix-config-purge.patch + source = 0030-linux-extension-config-paths.patch + source = 0031-linux-deployment-preflight.patch + source = 0032-linux-deployment-target-paths.patch + source = 0033-linux-symlinked-extension-paths.patch + source = 0034-linux-case-only-platform-paths.patch + source = 0035-linux-backslash-archive-paths.patch + source = 0036-linux-appimage-nxm-links.patch + source = 0037-linux-case-consistent-folders.patch + source = 0038-linux-default-wine-prefix.patch source = vortex.sh source = vortex.desktop source = test-loot.cjs @@ -137,6 +146,15 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/recipes/2.6.2/PKGBUILD b/recipes/2.6.2/PKGBUILD index e448ee3..ec56c43 100644 --- a/recipes/2.6.2/PKGBUILD +++ b/recipes/2.6.2/PKGBUILD @@ -2,7 +2,7 @@ pkgname=vortex-linux pkgver=2.6.2 -pkgrel=4 +pkgrel=5 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" @@ -134,6 +134,15 @@ _patches=( '0027-linux-release-notifications.patch' '0028-linux-atomic-config-writes.patch' '0029-linux-prefix-config-purge.patch' + '0030-linux-extension-config-paths.patch' + '0031-linux-deployment-preflight.patch' + '0032-linux-deployment-target-paths.patch' + '0033-linux-symlinked-extension-paths.patch' + '0034-linux-case-only-platform-paths.patch' + '0035-linux-backslash-archive-paths.patch' + '0036-linux-appimage-nxm-links.patch' + '0037-linux-case-consistent-folders.patch' + '0038-linux-default-wine-prefix.patch' ) source=( @@ -204,6 +213,15 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/recipes/2.6.2/patches/0030-linux-extension-config-paths.patch b/recipes/2.6.2/patches/0030-linux-extension-config-paths.patch new file mode 100644 index 0000000..8021503 --- /dev/null +++ b/recipes/2.6.2/patches/0030-linux-extension-config-paths.patch @@ -0,0 +1,816 @@ +diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util/compatibilityPaths.ts +--- a/src/renderer/src/util/compatibilityPaths.ts ++++ b/src/renderer/src/util/compatibilityPaths.ts +@@ -232,7 +232,11 @@ function prefixUsers(users: string[]): string[] { + return selected !== undefined ? [selected] : users; + } + +-function windowsUserRoots(prefixes: string[], relativeRoot: string[]): string[] { ++function windowsUserRoots( ++ prefixes: string[], ++ relativeRoot: string[], ++ allowMissingRoot = false, ++): string[] { + return prefixes.flatMap((prefix) => { + const users = path.join(prefix, "drive_c", "users"); + try { +@@ -240,13 +244,23 @@ function windowsUserRoots(prefixes: string[], relativeRoot: string[]): string[] + .readdirSync(users, { withFileTypes: true }) + .filter( + (entry) => +- entry.isDirectory() && + !["public", "default", "default user", "all users"].includes(entry.name.toLowerCase()), + ) + .map((entry) => entry.name); + return prefixUsers(profiles) +- .map((user) => path.join(users, user, ...relativeRoot)) +- .filter(fs.existsSync); ++ .filter((user) => { ++ try { ++ return fs.statSync(path.join(users, user)).isDirectory(); ++ } catch { ++ return false; ++ } ++ }) ++ .map( ++ (user) => resolveCandidate(path.join(users, user), path.join(...relativeRoot), 0)?.path, ++ ) ++ .filter( ++ (root): root is string => root !== undefined && (allowMissingRoot || fs.existsSync(root)), ++ ); + } catch { + return []; + } +@@ -263,6 +277,11 @@ function resolveCandidate( + let existingParts = 0; + + try { ++ if (!fs.existsSync(root)) { ++ return minimumExistingParts === 0 ++ ? { existingParts: 0, path: path.join(root, relative) } ++ : undefined; ++ } + for (const [index, part] of parts.entries()) { + const exact = path.join(current, part); + if (fs.existsSync(exact)) { +@@ -299,6 +318,24 @@ export function resolveSelectedWindowsUserPathSync(input: string): string { + } + + export function resolveWindowsUserPathSync(input: string): string { ++ return resolveUserPathSync(input, false); ++} ++ ++/** Resolve Windows-only roots for extension filesystem calls, including the root itself. */ ++export function resolveExtensionUserPathSync(input: string): string { ++ return gameProvider?.() === undefined ? input : resolveUserPathSync(input, true); ++} ++ ++function unavailablePrefix(): Error { ++ return Object.assign( ++ new Error( ++ "The selected game's Wine/Proton user profile is unavailable. Launch the game once, or configure its WINEPREFIX.", ++ ), ++ { code: "ENOENT" }, ++ ); ++} ++ ++function resolveUserPathSync(input: string, extensionRoots: boolean): string { + if (process.platform !== "linux" || !path.isAbsolute(input)) { + return input; + } +@@ -310,9 +347,10 @@ export function resolveWindowsUserPathSync(input: string): string { + const selected = gameProvider?.() !== undefined; + let prefixes: string[] | undefined; + const candidates = linuxUserPathMappings() ++ .filter((mapping) => !extensionRoots || mapping.gameData) + .flatMap((mapping) => { + const relative = path.relative(mapping.linuxRoot, input); +- if (relative.startsWith("..") || path.isAbsolute(relative) || relative.length === 0) { ++ if (!isPathInside(mapping.linuxRoot, input) || (!extensionRoots && relative.length === 0)) { + return []; + } + const myGames = relative.split(path.sep)[0].toLowerCase() === "my games"; +@@ -320,20 +358,24 @@ export function resolveWindowsUserPathSync(input: string): string { + prefixes ??= compatibilityPrefixes(); + if (prefixes === undefined) { + if (!gameData) return []; +- throw Object.assign( +- new Error( +- "The selected game's Wine/Proton prefix is unavailable. Launch the game once, or configure its WINEPREFIX.", +- ), +- { code: "ENOENT" }, +- ); ++ throw unavailablePrefix(); + } + // The selected game's own data belongs in its prefix even before the game + // creates it. Other paths are redirected only when the prefix already has + // them, so Linux files in Documents or ~/.config stay where they are. + const minimumExistingParts = selected && gameData ? 0 : myGames ? 2 : 1; +- return windowsUserRoots(prefixes, mapping.windowsRoot).map((root) => +- resolveCandidate(root, relative, minimumExistingParts), +- ); ++ const roots = windowsUserRoots(prefixes, mapping.windowsRoot, selected && gameData); ++ const resolved = roots.map((root) => resolveCandidate(root, relative, minimumExistingParts)); ++ if ( ++ selected && ++ gameData && ++ prefixes.length > 0 && ++ resolved.every((entry) => entry === undefined) ++ ) { ++ // An unusable profile must never turn a Windows write into a host write. ++ throw unavailablePrefix(); ++ } ++ return resolved; + }) + .filter((candidate): candidate is ICandidate => candidate !== undefined); + +diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer/src/util/compatibilitySelection.test.ts +--- a/src/renderer/src/util/compatibilitySelection.test.ts ++++ b/src/renderer/src/util/compatibilitySelection.test.ts +@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + + import { + resetCompatibilityPathCache, ++ resolveExtensionUserPathSync, + resolveWindowsUserPathSync, + setCompatibilityGameProvider, + } from "./compatibilityPaths"; +@@ -194,4 +195,120 @@ describe.runIf(process.platform === "linux")("selected compatibility prefix", () + resetCompatibilityPathCache(); + expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe(expected); + }); ++ ++ it("uses a symlinked steamuser profile instead of stale host configuration", () => { ++ const prefix = path.join(home, "prefix"); ++ const user = path.join(home, "moved-user"); ++ const relativeConfig = "Example/Saved/Config/Windows/Engine.ini"; ++ const local = path.join(home, "Local"); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ fs.mkdirSync(path.join(user, "AppData/Local", path.dirname(relativeConfig)), { ++ recursive: true, ++ }); ++ fs.mkdirSync(path.join(prefix, "drive_c/users"), { recursive: true }); ++ fs.symlinkSync(user, path.join(prefix, "drive_c/users/steamuser")); ++ fs.mkdirSync(path.join(local, path.dirname(relativeConfig)), { recursive: true }); ++ fs.writeFileSync(path.join(local, relativeConfig), "host"); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ const expected = path.join(prefix, "drive_c/users/steamuser/AppData/Local", relativeConfig); ++ expect(resolveCaseInsensitiveSync(path.join(local, relativeConfig))).toBe(expected); ++ fs.writeFileSync(expected, "prefix"); ++ expect(fs.readFileSync(path.join(local, relativeConfig), "utf8")).toBe("host"); ++ }); ++ ++ it("creates a missing AppData root inside the selected user profile", () => { ++ const prefix = path.join(home, "prefix"); ++ fixture(prefix); ++ const local = path.join(home, "Local"); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ const destination = resolveCaseInsensitiveSync(path.join(local, "Example/Engine.ini")); ++ expect(destination).toBe( ++ path.join(prefix, "drive_c/users/steamuser/AppData/Local/Example/Engine.ini"), ++ ); ++ fs.mkdirSync(path.dirname(destination), { recursive: true }); ++ fs.writeFileSync(destination, "config"); ++ expect(fs.existsSync(local)).toBe(false); ++ }); ++ ++ it.each(["missing", "broken-symlink", "file"])("rejects a %s selected user profile", (kind) => { ++ const prefix = path.join(home, "prefix"); ++ const users = path.join(prefix, "drive_c/users"); ++ const local = path.join(home, "Local"); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ fs.mkdirSync(users, { recursive: true }); ++ if (kind === "broken-symlink") { ++ fs.symlinkSync(path.join(home, "missing-user"), path.join(users, "steamuser")); ++ fs.mkdirSync(path.join(users, "other/AppData/Local"), { recursive: true }); ++ } else if (kind === "file") { ++ fs.writeFileSync(path.join(users, "steamuser"), "not a profile"); ++ fs.mkdirSync(path.join(users, "other/AppData/Local"), { recursive: true }); ++ } ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ expect(() => resolveCaseInsensitiveSync(path.join(local, "Example/Engine.ini"))).toThrow( ++ /unavailable/, ++ ); ++ expect(() => resolveExtensionUserPathSync(local)).toThrow(/unavailable/); ++ expect(fs.existsSync(local)).toBe(false); ++ }); ++ ++ it.each([ ++ ["VORTEX_LINUX_LOCAL_APPDATA", "Local", "AppData/Local"], ++ ["VORTEX_LINUX_LOCAL_LOW", "LocalLow", "AppData/LocalLow"], ++ ["VORTEX_LINUX_SAVED_GAMES", "Saved Games", "Saved Games"], ++ ])( ++ "maps the exact extension root %s only with a selected Windows game", ++ (env, hostRoot, windowsRoot) => { ++ const prefix = path.join(home, "prefix"); ++ fixture(prefix); ++ const root = path.join(home, hostRoot); ++ vi.stubEnv(env, root); ++ fs.mkdirSync(root); ++ expect(resolveExtensionUserPathSync(root)).toBe(root); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ expect(resolveExtensionUserPathSync(root)).toBe( ++ path.join(prefix, "drive_c/users/steamuser", windowsRoot), ++ ); ++ // Existing callers keep their root semantics; only the extension adapter opts in. ++ expect(resolveWindowsUserPathSync(root)).toBe(root); ++ setCompatibilityGameProvider(() => ({ path: home, executable: "Game.sh" })); ++ expect(resolveExtensionUserPathSync(root)).toBe(root); ++ }, ++ ); ++ ++ it("preserves shared host paths and Vortex data in extension operations", () => { ++ const prefix = path.join(home, "prefix"); ++ fixture(prefix); ++ const appData = path.join(home, ".config"); ++ const local = path.join(home, "Local"); ++ const userData = path.join(local, "Vortex"); ++ vi.stubEnv("VORTEX_LINUX_APPDATA", appData); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ vi.stubEnv("VORTEX_USERDATA", userData); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ for (const hostPath of [ ++ documents, ++ path.join(documents, relative), ++ appData, ++ path.join(appData, "other/settings"), ++ userData, ++ path.join(userData, "mods/test"), ++ path.join(home, "LocalOther/file"), ++ ]) { ++ expect(resolveExtensionUserPathSync(hostPath)).toBe(hostPath); ++ } ++ }); ++ ++ it("resolves Windows root casing before creating new configuration", () => { ++ const prefix = path.join(home, "prefix"); ++ const actualRoot = path.join(prefix, "drive_c/users/steamuser/appdata/local"); ++ fs.mkdirSync(actualRoot, { recursive: true }); ++ const local = path.join(home, "Local"); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ expect(resolveExtensionUserPathSync(local)).toBe(actualRoot); ++ expect(resolveCaseInsensitiveSync(path.join(local, "Example/Engine.ini"))).toBe( ++ path.join(actualRoot, "Example/Engine.ini"), ++ ); ++ }); + }); +diff --git a/src/renderer/src/util/extensionFs.test.ts b/src/renderer/src/util/extensionFs.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/extensionFs.test.ts +@@ -0,0 +1,248 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++import { pathToFileURL } from "node:url"; ++import { promisify } from "node:util"; ++ ++import { afterEach, beforeEach, describe, expect, it } from "vitest"; ++ ++import { type CompatibilityGame, setCompatibilityGameProvider } from "./compatibilityPaths"; ++import { wrapExtensionFs } from "./extensionFs"; ++ ++describe.runIf(process.platform === "linux")("extension filesystem game paths", () => { ++ let root: string; ++ let local: string; ++ let profile: string; ++ let game: CompatibilityGame | undefined; ++ const oldEnvironment = new Map(); ++ ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-extension-fs-")); ++ local = path.join(root, "host", "Local"); ++ profile = path.join(root, "prefix", "drive_c", "users", "steamuser"); ++ fs.mkdirSync(profile, { recursive: true }); ++ setEnvironment("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setEnvironment("VORTEX_LINUX_LOCAL_LOW", path.join(root, "host", "LocalLow")); ++ setEnvironment("VORTEX_LINUX_APPDATA", path.join(root, "host", ".config")); ++ setEnvironment("VORTEX_LINUX_DOCUMENTS", path.join(root, "host", "Documents")); ++ setEnvironment("VORTEX_LINUX_SAVED_GAMES", path.join(root, "host", "Saved Games")); ++ setEnvironment("VORTEX_USERDATA", path.join(root, "host", ".config", "Vortex")); ++ game = { ++ path: path.join(root, "game"), ++ environment: { WINEPREFIX: path.join(root, "prefix") }, ++ }; ++ setCompatibilityGameProvider(() => game); ++ }); ++ ++ afterEach(() => { ++ setCompatibilityGameProvider(undefined); ++ fs.rmSync(root, { recursive: true, force: true }); ++ for (const [key, value] of oldEnvironment) { ++ if (value === undefined) delete process.env[key]; ++ else process.env[key] = value; ++ } ++ oldEnvironment.clear(); ++ }); ++ ++ it.each([false, true])("checks the prefix filesystem with a stale host root: %s", (stale) => { ++ if (stale) { ++ fs.mkdirSync(local, { recursive: true }); ++ fs.writeFileSync(path.join(local, "host.ini"), "keep"); ++ } ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ // This is the exact-root mkdir/stat sequence used by extension partition checks. ++ extensionFs.mkdirSync(local, { recursive: true }); ++ const actualRoot = path.join(profile, "AppData", "Local"); ++ const actualStat = fs.statSync(actualRoot); ++ expect(extensionFs.statSync(local).ino).toBe(actualStat.ino); ++ expect(extensionFs.statSync(local).dev).toBe(actualStat.dev); ++ const config = path.join(local, "Example", "Saved", "Config"); ++ extensionFs.mkdirSync(config, { recursive: true }); ++ extensionFs.writeFileSync(path.join(config, "Engine.ini"), "prefix"); ++ expect( ++ fs.readFileSync(path.join(actualRoot, "Example", "Saved", "Config", "Engine.ini"), "utf8"), ++ ).toBe("prefix"); ++ expect(fs.existsSync(path.join(local, "Example"))).toBe(false); ++ if (stale) expect(fs.readFileSync(path.join(local, "host.ini"), "utf8")).toBe("keep"); ++ else expect(fs.existsSync(local)).toBe(false); ++ }); ++ ++ it("maps callback mkdir, read/write, stat, and exists including promisified exists", async () => { ++ const extensionFs = wrapExtensionFs(fs, "node:fs"); ++ await promisify(extensionFs.mkdir)(local, { recursive: true }); ++ const file = path.join(local, "Engine.ini"); ++ await promisify(extensionFs.writeFile)(file, "callback"); ++ expect((await promisify(extensionFs.stat)(file)).size).toBe(8); ++ expect(await promisify(extensionFs.readFile)(file, "utf8")).toBe("callback"); ++ expect(await new Promise((resolve) => extensionFs.exists(file, resolve))).toBe(true); ++ expect(await promisify(extensionFs.exists)(file)).toBe(true); ++ expect(fs.readFileSync(path.join(profile, "AppData", "Local", "Engine.ini"), "utf8")).toBe( ++ "callback", ++ ); ++ }); ++ ++ it.each(["fs", "node:fs", "fs/promises", "node:fs/promises"])( ++ "maps promise operations from %s", ++ async (id) => { ++ const extensionFs = id.endsWith("/promises") ++ ? wrapExtensionFs(fs.promises, id) ++ : wrapExtensionFs(fs, id).promises; ++ await extensionFs.mkdir(local, { recursive: true }); ++ const file = path.join(local, "Engine.ini"); ++ await extensionFs.writeFile(file, "promise"); ++ expect(await extensionFs.readFile(file, "utf8")).toBe("promise"); ++ expect((await extensionFs.stat(file)).size).toBe(7); ++ expect(fs.readFileSync(path.join(profile, "AppData", "Local", "Engine.ini"), "utf8")).toBe( ++ "promise", ++ ); ++ }, ++ ); ++ ++ it.each(["LocalLow", "Saved Games"])("maps %s alongside Local", (name) => { ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ const logical = path.join(root, "host", name); ++ extensionFs.mkdirSync(logical, { recursive: true }); ++ extensionFs.writeFileSync(path.join(logical, "settings.ini"), name); ++ const actual = ++ name === "LocalLow" ? path.join(profile, "AppData", name) : path.join(profile, name); ++ expect(fs.readFileSync(path.join(actual, "settings.ini"), "utf8")).toBe(name); ++ expect(fs.existsSync(logical)).toBe(false); ++ }); ++ ++ it("resolves captured methods at each operation after selecting and switching games", () => { ++ game = undefined; ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ const mkdir = extensionFs.mkdirSync; ++ const write = extensionFs.writeFileSync; ++ mkdir(local, { recursive: true }); ++ write(path.join(local, "settings.ini"), "no game"); ++ const select = (prefix: string) => { ++ fs.mkdirSync(path.join(prefix, "drive_c", "users", "steamuser"), { recursive: true }); ++ game = { path: path.join(root, "game"), environment: { WINEPREFIX: prefix } }; ++ mkdir(local, { recursive: true }); ++ }; ++ select(path.join(root, "prefix")); ++ write(path.join(local, "settings.ini"), "first"); ++ const second = path.join(root, "second"); ++ select(second); ++ write(path.join(local, "settings.ini"), "second"); ++ expect(fs.readFileSync(path.join(profile, "AppData", "Local", "settings.ini"), "utf8")).toBe( ++ "first", ++ ); ++ expect( ++ fs.readFileSync( ++ path.join(second, "drive_c", "users", "steamuser", "AppData", "Local", "settings.ini"), ++ "utf8", ++ ), ++ ).toBe("second"); ++ expect(fs.readFileSync(path.join(local, "settings.ini"), "utf8")).toBe("no game"); ++ game = { path: path.join(root, "native"), executable: "game.sh" }; ++ write(path.join(local, "settings.ini"), "native"); ++ expect(fs.readFileSync(path.join(local, "settings.ini"), "utf8")).toBe("native"); ++ }); ++ ++ it("keeps Documents, shared appData, and Vortex data on the host", () => { ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ for (const [logical, physical] of [ ++ [ ++ path.join(root, "host", "Documents", "My Games", "Example"), ++ path.join(profile, "Documents", "My Games", "Example"), ++ ], ++ [ ++ path.join(root, "host", ".config", "Example"), ++ path.join(profile, "AppData", "Roaming", "Example"), ++ ], ++ [process.env.VORTEX_USERDATA!, path.join(profile, "AppData", "Roaming", "Vortex")], ++ ]) { ++ fs.mkdirSync(physical, { recursive: true }); ++ fs.writeFileSync(path.join(physical, "settings.ini"), "prefix"); ++ extensionFs.mkdirSync(logical, { recursive: true }); ++ extensionFs.writeFileSync(path.join(logical, "settings.ini"), "host"); ++ expect(extensionFs.readFileSync(path.join(logical, "settings.ini"), "utf8")).toBe("host"); ++ expect(fs.readFileSync(path.join(physical, "settings.ini"), "utf8")).toBe("prefix"); ++ } ++ }); ++ ++ it("maps both sides of copy, rename, hardlink, and symlink operations", async () => { ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ extensionFs.mkdirSync(local, { recursive: true }); ++ const file = path.join(local, "Engine.ini"); ++ extensionFs.writeFileSync(file, "mod"); ++ extensionFs.copyFileSync(file, file + ".copy"); ++ await promisify(extensionFs.rename)(file + ".copy", file + ".renamed"); ++ await extensionFs.promises.link(file + ".renamed", file + ".hardlink"); ++ extensionFs.symlinkSync(file, file + ".symlink"); ++ extensionFs.symlinkSync("Engine.ini", file + ".relative"); ++ expect(extensionFs.statSync(file + ".hardlink").ino).toBe( ++ extensionFs.statSync(file + ".renamed").ino, ++ ); ++ expect(extensionFs.readFileSync(file + ".symlink", "utf8")).toBe("mod"); ++ expect(extensionFs.readFileSync(file + ".relative", "utf8")).toBe("mod"); ++ expect(extensionFs.realpathSync.native(file + ".symlink")).toBe( ++ path.join(profile, "AppData", "Local", "Engine.ini"), ++ ); ++ extensionFs.unlinkSync(file + ".hardlink"); ++ expect(fs.existsSync(path.join(profile, "AppData", "Local", "Engine.ini.hardlink"))).toBe( ++ false, ++ ); ++ expect(fs.existsSync(local)).toBe(false); ++ }); ++ ++ it("preserves Buffer paths, URL paths, and file descriptors", () => { ++ fs.mkdirSync(local, { recursive: true }); ++ const file = path.join(local, "host.ini"); ++ fs.writeFileSync(file, "host"); ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ expect(extensionFs.readFileSync(Buffer.from(file), "utf8")).toBe("host"); ++ expect(extensionFs.readFileSync(pathToFileURL(file), "utf8")).toBe("host"); ++ const fd = fs.openSync(file, "r"); ++ try { ++ expect(extensionFs.readFileSync(fd, "utf8")).toBe("host"); ++ } finally { ++ fs.closeSync(fd); ++ } ++ }); ++ ++ it("reports unavailable prefixes using the operation's error convention", async () => { ++ fs.mkdirSync(local, { recursive: true }); ++ game = { ++ path: path.join(root, "game"), ++ environment: { WINEPREFIX: path.join(root, "missing") }, ++ }; ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ expect(() => extensionFs.mkdirSync(local, { recursive: true })).toThrow(/prefix/i); ++ await expect(promisify(extensionFs.mkdir)(local, { recursive: true })).rejects.toMatchObject({ ++ code: "ENOENT", ++ }); ++ await expect(extensionFs.promises.stat(local)).rejects.toMatchObject({ code: "ENOENT" }); ++ expect(extensionFs.existsSync(local)).toBe(false); ++ expect(await new Promise((resolve) => extensionFs.exists(local, resolve))).toBe(false); ++ expect(await promisify(extensionFs.exists)(local)).toBe(false); ++ }); ++ ++ it("leaves the shared module unchanged and caches private views and methods", () => { ++ const originalStat = fs.statSync; ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ expect(extensionFs).not.toBe(fs); ++ expect(wrapExtensionFs(fs, "node:fs")).toBe(extensionFs); ++ expect(extensionFs.statSync).toBe(extensionFs.statSync); ++ expect(extensionFs.promises).toBe(wrapExtensionFs(fs.promises, "node:fs/promises")); ++ expect(fs.statSync).toBe(originalStat); ++ expect(wrapExtensionFs(fs, "unrelated-module")).toBe(fs); ++ }); ++ ++ it("does not adapt fs on other platforms", () => { ++ const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; ++ try { ++ Object.defineProperty(process, "platform", { value: "win32" }); ++ expect(wrapExtensionFs(fs, "fs")).toBe(fs); ++ } finally { ++ Object.defineProperty(process, "platform", descriptor); ++ } ++ }); ++ ++ function setEnvironment(key: string, value: string) { ++ oldEnvironment.set(key, process.env[key]); ++ process.env[key] = value; ++ } ++}); +diff --git a/src/renderer/src/util/extensionFs.ts b/src/renderer/src/util/extensionFs.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/extensionFs.ts +@@ -0,0 +1,141 @@ ++import { promisify } from "node:util"; ++ ++import { resolveExtensionUserPathSync } from "./compatibilityPaths"; ++ ++// Extensions often use Node's fs directly, including successful mkdir/stat calls ++// on stale Linux directories. Resolve their game-specific Windows paths before ++// the operation, rather than waiting for an ENOENT fallback. ++const singlePathMethods = new Set([ ++ "access", ++ "appendFile", ++ "chmod", ++ "chown", ++ "exists", ++ "lchmod", ++ "lchown", ++ "lstat", ++ "lutimes", ++ "mkdir", ++ "mkdtemp", ++ "open", ++ "opendir", ++ "readFile", ++ "readdir", ++ "readlink", ++ "realpath", ++ "rm", ++ "rmdir", ++ "stat", ++ "statfs", ++ "truncate", ++ "unlink", ++ "utimes", ++ "writeFile", ++]); ++const twoPathMethods = new Set(["copyFile", "cp", "link", "rename", "symlink"]); ++const moduleCache = new WeakMap>(); ++type MethodKind = "sync" | "callback" | "promise"; ++type FsMethod = (...args: unknown[]) => unknown; ++ ++function wrapMethod( ++ method: FsMethod, ++ receiver: object, ++ pathArguments: number[], ++ kind: MethodKind, ++ exists: boolean, ++): typeof method { ++ const helpers = new Map(); ++ // A separate callable avoids violating Proxy invariants for Node's read-only ++ // promisify.custom property, while leaving the original function untouched. ++ const invoke = (...args: unknown[]) => Reflect.apply(method, receiver, args); ++ return new Proxy(invoke, { ++ apply(target, _this, args: unknown[]) { ++ let mapped: unknown[]; ++ try { ++ mapped = [...args]; ++ for (const index of pathArguments) { ++ // Node also accepts file descriptors, Buffers, and URLs. Preserve ++ // those values and let Node validate their original overloads. ++ if (typeof mapped[index] === "string") { ++ mapped[index] = resolveExtensionUserPathSync(mapped[index]); ++ } ++ } ++ } catch (error) { ++ if (kind === "promise") return exists ? Promise.resolve(false) : Promise.reject(error); ++ if (kind === "callback") { ++ const callback = args[args.length - 1]; ++ if (typeof callback === "function") { ++ queueMicrotask(() => callback(exists ? false : error)); ++ return; ++ } ++ } ++ if (exists) return false; ++ throw error; ++ } ++ return Reflect.apply(target, receiver, mapped); ++ }, ++ get(_target, key) { ++ const value: unknown = Reflect.get(method, key); ++ // Keep realpath.native and promisify(fs.exists) on the same adapter. ++ if ((key === "native" || key === promisify.custom) && typeof value === "function") { ++ if (!helpers.has(key)) { ++ helpers.set( ++ key, ++ wrapMethod( ++ value as FsMethod, ++ receiver, ++ pathArguments, ++ key === promisify.custom ? "promise" : kind, ++ exists, ++ ), ++ ); ++ } ++ return helpers.get(key); ++ } ++ return value; ++ }, ++ }); ++} ++ ++/** A private fs view for extension modules; never modifies Node's shared fs. */ ++export function wrapExtensionFs(target: T, moduleId: string): T { ++ if ( ++ process.platform !== "linux" || ++ !["fs", "node:fs", "fs/promises", "node:fs/promises"].includes(moduleId) ++ ) { ++ return target; ++ } ++ const promises = moduleId.endsWith("/promises"); ++ let cached = moduleCache.get(target); ++ if (cached?.has(promises)) return cached.get(promises) as T; ++ cached ??= new Map(); ++ const methods = new Map(); ++ const proxy = new Proxy(target, { ++ get(fs, key, receiver) { ++ const value: unknown = Reflect.get(fs, key, receiver); ++ if (!promises && key === "promises" && typeof value === "object" && value !== null) { ++ return wrapExtensionFs(value, "fs/promises"); ++ } ++ if (typeof key !== "string" || typeof value !== "function") return value; ++ const name = key.replace(/Sync$/, ""); ++ if (!singlePathMethods.has(name) && !twoPathMethods.has(name)) return value; ++ if (!methods.has(key)) { ++ const kind = promises ? "promise" : key.endsWith("Sync") ? "sync" : "callback"; ++ methods.set( ++ key, ++ wrapMethod( ++ value as FsMethod, ++ fs, ++ twoPathMethods.has(name) ? [0, 1] : [0], ++ kind, ++ name === "exists", ++ ), ++ ); ++ } ++ return methods.get(key); ++ }, ++ }); ++ cached.set(promises, proxy); ++ moduleCache.set(target, cached); ++ return proxy; ++} +diff --git a/src/renderer/src/util/extensionRequire.test.ts b/src/renderer/src/util/extensionRequire.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/extensionRequire.test.ts +@@ -0,0 +1,59 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { afterEach, describe, expect, it, vi } from "vitest"; ++ ++const modules = vi.hoisted(() => ({ load: vi.fn(), paths: [] as { path: string }[] })); ++vi.mock("../api", () => ({})); ++vi.mock("../controls/ReactSelectWrap", () => ({})); ++vi.mock("../ExtensionManager", () => ({ default: { getExtensionPaths: () => modules.paths } })); ++vi.mock("../extensions/extension_manager/queries", () => ({ findInstalled: vi.fn() })); ++vi.mock("./deprecatedApiUsage", () => ({ deprecatedApiGet: vi.fn() })); ++vi.mock("./webpack-hacks", () => ({ webpackRequireHack: modules.load })); ++ ++import { setCompatibilityGameProvider } from "./compatibilityPaths"; ++import { extensionRequire } from "./extensionRequire"; ++ ++describe.runIf(process.platform === "linux")("extension-only filesystem require", () => { ++ afterEach(() => { ++ setCompatibilityGameProvider(undefined); ++ modules.load.mockReset(); ++ modules.paths = []; ++ vi.unstubAllEnvs(); ++ }); ++ ++ it("maps an extension's fs while application callers keep Node's shared module", async () => { ++ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-extension-require-")); ++ try { ++ const local = path.join(root, "host", "Local"); ++ const prefix = path.join(root, "prefix"); ++ const actual = path.join(prefix, "drive_c", "users", "steamuser", "AppData", "Local"); ++ fs.mkdirSync(path.dirname(actual), { recursive: true }); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setCompatibilityGameProvider(() => ({ ++ path: path.join(root, "game"), ++ environment: { WINEPREFIX: prefix }, ++ })); ++ modules.paths = [{ path: path.join(root, "extensions") + path.sep }]; ++ modules.load.mockImplementation((id: string) => ++ id.endsWith("/promises") ? fs.promises : fs, ++ ); ++ const originalRequire = vi.fn(() => fs); ++ const load = extensionRequire(originalRequire, () => []); ++ const appModule = { filename: path.join(root, "application", "main.js") }; ++ const extModule = { filename: path.join(root, "extensions", "game-example", "index.js") }; ++ expect(load.call(appModule, "fs")).toBe(fs); ++ const extensionFs: typeof fs = load.call(extModule, "fs"); ++ extensionFs.mkdirSync(local, { recursive: true }); ++ expect(extensionFs.statSync(local).ino).toBe(fs.statSync(actual).ino); ++ const extensionPromises: typeof fs.promises = load.call(extModule, "node:fs/promises"); ++ await extensionPromises.writeFile(path.join(local, "Engine.ini"), "extension"); ++ expect(fs.readFileSync(path.join(actual, "Engine.ini"), "utf8")).toBe("extension"); ++ expect(fs.existsSync(local)).toBe(false); ++ expect(originalRequire).toHaveBeenCalledTimes(1); ++ } finally { ++ fs.rmSync(root, { recursive: true, force: true }); ++ } ++ }); ++}); +diff --git a/src/renderer/src/util/extensionRequire.ts b/src/renderer/src/util/extensionRequire.ts +--- a/src/renderer/src/util/extensionRequire.ts ++++ b/src/renderer/src/util/extensionRequire.ts +@@ -7,6 +7,7 @@ import * as api from "../api"; + import * as reactSelect from "../controls/ReactSelectWrap"; + import ExtensionManager from "../ExtensionManager"; + import type { IRegisteredExtension } from "../types/extensions"; ++import { wrapExtensionFs } from "./extensionFs"; + import type { LogLevel } from "./log"; + import { webpackRequireHack } from "./webpack-hacks"; + +@@ -101,7 +102,7 @@ const handlerMapReactAct: { [extId: string]: typeof reduxAct } = {}; + * @param {any} orig + * @returns + */ +-function extensionRequire(orig, getExtensions: () => IRegisteredExtension[]) { ++export function extensionRequire(orig, getExtensions: () => IRegisteredExtension[]) { + const extensionPaths = ExtensionManager.getExtensionPaths(); + return function (id) { + if (id === "vortex-api" || id === "@nexusmods/vortex-api") { +@@ -137,7 +138,7 @@ function extensionRequire(orig, getExtensions: () => IRegisteredExtension[]) { + if (res === undefined) { + res = orig.apply(this, arguments); + } +- return res; ++ return wrapExtensionFs(res, id); + } else { + return orig.apply(this, arguments); + } +diff --git a/src/renderer/src/util/linuxSelfTest.ts b/src/renderer/src/util/linuxSelfTest.ts +--- a/src/renderer/src/util/linuxSelfTest.ts ++++ b/src/renderer/src/util/linuxSelfTest.ts +@@ -10,6 +10,7 @@ import { + purgeMatchingHardlinks, + } from "../extensions/hardlink_activator/hardlinkIdentity"; + import { resolveWindowsUserPathSync, setCompatibilityGameProvider } from "./compatibilityPaths"; ++import { wrapExtensionFs } from "./extensionFs"; + import { resolveGameTool } from "./linux/gameTools"; + import { heroicPrefix, readHeroicGames } from "./linux/heroic"; + import { resolveCaseInsensitiveSync } from "./linuxCompatibility"; +@@ -17,7 +18,12 @@ import { GetPrivateProfileString, WritePrivateProfileString } from "./linuxIni"; + + export async function runLinuxSelfTest(): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-runtime-fixtures-")); +- const variables = ["VORTEX_LINUX_DOCUMENTS", "XDG_CONFIG_HOME", "VORTEX_USERDATA"]; ++ const variables = [ ++ "VORTEX_LINUX_DOCUMENTS", ++ "VORTEX_LINUX_LOCAL_APPDATA", ++ "XDG_CONFIG_HOME", ++ "VORTEX_USERDATA", ++ ]; + const previous = variables.map((key) => process.env[key]); + const prefix = path.join(root, "prefix"); + const documents = path.join(root, "Documents"); +@@ -25,6 +31,7 @@ export async function runLinuxSelfTest(): Promise { + process.env.VORTEX_LINUX_DOCUMENTS = documents; + process.env.XDG_CONFIG_HOME = path.join(root, "config"); + process.env.VORTEX_USERDATA = path.join(root, "config/Vortex"); ++ process.env.VORTEX_LINUX_LOCAL_APPDATA = path.join(root, "Local"); + const ini = path.join( + prefix, + "drive_c/users/steamuser/Documents/My Games/Example/settings.ini", +@@ -55,6 +62,23 @@ export async function runLinuxSelfTest(): Promise { + resolveWindowsUserPathSync(process.env.VORTEX_USERDATA), + process.env.VORTEX_USERDATA, + ); ++ ++ const extensionFs = wrapExtensionFs(fs, "node:fs"); ++ const local = process.env.VORTEX_LINUX_LOCAL_APPDATA; ++ const prefixLocal = path.join(prefix, "drive_c/users/steamuser/AppData/Local"); ++ extensionFs.mkdirSync(path.join(local, "Example"), { recursive: true }); ++ assert.equal(fs.readdirSync(root).includes("Local"), false); ++ assert.equal(extensionFs.statSync(local).ino, fs.statSync(prefixLocal).ino); ++ fs.mkdirSync(local); ++ fs.writeFileSync(path.join(local, "host.txt"), "host"); ++ await extensionFs.promises.writeFile(path.join(local, "Example/Engine.ini"), "mod"); ++ assert.equal(fs.readFileSync(path.join(prefixLocal, "Example/Engine.ini"), "utf8"), "mod"); ++ assert.deepEqual(fs.readdirSync(local), ["host.txt"]); ++ const user = path.join(prefix, "drive_c/users/steamuser"); ++ const movedUser = path.join(prefix, "moved-user"); ++ fs.renameSync(user, movedUser); ++ fs.symlinkSync(movedUser, user); ++ assert.equal(extensionFs.readFileSync(path.join(local, "Example/Engine.ini"), "utf8"), "mod"); + setCompatibilityGameProvider(undefined); + + const staging = path.join(root, "staging"); +@@ -117,7 +141,7 @@ export async function runLinuxSelfTest(): Promise { + prefix, + }); + console.log( +- "Packaged Linux fixtures: INI, selected prefix, Heroic, tool arguments and hardlink purge passed", ++ "Packaged Linux fixtures: INI, selected prefix, extension filesystem, Heroic, tool arguments and hardlink purge passed", + ); + } finally { + setCompatibilityGameProvider(undefined); diff --git a/recipes/2.6.2/patches/0031-linux-deployment-preflight.patch b/recipes/2.6.2/patches/0031-linux-deployment-preflight.patch new file mode 100644 index 0000000..281f24b --- /dev/null +++ b/recipes/2.6.2/patches/0031-linux-deployment-preflight.patch @@ -0,0 +1,910 @@ +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 ++++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.staging.test.ts +@@ -0,0 +1,131 @@ ++import * as nodeFs from "node:fs"; ++import * as path from "node:path"; ++ ++import { afterEach, expect, vi } from "vitest"; ++ ++import { test } from "../../test-utils/deploymentTest"; ++import type { IGame } from "../../types/IGame"; ++import { setCompatibilityGameProvider } from "../../util/compatibilityPaths"; ++import * as fs from "../../util/fs"; ++import deployMods from "./modActivation"; ++import type { IMod } from "./types/IMod"; ++import BlacklistSet from "./util/BlacklistSet"; ++ ++afterEach(() => { ++ setCompatibilityGameProvider(undefined); ++ vi.unstubAllEnvs(); ++}); ++for (const method of ["hardlink", "symlink"] as const) { ++ test.runIf(process.platform === "linux")( ++ `case-mismatched staging can deploy using ${method}`, ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" }, method, modName: "ConfigMod" }); ++ setCompatibilityGameProvider(() => ({ path: h.gameDir, executable: "Game.sh" })); ++ const logicalStaging = path.join(h.stagingDir, "..", "STAGING"); ++ const logicalMod = path.join(logicalStaging, "configmod"); ++ expect((await fs.statAsync(logicalMod)).isDirectory()).toBe(true); ++ const deployed = await deployMods( ++ h.api, ++ "skyrimse", ++ logicalStaging, ++ h.gameDir, ++ [{ id: "config", installationPath: "configmod", state: "installed" } as IMod], ++ h.method, ++ [], ++ h.normalize, ++ "", ++ new BlacklistSet([], { details: {} } as IGame, h.normalize), ++ () => "", ++ ); ++ expect(deployed).toHaveLength(1); ++ expect(nodeFs.readFileSync(h.inGame("Engine.ini"), "utf8")).toBe("config"); ++ expect( ++ await h.method.externalChanges("skyrimse", logicalStaging, h.gameDir, deployed), ++ ).toEqual([]); ++ }, ++ ); ++} ++ ++for (const method of ["hardlink", "symlink"] as const) { ++ test.runIf(process.platform === "linux")( ++ `case-mismatched staging can deactivate using ${method}`, ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" }, method, modName: "ConfigMod" }); ++ setCompatibilityGameProvider(() => ({ path: h.gameDir, executable: "Game.sh" })); ++ const lastDeployment = await h.deploy(); ++ expect(lastDeployment).toHaveLength(1); ++ const logicalStaging = path.join(h.stagingDir, "..", "STAGING"); ++ const logicalMod = path.join(logicalStaging, "ConfigMod"); ++ expect((await fs.statAsync(logicalMod)).isDirectory()).toBe(true); ++ await h.method.prepare(h.gameDir, false, lastDeployment, h.normalize); ++ try { ++ await h.method.deactivate(logicalMod, "", "ConfigMod"); ++ const deployed = await h.method.finalize("skyrimse", h.gameDir, logicalStaging); ++ expect(deployed).toHaveLength(0); ++ expect(nodeFs.existsSync(h.inGame("Engine.ini"))).toBe(false); ++ } catch (err) { ++ await h.method.cancel?.("skyrimse", h.gameDir, logicalStaging); ++ throw err; ++ } ++ }, ++ ); ++ ++ test.runIf(process.platform === "linux")( ++ `case-mismatched staging preserves existing deployment using ${method}`, ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" }, method, modName: "ConfigMod" }); ++ setCompatibilityGameProvider(() => ({ path: h.gameDir, executable: "Game.sh" })); ++ const lastDeployment = await h.deploy(); ++ expect(lastDeployment).toHaveLength(1); ++ const logicalStaging = path.join(h.stagingDir, "..", "STAGING"); ++ const logicalMod = path.join(logicalStaging, "ConfigMod"); ++ expect((await fs.statAsync(logicalMod)).isDirectory()).toBe(true); ++ const deployed = await deployMods( ++ h.api, ++ "skyrimse", ++ logicalStaging, ++ h.gameDir, ++ [{ id: "config", installationPath: "ConfigMod", state: "installed" } as IMod], ++ h.method, ++ lastDeployment, ++ h.normalize, ++ "", ++ new BlacklistSet([], { details: {} } as IGame, h.normalize), ++ () => "", ++ ); ++ expect(nodeFs.existsSync(h.inGame("Engine.ini"))).toBe(true); ++ expect(deployed).toHaveLength(1); ++ expect(nodeFs.readFileSync(h.inGame("Engine.ini"), "utf8")).toBe("config"); ++ }, ++ ); ++} ++ ++for (const operation of ["activate", "deactivate"] as const) { ++ test.runIf(process.platform === "linux")( ++ `rejects ${operation} when the selected prefix is missing instead of treating it as an empty mod`, ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" } }); ++ const root = path.dirname(h.stagingDir); ++ const local = path.join(root, "Local"); ++ const mod = path.join(local, "ConfigMod"); ++ nodeFs.mkdirSync(mod, { recursive: true }); ++ nodeFs.writeFileSync(path.join(mod, "Engine.ini"), "host configuration"); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setCompatibilityGameProvider(() => ({ ++ environment: { WINEPREFIX: path.join(root, "missing-prefix") }, ++ })); ++ // Resolution failures must be promise rejections, including for deactivate. ++ const result = ++ operation === "activate" ++ ? h.method.activate( ++ mod, ++ "ConfigMod", ++ "", ++ new BlacklistSet([], { details: {} } as IGame, h.normalize), ++ ) ++ : h.method.deactivate(mod, "", "ConfigMod"); ++ await expect(result).rejects.toMatchObject({ code: "ENOENT" }); ++ expect(nodeFs.readFileSync(path.join(mod, "Engine.ini"), "utf8")).toBe("host configuration"); ++ }, ++ ); ++} +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 +@@ -361,8 +361,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( +@@ -399,32 +402,35 @@ abstract class LinkingActivator implements IDeploymentMethod { + 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; + } +- }); +- }, +- { 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]; ++ } ++ } ++ }); ++ }, ++ { skipHidden: false }, ++ ); ++ }); + } + + 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,283 @@ ++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"; ++ ++// Importing mod_management constructs the Steam store, which reads the home path from ++// ApplicationData; this suite's setup does not initialize it. ++vi.mock("../../util/linux/steamPaths", () => ({ ++ findLinuxSteamPath: () => undefined, ++ getLinuxSteamPaths: () => [], ++ isValidSteamPath: () => false, ++})); ++vi.mock("../gamemode_management/util/getGame", () => ({ getGame: vi.fn() })); ++vi.mock("../gamemode_management/util/modTypeExtensions", () => ({ getModType: vi.fn() })); ++vi.mock("./util/deploymentMethods", () => ({ getCurrentActivator: vi.fn() })); ++vi.mock("./util/activationStore", () => ({ ++ withActivationLock: vi.fn((callback: () => Promise) => callback()), ++ loadActivation: vi.fn(async () => []), ++})); ++vi.mock("./util/externalChanges", () => ({ dealWithExternalChanges: vi.fn() })); ++vi.mock("../../util/errorHandling", async (importOriginal) => ({ ++ ...(await importOriginal()), ++ withTrackedActivity: (_scope, _name, _attributes, callback) => callback(), ++})); ++ ++const gameId = "starwarsgalacticracer"; ++const configType = `${gameId}-config`; ++let state: IState; ++let api: IExtensionApi; ++let manager: InstallManager; ++let activator: IDeploymentMethod; ++ ++beforeEach(() => { ++ vi.clearAllMocks(); ++ vi.mocked(withActivationLock).mockImplementation((callback) => Bluebird.resolve(callback())); ++ state = { ++ settings: { ++ gameMode: { discovered: { [gameId]: { path: "/game" } } }, ++ mods: { installPath: { [gameId]: "/staging" } }, ++ window: { useModernLayout: false }, ++ }, ++ persistent: { ++ profiles: { profile: { id: "profile", gameId, modState: { config: { enabled: true } } } }, ++ mods: { ++ [gameId]: { ++ config: { ++ id: "config", ++ type: configType, ++ state: "installed", ++ installationPath: "config", ++ }, ++ }, ++ }, ++ }, ++ session: { base: { toolsRunning: {} } }, ++ } as unknown as IState; ++ api = { ++ getState: () => state, ++ store: { getState: () => state, dispatch: vi.fn() }, ++ translate: (message: string, options?: { replace: { mods: string } }) => ++ message.replace("{{mods}}", options?.replace.mods ?? ""), ++ sendNotification: vi.fn(() => "notification"), ++ dismissNotification: vi.fn(), ++ emitAndAwait: vi.fn(async () => undefined), ++ showErrorNotification: vi.fn(), ++ } as unknown as IExtensionApi; ++ manager = { ++ getActiveInstallationCount: () => 0, ++ waitForIdle: vi.fn(async () => undefined), ++ consumeRecentChanges: vi.fn(() => new Set()), ++ } as unknown as InstallManager; ++ activator = { name: "Hardlink Deployment", prepare: vi.fn() } as unknown as IDeploymentMethod; ++ vi.mocked(getCurrentActivator).mockReturnValue(activator); ++ vi.mocked(getGame).mockReturnValue({ ++ id: gameId, ++ getModPaths: () => ({ "": "/game" }), ++ } as unknown as IGame); ++ vi.mocked(getModType).mockReturnValue({ ++ typeId: configType, ++ options: {}, ++ isSupported: () => false, ++ getPath: () => "/prefix/config", ++ } as unknown as ReturnType); ++}); ++ ++describe("deployment entrypoint destination guards", () => { ++ it("aborts full deployment before consuming pending changes, loading manifests or invoking extension events", async () => { ++ await genUpdateModDeployment(manager)(api, true, "profile"); ++ expect(manager.consumeRecentChanges).not.toHaveBeenCalled(); ++ expect(loadActivation).not.toHaveBeenCalled(); ++ expect(api.emitAndAwait).not.toHaveBeenCalled(); ++ expect(dealWithExternalChanges).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining(configType), ++ }), ++ ); ++ expect(api.showErrorNotification).not.toHaveBeenCalled(); ++ }); ++ ++ it("refreshes mods installed while deployment was waiting before validating", async () => { ++ state.persistent.mods[gameId].config.state = "installing"; ++ vi.spyOn(manager, "getActiveInstallationCount").mockReturnValue(1); ++ vi.mocked(manager.waitForIdle).mockImplementation(async () => { ++ state = { ++ ...state, ++ persistent: { ++ ...state.persistent, ++ mods: { ++ [gameId]: { ++ config: { ++ ...state.persistent.mods[gameId].config, ++ state: "installed", ++ }, ++ }, ++ }, ++ }, ++ }; ++ }); ++ await genUpdateModDeployment(manager)(api, true, "profile"); ++ expect(manager.waitForIdle).toHaveBeenCalledOnce(); ++ expect(loadActivation).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining(configType), ++ }), ++ ); ++ }); ++ ++ it("rechecks a mod enabled by a will-deploy handler before external changes", async () => { ++ state.persistent.profiles.profile.modState.config.enabled = false; ++ vi.mocked(api.emitAndAwait).mockImplementation(async () => { ++ state.persistent.profiles.profile.modState.config.enabled = true; ++ return []; ++ }); ++ await genUpdateModDeployment(manager)(api, true, "profile"); ++ expect(loadActivation).toHaveBeenCalledOnce(); ++ expect(api.emitAndAwait).toHaveBeenCalledOnce(); ++ expect(dealWithExternalChanges).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining(configType), ++ }), ++ ); ++ }); ++ ++ it("rechecks support removed by a will-deploy handler against fresh targets", async () => { ++ let supported = true; ++ vi.mocked(getGame).mockReturnValue({ ++ id: gameId, ++ getModPaths: () => ++ supported ? { "": "/game", [configType]: "/prefix/config" } : { "": "/game" }, ++ } as unknown as IGame); ++ vi.mocked(getModType).mockReturnValue({ ++ typeId: configType, ++ options: {}, ++ isSupported: () => supported, ++ getPath: () => "/prefix/config", ++ } as unknown as ReturnType); ++ vi.mocked(api.emitAndAwait).mockImplementation(async () => { ++ supported = false; ++ return []; ++ }); ++ await genUpdateModDeployment(manager)(api, true, "profile"); ++ expect(loadActivation).toHaveBeenCalledTimes(2); ++ expect(manager.consumeRecentChanges).not.toHaveBeenCalled(); ++ expect(dealWithExternalChanges).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining(configType), ++ }), ++ ); ++ }); ++ ++ it("does not apply an old manifest when a will-deploy handler changes the target", async () => { ++ let target = "/prefix/config"; ++ vi.mocked(getGame).mockReturnValue({ ++ id: gameId, ++ getModPaths: () => ({ "": "/game", [configType]: target }), ++ } as unknown as IGame); ++ vi.mocked(api.emitAndAwait).mockImplementation(async () => { ++ target = "/other-prefix/config"; ++ return []; ++ }); ++ await genUpdateModDeployment(manager)(api, true, "profile"); ++ expect(loadActivation).toHaveBeenCalledTimes(2); ++ expect(manager.consumeRecentChanges).not.toHaveBeenCalled(); ++ expect(dealWithExternalChanges).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining("Please deploy again"), ++ }), ++ ); ++ }); ++ ++ it.each([true, false])( ++ "reports the missing target for single-mod enable=%s without preparing deployment", ++ async (enable) => { ++ await onDeploySingleMod(api)(gameId, "config", enable); ++ expect(loadActivation).not.toHaveBeenCalled(); ++ expect(activator.prepare).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining(configType), ++ }), ++ ); ++ expect(api.store.dispatch).toHaveBeenCalledWith(setDeploymentNecessary(gameId, true)); ++ }, ++ ); ++ ++ it.each([undefined, "/other-prefix/config"])( ++ "rechecks a queued single-mod request when its target becomes %s", ++ async (newTarget) => { ++ let target: string | undefined = "/prefix/config"; ++ vi.mocked(getGame).mockReturnValue({ ++ id: gameId, ++ getModPaths: () => ++ target === undefined ? { "": "/game" } : { "": "/game", [configType]: target }, ++ } as unknown as IGame); ++ vi.mocked(getModType).mockReturnValue({ ++ typeId: configType, ++ options: {}, ++ isSupported: () => target !== undefined, ++ getPath: () => target, ++ } as unknown as ReturnType); ++ let unlock: () => void; ++ const locked = new Promise((resolve) => { ++ unlock = resolve; ++ }); ++ vi.mocked(withActivationLock).mockImplementationOnce((callback) => ++ Bluebird.resolve(locked).then(callback), ++ ); ++ const pending = onDeploySingleMod(api)(gameId, "config", true); ++ expect(withActivationLock).toHaveBeenCalledOnce(); ++ expect(loadActivation).not.toHaveBeenCalled(); ++ target = newTarget; ++ unlock(); ++ await pending; ++ expect(loadActivation).not.toHaveBeenCalled(); ++ expect(activator.prepare).not.toHaveBeenCalled(); ++ expect(api.sendNotification).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ title: "Deployment interrupted", ++ message: expect.stringContaining( ++ newTarget === undefined ? configType : "Please deploy again", ++ ), ++ }), ++ ); ++ expect(api.store.dispatch).toHaveBeenCalledWith(setDeploymentNecessary(gameId, true)); ++ }, ++ ); ++ ++ it("allows a single-mod request for an intentionally non-deploying collection", async () => { ++ state.persistent.mods[gameId].config.type = "collection"; ++ vi.mocked(getModType).mockReturnValue({ ++ typeId: "collection", ++ options: {}, ++ isSupported: () => true, ++ getPath: () => undefined, ++ } as unknown as ReturnType); ++ await onDeploySingleMod(api)(gameId, "config", true); ++ expect(loadActivation).not.toHaveBeenCalled(); ++ expect(activator.prepare).not.toHaveBeenCalled(); ++ expect(api.sendNotification).not.toHaveBeenCalled(); ++ }); ++}); +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 +@@ -134,6 +134,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"; +@@ -365,30 +369,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, +@@ -628,7 +608,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, +@@ -681,7 +661,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) { +@@ -771,15 +751,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"); + api.sendNotification(notification); + api.store.dispatch(startActivity("mods", "deployment")); +@@ -812,6 +795,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."), ++ ); ++ } ++ ++ // 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, +@@ -844,10 +844,6 @@ function genUpdateModDeployment(installManager: InstallManager) { + 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, +@@ -893,6 +889,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", +@@ -1215,7 +1212,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); +@@ -1233,8 +1230,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); +@@ -1254,7 +1262,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, +@@ -1264,6 +1298,7 @@ function onDeploySingleMod(api: IExtensionApi) { + stagingPath, + activator, + ); ++ deploymentPrepared = true; + await activator.prepare(dataPath, false, lastActivation, normalize); + if (mod !== undefined) { + if (enable !== false) { +@@ -1288,11 +1323,12 @@ function onDeploySingleMod(api: IExtensionApi) { + activator.id, + ); + } 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 ++++ b/src/renderer/src/extensions/mod_management/util/validateDeploymentTargets.test.ts +@@ -0,0 +1,100 @@ ++import { afterEach, describe, expect, it, vi } from "vitest"; ++ ++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"; ++import { ++ validateDeploymentTargets, ++ validateProfileDeploymentTargets, ++} from "./validateDeploymentTargets"; ++ ++vi.mock("../../gamemode_management/util/modTypeExtensions", () => ({ getModType: vi.fn() })); ++ ++const game = { id: "starwarsgalacticracer" } as IGame; ++const api = { ++ translate: (message: string, options?: { replace: { mods: string } }) => ++ message.replace("{{mods}}", options?.replace.mods ?? ""), ++} as IExtensionApi; ++const config: IMod = { ++ id: "performance-tweaks", ++ type: "starwarsgalacticracer-config", ++ state: "installed", ++ installationPath: "performance-tweaks", ++ attributes: { name: "Performance Tweaks" }, ++}; ++ ++afterEach(() => vi.resetAllMocks()); ++ ++function registerType(supported: boolean, target: string | undefined) { ++ vi.mocked(getModType).mockReturnValue({ ++ typeId: config.type, ++ options: { deploymentEssential: false }, ++ isSupported: () => supported, ++ getPath: () => target, ++ } as unknown as ReturnType); ++} ++ ++describe("deployment destination preflight", () => { ++ it("stops an installed config mod whose extension no longer supports its type", () => { ++ registerType(false, "/prefix/config"); ++ expect(() => validateDeploymentTargets(api, game, [config], { "": "/game" })).toThrow( ++ /Performance Tweaks.*starwarsgalacticracer-config/, ++ ); ++ }); ++ ++ it("stops mods whose type registration disappeared", () => { ++ expect(() => validateDeploymentTargets(api, game, [config], { "": "/game" })).toThrow( ++ ProcessCanceled, ++ ); ++ }); ++ ++ it("stops an explicitly empty target even when deployment is not essential", () => { ++ registerType(true, ""); ++ expect(() => validateDeploymentTargets(api, game, [config], { [config.type]: "" })).toThrow( ++ ProcessCanceled, ++ ); ++ }); ++ ++ it("allows a resolved target, including the default type", () => { ++ expect(() => ++ validateDeploymentTargets(api, game, [config, { ...config, id: "default", type: "" }], { ++ [config.type]: "/prefix/config", ++ "": "/game", ++ }), ++ ).not.toThrow(); ++ }); ++ ++ it.each(["collection", "umm"])("allows the intentionally non-deploying %s type", (type) => { ++ registerType(true, undefined); ++ expect(() => ++ validateDeploymentTargets(api, game, [{ ...config, type }], { "": "/game" }), ++ ).not.toThrow(); ++ }); ++ ++ it("does not mistake an unsupported type returning undefined for a non-deploying type", () => { ++ registerType(false, undefined); ++ expect(() => validateDeploymentTargets(api, game, [config], { "": "/game" })).toThrow( ++ ProcessCanceled, ++ ); ++ }); ++ ++ it("ignores mods not installed yet", () => { ++ expect(() => ++ validateDeploymentTargets(api, game, [{ ...config, state: "installing" }], {}), ++ ).not.toThrow(); ++ }); ++ ++ it("checks only mods enabled in the requested profile", () => { ++ const profile = { gameId: game.id, modState: { [config.id]: { enabled: false } } } as IProfile; ++ expect(() => ++ validateProfileDeploymentTargets(api, game, profile, { [config.id]: config }, {}), ++ ).not.toThrow(); ++ profile.modState[config.id].enabled = true; ++ expect(() => ++ validateProfileDeploymentTargets(api, game, profile, { [config.id]: config }, {}), ++ ).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 @@ import { UserCanceled } from "../../util/CustomErrors"; + import * as fs from "../../util/fs"; + import getVortexPath from "../../util/getVortexPath"; + import type { TFunction } from "../../util/i18n"; ++import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; + import { log } from "../../util/log"; + import { activeGameId, gameName } from "../../util/selectors"; + import walk from "../../util/walk"; +@@ -222,7 +223,7 @@ class DeploymendMethod extends LinkingDeployment { + return ( + fs + .readlinkAsync(linkPath) +- .then((symlinkPath) => symlinkPath === sourcePath) ++ .then((symlinkPath) => symlinkPath === resolveCaseInsensitiveSync(sourcePath)) + // readlink throws an "unknown" error if the file is no link at all. Super helpful + .catch(() => false) + ); diff --git a/recipes/2.6.2/patches/0032-linux-deployment-target-paths.patch b/recipes/2.6.2/patches/0032-linux-deployment-target-paths.patch new file mode 100644 index 0000000..9849da2 --- /dev/null +++ b/recipes/2.6.2/patches/0032-linux-deployment-target-paths.patch @@ -0,0 +1,192 @@ +diff --git a/src/renderer/src/extensions/mod_management/modActivation.linux.test.ts b/src/renderer/src/extensions/mod_management/modActivation.linux.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/extensions/mod_management/modActivation.linux.test.ts +@@ -0,0 +1,123 @@ ++import * as nodeFs from "node:fs"; ++import * as path from "node:path"; ++ ++import { afterEach, expect, vi } from "vitest"; ++ ++import { log } from "../../logging"; ++import { test } from "../../test-utils/deploymentTest"; ++import type { IDeploymentHarness } from "../../test-utils/deploymentTest"; ++import type { IGame } from "../../types/IGame"; ++import { setCompatibilityGameProvider } from "../../util/compatibilityPaths"; ++import deployMods from "./modActivation"; ++import type { IMod } from "./types/IMod"; ++import BlacklistSet from "./util/BlacklistSet"; ++ ++vi.mock("../../logging", () => ({ log: vi.fn() })); ++ ++const gameId = "skyrimse"; ++const typeId = `${gameId}-config`; ++const relativeConfig = "Example/Saved/Config/Windows"; ++ ++afterEach(() => { ++ setCompatibilityGameProvider(undefined); ++ vi.unstubAllEnvs(); ++ vi.mocked(log).mockClear(); ++}); ++ ++function deploy(h: IDeploymentHarness, destination: string) { ++ return deployMods( ++ h.api, ++ gameId, ++ h.stagingDir, ++ destination, ++ [{ id: "config", installationPath: "ConfigMod", state: "installed" } as IMod], ++ h.method, ++ [], ++ h.normalize, ++ typeId, ++ new BlacklistSet([], { details: {} } as IGame, h.normalize), ++ () => "", ++ ); ++} ++ ++function deployingLogs(): unknown[] { ++ return vi ++ .mocked(log) ++ .mock.calls.filter(([, message]) => message === "deploying") ++ .map(([, , metadata]) => metadata); ++} ++ ++test.runIf(process.platform === "linux")( ++ "deploys a Windows-only target into the selected Proton profile and logs where it went", ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" }, modName: "ConfigMod" }); ++ const root = path.dirname(h.stagingDir); ++ const local = path.join(root, "Local"); ++ const prefix = path.join(root, "prefix"); ++ const prefixConfig = path.join(prefix, "drive_c/users/steamuser/AppData/Local", relativeConfig); ++ nodeFs.mkdirSync(prefixConfig, { recursive: true }); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); ++ ++ const deployed = await deploy(h, path.join(local, relativeConfig)); ++ ++ expect(deployed).toHaveLength(1); ++ expect(nodeFs.readFileSync(path.join(prefixConfig, "Engine.ini"), "utf8")).toBe("config"); ++ // The writability check must not leave an empty copy of the target on the host. ++ expect(nodeFs.existsSync(local)).toBe(false); ++ expect(deployingLogs()).toEqual([ ++ expect.objectContaining({ ++ typeId, ++ destinationPath: path.join(local, relativeConfig), ++ resolvedPath: prefixConfig, ++ prefix, ++ }), ++ ]); ++ }, ++); ++ ++test.runIf(process.platform === "linux")( ++ "reports an unavailable profile instead of deploying to the host", ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" }, modName: "ConfigMod" }); ++ const root = path.dirname(h.stagingDir); ++ const local = path.join(root, "Local"); ++ vi.stubEnv("VORTEX_LINUX_LOCAL_APPDATA", local); ++ setCompatibilityGameProvider(() => ({ ++ environment: { WINEPREFIX: path.join(root, "missing-prefix") }, ++ })); ++ ++ await expect(deploy(h, path.join(local, relativeConfig))).rejects.toMatchObject({ ++ code: "ENOENT", ++ }); ++ ++ expect(nodeFs.existsSync(local)).toBe(false); ++ expect(deployingLogs()).toEqual([ ++ expect.objectContaining({ ++ typeId, ++ prefix: path.join(root, "missing-prefix"), ++ targetError: expect.stringMatching(/unavailable/), ++ }), ++ ]); ++ }, ++); ++ ++test.runIf(process.platform === "linux")( ++ "keeps a native game's target on the host", ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "Engine.ini": "config" }, modName: "ConfigMod" }); ++ setCompatibilityGameProvider(() => ({ path: h.gameDir, executable: "Game.sh" })); ++ ++ const deployed = await deploy(h, h.gameDir); ++ ++ expect(deployed).toHaveLength(1); ++ expect(nodeFs.readFileSync(h.inGame("Engine.ini"), "utf8")).toBe("config"); ++ expect(deployingLogs()).toEqual([ ++ expect.objectContaining({ ++ destinationPath: h.gameDir, ++ resolvedPath: h.gameDir, ++ prefix: null, ++ }), ++ ]); ++ }, ++); +diff --git a/src/renderer/src/extensions/mod_management/modActivation.ts b/src/renderer/src/extensions/mod_management/modActivation.ts +--- a/src/renderer/src/extensions/mod_management/modActivation.ts ++++ b/src/renderer/src/extensions/mod_management/modActivation.ts +@@ -1,10 +1,14 @@ + import * as path from "path"; + ++import { getErrorMessageOrDefault } from "@vortex/shared"; ++ + import { log } from "../../logging"; + import type { IExtensionApi } from "../../types/IExtensionContext"; ++import { selectedGamePrefix } from "../../util/compatibilityPaths"; + import { UserCanceled } from "../../util/CustomErrors"; + import * as fs from "../../util/fs"; + import type { Normalize } from "../../util/getNormalizeFunc"; ++import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; + import { truthy } from "../../util/util"; + import { MERGED_PATH } from "./modMerging"; + import type { IDeployedFile, IDeploymentMethod } from "./types/IDeploymentMethod"; +@@ -32,6 +36,24 @@ async function ensureWritable(api: IExtensionApi, modPath: string): Promise { ++ if (process.platform !== "linux") { ++ return {}; ++ } ++ let prefix: string | null | undefined; ++ try { ++ prefix = selectedGamePrefix(); ++ return { prefix, resolvedPath: resolveCaseInsensitiveSync(destinationPath) }; ++ } catch (err) { ++ return { prefix, targetError: getErrorMessageOrDefault(err) }; ++ } ++} ++ + /** + * activate a list of mod + * +@@ -66,6 +88,7 @@ async function deployMods( + typeId, + installationPath, + destinationPath, ++ ...linuxDeploymentTarget(destinationPath), + }); + + try { +diff --git a/src/renderer/src/util/fs.ts b/src/renderer/src/util/fs.ts +--- a/src/renderer/src/util/fs.ts ++++ b/src/renderer/src/util/fs.ts +@@ -1069,6 +1069,9 @@ export function ensureDirWritableAsync( + if (confirm === undefined) { + confirm = () => PromiseBB.resolve(); + } ++ // fs-extra bypasses the compatibility layer; without this the writability ++ // check creates an empty copy of a Windows-only target on the Linux side. ++ dirPath = resolveCaseInsensitiveSync(dirPath); + const stackErr = new Error(); + return PromiseBB.resolve(fs.ensureDir(dirPath)) + .then(() => { diff --git a/recipes/2.6.2/patches/0033-linux-symlinked-extension-paths.patch b/recipes/2.6.2/patches/0033-linux-symlinked-extension-paths.patch new file mode 100644 index 0000000..6c409fc --- /dev/null +++ b/recipes/2.6.2/patches/0033-linux-symlinked-extension-paths.patch @@ -0,0 +1,265 @@ +diff --git a/src/renderer/src/renderer.tsx b/src/renderer/src/renderer.tsx +--- a/src/renderer/src/renderer.tsx ++++ b/src/renderer/src/renderer.tsx +@@ -161,6 +161,7 @@ import type { IState } from "./types/IState"; + import { relaunch } from "./util/commandLine"; + import { ProcessCanceled, UserCanceled } from "./util/CustomErrors"; + import { recordErrorSpan, setOutdated, terminate, toError } from "./util/errorHandling"; ++import { extensionInStack } from "./util/extensionPaths"; + import {} from "./util/extensionRequire"; + import { setTFunction } from "./util/fs"; + import GlobalNotifications from "./util/GlobalNotifications"; +@@ -383,12 +384,8 @@ function errorHandler(evt: any) { + const dynPaths = ExtensionManager.getExtensionPaths().filter((extPath) => !extPath.bundled); + + if (dynPaths.length > 0) { +- if (stack.includes(`at ${dynPaths[0].path}`)) { +- const extPath = (dynPaths[0].path + path.sep).replace(/[\\]/g, "\\\\"); +- const re = new RegExp(`at ${extPath}(Vortex Extension Update - )?([^/\\\\]*)`); +- const reMatch = stack.match(re); +- const extName = reMatch?.[2] ?? "unknown"; +- ++ const extName = extensionInStack(stack, dynPaths[0].path); ++ if (extName !== undefined) { + error["extension"] = extName; + + log("error", "extension caused an unhandled exception", { +diff --git a/src/renderer/src/util/extensionPaths.test.ts b/src/renderer/src/util/extensionPaths.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/extensionPaths.test.ts +@@ -0,0 +1,72 @@ ++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 { extensionInStack, isInsideExtensionRoot } from "./extensionPaths"; ++ ++// Fedora Atomic links /home to /var/home: Vortex records extension paths ++// under /home while Node reports module filenames and stack frames under ++// /var/home. ++describe.runIf(process.platform === "linux")("extension roots behind a symlink", () => { ++ let root: string; ++ let linked: string; ++ let real: string; ++ ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-extension-paths-")); ++ real = path.join(root, "var", "home", "plugins"); ++ fs.mkdirSync(path.join(real, "game-example"), { recursive: true }); ++ fs.symlinkSync(path.join(root, "var", "home"), path.join(root, "home")); ++ linked = path.join(root, "home", "plugins"); ++ }); ++ ++ afterEach(() => { ++ fs.rmSync(root, { recursive: true, force: true }); ++ }); ++ ++ it("places a module reported by its real path inside the linked root", () => { ++ expect(isInsideExtensionRoot(path.join(real, "game-example", "index.js"), linked)).toBe(true); ++ }); ++ ++ it("keeps modules outside the root outside", () => { ++ expect(isInsideExtensionRoot(path.join(root, "app", "index.js"), linked)).toBe(false); ++ }); ++ ++ // plugins/ doesn't exist until the first extension is installed. ++ it("places a module inside a root created after its first lookup", () => { ++ const later = path.join(root, "home", "later-plugins"); ++ const extModule = path.join(root, "var", "home", "later-plugins", "game-example", "index.js"); ++ ++ expect(isInsideExtensionRoot(extModule, later)).toBe(true); ++ fs.mkdirSync(path.dirname(extModule), { recursive: true }); ++ expect(isInsideExtensionRoot(extModule, later)).toBe(true); ++ }); ++ ++ it("names the extension a stack frame under the real path points into", () => { ++ const stack = `Error: boom\n at ${path.join(real, "game-example", "index.js")}:3:9`; ++ ++ expect(extensionInStack(stack, linked)).toBe("game-example"); ++ }); ++ ++ it("names an extension installed as an update archive", () => { ++ const stack = `Error: boom\n at ${path.join(linked, "Vortex Extension Update - game-example", "index.js")}:3:9`; ++ ++ expect(extensionInStack(stack, linked)).toBe("game-example"); ++ }); ++ ++ it("names the extension below a root whose path has regex characters", () => { ++ const special = path.join(root, "Data (2TB)", "plugins"); ++ fs.mkdirSync(path.join(special, "game-example"), { recursive: true }); ++ const stack = `Error: boom\n at ${path.join(special, "game-example", "index.js")}:3:9`; ++ ++ expect(extensionInStack(stack, special)).toBe("game-example"); ++ }); ++ ++ it("does not claim a stack that never enters the root", () => { ++ const stack = `Error: boom\n at ${path.join(root, "app", "renderer.js")}:1:1`; ++ ++ expect(extensionInStack(stack, linked)).toBeUndefined(); ++ }); ++}); +diff --git a/src/renderer/src/util/extensionPaths.ts b/src/renderer/src/util/extensionPaths.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/extensionPaths.ts +@@ -0,0 +1,55 @@ ++import * as fs from "node:fs"; ++import * as path from "node:path"; ++ ++import * as _ from "lodash"; ++ ++// Node reports module filenames and stack frames with symlinks resolved, but ++// extension paths are built from userData and keep them. On Fedora Atomic ++// /home links to /var/home, so a plain prefix check misses every ++// user-installed extension. Cached because the require hook asks on every ++// require in the application. ++const realRoots = new Map(); ++ ++// A root that doesn't exist yet, such as plugins/ before the first extension ++// is installed, takes the real path of its nearest existing parent, so the ++// cached value still holds once the folder is created. ++function realPathOf(input: string): string { ++ try { ++ return fs.realpathSync(input); ++ } catch { ++ const parent = path.dirname(input); ++ return parent === input ? input : path.join(realPathOf(parent), path.basename(input)); ++ } ++} ++ ++function realRoot(root: string): string { ++ let real = realRoots.get(root); ++ if (real === undefined) { ++ real = realPathOf(root); ++ realRoots.set(root, real); ++ } ++ return real; ++} ++ ++function spellings(root: string): string[] { ++ const real = realRoot(root); ++ return real === root ? [root] : [root, real]; ++} ++ ++export function isInsideExtensionRoot(filename: string, root: string): boolean { ++ return spellings(root).some((spelling) => filename.startsWith(spelling)); ++} ++ ++/** ++ * Name of the extension below root that a stack trace runs through, or ++ * undefined if the stack never enters root. ++ */ ++export function extensionInStack(stack: string, root: string): string | undefined { ++ const spelling = spellings(root).find((iter) => stack.includes(`at ${iter}`)); ++ if (spelling === undefined) { ++ return undefined; ++ } ++ const extPath = _.escapeRegExp(spelling + path.sep); ++ const re = new RegExp(`at ${extPath}(Vortex Extension Update - )?([^/\\\\]*)`); ++ return stack.match(re)?.[2] ?? "unknown"; ++} +diff --git a/src/renderer/src/util/extensionRequire.test.ts b/src/renderer/src/util/extensionRequire.test.ts +--- a/src/renderer/src/util/extensionRequire.test.ts ++++ b/src/renderer/src/util/extensionRequire.test.ts +@@ -12,6 +12,8 @@ vi.mock("../extensions/extension_manager/queries", () => ({ findInstalled: vi.fn + vi.mock("./deprecatedApiUsage", () => ({ deprecatedApiGet: vi.fn() })); + vi.mock("./webpack-hacks", () => ({ webpackRequireHack: modules.load })); + ++import * as api from "../api"; ++import type { IRegisteredExtension } from "../types/extensions"; + import { setCompatibilityGameProvider } from "./compatibilityPaths"; + import { extensionRequire } from "./extensionRequire"; + +@@ -56,4 +58,49 @@ describe.runIf(process.platform === "linux")("extension-only filesystem require" + fs.rmSync(root, { recursive: true, force: true }); + } + }); ++ ++ // Fedora Atomic links /home to /var/home: Vortex records extension paths ++ // under /home while Node reports module filenames under /var/home. ++ function symlinkedExtensionRoot(): { root: string; linked: string; real: string } { ++ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-extension-symlink-")); ++ const real = path.join(root, "var", "home", "plugins"); ++ fs.mkdirSync(path.join(real, "game-example"), { recursive: true }); ++ fs.symlinkSync(path.join(root, "var", "home"), path.join(root, "home")); ++ return { root, linked: path.join(root, "home", "plugins"), real }; ++ } ++ ++ it("serves application modules to an extension reached through a symlinked root", () => { ++ const { root, linked, real } = symlinkedExtensionRoot(); ++ try { ++ const bundled = { walk: "bundled" }; ++ modules.paths = [{ path: linked }]; ++ modules.load.mockImplementation((id: string) => (id === "turbowalk" ? bundled : undefined)); ++ const originalRequire = vi.fn(() => { ++ throw new Error("Cannot find module 'turbowalk'"); ++ }); ++ const load = extensionRequire(originalRequire, () => []); ++ const extModule = { filename: path.join(real, "game-example", "index.js") }; ++ expect(load.call(extModule, "turbowalk")).toBe(bundled); ++ expect(originalRequire).not.toHaveBeenCalled(); ++ } finally { ++ fs.rmSync(root, { recursive: true, force: true }); ++ } ++ }); ++ ++ it("gives an extension reached through a symlinked root its own api proxy", () => { ++ const { root, linked, real } = symlinkedExtensionRoot(); ++ try { ++ modules.paths = [{ path: linked }]; ++ const extension = { ++ name: "game-example", ++ namespace: "game-example", ++ path: path.join(linked, "game-example"), ++ } as IRegisteredExtension; ++ const load = extensionRequire(vi.fn(), () => [extension]); ++ const extModule = { filename: path.join(real, "game-example", "index.js") }; ++ expect(load.call(extModule, "vortex-api") === api).toBe(false); ++ } finally { ++ fs.rmSync(root, { recursive: true, force: true }); ++ } ++ }); + }); +diff --git a/src/renderer/src/util/extensionRequire.ts b/src/renderer/src/util/extensionRequire.ts +--- a/src/renderer/src/util/extensionRequire.ts ++++ b/src/renderer/src/util/extensionRequire.ts +@@ -8,6 +8,7 @@ import * as reactSelect from "../controls/ReactSelectWrap"; + import ExtensionManager from "../ExtensionManager"; + import type { IRegisteredExtension } from "../types/extensions"; + import { wrapExtensionFs } from "./extensionFs"; ++import { isInsideExtensionRoot } from "./extensionPaths"; + import type { LogLevel } from "./log"; + import { webpackRequireHack } from "./webpack-hacks"; + +@@ -106,7 +107,7 @@ export function extensionRequire(orig, getExtensions: () => IRegisteredExtension + const extensionPaths = ExtensionManager.getExtensionPaths(); + return function (id) { + if (id === "vortex-api" || id === "@nexusmods/vortex-api") { +- const ext = getExtensions().find((iter) => this.filename.startsWith(iter.path)); ++ const ext = getExtensions().find((iter) => isInsideExtensionRoot(this.filename, iter.path)); + if (ext !== undefined) { + if (handlerMapAPI[ext.name] === undefined) { + handlerMapAPI[ext.name] = new Proxy(api, new ExtProxyHandler(ext)); +@@ -120,7 +121,7 @@ export function extensionRequire(orig, getExtensions: () => IRegisteredExtension + } else if (id === "react-select") { + return reactSelect; + } else if (id === "redux-act") { +- const ext = getExtensions().find((iter) => this.filename.startsWith(iter.path)); ++ const ext = getExtensions().find((iter) => isInsideExtensionRoot(this.filename, iter.path)); + if (ext !== undefined) { + if (handlerMapReactAct[ext.name] === undefined) { + handlerMapReactAct[ext.name] = new Proxy(reduxAct, new ExtProxyHandlerReduxAct(ext)); +@@ -128,7 +129,9 @@ export function extensionRequire(orig, getExtensions: () => IRegisteredExtension + return handlerMapReactAct[ext.name]; + } + } +- if (extensionPaths.find((iter) => this.filename.startsWith(iter.path)) !== undefined) { ++ if ( ++ extensionPaths.find((iter) => isInsideExtensionRoot(this.filename, iter.path)) !== undefined ++ ) { + let res; + try { + res = webpackRequireHack(id); diff --git a/recipes/2.6.2/patches/0034-linux-case-only-platform-paths.patch b/recipes/2.6.2/patches/0034-linux-case-only-platform-paths.patch new file mode 100644 index 0000000..b71f1ed --- /dev/null +++ b/recipes/2.6.2/patches/0034-linux-case-only-platform-paths.patch @@ -0,0 +1,272 @@ +diff --git a/src/renderer/src/util/linuxCompatibility.test.ts b/src/renderer/src/util/linuxCompatibility.test.ts +--- a/src/renderer/src/util/linuxCompatibility.test.ts ++++ b/src/renderer/src/util/linuxCompatibility.test.ts +@@ -3,7 +3,7 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + + import { + filterSuccessfulWineStderr, +@@ -27,6 +27,7 @@ describe.runIf(process.platform === "linux")("linuxCompatibility", () => { + }); + + afterEach(() => { ++ vi.unstubAllEnvs(); + fs.rmSync(tempDir, { force: true, recursive: true }); + if (previousRuntimeRoot === undefined) { + delete process.env.VORTEX_DOTNET_WIN_ROOT; +@@ -60,6 +61,40 @@ describe.runIf(process.platform === "linux")("linuxCompatibility", () => { + ).toBe(path.join(plugins, "new.dll")); + }); + ++ // Documents/Shared exists in each prefix, so a path below the host's ++ // Documents/Shared is a candidate for the Wine user-folder mapping. ++ function sharedDocumentsInPrefixes(count: number): string { ++ const documents = path.join(tempDir, "Documents"); ++ fs.mkdirSync(path.join(documents, "Shared"), { recursive: true }); ++ const prefixes = Array.from({ length: count }, (_, idx) => { ++ const prefix = path.join(tempDir, `pfx${idx}`); ++ fs.mkdirSync(path.join(prefix, "drive_c", "users", "steamuser", "Documents", "Shared"), { ++ recursive: true, ++ }); ++ return prefix; ++ }); ++ vi.stubEnv("HOME", tempDir); ++ vi.stubEnv("VORTEX_LINUX_DOCUMENTS", documents); ++ vi.stubEnv("VORTEX_COMPAT_PREFIXES", prefixes.join(path.delimiter)); ++ return documents; ++ } ++ ++ it("keeps a matched host path that does not resolve, such as a dangling link", () => { ++ const documents = sharedDocumentsInPrefixes(1); ++ const link = path.join(documents, "Shared", "link"); ++ fs.symlinkSync(path.join(tempDir, "missing"), link); ++ ++ expect(resolveCaseInsensitiveSync(path.join(documents, "shared", "link"))).toBe(link); ++ }); ++ ++ it("falls back to the requested path when mapping the corrected path fails", () => { ++ sharedDocumentsInPrefixes(2); ++ // Only the corrected spelling falls under the mapped Documents folder. ++ const requested = path.join(tempDir, "documents", "Shared", "new.txt"); ++ ++ expect(resolveCaseInsensitiveSync(requested)).toBe(requested); ++ }); ++ + it("maps local absolute arguments into Wine's Z drive", () => { + const localPath = path.join(tempDir, "Data", "input.pak"); + fs.mkdirSync(path.dirname(localPath)); +diff --git a/src/renderer/src/util/linuxCompatibility.ts b/src/renderer/src/util/linuxCompatibility.ts +--- a/src/renderer/src/util/linuxCompatibility.ts ++++ b/src/renderer/src/util/linuxCompatibility.ts +@@ -80,42 +80,64 @@ function wineEnvironment(input: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEn + return result; + } + +-export function resolveCaseInsensitiveSync(input: string): string { +- if (process.platform !== "linux" || !path.isAbsolute(input)) return input; +- input = resolveSelectedWindowsUserPathSync(input); +- if (fs.existsSync(input)) return input; +- ++// Walks input one part at a time, matching each against the directory listing ++// case-insensitively. complete is false when it stopped early: a part doesn't ++// exist (the rest keeps the casing of the prefix that does) or has several case ++// variants on disk. ++function walkExistingCase(input: string): { path: string; complete: boolean } { + const normalized = path.normalize(input); + const root = path.parse(normalized).root; + const parts = path.relative(root, normalized).split(path.sep).filter(Boolean); + let current = root; + +- try { +- for (let idx = 0; idx < parts.length; idx++) { +- const part = parts[idx]; +- const exact = path.join(current, part); +- if (fs.existsSync(exact)) { +- current = exact; +- continue; +- } +- +- const matches = fs +- .readdirSync(current) +- .filter((entry) => entry.toLowerCase() === part.toLowerCase()); +- if (matches.length === 0) { +- // Keep the casing of the prefix that already exists. This matters when +- // creating a new file below a Windows-authored directory such as +- // "SFSE/Plugins" through a mod path spelling it "SFSE/plugins". +- return resolveWindowsUserPathSync(path.join(current, ...parts.slice(idx))); +- } +- if (matches.length > 1) { +- // The host contains multiple case variants and the requested spelling +- // is not an exact match. Do not guess which existing entry to mutate. +- return resolveWindowsUserPathSync(path.join(current, ...parts.slice(idx))); +- } +- current = path.join(current, matches[0]); ++ for (let idx = 0; idx < parts.length; idx++) { ++ const part = parts[idx]; ++ const exact = path.join(current, part); ++ if (fs.existsSync(exact)) { ++ current = exact; ++ continue; + } +- return current; ++ ++ const matches = fs ++ .readdirSync(current) ++ .filter((entry) => entry.toLowerCase() === part.toLowerCase()); ++ if (matches.length === 0) { ++ // Keep the casing of the prefix that already exists. This matters when ++ // creating a new file below a Windows-authored directory such as ++ // "SFSE/Plugins" through a mod path spelling it "SFSE/plugins". ++ return { path: path.join(current, ...parts.slice(idx)), complete: false }; ++ } ++ if (matches.length > 1) { ++ // The host contains multiple case variants and the requested spelling ++ // is not an exact match. Do not guess which existing entry to mutate. ++ return { path: path.join(current, ...parts.slice(idx)), complete: false }; ++ } ++ current = path.join(current, matches[0]); ++ } ++ return { path: current, complete: true }; ++} ++ ++/** ++ * Corrects the casing of an absolute path against what exists on disk, without ++ * the Windows user-folder mapping resolveCaseInsensitiveSync applies. ++ */ ++export function matchExistingCaseSync(input: string): string { ++ if (fs.existsSync(input)) return input; ++ try { ++ return walkExistingCase(input).path; ++ } catch { ++ return input; ++ } ++} ++ ++export function resolveCaseInsensitiveSync(input: string): string { ++ if (process.platform !== "linux" || !path.isAbsolute(input)) return input; ++ input = resolveSelectedWindowsUserPathSync(input); ++ if (fs.existsSync(input)) return input; ++ ++ try { ++ const walked = walkExistingCase(input); ++ return walked.complete ? walked.path : resolveWindowsUserPathSync(walked.path); + } catch { + return resolveWindowsUserPathSync(input); + } +diff --git a/src/renderer/src/util/nativeExecutable.test.ts b/src/renderer/src/util/nativeExecutable.test.ts +--- a/src/renderer/src/util/nativeExecutable.test.ts ++++ b/src/renderer/src/util/nativeExecutable.test.ts +@@ -2,8 +2,9 @@ import * as fs from "fs"; + import * as os from "os"; + import * as path from "path"; + +-import { afterEach, beforeEach, describe, expect, it } from "vitest"; ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + ++import { setCompatibilityGameProvider } from "./compatibilityPaths"; + import { resolveNativeExecutable, resolvePlatformPath } from "./nativeExecutable"; + + describe("native executable resolution", () => { +@@ -14,6 +15,8 @@ describe("native executable resolution", () => { + }); + + afterEach(() => { ++ setCompatibilityGameProvider(undefined); ++ vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + }); + +@@ -60,6 +63,58 @@ describe("native executable resolution", () => { + expect(resolveNativeExecutable(root, "bin/sample.exe", "win32")).toBe("bin/sample.exe"); + }); + ++ it("finds a required executable whose directory differs only in case", () => { ++ executable("Game/sample.exe"); ++ ++ expect(resolvePlatformPath(root, "game/sample.exe", "linux")).toBe( ++ path.join(root, "Game", "sample.exe"), ++ ); ++ }); ++ ++ it("finds a required data file whose path differs only in case", () => { ++ fs.mkdirSync(path.join(root, "Data"), { recursive: true }); ++ fs.writeFileSync(path.join(root, "Data", "Sample.esm"), "data"); ++ ++ expect(resolvePlatformPath(root, "data/sample.esm", "linux")).toBe( ++ path.join(root, "Data", "Sample.esm"), ++ ); ++ }); ++ ++ it("maps to a native executable inside a directory that differs only in case", () => { ++ executable("Bin/sample"); ++ ++ expect(resolvePlatformPath(root, "bin/sample.exe", "linux")).toBe( ++ path.join(root, "Bin", "sample"), ++ ); ++ }); ++ ++ // A game installed under a folder Vortex maps into Proton prefixes must keep ++ // its host path: only the casing of the declared path is corrected. ++ it.runIf(process.platform === "linux")( ++ "does not move a game under a mapped user folder into the game prefix", ++ () => { ++ const documents = path.join(root, "Documents"); ++ const prefix = path.join(root, "prefix"); ++ const install = path.join(documents, "Games", "Sample"); ++ executable(path.join("Documents", "Games", "Sample", "Game", "sample.exe")); ++ fs.mkdirSync(path.join(prefix, "drive_c", "users", "steamuser", "Documents", "Games"), { ++ recursive: true, ++ }); ++ vi.stubEnv("VORTEX_LINUX_DOCUMENTS", documents); ++ setCompatibilityGameProvider(() => ({ ++ path: install, ++ environment: { WINEPREFIX: prefix }, ++ })); ++ ++ expect(resolvePlatformPath(install, "Game/sample.exe", "linux")).toBe( ++ path.join(install, "Game", "sample.exe"), ++ ); ++ expect(resolvePlatformPath(install, "game/sample.exe", "linux")).toBe( ++ path.join(install, "Game", "sample.exe"), ++ ); ++ }, ++ ); ++ + it("returns an absolute platform path for validation", () => { + executable("bin/sample"); + +diff --git a/src/renderer/src/util/nativeExecutable.ts b/src/renderer/src/util/nativeExecutable.ts +--- a/src/renderer/src/util/nativeExecutable.ts ++++ b/src/renderer/src/util/nativeExecutable.ts +@@ -1,6 +1,8 @@ + import * as fs from "fs"; + import * as path from "path"; + ++import { matchExistingCaseSync } from "./linuxCompatibility"; ++ + function isExecutableFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); +@@ -107,7 +109,15 @@ export function resolvePlatformPath( + declaredPath: string, + platform: NodeJS.Platform = process.platform, + ): string { +- return path.isAbsolute(declaredPath) +- ? resolveNativeExecutable(basePath, declaredPath, platform) +- : path.join(basePath, resolveNativeExecutable(basePath, declaredPath, platform)); ++ if (platform !== "linux") { ++ return path.isAbsolute(declaredPath) ++ ? resolveNativeExecutable(basePath, declaredPath, platform) ++ : path.join(basePath, resolveNativeExecutable(basePath, declaredPath, platform)); ++ } ++ // Extensions spell paths the way Windows shows them, and Windows ignores ++ // case: Elden Ring declares "game/eldenring.exe" for an install folder ++ // named "Game". Correct the casing only. resolveCaseInsensitiveSync would ++ // also move a game installed under ~/Documents into the selected prefix. ++ const declared = matchExistingCaseSync(path.resolve(basePath, declaredPath)); ++ return resolveNativeExecutable(basePath, declared, platform); + } diff --git a/recipes/2.6.2/patches/0035-linux-backslash-archive-paths.patch b/recipes/2.6.2/patches/0035-linux-backslash-archive-paths.patch new file mode 100644 index 0000000..f26f046 --- /dev/null +++ b/recipes/2.6.2/patches/0035-linux-backslash-archive-paths.patch @@ -0,0 +1,310 @@ +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 +@@ -184,6 +184,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, +@@ -251,6 +252,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,120 @@ ++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 { withPatchedPath } from "../../../test-utils/patchedPath"; ++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(); ++} ++ ++for (const patchedPath of [false, true]) { ++ const split = (root: string) => ++ patchedPath ? withPatchedPath(() => splitBackslashNames(root)) : splitBackslashNames(root); ++ ++ describe.runIf(process.platform === "linux")( ++ `archive entries with backslash separators${patchedPath ? " (renderer path patch)" : ""}`, ++ () => { ++ 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 split(root); ++ ++ expect(tree(root)).toEqual([ ++ "normal/", ++ "normal/readme.txt=readme", ++ "red4ext/", ++ "red4ext/plugins/", ++ "red4ext/plugins/AppearanceRuntimePlugin/", ++ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll", ++ ]); ++ }); ++ ++ it("turns empty entries that end in a backslash into folders", async () => { ++ // 7-Zip extracts folder entries as empty files when the archive doesn't mark them as ++ // folders, e.g. when it says it was made on Unix. ++ fs.writeFileSync(path.join(root, "bin\\x64\\"), ""); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\"), ""); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\Mod.dll"), "dll"); ++ fs.writeFileSync(path.join(root, "bin\\x64\\plugins\\Empty\\"), ""); ++ ++ await split(root); ++ ++ expect(tree(root)).toEqual([ ++ "bin/", ++ "bin/x64/", ++ "bin/x64/plugins/", ++ "bin/x64/plugins/Empty/", ++ "bin/x64/plugins/Mod.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 split(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 split(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 split(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,55 @@ ++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 = child(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 = parts.reduce(child, directory); ++ if (entry.isFile() && entry.name.endsWith("\\") && (await fs.stat(source)).size === 0) { ++ // A folder entry the archive doesn't mark as one, which 7-Zip extracts as an empty file. ++ await fs.mkdir(target, { recursive: true }); ++ await fs.unlink(source); ++ continue; ++ } ++ await move(source, target, entry.isDirectory()); ++ if (entry.isDirectory()) await visit(target); ++ } ++ }; ++ await visit(root); ++} ++ ++/** ++ * The renderer patches path.join to turn backslashes into slashes for extensions ++ * (util/pathCompatibility.ts), which would rewrite the very names this file moves. ++ */ ++function child(directory: string, name: string): string { ++ return `${directory}/${name}`; ++} ++ ++async function move(source: string, target: string, isDirectory: boolean): Promise { ++ const existing = await fs.stat(target).catch(() => undefined); ++ if (isDirectory && existing?.isDirectory()) { ++ for (const entry of await fs.readdir(source, { withFileTypes: true })) { ++ await move(child(source, entry.name), child(target, entry.name), entry.isDirectory()); ++ } ++ await fs.rmdir(source); ++ return; ++ } ++ await fs.mkdir(path.dirname(target), { recursive: true }); ++ await fs.rename(source, target); ++} +diff --git a/src/renderer/src/test-utils/patchedPath.ts b/src/renderer/src/test-utils/patchedPath.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/test-utils/patchedPath.ts +@@ -0,0 +1,21 @@ ++import { createRequire, syncBuiltinESMExports } from "node:module"; ++import type * as path from "node:path"; ++ ++import { patchPathCompatibility } from "../util/pathCompatibility"; ++ ++/** ++ * Runs fn with path.join, normalize and resolve turning backslashes into slashes, the way the ++ * renderer patches them at startup (util/monkeyPatching.ts). ++ */ ++export async function withPatchedPath(fn: () => PromiseLike): Promise { ++ const hostPath: typeof path = createRequire(__filename)("node:path"); ++ const { join, normalize, resolve } = hostPath; ++ patchPathCompatibility(hostPath, "linux"); ++ syncBuiltinESMExports(); ++ try { ++ return await fn(); ++ } finally { ++ Object.assign(hostPath, { join, normalize, resolve }); ++ syncBuiltinESMExports(); ++ } ++} +diff --git a/src/renderer/src/util/fs.linuxCompatibility.test.ts b/src/renderer/src/util/fs.linuxCompatibility.test.ts +--- a/src/renderer/src/util/fs.linuxCompatibility.test.ts ++++ b/src/renderer/src/util/fs.linuxCompatibility.test.ts +@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + // @ts-expect-error -- Vitest supports virtual modules at runtime but omits this overload. + vi.mock("original-fs", async () => import("node:fs"), { virtual: true }); + ++import { withPatchedPath } from "../test-utils/patchedPath"; + import { resetCompatibilityPathCache } from "./compatibilityPaths"; +-import { copyAsync, readFileSync, statAsync, statSync, writeFileAsync } from "./fs"; ++import { copyAsync, readFileSync, removeAsync, statAsync, statSync, writeFileAsync } from "./fs"; + + describe.runIf(process.platform === "linux")("filesystem compatibility profile paths", () => { + let tempDir: string; +@@ -91,3 +92,23 @@ describe.runIf(process.platform === "linux")("filesystem compatibility profile p + process.env[key] = value; + } + }); ++ ++describe.runIf(process.platform === "linux")("removing names with backslashes", () => { ++ let tempDir: string; ++ beforeEach(() => { ++ tempDir = nodeFs.mkdtempSync(path.join(os.tmpdir(), "vortex-fs-remove-")); ++ }); ++ afterEach(() => nodeFs.rmSync(tempDir, { force: true, recursive: true })); ++ ++ it("removes a mod that was installed with Windows separators in its file names", async () => { ++ const modPath = path.join(tempDir, "Appearance Runtime Plugin"); ++ nodeFs.mkdirSync(path.join(modPath, "red4ext\\plugins\\"), { recursive: true }); ++ nodeFs.writeFileSync(path.join(modPath, "red4ext\\plugins\\Mod\\Mod.dll"), "dll"); ++ nodeFs.mkdirSync(path.join(modPath, "red4ext", "plugins"), { recursive: true }); ++ nodeFs.writeFileSync(path.join(modPath, "red4ext", "plugins", "Other.dll"), "dll"); ++ ++ await withPatchedPath(() => removeAsync(modPath, { showDialogCallback: () => false })); ++ ++ expect(nodeFs.existsSync(modPath)).toBe(false); ++ }); ++}); +diff --git a/src/renderer/src/util/fs.ts b/src/renderer/src/util/fs.ts +--- a/src/renderer/src/util/fs.ts ++++ b/src/renderer/src/util/fs.ts +@@ -937,6 +937,9 @@ function removeInt( + } + + function rimrafAsync(remPath: string): PromiseBB { ++ if (process.platform === "linux") { ++ return PromiseBB.resolve(removeTree(remPath)); ++ } + return new PromiseBB((resolve, reject) => { + // don't use the rimraf implementation of busy retries because it's f*cked: + // https://github.com/isaacs/rimraf/issues/187 +@@ -956,6 +959,26 @@ function rimrafAsync(remPath: string): PromiseBB { + }); + } + ++/** ++ * rimraf builds child paths with path.join, which the renderer patches to turn backslashes into ++ * slashes (pathCompatibility.ts). Names extracted from Windows archives, such as ++ * "red4ext\plugins\Mod.dll", then look already gone and their folder can't be removed. ++ */ ++async function removeTree(remPath: string): Promise { ++ const ignoreMissing = (err: unknown) => { ++ if (getErrorCode(err) !== "ENOENT") throw err; ++ }; ++ const stats = await fs.lstat(remPath).catch(ignoreMissing); ++ if (!stats) return; ++ if (stats.isDirectory()) { ++ const names = await fs.readdir(remPath); ++ await Promise.all(names.map((name) => removeTree(`${remPath}/${name}`))); ++ await fs.rmdir(remPath).catch(ignoreMissing); ++ } else { ++ await fs.unlink(remPath).catch(ignoreMissing); ++ } ++} ++ + /** @deprecated use node:fs directly */ + export function readlinkAsync(linkPath: string): PromiseBB { + linkPath = resolveCaseInsensitiveSync(linkPath); diff --git a/recipes/2.6.2/patches/0036-linux-appimage-nxm-links.patch b/recipes/2.6.2/patches/0036-linux-appimage-nxm-links.patch new file mode 100644 index 0000000..87bda74 --- /dev/null +++ b/recipes/2.6.2/patches/0036-linux-appimage-nxm-links.patch @@ -0,0 +1,186 @@ +diff --git a/src/renderer/src/util/protocolRegistration/linux/nxm.appimage.test.ts b/src/renderer/src/util/protocolRegistration/linux/nxm.appimage.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/protocolRegistration/linux/nxm.appimage.test.ts +@@ -0,0 +1,90 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; ++ ++const common = vi.hoisted(() => ({ ++ applications: "", ++ refreshDesktopDatabase: vi.fn(), ++ getDefaultUrlSchemeHandler: vi.fn(), ++ setDefaultUrlSchemeHandler: vi.fn(), ++})); ++ ++vi.mock("../../log", () => ({ log: vi.fn() })); ++vi.mock("./common", () => ({ ++ applicationsDirectory: () => common.applications, ++ refreshDesktopDatabase: common.refreshDesktopDatabase, ++ getDefaultUrlSchemeHandler: common.getDefaultUrlSchemeHandler, ++ setDefaultUrlSchemeHandler: common.setDefaultUrlSchemeHandler, ++})); ++ ++import { registerLinuxNxmProtocolHandler } from "./nxm"; ++ ++const options = { setAsDefault: true, executablePath: "/tmp/.mount_x/vortex", appPath: "" }; ++ ++describe.runIf(process.platform === "linux")("nxm links from an AppImage", () => { ++ let root: string; ++ beforeEach(() => { ++ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-nxm-")); ++ common.applications = path.join(root, "applications"); ++ vi.stubEnv("NODE_ENV", "production"); ++ vi.stubEnv("IS_FLATPAK", ""); ++ vi.clearAllMocks(); ++ }); ++ afterEach(() => { ++ vi.unstubAllEnvs(); ++ fs.rmSync(root, { recursive: true, force: true }); ++ }); ++ ++ it("registers a desktop entry that starts the AppImage file itself", () => { ++ const appImage = path.join(root, "My Apps", "Vortex-2.9.0-x86_64.AppImage"); ++ vi.stubEnv("APPIMAGE", appImage); ++ common.getDefaultUrlSchemeHandler.mockReturnValue("org.mo2.desktop"); ++ ++ expect(registerLinuxNxmProtocolHandler(options)).toBe(true); ++ ++ const desktop = fs.readFileSync( ++ path.join(common.applications, "com.nexusmods.vortex.appimage.desktop"), ++ "utf8", ++ ); ++ const wrapper = path.join(common.applications, "com.nexusmods.vortex.appimage.sh"); ++ expect(desktop).toContain(`Exec=${wrapper} %u\n`); ++ expect(desktop).toContain("MimeType=x-scheme-handler/nxm;\n"); ++ expect(desktop).toContain("NoDisplay=true\n"); ++ expect(fs.readFileSync(wrapper, "utf8")).toContain(`exec "${appImage}" "$@"\n`); ++ expect(fs.statSync(wrapper).mode & 0o111).not.toBe(0); ++ expect(common.refreshDesktopDatabase).toHaveBeenCalledWith(common.applications); ++ expect(common.setDefaultUrlSchemeHandler).toHaveBeenCalledWith( ++ "nxm", ++ "com.nexusmods.vortex.appimage.desktop", ++ ); ++ }); ++ ++ it("follows the AppImage when it was moved or replaced", () => { ++ vi.stubEnv("APPIMAGE", path.join(root, "old.AppImage")); ++ registerLinuxNxmProtocolHandler(options); ++ common.refreshDesktopDatabase.mockClear(); ++ ++ vi.stubEnv("APPIMAGE", path.join(root, "new.AppImage")); ++ registerLinuxNxmProtocolHandler(options); ++ ++ const wrapper = path.join(common.applications, "com.nexusmods.vortex.appimage.sh"); ++ expect(fs.readFileSync(wrapper, "utf8")).toContain(`exec "${path.join(root, "new.AppImage")}"`); ++ expect(common.refreshDesktopDatabase).toHaveBeenCalledOnce(); ++ ++ common.refreshDesktopDatabase.mockClear(); ++ registerLinuxNxmProtocolHandler(options); ++ expect(common.refreshDesktopDatabase).not.toHaveBeenCalled(); ++ }); ++ ++ it("leaves package installs on the packaged desktop file", () => { ++ vi.stubEnv("APPIMAGE", ""); ++ registerLinuxNxmProtocolHandler(options); ++ expect(fs.existsSync(common.applications)).toBe(false); ++ expect(common.setDefaultUrlSchemeHandler).toHaveBeenCalledWith( ++ "nxm", ++ "com.nexusmods.vortex.desktop", ++ ); ++ }); ++}); +diff --git a/src/renderer/src/util/protocolRegistration/linux/nxm.ts b/src/renderer/src/util/protocolRegistration/linux/nxm.ts +--- a/src/renderer/src/util/protocolRegistration/linux/nxm.ts ++++ b/src/renderer/src/util/protocolRegistration/linux/nxm.ts +@@ -28,6 +28,9 @@ const NXM_PROTOCOL = "nxm"; + const PACKAGE_DESKTOP_ID = "com.nexusmods.vortex.desktop"; + const DEV_DESKTOP_ID = "com.nexusmods.vortex.dev.desktop"; + const DEV_WRAPPER_FILE_NAME = "com.nexusmods.vortex.dev.sh"; ++// An AppImage installs no desktop file, so it registers its own entry pointing at the image. ++const APPIMAGE_DESKTOP_ID = "com.nexusmods.vortex.appimage.desktop"; ++const APPIMAGE_WRAPPER_FILE_NAME = "com.nexusmods.vortex.appimage.sh"; + + /** + * Required registration inputs for Linux `nxm` routing. +@@ -60,6 +63,8 @@ export function registerLinuxNxmProtocolHandler( + options.executablePath, + options.appPath, + ); ++ } else if (desktopId === APPIMAGE_DESKTOP_ID) { ++ didChangeDesktopFiles = ensureAppImageDesktopEntry(applicationsDir, appImagePath()!); + } + + if (didChangeDesktopFiles) { +@@ -94,6 +99,12 @@ function isFlatpakBuild(): boolean { + return process.env.IS_FLATPAK === "true"; + } + ++/** The AppImage file itself; the mounted directory Vortex runs from changes every launch. */ ++function appImagePath(): string | undefined { ++ const appImage = process.env.APPIMAGE; ++ return appImage !== undefined && path.isAbsolute(appImage) ? appImage : undefined; ++} ++ + function isDevelopmentBuild(): boolean { + return process.defaultApp === true || process.env.NODE_ENV === "development"; + } +@@ -103,6 +114,10 @@ function desktopIdForCurrentBuild(): string { + return PACKAGE_DESKTOP_ID; + } + ++ if (appImagePath() !== undefined) { ++ return APPIMAGE_DESKTOP_ID; ++ } ++ + if (isDevelopmentBuild()) { + return DEV_DESKTOP_ID; + } +@@ -251,3 +266,44 @@ function ensureDevDesktopEntry( + + return wrapperChanged || desktopChanged; + } ++ ++/** ++ * Write the desktop entry an AppImage uses for nxm:// links. The wrapper runs the AppImage ++ * file, whose AppRun adds --download for nxm:// arguments. Vortex rewrites both on every ++ * registration, so moving or replacing the AppImage only needs one start of the new file. ++ */ ++function ensureAppImageDesktopEntry(applicationsDir: string, appImage: string): boolean { ++ const wrapperPath = path.join(applicationsDir, APPIMAGE_WRAPPER_FILE_NAME); ++ const desktopFilePath = path.join(applicationsDir, APPIMAGE_DESKTOP_ID); ++ ++ warnIfApplicationsPathNeedsEscaping(applicationsDir); ++ ++ const wrapperChanged = writeFileIfChanged( ++ wrapperPath, ++ "#!/bin/sh\n" + ++ "unset LD_LIBRARY_PATH\n" + ++ "unset LD_PRELOAD\n" + ++ `exec "${escapeShellScriptArgument(appImage)}" "$@"\n`, ++ 0o755, ++ ); ++ ++ // Hidden from menus: integrating the AppImage itself is left to AppImageLauncher or Gear Lever. ++ const desktopChanged = writeFileIfChanged( ++ desktopFilePath, ++ "[Desktop Entry]\n" + ++ "Type=Application\n" + ++ "Name=Vortex (AppImage)\n" + ++ "GenericName=Mod Manager\n" + ++ "Comment=Handles Nexus Mods download links for the Vortex AppImage\n" + ++ "NoDisplay=true\n" + ++ `Exec=${escapeDesktopExecFilePath(wrapperPath)} %u\n` + ++ `TryExec=${escapeDesktopFilePath(wrapperPath)}\n` + ++ "Icon=vortex\n" + ++ "Terminal=false\n" + ++ "MimeType=x-scheme-handler/nxm;\n" + ++ "StartupWMClass=Vortex\n", ++ 0o755, ++ ); ++ ++ return wrapperChanged || desktopChanged; ++} diff --git a/recipes/2.6.2/patches/0037-linux-case-consistent-folders.patch b/recipes/2.6.2/patches/0037-linux-case-consistent-folders.patch new file mode 100644 index 0000000..6e5a08e --- /dev/null +++ b/recipes/2.6.2/patches/0037-linux-case-consistent-folders.patch @@ -0,0 +1,114 @@ +diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts +--- a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts ++++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts +@@ -161,3 +161,65 @@ test.runIf(process.platform === "linux")( + ); + }, + ); ++ ++for (const method of ["hardlink", "symlink"] as const) { ++ test.runIf(process.platform === "linux")( ++ `${method} deployment puts folders that mods spell in different case into one folder`, ++ async ({ makeDeployment }) => { ++ // Fallout 4 has no loose Sound or Textures folders, so the mods create them while their ++ // files are linked in parallel. ++ const lowercase: Record = {}; ++ const capitalized: Record = {}; ++ for (let idx = 0; idx < 40; idx++) { ++ lowercase[`sound/fx/npc/creature${idx % 7}/a${idx}.wav`] = "a"; ++ lowercase[`textures/actors/character/hair${idx % 5}/a${idx}.dds`] = "a"; ++ capitalized[`Sound/FX/NPC/Creature${idx % 7}/b${idx}.wav`] = "b"; ++ capitalized[`Textures/Actors/Character/Hair${idx % 5}/b${idx}.dds`] = "b"; ++ } ++ const h = makeDeployment({ method, modName: "Lowercase", files: lowercase }); ++ for (const [relPath, content] of Object.entries(capitalized)) { ++ const source = path.join(h.stagingDir, "Capitalized", relPath); ++ nodeFs.mkdirSync(path.dirname(source), { recursive: true }); ++ nodeFs.writeFileSync(source, content); ++ } ++ const blacklist = new BlacklistSet([], { details: {} } as IGame, h.normalize); ++ ++ await h.method.prepare(h.gameDir, false, [], h.normalize); ++ for (const modName of ["Lowercase", "Capitalized"]) { ++ await h.method.activate(path.join(h.stagingDir, modName), modName, "", blacklist); ++ } ++ await h.method.finalize("skyrimse", h.gameDir, h.stagingDir); ++ ++ const deployed = nodeFs.readdirSync(h.gameDir, { recursive: true, withFileTypes: true }); ++ const relative = (entry: nodeFs.Dirent) => ++ path.relative(h.gameDir, path.join(entry.parentPath, entry.name)).toLowerCase(); ++ expect( ++ deployed ++ .filter((entry) => entry.isDirectory()) ++ .map(relative) ++ .sort(), ++ ).toEqual( ++ [ ++ "sound", ++ "sound/fx", ++ "sound/fx/npc", ++ ...[0, 1, 2, 3, 4, 5, 6].map((idx) => `sound/fx/npc/creature${idx}`), ++ "textures", ++ "textures/actors", ++ "textures/actors/character", ++ ...[0, 1, 2, 3, 4].map((idx) => `textures/actors/character/hair${idx}`), ++ ].sort(), ++ ); ++ expect( ++ deployed ++ .filter((entry) => !entry.isDirectory() && !entry.name.startsWith(".")) ++ .map(relative) ++ .sort(), ++ ).toEqual( ++ [...Object.keys(lowercase), ...Object.keys(capitalized)] ++ .map((relPath) => relPath.toLowerCase()) ++ .sort(), ++ ); ++ }, ++ ); ++} +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 +@@ -84,6 +84,7 @@ abstract class LinkingActivator implements IDeploymentMethod { + private mQueue: Promise = Promise.resolve(); + private mContext: IDeploymentContext; + private mDirCache: Set; ++ private mDirQueue: Promise = Promise.resolve(); + + constructor( + id: string, +@@ -676,16 +677,23 @@ abstract class LinkingActivator implements IDeploymentMethod { + } + }; + +- return Promise.resolve( +- this.mDirCache === undefined || !this.mDirCache.has(dirPath) +- ? fs.ensureDirAsync(dirPath, onDirCreated).then(() => { +- if (this.mDirCache === undefined) { +- this.mDirCache = new Set(); +- } +- this.mDirCache.add(dirPath); +- }) +- : Promise.resolve(), +- ).then(() => didCreate); ++ const key = this.mNormalize !== undefined ? this.mNormalize(dirPath) : dirPath; ++ if (this.mDirCache?.has(key)) { ++ return Promise.resolve(false); ++ } ++ // Create one directory at a time. ensureDirAsync reuses the casing of a directory that ++ // exists, but links deployed in parallel to new "textures" and "Textures" folders would ++ // each find nothing and create both, and a Windows game sees only one of them. ++ const created = this.mDirQueue ++ .then(() => fs.ensureDirAsync(dirPath, onDirCreated)) ++ .then(() => { ++ if (this.mDirCache === undefined) { ++ this.mDirCache = new Set(); ++ } ++ this.mDirCache.add(key); ++ }); ++ this.mDirQueue = created.catch(() => undefined); ++ return created.then(() => didCreate); + } + + private deduplicate(input: IFileChange[]): IFileChange[] { diff --git a/recipes/2.6.2/patches/0038-linux-default-wine-prefix.patch b/recipes/2.6.2/patches/0038-linux-default-wine-prefix.patch new file mode 100644 index 0000000..5a53498 --- /dev/null +++ b/recipes/2.6.2/patches/0038-linux-default-wine-prefix.patch @@ -0,0 +1,103 @@ +diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util/compatibilityPaths.ts +--- a/src/renderer/src/util/compatibilityPaths.ts ++++ b/src/renderer/src/util/compatibilityPaths.ts +@@ -85,9 +85,21 @@ function resolveGamePrefix(game: CompatibilityGame): string | null | undefined { + return installation.prefix; + } + } ++ if (!game.store) return defaultWinePrefix(); + return undefined; + } + ++/** ++ * A game added by hand has no store to ask, so it lives where plain Wine runs ++ * everything: $WINEPREFIX, or ~/.wine when that isn't set. ++ */ ++function defaultWinePrefix(): string | undefined { ++ const prefix = path.resolve( ++ expandHome(process.env.WINEPREFIX || path.join(os.homedir(), ".wine")), ++ ); ++ return fs.existsSync(path.join(prefix, "drive_c")) ? prefix : undefined; ++} ++ + interface IUserPathMapping { + linuxRoot: string; + windowsRoot: string[]; +@@ -329,7 +341,7 @@ export function resolveExtensionUserPathSync(input: string): string { + function unavailablePrefix(): Error { + return Object.assign( + new Error( +- "The selected game's Wine/Proton user profile is unavailable. Launch the game once, or configure its WINEPREFIX.", ++ "The selected game's Wine/Proton user profile is unavailable. Launch the game once, or edit the game on the Dashboard and set WINEPREFIX under Environment Variables.", + ), + { code: "ENOENT" }, + ); +diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer/src/util/compatibilitySelection.test.ts +--- a/src/renderer/src/util/compatibilitySelection.test.ts ++++ b/src/renderer/src/util/compatibilitySelection.test.ts +@@ -172,6 +172,30 @@ describe.runIf(process.platform === "linux")("selected compatibility prefix", () + expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe(expected); + }); + ++ it("uses Wine's default prefix for a game added by hand", () => { ++ vi.stubEnv("WINEPREFIX", ""); ++ const wine = path.join(home, ".wine"); ++ const user = path.join(wine, "drive_c/users", os.userInfo().username); ++ fs.mkdirSync(path.join(user, "Documents"), { recursive: true }); ++ const game = { path: path.join(home, "Games/Example"), executable: "Example.exe" }; ++ setCompatibilityGameProvider(() => game); ++ expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe( ++ path.join(user, "Documents", relative), ++ ); ++ ++ const exported = path.join(home, "exported-prefix"); ++ fs.mkdirSync(path.join(exported, "drive_c/users/steamuser/Documents"), { recursive: true }); ++ vi.stubEnv("WINEPREFIX", exported); ++ resetCompatibilityPathCache(); ++ expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe( ++ path.join(exported, "drive_c/users/steamuser/Documents", relative), ++ ); ++ ++ // A store game that wasn't launched yet keeps asking for its own prefix. ++ setCompatibilityGameProvider(() => ({ ...game, store: "steam" })); ++ expect(() => resolveWindowsUserPathSync(path.join(documents, relative))).toThrow(/unavailable/); ++ }); ++ + it("keeps native game paths and Vortex state outside Proton", () => { + const gamePath = path.join(home, "native"); + fs.mkdirSync(gamePath); +diff --git a/src/renderer/src/util/linux/gameTools.test.ts b/src/renderer/src/util/linux/gameTools.test.ts +--- a/src/renderer/src/util/linux/gameTools.test.ts ++++ b/src/renderer/src/util/linux/gameTools.test.ts +@@ -171,6 +171,19 @@ describe.runIf(process.platform === "linux")("game tool runner", () => { + resolveGameTool("Tool.exe", [], { env: { WINEPREFIX: path.join(root, "missing") } }), + ).rejects.toThrow(/prefix is unavailable/); + }); ++ it("runs a game added by hand in Wine's default prefix", async () => { ++ vi.stubEnv("WINEPREFIX", ""); ++ const manual = { path: path.join(root, "Games/Example"), executable: "Example.exe" }; ++ const game = path.join(manual.path, manual.executable); ++ write(game, "MZ"); ++ await expect(resolveGameTool(game, [], {}, manual)).rejects.toThrow(/Environment Variables/); ++ fs.mkdirSync(path.join(root, ".wine/drive_c"), { recursive: true }); ++ resetCompatibilityPathCache(); ++ const command = await resolveGameTool(game, [], {}, manual); ++ expect(command.executable).toBe("wine"); ++ expect(command.args).toEqual([game]); ++ expect(command.options.env.WINEPREFIX).toBe(path.join(root, ".wine")); ++ }); + it("does not launch a Steam tool in a different Wine prefix", async () => { + proton("Proton 10.0"); + const other = path.join(root, "other"); +diff --git a/src/renderer/src/util/linux/gameTools.ts b/src/renderer/src/util/linux/gameTools.ts +--- a/src/renderer/src/util/linux/gameTools.ts ++++ b/src/renderer/src/util/linux/gameTools.ts +@@ -47,7 +47,7 @@ export async function resolveGameTool( + const prefix = options.env?.WINEPREFIX ?? selectedGamePrefix(game); + if (!prefix || !fs.existsSync(path.join(prefix, "drive_c"))) { + throw new Error( +- "The game's Wine prefix is unavailable. Launch the game once or configure WINEPREFIX for this tool.", ++ "The game's Wine prefix is unavailable. Launch the game once, or edit the game or tool on the Dashboard and set WINEPREFIX under Environment Variables.", + ); + } + if (game?.store === "steam") { diff --git a/scripts/build-version.py b/scripts/build-version.py index 3635c30..1f0985d 100755 --- a/scripts/build-version.py +++ b/scripts/build-version.py @@ -140,6 +140,18 @@ def validate_recipe(commit, contents, version, build_format, recipe_path="."): expected_source = f"vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit={upstream}" if expected_source not in sources: raise BuildError("The selected .SRCINFO does not match the pinned upstream commit") + # makepkg pairs sha512sums with sources by position and only complains after + # downloading everything. A patch added to one array but not the other, or a + # SKIP slipped in above the pnpm checksum, shows up here instead. + checksums = re.findall(r"^\s*sha512sums = (.+)$", srcinfo, re.MULTILINE) + if len(checksums) != len(sources): + raise BuildError( + f"The selected .SRCINFO lists {len(sources)} sources but {len(checksums)} sha512sums" + ) + for source, checksum in zip(sources, checksums): + downloaded = "::http" in source or source.startswith("http") + if downloaded and checksum == "SKIP": + raise BuildError(f"The selected .SRCINFO has no checksum for a downloaded source: {source}") required = ["scripts/build-arch-package.sh"] if build_format != "arch": diff --git a/tests/test_build_version.py b/tests/test_build_version.py index 9f43f61..91a7cce 100644 --- a/tests/test_build_version.py +++ b/tests/test_build_version.py @@ -103,6 +103,7 @@ def recipe(self, version, revision, recipe_path="."): f"\tpkgver = {version}\n" f"\tpkgrel = {revision}\n" f"\tsource = vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit={UPSTREAM_COMMIT}\n" + "\tsha512sums = SKIP\n" "pkgname = vortex-linux\n" )) @@ -279,6 +280,31 @@ def test_maintained_recipe_srcinfo_is_validated_inside_its_directory(self): self.assertIn("SRCINFO", result.stderr) self.assertFalse(self.output("1.16.9").exists()) + def test_recipe_checksums_must_pair_with_sources(self): + recipe_path = self.maintained_recipe() + srcinfo = self.repository / recipe_path / ".SRCINFO" + srcinfo.write_text(srcinfo.read_text().replace( + "pkgname = vortex-linux\n", "\tsource = fix.patch\npkgname = vortex-linux\n")) + self.commit("Patch without a checksum") + + result = self.run_builder("--version", "1.16.9", "--prepare-only", success=False) + + self.assertIn("sha512sums", result.stderr) + self.assertFalse(self.output("1.16.9").exists()) + + def test_downloaded_sources_need_a_checksum(self): + recipe_path = self.maintained_recipe() + srcinfo = self.repository / recipe_path / ".SRCINFO" + srcinfo.write_text(srcinfo.read_text().replace( + "pkgname = vortex-linux\n", + "\tsource = pnpm.tgz::https://registry.npmjs.org/pnpm/-/pnpm-11.10.0.tgz\n" + "\tsha512sums = SKIP\npkgname = vortex-linux\n")) + self.commit("Download without a checksum") + + result = self.run_builder("--version", "1.16.9", "--prepare-only", success=False) + + self.assertIn("no checksum for a downloaded source", result.stderr) + def test_maintained_recipe_cannot_borrow_missing_scripts_from_root(self): recipe_path = self.maintained_recipe() (self.repository / recipe_path / "scripts/build-appimage.sh").unlink()