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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .SRCINFO
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
pkgbase = vortex-linux
pkgdesc = Community build of Vortex with generic Linux compatibility patches
pkgver = 2.9.0
pkgrel = 1
pkgrel = 2
url = https://github.com/Arkazzae/VortexLinux
install = vortex.install
arch = x86_64
Expand Down Expand Up @@ -93,6 +93,7 @@ pkgbase = vortex-linux
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 = vortex.sh
source = vortex.desktop
source = README.md
Expand Down Expand Up @@ -144,6 +145,7 @@ pkgbase = vortex-linux
sha512sums = SKIP
sha512sums = SKIP
sha512sums = SKIP
sha512sums = SKIP
sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2
sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632
sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ publishes a new Vortex version, taking any pending notes with it.

## Unreleased

- Mods whose archives use Windows backslashes between folders install
correctly. Vortex can also remove a leftover `.installing` folder, and mods
that earlier releases installed with backslashes in their file names, instead
of reporting them as "open in another application". (#16)
- Folders that mods spell in different case, such as `textures` and
`Textures`, are deployed into one folder. Vortex created both when it linked
their files at the same time, and the game found only one of them. (#20)

## 2.9.0-1 - 2026-10-19

- Vortex 2.9.0.
Expand Down
4 changes: 3 additions & 1 deletion PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

pkgname=vortex-linux
pkgver=2.9.0
pkgrel=1
pkgrel=2
pkgdesc="Community build of Vortex with generic Linux compatibility patches"
arch=('x86_64')
url="https://github.com/Arkazzae/VortexLinux"
Expand Down Expand Up @@ -137,6 +137,7 @@ _patches=(
'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'
)

source=(
Expand Down Expand Up @@ -209,6 +210,7 @@ sha512sums=(
'SKIP'
'SKIP'
'SKIP'
'SKIP'
# Keep integrity checks for binaries downloaded outside this repository.
'935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2'
'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632'
Expand Down
276 changes: 211 additions & 65 deletions patches/0035-linux-backslash-archive-paths.patch
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ diff --git a/src/renderer/src/extensions/mod_management/util/archivePaths.test.t
new file mode 100644
--- /dev/null
+++ b/src/renderer/src/extensions/mod_management/util/archivePaths.test.ts
@@ -0,0 +1,92 @@
@@ -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[] {
Expand All @@ -50,77 +51,104 @@ new file mode 100644
+ return result.sort();
+}
+
+describe.runIf(process.platform === "linux")("archive entries with backslash separators", () => {
+ let root: string;
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-archive-paths-"));
+ });
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
+
+ it("turns the names 7-Zip extracts from a Windows-style archive into folders", async () => {
+ // What 7-Zip on Linux extracts from the Appearance Runtime Plugin archive.
+ fs.mkdirSync(path.join(root, "red4ext\\plugins\\"));
+ fs.writeFileSync(
+ path.join(root, "red4ext\\plugins\\AppearanceRuntimePlugin\\AppearanceRuntimePlugin.dll"),
+ "dll",
+ );
+ fs.mkdirSync(path.join(root, "normal"));
+ fs.writeFileSync(path.join(root, "normal", "readme.txt"), "readme");
+
+ await splitBackslashNames(root);
+
+ expect(tree(root)).toEqual([
+ "normal/",
+ "normal/readme.txt=readme",
+ "red4ext/",
+ "red4ext/plugins/",
+ "red4ext/plugins/AppearanceRuntimePlugin/",
+ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll",
+ ]);
+ });
+for (const patchedPath of [false, true]) {
+ const split = (root: string) =>
+ patchedPath ? withPatchedPath(() => splitBackslashNames(root)) : splitBackslashNames(root);
+
+ it("merges into folders that already exist, also below a renamed folder", async () => {
+ fs.mkdirSync(path.join(root, "Data", "Scripts"), { recursive: true });
+ fs.writeFileSync(path.join(root, "Data", "Scripts", "kept.pex"), "kept");
+ fs.mkdirSync(path.join(root, "Data\\Scripts"));
+ fs.writeFileSync(path.join(root, "Data\\Scripts", "added.pex"), "added");
+ fs.writeFileSync(path.join(root, "Data\\Scripts", "Source\\added.psc"), "source");
+
+ await splitBackslashNames(root);
+
+ expect(tree(root)).toEqual([
+ "Data/",
+ "Data/Scripts/",
+ "Data/Scripts/Source/",
+ "Data/Scripts/Source/added.psc=source",
+ "Data/Scripts/added.pex=added",
+ "Data/Scripts/kept.pex=kept",
+ ]);
+ });
+ 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("keeps entries inside the extraction folder", async () => {
+ fs.writeFileSync(path.join(root, "..\\..\\escape.txt"), "x");
+ fs.writeFileSync(path.join(root, "\\absolute.txt"), "y");
+ it("turns the names 7-Zip extracts from a Windows-style archive into folders", async () => {
+ // What 7-Zip on Linux extracts from the Appearance Runtime Plugin archive.
+ fs.mkdirSync(path.join(root, "red4ext\\plugins\\"));
+ fs.writeFileSync(
+ path.join(root, "red4ext\\plugins\\AppearanceRuntimePlugin\\AppearanceRuntimePlugin.dll"),
+ "dll",
+ );
+ fs.mkdirSync(path.join(root, "normal"));
+ fs.writeFileSync(path.join(root, "normal", "readme.txt"), "readme");
+
+ await splitBackslashNames(root);
+ await split(root);
+
+ expect(tree(root)).toEqual(["absolute.txt=y", "escape.txt=x"]);
+ });
+ expect(tree(root)).toEqual([
+ "normal/",
+ "normal/readme.txt=readme",
+ "red4ext/",
+ "red4ext/plugins/",
+ "red4ext/plugins/AppearanceRuntimePlugin/",
+ "red4ext/plugins/AppearanceRuntimePlugin/AppearanceRuntimePlugin.dll=dll",
+ ]);
+ });
+
+ it("leaves archives without backslashes untouched", async () => {
+ fs.mkdirSync(path.join(root, "Data"));
+ fs.writeFileSync(path.join(root, "Data", "plugin.esp"), "esp");
+ 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 splitBackslashNames(root);
+ await split(root);
+
+ expect(tree(root)).toEqual(["Data/", "Data/plugin.esp=esp"]);
+ });
+});
+ 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,41 @@
@@ -0,0 +1,55 @@
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+
Expand All @@ -133,7 +161,7 @@ new file mode 100644
+export async function splitBackslashNames(root: string): Promise<void> {
+ const visit = async (directory: string): Promise<void> => {
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
+ const source = path.join(directory, entry.name);
+ const source = child(directory, entry.name);
+ if (!entry.name.includes("\\")) {
+ if (entry.isDirectory()) await visit(source);
+ continue;
Expand All @@ -142,23 +170,141 @@ new file mode 100644
+ .split("\\")
+ .filter((part) => part.length > 0 && part !== "." && part !== "..");
+ if (parts.length === 0) continue;
+ const target = path.join(directory, ...parts);
+ 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<void> {
+ const existing = await fs.stat(target).catch(() => undefined);
+ if (isDirectory && existing?.isDirectory()) {
+ for (const child of await fs.readdir(source, { withFileTypes: true })) {
+ await move(path.join(source, child.name), path.join(target, child.name), child.isDirectory());
+ 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<T>(fn: () => PromiseLike<T>): Promise<T> {
+ 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
@@ -933,6 +933,9 @@ function removeInt(
}

function rimrafAsync(remPath: string): PromiseBB<void> {
+ 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
@@ -952,6 +955,26 @@ function rimrafAsync(remPath: string): PromiseBB<void> {
});
}

+/**
+ * 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<void> {
+ 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<string> {
linkPath = resolveCaseInsensitiveSync(linkPath);
Loading
Loading