Skip to content

[Feature] Configure scripts to run before and after a game is launched #3565

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 31, 2024
Merged
2 changes: 2 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@
"addgamestoapplications": "Add games to Applications automatically",
"addgamestostartmenu": "Add games to start menu automatically",
"addgamestosteam": "Add games to Steam automatically",
"after-launch-script-path": "Select a script to run after the game exits",
"alt-gogdl-bin": "Choose an Alternative GOGDL Binary to use",
"alt-legendary-bin": "Choose an Alternative Legendary Binary",
"alt-nile-bin": "Choose an Alternative Nile Binary",
Expand All @@ -557,6 +558,7 @@
"autosync": "Autosync Saves",
"autoUpdateGames": "Automatically update games",
"autovkd3d": "Auto Install/Update VKD3D on Prefix",
"before-launch-script-path": "Select a script to run before the game is launched",
"change-target-exe": "Select an alternative EXE to run",
"checkForUpdatesOnStartup": "Check for Heroic Updates on Startup",
"crossover-version": "Crossover/Wine Version",
Expand Down
83 changes: 80 additions & 3 deletions src/backend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
DiskSpaceData,
StatusPromise,
GamepadInputEvent,
Runner
Runner,
GameInfo
} from 'common/types'
import * as path from 'path'
import {
Expand Down Expand Up @@ -96,7 +97,8 @@ import {
isSnap,
fixesPath,
isWindows,
isMac
isMac,
execOptions
} from './constants'
import { handleProtocol } from './protocol'
import {
Expand Down Expand Up @@ -158,6 +160,7 @@ import {
getGameSdl
} from 'backend/storeManagers/legendary/library'
import { storeMap } from 'common/utils'
import { execSync, spawn } from 'child_process'

app.commandLine?.appendSwitch('ozone-platform-hint', 'auto')

Expand Down Expand Up @@ -1070,6 +1073,8 @@ ipcMain.handle(
}
}

await runBeforeLaunchScript(game, gameSettings)

sendGameStatusUpdate({
appName,
runner,
Expand All @@ -1092,7 +1097,10 @@ ipcMain.handle(

return false
})
.finally(() => stopLogger(appName))
.finally(() => {
runAfterLaunchScript(game, gameSettings)
stopLogger(appName)
})

// Stop display sleep blocker
if (powerDisplayId !== null) {
Expand Down Expand Up @@ -1168,6 +1176,75 @@ ipcMain.handle(
}
)

async function runBeforeLaunchScript(
gameInfo: GameInfo,
gameSettings: GameSettings
) {
if (gameSettings.beforeLaunchScriptPath) {
return new Promise((resolve, reject) => {
logInfo(
[
'Running script before',
gameInfo.title,
`(${gameSettings.beforeLaunchScriptPath})`
],
LogPrefix.Backend
)

// Execute script before launch, wait until the script
// exits to continue
//
// The script can start sub-processes with `bash another-command &`
// if `another-command` can run async
const child = spawn(gameSettings.beforeLaunchScriptPath, {
cwd: gameInfo.install.install_path,
...execOptions
})

child.stdout.on('data', (data) => {
logInfo(data.toString(), LogPrefix.Backend)
})

child.stderr.on('data', (data) => {
logInfo(data.toString(), LogPrefix.Backend)
})

child.on('exit', () => {
resolve(true)
})

child.on('error', (err: Error) => {
logError(
['Error running before script: ', err.message],
LogPrefix.Backend
)
if (err.stack) logError(err.stack, LogPrefix.Backend)
reject(err.message)
})
})
} else {
return Promise.resolve(true)
}
}

function runAfterLaunchScript(gameInfo: GameInfo, gameSettings: GameSettings) {
if (gameSettings.afterLaunchScriptPath) {
logInfo(
[
'Running script after',
gameInfo.title,
`(${gameSettings.afterLaunchScriptPath})`
],
LogPrefix.Backend
)
const output = execSync(gameSettings.afterLaunchScriptPath, {
cwd: gameInfo.install.install_path,
...execOptions
})
logInfo(output.toString(), LogPrefix.Backend)
}
}

ipcMain.handle('openDialog', async (e, args) => {
const mainWindow = getMainWindow()
if (!mainWindow) {
Expand Down
2 changes: 2 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export interface GameSettings {
wrapperOptions: WrapperVariable[]
savesPath: string
gogSaves?: GOGCloudSavesLocation[]
beforeLaunchScriptPath: string
afterLaunchScriptPath: string
}

export type Status =
Expand Down
34 changes: 34 additions & 0 deletions src/frontend/screens/Settings/components/AfterLaunchScriptPath.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import SettingsContext from '../SettingsContext'
import useSetting from 'frontend/hooks/useSetting'
import { PathSelectionBox } from 'frontend/components/UI'

const AfterLaunchScriptPath = () => {
const { t } = useTranslation()
const { isDefault, gameInfo } = useContext(SettingsContext)

const [scriptPath, setScriptPath] = useSetting('afterLaunchScriptPath', '')

if (isDefault) {
return <></>
}

return (
<PathSelectionBox
type="file"
onPathChange={setScriptPath}
path={scriptPath}
pathDialogTitle={t('box.select.exe', 'Select EXE')}
pathDialogDefaultPath={gameInfo?.install.install_path}
placeholder={scriptPath || t('box.select.exe', 'Select script ...')}
label={t(
'setting.after-launch-script-path',
'Select a script to run after the game exits'
)}
htmlId="after-launch-script-path"
/>
)
}

export default AfterLaunchScriptPath
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import SettingsContext from '../SettingsContext'
import useSetting from 'frontend/hooks/useSetting'
import { PathSelectionBox } from 'frontend/components/UI'

const BeforeLaunchScriptPath = () => {
const { t } = useTranslation()
const { isDefault, gameInfo } = useContext(SettingsContext)

const [scriptPath, setScriptPath] = useSetting('beforeLaunchScriptPath', '')

if (isDefault) {
return <></>
}

return (
<PathSelectionBox
type="file"
onPathChange={setScriptPath}
path={scriptPath}
pathDialogTitle={t('box.select.exe', 'Select EXE')}
pathDialogDefaultPath={gameInfo?.install.install_path}
placeholder={scriptPath || t('box.select.exe', 'Select script ...')}
label={t(
'setting.before-launch-script-path',
'Select a script to run before the game is launched'
)}
htmlId="before-launch-script-path"
/>
)
}

export default BeforeLaunchScriptPath
2 changes: 2 additions & 0 deletions src/frontend/screens/Settings/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,5 @@ export { default as EnableDXVKFpsLimit } from './EnableDXVKFpsLimit'
export { default as PlaytimeSync } from './PlaytimeSync'
export { default as ClearCache } from './ClearCache'
export { default as ResetHeroic } from './ResetHeroic'
export { default as BeforeLaunchScriptPath } from './BeforeLaunchScriptPath'
export { default as AfterLaunchScriptPath } from './AfterLaunchScriptPath'
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import {
WrappersTable,
EnableDXVKFpsLimit,
IgnoreGameUpdates,
Gamescope
Gamescope,
BeforeLaunchScriptPath,
AfterLaunchScriptPath
} from '../../components'
import ContextProvider from 'frontend/state/ContextProvider'
import Tools from '../../components/Tools'
Expand Down Expand Up @@ -230,7 +232,9 @@ export default function GamesSettings() {
<OfflineMode />
</>
)}
<BeforeLaunchScriptPath />
<AlternativeExe />
<AfterLaunchScriptPath />
<LauncherArgs />
<WrappersTable />
<EnvVariablesTable />
Expand Down