diff --git a/.SRCINFO b/.SRCINFO index 70fadd2..0a91e7a 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -91,6 +91,7 @@ pkgbase = vortex-linux 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 = vortex.sh source = vortex.desktop source = README.md @@ -139,6 +140,7 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/PKGBUILD b/PKGBUILD index d5d5c2f..52afe65 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -136,6 +136,7 @@ _patches=( '0030-linux-extension-config-paths.patch' '0031-linux-deployment-preflight.patch' '0032-linux-deployment-target-paths.patch' + '0033-linux-symlinked-extension-paths.patch' ) source=( @@ -206,6 +207,7 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/patches/0033-linux-symlinked-extension-paths.patch b/patches/0033-linux-symlinked-extension-paths.patch new file mode 100644 index 0000000..e897e6b --- /dev/null +++ b/patches/0033-linux-symlinked-extension-paths.patch @@ -0,0 +1,250 @@ +diff --git a/src/renderer/src/renderer.tsx b/src/renderer/src/renderer.tsx +index d34c188..f802f85 100644 +--- a/src/renderer/src/renderer.tsx ++++ b/src/renderer/src/renderer.tsx +@@ -162,6 +162,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"; +@@ -385,12 +386,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 +index 0000000..e2ee1c8 +--- /dev/null ++++ b/src/renderer/src/util/extensionPaths.test.ts +@@ -0,0 +1,54 @@ ++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); ++ }); ++ ++ 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("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 +index 0000000..46ef365 +--- /dev/null ++++ b/src/renderer/src/util/extensionPaths.ts +@@ -0,0 +1,45 @@ ++import * as fs from "node:fs"; ++import * as path from "node:path"; ++ ++// 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(); ++ ++function realRoot(root: string): string { ++ let real = realRoots.get(root); ++ if (real === undefined) { ++ try { ++ real = fs.realpathSync(root); ++ } catch { ++ real = 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 = (spelling + path.sep).replace(/[\\]/g, "\\\\"); ++ 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 +index 0d29edb..1cfec04 100644 +--- 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"; + +@@ -60,4 +62,57 @@ 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 +index 1b4d136..ad24e6a 100644 +--- a/src/renderer/src/util/extensionRequire.ts ++++ b/src/renderer/src/util/extensionRequire.ts +@@ -11,6 +11,7 @@ import type { IRegisteredExtension } from "../types/extensions"; + import type { IExtensionState } from "../types/IState"; + import { deprecatedApiGet } from "./deprecatedApiUsage"; + import { wrapExtensionFs } from "./extensionFs"; ++import { isInsideExtensionRoot } from "./extensionPaths"; + import type { LogLevel } from "./log"; + import { webpackRequireHack } from "./webpack-hacks"; + +@@ -114,7 +115,7 @@ export function extensionRequire( + 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) { + const state = findInstalled(getInstalled(), { path: ext.path })?.extension; +@@ -129,7 +130,7 @@ export function extensionRequire( + } 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)); +@@ -137,7 +138,9 @@ export function extensionRequire( + 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);