diff --git a/.SRCINFO b/.SRCINFO index be9f633..e4dcdb7 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches - pkgver = 2.9.1 + pkgver = 2.10.0 pkgrel = 1 url = https://github.com/Arkazzae/VortexLinux install = vortex.install @@ -43,7 +43,7 @@ pkgbase = vortex-linux depends = xdg-utils optdepends = kde-cli-tools: native trash support on KDE Plasma optdepends = pipewire: screen sharing under Wayland - provides = vortex=2.9.1 + provides = vortex=2.10.0 conflicts = vortex conflicts = vortex-bin conflicts = vortex-git @@ -56,7 +56,7 @@ pkgbase = vortex-linux noextract = windowsdesktop-runtime-10.0.10-win-x64.zip options = !debug options = !strip - source = vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit=c80326367cbe6d96621ad6cf4a5488ef49bac110 + source = vortex::git+https://github.com/Nexus-Mods/Vortex.git#commit=d6c694982beab5ae53b9f8da13428cdfbc5b63ce source = pnpm-11.10.0.tgz::https://registry.npmjs.org/pnpm/-/pnpm-11.10.0.tgz source = 0001-linux-runtime-compatibility.patch source = 0002-linux-path-and-executable-discovery.patch diff --git a/.github/workflows/build-version.yml b/.github/workflows/build-version.yml index 7f1d727..69d7df3 100644 --- a/.github/workflows/build-version.yml +++ b/.github/workflows/build-version.yml @@ -81,11 +81,13 @@ jobs: env: SOURCE_DIRECTORY: ${{ steps.prepare.outputs.source_directory }} run: | - if [[ -f "$SOURCE_DIRECTORY/scripts/test-appimage.sh" ]]; then + # Older recipes ship test-appimage.sh instead of test-package.sh. + if [[ -f "$SOURCE_DIRECTORY/scripts/test-package.sh" || \ + -f "$SOURCE_DIRECTORY/scripts/test-appimage.sh" ]]; then echo 'supported=true' >> "$GITHUB_OUTPUT" else echo 'supported=false' >> "$GITHUB_OUTPUT" - echo 'AppImage runtime check skipped: the selected historical recipe does not provide scripts/test-appimage.sh.' \ + echo 'AppImage runtime check skipped: the selected historical recipe provides neither scripts/test-package.sh nor scripts/test-appimage.sh.' \ >> "$GITHUB_STEP_SUMMARY" fi @@ -198,5 +200,8 @@ jobs: run: | cd "$ARTIFACT_DIRECTORY" sha256sum --check "$APPIMAGE_ARCHIVE.sha256" - bash "$GITHUB_WORKSPACE/$RECIPE_PATH/scripts/test-appimage.sh" \ - "$ARTIFACT_DIRECTORY/$APPIMAGE_ARCHIVE" + test_script="$GITHUB_WORKSPACE/$RECIPE_PATH/scripts/test-package.sh" + if [[ ! -f "$test_script" ]]; then + test_script="$GITHUB_WORKSPACE/$RECIPE_PATH/scripts/test-appimage.sh" + fi + bash "$test_script" "$ARTIFACT_DIRECTORY/$APPIMAGE_ARCHIVE" diff --git a/.github/workflows/upstream-compatibility.yml b/.github/workflows/upstream-compatibility.yml index ced4f0c..ddc38c8 100644 --- a/.github/workflows/upstream-compatibility.yml +++ b/.github/workflows/upstream-compatibility.yml @@ -180,6 +180,10 @@ jobs: "Vortex-${package_version}-x86_64.AppImage" "Vortex-${package_version}-x86_64.AppImage.sha256" "Vortex-${package_version}-x86_64.AppImage.zsync" + "vortex-linux_${package_version}_amd64.deb" + "vortex-linux_${package_version}_amd64.deb.sha256" + "vortex-linux-${package_version}-x86_64.rpm" + "vortex-linux-${package_version}-x86_64.rpm.sha256" ) release_needed=false for required_asset in "${required_assets[@]}"; do @@ -283,6 +287,10 @@ jobs: appimage_archive: ${{ steps.appimage.outputs.appimage_archive }} appimage_checksum: ${{ steps.appimage.outputs.appimage_checksum }} appimage_zsync: ${{ steps.appimage.outputs.appimage_zsync }} + deb_archive: ${{ steps.distro.outputs.deb_archive }} + deb_checksum: ${{ steps.distro.outputs.deb_checksum }} + rpm_archive: ${{ steps.distro.outputs.rpm_archive }} + rpm_checksum: ${{ steps.distro.outputs.rpm_checksum }} steps: - name: Check out packaging repository uses: actions/checkout@v6 @@ -308,9 +316,11 @@ jobs: fi pacman -Syu --needed --noconfirm "${build_dependencies[@]}" - # build-appimage.sh validates the AppStream metadata when appstreamcli - # is available; the package itself does not need it. - pacman -S --needed --noconfirm appstream + # build-appimage.sh and build-distro-packages.sh validate the + # AppStream metadata when appstreamcli is available, and build the + # Debian and RPM packages with dpkg-deb and rpmbuild. The Arch package + # needs none of them. + pacman -S --needed --noconfirm appstream dpkg rpm-tools useradd --create-home --shell /bin/bash builder chown -R builder:builder "$GITHUB_WORKSPACE" df -h "$GITHUB_WORKSPACE" @@ -335,6 +345,16 @@ jobs: scripts/build-appimage.sh \ "dist/${{ steps.package.outputs.package_archive }}" + - name: Build Debian and RPM packages + id: distro + run: | + chown builder:builder "$GITHUB_OUTPUT" "$GITHUB_STEP_SUMMARY" + runuser --user builder -- env \ + HOME=/home/builder \ + XDG_CACHE_HOME=/home/builder/.cache \ + scripts/build-distro-packages.sh \ + "dist/${{ steps.package.outputs.package_archive }}" + - name: Upload release artifacts uses: actions/upload-artifact@v4 with: @@ -345,16 +365,35 @@ jobs: dist/${{ steps.appimage.outputs.appimage_archive }} dist/${{ steps.appimage.outputs.appimage_checksum }} dist/${{ steps.appimage.outputs.appimage_zsync }} + dist/${{ steps.distro.outputs.deb_archive }} + dist/${{ steps.distro.outputs.deb_checksum }} + dist/${{ steps.distro.outputs.rpm_archive }} + dist/${{ steps.distro.outputs.rpm_checksum }} if-no-files-found: error compression-level: 0 retention-days: 30 - test-appimage: - name: Test AppImage on clean Ubuntu + test-packages: + name: Test ${{ matrix.kind }} on ${{ matrix.image }} needs: build-package if: ${{ needs.build-package.result == 'success' }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { kind: appimage, image: "ubuntu:24.04" } + - { kind: deb, image: "ubuntu:24.04" } + - { kind: deb, image: "ubuntu:26.04" } + - { kind: deb, image: "debian:13" } + - { kind: rpm, image: "fedora:43" } + - { kind: rpm, image: "fedora:44" } + - { kind: rpm, image: "opensuse/leap:16.0" } + - { kind: rpm, image: "opensuse/tumbleweed" } + env: + ARTIFACT: ${{ needs.build-package.outputs[format('{0}_archive', matrix.kind)] }} + CHECKSUM: ${{ needs.build-package.outputs[format('{0}_checksum', matrix.kind)] }} steps: - name: Check out packaging repository uses: actions/checkout@v6 @@ -365,13 +404,13 @@ jobs: name: vortex-linux-${{ needs.build-package.outputs.package_version }} path: dist - - name: Verify and start AppImage in a clean container + - name: Verify and test the artifact in a clean container run: | ( cd dist || exit 1 - sha256sum --check "${{ needs.build-package.outputs.appimage_checksum }}" + sha256sum --check "$CHECKSUM" ) - scripts/test-appimage.sh "dist/${{ needs.build-package.outputs.appimage_archive }}" + scripts/test-package.sh "dist/$ARTIFACT" "${{ matrix.image }}" finalize-source: name: Record tested upstream release @@ -379,10 +418,10 @@ jobs: - compatibility - prepare-release - build-package - - test-appimage + - test-packages if: >- ${{ - needs.test-appimage.result == 'success' && + needs.test-packages.result == 'success' && github.ref == 'refs/heads/master' && needs.prepare-release.outputs.release_needed == 'true' }} @@ -460,7 +499,7 @@ jobs: - compatibility - prepare-release - build-package - - test-appimage + - test-packages - finalize-source if: >- ${{ @@ -479,6 +518,10 @@ jobs: APPIMAGE_ARCHIVE: ${{ needs.build-package.outputs.appimage_archive }} APPIMAGE_CHECKSUM: ${{ needs.build-package.outputs.appimage_checksum }} APPIMAGE_ZSYNC: ${{ needs.build-package.outputs.appimage_zsync }} + DEB_ARCHIVE: ${{ needs.build-package.outputs.deb_archive }} + DEB_CHECKSUM: ${{ needs.build-package.outputs.deb_checksum }} + RPM_ARCHIVE: ${{ needs.build-package.outputs.rpm_archive }} + RPM_CHECKSUM: ${{ needs.build-package.outputs.rpm_checksum }} UPSTREAM_COMMIT: ${{ needs.compatibility.outputs.upstream_commit }} SOURCE_COMMIT: ${{ needs.finalize-source.outputs.source_commit }} steps: @@ -490,7 +533,7 @@ jobs: - name: Verify downloaded artifacts working-directory: dist - run: sha256sum --check "$CHECKSUM_FILE" "$APPIMAGE_CHECKSUM" + run: sha256sum --check -- *.sha256 - name: Download release notes uses: actions/download-artifact@v5 @@ -523,6 +566,27 @@ jobs: sudo pacman -U ./${PACKAGE_ARCHIVE} \`\`\` + ## Debian and Ubuntu + + For Ubuntu 24.04 or newer and Debian 13 or newer: + + \`\`\`sh + sha256sum --check ${DEB_CHECKSUM} + sudo apt install ./${DEB_ARCHIVE} + \`\`\` + + ## Fedora and openSUSE + + For Fedora 43 or newer, openSUSE Leap 16 and Tumbleweed: + + \`\`\`sh + sha256sum --check ${RPM_CHECKSUM} + sudo dnf install ./${RPM_ARCHIVE} + \`\`\` + + The package is not signed, so on openSUSE install it with + \`sudo zypper install --allow-unsigned-rpm ./${RPM_ARCHIVE}\`. + ## Other x86_64 Linux distributions Install Wine with your distribution's package manager, then: @@ -560,13 +624,10 @@ jobs: exit 1 fi + # The downloaded artifact holds exactly the release files. + assets=(dist/*) if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" \ - "dist/$PACKAGE_ARCHIVE" \ - "dist/$CHECKSUM_FILE" \ - "dist/$APPIMAGE_ARCHIVE" \ - "dist/$APPIMAGE_CHECKSUM" \ - "dist/$APPIMAGE_ZSYNC" \ + gh release upload "$RELEASE_TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --clobber gh release edit "$RELEASE_TAG" \ @@ -575,23 +636,14 @@ jobs: --notes-file release-notes.md \ --latest elif [[ -n "$target_tag_sha" ]]; then - gh release create "$RELEASE_TAG" \ - "dist/$PACKAGE_ARCHIVE" \ - "dist/$CHECKSUM_FILE" \ - "dist/$APPIMAGE_ARCHIVE" \ - "dist/$APPIMAGE_CHECKSUM" \ + gh release create "$RELEASE_TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "Vortex for Linux ${PACKAGE_REVISION}" \ --notes-file release-notes.md \ --latest else - gh release create "$RELEASE_TAG" \ - "dist/$PACKAGE_ARCHIVE" \ - "dist/$CHECKSUM_FILE" \ - "dist/$APPIMAGE_ARCHIVE" \ - "dist/$APPIMAGE_CHECKSUM" \ - "dist/$APPIMAGE_ZSYNC" \ + gh release create "$RELEASE_TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --target "$SOURCE_COMMIT" \ --title "Vortex for Linux ${PACKAGE_REVISION}" \ @@ -613,7 +665,7 @@ jobs: - compatibility - prepare-release - build-package - - test-appimage + - test-packages - finalize-source - publish-package if: ${{ always() && github.event_name == 'schedule' }} @@ -632,7 +684,7 @@ jobs: COMPATIBILITY_STATUS: ${{ needs.compatibility.result }} PREPARE_STATUS: ${{ needs.prepare-release.result }} BUILD_STATUS: ${{ needs.build-package.result }} - APPIMAGE_STATUS: ${{ needs.test-appimage.result }} + TEST_STATUS: ${{ needs.test-packages.result }} FINALIZE_STATUS: ${{ needs.finalize-source.result }} PUBLISH_STATUS: ${{ needs.publish-package.result }} RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -650,7 +702,7 @@ jobs: "$COMPATIBILITY_STATUS" "$PREPARE_STATUS" "$BUILD_STATUS" - "$APPIMAGE_STATUS" + "$TEST_STATUS" "$FINALIZE_STATUS" "$PUBLISH_STATUS" ) @@ -666,7 +718,7 @@ jobs: true) required_statuses=( "$BUILD_STATUS" - "$APPIMAGE_STATUS" + "$TEST_STATUS" "$FINALIZE_STATUS" "$PUBLISH_STATUS" ) @@ -697,7 +749,7 @@ jobs: "- Patch tests: \`${COMPATIBILITY_STATUS}\`" \ "- Metadata: \`${PREPARE_STATUS}\`" \ "- Package build: \`${BUILD_STATUS}\`" \ - "- AppImage smoke test: \`${APPIMAGE_STATUS}\`" \ + "- Package smoke tests: \`${TEST_STATUS}\`" \ "- Source update: \`${FINALIZE_STATUS}\`" \ "- Release publish: \`${PUBLISH_STATUS}\`" \ "- Workflow run: ${RUN_URL}" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f52f3c..c39644c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- Vortex 2.10.0. Some of our Linux fixes are part of Vortex itself now + (finding the drive a folder is on, suggesting a staging folder on a mounted + drive), so the patches that carried them got smaller. +- Choosing "import" for a file changed outside Vortex no longer leaves a + `.vortex-import` folder in the staging folder, which Vortex then offered to + add as a mod. +- Windows .NET programs started through Wine no longer inherit the location of + the Linux .NET runtime that the AppImage and the packages bring, which could + keep them from finding their own runtime. +- Game extensions that use Node's `fs` directly now find files in Documents + and `%APPDATA%` where Vortex's own file functions put them: in the game's + Proton prefix when the prefix has that folder. The Witcher 3 could not save + its load order (`mods.settings`) because of this. +- Native packages for Debian, Ubuntu, Fedora and openSUSE: releases now + include `vortex-linux__amd64.deb` for Ubuntu 24.04 or newer and + Debian 13 or newer, and `vortex-linux--x86_64.rpm` for Fedora 43 or + newer, openSUSE Leap 16 and Tumbleweed. apt, dnf or zypper install Vortex with + its dependencies and an app menu entry. Like the AppImage, the packages bring + their own .NET runtimes. + ## 2.9.1-1 - 2026-10-27 Automatic update to Vortex 2.9.1, published after the usual checks passed. Upstream commit `c80326367cbe6d96621ad6cf4a5488ef49bac110`. The Linux patches and packaging are unchanged since 2.9.0-5. diff --git a/PKGBUILD b/PKGBUILD index aedd868..ec5a010 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Arkazzae pkgname=vortex-linux -pkgver=2.9.1 +pkgver=2.10.0 pkgrel=1 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') @@ -96,7 +96,7 @@ conflicts=('vortex' 'vortex-bin' 'vortex-git') options=('!debug' '!strip') install=vortex.install -_upstream_commit='c80326367cbe6d96621ad6cf4a5488ef49bac110' +_upstream_commit='d6c694982beab5ae53b9f8da13428cdfbc5b63ce' _pnpm='11.10.0' _dotnet6='6.0.36' _dotnet8='8.0.29' diff --git a/README.md b/README.md index 88ae53d..014a06b 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,9 @@ Vortex itself is slowly becoming more Linux-friendly. Since 2.7.1, the extension 2.9.0 goes further: Vortex now builds LOOT on Linux itself, with a libloot made for Linux, so we no longer compile libloot from source and our LOOT patch shrank to fixing file name casing and the Proton folder 🎉. Reading game versions from `.exe` files is plain TypeScript now too, so it works the same on every platform. -Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. - -## Games tested (confirmed working) +2.10.0 brings three Linux fixes of its own 🎉: Vortex finds the drive a folder sits on, so moving the staging folder and the folder it suggests work across mounted drives ([#24375](https://github.com/Nexus-Mods/Vortex/pull/24375)), it accepts installer instructions that separate folders with backslashes ([#24378](https://github.com/Nexus-Mods/Vortex/pull/24378)), and the "Handle Mod Manager Download buttons" setting (`nxm://` links) is no longer greyed out on Linux ([#24387](https://github.com/Nexus-Mods/Vortex/pull/24387)). The first one made two of our patches smaller. -- Starfield -- Skyrim Special Edition -- Abiotic Factor -- Zero Sievert -- Baldur's Gate 3 -- Cyberpunk 2077 +Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. ## Install @@ -62,6 +55,31 @@ sha256sum --check vortex-linux-*.pkg.tar.zst.sha256 sudo pacman -U ./vortex-linux-*.pkg.tar.zst ``` +### Debian and Ubuntu + +For Ubuntu 24.04 or newer and Debian 13 or newer: + +```sh +sha256sum --check vortex-linux_*_amd64.deb.sha256 +sudo apt install ./vortex-linux_*_amd64.deb +``` + +apt pulls in Wine and the libraries Vortex needs, and Vortex shows up in your app menu. Like the AppImage, the package brings its own Linux .NET runtime and the Windows .NET runtimes modding tools need. To update, install the newer `.deb` the same way. + +Debian 12 and Ubuntu 22.04 are too old: the FOMOD installer Vortex ships needs glibc 2.38, so apt refuses the package there. The AppImage needs the same. + +### Fedora and openSUSE + +For Fedora 43 or newer, openSUSE Leap 16 and Tumbleweed: + +```sh +sha256sum --check vortex-linux-*-x86_64.rpm.sha256 +sudo dnf install ./vortex-linux-*-x86_64.rpm # Fedora +sudo zypper install --allow-unsigned-rpm ./vortex-linux-*-x86_64.rpm # openSUSE +``` + +The package works like the Debian one: the package manager pulls in Wine and the libraries, and the .NET runtimes come with it. It isn't signed, which is why zypper needs `--allow-unsigned-rpm`; the checksum is how you verify the download. + ### Everything else (AppImage) Install Wine from your package manager first, then: @@ -165,8 +183,8 @@ Every change goes through the same pipeline before it can become a release: 1. **The patches apply.** CI checks out the pinned Vortex commit and applies every patch in order. 2. **The tests pass.** It runs the tests that ship with the patches, typechecks the renderer, and builds and tests the native LOOT binding. -3. **Both packages build.** The Arch package is built in a clean Arch container, and the AppImage is built from that package. -4. **The AppImage starts.** In a clean Ubuntu 24.04 container with Wine but no system .NET, it runs the bundled checks (the .NET probe, the Windows .NET runtimes through Wine, a FOMOD install, LOOT sorting, INI writes, prefix selection, Heroic discovery, tool arguments, hardlink purge), then boots Vortex under Xvfb and waits for the main page to render. +3. **Every package builds.** The Arch package is built in a clean Arch container, and the AppImage, `.deb` and `.rpm` are built from that package. +4. **Every package starts.** The AppImage in a clean Ubuntu 24.04 container, the `.deb` on Ubuntu 24.04, Ubuntu 26.04 and Debian 13, the `.rpm` on Fedora 43 and 44, openSUSE Leap 16 and Tumbleweed. Each container has no system .NET; the packages are installed with only their own dependencies, and their libraries are checked against the distribution. Then the bundled checks run (the .NET probe, the Windows .NET runtimes through Wine, a FOMOD install, LOOT sorting, INI writes, prefix selection, Heroic discovery, tool arguments, hardlink purge), Vortex boots under Xvfb, the main page has to render and Vortex has to find .NET from inside the app. The packages must also uninstall cleanly. Only when all of that passes does a push to `master` publish a release, and only if that package revision has no complete release yet. The release notes come from [CHANGELOG.md](CHANGELOG.md): describe changes under *Unreleased*, and when `pkgrel` is bumped the release job files them under that revision and commits the result. @@ -177,7 +195,8 @@ What this does not cover: launching a real game, Steam login, Flatpak permission ### Run it yourself ```sh -scripts/test-appimage.sh dist/Vortex-*.AppImage # the AppImage check +scripts/test-package.sh dist/Vortex-*.AppImage # the AppImage check +scripts/test-package.sh dist/vortex-linux-*.rpm fedora:44 # a package on one distribution scripts/check-upstream-compatibility.sh --ref latest --keep # patches against the newest Vortex scripts/run-upstream-tests.sh /path/to/patched-vortex # the test suite ``` diff --git a/appimage/com.nexusmods.vortex.metainfo.xml b/appimage/com.nexusmods.vortex.metainfo.xml index 93c6ca4..070fdfc 100644 --- a/appimage/com.nexusmods.vortex.metainfo.xml +++ b/appimage/com.nexusmods.vortex.metainfo.xml @@ -1,7 +1,8 @@ com.nexusmods.vortex diff --git a/patches/0002-linux-path-and-executable-discovery.patch b/patches/0002-linux-path-and-executable-discovery.patch index 92869bd..4512380 100644 --- a/patches/0002-linux-path-and-executable-discovery.patch +++ b/patches/0002-linux-path-and-executable-discovery.patch @@ -44,7 +44,7 @@ index cec6708..cb110fa 100644 return t(""); } diff --git a/src/renderer/src/extensions/gamemode_management/index.ts b/src/renderer/src/extensions/gamemode_management/index.ts -index 06e95bf..df00918 100644 +index 23d5196..9c56d72 100644 --- a/src/renderer/src/extensions/gamemode_management/index.ts +++ b/src/renderer/src/extensions/gamemode_management/index.ts @@ -30,6 +30,7 @@ import GameStoreHelperInstance from "../../util/GameStoreHelper"; @@ -55,7 +55,7 @@ index 06e95bf..df00918 100644 import opn from "../../util/opn"; import { activeGameId, activeProfile } from "../../util/selectors"; import { getSafe } from "../../util/storeHelper"; -@@ -187,7 +188,7 @@ function refreshGameInfo(store: Redux.Store, gameId: string): PromiseBB< +@@ -192,7 +193,7 @@ function refreshGameInfo(store: Redux.Store, gameId: string): PromiseBB< function verifyGamePath(game: IGame, gamePath: string): PromiseBB { return PromiseBB.map(game.requiredFiles || [], (file) => @@ -64,7 +64,7 @@ index 06e95bf..df00918 100644 ) .then(() => undefined) .catch((err) => { -@@ -330,7 +331,7 @@ async function browseGameLocation(api: IExtensionApi, gameId: string): Promise @@ -83,30 +83,17 @@ index 06e95bf..df00918 100644 .then(() => undefined) .catch((err) => { diff --git a/src/renderer/src/extensions/gamemode_management/util/discovery.ts b/src/renderer/src/extensions/gamemode_management/util/discovery.ts -index 90f5c70..4b193a7 100644 +index 898bae7..788f6a9 100644 --- a/src/renderer/src/extensions/gamemode_management/util/discovery.ts +++ b/src/renderer/src/extensions/gamemode_management/util/discovery.ts -@@ -5,7 +5,6 @@ import { getErrorCode, getErrorMessageOrDefault, unknownToError } from "@vortex/ - import Bluebird from "bluebird"; - import * as fsExtra from "fs-extra"; - import turbowalk from "turbowalk"; --import * as winapi from "winapi-bindings"; - - import { log } from "@/logging"; - -@@ -21,10 +20,12 @@ import * as fs from "../../../util/fs"; - import type { Normalize } from "../../../util/getNormalizeFunc"; +@@ -21,6 +21,7 @@ import type { Normalize } from "../../../util/getNormalizeFunc"; import getNormalizeFunc from "../../../util/getNormalizeFunc"; + import { getVolumePath } from "../../../util/getVolumePath"; import getVortexPath from "../../../util/getVortexPath"; +import { resolveNativeExecutable, resolvePlatformPath } from "../../../util/nativeExecutable"; import StarterInfo from "../../../util/StarterInfo"; import { getSafe } from "../../../util/storeHelper"; import * as storeLookup from "../../../util/storeLookup"; - import { truthy } from "../../../util/util"; -+import { getVolumePath } from "../../../util/volume"; - import { modPathsForGame } from "../../mod_management/selectors"; - import type { IDiscoveryResult } from "../types/IDiscoveryResult"; - import type { IToolStored } from "../types/IToolStored"; @@ -72,7 +73,7 @@ export async function quickDiscoveryTools( onDiscoveredTool(gameId, { @@ -116,7 +103,7 @@ index 90f5c70..4b193a7 100644 hidden: false, parameters: tool.parameters || [], custom: false, -@@ -222,7 +223,7 @@ async function handleDiscoveredGame( +@@ -221,7 +222,7 @@ async function handleDiscoveredGame( if (!resolvedPath) return undefined; log("info", "found game", { name: game.name, location: resolvedPath, store }); @@ -125,7 +112,7 @@ index 90f5c70..4b193a7 100644 const discovery: IDiscoveryResult = { path: resolvedPath, executable: exe !== game.executable() ? exe : undefined, -@@ -423,7 +424,7 @@ function verifyToolDir(tool: ITool, testPath: string): Bluebird { +@@ -422,7 +423,7 @@ function verifyToolDir(tool: ITool, testPath: string): Bluebird { // is not something we want at this point because we don't even know yet if the user // wants to manage the game at all. (fileName: string) => @@ -134,7 +121,7 @@ index 90f5c70..4b193a7 100644 return Bluebird.reject(err); }), ).then(() => undefined); -@@ -532,7 +533,7 @@ function testApplicationDirValid( +@@ -531,7 +532,7 @@ function testApplicationDirValid( .then(() => { const game = application as IGame; if (game.queryModPath !== undefined) { @@ -143,7 +130,7 @@ index 90f5c70..4b193a7 100644 const disco: IDiscoveryResult = { path: testPath, executable: exe !== game.executable() ? exe : undefined, -@@ -541,7 +542,7 @@ function testApplicationDirValid( +@@ -540,7 +541,7 @@ function testApplicationDirValid( return discoverRelativeTools(game, testPath, discoveredGames, onDiscoveredTool, normalize); } else { @@ -152,23 +139,8 @@ index 90f5c70..4b193a7 100644 return autoGenIcon(application, exePath, gameId).then(() => { onDiscoveredTool(gameId, { ...application, -@@ -729,12 +730,12 @@ export async function suggestStagingPath(api: IExtensionApi, gameId: string): Pr - - let suggestion: string; - -- if (statModPath.dev === statUserData.dev || process.platform !== "win32") { -+ if (statModPath.dev === statUserData.dev) { - // main mod folder is on same drive as userdata, use a subdirectory below that - suggestion = path.join("{USERDATA}", "{game}", "mods"); - } else { - // different drives, suggest path on same drive -- const volume = winapi.GetVolumePathName(modPaths[""]); -+ const volume = getVolumePath(modPaths[""]); - suggestion = path.join(volume, state.settings.mods.suggestInstallPathDirectory, "{game}"); - } - diff --git a/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts b/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts -index 8c6d5a8..eb8c9f0 100644 +index 0df35a7..d797474 100644 --- a/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts +++ b/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts @@ -1,5 +1,4 @@ @@ -186,20 +158,20 @@ index 8c6d5a8..eb8c9f0 100644 import type { IGame } from "../../../types/IGame"; import type { IDiscoveryResult } from "../types/IDiscoveryResult"; -@@ -17,7 +18,7 @@ async function fromExtension(game: IGame, discovery: IDiscoveryResult): Promise< - } +@@ -42,7 +43,7 @@ async function fromExtension(game: IGame, discovery: IDiscoveryResult): Promise< - async function fromExecutable(game: IGame, discovery: IDiscoveryResult): Promise { + /** The version attribute of the discovered executable; null when it can't be read. */ + async function fromExecutable(game: IGame, discovery: IDiscoveryResult): Promise { - const exePath = path.join(discovery.path, discovery.executable || game.executable()); + const exePath = resolvePlatformPath(discovery.path, discovery.executable || game.executable()); try { await stat(exePath); diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts -index 3e13fbc..976df7b 100644 +index de33785..0147b0d 100644 --- a/src/renderer/src/util/StarterInfo.ts +++ b/src/renderer/src/util/StarterInfo.ts -@@ -26,6 +26,7 @@ import { +@@ -25,6 +25,7 @@ import { import { emitGameLaunched, recordLaunchExit } from "./gameLaunchAnalytics"; import getVortexPath from "./getVortexPath"; import { isWindowsExecutable } from "./linux/proton"; @@ -207,7 +179,7 @@ index 3e13fbc..976df7b 100644 import type { Steam, ISteamEntry } from "./Steam"; import { getSafe } from "./storeHelper"; import * as storeLookup from "./storeLookup"; -@@ -487,7 +488,10 @@ class StarterInfo implements IStarterInfo { +@@ -516,7 +517,10 @@ class StarterInfo implements IStarterInfo { private initFromGame(game: IGameStored, gameDiscovery: IDiscoveryResult) { this.name = gameDiscovery.name || game.name; diff --git a/patches/0003-linux-filesystem-and-deployment.patch b/patches/0003-linux-filesystem-and-deployment.patch index bf69b9d..06eeff8 100644 --- a/patches/0003-linux-filesystem-and-deployment.patch +++ b/patches/0003-linux-filesystem-and-deployment.patch @@ -70,26 +70,18 @@ index 611c1d771..8c338f750 100644 const relPathL = relPath.toLowerCase(); // when getDeployedPath actually renames the file it's possible to get multiple diff --git a/src/renderer/src/extensions/hardlink_activator/index.ts b/src/renderer/src/extensions/hardlink_activator/index.ts -index 917490366..1b0e3e4cf 100644 +index 73c530dde..77589bedc 100644 --- a/src/renderer/src/extensions/hardlink_activator/index.ts +++ b/src/renderer/src/extensions/hardlink_activator/index.ts -@@ -5,7 +5,6 @@ import { getErrorCode, getErrorMessageOrDefault, unknownToError } from "@vortex/ - import PromiseBB from "bluebird"; - import type { TFunction } from "i18next"; - import turbowalk from "turbowalk"; --import * as winapi from "winapi-bindings"; - - import { setSettingsPage } from "../../actions/session"; - import { log } from "../../logging"; -@@ -14,6 +13,7 @@ import type { IGame } from "../../types/IGame"; - import type { IState } from "../../types/IState"; +@@ -14,6 +14,7 @@ import type { IState } from "../../types/IState"; import * as fs from "../../util/fs"; + import { getVolumePath } from "../../util/getVolumePath"; import { installPathForGame } from "../../util/selectors"; -+import { assertPathWritable, getPathDevice, getVolumePath } from "../../util/volume"; ++import { assertPathWritable, getPathDevice } from "../../util/volume"; import type { IDiscoveryResult } from "../gamemode_management/types/IDiscoveryResult"; import { getGame } from "../gamemode_management/util/getGame"; import LinkingDeployment from "../mod_management/LinkingDeployment"; -@@ -84,7 +84,10 @@ class DeploymentMethod extends LinkingDeployment { +@@ -84,7 +85,10 @@ class DeploymentMethod extends LinkingDeployment { } try { @@ -101,7 +93,7 @@ index 917490366..1b0e3e4cf 100644 } catch { log("info", "hardlink deployment not supported due to lack of write access", { typeId, -@@ -109,7 +112,7 @@ class DeploymentMethod extends LinkingDeployment { +@@ -109,7 +113,7 @@ class DeploymentMethod extends LinkingDeployment { const installationPath = installPathForGame(state, gameId); try { @@ -110,15 +102,6 @@ index 917490366..1b0e3e4cf 100644 // hard links work only on the same drive return { description: (t) => t("Works only if mods are installed on the same drive as the game."), -@@ -117,7 +120,7 @@ class DeploymentMethod extends LinkingDeployment { - solution: (t) => { - let displayPath = modPaths[typeId]; - try { -- displayPath = winapi.GetVolumePathName(modPaths[typeId]); -+ displayPath = getVolumePath(modPaths[typeId]); - } catch { - log("warn", "Failed to resolve volume path", { - path: modPaths[typeId], diff --git a/src/renderer/src/extensions/mod_management/stagingDirectory.ts b/src/renderer/src/extensions/mod_management/stagingDirectory.ts index 21a7e5941..2aa51176a 100644 --- a/src/renderer/src/extensions/mod_management/stagingDirectory.ts @@ -170,10 +153,10 @@ index 21a7e5941..2aa51176a 100644 } } diff --git a/src/renderer/src/extensions/mod_management/util/deploy.ts b/src/renderer/src/extensions/mod_management/util/deploy.ts -index 6c6fb9c86..08ede799e 100644 +index 6a0002641..8bdbc6824 100644 --- a/src/renderer/src/extensions/mod_management/util/deploy.ts +++ b/src/renderer/src/extensions/mod_management/util/deploy.ts -@@ -152,6 +152,16 @@ export function purgeMods( +@@ -193,6 +193,16 @@ export function purgeMods( }); const deployedActivator = getActivator(manifest?.deploymentMethod); return purgeModsImpl(api, deployedActivator, profile); @@ -190,56 +173,19 @@ index 6c6fb9c86..08ede799e 100644 } else { return purgeModsImpl(api, undefined, profile).catch((err: unknown) => { // If the user is unmanaging the game and the purge was unable to find any -diff --git a/src/renderer/src/extensions/mod_management/views/Settings.tsx b/src/renderer/src/extensions/mod_management/views/Settings.tsx -index dfd5a6cb3..5a30b36e3 100644 ---- a/src/renderer/src/extensions/mod_management/views/Settings.tsx -+++ b/src/renderer/src/extensions/mod_management/views/Settings.tsx -@@ -17,7 +17,6 @@ import { - } from "react-bootstrap"; - import type * as Redux from "redux"; - import type { ThunkDispatch } from "redux-thunk"; --import * as winapi from "winapi-bindings"; - - import { showDialog } from "../../../actions/notifications"; - import { ComponentEx, connect, translate } from "../../../controls/ComponentEx"; -@@ -56,6 +55,7 @@ import * as selectors from "../../../util/selectors"; - import { getSafe } from "../../../util/storeHelper"; - import { cleanFailedTransfer, testPathTransfer, transferPath } from "../../../util/transferPath"; - import { ciEqual, isChildPath, isPathValid, isReservedDirectory } from "../../../util/util"; -+import { getVolumePath } from "../../../util/volume"; - import { currentGame, currentGameDiscovery } from "../../gamemode_management/selectors"; - import type { IDiscoveryResult } from "../../gamemode_management/types/IDiscoveryResult"; - import type { IGameStored } from "../../gamemode_management/types/IGameStored"; -@@ -1053,7 +1053,7 @@ class Settings extends ComponentEx { - if (modPathStats.dev === userDataStats.dev) { - suggestion = path.join("{USERDATA}", "{game}", "mods"); - } else { -- const volume = winapi.GetVolumePathName(modPaths[""]); -+ const volume = getVolumePath(modPaths[""]); - suggestion = path.join(volume, suggestInstallPathDirectory, "{game}"); - } - this.changePath(suggestion); diff --git a/src/renderer/src/extensions/move_activator/index.ts b/src/renderer/src/extensions/move_activator/index.ts -index 8e712f5c2..7c99e812e 100644 +index 6a1c1bd9b..ee710adad 100644 --- a/src/renderer/src/extensions/move_activator/index.ts +++ b/src/renderer/src/extensions/move_activator/index.ts -@@ -5,7 +5,6 @@ import PromiseBB from "bluebird"; - import type { TFunction } from "i18next"; - import type { IEntry } from "turbowalk"; - import turbowalk from "turbowalk"; --import * as winapi from "winapi-bindings"; - - import { setSettingsPage } from "../../actions/session"; - import type { IExtensionApi, IExtensionContext } from "../../types/IExtensionContext"; -@@ -13,6 +12,7 @@ import type { IGame } from "../../types/IGame"; - import { UserCanceled } from "../../util/CustomErrors"; +@@ -13,6 +13,7 @@ import { UserCanceled } from "../../util/CustomErrors"; import * as fs from "../../util/fs"; + import { getVolumePath } from "../../util/getVolumePath"; import { log } from "../../util/log"; -+import { assertPathWritable, getPathDevice, getVolumePath } from "../../util/volume"; ++import { assertPathWritable, getPathDevice } from "../../util/volume"; import type { IDiscoveryResult } from "../gamemode_management/types/IDiscoveryResult"; import { getGame } from "../gamemode_management/util/getGame"; import type { IDeployment } from "../mod_management/LinkingDeployment"; -@@ -86,7 +86,9 @@ class DeploymentMethod extends LinkingDeployment { +@@ -86,7 +87,9 @@ class DeploymentMethod extends LinkingDeployment { } try { @@ -250,7 +196,7 @@ index 8e712f5c2..7c99e812e 100644 } catch (err) { log("info", "move deployment not supported due to lack of write access", { typeId, -@@ -109,7 +111,7 @@ class DeploymentMethod extends LinkingDeployment { +@@ -109,7 +112,7 @@ class DeploymentMethod extends LinkingDeployment { } try { @@ -259,20 +205,11 @@ index 8e712f5c2..7c99e812e 100644 // actually we could support this but it would be so slow it wouldn't make sense return { -@@ -118,7 +120,7 @@ class DeploymentMethod extends LinkingDeployment { - solution: (t) => { - let displayPath = modPaths[typeId]; - try { -- displayPath = winapi.GetVolumePathName(modPaths[typeId]); -+ displayPath = getVolumePath(modPaths[typeId]); - } catch (err) { - log("warn", "Failed to resolve volume path", { - path: modPaths[typeId], diff --git a/src/renderer/src/util/transferPath.ts b/src/renderer/src/util/transferPath.ts -index d6553d987..afadcdc73 100644 +index dea986c24..20e2601dd 100644 --- a/src/renderer/src/util/transferPath.ts +++ b/src/renderer/src/util/transferPath.ts -@@ -1,11 +1,10 @@ +@@ -1,7 +1,7 @@ import * as nodeFs from "fs"; import * as path from "path"; @@ -281,38 +218,9 @@ index d6553d987..afadcdc73 100644 import PromiseBB from "bluebird"; import type { IEntry } from "turbowalk"; import turbowalk from "turbowalk"; --import * as winapi from "winapi-bindings"; - - import { DOWNLOADS_DIR_TAG } from "../extensions/download_management/util/downloadDirectory"; - import { STAGING_DIR_TAG } from "../extensions/mod_management/stagingDirectory"; -@@ -14,7 +13,6 @@ import { - InsufficientDiskSpace, - NotFound, - ProcessCanceled, -- UnsupportedOperatingSystem, - UserCanceled, - } from "./CustomErrors"; - import * as fs from "./fs"; -@@ -22,6 +20,7 @@ import type { Normalize } from "./getNormalizeFunc"; - import getNormalizeFunc from "./getNormalizeFunc"; - import { log } from "./log"; - import { isChildPath } from "./util"; -+import { getVolumePath } from "./volume"; - - const MIN_DISK_SPACE_OFFSET = 512 * 1024 * 1024; - -@@ -36,18 +35,12 @@ const MIN_DISK_SPACE_OFFSET = 512 * 1024 * 1024; - * @param destination The proposed destination folder. - */ - export function testPathTransfer(source: string, destination: string): PromiseBB { -- if (process.platform !== "win32") { -- return PromiseBB.reject(new UnsupportedOperatingSystem()); -- } -- - let destinationRoot: string; +@@ -39,10 +39,8 @@ export function testPathTransfer(source: string, destination: string): PromiseBB try { -- destinationRoot = winapi.GetVolumePathName(destination); -+ destinationRoot = getVolumePath(destination); + destinationRoot = getVolumePath(destination); } catch (err) { - if (isErrorWithSystemCode(err)) { - if (err.systemCode === 2) { @@ -382,15 +290,17 @@ index 000000000..5a885f327 +}); diff --git a/src/renderer/src/util/volume.ts b/src/renderer/src/util/volume.ts new file mode 100644 -index 000000000..e38bcba47 +index 000000000..f04ed7b8d --- /dev/null +++ b/src/renderer/src/util/volume.ts -@@ -0,0 +1,70 @@ +@@ -0,0 +1,54 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import * as winapi from "winapi-bindings"; + ++export { getVolumePath } from "./getVolumePath"; ++ +export interface IDiskSpace { + free: number; + freeToCaller: number; @@ -426,24 +336,6 @@ index 000000000..e38bcba47 + return fs.statSync(nearestExistingPath(input)).dev; +} + -+export function getVolumePath(input: string): string { -+ if (process.platform === "win32") { -+ return winapi.GetVolumePathName(input); -+ } -+ -+ const existing = nearestExistingPath(input); -+ let current = fs.statSync(existing).isDirectory() ? existing : path.dirname(existing); -+ const device = fs.statSync(current).dev; -+ -+ while (true) { -+ const parent = path.dirname(current); -+ if (parent === current || fs.statSync(parent).dev !== device) { -+ return current; -+ } -+ current = parent; -+ } -+} -+ +export function getDiskSpace(input: string): IDiskSpace { + if (process.platform === "win32") { + return winapi.GetDiskFreeSpaceEx(input); diff --git a/patches/0005-linux-steam-game-launch.patch b/patches/0005-linux-steam-game-launch.patch index 19d2ba4..e5c8fd3 100644 --- a/patches/0005-linux-steam-game-launch.patch +++ b/patches/0005-linux-steam-game-launch.patch @@ -192,10 +192,10 @@ index 0000000..7069782 + }); +}); diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts -index 976df7b..b02a209 100644 +index 0147b0d..879e6c4 100644 --- a/src/renderer/src/util/StarterInfo.ts +++ b/src/renderer/src/util/StarterInfo.ts -@@ -70,44 +70,55 @@ type OnShowErrorFunc = ( +@@ -69,44 +69,55 @@ type OnShowErrorFunc = ( allowReport?: boolean, ) => void; @@ -221,15 +221,15 @@ index 976df7b..b02a209 100644 - api: IExtensionApi, -): Promise { - if (process.platform === "win32") { -- return undefined; -- } -- if (!isWindowsExecutable(info.exePath)) { + games: ISteamEntry[], + platform: NodeJS.Platform = process.platform, +): ISteamEntry | undefined { + if (platform === "win32" || info.store !== "steam") { return undefined; } +- if (!isWindowsExecutable(info.exePath)) { +- return undefined; +- } - if (info.store !== "steam") { + + const candidates = [info.workingDirectory, info.exePath].filter( @@ -278,21 +278,22 @@ index 976df7b..b02a209 100644 } /** -@@ -188,12 +199,7 @@ class StarterInfo implements IStarterInfo { - .then(() => { - // assuming that runThroughLauncher returns immediately on handing things off - // to the launcher -- const isLinuxSteamLauncher = process.platform !== "win32" && res.launcher === "steam"; -- // Keep existing Linux Steam behavior unless running under Flatpak, where we now -- // emulate stop events to avoid stale running state. -- if (!isLinuxSteamLauncher || process.env.IS_FLATPAK === "true") { -- onSpawned(); -- } -+ onSpawned(); - if (["hide", "hide_recover"].includes(info.onStart)) { - void hideWindow(); - } else if (info.onStart === "close") { -@@ -257,8 +263,32 @@ class StarterInfo implements IStarterInfo { +@@ -218,13 +229,7 @@ class StarterInfo implements IStarterInfo { + await StarterInfo.runThroughLauncher(launcherResult.launcher, info, api, infoObj); + // assuming that runThroughLauncher returns immediately on handing things off + // to the launcher +- const isLinuxSteamLauncher = +- process.platform !== "win32" && launcherResult.launcher === "steam"; +- // Keep existing Linux Steam behavior unless running under Flatpak, where we now +- // emulate stop events to avoid stale running state. +- if (!isLinuxSteamLauncher || process.env.IS_FLATPAK === "true") { +- onSpawned(); +- } ++ onSpawned(); + if (["hide", "hide_recover"].includes(info.onStart)) { + void hideWindow(); + } else if (info.onStart === "close") { +@@ -290,8 +295,32 @@ class StarterInfo implements IStarterInfo { } }; diff --git a/patches/0008-linux-proton-user-folders.patch b/patches/0008-linux-proton-user-folders.patch index e483897..5fdf00a 100644 --- a/patches/0008-linux-proton-user-folders.patch +++ b/patches/0008-linux-proton-user-folders.patch @@ -1,8 +1,8 @@ diff --git a/src/main/src/main.ts b/src/main/src/main.ts -index 612592a11..2bc1ff49f 100644 +index 30bb5efb0..b403f9be0 100644 --- a/src/main/src/main.ts +++ b/src/main/src/main.ts -@@ -116,6 +116,15 @@ function setEnv(key: string, value: string, force?: boolean) { +@@ -127,6 +127,15 @@ function setEnv(key: string, value: string, force?: boolean) { } } @@ -269,7 +269,7 @@ index 000000000..def35913e + } +}); diff --git a/src/renderer/src/util/fs.ts b/src/renderer/src/util/fs.ts -index ad20c1d2a..88b78322e 100644 +index 8f48e50e3..e0742482a 100644 --- a/src/renderer/src/util/fs.ts +++ b/src/renderer/src/util/fs.ts @@ -29,6 +29,7 @@ import * as fs from "fs-extra"; @@ -370,7 +370,7 @@ index ad20c1d2a..88b78322e 100644 export interface ILinkFileOptions { // Used to dictate whether error dialogs should -@@ -511,9 +526,14 @@ function errorHandler( +@@ -509,9 +524,14 @@ function errorHandler( .catch((err) => PromiseBB.reject(restackErr(err, stackErr))); } @@ -387,7 +387,7 @@ index ad20c1d2a..88b78322e 100644 errorHandler(err, stackErr, tries).then(() => wrapper(stackErr, tries - 1, ...args)), ); -@@ -526,25 +546,26 @@ export function genFSWrapperAsync any>(func: T) { +@@ -524,25 +544,26 @@ export function genFSWrapperAsync any>(func: T) { // tslint:disable:max-line-length const chmodAsync: (path: string, mode: string | number) => PromiseBB = genFSWrapperAsync( fs.chmod, @@ -425,7 +425,7 @@ index ad20c1d2a..88b78322e 100644 // fs.write and fs.read don't promisify correctly because it has two return values. fs-extra already works around this in their // promisified api so no reason to reinvent the wheel (also we want the api to be compatible) const writeAsync: ( -@@ -553,9 +574,9 @@ const writeAsync: ( +@@ -551,9 +572,9 @@ const writeAsync: ( const readAsync: (...args: any[]) => PromiseBB<{ bytesRead: number; buffer: BufferT }> = genFSWrapperAsync(fs.read) as any; const writeFileAsync: (file: string, data: any, options?: fs.WriteFileOptions) => PromiseBB = @@ -437,7 +437,7 @@ index ad20c1d2a..88b78322e 100644 // tslint:enable:max-line-length export { -@@ -599,13 +620,15 @@ export { +@@ -597,13 +618,15 @@ export { /** @deprecated use node:fs directly */ export function isDirectoryAsync(dirPath: string): PromiseBB { @@ -455,7 +455,7 @@ index ad20c1d2a..88b78322e 100644 } catch (err) { throw restackErr(err, new Error()); } -@@ -614,7 +637,7 @@ export function ensureDirSync(dirPath: string) { +@@ -612,7 +635,7 @@ export function ensureDirSync(dirPath: string) { /** @deprecated use node:fs directly */ export function ensureFileAsync(filePath: string): PromiseBB { const stackErr = new Error(); @@ -464,7 +464,7 @@ index ad20c1d2a..88b78322e 100644 throw restackErr(err, stackErr); }); } -@@ -624,6 +647,7 @@ export function ensureDirAsync( +@@ -622,6 +645,7 @@ export function ensureDirAsync( dirPath: string, onDirCreatedCB?: (created: string) => PromiseLike, ): PromiseBB { @@ -472,7 +472,7 @@ index ad20c1d2a..88b78322e 100644 const stackErr = new Error(); // If a onDirCreated callback is provided, we can't use fs-extra's // implementation directly as there's no way for us to reliably determine -@@ -734,6 +758,8 @@ export function copyAsync( +@@ -732,6 +756,8 @@ export function copyAsync( showDialogCallback?: () => boolean; }, ): PromiseBB { @@ -481,7 +481,7 @@ index ad20c1d2a..88b78322e 100644 const stackErr = new Error(); // fs.copy in fs-extra has a bug where it doesn't correctly avoid copying files onto themselves const check = options?.noSelfCopy ? PromiseBB.resolve() : selfCopyCheck(src, dest); -@@ -764,6 +790,8 @@ function copyInt( +@@ -762,6 +788,8 @@ function copyInt( /** @deprecated use node:fs directly */ export function linkAsync(src: string, dest: string, options?: ILinkFileOptions): PromiseBB { @@ -490,7 +490,7 @@ index ad20c1d2a..88b78322e 100644 const stackErr = new Error(); return linkInt(src, dest, stackErr, NUM_RETRIES, options).catch((err) => PromiseBB.reject(restackErr(err, stackErr)), -@@ -788,10 +816,11 @@ function linkInt( +@@ -786,10 +814,11 @@ function linkInt( } export function removeSync(dirPath: string) { @@ -503,7 +503,7 @@ index ad20c1d2a..88b78322e 100644 return unlinkInt(filePath, new Error(), NUM_RETRIES, options || {}); } -@@ -833,6 +862,8 @@ function unlinkInt( +@@ -831,6 +860,8 @@ function unlinkInt( /** @deprecated use node:fs directly */ export function renameAsync(sourcePath: string, destinationPath: string): PromiseBB { @@ -512,7 +512,7 @@ index ad20c1d2a..88b78322e 100644 return renameInt(sourcePath, destinationPath, new Error(), NUM_RETRIES); } -@@ -867,6 +898,7 @@ function renameInt( +@@ -865,6 +896,7 @@ function renameInt( } export function rmdirAsync(dirPath: string): PromiseBB { @@ -520,7 +520,7 @@ index ad20c1d2a..88b78322e 100644 return rmdirInt(dirPath, new Error(), NUM_RETRIES); } -@@ -884,6 +916,7 @@ function rmdirInt(dirPath: string, stackErr: Error, tries: number): PromiseBB { @@ -528,7 +528,7 @@ index ad20c1d2a..88b78322e 100644 const stackErr = new Error(); return removeInt(remPath, stackErr, NUM_RETRIES, options || {}); } -@@ -923,6 +956,7 @@ function rimrafAsync(remPath: string): PromiseBB { +@@ -921,6 +954,7 @@ function rimrafAsync(remPath: string): PromiseBB { /** @deprecated use node:fs directly */ export function readlinkAsync(linkPath: string): PromiseBB { @@ -536,11 +536,36 @@ index ad20c1d2a..88b78322e 100644 const stackErr = new Error(); return readlinkInt(linkPath, stackErr, NUM_RETRIES); } +diff --git a/src/renderer/src/util/linuxCompatibility.test.ts b/src/renderer/src/util/linuxCompatibility.test.ts +index c2637d3ec..2487d412b 100644 +--- a/src/renderer/src/util/linuxCompatibility.test.ts ++++ b/src/renderer/src/util/linuxCompatibility.test.ts +@@ -89,6 +89,20 @@ describe.runIf(process.platform === "linux")("linuxCompatibility", () => { + expect(result.translated).toBe(true); + }); + ++ it("keeps the launcher's Linux .NET location out of Windows processes", () => { ++ vi.stubEnv("DOTNET_ROOT", "/opt/Vortex/dotnet-linux-x64"); ++ vi.stubEnv("DOTNET_ROOT_X64", "/opt/Vortex/dotnet-linux-x64"); ++ const helper = path.join(tempDir, "Tool.exe"); ++ fs.writeFileSync(helper, ""); ++ ++ const inherited = prepareWindowsProcess(helper, [], { env: { ...process.env } }); ++ expect(inherited.options.env?.DOTNET_ROOT).toBeUndefined(); ++ expect(inherited.options.env?.DOTNET_ROOT_X64).toBeUndefined(); ++ ++ const chosen = prepareWindowsProcess(helper, [], { env: { DOTNET_ROOT: "C:\\dotnet" } }); ++ expect(chosen.options.env?.DOTNET_ROOT).toBe("C:\\dotnet"); ++ }); ++ + it("rewrites a shell invocation without knowing the helper or game", () => { + const helper = path.join(tempDir, "Generic Tool.exe"); + const input = path.join(tempDir, "Data", "input.bin"); diff --git a/src/renderer/src/util/linuxCompatibility.ts b/src/renderer/src/util/linuxCompatibility.ts -index b52bb76ff..6d82ea0a8 100644 +index b52bb76ff..107376d05 100644 --- a/src/renderer/src/util/linuxCompatibility.ts +++ b/src/renderer/src/util/linuxCompatibility.ts -@@ -62,12 +62,19 @@ function wineExecutable(): string { +@@ -62,12 +62,28 @@ function wineExecutable(): string { } function wineEnvironment(input: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { @@ -557,6 +582,15 @@ index b52bb76ff..6d82ea0a8 100644 + delete result.APPDATA; + delete result.LOCALAPPDATA; + delete result.USERPROFILE; ++ ++ // The launcher points these at the Linux .NET runtime, where a Windows .NET ++ // program would look for hostfxr.dll and give up. Drop the inherited values; ++ // a different one the caller sets for this process stays. ++ for (const name of ["DOTNET_ROOT", "DOTNET_ROOT_X64"]) { ++ if (result[name] !== undefined && result[name] === process.env[name]) { ++ delete result[name]; ++ } ++ } + return result; } diff --git a/patches/0009-linux-staging-suggestions.patch b/patches/0009-linux-staging-suggestions.patch index e4b2406..d1eb091 100644 --- a/patches/0009-linux-staging-suggestions.patch +++ b/patches/0009-linux-staging-suggestions.patch @@ -1,69 +1,8 @@ -diff --git a/src/renderer/src/extensions/mod_management/views/Settings.tsx b/src/renderer/src/extensions/mod_management/views/Settings.tsx -index 5a30b36..dd51df5 100644 ---- a/src/renderer/src/extensions/mod_management/views/Settings.tsx -+++ b/src/renderer/src/extensions/mod_management/views/Settings.tsx -@@ -55,10 +55,10 @@ import * as selectors from "../../../util/selectors"; - import { getSafe } from "../../../util/storeHelper"; - import { cleanFailedTransfer, testPathTransfer, transferPath } from "../../../util/transferPath"; - import { ciEqual, isChildPath, isPathValid, isReservedDirectory } from "../../../util/util"; --import { getVolumePath } from "../../../util/volume"; - import { currentGame, currentGameDiscovery } from "../../gamemode_management/selectors"; - import type { IDiscoveryResult } from "../../gamemode_management/types/IDiscoveryResult"; - import type { IGameStored } from "../../gamemode_management/types/IGameStored"; -+import { suggestStagingPath } from "../../gamemode_management/util/discovery"; - import { setDeploymentNecessary } from "../actions/deployment"; - import { setActivator, setInstallPath, setInstallPathMode } from "../actions/settings"; - import { setTransferMods } from "../actions/transactions"; -@@ -1040,23 +1040,13 @@ class Settings extends ComponentEx { - }; - - private suggestPath = async () => { -- const { modPaths, onShowError, suggestInstallPathDirectory } = this.props; -+ const { gameMode, onShowError } = this.props; - try { -- // stat the volume root rather than the full mod path — the mod directory -- // may not exist yet (e.g. first-time setup), but we only need the device id -- const [modPathStats, userDataStats] = await Promise.all([ -- fs.statAsync(path.parse(modPaths[""]).root), -- window.api.app.getPath("userData").then((userDataPath) => fs.statAsync(userDataPath)), -- ]); -- -- let suggestion: string; -- if (modPathStats.dev === userDataStats.dev) { -- suggestion = path.join("{USERDATA}", "{game}", "mods"); -- } else { -- const volume = getVolumePath(modPaths[""]); -- suggestion = path.join(volume, suggestInstallPathDirectory, "{game}"); -- } -- this.changePath(suggestion); -+ // Use the same nearest-existing-ancestor/device logic as automatic setup. -+ // On POSIX, path.parse("/mnt/library/game").root is merely "/" and may be -+ // a different filesystem from the game, so inspecting it gives a false -+ // same-device result and suggests USERDATA again. -+ this.changePath(await suggestStagingPath(this.context.api, gameMode)); - } catch (err) { - if (err instanceof UserCanceled) { - return; -diff --git a/src/renderer/src/extensions/mod_management/reducers/settings.ts b/src/renderer/src/extensions/mod_management/reducers/settings.ts -index 09b8370..9195215 100644 ---- a/src/renderer/src/extensions/mod_management/reducers/settings.ts -+++ b/src/renderer/src/extensions/mod_management/reducers/settings.ts -@@ -27,7 +27,9 @@ export const settingsReducer: IReducerSpec = { - }, - defaults: { - installPath: {}, -- installPathMode: "userData", -+ // Linux game libraries commonly live on a separate mounted filesystem. -+ // Defaulting to USERDATA there makes hardlink deployment impossible. -+ installPathMode: process.platform === "linux" ? "suggested" : "userData", - suggestInstallPathDirectory: "Vortex Mods", - modlistState: {}, - activator: {}, diff --git a/src/renderer/src/extensions/hardlink_activator/index.ts b/src/renderer/src/extensions/hardlink_activator/index.ts -index 53250a5..6a16dc7 100644 +index 65edd22..e19ebec 100644 --- a/src/renderer/src/extensions/hardlink_activator/index.ts +++ b/src/renderer/src/extensions/hardlink_activator/index.ts -@@ -21,6 +21,7 @@ import type { +@@ -22,6 +22,7 @@ import type { IDeploymentMethod, IUnavailableReason, } from "../mod_management/types/IDeploymentMethod"; @@ -71,7 +10,7 @@ index 53250a5..6a16dc7 100644 import { collectHardlinkKeys, purgeMatchingHardlinks } from "./hardlinkIdentity"; export class FileFound extends Error { -@@ -136,16 +137,19 @@ class DeploymentMethod extends LinkingDeployment { +@@ -137,16 +138,19 @@ class DeploymentMethod extends LinkingDeployment { }, ); }, @@ -101,48 +40,41 @@ index 53250a5..6a16dc7 100644 }; } } catch (err) { -diff --git a/src/renderer/src/extensions/move_activator/index.ts b/src/renderer/src/extensions/move_activator/index.ts -index 7c99e81..7e3bff2 100644 ---- a/src/renderer/src/extensions/move_activator/index.ts -+++ b/src/renderer/src/extensions/move_activator/index.ts -@@ -23,6 +23,7 @@ import type { - IDeploymentMethod, - IUnavailableReason, - } from "../mod_management/types/IDeploymentMethod"; -+import { applySuggestedStagingPathIfEmpty } from "../mod_management/util/stagingFix"; - - const LNK_EXT = ".vortex_lnk"; - -@@ -136,16 +137,19 @@ class DeploymentMethod extends LinkingDeployment { - }, - ); - }, -- fixCallback: (api: IExtensionApi) => -- new PromiseBB((resolve, reject) => { -- api.events.emit("show-main-page", "application_settings"); -- api.store.dispatch(setSettingsPage("Mods")); -- api.highlightControl( -- "#install-path-form", -- 5000, -- "Change this to be on the same drive as the game.", -- ); -- }), -+ fixCallback: async (api: IExtensionApi) => { -+ if (await applySuggestedStagingPathIfEmpty(api, gameId)) { -+ return; -+ } +diff --git a/src/renderer/src/extensions/mod_management/reducers/settings.ts b/src/renderer/src/extensions/mod_management/reducers/settings.ts +index 5c58c00..0e3f612 100644 +--- a/src/renderer/src/extensions/mod_management/reducers/settings.ts ++++ b/src/renderer/src/extensions/mod_management/reducers/settings.ts +@@ -28,7 +28,9 @@ export const settingsReducer: IReducerSpec = { + }, + defaults: { + installPath: {}, +- installPathMode: "userData", ++ // Linux game libraries commonly live on a separate mounted filesystem. ++ // Defaulting to USERDATA there makes hardlink deployment impossible. ++ installPathMode: process.platform === "linux" ? "suggested" : "userData", + suggestInstallPathDirectory: "Vortex Mods", + modlistState: {}, + activator: {}, +diff --git a/src/renderer/src/extensions/mod_management/util/stagingFix.test.ts b/src/renderer/src/extensions/mod_management/util/stagingFix.test.ts +new file mode 100644 +index 0000000..8f57749 +--- /dev/null ++++ b/src/renderer/src/extensions/mod_management/util/stagingFix.test.ts +@@ -0,0 +1,14 @@ ++import { describe, expect, it } from "vitest"; + -+ api.events.emit("show-main-page", "application_settings"); -+ api.store.dispatch(setSettingsPage("Mods")); -+ api.highlightControl( -+ "#install-path-form", -+ 5000, -+ "Change this to be on the same drive as the game.", -+ ); -+ }, - }; - } - } catch (err) { ++import { stagingDirectoryHasMods } from "./stagingFix"; ++ ++describe("stagingDirectoryHasMods", () => { ++ it("treats an absent tag payload as empty", () => { ++ expect(stagingDirectoryHasMods([])).toBe(false); ++ expect(stagingDirectoryHasMods(["__vortex_staging_folder"])).toBe(false); ++ }); ++ ++ it("does not allow an automatic path change when mod data exists", () => { ++ expect(stagingDirectoryHasMods(["__vortex_staging_folder", "example-mod"])).toBe(true); ++ }); ++}); diff --git a/src/renderer/src/extensions/mod_management/util/stagingFix.ts b/src/renderer/src/extensions/mod_management/util/stagingFix.ts new file mode 100644 index 0000000..8ac0ffe @@ -203,24 +135,45 @@ index 0000000..8ac0ffe + }); + return true; +} -diff --git a/src/renderer/src/extensions/mod_management/util/stagingFix.test.ts b/src/renderer/src/extensions/mod_management/util/stagingFix.test.ts -new file mode 100644 -index 0000000..8f57749 ---- /dev/null -+++ b/src/renderer/src/extensions/mod_management/util/stagingFix.test.ts -@@ -0,0 +1,14 @@ -+import { describe, expect, it } from "vitest"; -+ -+import { stagingDirectoryHasMods } from "./stagingFix"; -+ -+describe("stagingDirectoryHasMods", () => { -+ it("treats an absent tag payload as empty", () => { -+ expect(stagingDirectoryHasMods([])).toBe(false); -+ expect(stagingDirectoryHasMods(["__vortex_staging_folder"])).toBe(false); -+ }); +diff --git a/src/renderer/src/extensions/move_activator/index.ts b/src/renderer/src/extensions/move_activator/index.ts +index ee710ad..5b639f4 100644 +--- a/src/renderer/src/extensions/move_activator/index.ts ++++ b/src/renderer/src/extensions/move_activator/index.ts +@@ -24,6 +24,7 @@ import type { + IDeploymentMethod, + IUnavailableReason, + } from "../mod_management/types/IDeploymentMethod"; ++import { applySuggestedStagingPathIfEmpty } from "../mod_management/util/stagingFix"; + + const LNK_EXT = ".vortex_lnk"; + +@@ -137,16 +138,19 @@ class DeploymentMethod extends LinkingDeployment { + }, + ); + }, +- fixCallback: (api: IExtensionApi) => +- new PromiseBB((resolve, reject) => { +- api.events.emit("show-main-page", "application_settings"); +- api.store.dispatch(setSettingsPage("Mods")); +- api.highlightControl( +- "#install-path-form", +- 5000, +- "Change this to be on the same drive as the game.", +- ); +- }), ++ fixCallback: async (api: IExtensionApi) => { ++ if (await applySuggestedStagingPathIfEmpty(api, gameId)) { ++ return; ++ } + -+ it("does not allow an automatic path change when mod data exists", () => { -+ expect(stagingDirectoryHasMods(["__vortex_staging_folder", "example-mod"])).toBe(true); -+ }); -+}); - ++ api.events.emit("show-main-page", "application_settings"); ++ api.store.dispatch(setSettingsPage("Mods")); ++ api.highlightControl( ++ "#install-path-form", ++ 5000, ++ "Change this to be on the same drive as the game.", ++ ); ++ }, + }; + } + } catch (err) { diff --git a/patches/0011-linux-case-safe-deployment.patch b/patches/0011-linux-case-safe-deployment.patch index 50e97cf..7c4d80c 100644 --- a/patches/0011-linux-case-safe-deployment.patch +++ b/patches/0011-linux-case-safe-deployment.patch @@ -1,8 +1,8 @@ diff --git a/src/renderer/src/extensions/mod_management/eventHandlers.ts b/src/renderer/src/extensions/mod_management/eventHandlers.ts -index c62350f..7db497e 100644 +index b79aabe..350f4df 100644 --- a/src/renderer/src/extensions/mod_management/eventHandlers.ts +++ b/src/renderer/src/extensions/mod_management/eventHandlers.ts -@@ -65,6 +65,7 @@ import { +@@ -68,6 +68,7 @@ import { getSupportedActivators, } from "./util/deploymentMethods"; import { dropMissingMods } from "./util/dropMissingMods"; @@ -10,7 +10,7 @@ index c62350f..7db497e 100644 import modName from "./util/modName"; import queryGameId from "./util/queryGameId"; import refreshMods from "./util/refreshMods"; -@@ -672,7 +673,7 @@ async function undeploy( +@@ -688,7 +689,7 @@ async function undeploy( } let normalize: Normalize; try { @@ -20,10 +20,10 @@ index c62350f..7db497e 100644 api, gameMode, diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/src/extensions/mod_management/index.ts -index 10c5296..e302b92 100644 +index fd15d33..8de776b 100644 --- a/src/renderer/src/extensions/mod_management/index.ts +++ b/src/renderer/src/extensions/mod_management/index.ts -@@ -129,6 +129,7 @@ import { dealWithExternalChanges } from "./util/externalChanges"; +@@ -148,6 +148,7 @@ import { dealWithExternalChanges } from "./util/externalChanges"; import { attributeExtractor, upgradeExtractor } from "./util/extractors"; import { registerAttributeExtractor } from "./util/filterModInfo"; import { findModByRef } from "./util/findModByRef"; @@ -31,7 +31,7 @@ index 10c5296..e302b92 100644 import ModHistory from "./util/ModHistory"; import renderModName from "./util/modName"; import { getModSources, registerModSource } from "./util/modSource"; -@@ -264,7 +265,8 @@ async function deployModType( +@@ -295,7 +296,8 @@ async function deployModType( count: lastDeployment.length, }); @@ -41,7 +41,7 @@ index 10c5296..e302b92 100644 const newActivation = await deployMods( api, game.id, -@@ -273,6 +275,7 @@ async function deployModType( +@@ -304,6 +306,7 @@ async function deployModType( filteredModList, activator, lastDeployment, @@ -49,7 +49,7 @@ index 10c5296..e302b92 100644 typeId, new BlacklistSet(mergeResult[typeId]?.usedInMerge ?? [], game, normalize), genSubDirFunc(game, getModType(typeId)), -@@ -1254,7 +1257,7 @@ function onDeploySingleMod(api: IExtensionApi) { +@@ -1312,7 +1315,7 @@ function onDeploySingleMod(api: IExtensionApi) { const subdir = genSubDirFunc(game, getModType(mod.type)); await withActivationLock(async () => { try { @@ -59,7 +59,7 @@ index 10c5296..e302b92 100644 api, gameId, diff --git a/src/renderer/src/extensions/mod_management/modActivation.ts b/src/renderer/src/extensions/mod_management/modActivation.ts -index c76391e..4658ea0 100644 +index d3358aa..9d5578b 100644 --- a/src/renderer/src/extensions/mod_management/modActivation.ts +++ b/src/renderer/src/extensions/mod_management/modActivation.ts @@ -4,7 +4,7 @@ import { log } from "../../logging"; @@ -69,9 +69,9 @@ index c76391e..4658ea0 100644 -import getNormalizeFunc, { type Normalize } from "../../util/getNormalizeFunc"; +import type { Normalize } from "../../util/getNormalizeFunc"; import { truthy } from "../../util/util"; - import { MERGED_PATH } from "./modMerging"; + import { mergedSourceName } from "./modMerging"; import type { IDeployedFile, IDeploymentMethod } from "./types/IDeploymentMethod"; -@@ -51,6 +51,7 @@ async function deployMods( +@@ -52,6 +52,7 @@ async function deployMods( mods: IMod[], method: IDeploymentMethod, lastActivation: IDeployedFile[], @@ -79,7 +79,7 @@ index c76391e..4658ea0 100644 typeId: string, skipFiles: BlacklistSet, subDir: (mod: IMod) => string, -@@ -69,7 +70,6 @@ async function deployMods( +@@ -70,7 +71,6 @@ async function deployMods( try { await ensureWritable(api, destinationPath); @@ -88,133 +88,129 @@ index c76391e..4658ea0 100644 for (let idx = 0; idx < mods.length; idx++) { diff --git a/src/renderer/src/extensions/mod_management/util/externalChanges.test.ts b/src/renderer/src/extensions/mod_management/util/externalChanges.test.ts -index 4dd37e3..8891017 100644 +index e1173be..2939a5a 100644 --- a/src/renderer/src/extensions/mod_management/util/externalChanges.test.ts +++ b/src/renderer/src/extensions/mod_management/util/externalChanges.test.ts -@@ -1,3 +1,5 @@ -+import * as path from "path"; -+ - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +@@ -1,4 +1,4 @@ +-import { existsSync, rmSync } from "node:fs"; ++import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; + import * as path from "node:path"; - import type { -@@ -8,11 +10,14 @@ import type { - } from "../../../types/IExtensionContext"; - import type { IState } from "../../../types/IState"; + import { +@@ -24,7 +24,12 @@ import { + } from "../../../test-utils/externalChangesTest"; import { MERGED_PATH } from "../modMerging"; -+import type { IFileEntry } from "../types/IFileEntry"; - - // Mock fs. applyFileActions inside dealWithExternalChanges calls into it - // whenever any auto-resolved changes are present. Real fs ops would fail in a - // unit test, so make every call a no-op. - vi.mock("../../../util/fs", () => ({ -+ copyAsync: vi.fn(() => Promise.resolve()), -+ ensureDirAsync: vi.fn(() => Promise.resolve()), - removeAsync: vi.fn(() => Promise.resolve()), - moveAsync: vi.fn(() => Promise.resolve()), - statAsync: vi.fn(() => Promise.resolve({ mtime: new Date(0) })), -@@ -49,8 +54,9 @@ vi.mock("../actions/session", () => ({ - }), - })); + import { deployedFilePath } from "./deployedFilePath"; +-import { changesBySource, changeToEntry, dealWithExternalChanges } from "./externalChanges"; ++import { ++ applyFileActions, ++ changesBySource, ++ changeToEntry, ++ dealWithExternalChanges, ++} from "./externalChanges"; -+import * as mockedFS from "../../../util/fs"; - import InstallManager from "../InstallManager"; --import { dealWithExternalChanges } from "./externalChanges"; -+import { applyFileActions, changeToEntry, dealWithExternalChanges } from "./externalChanges"; + vi.mock("../../../logging", () => { + const log = vi.fn(); +@@ -104,6 +109,67 @@ describe("changeToEntry", () => { - function makeRefchange(source: string, filePath: string): IFileChange { - return { -@@ -113,6 +119,62 @@ const FAKE_MOD_PATHS = { "": "C:\\game\\Data" }; - // are produced by the (mocked) activator. - const FAKE_LAST_DEPLOYMENT: { [typeId: string]: IDeployedFile[] } = { "": [] }; - -+describe("external change safety defaults", () => { -+ it("keeps staging authoritative unless the user explicitly saves an external change", () => { -+ expect(changeToEntry("", makeRefchange("mod", "file.esm")).action).toBe("drop"); -+ expect( -+ changeToEntry("", { -+ filePath: "file.esm", -+ source: "mod", -+ changeType: "deleted", -+ }).action, -+ ).toBe("restore"); -+ expect( -+ changeToEntry("", { -+ filePath: "file.esm", -+ source: "mod", -+ changeType: "srcdeleted", -+ }).action, -+ ).toBe("import"); + expect(changeToEntry("", change, existing).action).toBe("drop"); + }); ++ ++ it("preselects keeping the staged file over a deployed file replaced on disk", () => { ++ const change = makeFileChange({ changeType: "refchange", source: MOD_A_FOLDER }); ++ ++ expect(changeToEntry("", change, existing).action).toBe("drop"); + }); ++}); + -+ it("imports through an independent temporary copy without deleting staging first", async () => { -+ const { api } = makeApi({ externalChanges: [] }); -+ const entry: IFileEntry = { -+ modTypeId: "", -+ filePath: "file.esm", -+ source: "translated-mod", -+ type: "refchange", -+ action: "import", -+ sourceModified: new Date(0), -+ destModified: new Date(1), -+ }; -+ const source = path.join(FAKE_STAGING, entry.source, entry.filePath); -+ const deployed = path.join(FAKE_MOD_PATHS[""], entry.filePath); ++describe("applyFileActions import", () => { ++ const importEntry = (filePath: string) => ({ ++ modTypeId: "", ++ filePath, ++ source: MOD_A_FOLDER, ++ type: "refchange" as const, ++ action: "import" as const, ++ sourceModified: new Date(0), ++ destModified: new Date(1), ++ }); + -+ await applyFileActions(api, "test-profile", FAKE_STAGING, FAKE_MOD_PATHS[""], [], [entry]); ++ test("moves the deployed file into staging", async ({ makeExternalChanges }) => { ++ const h = await makeExternalChanges([], MODS_WITH_A); ++ await h.stageFile(MOD_A_FOLDER, "plugin.esp"); ++ await h.deployFile("plugin.esp"); ++ writeFileSync(path.join(h.gameDir, "plugin.esp"), "edited in the game folder"); + -+ const copy = vi.mocked(mockedFS.copyAsync); -+ const move = vi.mocked(mockedFS.moveAsync); -+ const remove = vi.mocked(mockedFS.removeAsync); -+ const temporary = copy.mock.calls[0][1]; ++ await applyFileActions( ++ h.api, ++ "skyrimse", ++ new Map([[MOD_A_FOLDER, MOD_A]]), ++ VortexStagingFolder(h.stagingPath), ++ h.gameDir, ++ [], ++ [importEntry("plugin.esp")], ++ ); + -+ expect(copy).toHaveBeenCalledWith( -+ deployed, -+ expect.stringContaining(path.join(FAKE_STAGING, ".vortex-import")), -+ { overwrite: false }, ++ expect(readFileSync(path.join(h.stagingPath, MOD_A_FOLDER, "plugin.esp"), "utf8")).toBe( ++ "edited in the game folder", + ); -+ expect(move).toHaveBeenCalledWith(temporary, source, { overwrite: true }); -+ expect(remove.mock.calls.map(([filePath]) => filePath)).not.toContain(source); -+ expect(copy.mock.invocationCallOrder[0]).toBeLessThan(move.mock.invocationCallOrder[0]); -+ expect(api.events.emit).toHaveBeenCalledWith( -+ "mod-content-changed", ++ expect(existsSync(path.join(h.gameDir, "plugin.esp"))).toBe(false); ++ // nothing left for refreshMods to mistake for a mod ++ expect(readdirSync(h.stagingPath)).toEqual([MOD_A_FOLDER]); ++ }); ++ ++ test("keeps the staged file when the deployed file is gone", async ({ ++ makeExternalChanges, ++ }) => { ++ const h = await makeExternalChanges([], MODS_WITH_A); ++ await h.stageFile(MOD_A_FOLDER, "plugin.esp"); ++ ++ await applyFileActions( ++ h.api, + "skyrimse", -+ "translated-mod", ++ new Map([[MOD_A_FOLDER, MOD_A]]), ++ VortexStagingFolder(h.stagingPath), ++ h.gameDir, ++ [], ++ [importEntry("plugin.esp")], + ); -+ }); -+}); + ++ expect(existsSync(path.join(h.stagingPath, MOD_A_FOLDER, "plugin.esp"))).toBe(true); ++ }); + }); + describe("dealWithExternalChanges", () => { - beforeEach(() => { - showExternalChangesCalls.length = 0; diff --git a/src/renderer/src/extensions/mod_management/util/externalChanges.ts b/src/renderer/src/extensions/mod_management/util/externalChanges.ts -index 8ead7b4..b5187d1 100644 +index 0e2a7c3..cb828b1 100644 --- a/src/renderer/src/extensions/mod_management/util/externalChanges.ts +++ b/src/renderer/src/extensions/mod_management/util/externalChanges.ts -@@ -1,6 +1,7 @@ - import * as path from "path"; - - import { getErrorCode, unknownToError } from "@vortex/shared"; +@@ -9,6 +9,7 @@ import { + type VortexProfileId, + type VortexStagingFolder, + } from "@vortex/shared"; +import { generate as shortid } from "shortid"; import { log } from "../../../logging"; import type { -@@ -34,7 +35,7 @@ import type { FileAction, IFileEntry } from "../types/IFileEntry"; +@@ -44,7 +45,7 @@ import { deployedFileKey, deployedFilePath } from "./deployedFilePath"; * @returns {Promise} an updated deployment manifest to use as a reference * for the new one */ -async function applyFileActions( +export async function applyFileActions( api: IExtensionApi, - profileId: string, - sourcePath: string, -@@ -82,24 +83,32 @@ async function applyFileActions( + gameId: string, + modIdByFolder: ReadonlyMap, +@@ -95,27 +96,38 @@ async function applyFileActions( + ), ); ++ // refreshMods skips __vortex folders, so the temporary copies never look like a mod ++ const importFolder = path.join(sourcePath, "__vortex_import"); await Promise.all( - (actionGroups["import"] || []).map((entry) => { + (actionGroups["import"] || []).map(async (entry) => { const source = path.join(sourcePath, entry.source, entry.filePath); - const deployed = path.join(outputPath, entry.filePath); + const deployed = deployedPath(entry); - // Very rarely we have a case where the files are links of each other - // (or at least node reports that) so the copy would fail. - // Instead of handling the errors (when we can't be sure if it's due to a bug in node.js @@ -230,11 +226,7 @@ index 8ead7b4..b5187d1 100644 - throw err; - } - }); -+ const temporary = path.join( -+ sourcePath, -+ ".vortex-import", -+ `${shortid()}-${path.basename(entry.filePath)}`, -+ ); ++ const temporary = path.join(importFolder, `${shortid()}-${path.basename(entry.filePath)}`); + + try { + // A deployed hardlink may be the same inode as the staging file. Copy @@ -247,6 +239,8 @@ index 8ead7b4..b5187d1 100644 + await fs.moveAsync(temporary, source, { overwrite: true }); + await fs.removeAsync(deployed); + } catch (err) { ++ // the deployed file is only removed last, so the copy is never the only one ++ await fs.removeAsync(temporary).catch(() => undefined); + if (getErrorCode(err) === "ENOENT") { + log("warn", "file disappeared", unknownToError(err).message); + } else { @@ -255,33 +249,21 @@ index 8ead7b4..b5187d1 100644 + } }), ); ++ if ((actionGroups["import"] || []).length > 0) { ++ await fs.rmdirAsync(importFolder).catch(() => undefined); ++ } -@@ -141,7 +150,7 @@ async function applyFileActions( - if (profileId !== undefined) { - const profile = profileById(state, profileId); - if (profile !== undefined) { -- gameId = profile.id; -+ gameId = profile.gameId; - } - } - -@@ -163,13 +172,13 @@ async function applyFileActions( - function defaultAction(changeType: string): FileAction { - switch (changeType) { + // remove files that the user wants to restore from + // the activation list because then they get reinstalled. +@@ -173,7 +185,7 @@ function defaultAction( + ): FileAction { + switch (change.changeType) { case "refchange": - return "newest"; + return "drop"; case "valchange": return "nop"; case "deleted": -- return "delete"; -+ return "restore"; - case "srcdeleted": -- return "drop"; -+ return "import"; - default: - throw new Error("invalid file change " + changeType); - } diff --git a/src/renderer/src/extensions/mod_management/util/getDeploymentNormalizeFunc.ts b/src/renderer/src/extensions/mod_management/util/getDeploymentNormalizeFunc.ts new file mode 100644 index 0000000..d1e1282 @@ -387,7 +369,7 @@ index 82ebf60..a4f3dc6 100644 return PromiseBB.reject(restackErr(err, stackErr)); } diff --git a/src/renderer/src/util/linuxCompatibility.test.ts b/src/renderer/src/util/linuxCompatibility.test.ts -index c2637d3..4821f76 100644 +index 2487d41..df4d17e 100644 --- a/src/renderer/src/util/linuxCompatibility.test.ts +++ b/src/renderer/src/util/linuxCompatibility.test.ts @@ -40,17 +40,26 @@ describe.runIf(process.platform === "linux")("linuxCompatibility", () => { @@ -420,10 +402,10 @@ index c2637d3..4821f76 100644 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 -index 6d82ea0..167f7b6 100644 +index 107376d..11ada8a 100644 --- a/src/renderer/src/util/linuxCompatibility.ts +++ b/src/renderer/src/util/linuxCompatibility.ts -@@ -88,20 +88,29 @@ export function resolveCaseInsensitiveSync(input: string): string { +@@ -97,20 +97,29 @@ export function resolveCaseInsensitiveSync(input: string): string { let current = root; try { diff --git a/patches/0015-linux-dotnet-game-version.patch b/patches/0015-linux-dotnet-game-version.patch index 72e6485..037da98 100644 --- a/patches/0015-linux-dotnet-game-version.patch +++ b/patches/0015-linux-dotnet-game-version.patch @@ -104,7 +104,7 @@ index 0000000..e31cf64 + }); +}); diff --git a/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts b/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts -index eb8c9f0..ce04f45 100644 +index d797474..cb0965c 100644 --- a/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts +++ b/src/renderer/src/extensions/gamemode_management/util/getGameVersion.ts @@ -1,4 +1,5 @@ @@ -114,12 +114,10 @@ index eb8c9f0..ce04f45 100644 import { getErrorMessageOrDefault } from "@vortex/shared"; import { ProcessCanceled } from "@vortex/shared/errors"; -@@ -11,6 +12,103 @@ import { resolvePlatformPath } from "../../../util/nativeExecutable"; +@@ -11,6 +12,102 @@ import { resolvePlatformPath } from "../../../util/nativeExecutable"; import type { IGame } from "../../../types/IGame"; import type { IDiscoveryResult } from "../types/IDiscoveryResult"; -+const UNKNOWN_VERSION = "0.0.0"; -+ +function normalizedApplicationName(input: string): string { + return input.toLowerCase().replace(/[\s._-]/g, ""); +} @@ -206,33 +204,42 @@ index eb8c9f0..ce04f45 100644 + return bestMatch?.version; +} + -+function executableVersion(executablePath: string): string { ++/** The version resource; on Linux null for a placeholder, so the deps manifest can answer. */ ++function executableVersion(executablePath: string): string | null { + try { + const version = exeVersion(executablePath); -+ return knownVersion(version) ? version : UNKNOWN_VERSION; ++ return process.platform !== "linux" || knownVersion(version) ? version : null; + } catch { -+ return UNKNOWN_VERSION; ++ return null; + } +} + export type GameVersionResolver = (game: IGame, discovery: IDiscoveryResult) => Promise; - async function fromExtension(game: IGame, discovery: IDiscoveryResult): Promise { -@@ -22,10 +120,15 @@ async function fromExecutable(game: IGame, discovery: IDiscoveryResult): Promise + /** The version the game extension reports through getGameVersion; null when the +@@ -41,16 +138,23 @@ async function fromExtension(game: IGame, discovery: IDiscoveryResult): Promise< + return null; + } + +-/** The version attribute of the discovered executable; null when it can't be read. */ ++/** The version attribute of the discovered executable; null when it can't be read. ++ Native Linux .NET launchers have no version resource, so their deps manifest is ++ read instead. */ + async function fromExecutable(game: IGame, discovery: IDiscoveryResult): Promise { + const exePath = resolvePlatformPath(discovery.path, discovery.executable || game.executable()); try { await stat(exePath); - return exeVersion(exePath); } catch { -- return "0.0.0"; -+ return UNKNOWN_VERSION; -+ } + return null; + } + + const version = executableVersion(exePath); -+ if (version !== UNKNOWN_VERSION || process.platform !== "linux") { ++ if (version !== null || process.platform !== "linux") { + return version; - } -+ return (await getDotNetGameVersion(discovery.path, exePath)) ?? UNKNOWN_VERSION; ++ } ++ return (await getDotNetGameVersion(discovery.path, exePath)) ?? null; } - export const resolveGameVersion: GameVersionResolver = async (game, discovery) => { + function assertDiscovered(game: IGame, discovery: IDiscoveryResult): void { diff --git a/patches/0020-linux-native-loot.patch b/patches/0020-linux-native-loot.patch index 1c34636..b6e193f 100644 --- a/patches/0020-linux-native-loot.patch +++ b/patches/0020-linux-native-loot.patch @@ -1,16 +1,16 @@ diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts b/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts -index b37fb28..e9a144e 100644 +index 743cde6..9bb2125 100644 --- a/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts +++ b/src/renderer/src/extensions/gamebryo_plugin_management/autosort.ts -@@ -16,6 +16,7 @@ import type { IExtensionApi } from "../../types/IExtensionContext"; - import { fileMD5 } from "../../util/checksum"; +@@ -17,6 +17,7 @@ import { fileMD5 } from "../../util/checksum"; + import { recordErrorSpan } from "../../util/errorHandling"; import * as fs from "../../util/fs"; import getVortexPath from "../../util/getVortexPath"; +import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; import { getSafe } from "../../util/storeHelper"; import { batchDispatch } from "../../util/util"; import { webpackRequireHack } from "../../util/webpack-hacks"; -@@ -39,6 +40,7 @@ import { +@@ -40,6 +41,7 @@ import { } from "./util/gameSupport"; import { isGhosted } from "./util/ghost"; import { missingGroupFixes } from "./util/groups"; @@ -18,22 +18,25 @@ index b37fb28..e9a144e 100644 import { lootErrorReporter, LootPhase } from "./util/LootErrorReporter"; import { toLootError } from "./util/lootErrors"; import { downloadMasterlist, downloadPrelude } from "./util/masterlist"; -@@ -853,11 +855,15 @@ class LootInterface { - loot = await getLootAsync().create( - this.convertGameId(gameMode, false), - gamePath, -- localPath, -+ // libloot reads plugins.txt itself, so it needs the folder inside the Proton prefix. -+ process.platform === "linux" ? resolveCaseInsensitiveSync(localPath) : localPath, - "en", - this.logCB, - (modulePath, args) => this.fork(modulePath, args, () => loot?.isClosed() === true), - ); +@@ -873,7 +875,8 @@ class LootInterface { + getLootAsync().create( + this.convertGameId(gameMode, false), + gamePath, +- localPath, ++ // libloot reads plugins.txt itself, so it needs the folder inside the Proton prefix. ++ process.platform === "linux" ? resolveCaseInsensitiveSync(localPath) : localPath, + "en", + this.logCB, + (modulePath, args) => +@@ -883,6 +886,9 @@ class LootInterface { + throw new VortexError("LOOT process ended", { kind: "loot:process-died", call: "init" }); + }), + ]); + if (process.platform === "linux") { + loot = adaptLootForLinux(loot, () => gameDataPath(gameMode)); + } } catch (rawErr) { - const err = toLootError(rawErr); + const err = rawErr instanceof VortexError ? rawErr : toLootError(rawErr); log("error", "failed to initialize LOOT", { kind: err.data.kind, error: err.message }); diff --git a/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts b/src/renderer/src/extensions/gamebryo_plugin_management/util/linuxLoot.test.ts new file mode 100644 diff --git a/patches/0022-linux-heroic-stores.patch b/patches/0022-linux-heroic-stores.patch index cce285b..ff8bc8d 100644 --- a/patches/0022-linux-heroic-stores.patch +++ b/patches/0022-linux-heroic-stores.patch @@ -1,8 +1,8 @@ diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts -index fee040e..2139e65 100644 +index 7f73115..f3a3c89 100644 --- a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +++ b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts -@@ -26,6 +26,7 @@ import { getNormalizeFunc } from "../../util/api"; +@@ -27,6 +27,7 @@ import { getNormalizeFunc } from "../../util/api"; import { ProcessCanceled, SetupError, UserCanceled } from "../../util/CustomErrors"; import EpicGamesLauncher from "../../util/EpicGamesLauncher"; import * as fs from "../../util/fs"; @@ -10,7 +10,7 @@ index fee040e..2139e65 100644 import { activeProfile, discoveryByGame } from "../../util/selectors"; import Steam from "../../util/Steam"; import { normalizeStoreQuery } from "../../util/storeQuery"; -@@ -82,6 +83,7 @@ class GameModeManager { +@@ -84,6 +85,7 @@ export class GameModeManager { OriginLauncher.create(), UPlayLauncher.create(), XboxLauncher.create(api), @@ -72,18 +72,18 @@ index 0000000..9ce6155 + }); +}); diff --git a/src/renderer/src/util/EpicGamesLauncher.ts b/src/renderer/src/util/EpicGamesLauncher.ts -index 8c577f7..90d780d 100644 +index a461f68..36c4ad6 100644 --- a/src/renderer/src/util/EpicGamesLauncher.ts +++ b/src/renderer/src/util/EpicGamesLauncher.ts -@@ -10,6 +10,7 @@ import type { IGameStore, IGameStoreSnapshot } from "../types/IGameStore"; +@@ -10,6 +10,7 @@ import type { IExtensionApi } from "../types/IExtensionContext"; + import type { IGameStore, IGameStoreSnapshot } from "../types/IGameStore"; import { GameEntryNotFound } from "../types/IGameStore"; import type { IGameStoreEntry } from "../types/IGameStoreEntry"; - import * as fs from "./fs"; +import { HeroicStore } from "./Heroic"; - import lazyRequire from "./lazyRequire"; - import opn from "./opn"; -@@ -243,6 +244,10 @@ const manifestSchema = z.looseObject({ + const ITEM_EXT = "item"; + const STORE_ID = "epic"; +@@ -254,6 +255,10 @@ const manifestSchema = z.looseObject({ }); const instance: IGameStore | undefined = @@ -97,6 +97,7 @@ index 8c577f7..90d780d 100644 export default instance; diff --git a/src/renderer/src/util/Heroic.test.ts b/src/renderer/src/util/Heroic.test.ts new file mode 100644 +index 0000000..7d5c193 --- /dev/null +++ b/src/renderer/src/util/Heroic.test.ts @@ -0,0 +1,223 @@ @@ -325,11 +326,10 @@ new file mode 100644 +}); diff --git a/src/renderer/src/util/Heroic.ts b/src/renderer/src/util/Heroic.ts new file mode 100644 +index 0000000..32837bb --- /dev/null +++ b/src/renderer/src/util/Heroic.ts -@@ -0,0 +1,107 @@ -+import Bluebird from "bluebird"; -+ +@@ -0,0 +1,103 @@ +import type { IExtensionApi } from "../types/IExtensionContext"; +import { GameEntryNotFound, GameStoreNotFound, type IGameStore, type IGameStoreSnapshot } from "../types/IGameStore"; +import { registerCompatibilityStore, resetCompatibilityPathCache } from "./compatibilityPaths"; @@ -360,86 +360,84 @@ new file mode 100644 + }); + registerGameToolRunner(id, heroicToolCommand); + } -+ public allGames(): Bluebird { -+ return Bluebird.try(() => { -+ if (this.games === undefined) { -+ this.games = readHeroicGames().filter((entry) => entry.gameStoreId === this.id); -+ this.installed = heroicRoots().some((root) => heroicLauncher(root.flatpak) !== undefined); -+ } -+ return this.games; -+ }); ++ public async allGames(): Promise { ++ if (this.games === undefined) { ++ this.games = readHeroicGames().filter((entry) => entry.gameStoreId === this.id); ++ this.installed = heroicRoots().some((root) => heroicLauncher(root.flatpak) !== undefined); ++ } ++ return this.games; + } + public snapshot(): IGameStoreSnapshot { + return { entries: this.games ?? [], isInstalled: this.installed }; + } -+ public reloadGames(): Bluebird { ++ public async reloadGames(): Promise { + this.games = undefined; + resetCompatibilityPathCache(); -+ return this.allGames().then(() => undefined); ++ await this.allGames(); + } -+ private find(predicate: (game: HeroicGame) => boolean, query: string): Bluebird { -+ return this.allGames().then((games) => { -+ const matches = games.filter(predicate); -+ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); -+ if (matches.length > 1) -+ throw new Error( -+ "Several Heroic installations match this game. Select the game directory manually.", -+ ); -+ return matches[0]; -+ }); ++ private async find( ++ predicate: (game: HeroicGame) => boolean, ++ query: string, ++ ): Promise { ++ const games = await this.allGames(); ++ const matches = games.filter(predicate); ++ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); ++ if (matches.length > 1) ++ throw new Error( ++ "Several Heroic installations match this game. Select the game directory manually.", ++ ); ++ return matches[0]; + } -+ public findByAppId(id: string | string[]): Bluebird { ++ public findByAppId(id: string | string[]): Promise { + const ids = Array.isArray(id) ? id : [id]; + return this.find((game) => ids.includes(game.appid), ids.join(", ")); + } -+ public findByName(name: string): Bluebird { -+ return Bluebird.try(() => new RegExp(`^(?:${name})$`, "i")).then((pattern) => -+ this.find((game) => pattern.test(game.name), name), -+ ); ++ public async findByName(name: string): Promise { ++ const pattern = new RegExp(`^(?:${name})$`, "i"); ++ return this.find((game) => pattern.test(game.name), name); + } -+ public getGameStorePath(): Bluebird { -+ return Bluebird.resolve( -+ heroicRoots() -+ .map((root) => heroicLauncher(root.flatpak)?.executable) -+ .find(Boolean), -+ ); ++ public async getGameStorePath(): Promise { ++ return heroicRoots() ++ .map((root) => heroicLauncher(root.flatpak)?.executable) ++ .find(Boolean); + } -+ public isGameStoreInstalled(): Bluebird { -+ return this.getGameStorePath().then(Boolean); ++ public async isGameStoreInstalled(): Promise { ++ return Boolean(await this.getGameStorePath()); + } -+ public isGameInstalled(name: string): Bluebird { -+ return this.findByName(name) -+ .then(() => true) -+ .catch(GameEntryNotFound, () => false); ++ public async isGameInstalled(name: string): Promise { ++ try { ++ await this.findByName(name); ++ return true; ++ } catch (err) { ++ if (err instanceof GameEntryNotFound) { ++ return false; ++ } ++ throw err; ++ } + } -+ public launchGame( ++ public async launchGame( + appInfo: string | { appId: string; parameters?: string[] }, + api?: IExtensionApi, -+ ): Bluebird { -+ return this.isGameStoreInstalled() -+ .then((installed) => { -+ if (!installed) throw new GameStoreNotFound(this.name); -+ return this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); -+ }) -+ .then((game) => { -+ if (!api) throw new Error("Heroic launch requires the Vortex application API"); -+ const command = heroicLaunchCommand( -+ game, -+ typeof appInfo === "string" ? [] : appInfo.parameters, -+ ); -+ return api.runExecutable(command.executable, command.args, { -+ ...command.options, -+ cwd: game.gamePath, -+ suggestDeploy: true, -+ }); -+ }); ++ ): Promise { ++ if (!(await this.isGameStoreInstalled())) throw new GameStoreNotFound(this.name); ++ const game = await this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); ++ if (!api) throw new Error("Heroic launch requires the Vortex application API"); ++ const command = heroicLaunchCommand( ++ game, ++ typeof appInfo === "string" ? [] : appInfo.parameters, ++ ); ++ await api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: game.gamePath, ++ suggestDeploy: true, ++ }); + } +} diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts -index 338e8f7..4ee8b14 100644 +index ae2b387..dc45968 100644 --- a/src/renderer/src/util/StarterInfo.ts +++ b/src/renderer/src/util/StarterInfo.ts -@@ -25,6 +25,7 @@ import { +@@ -24,6 +24,7 @@ import { } from "./CustomErrors"; import { emitGameLaunched, recordLaunchExit } from "./gameLaunchAnalytics"; import getVortexPath from "./getVortexPath"; @@ -447,7 +445,7 @@ index 338e8f7..4ee8b14 100644 import { resolvePlatformPath } from "./nativeExecutable"; import type { Steam, ISteamEntry } from "./Steam"; import { getSafe } from "./storeHelper"; -@@ -250,6 +251,33 @@ class StarterInfo implements IStarterInfo { +@@ -282,6 +283,33 @@ class StarterInfo implements IStarterInfo { } }; diff --git a/patches/0029-linux-prefix-config-purge.patch b/patches/0029-linux-prefix-config-purge.patch index 818e6e4..61b2476 100644 --- a/patches/0029-linux-prefix-config-purge.patch +++ b/patches/0029-linux-prefix-config-purge.patch @@ -1,84 +1,18 @@ -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 -@@ -13,6 +13,7 @@ - import { getGame, UserCanceled } from "../../util/api"; - import * as fs from "../../util/fs"; - import type { Normalize } from "../../util/getNormalizeFunc"; -+import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; - import { activeGameId } from "../../util/selectors"; - import { truthy } from "../../util/util"; - import type { IDeploymentFailure } from "./actions/session"; -@@ -435,9 +436,13 @@ - const game = getGame(gameId); - const directoryCleaning = game.directoryCleaning || "tag"; - -- // stat to ensure the target directory exists -- return Promise.resolve( -- fs -+ return Promise.resolve().then(() => { -+ // Link scanners use raw filesystem APIs, unlike fs.statAsync. -+ // Resolve both roots so purge reaches the same files as deployment. -+ installPath = resolveCaseInsensitiveSync(installPath); -+ dataPath = resolveCaseInsensitiveSync(dataPath); -+ // A missing selected prefix must fail, rather than count as an empty target. -+ return fs - .statAsync(dataPath) - .then(() => this.purgeLinks(installPath, dataPath, onProgress)) - .then(() => this.postLinkPurge(dataPath, false, true, directoryCleaning)) -@@ -447,8 +452,8 @@ - return Promise.resolve(undefined); - } - return Promise.reject(err); -- }), -- ); -+ }); -+ }); - } - - public postPurge(): PromiseLike { -@@ -851,15 +856,18 @@ - (entry) => !entry.isDirectory && LinkingActivator.isTagName(entry.filePath), - ) !== undefined; - -- return Promise.resolve( -- turbowalk( -- baseDir, -- (entries) => { -- allEntries = allEntries.concat(entries); -- }, -- { recurse: false, skipHidden: false, skipLinks: false }, -- ), -- ) -+ return Promise.resolve() -+ .then(() => { -+ // finalize also calls this directly with the game's logical mod path. -+ baseDir = resolveCaseInsensitiveSync(baseDir); -+ return turbowalk( -+ baseDir, -+ (entries) => { -+ allEntries = allEntries.concat(entries); -+ }, -+ { recurse: false, skipHidden: false, skipLinks: false }, -+ ); -+ }) - .then(() => { - doRemove = doRemove || taggedForRemoval(allEntries); - diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts new file mode 100644 --- /dev/null +++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts -@@ -0,0 +1,163 @@ +@@ -0,0 +1,213 @@ +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 { IExtensionApi } from "../../types/IExtensionContext"; +import type { IGame } from "../../types/IGame"; +import { setCompatibilityGameProvider } from "../../util/compatibilityPaths"; -+import { saveActivation } from "./util/activationStore"; ++import { filesStillDeployed, ownDeployedFiles, saveActivation } from "./util/activationStore"; +import BlacklistSet from "./util/BlacklistSet"; + +afterEach(() => { @@ -148,6 +82,13 @@ new file mode 100644 + expect( + JSON.parse(nodeFs.readFileSync(path.join(prefixConfig, manifest), "utf8")).files, + ).toHaveLength(2); ++ expect(await filesStillDeployed(hostConfig, deployed)).toHaveLength(2); ++ const instanceApi = { ++ getState: () => ({ app: { instanceId: "test-instance" } }), ++ } as unknown as IExtensionApi; ++ expect(await ownDeployedFiles(instanceApi, modType, hostConfig, h.stagingDir)).toHaveLength( ++ 2, ++ ); + + await h.method.prePurge(h.stagingDir); + await h.method.purge(h.stagingDir, hostConfig, gameId); @@ -233,3 +174,192 @@ new file mode 100644 + ); + }, +); ++ ++test.runIf(process.platform === "linux")( ++ "repairs deployment times that earlier Linux builds stored a thousand times too large", ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "plugin.esp": "plugin" } }); ++ const deployed = await h.deploy(); ++ const stored = deployed.map((file) => ({ ...file, time: file.time * 1000 })); ++ await saveActivation( ++ "skyrimse", ++ "", ++ "test-instance", ++ h.gameDir, ++ h.stagingDir, ++ stored, ++ h.method.id, ++ ); ++ const instanceApi = { ++ getState: () => ({ app: { instanceId: "test-instance" } }), ++ } as unknown as IExtensionApi; ++ ++ const read = await ownDeployedFiles(instanceApi, "", h.gameDir, h.stagingDir); ++ ++ expect(read.map((file) => file.time)).toEqual(deployed.map((file) => file.time)); ++ expect(await filesStillDeployed(h.gameDir, read)).toHaveLength(1); ++ }, ++); ++ ++test.runIf(process.platform === "linux")( ++ "keeps an earlier backup whose name differs in case", ++ async ({ makeDeployment }) => { ++ const h = makeDeployment({ files: { "D3D9.dll": "mod" } }); ++ nodeFs.writeFileSync(h.inGame("D3D9.dll"), "game"); ++ nodeFs.writeFileSync(h.inGame("d3d9.dll.vortex_backup"), "first original"); ++ ++ await h.deploy().catch(() => undefined); ++ ++ expect(nodeFs.readFileSync(h.inGame("d3d9.dll.vortex_backup"), "utf8")).toBe( ++ "first original", ++ ); ++ expect(nodeFs.readFileSync(h.inGame("D3D9.dll"), "utf8")).toBe("game"); ++ }, ++); +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 +@@ -14,6 +14,7 @@ import type { IState } from "../../types/IState"; + import { getGame, UserCanceled } from "../../util/api"; + import * as fs from "../../util/fs"; + import type { Normalize } from "../../util/getNormalizeFunc"; ++import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; + import { activeGameId } from "../../util/selectors"; + import { truthy } from "../../util/util"; + import type { IDeploymentFailure } from "./actions/session"; +@@ -47,9 +48,18 @@ async function isAbsent(filePath: string): Promise { + } + } + ++/** turbowalk reports mtime in seconds, but outside Windows it passes lstat's Date. */ ++function entryTime(entry: IEntry): number { ++ const mtime = entry.mtime as number | Date; ++ return mtime instanceof Date ? mtime.getTime() : mtime * 1000; ++} ++ + /** Moves a file aside for a link, refusing when an earlier backup already holds the name. */ + async function backUpGameFile(filePath: string): Promise { +- const backupPath = filePath + BACKUP_TAG; ++ // lstat below bypasses util/fs, so map a Windows user folder into the Wine prefix ++ // and find an earlier backup in another case here, as the rename would ++ filePath = resolveCaseInsensitiveSync(filePath); ++ const backupPath = resolveCaseInsensitiveSync(filePath + BACKUP_TAG); + if (await isAbsent(filePath)) { + return; + } +@@ -399,7 +409,7 @@ abstract class LinkingActivator implements IDeploymentMethod { + relPath, + source: sourceName, + target: deployPath, +- time: entry.mtime * 1000, ++ time: entryTime(entry), + }; + } + }); +@@ -463,9 +473,13 @@ abstract class LinkingActivator implements IDeploymentMethod { + const game = getGame(gameId); + const directoryCleaning = game.directoryCleaning || "tag"; + +- // stat to ensure the target directory exists +- return Promise.resolve( +- fs ++ return Promise.resolve().then(() => { ++ // Link scanners use raw filesystem APIs, unlike fs.statAsync. ++ // Resolve both roots so purge reaches the same files as deployment. ++ installPath = resolveCaseInsensitiveSync(installPath); ++ dataPath = resolveCaseInsensitiveSync(dataPath); ++ // A missing selected prefix must fail, rather than count as an empty target. ++ return fs + .statAsync(dataPath) + .then(() => this.purgeLinks(installPath, dataPath, onProgress)) + .then(() => this.postLinkPurge(dataPath, false, true, directoryCleaning)) +@@ -475,8 +489,8 @@ abstract class LinkingActivator implements IDeploymentMethod { + return Promise.resolve(undefined); + } + return Promise.reject(err); +- }), +- ); ++ }); ++ }); + } + + public postPurge(): PromiseLike { +@@ -877,15 +891,18 @@ abstract class LinkingActivator implements IDeploymentMethod { + (entry) => !entry.isDirectory && LinkingActivator.isTagName(entry.filePath), + ) !== undefined; + +- return Promise.resolve( +- turbowalk( +- baseDir, +- (entries) => { +- allEntries = allEntries.concat(entries); +- }, +- { recurse: false, skipHidden: false, skipLinks: false }, +- ), +- ) ++ return Promise.resolve() ++ .then(() => { ++ // finalize also calls this directly with the game's logical mod path. ++ baseDir = resolveCaseInsensitiveSync(baseDir); ++ return turbowalk( ++ baseDir, ++ (entries) => { ++ allEntries = allEntries.concat(entries); ++ }, ++ { recurse: false, skipHidden: false, skipLinks: false }, ++ ); ++ }) + .then(() => { + doRemove = doRemove || taggedForRemoval(allEntries); + +diff --git a/src/renderer/src/extensions/mod_management/util/activationStore.ts b/src/renderer/src/extensions/mod_management/util/activationStore.ts +--- a/src/renderer/src/extensions/mod_management/util/activationStore.ts ++++ b/src/renderer/src/extensions/mod_management/util/activationStore.ts +@@ -16,6 +16,7 @@ import * as fs from "../../../util/fs"; + import { writeFileAtomic } from "../../../util/fsAtomic"; + import getVortexPath from "../../../util/getVortexPath"; + import type { TFunction } from "../../../util/i18n"; ++import { resolveCaseInsensitiveSync } from "../../../util/linuxCompatibility"; + import { log } from "../../../util/log"; + import { activeGameId, discoveryByGame, installPathForGame } from "../../../util/selectors"; + import { getSafe } from "../../../util/storeHelper"; +@@ -52,6 +53,10 @@ function emptyManifest(instance: string): IDeploymentManifest { + }; + } + ++// Earlier Linux builds stored deployment times a thousand times too large ++// (turbowalk's Date multiplied as if it were seconds); no real time in ms gets here. ++const IMPLAUSIBLE_TIME = 1e14; ++ + /** + * since the manifest is read from disc, it could have been modified by the user. + * Check it for correctness +@@ -75,7 +80,9 @@ function repairManifest(input: IDeploymentManifest): IDeploymentManifest { + file.time !== undefined && + file.time !== null + ) { +- prev.push(file); ++ prev.push( ++ file.time > IMPLAUSIBLE_TIME ? { ...file, time: Math.round(file.time / 1000) } : file, ++ ); + } + return prev; + }, [] as IDeployedFile[]); +@@ -129,6 +136,8 @@ export async function filesStillDeployed( + files: IDeployedFile[], + ): Promise { + const limit = new ConcurrencyLimiter(PRESENCE_CHECK_CONCURRENCY); ++ // lstat bypasses util/fs, so map a Windows user folder into the Wine prefix once ++ basePath = resolveCaseInsensitiveSync(basePath); + const present = await Promise.all( + files.map((file) => + limit.do(() => +@@ -429,7 +438,7 @@ export async function ownDeployedFiles( + let manifest: IDeploymentManifest | undefined; + try { + const { manifestPath } = manifestPaths(deployPath, stagingPath, modType); +- manifest = readManifest(await readFile(manifestPath, "utf8")); ++ manifest = readManifest(await readFile(resolveCaseInsensitiveSync(manifestPath), "utf8")); + } catch { + manifest = undefined; + } diff --git a/patches/0030-linux-extension-config-paths.patch b/patches/0030-linux-extension-config-paths.patch index 61bf801..4c356e1 100644 --- a/patches/0030-linux-extension-config-paths.patch +++ b/patches/0030-linux-extension-config-paths.patch @@ -53,14 +53,18 @@ diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util for (const [index, part] of parts.entries()) { const exact = path.join(current, part); if (fs.existsSync(exact)) { -@@ -299,6 +318,24 @@ +@@ -299,6 +318,28 @@ } export function resolveWindowsUserPathSync(input: string): string { + return resolveUserPathSync(input, false); +} + -+/** Resolve Windows-only roots for extension filesystem calls, including the root itself. */ ++/** ++ * Resolve an extension's own filesystem call to where vortex-api's fs would ++ * put it, so a file written through one is found through the other. Windows-only ++ * roots also map themselves. ++ */ +export function resolveExtensionUserPathSync(input: string): string { + return gameProvider?.() === undefined ? input : resolveUserPathSync(input, true); +} @@ -78,19 +82,19 @@ diff --git a/src/renderer/src/util/compatibilityPaths.ts b/src/renderer/src/util if (process.platform !== "linux" || !path.isAbsolute(input)) { return input; } -@@ -310,9 +347,10 @@ - const selected = gameProvider?.() !== undefined; - let prefixes: string[] | undefined; +@@ -312,7 +353,10 @@ 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)) { ++ // Documents and ~/.config are Linux folders too, so only what is below ++ // them can move into the prefix. ++ const mapRoot = extensionRoots && mapping.gameData; ++ if (!isPathInside(mapping.linuxRoot, input) || (!mapRoot && relative.length === 0)) { return []; } const myGames = relative.split(path.sep)[0].toLowerCase() === "my games"; -@@ -320,20 +358,24 @@ +@@ -320,20 +364,24 @@ prefixes ??= compatibilityPrefixes(); if (prefixes === undefined) { if (!gameData) return []; @@ -135,7 +139,7 @@ diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer resolveWindowsUserPathSync, setCompatibilityGameProvider, } from "./compatibilityPaths"; -@@ -194,4 +195,120 @@ +@@ -194,4 +195,125 @@ resetCompatibilityPathCache(); expect(resolveWindowsUserPathSync(path.join(documents, relative))).toBe(expected); }); @@ -220,7 +224,7 @@ diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer + }, + ); + -+ it("preserves shared host paths and Vortex data in extension operations", () => { ++ it("keeps Linux roots and Vortex data on the host in extension operations", () => { + const prefix = path.join(home, "prefix"); + fixture(prefix); + const appData = path.join(home, ".config"); @@ -232,7 +236,6 @@ diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer + setCompatibilityGameProvider(() => ({ environment: { WINEPREFIX: prefix } })); + for (const hostPath of [ + documents, -+ path.join(documents, relative), + appData, + path.join(appData, "other/settings"), + userData, @@ -241,6 +244,12 @@ diff --git a/src/renderer/src/util/compatibilitySelection.test.ts b/src/renderer + ]) { + expect(resolveExtensionUserPathSync(hostPath)).toBe(hostPath); + } ++ // A folder the prefix has goes where vortex-api's fs puts it. ++ const settings = path.join(documents, relative); ++ expect(resolveExtensionUserPathSync(settings)).toBe( ++ path.join(prefix, "drive_c/users/steamuser/Documents", relative), ++ ); ++ expect(resolveExtensionUserPathSync(settings)).toBe(resolveCaseInsensitiveSync(settings)); + }); + + it("resolves Windows root casing before creating new configuration", () => { @@ -406,17 +415,21 @@ diff --git a/src/renderer/src/util/extensionFs.test.ts b/src/renderer/src/util/e new file mode 100644 --- /dev/null +++ b/src/renderer/src/util/extensionFs.test.ts -@@ -0,0 +1,248 @@ +@@ -0,0 +1,294 @@ +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 { 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 { type CompatibilityGame, setCompatibilityGameProvider } from "./compatibilityPaths"; +import { wrapExtensionFs } from "./extensionFs"; ++import { writeFileAsync } from "./fs"; + +describe.runIf(process.platform === "linux")("extension filesystem game paths", () => { + let root: string; @@ -550,7 +563,7 @@ new file mode 100644 + expect(fs.readFileSync(path.join(local, "settings.ini"), "utf8")).toBe("native"); + }); + -+ it("keeps Documents, shared appData, and Vortex data on the host", () => { ++ it("maps Documents and appData folders that the prefix has", () => { + const extensionFs = wrapExtensionFs(fs, "fs"); + for (const [logical, physical] of [ + [ @@ -561,15 +574,51 @@ new file mode 100644 + 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"); ++ extensionFs.writeFileSync(path.join(logical, "settings.ini"), "prefix"); + expect(fs.readFileSync(path.join(physical, "settings.ini"), "utf8")).toBe("prefix"); ++ expect(fs.existsSync(logical)).toBe(false); ++ } ++ }); ++ ++ it("keeps the Linux roots, folders the prefix lacks, and Vortex data on the host", () => { ++ const extensionFs = wrapExtensionFs(fs, "fs"); ++ const prefixVortex = path.join(profile, "AppData", "Roaming", "Vortex"); ++ fs.mkdirSync(prefixVortex, { recursive: true }); ++ fs.mkdirSync(path.join(profile, "Documents"), { recursive: true }); ++ for (const logical of [ ++ path.join(root, "host", "Documents", "Mods"), ++ path.join(root, "host", ".config", "someapp"), ++ process.env.VORTEX_USERDATA!, ++ ]) { ++ extensionFs.mkdirSync(logical, { recursive: true }); ++ extensionFs.writeFileSync(path.join(logical, "settings.ini"), "host"); ++ expect(fs.readFileSync(path.join(logical, "settings.ini"), "utf8")).toBe("host"); + } ++ for (const hostRoot of [ ++ path.join(root, "host", "Documents"), ++ path.join(root, "host", ".config"), ++ ]) { ++ expect(extensionFs.statSync(hostRoot).ino).toBe(fs.statSync(hostRoot).ino); ++ } ++ expect(fs.readdirSync(prefixVortex)).toEqual([]); ++ expect(fs.readdirSync(path.join(profile, "Documents"))).toEqual([]); ++ }); ++ ++ it("finds a file that vortex-api's fs wrote into the prefix's Documents", async () => { ++ // The Witcher 3 extension writes mods.settings beside the real file through ++ // vortex-api's fs and then moves it over with node:fs/promises. ++ const settings = path.join(profile, "Documents", "The Witcher 3"); ++ fs.mkdirSync(settings, { recursive: true }); ++ const logical = path.join(root, "host", "Documents", "The Witcher 3", "mods.settings"); ++ await writeFileAsync(`${logical}.vortex_tmp`, "[Example]\nEnabled=1\n"); ++ const extensionFs = wrapExtensionFs(fs.promises, "node:fs/promises"); ++ await extensionFs.rename(`${logical}.vortex_tmp`, logical); ++ expect(await extensionFs.readFile(logical, "utf8")).toBe("[Example]\nEnabled=1\n"); ++ expect(fs.readdirSync(settings)).toEqual(["mods.settings"]); ++ expect(fs.existsSync(path.join(root, "host", "Documents"))).toBe(false); + }); + + it("maps both sides of copy, rename, hardlink, and symlink operations", async () => { @@ -627,6 +676,12 @@ new file mode 100644 + 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); ++ // My Games is game data too, so it never falls back to the host Documents. ++ const myGames = path.join(root, "host", "Documents", "My Games", "Example"); ++ expect(() => extensionFs.mkdirSync(myGames, { recursive: true })).toThrow(/prefix/i); ++ await expect(extensionFs.promises.stat(myGames)).rejects.toMatchObject({ code: "ENOENT" }); ++ expect(extensionFs.existsSync(myGames)).toBe(false); ++ expect(fs.existsSync(path.join(root, "host", "Documents"))).toBe(false); + }); + + it("leaves the shared module unchanged and caches private views and methods", () => { diff --git a/patches/0031-linux-deployment-preflight.patch b/patches/0031-linux-deployment-preflight.patch index eccfcd9..4549036 100644 --- a/patches/0031-linux-deployment-preflight.patch +++ b/patches/0031-linux-deployment-preflight.patch @@ -137,7 +137,7 @@ new file mode 100644 diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts --- a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts +++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.ts -@@ -348,8 +348,11 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -376,8 +376,11 @@ abstract class LinkingActivator implements IDeploymentMethod { deployPath: string, blackList: BlacklistSet, ): Promise { @@ -151,7 +151,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s .statAsync(sourcePath) .then(() => turbowalk( -@@ -386,32 +389,35 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -414,32 +417,35 @@ abstract class LinkingActivator implements IDeploymentMethod { return null; } throw err; @@ -493,7 +493,7 @@ new file mode 100644 diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/src/extensions/mod_management/index.ts --- a/src/renderer/src/extensions/mod_management/index.ts +++ b/src/renderer/src/extensions/mod_management/index.ts -@@ -133,6 +133,10 @@ import renderModName from "./util/modName"; +@@ -154,6 +154,10 @@ import renderModName from "./util/modName"; import { getModSources, registerModSource } from "./util/modSource"; import sortMods from "./util/sort"; import { setResolvedCB } from "./util/testModReference"; @@ -504,7 +504,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ import ActivationButton from "./views/ActivationButton"; import DeactivationButton from "./views/DeactivationButton"; import type { IDuplicatesMap, IRemoveDuplicateMap } from "./views/DuplicatesDialog"; -@@ -364,30 +368,6 @@ async function deployAllModTypes( +@@ -397,30 +401,6 @@ async function deployAllModTypes( } } @@ -535,7 +535,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ function checkIncompatibilities( api: IExtensionApi, profile: IProfile, -@@ -627,7 +607,7 @@ function deployableModTypes(modPaths: { [typeId: string]: string }) { +@@ -667,7 +647,7 @@ function deployableModTypes(modPaths: { [typeId: string]: string }) { return Object.keys(modPaths).filter((typeId) => truthy(modPaths[typeId])); } @@ -544,7 +544,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ return ( api: IExtensionApi, manual: boolean, -@@ -685,7 +665,7 @@ function genUpdateModDeployment(installManager: InstallManager) { +@@ -725,7 +705,7 @@ function genUpdateModDeployment(installManager: InstallManager) { } const stagingPath = installPathForGame(state, gameId); @@ -553,7 +553,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ const activator = getCurrentActivator(state, gameId, true); if (activator === undefined) { -@@ -778,15 +758,18 @@ function genUpdateModDeployment(installManager: InstallManager) { +@@ -818,15 +798,18 @@ function genUpdateModDeployment(installManager: InstallManager) { await installManager.waitForIdle(); } @@ -578,7 +578,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ notification.message = t("Deploying mods"); if (showNotification) { api.sendNotification(notification); -@@ -822,6 +805,23 @@ function genUpdateModDeployment(installManager: InstallManager) { +@@ -862,6 +845,23 @@ function genUpdateModDeployment(installManager: InstallManager) { log("warn", "profile no longer found?", profileId); } @@ -602,7 +602,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ progress(t("Checking for external changes"), 5); await dealWithExternalChanges( api, -@@ -855,10 +855,6 @@ function genUpdateModDeployment(installManager: InstallManager) { +@@ -895,10 +895,6 @@ function genUpdateModDeployment(installManager: InstallManager) { const deployProgress = (name, percent) => progress(t("Deploying: ") + name, 50 + percent / 2, name); @@ -613,7 +613,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ await deployAllModTypes( api, activator, -@@ -907,6 +903,7 @@ function genUpdateModDeployment(installManager: InstallManager) { +@@ -948,6 +944,7 @@ function genUpdateModDeployment(installManager: InstallManager) { if (err instanceof UserCanceled) { // nop } else if (err instanceof ProcessCanceled) { @@ -621,7 +621,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ api.sendNotification({ type: "warning", title: "Deployment interrupted", -@@ -1235,7 +1232,7 @@ function onModsEnabled(api: IExtensionApi, deploymentTimer: Debouncer) { +@@ -1275,7 +1272,7 @@ function onModsEnabled(api: IExtensionApi, deploymentTimer: Debouncer) { }; } @@ -630,7 +630,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ return async (gameId: string, modId: string, enable?: boolean) => { const state: IState = api.store.getState(); const game = getGame(gameId); -@@ -1253,8 +1250,19 @@ function onDeploySingleMod(api: IExtensionApi) { +@@ -1293,8 +1290,19 @@ function onDeploySingleMod(api: IExtensionApi) { return; } @@ -651,7 +651,7 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ return; } const stagingPath: string = installPathForGame(state, gameId); -@@ -1274,7 +1282,33 @@ function onDeploySingleMod(api: IExtensionApi) { +@@ -1314,7 +1322,33 @@ function onDeploySingleMod(api: IExtensionApi) { const subdir = genSubDirFunc(game, getModType(mod.type)); await withActivationLock(async () => { @@ -685,15 +685,23 @@ diff --git a/src/renderer/src/extensions/mod_management/index.ts b/src/renderer/ const normalize = await getDeploymentNormalizeFunc(game, discovery.path, dataPath); const lastActivation = await loadActivation( api, -@@ -1285,6 +1319,7 @@ function onDeploySingleMod(api: IExtensionApi) { - activator, - ); - resetDeploymentFailures(api, gameId); -+ deploymentPrepared = true; - await activator.prepare(dataPath, false, lastActivation, normalize); - if (mod !== undefined) { - if (enable !== false) { -@@ -1310,11 +1345,12 @@ function onDeploySingleMod(api: IExtensionApi) { +@@ -1331,6 +1365,7 @@ function onDeploySingleMod(api: IExtensionApi) { + profile !== undefined + ? await doSortMods(api, profile, state.persistent.mods[gameId], VortexModId(mod.id)) + : []; ++ deploymentPrepared = true; + await activateSingleMod({ + method: activator, + game, +@@ -1343,6 +1378,7 @@ function onDeploySingleMod(api: IExtensionApi) { + laterSources: laterStagingFolders(sorted, mod), + }); + } else { ++ deploymentPrepared = true; + await activator.prepare(dataPath, false, lastActivation, normalize); + await activator.deactivate(modPath, subdir(mod), mod.installationPath); + } +@@ -1358,11 +1394,12 @@ function onDeploySingleMod(api: IExtensionApi) { ); reportRecordedFailures(api, gameId); } catch (unknownErr) { diff --git a/patches/0032-linux-deployment-target-paths.patch b/patches/0032-linux-deployment-target-paths.patch index 9849da2..99e97b0 100644 --- a/patches/0032-linux-deployment-target-paths.patch +++ b/patches/0032-linux-deployment-target-paths.patch @@ -142,9 +142,9 @@ diff --git a/src/renderer/src/extensions/mod_management/modActivation.ts b/src/r import type { Normalize } from "../../util/getNormalizeFunc"; +import { resolveCaseInsensitiveSync } from "../../util/linuxCompatibility"; import { truthy } from "../../util/util"; - import { MERGED_PATH } from "./modMerging"; + import { mergedSourceName } from "./modMerging"; import type { IDeployedFile, IDeploymentMethod } from "./types/IDeploymentMethod"; -@@ -32,6 +36,24 @@ async function ensureWritable(api: IExtensionApi, modPath: string): Promise PromiseBB.resolve(); } diff --git a/patches/0035-linux-backslash-archive-paths.patch b/patches/0035-linux-backslash-archive-paths.patch index ac9087b..f7f4b93 100644 --- a/patches/0035-linux-backslash-archive-paths.patch +++ b/patches/0035-linux-backslash-archive-paths.patch @@ -1,15 +1,15 @@ diff --git a/src/renderer/src/extensions/mod_management/InstallManager.ts b/src/renderer/src/extensions/mod_management/InstallManager.ts --- a/src/renderer/src/extensions/mod_management/InstallManager.ts +++ b/src/renderer/src/extensions/mod_management/InstallManager.ts -@@ -187,6 +187,7 @@ import type { IModInstaller, ISupportedInstaller } from "./types/IModInstaller"; +@@ -186,6 +186,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 { calculateModSize } from "./util/calculate_mod_size/calculateModSize.util"; import { getCSharpScriptAllowListForGame } from "./util/cSharpScriptAllowList"; import gatherDependencies, { - findDownloadByRef, -@@ -254,6 +255,9 @@ class InstructionGroups { +@@ -255,6 +256,9 @@ class InstructionGroups { } async function buildFileList(basePath: string): Promise { diff --git a/patches/0037-linux-case-consistent-folders.patch b/patches/0037-linux-case-consistent-folders.patch index 6a6a1ef..bdf6e90 100644 --- a/patches/0037-linux-case-consistent-folders.patch +++ b/patches/0037-linux-case-consistent-folders.patch @@ -1,8 +1,8 @@ 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")( - ); +@@ -211,3 +211,65 @@ test.runIf(process.platform === "linux")( + expect(nodeFs.readFileSync(h.inGame("D3D9.dll"), "utf8")).toBe("game"); }, ); + @@ -70,7 +70,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux. 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 -@@ -85,6 +85,7 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -121,6 +121,7 @@ abstract class LinkingActivator implements IDeploymentMethod { private mQueue: Promise = Promise.resolve(); private mContext: IDeploymentContext; private mDirCache: Set; @@ -78,7 +78,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s constructor( id: string, -@@ -663,16 +664,23 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -699,16 +700,23 @@ abstract class LinkingActivator implements IDeploymentMethod { } }; diff --git a/patches/0039-linux-lutris-store.patch b/patches/0039-linux-lutris-store.patch index b2ea401..475cc33 100644 --- a/patches/0039-linux-lutris-store.patch +++ b/patches/0039-linux-lutris-store.patch @@ -1,7 +1,7 @@ diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts --- a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +++ b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts -@@ -27,6 +27,7 @@ import { ProcessCanceled, SetupError, UserCanceled } from "../../util/CustomErro +@@ -28,6 +28,7 @@ import { ProcessCanceled, SetupError, UserCanceled } from "../../util/CustomErro import EpicGamesLauncher from "../../util/EpicGamesLauncher"; import * as fs from "../../util/fs"; import { HeroicStore } from "../../util/Heroic"; @@ -9,7 +9,7 @@ diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts import { activeProfile, discoveryByGame } from "../../util/selectors"; import Steam from "../../util/Steam"; import { normalizeStoreQuery } from "../../util/storeQuery"; -@@ -83,7 +84,9 @@ class GameModeManager { +@@ -85,7 +86,9 @@ export class GameModeManager { OriginLauncher.create(), UPlayLauncher.create(), XboxLauncher.create(api), @@ -430,8 +430,6 @@ new file mode 100644 --- /dev/null +++ b/src/renderer/src/util/Lutris.ts @@ -0,0 +1,112 @@ -+import Bluebird from "bluebird"; -+ +import type { IExtensionApi } from "../types/IExtensionContext"; +import { + GameEntryNotFound, @@ -467,85 +465,87 @@ new file mode 100644 + registerCompatibilityStore(this.id, lutrisPrefix); + registerGameToolRunner(this.id, lutrisToolCommand); + } -+ public allGames(): Bluebird { -+ return Bluebird.try(() => { -+ if (this.games === undefined) { -+ this.games = readLutrisGames(); -+ this.installed = lutrisRoots().some((root) => lutrisLauncher(root.flatpak) !== undefined); -+ } -+ return this.games; -+ }); ++ public async allGames(): Promise { ++ if (this.games === undefined) { ++ this.games = readLutrisGames(); ++ this.installed = lutrisRoots().some((root) => lutrisLauncher(root.flatpak) !== undefined); ++ } ++ return this.games; + } + public snapshot(): IGameStoreSnapshot { + return { entries: this.games ?? [], isInstalled: this.installed }; + } -+ public reloadGames(): Bluebird { ++ public async reloadGames(): Promise { + this.games = undefined; + resetCompatibilityPathCache(); -+ return this.allGames().then(() => undefined); ++ await this.allGames(); + } -+ private find(predicate: (game: LutrisGame) => boolean, query: string): Bluebird { -+ return this.allGames().then((games) => { -+ const matches = games.filter(predicate); -+ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); -+ if (matches.length > 1) -+ throw new Error( -+ "Several Lutris games match this game. Select the game directory manually.", -+ ); -+ return matches[0]; -+ }); ++ private async find( ++ predicate: (game: LutrisGame) => boolean, ++ query: string, ++ ): Promise { ++ const games = await this.allGames(); ++ const matches = games.filter(predicate); ++ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); ++ if (matches.length > 1) ++ throw new Error( ++ "Several Lutris games match this game. Select the game directory manually.", ++ ); ++ return matches[0]; + } -+ public findByAppId(id: string | string[]): Bluebird { ++ public findByAppId(id: string | string[]): Promise { + const ids = Array.isArray(id) ? id : [id]; + return this.find((game) => ids.includes(game.appid), ids.join(", ")); + } -+ public findByName(name: string): Bluebird { -+ return Bluebird.try(() => new RegExp(`^(?:${name})$`, "i")).then((pattern) => -+ this.find((game) => pattern.test(game.name), name), -+ ); ++ public async findByName(name: string): Promise { ++ const pattern = new RegExp(`^(?:${name})$`, "i"); ++ return this.find((game) => pattern.test(game.name), name); + } -+ public identifyGame(gamePath: string): Bluebird { -+ return Bluebird.try(() => matchLutrisGames(gamePath).length > 0).catch(() => false); ++ public async identifyGame(gamePath: string): Promise { ++ try { ++ return matchLutrisGames(gamePath).length > 0; ++ } catch { ++ return false; ++ } + } -+ public getGameStorePath(): Bluebird { -+ return Bluebird.resolve( -+ lutrisRoots() -+ .map((root) => lutrisLauncher(root.flatpak)?.executable) -+ .find(Boolean), -+ ); ++ public async getGameStorePath(): Promise { ++ return lutrisRoots() ++ .map((root) => lutrisLauncher(root.flatpak)?.executable) ++ .find(Boolean); + } -+ public isGameStoreInstalled(): Bluebird { -+ return this.getGameStorePath().then(Boolean); ++ public async isGameStoreInstalled(): Promise { ++ return Boolean(await this.getGameStorePath()); + } -+ public isGameInstalled(name: string): Bluebird { -+ return this.findByName(name) -+ .then(() => true) -+ .catch(GameEntryNotFound, () => false); ++ public async isGameInstalled(name: string): Promise { ++ try { ++ await this.findByName(name); ++ return true; ++ } catch (err) { ++ if (err instanceof GameEntryNotFound) { ++ return false; ++ } ++ throw err; ++ } + } -+ public launchGame( ++ public async launchGame( + appInfo: string | { appId: string; parameters?: string[] }, + api?: IExtensionApi, -+ ): Bluebird { -+ return this.isGameStoreInstalled() -+ .then((installed) => { -+ if (!installed) throw new GameStoreNotFound(this.name); -+ return this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); -+ }) -+ .then((game) => { -+ if (!api) throw new Error("Lutris launch requires the Vortex application API"); -+ const command = lutrisLaunchCommand(game); -+ return api.runExecutable(command.executable, command.args, { -+ ...command.options, -+ cwd: game.gamePath, -+ suggestDeploy: true, -+ }); -+ }); ++ ): Promise { ++ if (!(await this.isGameStoreInstalled())) throw new GameStoreNotFound(this.name); ++ const game = await this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); ++ if (!api) throw new Error("Lutris launch requires the Vortex application API"); ++ const command = lutrisLaunchCommand(game); ++ await api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: game.gamePath, ++ suggestDeploy: true, ++ }); + } +} diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts --- a/src/renderer/src/util/StarterInfo.ts +++ b/src/renderer/src/util/StarterInfo.ts -@@ -26,6 +26,7 @@ import { +@@ -25,6 +25,7 @@ import { import { emitGameLaunched, recordLaunchExit } from "./gameLaunchAnalytics"; import getVortexPath from "./getVortexPath"; import { findHeroicGame, heroicLaunchCommand } from "./linux/heroic"; @@ -553,7 +553,7 @@ diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/Starte import { resolvePlatformPath } from "./nativeExecutable"; import type { Steam, ISteamEntry } from "./Steam"; import { getSafe } from "./storeHelper"; -@@ -278,6 +279,36 @@ class StarterInfo implements IStarterInfo { +@@ -310,6 +311,36 @@ class StarterInfo implements IStarterInfo { } } diff --git a/patches/0040-linux-bottles-store.patch b/patches/0040-linux-bottles-store.patch index a37058d..431aed1 100644 --- a/patches/0040-linux-bottles-store.patch +++ b/patches/0040-linux-bottles-store.patch @@ -1,7 +1,7 @@ diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts --- a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +++ b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts -@@ -23,6 +23,7 @@ import { XboxLauncher } from "@/util/xbox/XboxLauncher"; +@@ -24,6 +24,7 @@ import { XboxLauncher } from "@/util/xbox/XboxLauncher"; import { setNextProfile } from "../../actions"; import { getNormalizeFunc } from "../../util/api"; @@ -9,7 +9,7 @@ diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts import { ProcessCanceled, SetupError, UserCanceled } from "../../util/CustomErrors"; import EpicGamesLauncher from "../../util/EpicGamesLauncher"; import * as fs from "../../util/fs"; -@@ -85,7 +86,7 @@ class GameModeManager { +@@ -87,7 +88,7 @@ export class GameModeManager { UPlayLauncher.create(), XboxLauncher.create(api), ...(process.platform === "linux" @@ -311,8 +311,6 @@ new file mode 100644 --- /dev/null +++ b/src/renderer/src/util/Bottles.ts @@ -0,0 +1,117 @@ -+import Bluebird from "bluebird"; -+ +import type { IExtensionApi } from "../types/IExtensionContext"; +import { + GameEntryNotFound, @@ -348,90 +346,92 @@ new file mode 100644 + registerCompatibilityStore(this.id, bottlesPrefix); + registerGameToolRunner(this.id, bottlesToolCommand); + } -+ public allGames(): Bluebird { -+ return Bluebird.try(() => { -+ if (this.games === undefined) { -+ this.games = readBottlesGames(); -+ this.installed = bottlesRoots().some((root) => bottlesCli(root.flatpak) !== undefined); -+ } -+ return this.games; -+ }); ++ public async allGames(): Promise { ++ if (this.games === undefined) { ++ this.games = readBottlesGames(); ++ this.installed = bottlesRoots().some((root) => bottlesCli(root.flatpak) !== undefined); ++ } ++ return this.games; + } + public snapshot(): IGameStoreSnapshot { + return { entries: this.games ?? [], isInstalled: this.installed }; + } -+ public reloadGames(): Bluebird { ++ public async reloadGames(): Promise { + this.games = undefined; + resetCompatibilityPathCache(); -+ return this.allGames().then(() => undefined); ++ await this.allGames(); + } -+ private find(predicate: (game: BottlesGame) => boolean, query: string): Bluebird { -+ return this.allGames().then((games) => { -+ const matches = games.filter(predicate); -+ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); -+ if (matches.length > 1) -+ throw new Error( -+ "Several Bottles programs match this game. Select the game directory manually.", -+ ); -+ return matches[0]; -+ }); ++ private async find( ++ predicate: (game: BottlesGame) => boolean, ++ query: string, ++ ): Promise { ++ const games = await this.allGames(); ++ const matches = games.filter(predicate); ++ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); ++ if (matches.length > 1) ++ throw new Error( ++ "Several Bottles programs match this game. Select the game directory manually.", ++ ); ++ return matches[0]; + } -+ public findByAppId(id: string | string[]): Bluebird { ++ public findByAppId(id: string | string[]): Promise { + const ids = Array.isArray(id) ? id : [id]; + return this.find((game) => ids.includes(game.appid), ids.join(", ")); + } -+ public findByName(name: string): Bluebird { -+ return Bluebird.try(() => new RegExp(`^(?:${name})$`, "i")).then((pattern) => -+ this.find((game) => pattern.test(game.name), name), -+ ); ++ public async findByName(name: string): Promise { ++ const pattern = new RegExp(`^(?:${name})$`, "i"); ++ return this.find((game) => pattern.test(game.name), name); + } -+ public identifyGame(gamePath: string): Bluebird { -+ return Bluebird.try(() => matchBottles(gamePath).length > 0).catch(() => false); ++ public async identifyGame(gamePath: string): Promise { ++ try { ++ return matchBottles(gamePath).length > 0; ++ } catch { ++ return false; ++ } + } -+ public getGameStorePath(): Bluebird { -+ return Bluebird.resolve( -+ bottlesRoots() -+ .map((root) => bottlesCli(root.flatpak)?.executable) -+ .find(Boolean), -+ ); ++ public async getGameStorePath(): Promise { ++ return bottlesRoots() ++ .map((root) => bottlesCli(root.flatpak)?.executable) ++ .find(Boolean); + } -+ public isGameStoreInstalled(): Bluebird { -+ return this.getGameStorePath().then(Boolean); ++ public async isGameStoreInstalled(): Promise { ++ return Boolean(await this.getGameStorePath()); + } -+ public isGameInstalled(name: string): Bluebird { -+ return this.findByName(name) -+ .then(() => true) -+ .catch(GameEntryNotFound, () => false); ++ public async isGameInstalled(name: string): Promise { ++ try { ++ await this.findByName(name); ++ return true; ++ } catch (err) { ++ if (err instanceof GameEntryNotFound) { ++ return false; ++ } ++ throw err; ++ } + } -+ public launchGame( ++ public async launchGame( + appInfo: string | { appId: string; parameters?: string[] }, + api?: IExtensionApi, -+ ): Bluebird { -+ return this.isGameStoreInstalled() -+ .then((installed) => { -+ if (!installed) throw new GameStoreNotFound(this.name); -+ return this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); -+ }) -+ .then((game) => { -+ if (!api) throw new Error("Bottles launch requires the Vortex application API"); -+ const command = bottlesLaunchCommand( -+ game.bottle, -+ game.program, -+ typeof appInfo === "string" ? [] : appInfo.parameters, -+ ); -+ return api.runExecutable(command.executable, command.args, { -+ ...command.options, -+ cwd: game.gamePath, -+ suggestDeploy: true, -+ detach: true, -+ }); -+ }); ++ ): Promise { ++ if (!(await this.isGameStoreInstalled())) throw new GameStoreNotFound(this.name); ++ const game = await this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); ++ if (!api) throw new Error("Bottles launch requires the Vortex application API"); ++ const command = bottlesLaunchCommand( ++ game.bottle, ++ game.program, ++ typeof appInfo === "string" ? [] : appInfo.parameters, ++ ); ++ await api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: game.gamePath, ++ suggestDeploy: true, ++ detach: true, ++ }); + } +} diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts --- a/src/renderer/src/util/StarterInfo.ts +++ b/src/renderer/src/util/StarterInfo.ts -@@ -25,6 +25,7 @@ import { +@@ -24,6 +24,7 @@ import { } from "./CustomErrors"; import { emitGameLaunched, recordLaunchExit } from "./gameLaunchAnalytics"; import getVortexPath from "./getVortexPath"; @@ -439,7 +439,7 @@ diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/Starte import { findHeroicGame, heroicLaunchCommand } from "./linux/heroic"; import { findLutrisLaunchGame, lutrisLaunchCommand } from "./linux/lutris"; import { resolvePlatformPath } from "./nativeExecutable"; -@@ -309,6 +310,31 @@ class StarterInfo implements IStarterInfo { +@@ -341,6 +342,31 @@ class StarterInfo implements IStarterInfo { } } diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index b4136ce..4ced0bc 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -8,13 +8,11 @@ readonly PACKAGE_PATH="${1:?usage: build-appimage.sh PACKAGE [OUTPUT_DIRECTORY]} readonly OUTPUT_DIRECTORY="${2:-${REPOSITORY_ROOT}/dist}" readonly APPIMAGETOOL_URL='https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage' readonly APPIMAGETOOL_SHA256='b90f4a8b18967545fda78a445b27680a1642f1ef9488ced28b65398f2be7add2' -readonly DOTNET_RUNTIME_VERSION='9.0.18' -readonly DOTNET_RUNTIME_URL="https://builds.dotnet.microsoft.com/dotnet/Runtime/${DOTNET_RUNTIME_VERSION}/dotnet-runtime-${DOTNET_RUNTIME_VERSION}-linux-x64.tar.gz" -readonly DOTNET_RUNTIME_SHA512='97eb89a5a3781b9761e2a850996912ae81b96996089c78abcebbc441113457088bfcbd7cf1fa97d3036f7ac8fe22c7d30e043e93ecafc910f07b73317c73bad5' # GitHub repository whose latest release AppImageUpdate-style tools (Gear Lever, # AppImageLauncher, appimageupdatetool) check for a newer AppImage. readonly RELEASE_REPOSITORY="${VORTEX_RELEASE_REPOSITORY:-${GITHUB_REPOSITORY:-Arkazzae/VortexLinux}}" -readonly METAINFO_NAME='com.nexusmods.vortex.metainfo.xml' +# shellcheck source=scripts/package-common.sh +source "$REPOSITORY_ROOT/scripts/package-common.sh" for command_name in bsdtar curl file install ldd patchelf readelf sha256sum sha512sum; do if ! command -v "$command_name" >/dev/null; then @@ -23,24 +21,7 @@ for command_name in bsdtar curl file install ldd patchelf readelf sha256sum sha5 fi done -if [[ ! -f "$PACKAGE_PATH" ]]; then - printf 'Arch package was not found: %s\n' "$PACKAGE_PATH" >&2 - exit 2 -fi - -package_info="$(bsdtar -xOf "$PACKAGE_PATH" .PKGINFO)" -package_name="$(sed -n 's/^pkgname = //p' <<< "$package_info")" -package_revision="$(sed -n 's/^pkgver = //p' <<< "$package_info")" -package_arch="$(sed -n 's/^arch = //p' <<< "$package_info")" -release_version="$(sed -n 's/^\tpkgver = //p' "$REPOSITORY_ROOT/.SRCINFO" | head -n 1)" - -if [[ "$package_name" != vortex-linux || -z "$package_revision" || \ - -z "$release_version" || "$package_revision" != "${release_version}-"* || \ - "$package_arch" != x86_64 ]]; then - printf 'Unsupported package metadata: %s %s %s\n' \ - "$package_name" "$package_revision" "$package_arch" >&2 - exit 2 -fi +read_package_metadata "$PACKAGE_PATH" if [[ ! "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then printf 'Unsupported release repository: %s\n' "$RELEASE_REPOSITORY" >&2 @@ -48,7 +29,6 @@ if [[ ! "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then fi # https://github.com/AppImage/AppImageSpec/blob/master/draft.md#github-releases update_information="gh-releases-zsync|${RELEASE_REPOSITORY%%/*}|${RELEASE_REPOSITORY#*/}|latest|Vortex-*-x86_64.AppImage.zsync" -release_date="$(date -u +%Y-%m-%d)" work_directory="$(mktemp -d "${TMPDIR:-/tmp}/vortex-appimage.XXXXXX")" cleanup() { @@ -94,21 +74,7 @@ if grep -Fq 'not found' <<< "$fomod_dependencies"; then exit 1 fi -# Vortex's native dotnetprobe is a framework-dependent net9.0 executable. The -# Arch package gets a host runtime through pacman, but an AppImage must carry it -# itself so the startup check also works on a clean distribution. -curl --fail --location --silent --show-error \ - "$DOTNET_RUNTIME_URL" --output "$dotnet_runtime_path" -printf '%s %s\n' "$DOTNET_RUNTIME_SHA512" "$dotnet_runtime_path" \ - | sha512sum --check --status -install -dm755 "$app_directory/usr/lib/dotnet" -bsdtar -xf "$dotnet_runtime_path" -C "$app_directory/usr/lib/dotnet" - -if ! "$app_directory/usr/lib/dotnet/dotnet" --list-runtimes \ - | grep -Fqx "Microsoft.NETCore.App ${DOTNET_RUNTIME_VERSION} [$app_directory/usr/lib/dotnet/shared/Microsoft.NETCore.App]"; then - printf 'The bundled .NET %s runtime is incomplete\n' "$DOTNET_RUNTIME_VERSION" >&2 - exit 1 -fi +install_dotnet_runtime "$app_directory/usr/lib/dotnet" "$dotnet_runtime_path" install -m755 "$REPOSITORY_ROOT/appimage/AppRun" "$app_directory/AppRun" install -Dm644 "$REPOSITORY_ROOT/scripts/test-runtime.cjs" "$app_directory/usr/share/vortex-linux/test-runtime.cjs" @@ -131,21 +97,7 @@ if ! grep -q '^X-AppImage-Version=' "$app_directory/com.nexusmods.vortex.desktop >> "$app_directory/com.nexusmods.vortex.desktop" fi -metainfo_path="$app_directory/usr/share/metainfo/$METAINFO_NAME" -install -dm755 "$(dirname "$metainfo_path")" -sed \ - -e "s/@PACKAGE_REVISION@/${package_revision}/g" \ - -e "s/@RELEASE_DATE@/${release_date}/g" \ - "$REPOSITORY_ROOT/appimage/$METAINFO_NAME" > "$metainfo_path" -if grep -q '@[A-Z_]*@' "$metainfo_path"; then - printf 'The AppStream metadata still contains placeholders\n' >&2 - exit 1 -fi -if command -v appstreamcli >/dev/null; then - appstreamcli validate --no-net "$metainfo_path" -else - printf 'appstreamcli is not installed; skipping AppStream validation\n' >&2 -fi +install_metainfo "$app_directory/usr/share/metainfo/$METAINFO_NAME" curl --fail --location --silent --show-error \ "$APPIMAGETOOL_URL" --output "$tool_path" diff --git a/scripts/build-distro-packages.sh b/scripts/build-distro-packages.sh new file mode 100755 index 0000000..76db5d2 --- /dev/null +++ b/scripts/build-distro-packages.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +umask 022 + +REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly REPOSITORY_ROOT +readonly PACKAGE_PATH="${1:?usage: build-distro-packages.sh PACKAGE [OUTPUT_DIRECTORY]}" +readonly OUTPUT_DIRECTORY="${2:-${REPOSITORY_ROOT}/dist}" +# shellcheck source=scripts/package-common.sh +source "$REPOSITORY_ROOT/scripts/package-common.sh" + +# .NET loads whichever ICU it finds, also versions newer than it knows. List +# sonames well past today's, so a published package still installs after a +# distribution moves to the next ICU. +readonly ICU_VERSIONS=({74..99}) +deb_icu="$(printf 'libicu%s | ' "${ICU_VERSIONS[@]}")" +rpm_icu="$(printf 'libicuuc.so.%s()(64bit) or ' "${ICU_VERSIONS[@]}")" + +# Package names as Ubuntu 24.04 and Debian 13 ship them. The libc6 and +# libstdc++6 versions are added from the binaries below. +readonly DEB_DEPENDS=( + 'libasound2t64' + 'libatk-bridge2.0-0t64' + 'libatk1.0-0t64' + 'libatspi2.0-0t64' + 'libcairo2' + 'libcups2t64' + 'libdbus-1-3' + 'libdrm2' + 'libexpat1' + 'libfontconfig1' + 'libgbm1' + 'libglib2.0-0t64' + 'libglib2.0-bin' + 'libgtk-3-0t64' + "${deb_icu% | }" + 'libnotify4' + 'libnspr4' + 'libnss3' + 'libpango-1.0-0' + 'libsecret-1-0' + 'libudev1' + 'libx11-6' + 'libxcb1' + 'libxcomposite1' + 'libxdamage1' + 'libxext6' + 'libxfixes3' + 'libxkbcommon0' + 'libxrandr2' + 'libxss1' + 'wine | wine64 | winehq-stable | winehq-staging | winehq-devel' + 'xdg-utils' +) +readonly DEB_RECOMMENDS=( + 'libayatana-appindicator3-1 | libappindicator3-1' +) +readonly DEB_SUGGESTS=( + 'kde-cli-tools' + 'pipewire' +) + +# rpmbuild adds the libraries the binaries link, with the glibc and libstdc++ +# symbol versions they need, so these are only what Vortex loads at runtime. +# Library names and paths work the same on Fedora and openSUSE. +readonly RPM_REQUIRES=( + '(wine or winehq-stable or winehq-staging or winehq-devel)' + 'xdg-utils' + '/usr/bin/gio' + 'libnotify.so.4()(64bit)' + 'libsecret-1.so.0()(64bit)' + "(${rpm_icu% or })" + # The interface stays blank without any font, and a minimal openSUSE has + # none. Debian's fontconfig already pulls one in. + 'liberation-fonts' +) +readonly RPM_RECOMMENDS=( + 'libayatana-appindicator3.so.1()(64bit)' +) + +readonly DESCRIPTION='Vortex is the mod manager from Nexus Mods. This build patches it to run +natively on Linux: it deploys mods into the game'"'"'s Proton or Wine prefix and +launches games through Steam, Heroic, Lutris or Bottles. + +Not affiliated with or supported by Nexus Mods.' + +for command_name in bsdtar cp curl dpkg-deb file install objdump rpm rpmbuild sha256sum sha512sum; do + if ! command -v "$command_name" >/dev/null; then + printf 'Required build command is missing: %s\n' "$command_name" >&2 + exit 2 + fi +done + +read_package_metadata "$PACKAGE_PATH" +package_release="${package_revision#"${release_version}-"}" +maintainer="$(sed -n 's/^# Maintainer: //p' "$REPOSITORY_ROOT/PKGBUILD")" +if [[ -z "$maintainer" ]]; then + printf 'PKGBUILD names no maintainer\n' >&2 + exit 2 +fi + +work_directory="$(mktemp -d "${TMPDIR:-/tmp}/vortex-distro.XXXXXX")" +cleanup() { + rm -rf -- "$work_directory" +} +trap cleanup EXIT + +# Both packages are hard-linked copies of one staged tree, in the layout RPM +# distributions use. +stage="$work_directory/stage" +install -dm755 "$stage" +bsdtar -xf "$PACKAGE_PATH" -C "$stage" \ + opt/Vortex \ + usr/share/applications/com.nexusmods.vortex.desktop \ + usr/share/icons/hicolor/256x256/apps/vortex.png \ + usr/share/licenses/vortex-linux \ + usr/share/doc/vortex-linux + +# Electron falls back to this helper where unprivileged user namespaces are +# restricted, as on Ubuntu 24.04. bsdtar drops the setuid bit for normal users. +chmod 4755 "$stage/opt/Vortex/chrome-sandbox" + +install_dotnet_runtime "$stage/opt/Vortex/dotnet-linux-x64" \ + "$work_directory/dotnet-runtime-linux-x64.tar.gz" +install -Dm755 "$REPOSITORY_ROOT/vortex.sh" "$stage/usr/bin/vortex" + +install_metainfo "$stage/usr/share/metainfo/$METAINFO_NAME" + +install -dm755 "$OUTPUT_DIRECTORY" + +### Debian package + +# The newest glibc and libstdc++ symbols the binaries use decide which +# distributions can run them. Declaring them lets apt refuse an older system +# instead of Vortex failing at startup. libstdc++6 carries the GCC version. +elf_files="$work_directory/elf-files" +find "$stage" -type f -print0 \ + | xargs -0 file --no-pad --separator '|' \ + | sed -n 's/| ELF 64-bit .*//p' > "$elf_files" +symbol_versions="$( + while IFS= read -r elf_file; do + # Static executables such as 7zzs have no dynamic symbols. + objdump -T "$elf_file" 2>/dev/null || true + done < "$elf_files" | grep -oE '(GLIBC_2|GLIBCXX_3\.4)\.[0-9]+' | sort -uV +)" +glibc_minimum="$(grep '^GLIBC_' <<< "$symbol_versions" | tail -n 1 | sed 's/^GLIBC_//' || true)" +glibcxx_minimum="$(grep '^GLIBCXX_' <<< "$symbol_versions" | tail -n 1 || true)" +case "$glibcxx_minimum" in + ''|GLIBCXX_3.4.[0-9]|GLIBCXX_3.4.1[0-9]|GLIBCXX_3.4.2[0-9]) libstdcxx_minimum='' ;; + GLIBCXX_3.4.30) libstdcxx_minimum='12' ;; + GLIBCXX_3.4.31) libstdcxx_minimum='13' ;; + GLIBCXX_3.4.32) libstdcxx_minimum='13.2' ;; + GLIBCXX_3.4.33) libstdcxx_minimum='14' ;; + GLIBCXX_3.4.34) libstdcxx_minimum='15' ;; + *) + printf 'Unknown libstdc++ symbol version %s; add its GCC release here\n' \ + "$glibcxx_minimum" >&2 + exit 1 + ;; +esac +if [[ ! "$glibc_minimum" =~ ^2\.[0-9]+$ ]]; then + printf 'Unable to determine the glibc version the binaries need\n' >&2 + exit 1 +fi + +depends=("libc6 (>= ${glibc_minimum})") +if [[ -n "$libstdcxx_minimum" ]]; then + depends+=("libstdc++6 (>= ${libstdcxx_minimum})") +fi +depends+=("${DEB_DEPENDS[@]}") +join_fields() { + local IFS=, + sed 's/,/, /g' <<< "$*" +} + +deb_root="$work_directory/deb" +cp -al "$stage" "$deb_root" + +# Debian keeps licenses next to the documentation and expects a copyright file. +doc_directory="$deb_root/usr/share/doc/$package_name" +mv "$deb_root/usr/share/licenses/vortex-linux"/* "$doc_directory/" +rm -r "$deb_root/usr/share/licenses" +cat > "$doc_directory/copyright" < "$deb_root/DEBIAN/control" < "$deb_root/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e + +if [ "$1" = configure ]; then + echo "To select Vortex for Nexus links, run:" + echo " xdg-mime default com.nexusmods.vortex.desktop x-scheme-handler/nxm" +fi +EOF +chmod 755 "$deb_root/DEBIAN/postinst" + +deb_archive="${package_name}_${release_version}_amd64.deb" +dpkg-deb --root-owner-group -Zxz --build "$deb_root" "$OUTPUT_DIRECTORY/$deb_archive" +rm -rf -- "$deb_root" + +sandbox_entry="$(dpkg-deb --contents "$OUTPUT_DIRECTORY/$deb_archive" \ + | grep -E ' \./opt/Vortex/chrome-sandbox$')" +if [[ "$sandbox_entry" != '-rwsr-xr-x root/root '* ]]; then + printf 'chrome-sandbox is not setuid root in the Debian package: %s\n' \ + "$sandbox_entry" >&2 + exit 1 +fi + +### RPM package + +# Requirements that libraries inside /opt/Vortex satisfy are dropped, and so +# is LTTng, which only the optional .NET tracing provider links. Nothing in +# /opt/Vortex is offered to other packages. +bundled_libraries="$( + find "$stage/opt/Vortex" -type f -regextype posix-extended \ + -regex '.*\.so(\.[0-9]+)*' -printf '%f\n' \ + | sort -u | sed 's/[.]/[.]/g' | paste -sd '|' +)" +rpm_topdir="$work_directory/rpm" +install -dm755 "$rpm_topdir" +cat > "$rpm_topdir/${package_name}.spec" <&2 + exit 1 +fi +rpm_archive="${package_name}-${release_version}-x86_64.rpm" +install -m644 "${built_rpms[0]}" "$OUTPUT_DIRECTORY/$rpm_archive" +rm -rf -- "$rpm_topdir" + +sandbox_entry="$(rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME}:%{FILEGROUPNAME} %{FILENAMES}\n]' \ + "$OUTPUT_DIRECTORY/$rpm_archive" | grep -E ' /opt/Vortex/chrome-sandbox$')" +if [[ "$sandbox_entry" != '-rwsr-xr-x root:root '* ]]; then + printf 'chrome-sandbox is not setuid root in the RPM package: %s\n' \ + "$sandbox_entry" >&2 + exit 1 +fi +rpm_requires="$(rpm -qp --requires "$OUTPUT_DIRECTORY/$rpm_archive")" +if ! grep -q '^libc\.so\.6(GLIBC_2\.[0-9]*)(64bit)$' <<< "$rpm_requires"; then + printf 'rpmbuild did not record the glibc version the binaries need\n' >&2 + exit 1 +fi +leaked_provides="$(rpm -qp --provides "$OUTPUT_DIRECTORY/$rpm_archive" | grep -F '.so' || true)" +if [[ -n "$leaked_provides" ]]; then + printf 'The RPM package offers its bundled libraries to the system:\n%s\n' \ + "$leaked_provides" >&2 + exit 1 +fi +rpm_glibc="$(grep -o '^libc\.so\.6(GLIBC_2\.[0-9]*)' <<< "$rpm_requires" | sort -V | tail -n 1)" + +( + cd "$OUTPUT_DIRECTORY" + sha256sum "$deb_archive" > "${deb_archive}.sha256" + sha256sum "$rpm_archive" > "${rpm_archive}.sha256" + sha256sum --check "${deb_archive}.sha256" "${rpm_archive}.sha256" +) + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'deb_archive=%s\n' "$deb_archive" >> "$GITHUB_OUTPUT" + printf 'deb_checksum=%s.sha256\n' "$deb_archive" >> "$GITHUB_OUTPUT" + printf 'rpm_archive=%s\n' "$rpm_archive" >> "$GITHUB_OUTPUT" + printf 'rpm_checksum=%s.sha256\n' "$rpm_archive" >> "$GITHUB_OUTPUT" +fi + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat >> "$GITHUB_STEP_SUMMARY" <= ${glibc_minimum})\`${libstdcxx_minimum:+ and \`libstdc++6 (>= ${libstdcxx_minimum})\`} +- RPM: \`${rpm_archive}\`, \`$(du -h "$OUTPUT_DIRECTORY/$rpm_archive" | cut -f1)\`, needs \`${rpm_glibc}\` +- Version: \`${package_revision}\` +- Bundled Linux .NET runtime: \`${DOTNET_RUNTIME_VERSION}\` +EOF +fi + +printf 'Built Debian package: %s\n' "$OUTPUT_DIRECTORY/$deb_archive" +printf 'Built RPM package: %s\n' "$OUTPUT_DIRECTORY/$rpm_archive" diff --git a/scripts/check-appimage-ui.py b/scripts/check-appimage-ui.py deleted file mode 100644 index 98db585..0000000 --- a/scripts/check-appimage-ui.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -from contextlib import closing -import json -import sys -import time -import urllib.request - -import websocket - - -def check_ui(): - deadline = time.monotonic() + 45 - last_error = 'No renderer target appeared' - while time.monotonic() < deadline: - try: - with urllib.request.urlopen('http://127.0.0.1:9222/json', timeout=2) as response: - targets = json.load(response) - for target in targets: - if target.get('type') != 'page' or 'index.html' not in target.get('url', ''): - continue - with closing(websocket.create_connection(target['webSocketDebuggerUrl'], timeout=3, suppress_origin=True)) as connection: - connection.send(json.dumps({ - 'id': 1, 'method': 'Runtime.evaluate', 'params': { - 'expression': '''JSON.stringify({ - ready: !!document.querySelector('#content .main-page-inner'), - text: document.querySelector('#content')?.innerText?.slice(0, 2000) - })''', 'returnByValue': True, - }, - })) - while True: - result = json.loads(connection.recv()) - if result.get('id') != 1: - continue - state = json.loads(result['result']['result']['value']) - if state.get('ready') and state.get('text'): - print('Vortex renderer loaded its main page') - return - last_error = repr(state) - break - except (OSError, ValueError, KeyError, websocket.WebSocketException) as error: - last_error = str(error) - time.sleep(1) - sys.exit(f'Vortex interface did not become ready: {last_error}') - - -if __name__ == '__main__': - check_ui() diff --git a/scripts/check-ui.py b/scripts/check-ui.py new file mode 100644 index 0000000..3a5cdb6 --- /dev/null +++ b/scripts/check-ui.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from contextlib import closing +import json +import sys +import time +import urllib.request + +import websocket + + +# Older pages render .main-page-inner, pages redesigned in Vortex 2.10 render +# id="page-". A page that failed to render gets that id too, around +# .render-failure. +MAIN_PAGE = '''JSON.stringify({ + ready: !!document.querySelector('#content .main-page-inner, #content [id^="page-"]') + && !document.querySelector('#content .render-failure'), + text: document.querySelector('#content')?.innerText?.slice(0, 2000) +})''' + +# Vortex reports a missing .NET in its interface, not on stdout. Run its probe +# from the renderer, as Vortex does at startup, so the check sees the +# environment the launcher gave the app. +DOTNET_PROBE = '''(() => { + const { execFileSync } = require('child_process'); + const probe = require('path').join(process.resourcesPath, 'app.asar.unpacked', 'assets', 'dotnetprobe'); + try { + return JSON.stringify({ ok: true, output: execFileSync(probe, ['9'], { encoding: 'utf8', timeout: 30000 }) }); + } catch (error) { + return JSON.stringify({ ok: false, output: String(error.stderr || error.message) }); + } +})()''' + + +def evaluate(connection, request_id, expression): + connection.send(json.dumps({ + 'id': request_id, 'method': 'Runtime.evaluate', + 'params': {'expression': expression, 'returnByValue': True}, + })) + while True: + result = json.loads(connection.recv()) + if result.get('id') == request_id: + return json.loads(result['result']['result']['value']) + + +def check_ui(): + deadline = time.monotonic() + 45 + last_error = 'No renderer target appeared' + while time.monotonic() < deadline: + try: + with urllib.request.urlopen('http://127.0.0.1:9222/json', timeout=2) as response: + targets = json.load(response) + for target in targets: + if target.get('type') != 'page' or 'index.html' not in target.get('url', ''): + continue + with closing(websocket.create_connection(target['webSocketDebuggerUrl'], timeout=3, suppress_origin=True)) as connection: + state = evaluate(connection, 1, MAIN_PAGE) + if not (state.get('ready') and state.get('text')): + last_error = repr(state) + continue + print('Vortex renderer loaded its main page') + connection.settimeout(40) + probe = evaluate(connection, 2, DOTNET_PROBE) + if not probe['ok']: + sys.exit(f'Vortex cannot run its .NET 9 probe: {probe["output"]}') + print(f'Vortex found .NET from its renderer: {probe["output"].strip()}') + return + except (OSError, ValueError, KeyError, websocket.WebSocketException) as error: + last_error = str(error) + time.sleep(1) + sys.exit(f'Vortex interface did not become ready: {last_error}') + + +if __name__ == '__main__': + check_ui() diff --git a/scripts/package-common.sh b/scripts/package-common.sh new file mode 100644 index 0000000..436d6ed --- /dev/null +++ b/scripts/package-common.sh @@ -0,0 +1,86 @@ +# Sourced by build-appimage.sh and build-distro-packages.sh, which both turn +# the Arch package into another release format. Expects REPOSITORY_ROOT. + +readonly METAINFO_NAME='com.nexusmods.vortex.metainfo.xml' + +# Vortex's native dotnetprobe is a framework-dependent net9.0 executable. The +# Arch package gets a host runtime through pacman, but the other formats carry +# their own: Debian has no .NET in its archive, and the startup check must +# also work on a clean distribution. +readonly DOTNET_RUNTIME_VERSION='9.0.18' +readonly DOTNET_RUNTIME_URL="https://builds.dotnet.microsoft.com/dotnet/Runtime/${DOTNET_RUNTIME_VERSION}/dotnet-runtime-${DOTNET_RUNTIME_VERSION}-linux-x64.tar.gz" +readonly DOTNET_RUNTIME_SHA512='97eb89a5a3781b9761e2a850996912ae81b96996089c78abcebbc441113457088bfcbd7cf1fa97d3036f7ac8fe22c7d30e043e93ecafc910f07b73317c73bad5' + +# read_package_metadata PACKAGE +# +# Sets package_name, package_revision, package_description, package_url, +# package_license and release_version, and stops unless PACKAGE is the +# x86_64 vortex-linux package for the version in .SRCINFO. +read_package_metadata() { + local package_path="$1" + local package_info package_arch + + if [[ ! -f "$package_path" ]]; then + printf 'Arch package was not found: %s\n' "$package_path" >&2 + exit 2 + fi + + package_info="$(bsdtar -xOf "$package_path" .PKGINFO)" + package_name="$(sed -n 's/^pkgname = //p' <<< "$package_info")" + package_revision="$(sed -n 's/^pkgver = //p' <<< "$package_info")" + package_arch="$(sed -n 's/^arch = //p' <<< "$package_info")" + package_description="$(sed -n 's/^pkgdesc = //p' <<< "$package_info")" + package_url="$(sed -n 's/^url = //p' <<< "$package_info")" + package_license="$(sed -n 's/^license = //p' <<< "$package_info")" + release_version="$(sed -n 's/^\tpkgver = //p' "$REPOSITORY_ROOT/.SRCINFO" | head -n 1)" + + if [[ "$package_name" != vortex-linux || -z "$package_revision" || \ + -z "$release_version" || "$package_revision" != "${release_version}-"* || \ + "$package_arch" != x86_64 || -z "$package_description" || \ + -z "$package_url" || -z "$package_license" ]]; then + printf 'Unsupported package metadata: %s %s %s\n' \ + "$package_name" "$package_revision" "$package_arch" >&2 + exit 2 + fi +} + +# install_dotnet_runtime DIRECTORY DOWNLOAD_PATH +install_dotnet_runtime() { + local directory="$1" + local archive="$2" + + curl --fail --location --silent --show-error \ + "$DOTNET_RUNTIME_URL" --output "$archive" + printf '%s %s\n' "$DOTNET_RUNTIME_SHA512" "$archive" \ + | sha512sum --check --status + install -dm755 "$directory" + bsdtar -xf "$archive" -C "$directory" + + if ! "$directory/dotnet" --list-runtimes \ + | grep -Fqx "Microsoft.NETCore.App ${DOTNET_RUNTIME_VERSION} [$directory/shared/Microsoft.NETCore.App]"; then + printf 'The bundled .NET %s runtime is incomplete\n' "$DOTNET_RUNTIME_VERSION" >&2 + return 1 + fi +} + +# install_metainfo PATH +# +# Writes the AppStream metadata for package_revision and validates it. +install_metainfo() { + local path="$1" + + install -dm755 "$(dirname -- "$path")" + sed \ + -e "s/@PACKAGE_REVISION@/${package_revision}/g" \ + -e "s/@RELEASE_DATE@/$(date -u +%Y-%m-%d)/g" \ + "$REPOSITORY_ROOT/appimage/$METAINFO_NAME" > "$path" + if grep -q '@[A-Z_]*@' "$path"; then + printf 'The AppStream metadata still contains placeholders\n' >&2 + exit 1 + fi + if command -v appstreamcli >/dev/null; then + appstreamcli validate --no-net "$path" + else + printf 'appstreamcli is not installed; skipping AppStream validation\n' >&2 + fi +} diff --git a/scripts/test-appimage-container.sh b/scripts/test-appimage-container.sh deleted file mode 100755 index 8794b0f..0000000 --- a/scripts/test-appimage-container.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -readonly SOURCE_APPIMAGE="${1:?usage: test-appimage-container.sh APPIMAGE}" -readonly TEST_ROOT='/tmp/vortex-appimage-smoke' -readonly TEST_APPIMAGE="${TEST_ROOT}/Vortex.AppImage" -readonly STARTUP_LOG="${TEST_ROOT}/startup.log" -readonly DEPENDENCY_LOG="${TEST_ROOT}/dependencies.log" - -if command -v dotnet >/dev/null 2>&1; then - printf 'The clean-container test image unexpectedly contains dotnet\n' >&2 - exit 1 -fi - -install -Dm755 "$SOURCE_APPIMAGE" "$TEST_APPIMAGE" - -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -if ! apt-get install --quiet=2 --no-install-recommends --yes \ - dbus-x11 \ - libasound2t64 \ - libatk-bridge2.0-0 \ - libatspi2.0-0 \ - libcups2t64 \ - libdrm2 \ - libgbm1 \ - libgtk-3-0t64 \ - libicu74 \ - libnss3 \ - libxcomposite1 \ - libxdamage1 \ - libxfixes3 \ - libxkbcommon0 \ - libxrandr2 \ - libxss1 \ - python3-websocket \ - wine \ - wine64 \ - xauth \ - xvfb >"$DEPENDENCY_LOG" 2>&1; then - printf 'Unable to install clean-container runtime dependencies\n' >&2 - sed -n '1,240p' "$DEPENDENCY_LOG" >&2 - exit 1 -fi - -if command -v dotnet >/dev/null 2>&1; then - printf 'Host dotnet was pulled in by runtime dependencies; test is not hermetic\n' >&2 - exit 1 -fi - -for command_name in dbus-run-session wine xvfb-run; do - if ! command -v "$command_name" >/dev/null 2>&1; then - printf 'Clean-container runtime command is missing: %s\n' "$command_name" >&2 - exit 1 - fi -done - -printf 'Running AppImage runtime self-test without host .NET\n' -PREBUILDS_ONLY=1 APPIMAGE_EXTRACT_AND_RUN=1 \ - timeout 120s xvfb-run --auto-servernum "$TEST_APPIMAGE" --vortex-self-test - -useradd --create-home --shell /bin/bash vortex-test -install -o vortex-test -g vortex-test -m755 "$TEST_APPIMAGE" /home/vortex-test/Vortex.AppImage - -printf 'Starting the full AppImage under Xvfb\n' -runuser --user vortex-test -- env \ - HOME=/home/vortex-test \ - XDG_CONFIG_HOME=/home/vortex-test/.config \ - XDG_DATA_HOME=/home/vortex-test/.local/share \ - APPIMAGE_EXTRACT_AND_RUN=1 \ - dbus-run-session -- \ - xvfb-run --auto-servernum /home/vortex-test/Vortex.AppImage --no-sandbox \ - --remote-debugging-port=9222 >"$STARTUP_LOG" 2>&1 & -app_pid=$! -cleanup() { - kill "$app_pid" 2>/dev/null || true - pkill -u vortex-test 2>/dev/null || true - wait "$app_pid" 2>/dev/null || true -} -trap cleanup EXIT - -if ! python3 /check-appimage-ui.py; then - sed -n '1,240p' "$STARTUP_LOG" >&2 - find /home/vortex-test/.config -name vortex.log -exec tail -n 100 {} \; >&2 - exit 1 -fi - -sleep 5 -if ! kill -0 "$app_pid" 2>/dev/null; then - printf 'AppImage exited after loading its main page\n' >&2 - sed -n '1,240p' "$STARTUP_LOG" >&2 - exit 1 -fi -if grep -Eq 'You must install \.NET|render process gone|Segmentation fault|MODULE_NOT_FOUND' "$STARTUP_LOG"; then - printf 'AppImage reported a runtime failure\n' >&2 - sed -n '1,240p' "$STARTUP_LOG" >&2 - exit 1 -fi -printf 'AppImage fixtures and desktop startup passed without system .NET\n' diff --git a/scripts/test-appimage.sh b/scripts/test-appimage.sh deleted file mode 100755 index 4cd8d25..0000000 --- a/scripts/test-appimage.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -readonly REPOSITORY_ROOT -readonly APPIMAGE_PATH="${1:?usage: test-appimage.sh APPIMAGE [CONTAINER_IMAGE]}" -readonly CONTAINER_IMAGE="${2:-ubuntu:24.04}" - -if ! command -v docker >/dev/null 2>&1; then - printf 'Docker is required to run the clean AppImage smoke test\n' >&2 - exit 2 -fi - -if [[ ! -f "$APPIMAGE_PATH" ]]; then - printf 'AppImage was not found: %s\n' "$APPIMAGE_PATH" >&2 - exit 2 -fi - -ABSOLUTE_APPIMAGE_PATH="$(cd -- "$(dirname -- "$APPIMAGE_PATH")" && pwd)/$(basename -- "$APPIMAGE_PATH")" -readonly ABSOLUTE_APPIMAGE_PATH - -printf 'Testing %s in a fresh %s container\n' \ - "$ABSOLUTE_APPIMAGE_PATH" "$CONTAINER_IMAGE" -docker run --rm --platform linux/amd64 \ - --volume "$ABSOLUTE_APPIMAGE_PATH:/artifact/Vortex.AppImage:ro" \ - --volume "$REPOSITORY_ROOT/scripts/test-appimage-container.sh:/test-appimage-container.sh:ro" \ - --volume "$REPOSITORY_ROOT/scripts/check-appimage-ui.py:/check-appimage-ui.py:ro" \ - "$CONTAINER_IMAGE" \ - bash /test-appimage-container.sh /artifact/Vortex.AppImage diff --git a/scripts/test-package-container.sh b/scripts/test-package-container.sh new file mode 100755 index 0000000..02f5f6b --- /dev/null +++ b/scripts/test-package-container.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly SOURCE_ARTIFACT="${1:?usage: test-package-container.sh ARTIFACT}" +readonly TEST_ROOT='/tmp/vortex-package-smoke' +TEST_ARTIFACT="${TEST_ROOT}/$(basename -- "$SOURCE_ARTIFACT")" +readonly TEST_ARTIFACT +readonly STARTUP_LOG="${TEST_ROOT}/startup.log" +readonly DEPENDENCY_LOG="${TEST_ROOT}/dependencies.log" +readonly VORTEX_ROOT='/opt/Vortex' +readonly TEST_HOME='/home/vortex-test' + +if command -v dotnet >/dev/null 2>&1; then + printf 'The clean-container test image unexpectedly contains dotnet\n' >&2 + exit 1 +fi + +# apt reads local packages as its sandbox user, which can't see the bind mount. +install -Dm755 "$SOURCE_ARTIFACT" "$TEST_ARTIFACT" + +. /etc/os-release +printf 'Testing %s on %s\n' "$(basename -- "$TEST_ARTIFACT")" "$PRETTY_NAME" + +# The test harness: an X server, a D-Bus session and a DevTools client. +if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + install_packages() { + apt-get install --quiet=2 --no-install-recommends --yes "$@" + } + remove_package() { + apt-get purge --quiet=2 --yes vortex-linux + } + harness=(dbus-x11 procps python3-websocket xauth xvfb) +elif command -v dnf >/dev/null 2>&1; then + install_packages() { + dnf install --quiet --assumeyes --setopt=install_weak_deps=False "$@" + } + remove_package() { + dnf remove --quiet --assumeyes vortex-linux + } + harness=(dbus-daemon procps-ng python3-websocket-client shadow-utils + util-linux xorg-x11-server-Xvfb xorg-x11-xauth) +elif command -v zypper >/dev/null 2>&1; then + # Release packages are not signed. + install_packages() { + zypper --non-interactive --quiet install --no-recommends \ + --allow-unsigned-rpm "$@" + } + remove_package() { + zypper --non-interactive --quiet remove vortex-linux + } + # Minimal openSUSE images lack the awk xvfb-run uses and the python3 + # command. + harness=(dbus-1-daemon gawk procps python3-base + python3-websocket-client shadow util-linux xauth xvfb-run) +else + printf 'The test image has no supported package manager\n' >&2 + exit 2 +fi + +case "$TEST_ARTIFACT" in + *.AppImage) + if ! command -v apt-get >/dev/null 2>&1; then + printf 'The AppImage test installs its libraries with apt\n' >&2 + exit 2 + fi + # The AppImage carries .NET. Wine and the libraries Electron needs come + # from the system, named as in Ubuntu 24.04. + is_package=false + packages=(libasound2t64 libatk-bridge2.0-0 libatspi2.0-0 libcups2t64 + libdrm2 libgbm1 libgtk-3-0t64 libicu74 libnss3 libxcomposite1 + libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 libxss1 wine wine64) + ;; + *.deb|*.rpm) + # The package goes in alone, with only its own hard dependencies, so the + # library check below sees what a user's installation gets. The harness + # follows afterwards. + is_package=true + packages=("$TEST_ARTIFACT") + ;; + *) + printf 'Expected an AppImage, .deb or .rpm: %s\n' "$TEST_ARTIFACT" >&2 + exit 2 + ;; +esac + +install_or_fail() { + local what="$1" + shift + if ! install_packages "$@" >>"$DEPENDENCY_LOG" 2>&1; then + printf 'Unable to install %s\n' "$what" >&2 + sed -n '1,240p' "$DEPENDENCY_LOG" >&2 + exit 1 + fi +} + +if [[ "$is_package" == true ]]; then + install_or_fail 'the package' "${packages[@]}" +else + install_or_fail 'the dependencies and the test harness' "${packages[@]}" "${harness[@]}" +fi + +if [[ "$is_package" == true ]]; then + sandbox_mode="$(stat -c '%U:%G %a' "$VORTEX_ROOT/chrome-sandbox")" + if [[ "$sandbox_mode" != 'root:root 4755' ]]; then + printf 'chrome-sandbox is installed as %s, not setuid root\n' "$sandbox_mode" >&2 + exit 1 + fi + + # Every library the binaries link must come from the declared dependencies, + # in a version this distribution has. The .NET LTTng tracing provider is + # optional and only loaded when LTTng is installed. + printf 'Checking the shared libraries of every packaged binary\n' + # Minimal openSUSE images have no find or file, so walk the tree in bash + # and look for the 64-bit ELF magic. + unresolved=0 + shopt -s globstar dotglob nullglob + for elf_file in "$VORTEX_ROOT"/**; do + [[ -f "$elf_file" && ! -L "$elf_file" ]] || continue + [[ "$(head -c 5 -- "$elf_file" | od -An -tx1 | tr -d ' \n')" == 7f454c4602 ]] || continue + [[ "$elf_file" == */libcoreclrtraceptprovider.so ]] && continue + if ! dependencies="$(ldd "$elf_file" 2>&1)"; then + grep -Fq 'not a dynamic executable' <<< "$dependencies" && continue + fi + if grep -Fq 'not found' <<< "$dependencies"; then + printf '%s:\n%s\n' "$elf_file" "$(grep -F 'not found' <<< "$dependencies")" >&2 + unresolved=1 + fi + done + shopt -u globstar dotglob nullglob + if ((unresolved)); then + printf 'Packaged binaries need libraries this system does not provide\n' >&2 + exit 1 + fi + + install_or_fail 'the test harness' "${harness[@]}" +fi + +if command -v dotnet >/dev/null 2>&1; then + printf 'Host dotnet was pulled in by the dependencies; test is not hermetic\n' >&2 + exit 1 +fi + +runtime_commands=(dbus-run-session wine xvfb-run) +if [[ "$is_package" == true ]]; then + runtime_commands+=(vortex) +fi +for command_name in "${runtime_commands[@]}"; do + if ! command -v "$command_name" >/dev/null 2>&1; then + printf 'Runtime command is missing after installation: %s\n' "$command_name" >&2 + exit 1 + fi +done + +# The self-test opens no window. It runs without a display because Wine 11 +# stalls while creating a prefix under Xvfb, which has no DRI3. +printf 'Running the runtime self-test without host .NET\n' +if [[ "$is_package" == true ]]; then + ELECTRON_RUN_AS_NODE=1 VORTEX_DOTNET_WIN_ROOT="$VORTEX_ROOT/dotnet-win-x64" \ + timeout 120s "$VORTEX_ROOT/vortex" \ + /vortex-tests/test-runtime.cjs "$VORTEX_ROOT/resources/app.asar" + DOTNET_ROOT="$VORTEX_ROOT/dotnet-linux-x64" DOTNET_ROLL_FORWARD=Major \ + "$VORTEX_ROOT/resources/app.asar.unpacked/assets/dotnetprobe" 9 +else + PREBUILDS_ONLY=1 APPIMAGE_EXTRACT_AND_RUN=1 \ + timeout 120s "$TEST_ARTIFACT" --vortex-self-test +fi + +useradd --create-home --shell /bin/bash vortex-test +if [[ "$is_package" == true ]]; then + vortex_command=vortex +else + vortex_command="$TEST_HOME/Vortex.AppImage" + install -o vortex-test -g vortex-test -m755 "$TEST_ARTIFACT" "$vortex_command" +fi + +# The container has no FUSE, so the AppImage extracts itself. +printf 'Starting Vortex under Xvfb\n' +runuser --user vortex-test -- env \ + HOME="$TEST_HOME" \ + XDG_CONFIG_HOME="$TEST_HOME/.config" \ + XDG_DATA_HOME="$TEST_HOME/.local/share" \ + APPIMAGE_EXTRACT_AND_RUN=1 \ + dbus-run-session -- \ + xvfb-run --auto-servernum "$vortex_command" --no-sandbox \ + --remote-debugging-port=9222 >"$STARTUP_LOG" 2>&1 & +app_pid=$! +cleanup() { + kill "$app_pid" 2>/dev/null || true + pkill -u vortex-test 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true +} +trap cleanup EXIT + +if ! python3 /check-ui.py; then + sed -n '1,240p' "$STARTUP_LOG" >&2 + find "$TEST_HOME/.config" -name vortex.log -exec tail -n 100 {} \; >&2 + exit 1 +fi + +sleep 5 +if ! kill -0 "$app_pid" 2>/dev/null; then + printf 'Vortex exited after loading its main page\n' >&2 + sed -n '1,240p' "$STARTUP_LOG" >&2 + exit 1 +fi +if grep -Eq 'You must install \.NET|render process gone|Segmentation fault|MODULE_NOT_FOUND' "$STARTUP_LOG"; then + printf 'Vortex reported a runtime failure\n' >&2 + sed -n '1,240p' "$STARTUP_LOG" >&2 + exit 1 +fi +cleanup +trap - EXIT + +if [[ "$is_package" == false ]]; then + printf 'AppImage fixtures and desktop startup passed on %s without system .NET\n' \ + "$PRETTY_NAME" + exit 0 +fi + +printf 'Removing the package\n' +if ! remove_package >>"$DEPENDENCY_LOG" 2>&1; then + printf 'Unable to remove the package\n' >&2 + tail -n 60 "$DEPENDENCY_LOG" >&2 + exit 1 +fi +for leftover in "$VORTEX_ROOT" /usr/bin/vortex \ + /usr/share/applications/com.nexusmods.vortex.desktop; do + if [[ -e "$leftover" ]]; then + printf 'Removing the package left %s behind\n' "$leftover" >&2 + exit 1 + fi +done + +printf 'Package installed, started and removed cleanly on %s without system .NET\n' \ + "$PRETTY_NAME" diff --git a/scripts/test-package.sh b/scripts/test-package.sh new file mode 100755 index 0000000..0154785 --- /dev/null +++ b/scripts/test-package.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly REPOSITORY_ROOT +readonly ARTIFACT_PATH="${1:?usage: test-package.sh ARTIFACT [CONTAINER_IMAGE]}" +case "$ARTIFACT_PATH" in + *.AppImage|*.deb) default_image='ubuntu:24.04' ;; + *.rpm) default_image='fedora:44' ;; + *) + printf 'Expected an AppImage, .deb or .rpm: %s\n' "$ARTIFACT_PATH" >&2 + exit 2 + ;; +esac +readonly CONTAINER_IMAGE="${2:-$default_image}" + +if ! command -v docker >/dev/null 2>&1; then + printf 'Docker is required to run the clean-container smoke test\n' >&2 + exit 2 +fi + +if [[ ! -f "$ARTIFACT_PATH" ]]; then + printf 'Artifact was not found: %s\n' "$ARTIFACT_PATH" >&2 + exit 2 +fi + +ABSOLUTE_ARTIFACT_PATH="$(cd -- "$(dirname -- "$ARTIFACT_PATH")" && pwd)/$(basename -- "$ARTIFACT_PATH")" +readonly ABSOLUTE_ARTIFACT_PATH +readonly CONTAINER_ARTIFACT_PATH="/artifact/$(basename -- "$ARTIFACT_PATH")" + +printf 'Testing %s in a fresh %s container\n' \ + "$ABSOLUTE_ARTIFACT_PATH" "$CONTAINER_IMAGE" +docker run --rm --platform linux/amd64 \ + --volume "$ABSOLUTE_ARTIFACT_PATH:$CONTAINER_ARTIFACT_PATH:ro" \ + --volume "$REPOSITORY_ROOT/scripts/test-package-container.sh:/test-package-container.sh:ro" \ + --volume "$REPOSITORY_ROOT/scripts/check-ui.py:/check-ui.py:ro" \ + --volume "$REPOSITORY_ROOT/scripts/test-runtime.cjs:/vortex-tests/test-runtime.cjs:ro" \ + --volume "$REPOSITORY_ROOT/scripts/test-loot.cjs:/vortex-tests/test-loot.cjs:ro" \ + "$CONTAINER_IMAGE" \ + bash /test-package-container.sh "$CONTAINER_ARTIFACT_PATH" diff --git a/vortex.sh b/vortex.sh index da426f9..24dbe02 100644 --- a/vortex.sh +++ b/vortex.sh @@ -4,6 +4,14 @@ export ELECTRON_TRASH="${ELECTRON_TRASH:-gio}" # FOMOD needs build/Release. unset PREBUILDS_ONLY +# The Debian package brings the Linux .NET runtime Vortex probes for at startup; +# the Arch package uses the system one. +if [ -x /opt/Vortex/dotnet-linux-x64/dotnet ]; then + export DOTNET_ROOT=/opt/Vortex/dotnet-linux-x64 + export DOTNET_ROOT_X64="$DOTNET_ROOT" + export PATH="$DOTNET_ROOT:${PATH:-/usr/local/bin:/usr/bin:/bin}" +fi + export VORTEX_DOTNET_WIN_ROOT="${VORTEX_DOTNET_WIN_ROOT:-/opt/Vortex/dotnet-win-x64}" export VORTEX_WINEPREFIX="${VORTEX_WINEPREFIX:-${XDG_DATA_HOME:-$HOME/.local/share}/vortex-linux/wineprefix}" export DOTNET_ROLL_FORWARD="${DOTNET_ROLL_FORWARD:-Major}"