diff --git a/application/src/electron/handlers/handler.library.ts b/application/src/electron/handlers/handler.library.ts index 4a6b1b06..8fa6b71c 100644 --- a/application/src/electron/handlers/handler.library.ts +++ b/application/src/electron/handlers/handler.library.ts @@ -5,7 +5,11 @@ import { ipcMain } from 'electron'; import { spawn, spawnSync } from 'child_process'; import type { LibraryInfo } from '@ogi-sdk/connect'; -import { isLinux } from '@/electron/handlers/helpers.app/platform.js'; +import { + isLinux, + isWindows, +} from '@/electron/handlers/helpers.app/platform.js'; +import { spawnWindowsGameProcess } from '@/electron/handlers/helpers.app/windows-launch.js'; import { getSteamAppIdWithFallback, getNonSteamGameAppID, @@ -134,16 +138,24 @@ export async function launchGameFromLibrary( appInfo.cwd ); - const needsShellOnWindows = /\.(bat|cmd)$/i.test(launchExecutable); - const spawnedItem = spawn(launchExecutable, otherLaunchArguments, { - cwd: appInfo.cwd, - shell: process.platform === 'win32' ? needsShellOnWindows : true, - env: { - ...process.env, - ...(launchEnv ?? {}), - ...effectiveLaunchEnv, - }, - }); + const launchEnvMerged = { + ...process.env, + ...(launchEnv ?? {}), + ...effectiveLaunchEnv, + }; + + const spawnedItem = isWindows() + ? spawnWindowsGameProcess({ + executable: launchExecutable, + args: otherLaunchArguments, + cwd: appInfo.cwd, + env: launchEnvMerged, + }) + : spawn(launchExecutable, otherLaunchArguments, { + cwd: appInfo.cwd, + shell: true, + env: launchEnvMerged, + }); spawnedItem.on('error', (error) => { console.error(error); sendNotification({ diff --git a/application/src/electron/handlers/helpers.app/platform.ts b/application/src/electron/handlers/helpers.app/platform.ts index dc05752e..3a06589e 100644 --- a/application/src/electron/handlers/helpers.app/platform.ts +++ b/application/src/electron/handlers/helpers.app/platform.ts @@ -9,6 +9,10 @@ export function isLinux(): boolean { return process.platform === 'linux'; } +export function isWindows(): boolean { + return process.platform === 'win32'; +} + export function getHomeDir(): string | null { return process.env.HOME || process.env.USERPROFILE || null; } diff --git a/application/src/electron/handlers/helpers.app/windows-launch.ts b/application/src/electron/handlers/helpers.app/windows-launch.ts new file mode 100644 index 00000000..24de4023 --- /dev/null +++ b/application/src/electron/handlers/helpers.app/windows-launch.ts @@ -0,0 +1,80 @@ +/** + * Windows game launch via ShellExecute (PowerShell Start-Process). + * + * child_process.spawn() uses CreateProcess and cannot trigger UAC for executables + * that declare requireAdministrator in their manifest. Start-Process uses ShellExecute + * and shows the elevation prompt when required. + */ +import { spawn, type ChildProcess } from 'child_process'; + +function quotePowerShellSingle(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function quotePowerShellEnvName(name: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error(`Invalid environment variable name: ${name}`); + } + return name; +} + +export function buildWindowsLaunchScript(options: { + executable: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +}): string { + const { executable, args, cwd, env } = options; + + const envAssignments = Object.entries(env) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map( + ([key, value]) => + `$env:${quotePowerShellEnvName(key)}=${quotePowerShellSingle(value)}` + ) + .join('; '); + + const argumentList = + args.length > 0 + ? `-ArgumentList ${args.map(quotePowerShellSingle).join(',')}` + : ''; + + const startProcess = [ + '$p = Start-Process', + `-FilePath ${quotePowerShellSingle(executable)}`, + argumentList, + `-WorkingDirectory ${quotePowerShellSingle(cwd)}`, + '-PassThru', + ] + .filter((part) => part.length > 0) + .join(' '); + + return [ + envAssignments, + startProcess, + 'if (-not $p) { exit 1 }', + '$p.WaitForExit()', + 'exit $p.ExitCode', + ] + .filter((part) => part.length > 0) + .join('; '); +} + +export function spawnWindowsGameProcess(options: { + executable: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +}): ChildProcess { + const script = buildWindowsLaunchScript(options); + + return spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], + { + env: process.env, + windowsHide: true, + stdio: 'ignore', + } + ); +}