Skip to content
16 changes: 13 additions & 3 deletions src/util/ExtensionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
)
Expand Down
94 changes: 88 additions & 6 deletions src/util/StarterInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ISteamEntry | undefined> {
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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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<void> {
): Promise<void> {
const spawned = () => {
onSpawned();
if (["hide", "hide_recover"].includes(info.onStart)) {
Expand All @@ -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),
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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(
Expand Down
81 changes: 77 additions & 4 deletions src/util/Steam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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 };
Loading