From 7305d9d203169fcbcbcc2b8dfb4bca5022ca3799 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 22:16:40 +0100 Subject: [PATCH 01/22] Share the bundled .NET runtime download --- scripts/build-appimage.sh | 21 +++------------------ scripts/dotnet-runtime.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) create mode 100644 scripts/dotnet-runtime.sh diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index b4136ce..43c8b5f 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -8,13 +8,12 @@ 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/dotnet-runtime.sh +source "$REPOSITORY_ROOT/scripts/dotnet-runtime.sh" for command_name in bsdtar curl file install ldd patchelf readelf sha256sum sha512sum; do if ! command -v "$command_name" >/dev/null; then @@ -94,21 +93,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" diff --git a/scripts/dotnet-runtime.sh b/scripts/dotnet-runtime.sh new file mode 100644 index 0000000..b8453c0 --- /dev/null +++ b/scripts/dotnet-runtime.sh @@ -0,0 +1,28 @@ +# Sourced by build-appimage.sh and build-deb-package.sh. +# +# Vortex's native dotnetprobe is a framework-dependent net9.0 executable. The +# Arch package gets a host runtime through pacman, but the AppImage and the +# Debian package 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' + +# 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 +} From 7bd258e979e65402714b3e5abc5e16d3809764ec Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 22:16:40 +0100 Subject: [PATCH 02/22] Check .NET from inside the running app in smoke tests Vortex reports a missing .NET in its window, so grepping stdout for it never failed. --- scripts/check-appimage-ui.py | 57 +++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/scripts/check-appimage-ui.py b/scripts/check-appimage-ui.py index 98db585..0062864 100644 --- a/scripts/check-appimage-ui.py +++ b/scripts/check-appimage-ui.py @@ -8,6 +8,36 @@ import websocket +MAIN_PAGE = '''JSON.stringify({ + ready: !!document.querySelector('#content .main-page-inner'), + 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' @@ -19,24 +49,17 @@ def check_ui(): 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 + state = evaluate(connection, 1, MAIN_PAGE) + if not (state.get('ready') and state.get('text')): last_error = repr(state) - break + 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) From aea1ee861114ffd9fd7e43f6b73f56974a922167 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 22:16:40 +0100 Subject: [PATCH 03/22] Build a Debian package for Debian and Ubuntu --- appimage/com.nexusmods.vortex.metainfo.xml | 5 +- scripts/build-deb-package.sh | 253 +++++++++++++++++++++ scripts/test-deb-package-container.sh | 138 +++++++++++ scripts/test-deb-package.sh | 32 +++ vortex.sh | 8 + 5 files changed, 434 insertions(+), 2 deletions(-) create mode 100755 scripts/build-deb-package.sh create mode 100755 scripts/test-deb-package-container.sh create mode 100755 scripts/test-deb-package.sh diff --git a/appimage/com.nexusmods.vortex.metainfo.xml b/appimage/com.nexusmods.vortex.metainfo.xml index 93c6ca4..8ea5a56 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/scripts/build-deb-package.sh b/scripts/build-deb-package.sh new file mode 100755 index 0000000..3889575 --- /dev/null +++ b/scripts/build-deb-package.sh @@ -0,0 +1,253 @@ +#!/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-deb-package.sh PACKAGE [OUTPUT_DIRECTORY]}" +readonly OUTPUT_DIRECTORY="${2:-${REPOSITORY_ROOT}/dist}" +readonly METAINFO_NAME='com.nexusmods.vortex.metainfo.xml' +# shellcheck source=scripts/dotnet-runtime.sh +source "$REPOSITORY_ROOT/scripts/dotnet-runtime.sh" + +# Package names as Ubuntu 24.04 and Debian 13 ship them. The libc6 and +# libstdc++6 versions are added from the binaries below. .NET loads whichever +# ICU it finds, so any of these will do. +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' + 'libicu74 | libicu75 | libicu76 | libicu77 | libicu78 | libicu79 | libicu80' + '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' +) + +for command_name in bsdtar curl dpkg-deb file install objdump 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 + +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")" +release_version="$(sed -n 's/^\tpkgver = //p' "$REPOSITORY_ROOT/.SRCINFO" | head -n 1)" +maintainer="$(sed -n 's/^# Maintainer: //p' "$REPOSITORY_ROOT/PKGBUILD")" + +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 "$maintainer" ]]; then + printf 'Unsupported package metadata: %s %s %s\n' \ + "$package_name" "$package_revision" "$package_arch" >&2 + exit 2 +fi +release_date="$(date -u +%Y-%m-%d)" + +work_directory="$(mktemp -d "${TMPDIR:-/tmp}/vortex-deb.XXXXXX")" +cleanup() { + rm -rf -- "$work_directory" +} +trap cleanup EXIT + +package_root="$work_directory/root" +doc_directory="$package_root/usr/share/doc/$package_name" +install -dm755 "$package_root" + +bsdtar -xf "$PACKAGE_PATH" -C "$package_root" \ + 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 "$package_root/opt/Vortex/chrome-sandbox" + +install_dotnet_runtime "$package_root/opt/Vortex/dotnet-linux-x64" \ + "$work_directory/dotnet-runtime-linux-x64.tar.gz" +install -Dm755 "$REPOSITORY_ROOT/vortex.sh" "$package_root/usr/bin/vortex" + +# Debian keeps licenses next to the documentation and expects a copyright file. +mv "$package_root/usr/share/licenses/vortex-linux"/* "$doc_directory/" +rm -r "$package_root/usr/share/licenses" +cat > "$doc_directory/copyright" < "$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 + +# 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 "$package_root" -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' <<< "$*" +} + +installed_size="$(du -sk --apparent-size "$package_root" | cut -f1)" +install -dm755 "$package_root/DEBIAN" +cat > "$package_root/DEBIAN/control" < "$package_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 "$package_root/DEBIAN/postinst" + +install -dm755 "$OUTPUT_DIRECTORY" +deb_archive="${package_name}_${release_version}_amd64.deb" +dpkg-deb --root-owner-group -Zxz --build "$package_root" "$OUTPUT_DIRECTORY/$deb_archive" + +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 package: %s\n' "$sandbox_entry" >&2 + exit 1 +fi + +( + cd "$OUTPUT_DIRECTORY" + sha256sum "$deb_archive" > "${deb_archive}.sha256" + sha256sum --check "${deb_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" +fi + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat >> "$GITHUB_STEP_SUMMARY" <= ${glibc_minimum})\`${libstdcxx_minimum:+, \`libstdc++6 (>= ${libstdcxx_minimum})\`} +- Bundled Linux .NET runtime: \`${DOTNET_RUNTIME_VERSION}\` +EOF +fi + +printf 'Built Debian package: %s\n' "$OUTPUT_DIRECTORY/$deb_archive" diff --git a/scripts/test-deb-package-container.sh b/scripts/test-deb-package-container.sh new file mode 100755 index 0000000..1c245cc --- /dev/null +++ b/scripts/test-deb-package-container.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly SOURCE_PACKAGE="${1:?usage: test-deb-package-container.sh DEB}" +readonly TEST_ROOT='/tmp/vortex-deb-smoke' +readonly TEST_PACKAGE="${TEST_ROOT}/vortex-linux.deb" +readonly STARTUP_LOG="${TEST_ROOT}/startup.log" +readonly DEPENDENCY_LOG="${TEST_ROOT}/dependencies.log" +readonly VORTEX_ROOT='/opt/Vortex' + +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 -Dm644 "$SOURCE_PACKAGE" "$TEST_PACKAGE" + +. /etc/os-release +printf 'Installing the package on %s\n' "$PRETTY_NAME" + +# Only the package's own dependencies are installed with it. The rest is the +# test harness: an X server, a D-Bus session and a DevTools client. +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +if ! apt-get install --quiet=2 --no-install-recommends --yes \ + "$TEST_PACKAGE" \ + dbus-x11 \ + file \ + procps \ + python3-websocket \ + xauth \ + xvfb >"$DEPENDENCY_LOG" 2>&1; then + printf 'Unable to install the package and the test harness\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 the package dependencies; test is not hermetic\n' >&2 + exit 1 +fi + +for command_name in dbus-run-session vortex wine xvfb-run; 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 + +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' +unresolved=0 +while IFS= read -r elf_file; do + [[ "$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 < <( + find "$VORTEX_ROOT" -type f -print0 \ + | xargs -0 file --no-pad --separator '|' \ + | sed -n 's/| ELF 64-bit .*//p' +) +if ((unresolved)); then + printf 'Packaged binaries need libraries this system does not provide\n' >&2 + exit 1 +fi + +printf 'Running the runtime self-test without host .NET\n' +ELECTRON_RUN_AS_NODE=1 VORTEX_DOTNET_WIN_ROOT="$VORTEX_ROOT/dotnet-win-x64" \ + timeout 120s xvfb-run --auto-servernum \ + "$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 + +useradd --create-home --shell /bin/bash vortex-test + +printf 'Starting Vortex through its launcher 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 \ + dbus-run-session -- \ + xvfb-run --auto-servernum vortex --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 '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 + +printf 'Removing the package\n' +apt-get purge --quiet=2 --yes vortex-linux >>"$DEPENDENCY_LOG" 2>&1 +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-deb-package.sh b/scripts/test-deb-package.sh new file mode 100755 index 0000000..32c98a1 --- /dev/null +++ b/scripts/test-deb-package.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly REPOSITORY_ROOT +readonly PACKAGE_PATH="${1:?usage: test-deb-package.sh DEB [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 Debian package smoke test\n' >&2 + exit 2 +fi + +if [[ ! -f "$PACKAGE_PATH" ]]; then + printf 'Debian package was not found: %s\n' "$PACKAGE_PATH" >&2 + exit 2 +fi + +ABSOLUTE_PACKAGE_PATH="$(cd -- "$(dirname -- "$PACKAGE_PATH")" && pwd)/$(basename -- "$PACKAGE_PATH")" +readonly ABSOLUTE_PACKAGE_PATH + +printf 'Testing %s in a fresh %s container\n' \ + "$ABSOLUTE_PACKAGE_PATH" "$CONTAINER_IMAGE" +docker run --rm --platform linux/amd64 \ + --volume "$ABSOLUTE_PACKAGE_PATH:/artifact/vortex-linux.deb:ro" \ + --volume "$REPOSITORY_ROOT/scripts/test-deb-package-container.sh:/test-deb-package-container.sh:ro" \ + --volume "$REPOSITORY_ROOT/scripts/check-appimage-ui.py:/check-appimage-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-deb-package-container.sh /artifact/vortex-linux.deb 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}" From a19a928a447e228313791b8bd91f8e9e62759ace Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 22:16:40 +0100 Subject: [PATCH 04/22] Build, test and publish the Debian package in CI --- .github/workflows/upstream-compatibility.yml | 83 +++++++++++++++++++- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/.github/workflows/upstream-compatibility.yml b/.github/workflows/upstream-compatibility.yml index ced4f0c..436cb28 100644 --- a/.github/workflows/upstream-compatibility.yml +++ b/.github/workflows/upstream-compatibility.yml @@ -180,6 +180,8 @@ 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" ) release_needed=false for required_asset in "${required_assets[@]}"; do @@ -283,6 +285,8 @@ 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.deb.outputs.deb_archive }} + deb_checksum: ${{ steps.deb.outputs.deb_checksum }} steps: - name: Check out packaging repository uses: actions/checkout@v6 @@ -308,9 +312,10 @@ 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-deb-package.sh validate the AppStream + # metadata when appstreamcli is available, and the Debian package is + # built with dpkg-deb. The Arch package needs neither. + pacman -S --needed --noconfirm appstream dpkg useradd --create-home --shell /bin/bash builder chown -R builder:builder "$GITHUB_WORKSPACE" df -h "$GITHUB_WORKSPACE" @@ -335,6 +340,16 @@ jobs: scripts/build-appimage.sh \ "dist/${{ steps.package.outputs.package_archive }}" + - name: Build Debian package + id: deb + run: | + chown builder:builder "$GITHUB_OUTPUT" "$GITHUB_STEP_SUMMARY" + runuser --user builder -- env \ + HOME=/home/builder \ + XDG_CACHE_HOME=/home/builder/.cache \ + scripts/build-deb-package.sh \ + "dist/${{ steps.package.outputs.package_archive }}" + - name: Upload release artifacts uses: actions/upload-artifact@v4 with: @@ -345,6 +360,8 @@ jobs: dist/${{ steps.appimage.outputs.appimage_archive }} dist/${{ steps.appimage.outputs.appimage_checksum }} dist/${{ steps.appimage.outputs.appimage_zsync }} + dist/${{ steps.deb.outputs.deb_archive }} + dist/${{ steps.deb.outputs.deb_checksum }} if-no-files-found: error compression-level: 0 retention-days: 30 @@ -373,6 +390,39 @@ jobs: ) scripts/test-appimage.sh "dist/${{ needs.build-package.outputs.appimage_archive }}" + test-deb-package: + name: Test Debian package on ${{ matrix.image }} + needs: build-package + if: ${{ needs.build-package.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + image: + - ubuntu:24.04 + - ubuntu:26.04 + - debian:13 + steps: + - name: Check out packaging repository + uses: actions/checkout@v6 + + - name: Download release artifacts + uses: actions/download-artifact@v5 + with: + name: vortex-linux-${{ needs.build-package.outputs.package_version }} + path: dist + + - name: Verify and install the package in a clean container + run: | + ( + cd dist || exit 1 + sha256sum --check "${{ needs.build-package.outputs.deb_checksum }}" + ) + scripts/test-deb-package.sh \ + "dist/${{ needs.build-package.outputs.deb_archive }}" \ + "${{ matrix.image }}" + finalize-source: name: Record tested upstream release needs: @@ -380,9 +430,11 @@ jobs: - prepare-release - build-package - test-appimage + - test-deb-package if: >- ${{ needs.test-appimage.result == 'success' && + needs.test-deb-package.result == 'success' && github.ref == 'refs/heads/master' && needs.prepare-release.outputs.release_needed == 'true' }} @@ -461,6 +513,7 @@ jobs: - prepare-release - build-package - test-appimage + - test-deb-package - finalize-source if: >- ${{ @@ -479,6 +532,8 @@ 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 }} UPSTREAM_COMMIT: ${{ needs.compatibility.outputs.upstream_commit }} SOURCE_COMMIT: ${{ needs.finalize-source.outputs.source_commit }} steps: @@ -490,7 +545,7 @@ jobs: - name: Verify downloaded artifacts working-directory: dist - run: sha256sum --check "$CHECKSUM_FILE" "$APPIMAGE_CHECKSUM" + run: sha256sum --check "$CHECKSUM_FILE" "$APPIMAGE_CHECKSUM" "$DEB_CHECKSUM" - name: Download release notes uses: actions/download-artifact@v5 @@ -523,6 +578,15 @@ 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} + \`\`\` + ## Other x86_64 Linux distributions Install Wine with your distribution's package manager, then: @@ -566,6 +630,8 @@ jobs: "dist/$CHECKSUM_FILE" \ "dist/$APPIMAGE_ARCHIVE" \ "dist/$APPIMAGE_CHECKSUM" \ + "dist/$DEB_ARCHIVE" \ + "dist/$DEB_CHECKSUM" \ "dist/$APPIMAGE_ZSYNC" \ --repo "$GITHUB_REPOSITORY" \ --clobber @@ -580,6 +646,8 @@ jobs: "dist/$CHECKSUM_FILE" \ "dist/$APPIMAGE_ARCHIVE" \ "dist/$APPIMAGE_CHECKSUM" \ + "dist/$DEB_ARCHIVE" \ + "dist/$DEB_CHECKSUM" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "Vortex for Linux ${PACKAGE_REVISION}" \ @@ -591,6 +659,8 @@ jobs: "dist/$CHECKSUM_FILE" \ "dist/$APPIMAGE_ARCHIVE" \ "dist/$APPIMAGE_CHECKSUM" \ + "dist/$DEB_ARCHIVE" \ + "dist/$DEB_CHECKSUM" \ "dist/$APPIMAGE_ZSYNC" \ --repo "$GITHUB_REPOSITORY" \ --target "$SOURCE_COMMIT" \ @@ -614,6 +684,7 @@ jobs: - prepare-release - build-package - test-appimage + - test-deb-package - finalize-source - publish-package if: ${{ always() && github.event_name == 'schedule' }} @@ -633,6 +704,7 @@ jobs: PREPARE_STATUS: ${{ needs.prepare-release.result }} BUILD_STATUS: ${{ needs.build-package.result }} APPIMAGE_STATUS: ${{ needs.test-appimage.result }} + DEB_STATUS: ${{ needs.test-deb-package.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 }} @@ -651,6 +723,7 @@ jobs: "$PREPARE_STATUS" "$BUILD_STATUS" "$APPIMAGE_STATUS" + "$DEB_STATUS" "$FINALIZE_STATUS" "$PUBLISH_STATUS" ) @@ -667,6 +740,7 @@ jobs: required_statuses=( "$BUILD_STATUS" "$APPIMAGE_STATUS" + "$DEB_STATUS" "$FINALIZE_STATUS" "$PUBLISH_STATUS" ) @@ -698,6 +772,7 @@ jobs: "- Metadata: \`${PREPARE_STATUS}\`" \ "- Package build: \`${BUILD_STATUS}\`" \ "- AppImage smoke test: \`${APPIMAGE_STATUS}\`" \ + "- Debian package smoke test: \`${DEB_STATUS}\`" \ "- Source update: \`${FINALIZE_STATUS}\`" \ "- Release publish: \`${PUBLISH_STATUS}\`" \ "- Workflow run: ${RUN_URL}" \ From aed927fda9e3a144b47fd2d4fa356a818f07e469 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 22:16:40 +0100 Subject: [PATCH 05/22] Document the Debian package and bump pkgrel --- .SRCINFO | 2 +- CHANGELOG.md | 6 ++++++ PKGBUILD | 2 +- README.md | 13 +++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.SRCINFO b/.SRCINFO index be9f633..d069ff5 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches pkgver = 2.9.1 - pkgrel = 1 + pkgrel = 2 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f52f3c..7ea941c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- Debian and Ubuntu package: releases now include + `vortex-linux__amd64.deb` for Ubuntu 24.04 or newer and Debian 13 or + newer. `sudo apt install ./vortex-linux_*_amd64.deb` installs Vortex with its + dependencies and an app menu entry. Like the AppImage, it brings its 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..2a44734 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -2,7 +2,7 @@ pkgname=vortex-linux pkgver=2.9.1 -pkgrel=1 +pkgrel=2 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" diff --git a/README.md b/README.md index 88ae53d..a39bbb9 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,19 @@ 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. + ### Everything else (AppImage) Install Wine from your package manager first, then: From 55a1b25e97623c99d1232812314e370ea6a04c3c Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 22:49:58 +0100 Subject: [PATCH 06/22] Build an RPM package for Fedora and openSUSE One script now builds the .deb and the .rpm from the same staged tree, and the smoke test installs either with apt, dnf or zypper. --- ...eb-package.sh => build-distro-packages.sh} | 221 ++++++++++++++---- ...er.sh => test-distro-package-container.sh} | 75 ++++-- ...-deb-package.sh => test-distro-package.sh} | 23 +- 3 files changed, 246 insertions(+), 73 deletions(-) rename scripts/{build-deb-package.sh => build-distro-packages.sh} (50%) rename scripts/{test-deb-package-container.sh => test-distro-package-container.sh} (66%) rename scripts/{test-deb-package.sh => test-distro-package.sh} (50%) diff --git a/scripts/build-deb-package.sh b/scripts/build-distro-packages.sh similarity index 50% rename from scripts/build-deb-package.sh rename to scripts/build-distro-packages.sh index 3889575..2050b7d 100755 --- a/scripts/build-deb-package.sh +++ b/scripts/build-distro-packages.sh @@ -5,15 +5,19 @@ umask 022 REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" readonly REPOSITORY_ROOT -readonly PACKAGE_PATH="${1:?usage: build-deb-package.sh PACKAGE [OUTPUT_DIRECTORY]}" +readonly PACKAGE_PATH="${1:?usage: build-distro-packages.sh PACKAGE [OUTPUT_DIRECTORY]}" readonly OUTPUT_DIRECTORY="${2:-${REPOSITORY_ROOT}/dist}" readonly METAINFO_NAME='com.nexusmods.vortex.metainfo.xml' # shellcheck source=scripts/dotnet-runtime.sh source "$REPOSITORY_ROOT/scripts/dotnet-runtime.sh" +# .NET loads whichever ICU it finds. +readonly ICU_VERSIONS=(74 75 76 77 78 79 80) +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. .NET loads whichever -# ICU it finds, so any of these will do. +# libstdc++6 versions are added from the binaries below. readonly DEB_DEPENDS=( 'libasound2t64' 'libatk-bridge2.0-0t64' @@ -29,7 +33,7 @@ readonly DEB_DEPENDS=( 'libglib2.0-0t64' 'libglib2.0-bin' 'libgtk-3-0t64' - 'libicu74 | libicu75 | libicu76 | libicu77 | libicu78 | libicu79 | libicu80' + "${deb_icu% | }" 'libnotify4' 'libnspr4' 'libnss3' @@ -56,7 +60,28 @@ readonly DEB_SUGGESTS=( 'pipewire' ) -for command_name in bsdtar curl dpkg-deb file install objdump sha256sum sha512sum; do +# 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 })" +) +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 @@ -74,30 +99,32 @@ 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)" maintainer="$(sed -n 's/^# Maintainer: //p' "$REPOSITORY_ROOT/PKGBUILD")" 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 "$maintainer" ]]; then + -z "$package_url" || -z "$package_license" || -z "$maintainer" ]]; then printf 'Unsupported package metadata: %s %s %s\n' \ "$package_name" "$package_revision" "$package_arch" >&2 exit 2 fi +package_release="${package_revision#"${release_version}-"}" release_date="$(date -u +%Y-%m-%d)" -work_directory="$(mktemp -d "${TMPDIR:-/tmp}/vortex-deb.XXXXXX")" +work_directory="$(mktemp -d "${TMPDIR:-/tmp}/vortex-distro.XXXXXX")" cleanup() { rm -rf -- "$work_directory" } trap cleanup EXIT -package_root="$work_directory/root" -doc_directory="$package_root/usr/share/doc/$package_name" -install -dm755 "$package_root" - -bsdtar -xf "$PACKAGE_PATH" -C "$package_root" \ +# 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 \ @@ -106,26 +133,13 @@ bsdtar -xf "$PACKAGE_PATH" -C "$package_root" \ # 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 "$package_root/opt/Vortex/chrome-sandbox" +chmod 4755 "$stage/opt/Vortex/chrome-sandbox" -install_dotnet_runtime "$package_root/opt/Vortex/dotnet-linux-x64" \ +install_dotnet_runtime "$stage/opt/Vortex/dotnet-linux-x64" \ "$work_directory/dotnet-runtime-linux-x64.tar.gz" -install -Dm755 "$REPOSITORY_ROOT/vortex.sh" "$package_root/usr/bin/vortex" - -# Debian keeps licenses next to the documentation and expects a copyright file. -mv "$package_root/usr/share/licenses/vortex-linux"/* "$doc_directory/" -rm -r "$package_root/usr/share/licenses" -cat > "$doc_directory/copyright" <&2 fi +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 "$package_root" -type f -print0 \ +find "$stage" -type f -print0 \ | xargs -0 file --no-pad --separator '|' \ | sed -n 's/| ELF 64-bit .*//p' > "$elf_files" symbol_versions="$( @@ -184,9 +202,26 @@ join_fields() { sed 's/,/, /g' <<< "$*" } -installed_size="$(du -sk --apparent-size "$package_root" | cut -f1)" -install -dm755 "$package_root/DEBIAN" -cat > "$package_root/DEBIAN/control" < "$doc_directory/copyright" < "$deb_root/DEBIAN/control" < "$package_root/DEBIAN/postinst" <<'EOF' +cat > "$deb_root/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e @@ -214,40 +245,134 @@ if [ "$1" = configure ]; then echo " xdg-mime default com.nexusmods.vortex.desktop x-scheme-handler/nxm" fi EOF -chmod 755 "$package_root/DEBIAN/postinst" +chmod 755 "$deb_root/DEBIAN/postinst" -install -dm755 "$OUTPUT_DIRECTORY" deb_archive="${package_name}_${release_version}_amd64.deb" -dpkg-deb --root-owner-group -Zxz --build "$package_root" "$OUTPUT_DIRECTORY/$deb_archive" +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 package: %s\n' "$sandbox_entry" >&2 + 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 --check "${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}\` -- Size: \`$(du -h "$OUTPUT_DIRECTORY/$deb_archive" | cut -f1)\` -- Needs: \`libc6 (>= ${glibc_minimum})\`${libstdcxx_minimum:+, \`libstdc++6 (>= ${libstdcxx_minimum})\`} - 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/test-deb-package-container.sh b/scripts/test-distro-package-container.sh similarity index 66% rename from scripts/test-deb-package-container.sh rename to scripts/test-distro-package-container.sh index 1c245cc..2ff7038 100755 --- a/scripts/test-deb-package-container.sh +++ b/scripts/test-distro-package-container.sh @@ -2,9 +2,10 @@ set -Eeuo pipefail -readonly SOURCE_PACKAGE="${1:?usage: test-deb-package-container.sh DEB}" -readonly TEST_ROOT='/tmp/vortex-deb-smoke' -readonly TEST_PACKAGE="${TEST_ROOT}/vortex-linux.deb" +readonly SOURCE_PACKAGE="${1:?usage: test-distro-package-container.sh PACKAGE}" +readonly TEST_ROOT='/tmp/vortex-package-smoke' +TEST_PACKAGE="${TEST_ROOT}/$(basename -- "$SOURCE_PACKAGE")" +readonly TEST_PACKAGE readonly STARTUP_LOG="${TEST_ROOT}/startup.log" readonly DEPENDENCY_LOG="${TEST_ROOT}/dependencies.log" readonly VORTEX_ROOT='/opt/Vortex' @@ -18,20 +19,54 @@ fi install -Dm644 "$SOURCE_PACKAGE" "$TEST_PACKAGE" . /etc/os-release -printf 'Installing the package on %s\n' "$PRETTY_NAME" - -# Only the package's own dependencies are installed with it. The rest is the -# test harness: an X server, a D-Bus session and a DevTools client. -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -if ! apt-get install --quiet=2 --no-install-recommends --yes \ - "$TEST_PACKAGE" \ - dbus-x11 \ - file \ - procps \ - python3-websocket \ - xauth \ - xvfb >"$DEPENDENCY_LOG" 2>&1; then +printf 'Installing %s on %s\n' "$(basename -- "$TEST_PACKAGE")" "$PRETTY_NAME" + +# Only the package's own hard dependencies are installed with it. The rest is +# the test harness: an X server, a D-Bus session and a DevTools client. +case "$TEST_PACKAGE" in + *.deb) + 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 file procps python3-websocket xauth xvfb) + ;; + *.rpm) + if 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 file procps-ng python3-websocket-client shadow-utils + util-linux xorg-x11-server-Xvfb xorg-x11-xauth) + else + # 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 file gawk procps python3-base + python3-websocket-client shadow util-linux xauth xvfb-run) + fi + ;; + *) + printf 'Unsupported package: %s\n' "$TEST_PACKAGE" >&2 + exit 2 + ;; +esac + +if ! install_packages "$TEST_PACKAGE" "${harness[@]}" >"$DEPENDENCY_LOG" 2>&1; then printf 'Unable to install the package and the test harness\n' >&2 sed -n '1,240p' "$DEPENDENCY_LOG" >&2 exit 1 @@ -125,7 +160,11 @@ cleanup trap - EXIT printf 'Removing the package\n' -apt-get purge --quiet=2 --yes vortex-linux >>"$DEPENDENCY_LOG" 2>&1 +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 diff --git a/scripts/test-deb-package.sh b/scripts/test-distro-package.sh similarity index 50% rename from scripts/test-deb-package.sh rename to scripts/test-distro-package.sh index 32c98a1..a6d8d5b 100755 --- a/scripts/test-deb-package.sh +++ b/scripts/test-distro-package.sh @@ -4,29 +4,38 @@ set -Eeuo pipefail REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" readonly REPOSITORY_ROOT -readonly PACKAGE_PATH="${1:?usage: test-deb-package.sh DEB [CONTAINER_IMAGE]}" -readonly CONTAINER_IMAGE="${2:-ubuntu:24.04}" +readonly PACKAGE_PATH="${1:?usage: test-distro-package.sh PACKAGE [CONTAINER_IMAGE]}" +case "$PACKAGE_PATH" in + *.deb) default_image='ubuntu:24.04' ;; + *.rpm) default_image='fedora:44' ;; + *) + printf 'Expected a .deb or .rpm package: %s\n' "$PACKAGE_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 Debian package smoke test\n' >&2 + printf 'Docker is required to run the clean package smoke test\n' >&2 exit 2 fi if [[ ! -f "$PACKAGE_PATH" ]]; then - printf 'Debian package was not found: %s\n' "$PACKAGE_PATH" >&2 + printf 'Package was not found: %s\n' "$PACKAGE_PATH" >&2 exit 2 fi ABSOLUTE_PACKAGE_PATH="$(cd -- "$(dirname -- "$PACKAGE_PATH")" && pwd)/$(basename -- "$PACKAGE_PATH")" readonly ABSOLUTE_PACKAGE_PATH +readonly CONTAINER_PACKAGE_PATH="/artifact/$(basename -- "$PACKAGE_PATH")" printf 'Testing %s in a fresh %s container\n' \ "$ABSOLUTE_PACKAGE_PATH" "$CONTAINER_IMAGE" docker run --rm --platform linux/amd64 \ - --volume "$ABSOLUTE_PACKAGE_PATH:/artifact/vortex-linux.deb:ro" \ - --volume "$REPOSITORY_ROOT/scripts/test-deb-package-container.sh:/test-deb-package-container.sh:ro" \ + --volume "$ABSOLUTE_PACKAGE_PATH:$CONTAINER_PACKAGE_PATH:ro" \ + --volume "$REPOSITORY_ROOT/scripts/test-distro-package-container.sh:/test-distro-package-container.sh:ro" \ --volume "$REPOSITORY_ROOT/scripts/check-appimage-ui.py:/check-appimage-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-deb-package-container.sh /artifact/vortex-linux.deb + bash /test-distro-package-container.sh "$CONTAINER_PACKAGE_PATH" From e095edb0c7e9ccd229c0dbfb719687ebc5cfb8e7 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 23:09:18 +0100 Subject: [PATCH 07/22] Test the AppImage and the packages with one script The self-test now runs without a display: Wine 11 stalls creating a prefix under Xvfb. --- .github/workflows/build-version.yml | 13 +- scripts/{check-appimage-ui.py => check-ui.py} | 0 scripts/test-appimage-container.sh | 100 -------- scripts/test-appimage.sh | 30 --- scripts/test-distro-package-container.sh | 177 -------------- scripts/test-distro-package.sh | 41 ---- scripts/test-package-container.sh | 222 ++++++++++++++++++ scripts/test-package.sh | 41 ++++ 8 files changed, 272 insertions(+), 352 deletions(-) rename scripts/{check-appimage-ui.py => check-ui.py} (100%) delete mode 100755 scripts/test-appimage-container.sh delete mode 100755 scripts/test-appimage.sh delete mode 100755 scripts/test-distro-package-container.sh delete mode 100755 scripts/test-distro-package.sh create mode 100755 scripts/test-package-container.sh create mode 100755 scripts/test-package.sh 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/scripts/check-appimage-ui.py b/scripts/check-ui.py similarity index 100% rename from scripts/check-appimage-ui.py rename to scripts/check-ui.py 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-distro-package-container.sh b/scripts/test-distro-package-container.sh deleted file mode 100755 index 2ff7038..0000000 --- a/scripts/test-distro-package-container.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -readonly SOURCE_PACKAGE="${1:?usage: test-distro-package-container.sh PACKAGE}" -readonly TEST_ROOT='/tmp/vortex-package-smoke' -TEST_PACKAGE="${TEST_ROOT}/$(basename -- "$SOURCE_PACKAGE")" -readonly TEST_PACKAGE -readonly STARTUP_LOG="${TEST_ROOT}/startup.log" -readonly DEPENDENCY_LOG="${TEST_ROOT}/dependencies.log" -readonly VORTEX_ROOT='/opt/Vortex' - -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 -Dm644 "$SOURCE_PACKAGE" "$TEST_PACKAGE" - -. /etc/os-release -printf 'Installing %s on %s\n' "$(basename -- "$TEST_PACKAGE")" "$PRETTY_NAME" - -# Only the package's own hard dependencies are installed with it. The rest is -# the test harness: an X server, a D-Bus session and a DevTools client. -case "$TEST_PACKAGE" in - *.deb) - 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 file procps python3-websocket xauth xvfb) - ;; - *.rpm) - if 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 file procps-ng python3-websocket-client shadow-utils - util-linux xorg-x11-server-Xvfb xorg-x11-xauth) - else - # 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 file gawk procps python3-base - python3-websocket-client shadow util-linux xauth xvfb-run) - fi - ;; - *) - printf 'Unsupported package: %s\n' "$TEST_PACKAGE" >&2 - exit 2 - ;; -esac - -if ! install_packages "$TEST_PACKAGE" "${harness[@]}" >"$DEPENDENCY_LOG" 2>&1; then - printf 'Unable to install the package and the test harness\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 the package dependencies; test is not hermetic\n' >&2 - exit 1 -fi - -for command_name in dbus-run-session vortex wine xvfb-run; 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 - -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' -unresolved=0 -while IFS= read -r elf_file; do - [[ "$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 < <( - find "$VORTEX_ROOT" -type f -print0 \ - | xargs -0 file --no-pad --separator '|' \ - | sed -n 's/| ELF 64-bit .*//p' -) -if ((unresolved)); then - printf 'Packaged binaries need libraries this system does not provide\n' >&2 - exit 1 -fi - -printf 'Running the runtime self-test without host .NET\n' -ELECTRON_RUN_AS_NODE=1 VORTEX_DOTNET_WIN_ROOT="$VORTEX_ROOT/dotnet-win-x64" \ - timeout 120s xvfb-run --auto-servernum \ - "$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 - -useradd --create-home --shell /bin/bash vortex-test - -printf 'Starting Vortex through its launcher 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 \ - dbus-run-session -- \ - xvfb-run --auto-servernum vortex --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 '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 - -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-distro-package.sh b/scripts/test-distro-package.sh deleted file mode 100755 index a6d8d5b..0000000 --- a/scripts/test-distro-package.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -readonly REPOSITORY_ROOT -readonly PACKAGE_PATH="${1:?usage: test-distro-package.sh PACKAGE [CONTAINER_IMAGE]}" -case "$PACKAGE_PATH" in - *.deb) default_image='ubuntu:24.04' ;; - *.rpm) default_image='fedora:44' ;; - *) - printf 'Expected a .deb or .rpm package: %s\n' "$PACKAGE_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 package smoke test\n' >&2 - exit 2 -fi - -if [[ ! -f "$PACKAGE_PATH" ]]; then - printf 'Package was not found: %s\n' "$PACKAGE_PATH" >&2 - exit 2 -fi - -ABSOLUTE_PACKAGE_PATH="$(cd -- "$(dirname -- "$PACKAGE_PATH")" && pwd)/$(basename -- "$PACKAGE_PATH")" -readonly ABSOLUTE_PACKAGE_PATH -readonly CONTAINER_PACKAGE_PATH="/artifact/$(basename -- "$PACKAGE_PATH")" - -printf 'Testing %s in a fresh %s container\n' \ - "$ABSOLUTE_PACKAGE_PATH" "$CONTAINER_IMAGE" -docker run --rm --platform linux/amd64 \ - --volume "$ABSOLUTE_PACKAGE_PATH:$CONTAINER_PACKAGE_PATH:ro" \ - --volume "$REPOSITORY_ROOT/scripts/test-distro-package-container.sh:/test-distro-package-container.sh:ro" \ - --volume "$REPOSITORY_ROOT/scripts/check-appimage-ui.py:/check-appimage-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-distro-package-container.sh "$CONTAINER_PACKAGE_PATH" diff --git a/scripts/test-package-container.sh b/scripts/test-package-container.sh new file mode 100755 index 0000000..52d3103 --- /dev/null +++ b/scripts/test-package-container.sh @@ -0,0 +1,222 @@ +#!/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 file 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 file 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 file 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) + # Only the package's own hard dependencies are installed with it. + is_package=true + packages=("$TEST_ARTIFACT") + ;; + *) + printf 'Expected an AppImage, .deb or .rpm: %s\n' "$TEST_ARTIFACT" >&2 + exit 2 + ;; +esac + +if ! install_packages "${packages[@]}" "${harness[@]}" >"$DEPENDENCY_LOG" 2>&1; then + printf 'Unable to install the dependencies and the test harness\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 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 + +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' + unresolved=0 + while IFS= read -r elf_file; do + [[ "$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 < <( + find "$VORTEX_ROOT" -type f -print0 \ + | xargs -0 file --no-pad --separator '|' \ + | sed -n 's/| ELF 64-bit .*//p' + ) + if ((unresolved)); then + printf 'Packaged binaries need libraries this system does not provide\n' >&2 + exit 1 + fi +fi + +# 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" From e960a0b16b560fb124ffcaa5ab7006535bc7481b Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 23:09:18 +0100 Subject: [PATCH 08/22] Share package metadata and AppStream setup between build scripts --- appimage/com.nexusmods.vortex.metainfo.xml | 4 +- scripts/build-appimage.sh | 41 +---------- scripts/build-distro-packages.sh | 47 ++---------- scripts/dotnet-runtime.sh | 28 ------- scripts/package-common.sh | 86 ++++++++++++++++++++++ 5 files changed, 99 insertions(+), 107 deletions(-) delete mode 100644 scripts/dotnet-runtime.sh create mode 100644 scripts/package-common.sh diff --git a/appimage/com.nexusmods.vortex.metainfo.xml b/appimage/com.nexusmods.vortex.metainfo.xml index 8ea5a56..070fdfc 100644 --- a/appimage/com.nexusmods.vortex.metainfo.xml +++ b/appimage/com.nexusmods.vortex.metainfo.xml @@ -1,7 +1,7 @@ diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index 43c8b5f..4ced0bc 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -11,9 +11,8 @@ readonly APPIMAGETOOL_SHA256='b90f4a8b18967545fda78a445b27680a1642f1ef9488ced28b # 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/dotnet-runtime.sh -source "$REPOSITORY_ROOT/scripts/dotnet-runtime.sh" +# 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 @@ -22,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 @@ -47,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() { @@ -116,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 index 2050b7d..fa4c406 100755 --- a/scripts/build-distro-packages.sh +++ b/scripts/build-distro-packages.sh @@ -7,9 +7,8 @@ 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}" -readonly METAINFO_NAME='com.nexusmods.vortex.metainfo.xml' -# shellcheck source=scripts/dotnet-runtime.sh -source "$REPOSITORY_ROOT/scripts/dotnet-runtime.sh" +# shellcheck source=scripts/package-common.sh +source "$REPOSITORY_ROOT/scripts/package-common.sh" # .NET loads whichever ICU it finds. readonly ICU_VERSIONS=(74 75 76 77 78 79 80) @@ -88,31 +87,13 @@ for command_name in bsdtar cp curl dpkg-deb file install objdump rpm rpmbuild sh 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")" -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)" +read_package_metadata "$PACKAGE_PATH" +package_release="${package_revision#"${release_version}-"}" maintainer="$(sed -n 's/^# Maintainer: //p' "$REPOSITORY_ROOT/PKGBUILD")" - -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" || -z "$maintainer" ]]; then - printf 'Unsupported package metadata: %s %s %s\n' \ - "$package_name" "$package_revision" "$package_arch" >&2 +if [[ -z "$maintainer" ]]; then + printf 'PKGBUILD names no maintainer\n' >&2 exit 2 fi -package_release="${package_revision#"${release_version}-"}" -release_date="$(date -u +%Y-%m-%d)" work_directory="$(mktemp -d "${TMPDIR:-/tmp}/vortex-distro.XXXXXX")" cleanup() { @@ -139,21 +120,7 @@ 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" -metainfo_path="$stage/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 "$stage/usr/share/metainfo/$METAINFO_NAME" install -dm755 "$OUTPUT_DIRECTORY" diff --git a/scripts/dotnet-runtime.sh b/scripts/dotnet-runtime.sh deleted file mode 100644 index b8453c0..0000000 --- a/scripts/dotnet-runtime.sh +++ /dev/null @@ -1,28 +0,0 @@ -# Sourced by build-appimage.sh and build-deb-package.sh. -# -# Vortex's native dotnetprobe is a framework-dependent net9.0 executable. The -# Arch package gets a host runtime through pacman, but the AppImage and the -# Debian package 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' - -# 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 -} 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 +} From 83aea43c356e698032e58cc4c8f74810154844be Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 23:09:18 +0100 Subject: [PATCH 09/22] Build, test and publish the RPM package in CI All artifacts are tested in one matrix job, and the release upload takes every downloaded file. --- .github/workflows/upstream-compatibility.yml | 151 ++++++++----------- 1 file changed, 64 insertions(+), 87 deletions(-) diff --git a/.github/workflows/upstream-compatibility.yml b/.github/workflows/upstream-compatibility.yml index 436cb28..ddc38c8 100644 --- a/.github/workflows/upstream-compatibility.yml +++ b/.github/workflows/upstream-compatibility.yml @@ -182,6 +182,8 @@ jobs: "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 @@ -285,8 +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.deb.outputs.deb_archive }} - deb_checksum: ${{ steps.deb.outputs.deb_checksum }} + 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 @@ -312,10 +316,11 @@ jobs: fi pacman -Syu --needed --noconfirm "${build_dependencies[@]}" - # build-appimage.sh and build-deb-package.sh validate the AppStream - # metadata when appstreamcli is available, and the Debian package is - # built with dpkg-deb. The Arch package needs neither. - pacman -S --needed --noconfirm appstream dpkg + # 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" @@ -340,14 +345,14 @@ jobs: scripts/build-appimage.sh \ "dist/${{ steps.package.outputs.package_archive }}" - - name: Build Debian package - id: deb + - 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-deb-package.sh \ + scripts/build-distro-packages.sh \ "dist/${{ steps.package.outputs.package_archive }}" - name: Upload release artifacts @@ -360,49 +365,35 @@ jobs: dist/${{ steps.appimage.outputs.appimage_archive }} dist/${{ steps.appimage.outputs.appimage_checksum }} dist/${{ steps.appimage.outputs.appimage_zsync }} - dist/${{ steps.deb.outputs.deb_archive }} - dist/${{ steps.deb.outputs.deb_checksum }} + 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 - steps: - - name: Check out packaging repository - uses: actions/checkout@v6 - - - name: Download release artifacts - uses: actions/download-artifact@v5 - with: - name: vortex-linux-${{ needs.build-package.outputs.package_version }} - path: dist - - - name: Verify and start AppImage in a clean container - run: | - ( - cd dist || exit 1 - sha256sum --check "${{ needs.build-package.outputs.appimage_checksum }}" - ) - scripts/test-appimage.sh "dist/${{ needs.build-package.outputs.appimage_archive }}" - - test-deb-package: - name: Test Debian package 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: - image: - - ubuntu:24.04 - - ubuntu:26.04 - - debian:13 + 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 @@ -413,15 +404,13 @@ jobs: name: vortex-linux-${{ needs.build-package.outputs.package_version }} path: dist - - name: Verify and install the package 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.deb_checksum }}" + sha256sum --check "$CHECKSUM" ) - scripts/test-deb-package.sh \ - "dist/${{ needs.build-package.outputs.deb_archive }}" \ - "${{ matrix.image }}" + scripts/test-package.sh "dist/$ARTIFACT" "${{ matrix.image }}" finalize-source: name: Record tested upstream release @@ -429,12 +418,10 @@ jobs: - compatibility - prepare-release - build-package - - test-appimage - - test-deb-package + - test-packages if: >- ${{ - needs.test-appimage.result == 'success' && - needs.test-deb-package.result == 'success' && + needs.test-packages.result == 'success' && github.ref == 'refs/heads/master' && needs.prepare-release.outputs.release_needed == 'true' }} @@ -512,8 +499,7 @@ jobs: - compatibility - prepare-release - build-package - - test-appimage - - test-deb-package + - test-packages - finalize-source if: >- ${{ @@ -534,6 +520,8 @@ jobs: 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: @@ -545,7 +533,7 @@ jobs: - name: Verify downloaded artifacts working-directory: dist - run: sha256sum --check "$CHECKSUM_FILE" "$APPIMAGE_CHECKSUM" "$DEB_CHECKSUM" + run: sha256sum --check -- *.sha256 - name: Download release notes uses: actions/download-artifact@v5 @@ -587,6 +575,18 @@ jobs: 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: @@ -624,15 +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/$DEB_ARCHIVE" \ - "dist/$DEB_CHECKSUM" \ - "dist/$APPIMAGE_ZSYNC" \ + gh release upload "$RELEASE_TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --clobber gh release edit "$RELEASE_TAG" \ @@ -641,27 +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" \ - "dist/$DEB_ARCHIVE" \ - "dist/$DEB_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/$DEB_ARCHIVE" \ - "dist/$DEB_CHECKSUM" \ - "dist/$APPIMAGE_ZSYNC" \ + gh release create "$RELEASE_TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --target "$SOURCE_COMMIT" \ --title "Vortex for Linux ${PACKAGE_REVISION}" \ @@ -683,8 +665,7 @@ jobs: - compatibility - prepare-release - build-package - - test-appimage - - test-deb-package + - test-packages - finalize-source - publish-package if: ${{ always() && github.event_name == 'schedule' }} @@ -703,8 +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 }} - DEB_STATUS: ${{ needs.test-deb-package.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 }} @@ -722,8 +702,7 @@ jobs: "$COMPATIBILITY_STATUS" "$PREPARE_STATUS" "$BUILD_STATUS" - "$APPIMAGE_STATUS" - "$DEB_STATUS" + "$TEST_STATUS" "$FINALIZE_STATUS" "$PUBLISH_STATUS" ) @@ -739,8 +718,7 @@ jobs: true) required_statuses=( "$BUILD_STATUS" - "$APPIMAGE_STATUS" - "$DEB_STATUS" + "$TEST_STATUS" "$FINALIZE_STATUS" "$PUBLISH_STATUS" ) @@ -771,8 +749,7 @@ jobs: "- Patch tests: \`${COMPATIBILITY_STATUS}\`" \ "- Metadata: \`${PREPARE_STATUS}\`" \ "- Package build: \`${BUILD_STATUS}\`" \ - "- AppImage smoke test: \`${APPIMAGE_STATUS}\`" \ - "- Debian package smoke test: \`${DEB_STATUS}\`" \ + "- Package smoke tests: \`${TEST_STATUS}\`" \ "- Source update: \`${FINALIZE_STATUS}\`" \ "- Release publish: \`${PUBLISH_STATUS}\`" \ "- Workflow run: ${RUN_URL}" \ From 44c61a41c882f2b51556a1f6869d5488e5abf3de Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 23:09:18 +0100 Subject: [PATCH 10/22] Document the RPM package --- CHANGELOG.md | 11 ++++++----- README.md | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea941c..dc8fb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased -- Debian and Ubuntu package: releases now include - `vortex-linux__amd64.deb` for Ubuntu 24.04 or newer and Debian 13 or - newer. `sudo apt install ./vortex-linux_*_amd64.deb` installs Vortex with its - dependencies and an app menu entry. Like the AppImage, it brings its own .NET - runtimes. +- 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 diff --git a/README.md b/README.md index a39bbb9..38d9f5d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,18 @@ apt pulls in Wine and the libraries Vortex needs, and Vortex shows up in your ap 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: @@ -178,8 +190,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. @@ -190,7 +202,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 ``` From 5f5e67cf890d52480c360cff7f5bb160bc745df2 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 28 Oct 2026 23:28:41 +0100 Subject: [PATCH 11/22] Require a font in the RPM package A minimal openSUSE has no fonts, and Vortex then shows an empty window. --- scripts/build-distro-packages.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/build-distro-packages.sh b/scripts/build-distro-packages.sh index fa4c406..123d0f5 100755 --- a/scripts/build-distro-packages.sh +++ b/scripts/build-distro-packages.sh @@ -69,6 +69,9 @@ readonly RPM_REQUIRES=( '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)' From 37ce7182f9e4a49f5c66cfbc371066930c6c9d9c Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 11:51:24 +0100 Subject: [PATCH 12/22] Update Vortex to 2.10.0 Vortex 2.10.0 has its own getVolumePath and moved the game stores to native promises, so 0003 and 0009 shrink and the Heroic, Lutris and Bottles stores follow. Its new backup and manifest code reads deployment paths with node:fs directly; 0029 maps them into the Wine prefix and records turbowalk's Linux mtimes in milliseconds. --- .SRCINFO | 8 +- CHANGELOG.md | 3 + PKGBUILD | 6 +- README.md | 2 + ...-linux-path-and-executable-discovery.patch | 64 ++--- ...0003-linux-filesystem-and-deployment.patch | 152 ++---------- patches/0005-linux-steam-game-launch.patch | 41 ++-- patches/0009-linux-staging-suggestions.patch | 203 +++++++--------- patches/0011-linux-case-safe-deployment.patch | 221 ++++++++---------- patches/0015-linux-dotnet-game-version.patch | 38 +-- patches/0020-linux-native-loot.patch | 35 +-- patches/0022-linux-heroic-stores.patch | 140 ++++++----- patches/0029-linux-prefix-config-purge.patch | 201 ++++++++++------ patches/0031-linux-deployment-preflight.patch | 52 +++-- .../0032-linux-deployment-target-paths.patch | 8 +- .../0035-linux-backslash-archive-paths.patch | 6 +- patches/0039-linux-lutris-store.patch | 122 +++++----- patches/0040-linux-bottles-store.patch | 132 +++++------ 18 files changed, 654 insertions(+), 780 deletions(-) diff --git a/.SRCINFO b/.SRCINFO index d069ff5..e4dcdb7 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches - pkgver = 2.9.1 - pkgrel = 2 + pkgver = 2.10.0 + pkgrel = 1 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 @@ -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/CHANGELOG.md b/CHANGELOG.md index dc8fb5b..f774a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ 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. - 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 diff --git a/PKGBUILD b/PKGBUILD index 2a44734..ec5a010 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,8 +1,8 @@ # Maintainer: Arkazzae pkgname=vortex-linux -pkgver=2.9.1 -pkgrel=2 +pkgver=2.10.0 +pkgrel=1 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" @@ -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 38d9f5d..244d837 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ 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. +2.10.0 finds the drive a folder sits on the Linux way as well, and suggests the staging folder from it, so two more of our patches got smaller. + 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) 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/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..a712940 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,123 @@ 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..418f2fd 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, readFileSync, rmSync } 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"; + import { deployedFilePath } from "./deployedFilePath"; +-import { changesBySource, changeToEntry, dealWithExternalChanges } from "./externalChanges"; ++import { ++ applyFileActions, ++ changesBySource, ++ changeToEntry, ++ dealWithExternalChanges, ++} from "./externalChanges"; - // 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", () => ({ - }), - })); + vi.mock("../../../logging", () => { + const log = vi.fn(); +@@ -104,6 +109,64 @@ describe("changeToEntry", () => { -+import * as mockedFS from "../../../util/fs"; - import InstallManager from "../InstallManager"; --import { dealWithExternalChanges } from "./externalChanges"; -+import { applyFileActions, changeToEntry, dealWithExternalChanges } from "./externalChanges"; - - 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"); + -+ 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( ++ "plugin.esp", + ); -+ 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); ++ }); ++ ++ 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..cf032e0 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, +@@ -96,24 +97,32 @@ async function applyFileActions( ); 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 @@ -256,32 +246,15 @@ index 8ead7b4..b5187d1 100644 }), ); -@@ -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) { +@@ -173,7 +182,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 diff --git a/patches/0015-linux-dotnet-game-version.patch b/patches/0015-linux-dotnet-game-version.patch index 72e6485..5d2b14b 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..01c61aa 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,101 @@ 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,41 @@ index eb8c9f0..ce04f45 100644 + return bestMatch?.version; +} + -+function executableVersion(executablePath: string): string { ++function executableVersion(executablePath: string): string | null { + try { + const version = exeVersion(executablePath); -+ return knownVersion(version) ? version : UNKNOWN_VERSION; ++ return 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 +137,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..2b75d93 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,171 @@ +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,125 @@ 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 +@@ -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,8 +48,16 @@ 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 { ++ // lstat below bypasses util/fs, so map a Windows user folder into the Wine prefix here ++ filePath = resolveCaseInsensitiveSync(filePath); + const backupPath = filePath + BACKUP_TAG; + if (await isAbsent(filePath)) { + return; +@@ -399,7 +408,7 @@ abstract class LinkingActivator implements IDeploymentMethod { + relPath, + source: sourceName, + target: deployPath, +- time: entry.mtime * 1000, ++ time: entryTime(entry), + }; + } + }); +@@ -463,9 +472,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 +488,8 @@ abstract class LinkingActivator implements IDeploymentMethod { + return Promise.resolve(undefined); + } + return Promise.reject(err); +- }), +- ); ++ }); ++ }); + } + + public postPurge(): PromiseLike { +@@ -877,15 +890,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"; +@@ -129,6 +130,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 +432,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/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/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 { } } From dc0e049ae97a17bb1333a762a7fbdd0fc5d17e20 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 13/22] Accept Vortex 2.10's redesigned pages in the UI check The Games page Vortex 2.10 opens on renders id="page-" instead of .main-page-inner, so every package smoke test timed out. A page that failed to render no longer counts as ready. --- scripts/check-ui.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/check-ui.py b/scripts/check-ui.py index 0062864..3a5cdb6 100644 --- a/scripts/check-ui.py +++ b/scripts/check-ui.py @@ -8,8 +8,12 @@ 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'), + ready: !!document.querySelector('#content .main-page-inner, #content [id^="page-"]') + && !document.querySelector('#content .render-failure'), text: document.querySelector('#content')?.innerText?.slice(0, 2000) })''' From 8d95bfe117fd9f98dc2f36249ceb964c1c632320 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 14/22] Check package libraries before installing the test harness Xvfb, D-Bus and Python came in the same transaction as the package and could hide a missing dependency from the ldd check. --- scripts/test-package-container.sh | 72 +++++++++++++++++++------------ 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/scripts/test-package-container.sh b/scripts/test-package-container.sh index 52d3103..02f5f6b 100755 --- a/scripts/test-package-container.sh +++ b/scripts/test-package-container.sh @@ -32,7 +32,7 @@ if command -v apt-get >/dev/null 2>&1; then remove_package() { apt-get purge --quiet=2 --yes vortex-linux } - harness=(dbus-x11 file procps python3-websocket xauth xvfb) + 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 "$@" @@ -40,7 +40,7 @@ elif command -v dnf >/dev/null 2>&1; then remove_package() { dnf remove --quiet --assumeyes vortex-linux } - harness=(dbus-daemon file procps-ng python3-websocket-client shadow-utils + 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. @@ -53,7 +53,7 @@ elif command -v zypper >/dev/null 2>&1; then } # Minimal openSUSE images lack the awk xvfb-run uses and the python3 # command. - harness=(dbus-1-daemon file gawk procps python3-base + 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 @@ -74,7 +74,9 @@ case "$TEST_ARTIFACT" in libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 libxss1 wine wine64) ;; *.deb|*.rpm) - # Only the package's own hard dependencies are installed with it. + # 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") ;; @@ -84,27 +86,21 @@ case "$TEST_ARTIFACT" in ;; esac -if ! install_packages "${packages[@]}" "${harness[@]}" >"$DEPENDENCY_LOG" 2>&1; then - printf 'Unable to install the dependencies and the test harness\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 the dependencies; test is not hermetic\n' >&2 - exit 1 -fi +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 +} -runtime_commands=(dbus-run-session wine xvfb-run) if [[ "$is_package" == true ]]; then - runtime_commands+=(vortex) + install_or_fail 'the package' "${packages[@]}" +else + install_or_fail 'the dependencies and the test harness' "${packages[@]}" "${harness[@]}" 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 if [[ "$is_package" == true ]]; then sandbox_mode="$(stat -c '%U:%G %a' "$VORTEX_ROOT/chrome-sandbox")" @@ -117,8 +113,13 @@ if [[ "$is_package" == true ]]; then # 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 - while IFS= read -r elf_file; do + 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 @@ -127,16 +128,31 @@ if [[ "$is_package" == true ]]; then printf '%s:\n%s\n' "$elf_file" "$(grep -F 'not found' <<< "$dependencies")" >&2 unresolved=1 fi - done < <( - find "$VORTEX_ROOT" -type f -print0 \ - | xargs -0 file --no-pad --separator '|' \ - | sed -n 's/| ELF 64-bit .*//p' - ) + 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. From f608147ebbace5813ad089b770b3460d36c67c83 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 15/22] Accept newer ICU versions in the .deb and .rpm The dependency listed ICU 74 to 80, so a published package would stop installing once a distribution moves to ICU 81. .NET loads newer ICU itself. --- scripts/build-distro-packages.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/build-distro-packages.sh b/scripts/build-distro-packages.sh index 123d0f5..76db5d2 100755 --- a/scripts/build-distro-packages.sh +++ b/scripts/build-distro-packages.sh @@ -10,8 +10,10 @@ 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. -readonly ICU_VERSIONS=(74 75 76 77 78 79 80) +# .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[@]}")" From 8f83b62d3498fdc2ba0839c55085512d79b1e86e Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 16/22] Keep the import folder out of the mod list Importing a file changed outside Vortex copied it through .vortex-import in the staging folder and never removed it, so Vortex offered it as a new mod. --- CHANGELOG.md | 3 ++ patches/0011-linux-case-safe-deployment.patch | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f774a77..a80784b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ publishes a new Vortex version, taking any pending notes with it. - 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. - 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 diff --git a/patches/0011-linux-case-safe-deployment.patch b/patches/0011-linux-case-safe-deployment.patch index a712940..7c4d80c 100644 --- a/patches/0011-linux-case-safe-deployment.patch +++ b/patches/0011-linux-case-safe-deployment.patch @@ -88,12 +88,12 @@ index d3358aa..9d5578b 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 e1173be..418f2fd 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,4 +1,4 @@ -import { existsSync, rmSync } from "node:fs"; -+import { existsSync, readFileSync, rmSync } from "node:fs"; ++import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { @@ -111,7 +111,7 @@ index e1173be..418f2fd 100644 vi.mock("../../../logging", () => { const log = vi.fn(); -@@ -104,6 +109,64 @@ describe("changeToEntry", () => { +@@ -104,6 +109,67 @@ describe("changeToEntry", () => { expect(changeToEntry("", change, existing).action).toBe("drop"); }); @@ -138,6 +138,7 @@ index e1173be..418f2fd 100644 + 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"); + + await applyFileActions( + h.api, @@ -150,9 +151,11 @@ index e1173be..418f2fd 100644 + ); + + expect(readFileSync(path.join(h.stagingPath, MOD_A_FOLDER, "plugin.esp"), "utf8")).toBe( -+ "plugin.esp", ++ "edited in the game folder", + ); + 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 ({ @@ -177,7 +180,7 @@ index e1173be..418f2fd 100644 describe("dealWithExternalChanges", () => { diff --git a/src/renderer/src/extensions/mod_management/util/externalChanges.ts b/src/renderer/src/extensions/mod_management/util/externalChanges.ts -index 0e2a7c3..cf032e0 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 @@ -9,6 +9,7 @@ import { @@ -197,9 +200,12 @@ index 0e2a7c3..cf032e0 100644 api: IExtensionApi, gameId: string, modIdByFolder: ReadonlyMap, -@@ -96,24 +97,32 @@ async function applyFileActions( +@@ -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) => { @@ -220,11 +226,7 @@ index 0e2a7c3..cf032e0 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 @@ -237,6 +239,8 @@ index 0e2a7c3..cf032e0 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 { @@ -245,8 +249,13 @@ index 0e2a7c3..cf032e0 100644 + } }), ); ++ if ((actionGroups["import"] || []).length > 0) { ++ await fs.rmdirAsync(importFolder).catch(() => undefined); ++ } -@@ -173,7 +182,7 @@ function defaultAction( + // 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": @@ -360,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", () => { @@ -393,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 { From b98fc2396ea869cf20b110ec561752e6c45afc6e Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 17/22] Keep the Linux .NET location out of Wine processes The AppImage and the packages set DOTNET_ROOT for Vortex itself, and a Windows .NET program started through Wine looks there for hostfxr.dll. --- CHANGELOG.md | 3 + patches/0008-linux-proton-user-folders.patch | 70 +++++++++++++++----- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80784b..9a2feb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ publishes a new Vortex version, taking any pending notes with it. - 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. - 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 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; } From 5b0d205a0d208e444ca618ebca14d17058c4eb31 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 18/22] Repair old Linux deployment times and keep case-variant backups Manifests from earlier Linux builds hold times a thousand times too large, which Vortex 2.10 reads as replaced files. A backup named in another case was overwritten instead of stopping the deployment. --- patches/0029-linux-prefix-config-purge.patch | 87 ++++++++++++++++--- .../0037-linux-case-consistent-folders.patch | 8 +- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/patches/0029-linux-prefix-config-purge.patch b/patches/0029-linux-prefix-config-purge.patch index 2b75d93..61b2476 100644 --- a/patches/0029-linux-prefix-config-purge.patch +++ b/patches/0029-linux-prefix-config-purge.patch @@ -2,7 +2,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.linux. new file mode 100644 --- /dev/null +++ b/src/renderer/src/extensions/mod_management/LinkingDeployment.linux.test.ts -@@ -0,0 +1,171 @@ +@@ -0,0 +1,213 @@ +import * as nodeFs from "node:fs"; +import * as path from "node:path"; + @@ -174,6 +174,48 @@ 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 @@ -185,7 +227,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s import { activeGameId } from "../../util/selectors"; import { truthy } from "../../util/util"; import type { IDeploymentFailure } from "./actions/session"; -@@ -47,8 +48,16 @@ async function isAbsent(filePath: string): Promise { +@@ -47,9 +48,18 @@ async function isAbsent(filePath: string): Promise { } } @@ -197,12 +239,15 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s + /** Moves a file aside for a link, refusing when an earlier backup already holds the name. */ async function backUpGameFile(filePath: string): Promise { -+ // lstat below bypasses util/fs, so map a Windows user folder into the Wine prefix here +- 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 = filePath + BACKUP_TAG; ++ const backupPath = resolveCaseInsensitiveSync(filePath + BACKUP_TAG); if (await isAbsent(filePath)) { return; -@@ -399,7 +408,7 @@ abstract class LinkingActivator implements IDeploymentMethod { + } +@@ -399,7 +409,7 @@ abstract class LinkingActivator implements IDeploymentMethod { relPath, source: sourceName, target: deployPath, @@ -211,7 +256,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s }; } }); -@@ -463,9 +472,13 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -463,9 +473,13 @@ abstract class LinkingActivator implements IDeploymentMethod { const game = getGame(gameId); const directoryCleaning = game.directoryCleaning || "tag"; @@ -228,7 +273,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s .statAsync(dataPath) .then(() => this.purgeLinks(installPath, dataPath, onProgress)) .then(() => this.postLinkPurge(dataPath, false, true, directoryCleaning)) -@@ -475,8 +488,8 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -475,8 +489,8 @@ abstract class LinkingActivator implements IDeploymentMethod { return Promise.resolve(undefined); } return Promise.reject(err); @@ -239,7 +284,7 @@ diff --git a/src/renderer/src/extensions/mod_management/LinkingDeployment.ts b/s } public postPurge(): PromiseLike { -@@ -877,15 +890,18 @@ abstract class LinkingActivator implements IDeploymentMethod { +@@ -877,15 +891,18 @@ abstract class LinkingActivator implements IDeploymentMethod { (entry) => !entry.isDirectory && LinkingActivator.isTagName(entry.filePath), ) !== undefined; @@ -278,7 +323,29 @@ diff --git a/src/renderer/src/extensions/mod_management/util/activationStore.ts import { log } from "../../../util/log"; import { activeGameId, discoveryByGame, installPathForGame } from "../../../util/selectors"; import { getSafe } from "../../../util/storeHelper"; -@@ -129,6 +130,8 @@ export async function filesStillDeployed( +@@ -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); @@ -287,7 +354,7 @@ diff --git a/src/renderer/src/extensions/mod_management/util/activationStore.ts const present = await Promise.all( files.map((file) => limit.do(() => -@@ -429,7 +432,7 @@ export async function ownDeployedFiles( +@@ -429,7 +438,7 @@ export async function ownDeployedFiles( let manifest: IDeploymentManifest | undefined; try { const { manifestPath } = manifestPaths(deployPath, stagingPath, modType); 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 { } }; From 61c1ff4b90fbba67b48b24e92d793cd1164d8217 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 12:46:51 +0100 Subject: [PATCH 19/22] Read exe versions as upstream does outside Linux --- patches/0015-linux-dotnet-game-version.patch | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/patches/0015-linux-dotnet-game-version.patch b/patches/0015-linux-dotnet-game-version.patch index 5d2b14b..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 d797474..01c61aa 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,7 +114,7 @@ index d797474..01c61aa 100644 import { getErrorMessageOrDefault } from "@vortex/shared"; import { ProcessCanceled } from "@vortex/shared/errors"; -@@ -11,6 +12,101 @@ 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"; @@ -204,10 +204,11 @@ index d797474..01c61aa 100644 + return bestMatch?.version; +} + ++/** 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 : null; ++ return process.platform !== "linux" || knownVersion(version) ? version : null; + } catch { + return null; + } @@ -216,7 +217,7 @@ index d797474..01c61aa 100644 export type GameVersionResolver = (game: IGame, discovery: IDiscoveryResult) => Promise; /** The version the game extension reports through getGameVersion; null when the -@@ -41,16 +137,23 @@ async function fromExtension(game: IGame, discovery: IDiscoveryResult): Promise< +@@ -41,16 +138,23 @@ async function fromExtension(game: IGame, discovery: IDiscoveryResult): Promise< return null; } From c6cc9f922ca3de3ec4b73a9325db66b2fec9a543 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 13:17:13 +0100 Subject: [PATCH 20/22] Map extension fs calls into the prefix like vortex-api's fs The Witcher 3 extension writes mods.settings through vortex-api's fs and then renames it with node:fs/promises, so the file landed in the prefix while the rename looked for it on the host. --- CHANGELOG.md | 4 + .../0030-linux-extension-config-paths.patch | 91 +++++++++++++++---- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a2feb4..c39644c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ publishes a new Vortex version, taking any pending notes with it. - 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 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", () => { From 2803c05411607ade50d25b94ff6438de28c442ed Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Wed, 4 Nov 2026 13:17:13 +0100 Subject: [PATCH 21/22] Mention Vortex 2.10's Linux fixes in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 244d837..9c266a1 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 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. -2.10.0 finds the drive a folder sits on the Linux way as well, and suggests the staging folder from it, so two more of our patches got smaller. +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. Every fix that lands upstream is one less patch to carry here, and the goal is for this repo to shrink. From b9e70a634260ebd9dd6dcd666eb2cf7281860fba Mon Sep 17 00:00:00 2001 From: Patryk Ebertowski Date: Wed, 4 Nov 2026 13:27:39 +0100 Subject: [PATCH 22/22] Remove games tested section from README Removed the 'Games tested (confirmed working)' section from the README. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 9c266a1..014a06b 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,6 @@ Vortex itself is slowly becoming more Linux-friendly. Since 2.7.1, the extension 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) - -- Starfield -- Skyrim Special Edition -- Abiotic Factor -- Zero Sievert -- Baldur's Gate 3 -- Cyberpunk 2077 - ## Install Grab the [latest release](https://github.com/Arkazzae/VortexLinux/releases/latest). What changed in each release is in [CHANGELOG.md](CHANGELOG.md).