From 57dbd7625b5e6a6ebc05b56415b21ff002526d00 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 21 Jan 2026 11:26:48 -0700 Subject: [PATCH 1/9] Add Linux Steam and Proton support - Add Linux Steam path discovery (findLinuxSteamPath) - Add Proton detection and environment setup utilities - Force Steam launcher for Linux Steam games - Enable running Windows tools through Proton --- src/util/StarterInfo.ts | 160 ++++++++++++++++----- src/util/Steam.ts | 81 ++++++++++- src/util/linux/proton.ts | 268 +++++++++++++++++++++++++++++++++++ src/util/linux/steamPaths.ts | 56 ++++++++ 4 files changed, 524 insertions(+), 41 deletions(-) create mode 100644 src/util/linux/proton.ts create mode 100644 src/util/linux/steamPaths.ts diff --git a/src/util/StarterInfo.ts b/src/util/StarterInfo.ts index 7e947e177..d16917502 100644 --- a/src/util/StarterInfo.ts +++ b/src/util/StarterInfo.ts @@ -28,6 +28,8 @@ import * as fs from "fs"; import * as path from "path"; import { GameEntryNotFound, GameStoreNotFound } from "../types/IGameStore"; import { getErrorCode, unknownToError } from "../shared/errors"; +import { isWindowsExecutable } from "./linux/proton"; +import { Steam, type ISteamEntry } from "./Steam"; function getCurrentWindow() { if (process.type === "renderer") { @@ -64,6 +66,63 @@ type OnShowErrorFunc = ( allowReport?: boolean, ) => void; +/** + * Check if we should force Steam launcher on Linux + * Steam handles Proton automatically for Windows games + */ +function shouldUseSteamLauncherOnLinux(info: IStarterInfo): boolean { + if (process.platform === "win32" || !info.isGame) { + return false; + } + + // Check if store is explicitly set to steam + if (info.store === "steam") { + return true; + } + + // Fallback: check if the path looks like a Steam path + // This handles cases where store wasn't set during discovery + const lowerPath = info.exePath.toLowerCase(); + return lowerPath.includes("/steamapps/") || lowerPath.includes("\\steamapps\\"); +} + +/** + * Check if a tool should run through Proton + */ +async function shouldRunToolWithProton( + info: IStarterInfo, + api: IExtensionApi, +): Promise { + if (process.platform === "win32") { + return undefined; + } + if (!isWindowsExecutable(info.exePath)) { + return undefined; + } + if (info.store !== "steam") { + return undefined; + } + + try { + const steamStore = GameStoreHelper.getGameStore("steam") as Steam; + const games = await steamStore.allGames(); + + // Find the game entry that matches this tool's game + return games.find( + (g) => + info.workingDirectory + ?.toLowerCase() + .startsWith(g.gamePath.toLowerCase()) || + info.exePath.toLowerCase().startsWith(g.gamePath.toLowerCase()), + ); + } catch (err: any) { + log("debug", "Could not check for Proton tool execution", { + error: err?.message, + }); + return undefined; + } +} + /** * wrapper for information about a game or tool, combining static and runtime/discovery information * for the purpose of actually starting them in a uniform way. @@ -96,42 +155,45 @@ class StarterInfo implements IStarterInfo { onShowError: OnShowErrorFunc, ) { const game: IGame = getGame(info.gameId); + // Determine launcher - Linux Steam games always use Steam launcher const launcherPromise: PromiseBB<{ launcher: string; addInfo?: any }> = - game.requiresLauncher !== undefined && info.isGame - ? PromiseBB.resolve( - game.requiresLauncher(path.dirname(info.exePath), info.store), - ).catch((err) => { - if (err instanceof UserCanceled) { - // warning because it'd be kind of unusual for the user to have to confirm anything - // in requiresLauncher - log( - "warn", - "failed to determine if launcher is required because user canceled something", - ); - } else { - const allowReport = !game.contributed; - const errorObj = allowReport - ? err - : { - message: - "Report this to the community extension author, not Vortex support!", - }; - onShowError( - "Failed to determine if launcher is required", - errorObj, - allowReport, - ); - if (!allowReport) { + shouldUseSteamLauncherOnLinux(info) + ? PromiseBB.resolve({ launcher: "steam" }) + : game.requiresLauncher !== undefined && info.isGame + ? PromiseBB.resolve( + game.requiresLauncher(path.dirname(info.exePath), info.store), + ).catch((err) => { + if (err instanceof UserCanceled) { + // warning because it'd be kind of unusual for the user to have to confirm anything + // in requiresLauncher log( - "error", - "failed to determine if launcher is required", - errorObj.message, + "warn", + "failed to determine if launcher is required because user canceled something", + ); + } else { + const allowReport = !game.contributed; + const errorObj = allowReport + ? err + : { + message: + "Report this to the community extension author, not Vortex support!", + }; + onShowError( + "Failed to determine if launcher is required", + errorObj, + allowReport, ); + if (!allowReport) { + log( + "error", + "failed to determine if launcher is required", + errorObj.message, + ); + } } - } - return PromiseBB.resolve(undefined); - }) - : PromiseBB.resolve(undefined); + return PromiseBB.resolve(undefined); + }) + : PromiseBB.resolve(undefined); const onSpawned = () => { api.store.dispatch( @@ -151,9 +213,13 @@ class StarterInfo implements IStarterInfo { .then(() => { // assuming that runThroughLauncher returns immediately on handing things off // to the launcher - api.store.dispatch( - setToolRunning(info.exePath, Date.now(), info.exclusive), - ); + // On Linux with Steam, we can't track when the game exits (ProcessMonitor + // only works on Windows), so don't set tool as running to avoid stuck spinner + if (!(process.platform !== "win32" && res.launcher === "steam")) { + api.store.dispatch( + setToolRunning(info.exePath, Date.now(), info.exclusive), + ); + } if (["hide", "hide_recover"].includes(info.onStart)) { getCurrentWindow().hide(); } else if (info.onStart === "close") { @@ -214,12 +280,12 @@ class StarterInfo implements IStarterInfo { return info["__iconCache"]; } - private static runDirectly( + private static async runDirectly( info: IStarterInfo, api: IExtensionApi, onShowError: OnShowErrorFunc, onSpawned: () => void, - ): PromiseBB { + ): Promise { const spawned = () => { onSpawned(); if (["hide", "hide_recover"].includes(info.onStart)) { @@ -229,6 +295,26 @@ class StarterInfo implements IStarterInfo { } }; + // Check if tool should run through Proton on Linux + const protonGameEntry = await shouldRunToolWithProton(info, api); + if (protonGameEntry?.usesProton) { + const steamStore = GameStoreHelper.getGameStore("steam") as Steam; + return steamStore.runToolWithProton( + api, + info.exePath, + info.commandLine, + { + cwd: info.workingDirectory || path.dirname(info.exePath), + env: info.environment, + suggestDeploy: true, + shell: info.shell, + detach: info.detach || info.onStart === "close", + onSpawned: spawned, + }, + protonGameEntry, + ); + } + return api .runExecutable(info.exePath, info.commandLine, { cwd: info.workingDirectory || path.dirname(info.exePath), diff --git a/src/util/Steam.ts b/src/util/Steam.ts index 3d58f2fe2..d6062d186 100644 --- a/src/util/Steam.ts +++ b/src/util/Steam.ts @@ -21,6 +21,12 @@ import type { IExtensionApi } from "../types/IExtensionContext"; import { GameEntryNotFound } from "../types/IGameStore"; import getVortexPath from "./getVortexPath"; import { getErrorMessageOrDefault } from "../shared/errors"; +import { findLinuxSteamPath } from "./linux/steamPaths"; +import { + getProtonInfo, + buildProtonEnvironment, + buildProtonCommand, +} from "./linux/proton"; const STORE_ID = "steam"; const STORE_NAME = "Steam"; @@ -29,6 +35,9 @@ const STORE_PRIORITY = 40; export interface ISteamEntry extends IGameStoreEntry { manifestData?: any; + usesProton?: boolean; + compatDataPath?: string; + protonPath?: string; } /// obsolete, no longer used. But it's exported through the api @@ -72,9 +81,8 @@ class Steam implements IGameStore { this.mBaseFolder = PromiseBB.resolve(undefined); } } else { - this.mBaseFolder = PromiseBB.resolve( - path.resolve(getVortexPath("home"), ".steam", "steam"), - ); + const linuxPath = findLinuxSteamPath(); + this.mBaseFolder = PromiseBB.resolve(linuxPath); } } @@ -358,6 +366,32 @@ class Steam implements IGameStore { }) .filter((obj) => obj !== undefined); }) + .then((entries: ISteamEntry[]) => { + // Add Proton info on Linux + if (process.platform === "win32") { + return entries; + } + return this.mBaseFolder.then((basePath) => + PromiseBB.map(entries, async (entry) => { + try { + const protonInfo = await getProtonInfo( + basePath, + steamAppsPath, + entry.appid, + ); + entry.usesProton = protonInfo.usesProton; + entry.compatDataPath = protonInfo.compatDataPath; + entry.protonPath = protonInfo.protonPath; + } catch (err: any) { + log("debug", "Could not get Proton info for game", { + appid: entry.appid, + error: err?.message, + }); + } + return entry; + }), + ); + }) .catch({ code: "ENOENT" }, (err: any) => { // no biggy, this can happen for example if the steam library is on a removable medium // which is currently removed @@ -385,8 +419,47 @@ class Steam implements IGameStore { }), ); } + + /** + * Run a Windows tool through Proton using the game's prefix + */ + public async runToolWithProton( + api: IExtensionApi, + exePath: string, + args: string[], + options: any, + gameEntry: ISteamEntry, + ): Promise { + if ( + !gameEntry.usesProton || + !gameEntry.protonPath || + !gameEntry.compatDataPath + ) { + return api.runExecutable(exePath, args, options); + } + + const steamPath = await this.mBaseFolder; + const { executable, args: protonArgs } = buildProtonCommand( + gameEntry.protonPath, + exePath, + args, + ); + const protonEnv = buildProtonEnvironment( + gameEntry.compatDataPath, + steamPath, + options.env, + ); + + return api.runExecutable(executable, protonArgs, { + ...options, + env: protonEnv, + shell: false, + }); + } } -const instance: IGameStore = new Steam(); +const instance: Steam = new Steam(); export default instance; + +export { Steam }; diff --git a/src/util/linux/proton.ts b/src/util/linux/proton.ts new file mode 100644 index 000000000..0a7fb29e3 --- /dev/null +++ b/src/util/linux/proton.ts @@ -0,0 +1,268 @@ +import * as path from "path"; +import * as fs from "../fs"; +import { parse } from "simple-vdf"; +import { log } from "../log"; + +export interface IProtonInfo { + usesProton: boolean; + compatDataPath?: string; + protonPath?: string; +} + +/** + * Check if a game uses Proton by looking for its compatdata folder + */ +export async function detectProtonUsage( + steamAppsPath: string, + appId: string, +): Promise { + const compatDataPath = path.join(steamAppsPath, "compatdata", appId); + try { + await fs.statAsync(compatDataPath); + return true; + } catch { + return false; + } +} + +/** + * Get the compatdata path for a game + */ +export function getCompatDataPath( + steamAppsPath: string, + appId: string, +): string { + return path.join(steamAppsPath, "compatdata", appId); +} + +/** + * Get the Wine prefix path within compatdata + */ +export function getWinePrefixPath(compatDataPath: string): string { + return path.join(compatDataPath, "pfx"); +} + +/** + * Read Steam's config.vdf to find the configured Proton version for a game + */ +export async function getConfiguredProtonName( + steamPath: string, + appId: string, +): Promise { + const configPath = path.join(steamPath, "config", "config.vdf"); + try { + const configData = await fs.readFileAsync(configPath, "utf8"); + const config = parse(configData.toString()) as any; + const mapping = + config?.InstallConfigStore?.Software?.Valve?.Steam?.CompatToolMapping; + return mapping?.[appId]?.name; + } catch (err: any) { + log("debug", "Could not read Steam config.vdf", { error: err?.message }); + return undefined; + } +} + +/** + * Check if a path exists asynchronously + */ +async function pathExists(filePath: string): Promise { + try { + await fs.statAsync(filePath); + return true; + } catch { + return false; + } +} + +/** + * Extract a searchable keyword from a Proton config name. + * Config names use formats like "proton_experimental", "proton_9", "proton_hotfix". + * Returns the portion after "proton_" for fuzzy matching against folder names. + */ +function extractProtonKeyword(protonName: string): string | undefined { + const lower = protonName.toLowerCase(); + if (!lower.startsWith("proton_")) { + return undefined; + } + return lower.slice("proton_".length); +} + +/** + * Check if a folder name matches a Proton keyword via fuzzy matching. + * Handles cases like: "proton_experimental" -> "Proton - Experimental" + * "proton_9" -> "Proton 9.0" + * "proton_hotfix" -> "Proton Hotfix" + */ +function folderMatchesKeyword(folderName: string, keyword: string): boolean { + const lowerFolder = folderName.toLowerCase(); + + // Direct substring match (handles "experimental", "hotfix", etc.) + if (lowerFolder.includes(keyword)) { + return true; + } + + // Version number match: "9" should match "9.0", "9.1", etc. + if (/^\d+$/.test(keyword)) { + const versionPattern = new RegExp(`\\b${keyword}(\\.\\d+)?\\b`); + return versionPattern.test(lowerFolder); + } + + return false; +} + +/** + * Resolve a Proton config name to its installation path. + * + * Steam stores the configured Proton version in config.vdf using internal names + * (e.g., "proton_experimental", "proton_9", "GE-Proton10-28"), but the actual + * installation folders use different naming conventions: + * - config.vdf: "proton_experimental" -> folder: "Proton - Experimental" + * - config.vdf: "proton_9" -> folder: "Proton 9.0" + * - config.vdf: "GE-Proton10-28" -> folder: "GE-Proton10-28" (exact match) + * + * Steam provides no direct mapping between these names. Custom tools (GE-Proton, etc.) + * use matching names, but official Proton versions do not. + * + * Resolution strategy (no hardcoded mappings): + * 1. Custom tools: Check compatibilitytools.d/{name} - custom Proton builds + * use their config name as the folder name directly. + * 2. Exact match: Check steamapps/common/{name} - in case config name matches. + * 3. Fuzzy match: Scan steamapps/common/Proton* folders and match by keyword. + * Extract the keyword after "proton_" and find a folder containing it. + * + * This approach is self-maintaining and doesn't require updates when Valve + * releases new Proton versions. + */ +export async function resolveProtonPath( + steamPath: string, + protonName: string, +): Promise { + // 1. Check custom compatibility tools directory (GE-Proton, etc.) + // Custom tools use their config name as the folder name directly + const customToolPath = path.join(steamPath, "compatibilitytools.d", protonName); + if (await pathExists(customToolPath)) { + return customToolPath; + } + + const commonPath = path.join(steamPath, "steamapps", "common"); + + // 2. Check for exact match in steamapps/common + const exactPath = path.join(commonPath, protonName); + if (await pathExists(exactPath)) { + return exactPath; + } + + // 3. Fuzzy match: scan Proton* folders and match by keyword + const keyword = extractProtonKeyword(protonName); + if (keyword) { + try { + const entries = await fs.readdirAsync(commonPath); + const protonDirs = entries.filter((e) => + e.toLowerCase().startsWith("proton"), + ); + + for (const dir of protonDirs) { + if (folderMatchesKeyword(dir, keyword)) { + return path.join(commonPath, dir); + } + } + } catch (err: any) { + log("debug", "Could not scan steamapps/common for Proton", { + error: err?.message, + }); + } + } + + return undefined; +} + +/** + * Find the latest installed Proton version (fallback) + */ +export async function findLatestProton( + steamPath: string, +): Promise { + const commonPath = path.join(steamPath, "steamapps", "common"); + try { + const entries = await fs.readdirAsync(commonPath); + const protonDirs = entries + .filter((e) => e.toLowerCase().startsWith("proton")) + .sort() + .reverse(); + + if (protonDirs.length > 0) { + return path.join(commonPath, protonDirs[0]); + } + } catch (err: any) { + log("debug", "Could not scan for Proton versions", { error: err?.message }); + } + return undefined; +} + +/** + * Get full Proton info for a game + */ +export async function getProtonInfo( + steamPath: string, + steamAppsPath: string, + appId: string, +): Promise { + const usesProton = await detectProtonUsage(steamAppsPath, appId); + if (!usesProton) { + return { usesProton: false }; + } + + const compatDataPath = getCompatDataPath(steamAppsPath, appId); + + // Try to get configured Proton, fall back to latest + const protonName = await getConfiguredProtonName(steamPath, appId); + let protonPath: string | undefined; + + if (protonName) { + protonPath = await resolveProtonPath(steamPath, protonName); + } + + if (!protonPath) { + protonPath = await findLatestProton(steamPath); + } + + return { usesProton: true, compatDataPath, protonPath }; +} + +/** + * Check if a file is a Windows executable + */ +export function isWindowsExecutable(filePath: string): boolean { + const ext = path.extname(filePath).toLowerCase(); + return [".exe", ".bat", ".cmd"].includes(ext); +} + +/** + * Build environment variables for running through Proton + */ +export function buildProtonEnvironment( + compatDataPath: string, + steamPath: string, + existingEnv?: Record, +): Record { + return { + ...existingEnv, + STEAM_COMPAT_DATA_PATH: compatDataPath, + STEAM_COMPAT_CLIENT_INSTALL_PATH: steamPath, + WINEPREFIX: getWinePrefixPath(compatDataPath), + }; +} + +/** + * Build the command to run an executable through Proton + */ +export function buildProtonCommand( + protonPath: string, + exePath: string, + args: string[], +): { executable: string; args: string[] } { + return { + executable: path.join(protonPath, "proton"), + args: ["run", exePath, ...args], + }; +} diff --git a/src/util/linux/steamPaths.ts b/src/util/linux/steamPaths.ts new file mode 100644 index 000000000..2b6f6f066 --- /dev/null +++ b/src/util/linux/steamPaths.ts @@ -0,0 +1,56 @@ +import * as path from "path"; +import * as fs from "fs"; +import getVortexPath from "../getVortexPath"; + +/** + * Default Steam installation paths for Linux systems + * Ordered by likelihood (most common first) + */ +export function getLinuxSteamPaths(): string[] { + const home = getVortexPath("home"); + return [ + path.join(home, ".local", "share", "Steam"), // XDG standard (native) + path.join(home, ".steam", "debian-installation"), // Debian/Ubuntu symlink + path.join(home, ".var", "app", "com.valvesoftware.Steam", "data", "Steam"), // Flatpak + path.join( + home, + ".var", + "app", + "com.valvesoftware.Steam", + ".local", + "share", + "Steam", + ), + path.join(home, "snap", "steam", "common", ".local", "share", "Steam"), // Snap + path.join(home, ".steam", "steam"), // Legacy + ]; +} + +/** + * Check if a path is a valid Steam installation + */ +export function isValidSteamPath(steamPath: string): boolean { + const libraryFoldersPath = path.join( + steamPath, + "config", + "libraryfolders.vdf", + ); + try { + fs.statSync(libraryFoldersPath); + return true; + } catch { + return false; + } +} + +/** + * Find the first valid Steam installation path on Linux + */ +export function findLinuxSteamPath(): string | undefined { + for (const steamPath of getLinuxSteamPaths()) { + if (isValidSteamPath(steamPath)) { + return steamPath; + } + } + return undefined; +} From 7d9f9cc339e2c193db6b5736b9029062a54a9284 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 21 Jan 2026 14:43:36 -0700 Subject: [PATCH 2/9] Run Linux Steam games/tools directly through Proton - Run games and tools through Proton directly instead of via `steam -applaunch` This allows passing custom command-line arguments and running different executables (like mod tools) with the game's Proton prefix - Tools now inherit store from game discovery, enabling Proton execution - Skip spinner on Linux Proton launches (ProcessMonitor only works on Windows) - Only attempt elevated execution on Windows (fixes ShellExecuteEx error on Linux) - Remove dead code: shouldUseSteamLauncherOnLinux function - Fix re-reselect imports (use default import instead of named) --- .../download_management/selectors.ts | 2 +- .../gamemode_management/selectors.ts | 2 +- src/extensions/mod_management/selectors.ts | 2 +- .../profile_management/selectors.ts | 2 +- src/util/ExtensionManager.ts | 16 ++- src/util/StarterInfo.ts | 122 +++++++++--------- 6 files changed, 76 insertions(+), 70 deletions(-) diff --git a/src/extensions/download_management/selectors.ts b/src/extensions/download_management/selectors.ts index 5755cf9f8..51da5ceb3 100644 --- a/src/extensions/download_management/selectors.ts +++ b/src/extensions/download_management/selectors.ts @@ -3,7 +3,7 @@ import type { IDownload, IState } from "../../types/IState"; import { log } from "../../util/log"; import getDownloadPath from "./util/getDownloadPath"; import type { OutputParametricSelector } from "re-reselect"; -import { createCachedSelector } from "re-reselect"; +import createCachedSelector from "re-reselect"; import { createSelector } from "reselect"; import type { DownloadState } from "./types/IDownload"; diff --git a/src/extensions/gamemode_management/selectors.ts b/src/extensions/gamemode_management/selectors.ts index 9697d72a6..bdd24506d 100644 --- a/src/extensions/gamemode_management/selectors.ts +++ b/src/extensions/gamemode_management/selectors.ts @@ -4,7 +4,7 @@ import { getSafe } from "../../util/storeHelper"; import type { IDiscoveryResult } from "./types/IDiscoveryResult"; import type { IGameStored } from "./types/IGameStored"; import { SITE_ID } from "./constants"; -import { createCachedSelector } from "re-reselect"; +import createCachedSelector from "re-reselect"; import { createSelector } from "reselect"; export function knownGames(state): IGameStored[] { diff --git a/src/extensions/mod_management/selectors.ts b/src/extensions/mod_management/selectors.ts index 2807a8ef3..f0803151f 100644 --- a/src/extensions/mod_management/selectors.ts +++ b/src/extensions/mod_management/selectors.ts @@ -8,7 +8,7 @@ import { getGame } from "../gamemode_management/util/getGame"; import getInstallPath from "./util/getInstallPath"; -import { createCachedSelector } from "re-reselect"; +import createCachedSelector from "re-reselect"; import { createSelector } from "reselect"; const installPathPattern = (state: IState) => state.settings.mods.installPath; diff --git a/src/extensions/profile_management/selectors.ts b/src/extensions/profile_management/selectors.ts index de5e90713..29310f536 100644 --- a/src/extensions/profile_management/selectors.ts +++ b/src/extensions/profile_management/selectors.ts @@ -1,6 +1,6 @@ import { getSafe } from "../../util/storeHelper"; import type { IProfile } from "./types/IProfile"; -import { createCachedSelector } from "re-reselect"; +import createCachedSelector from "re-reselect"; import { createSelector } from "reselect"; import type { IState } from "../../types/IState"; diff --git a/src/util/ExtensionManager.ts b/src/util/ExtensionManager.ts index 3317b47de..f6fc529fe 100644 --- a/src/util/ExtensionManager.ts +++ b/src/util/ExtensionManager.ts @@ -2557,9 +2557,19 @@ class ExtensionManager { }), ) .catch(ProcessCanceled, () => null) - .catch({ code: "EACCES" }, () => - this.runElevated(executable, cwd, args, env, options.onSpawned), - ) + .catch({ code: "EACCES" }, (err) => { + // Elevated execution is only supported on Windows + if (process.platform !== "win32") { + return PromiseBB.reject(err); + } + return this.runElevated( + executable, + cwd, + args, + env, + options.onSpawned, + ); + }) .catch({ code: "ECANCELED" }, () => PromiseBB.reject(new UserCanceled()), ) diff --git a/src/util/StarterInfo.ts b/src/util/StarterInfo.ts index d16917502..417a8decb 100644 --- a/src/util/StarterInfo.ts +++ b/src/util/StarterInfo.ts @@ -67,29 +67,14 @@ type OnShowErrorFunc = ( ) => void; /** - * Check if we should force Steam launcher on Linux - * Steam handles Proton automatically for Windows games - */ -function shouldUseSteamLauncherOnLinux(info: IStarterInfo): boolean { - if (process.platform === "win32" || !info.isGame) { - return false; - } - - // Check if store is explicitly set to steam - if (info.store === "steam") { - return true; - } - - // Fallback: check if the path looks like a Steam path - // This handles cases where store wasn't set during discovery - const lowerPath = info.exePath.toLowerCase(); - return lowerPath.includes("/steamapps/") || lowerPath.includes("\\steamapps\\"); -} - -/** - * Check if a tool should run through Proton + * Check if a game or tool should run through Proton on Linux. + * Returns the matching Steam game entry if Proton should be used, undefined otherwise. + * + * This enables running Windows executables directly through Proton rather than + * via `steam -applaunch`, allowing custom command-line arguments and running + * different executables (like mod tools) with the game's Proton prefix. */ -async function shouldRunToolWithProton( +async function shouldRunWithProton( info: IStarterInfo, api: IExtensionApi, ): Promise { @@ -107,7 +92,7 @@ async function shouldRunToolWithProton( const steamStore = GameStoreHelper.getGameStore("steam") as Steam; const games = await steamStore.allGames(); - // Find the game entry that matches this tool's game + // Find the game entry that matches this executable's location return games.find( (g) => info.workingDirectory @@ -116,7 +101,7 @@ async function shouldRunToolWithProton( info.exePath.toLowerCase().startsWith(g.gamePath.toLowerCase()), ); } catch (err: any) { - log("debug", "Could not check for Proton tool execution", { + log("debug", "Could not check for Proton execution", { error: err?.message, }); return undefined; @@ -155,45 +140,44 @@ class StarterInfo implements IStarterInfo { onShowError: OnShowErrorFunc, ) { const game: IGame = getGame(info.gameId); - // Determine launcher - Linux Steam games always use Steam launcher + // Determine if game requires a specific launcher (Steam, Epic, etc.) + // On Linux, Steam games run through Proton directly rather than via steam -applaunch const launcherPromise: PromiseBB<{ launcher: string; addInfo?: any }> = - shouldUseSteamLauncherOnLinux(info) - ? PromiseBB.resolve({ launcher: "steam" }) - : game.requiresLauncher !== undefined && info.isGame - ? PromiseBB.resolve( - game.requiresLauncher(path.dirname(info.exePath), info.store), - ).catch((err) => { - if (err instanceof UserCanceled) { - // warning because it'd be kind of unusual for the user to have to confirm anything - // in requiresLauncher + game.requiresLauncher !== undefined && info.isGame + ? PromiseBB.resolve( + game.requiresLauncher(path.dirname(info.exePath), info.store), + ).catch((err) => { + if (err instanceof UserCanceled) { + // warning because it'd be kind of unusual for the user to have to confirm anything + // in requiresLauncher + log( + "warn", + "failed to determine if launcher is required because user canceled something", + ); + } else { + const allowReport = !game.contributed; + const errorObj = allowReport + ? err + : { + message: + "Report this to the community extension author, not Vortex support!", + }; + onShowError( + "Failed to determine if launcher is required", + errorObj, + allowReport, + ); + if (!allowReport) { log( - "warn", - "failed to determine if launcher is required because user canceled something", - ); - } else { - const allowReport = !game.contributed; - const errorObj = allowReport - ? err - : { - message: - "Report this to the community extension author, not Vortex support!", - }; - onShowError( - "Failed to determine if launcher is required", - errorObj, - allowReport, + "error", + "failed to determine if launcher is required", + errorObj.message, ); - if (!allowReport) { - log( - "error", - "failed to determine if launcher is required", - errorObj.message, - ); - } } - return PromiseBB.resolve(undefined); - }) - : PromiseBB.resolve(undefined); + } + return PromiseBB.resolve(undefined); + }) + : PromiseBB.resolve(undefined); const onSpawned = () => { api.store.dispatch( @@ -295,9 +279,19 @@ class StarterInfo implements IStarterInfo { } }; - // Check if tool should run through Proton on Linux - const protonGameEntry = await shouldRunToolWithProton(info, api); + // Check if game/tool should run through Proton on Linux + const protonGameEntry = await shouldRunWithProton(info, api); if (protonGameEntry?.usesProton) { + // On Linux with Proton, we can't track when the process exits (ProcessMonitor + // only works on Windows), so don't set tool as running to avoid stuck spinner + const protonSpawned = () => { + if (["hide", "hide_recover"].includes(info.onStart)) { + getCurrentWindow().hide(); + } else if (info.onStart === "close") { + getApplication().quit(); + } + }; + const steamStore = GameStoreHelper.getGameStore("steam") as Steam; return steamStore.runToolWithProton( api, @@ -309,7 +303,7 @@ class StarterInfo implements IStarterInfo { suggestDeploy: true, shell: info.shell, detach: info.detach || info.onStart === "close", - onSpawned: spawned, + onSpawned: protonSpawned, }, protonGameEntry, ); @@ -497,6 +491,9 @@ class StarterInfo implements IStarterInfo { ) { this.gameId = gameDiscovery.id || game.id; this.extensionPath = gameDiscovery.extensionPath || game.extensionPath; + // Store is set from game discovery for both games and tools + // Tools need this to enable Proton execution on Linux + this.store = gameDiscovery.store; this.detach = getSafe( toolDiscovery, ["detach"], @@ -547,7 +544,6 @@ class StarterInfo implements IStarterInfo { this.logoName = gameDiscovery.logo || game.logo; this.details = game.details; this.exclusive = true; - this.store = gameDiscovery.store; } private initFromTool( From a31227343aca99574901fded2cd35d8d9c159ac5 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 27 Jan 2026 13:36:36 -0700 Subject: [PATCH 3/9] Apply suggestion from @erri120 Co-authored-by: erri120 --- src/extensions/download_management/selectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/download_management/selectors.ts b/src/extensions/download_management/selectors.ts index 51da5ceb3..5755cf9f8 100644 --- a/src/extensions/download_management/selectors.ts +++ b/src/extensions/download_management/selectors.ts @@ -3,7 +3,7 @@ import type { IDownload, IState } from "../../types/IState"; import { log } from "../../util/log"; import getDownloadPath from "./util/getDownloadPath"; import type { OutputParametricSelector } from "re-reselect"; -import createCachedSelector from "re-reselect"; +import { createCachedSelector } from "re-reselect"; import { createSelector } from "reselect"; import type { DownloadState } from "./types/IDownload"; From 2af9f6a64db68cc95d54d96a54db400e8b1871be Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 27 Jan 2026 13:36:44 -0700 Subject: [PATCH 4/9] Apply suggestion from @erri120 Co-authored-by: erri120 --- src/extensions/gamemode_management/selectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/gamemode_management/selectors.ts b/src/extensions/gamemode_management/selectors.ts index bdd24506d..9697d72a6 100644 --- a/src/extensions/gamemode_management/selectors.ts +++ b/src/extensions/gamemode_management/selectors.ts @@ -4,7 +4,7 @@ import { getSafe } from "../../util/storeHelper"; import type { IDiscoveryResult } from "./types/IDiscoveryResult"; import type { IGameStored } from "./types/IGameStored"; import { SITE_ID } from "./constants"; -import createCachedSelector from "re-reselect"; +import { createCachedSelector } from "re-reselect"; import { createSelector } from "reselect"; export function knownGames(state): IGameStored[] { From 5820d76031796d231043cdc1c59e961b9127d487 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 27 Jan 2026 13:36:51 -0700 Subject: [PATCH 5/9] Apply suggestion from @erri120 Co-authored-by: erri120 --- src/extensions/mod_management/selectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/mod_management/selectors.ts b/src/extensions/mod_management/selectors.ts index f0803151f..2807a8ef3 100644 --- a/src/extensions/mod_management/selectors.ts +++ b/src/extensions/mod_management/selectors.ts @@ -8,7 +8,7 @@ import { getGame } from "../gamemode_management/util/getGame"; import getInstallPath from "./util/getInstallPath"; -import createCachedSelector from "re-reselect"; +import { createCachedSelector } from "re-reselect"; import { createSelector } from "reselect"; const installPathPattern = (state: IState) => state.settings.mods.installPath; From 9905c6cbb2c48d22a11ab4c960a2f8a47e628c8c Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 27 Jan 2026 13:37:00 -0700 Subject: [PATCH 6/9] Apply suggestion from @erri120 Co-authored-by: erri120 --- src/extensions/profile_management/selectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/profile_management/selectors.ts b/src/extensions/profile_management/selectors.ts index 29310f536..de5e90713 100644 --- a/src/extensions/profile_management/selectors.ts +++ b/src/extensions/profile_management/selectors.ts @@ -1,6 +1,6 @@ import { getSafe } from "../../util/storeHelper"; import type { IProfile } from "./types/IProfile"; -import createCachedSelector from "re-reselect"; +import { createCachedSelector } from "re-reselect"; import { createSelector } from "reselect"; import type { IState } from "../../types/IState"; From 24d8a3182317c43e0dbcd2002e59c2cb68b8d48a Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Mon, 2 Feb 2026 09:43:23 -0700 Subject: [PATCH 7/9] Update src/util/Steam.ts Co-authored-by: erri120 --- src/util/Steam.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/Steam.ts b/src/util/Steam.ts index d6062d186..0252e5d1f 100644 --- a/src/util/Steam.ts +++ b/src/util/Steam.ts @@ -382,10 +382,10 @@ class Steam implements IGameStore { entry.usesProton = protonInfo.usesProton; entry.compatDataPath = protonInfo.compatDataPath; entry.protonPath = protonInfo.protonPath; - } catch (err: any) { + } catch (err) { log("debug", "Could not get Proton info for game", { appid: entry.appid, - error: err?.message, + error: getErrorMessageOrDefault(err), }); } return entry; From 782cedc2b055d5d6fd93716de21ae7cc6f5057a0 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 3 Feb 2026 10:11:04 +0000 Subject: [PATCH 8/9] import type and formatting --- src/util/StarterInfo.ts | 2 +- src/util/linux/proton.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/util/StarterInfo.ts b/src/util/StarterInfo.ts index 417a8decb..ea5bf9077 100644 --- a/src/util/StarterInfo.ts +++ b/src/util/StarterInfo.ts @@ -29,7 +29,7 @@ import * as path from "path"; import { GameEntryNotFound, GameStoreNotFound } from "../types/IGameStore"; import { getErrorCode, unknownToError } from "../shared/errors"; import { isWindowsExecutable } from "./linux/proton"; -import { Steam, type ISteamEntry } from "./Steam"; +import type { Steam, ISteamEntry } from "./Steam"; function getCurrentWindow() { if (process.type === "renderer") { diff --git a/src/util/linux/proton.ts b/src/util/linux/proton.ts index 0a7fb29e3..18239c0fa 100644 --- a/src/util/linux/proton.ts +++ b/src/util/linux/proton.ts @@ -139,7 +139,11 @@ export async function resolveProtonPath( ): Promise { // 1. Check custom compatibility tools directory (GE-Proton, etc.) // Custom tools use their config name as the folder name directly - const customToolPath = path.join(steamPath, "compatibilitytools.d", protonName); + const customToolPath = path.join( + steamPath, + "compatibilitytools.d", + protonName, + ); if (await pathExists(customToolPath)) { return customToolPath; } From 2861e68a352fb6b5d352f42a7e7211409ef718ca Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 3 Feb 2026 18:18:16 +0000 Subject: [PATCH 9/9] use new preload api --- src/util/StarterInfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/StarterInfo.ts b/src/util/StarterInfo.ts index 05a006ea3..f4471799c 100644 --- a/src/util/StarterInfo.ts +++ b/src/util/StarterInfo.ts @@ -304,7 +304,7 @@ class StarterInfo implements IStarterInfo { // only works on Windows), so don't set tool as running to avoid stuck spinner const protonSpawned = () => { if (["hide", "hide_recover"].includes(info.onStart)) { - getCurrentWindow().hide(); + hideWindow(); } else if (info.onStart === "close") { getApplication().quit(); }