diff --git a/src/util/ExtensionManager.ts b/src/util/ExtensionManager.ts index 22b661e8b..c979aff6f 100644 --- a/src/util/ExtensionManager.ts +++ b/src/util/ExtensionManager.ts @@ -2520,9 +2520,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 aff3a5595..f4471799c 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 type { Steam, ISteamEntry } from "./Steam"; import type { Api, PreloadWindow } from "../shared/types/preload"; // TODO: remove this when separation is complete @@ -82,6 +84,48 @@ type OnShowErrorFunc = ( allowReport?: boolean, ) => void; +/** + * 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 shouldRunWithProton( + 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 executable's location + 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 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. @@ -114,6 +158,8 @@ class StarterInfo implements IStarterInfo { onShowError: OnShowErrorFunc, ) { const game: IGame = getGame(info.gameId); + // 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 }> = game.requiresLauncher !== undefined && info.isGame ? PromiseBB.resolve( @@ -169,9 +215,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)) { void hideWindow(); } else if (info.onStart === "close") { @@ -232,12 +282,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)) { @@ -247,6 +297,36 @@ class StarterInfo implements IStarterInfo { } }; + // 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)) { + hideWindow(); + } else if (info.onStart === "close") { + getApplication().quit(); + } + }; + + 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: protonSpawned, + }, + protonGameEntry, + ); + } + return api .runExecutable(info.exePath, info.commandLine, { cwd: info.workingDirectory || path.dirname(info.exePath), @@ -426,6 +506,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"], @@ -476,7 +559,6 @@ class StarterInfo implements IStarterInfo { this.logoName = gameDiscovery.logo || game.logo; this.details = game.details; this.exclusive = true; - this.store = gameDiscovery.store; } private initFromTool( diff --git a/src/util/Steam.ts b/src/util/Steam.ts index 9b0a49f97..ebb293cc1 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); } } @@ -359,6 +367,32 @@ class Steam implements IGameStore { }) .filter((obj): obj is ISteamEntry => !!obj); }) + .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) { + log("debug", "Could not get Proton info for game", { + appid: entry.appid, + error: getErrorMessageOrDefault(err), + }); + } + 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 @@ -386,8 +420,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..18239c0fa --- /dev/null +++ b/src/util/linux/proton.ts @@ -0,0 +1,272 @@ +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; +}