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
5 changes: 4 additions & 1 deletion public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
},
"select": {
"button": "Select",
"exe": "Select EXE"
"exe": "Select EXE",
"script": "Select script ..."
},
"shortcuts": {
"message": "Shortcuts were created on Desktop and Start Menu",
Expand Down Expand Up @@ -549,6 +550,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 +559,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
4 changes: 3 additions & 1 deletion src/backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,9 @@ class GlobalConfigV0 extends GlobalConfig {
enableFsync: isLinux,
eacRuntime: isLinux,
battlEyeRuntime: isLinux,
framelessWindow: false
framelessWindow: false,
beforeLaunchScriptPath: '',
afterLaunchScriptPath: ''
}
// @ts-expect-error TODO: We need to settle on *one* place to define settings defaults
return settings
Expand Down
8 changes: 6 additions & 2 deletions src/backend/game_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ class GameConfigV0 extends GameConfig {
wineVersion,
useSteamRuntime,
eacRuntime,
battlEyeRuntime
battlEyeRuntime,
beforeLaunchScriptPath,
afterLaunchScriptPath
} = GlobalConfig.get().getSettings()

// initialize generic defaults
Expand Down Expand Up @@ -258,7 +260,9 @@ class GameConfigV0 extends GameConfig {
useSteamRuntime,
battlEyeRuntime,
eacRuntime,
language: '' // we want to fallback to '' always here, fallback lang for games should be ''
language: '', // we want to fallback to '' always here, fallback lang for games should be ''
beforeLaunchScriptPath,
afterLaunchScriptPath
} as GameSettings

let gameSettings = {} as GameSettings
Expand Down
85 changes: 84 additions & 1 deletion src/backend/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import {
appendFileLog,
appendGameLog,
appendGamePlayLog,
appendRunnerLog,
initFileLog,
initGameLog,
Expand Down Expand Up @@ -1281,6 +1282,86 @@ async function getWinePath({
return stdout.trim()
}

async function runBeforeLaunchScript(
gameInfo: GameInfo,
gameSettings: GameSettings
) {
if (!gameSettings.beforeLaunchScriptPath) {
return true
}

appendGamePlayLog(
gameInfo,
`Running script before ${gameInfo.title} (${gameSettings.beforeLaunchScriptPath})\n`
)

return runScriptForGame(gameInfo, gameSettings.beforeLaunchScriptPath)
}

async function runAfterLaunchScript(
gameInfo: GameInfo,
gameSettings: GameSettings
) {
if (!gameSettings.afterLaunchScriptPath) {
return true
}

appendGamePlayLog(
gameInfo,
`Running script after ${gameInfo.title} (${gameSettings.afterLaunchScriptPath})\n`
)
return runScriptForGame(gameInfo, gameSettings.afterLaunchScriptPath)
}

/* Execute script before launch/after exit, wait until the script
* exits to continue
*
* The script can start sub-processes with `bash another-command &`
* if `another-command` should run asynchronously
*
* For example:
*
* ```
* #!/bin/bash
*
* echo "this runs before/after the game"
* bash ./another.bash & # this is launched before/after the game but is not waited
* echo "this also runs before/after the game too" > someoutput.txt
* ```
*
* Notes:
* - Output and logs are printed in the game's log
* - Make sure the script is executable
* - Make sure any async process is not stuck running in the background forever,
* use the after script to kill any running process if that's the case
*/
async function runScriptForGame(
gameInfo: GameInfo,
scriptPath: string
): Promise<boolean | string> {
return new Promise((resolve, reject) => {
const child = spawn(scriptPath, { cwd: gameInfo.install.install_path })

child.stdout.on('data', (data) => {
appendGamePlayLog(gameInfo, data.toString())
})

child.stderr.on('data', (data) => {
appendGamePlayLog(gameInfo, data.toString())
})

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

child.on('error', (err: Error) => {
appendGamePlayLog(gameInfo, err.message)
if (err.stack) appendGamePlayLog(gameInfo, err.stack)
reject(err.message)
})
})
}

export {
prepareLaunch,
launchCleanup,
Expand All @@ -1292,5 +1373,7 @@ export {
runWineCommand,
callRunner,
getRunnerCallWithoutCredentials,
getWinePath
getWinePath,
runAfterLaunchScript,
runBeforeLaunchScript
}
13 changes: 11 additions & 2 deletions src/backend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ import {
} from './logger/logger'
import { gameInfoStore } from 'backend/storeManagers/legendary/electronStores'
import { getFonts } from 'font-list'
import { runWineCommand } from './launcher'
import {
runAfterLaunchScript,
runBeforeLaunchScript,
runWineCommand
} from './launcher'
import shlex from 'shlex'
import { initQueue } from './downloadmanager/downloadqueue'
import {
Expand Down Expand Up @@ -1070,6 +1074,8 @@ ipcMain.handle(
}
}

await runBeforeLaunchScript(game, gameSettings)

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

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

// Stop display sleep blocker
if (powerDisplayId !== null) {
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.script', 'Select script ...')}
pathDialogDefaultPath={gameInfo?.install.install_path}
placeholder={scriptPath || t('box.select.script', '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.script', 'Select script ...')}
pathDialogDefaultPath={gameInfo?.install.install_path}
placeholder={scriptPath || t('box.select.script', '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