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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion recipes/1.16.9/.SRCINFO
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion recipes/1.16.9/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -122,6 +125,9 @@ sha512sums=(
'SKIP'
'SKIP'
'SKIP'
'SKIP'
'SKIP'
'SKIP'
# Keep integrity checks for binaries downloaded outside this repository.
'935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2'
'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632'
Expand Down
313 changes: 313 additions & 0 deletions recipes/1.16.9/patches/0012-linux-backslash-archive-paths.patch
Original file line number Diff line number Diff line change
@@ -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<void> {
+ const visit = async (directory: string): Promise<void> => {
+ 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<void> {
+ 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<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/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<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
@@ -1019,6 +1022,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 ((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<string> {
linkPath = resolveCaseInsensitiveSync(linkPath);
const stackErr = new Error();
Loading
Loading