Skip to content

Commit 3741f0d

Browse files
arieljCommandMC
andauthored
[Feature] Configure scripts to run before and after a game is launched (#3565)
* Feature: before/after launch scripts for games * Add i18next * Say 'after exists' instead of 'after launched' to avoid confusion * Fix log output * Address feedback from PR review * Use `Select script...` for the placeholder instead of `Select EXE...` * File picker dialog title change * Set default value for before/after launch script setting * Update src/backend/launcher.ts Co-authored-by: Mathis Dröge <[email protected]> * Remove ...execOptions from spawn call * Reorder inputs --------- Co-authored-by: Mathis Dröge <[email protected]>
1 parent 4c07157 commit 3741f0d

File tree

10 files changed

+188
-8
lines changed

10 files changed

+188
-8
lines changed

public/locales/en/translation.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@
119119
},
120120
"select": {
121121
"button": "Select",
122-
"exe": "Select EXE"
122+
"exe": "Select EXE",
123+
"script": "Select script ..."
123124
},
124125
"shortcuts": {
125126
"message": "Shortcuts were created on Desktop and Start Menu",
@@ -572,6 +573,7 @@
572573
"addgamestoapplications": "Add games to Applications automatically",
573574
"addgamestostartmenu": "Add games to start menu automatically",
574575
"addgamestosteam": "Add games to Steam automatically",
576+
"after-launch-script-path": "Select a script to run after the game exits",
575577
"alt-gogdl-bin": "Choose an Alternative GOGDL Binary to use",
576578
"alt-legendary-bin": "Choose an Alternative Legendary Binary",
577579
"alt-nile-bin": "Choose an Alternative Nile Binary",
@@ -580,6 +582,7 @@
580582
"autosync": "Autosync Saves",
581583
"autoUpdateGames": "Automatically update games",
582584
"autovkd3d": "Auto Install/Update VKD3D on Prefix",
585+
"before-launch-script-path": "Select a script to run before the game is launched",
583586
"change-target-exe": "Select an alternative EXE to run",
584587
"checkForUpdatesOnStartup": "Check for Heroic Updates on Startup",
585588
"crossover-version": "Crossover/Wine Version",

src/backend/config.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,9 @@ class GlobalConfigV0 extends GlobalConfig {
316316
enableMsync: isMac,
317317
eacRuntime: isLinux,
318318
battlEyeRuntime: isLinux,
319-
framelessWindow: false
319+
framelessWindow: false,
320+
beforeLaunchScriptPath: '',
321+
afterLaunchScriptPath: ''
320322
}
321323
// @ts-expect-error TODO: We need to settle on *one* place to define settings defaults
322324
return settings

src/backend/game_config.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ class GameConfigV0 extends GameConfig {
231231
wineVersion,
232232
useSteamRuntime,
233233
eacRuntime,
234-
battlEyeRuntime
234+
battlEyeRuntime,
235+
beforeLaunchScriptPath,
236+
afterLaunchScriptPath
235237
} = GlobalConfig.get().getSettings()
236238

237239
// initialize generic defaults
@@ -260,7 +262,9 @@ class GameConfigV0 extends GameConfig {
260262
useSteamRuntime,
261263
battlEyeRuntime,
262264
eacRuntime,
263-
language: '' // we want to fallback to '' always here, fallback lang for games should be ''
265+
language: '', // we want to fallback to '' always here, fallback lang for games should be ''
266+
beforeLaunchScriptPath,
267+
afterLaunchScriptPath
264268
} as GameSettings
265269

266270
let gameSettings = {} as GameSettings

src/backend/launcher.ts

+84-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
import {
4545
appendFileLog,
4646
appendGameLog,
47+
appendGamePlayLog,
4748
appendRunnerLog,
4849
initFileLog,
4950
initGameLog,
@@ -1306,6 +1307,86 @@ async function getWinePath({
13061307
return stdout.trim()
13071308
}
13081309

1310+
async function runBeforeLaunchScript(
1311+
gameInfo: GameInfo,
1312+
gameSettings: GameSettings
1313+
) {
1314+
if (!gameSettings.beforeLaunchScriptPath) {
1315+
return true
1316+
}
1317+
1318+
appendGamePlayLog(
1319+
gameInfo,
1320+
`Running script before ${gameInfo.title} (${gameSettings.beforeLaunchScriptPath})\n`
1321+
)
1322+
1323+
return runScriptForGame(gameInfo, gameSettings.beforeLaunchScriptPath)
1324+
}
1325+
1326+
async function runAfterLaunchScript(
1327+
gameInfo: GameInfo,
1328+
gameSettings: GameSettings
1329+
) {
1330+
if (!gameSettings.afterLaunchScriptPath) {
1331+
return true
1332+
}
1333+
1334+
appendGamePlayLog(
1335+
gameInfo,
1336+
`Running script after ${gameInfo.title} (${gameSettings.afterLaunchScriptPath})\n`
1337+
)
1338+
return runScriptForGame(gameInfo, gameSettings.afterLaunchScriptPath)
1339+
}
1340+
1341+
/* Execute script before launch/after exit, wait until the script
1342+
* exits to continue
1343+
*
1344+
* The script can start sub-processes with `bash another-command &`
1345+
* if `another-command` should run asynchronously
1346+
*
1347+
* For example:
1348+
*
1349+
* ```
1350+
* #!/bin/bash
1351+
*
1352+
* echo "this runs before/after the game"
1353+
* bash ./another.bash & # this is launched before/after the game but is not waited
1354+
* echo "this also runs before/after the game too" > someoutput.txt
1355+
* ```
1356+
*
1357+
* Notes:
1358+
* - Output and logs are printed in the game's log
1359+
* - Make sure the script is executable
1360+
* - Make sure any async process is not stuck running in the background forever,
1361+
* use the after script to kill any running process if that's the case
1362+
*/
1363+
async function runScriptForGame(
1364+
gameInfo: GameInfo,
1365+
scriptPath: string
1366+
): Promise<boolean | string> {
1367+
return new Promise((resolve, reject) => {
1368+
const child = spawn(scriptPath, { cwd: gameInfo.install.install_path })
1369+
1370+
child.stdout.on('data', (data) => {
1371+
appendGamePlayLog(gameInfo, data.toString())
1372+
})
1373+
1374+
child.stderr.on('data', (data) => {
1375+
appendGamePlayLog(gameInfo, data.toString())
1376+
})
1377+
1378+
child.on('exit', () => {
1379+
resolve(true)
1380+
})
1381+
1382+
child.on('error', (err: Error) => {
1383+
appendGamePlayLog(gameInfo, err.message)
1384+
if (err.stack) appendGamePlayLog(gameInfo, err.stack)
1385+
reject(err.message)
1386+
})
1387+
})
1388+
}
1389+
13091390
export {
13101391
prepareLaunch,
13111392
launchCleanup,
@@ -1317,5 +1398,7 @@ export {
13171398
runWineCommand,
13181399
callRunner,
13191400
getRunnerCallWithoutCredentials,
1320-
getWinePath
1401+
getWinePath,
1402+
runAfterLaunchScript,
1403+
runBeforeLaunchScript
13211404
}

src/backend/main.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ import {
110110
} from './logger/logger'
111111
import { gameInfoStore } from 'backend/storeManagers/legendary/electronStores'
112112
import { getFonts } from 'font-list'
113-
import { runWineCommand } from './launcher'
113+
import {
114+
runAfterLaunchScript,
115+
runBeforeLaunchScript,
116+
runWineCommand
117+
} from './launcher'
114118
import shlex from 'shlex'
115119
import { initQueue } from './downloadmanager/downloadqueue'
116120
import {
@@ -1033,6 +1037,8 @@ ipcMain.handle(
10331037
}
10341038
}
10351039

1040+
await runBeforeLaunchScript(game, gameSettings)
1041+
10361042
sendGameStatusUpdate({
10371043
appName,
10381044
runner,
@@ -1055,7 +1061,10 @@ ipcMain.handle(
10551061

10561062
return false
10571063
})
1058-
.finally(() => stopLogger(appName))
1064+
.finally(async () => {
1065+
await runAfterLaunchScript(game, gameSettings)
1066+
stopLogger(appName)
1067+
})
10591068

10601069
// Stop display sleep blocker
10611070
if (powerDisplayId !== null) {

src/common/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ export interface GameSettings {
194194
wrapperOptions: WrapperVariable[]
195195
savesPath: string
196196
gogSaves?: GOGCloudSavesLocation[]
197+
beforeLaunchScriptPath: string
198+
afterLaunchScriptPath: string
197199
}
198200

199201
export type Status =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React, { useContext } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import SettingsContext from '../SettingsContext'
4+
import useSetting from 'frontend/hooks/useSetting'
5+
import { PathSelectionBox } from 'frontend/components/UI'
6+
7+
const AfterLaunchScriptPath = () => {
8+
const { t } = useTranslation()
9+
const { isDefault, gameInfo } = useContext(SettingsContext)
10+
11+
const [scriptPath, setScriptPath] = useSetting('afterLaunchScriptPath', '')
12+
13+
if (isDefault) {
14+
return <></>
15+
}
16+
17+
return (
18+
<PathSelectionBox
19+
type="file"
20+
onPathChange={setScriptPath}
21+
path={scriptPath}
22+
pathDialogTitle={t('box.select.script', 'Select script ...')}
23+
pathDialogDefaultPath={gameInfo?.install.install_path}
24+
placeholder={scriptPath || t('box.select.script', 'Select script ...')}
25+
label={t(
26+
'setting.after-launch-script-path',
27+
'Select a script to run after the game exits'
28+
)}
29+
htmlId="after-launch-script-path"
30+
/>
31+
)
32+
}
33+
34+
export default AfterLaunchScriptPath
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React, { useContext } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import SettingsContext from '../SettingsContext'
4+
import useSetting from 'frontend/hooks/useSetting'
5+
import { PathSelectionBox } from 'frontend/components/UI'
6+
7+
const BeforeLaunchScriptPath = () => {
8+
const { t } = useTranslation()
9+
const { isDefault, gameInfo } = useContext(SettingsContext)
10+
11+
const [scriptPath, setScriptPath] = useSetting('beforeLaunchScriptPath', '')
12+
13+
if (isDefault) {
14+
return <></>
15+
}
16+
17+
return (
18+
<PathSelectionBox
19+
type="file"
20+
onPathChange={setScriptPath}
21+
path={scriptPath}
22+
pathDialogTitle={t('box.select.script', 'Select script ...')}
23+
pathDialogDefaultPath={gameInfo?.install.install_path}
24+
placeholder={scriptPath || t('box.select.script', 'Select script ...')}
25+
label={t(
26+
'setting.before-launch-script-path',
27+
'Select a script to run before the game is launched'
28+
)}
29+
htmlId="before-launch-script-path"
30+
/>
31+
)
32+
}
33+
34+
export default BeforeLaunchScriptPath

src/frontend/screens/Settings/components/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ export { default as EnableDXVKFpsLimit } from './EnableDXVKFpsLimit'
5252
export { default as PlaytimeSync } from './PlaytimeSync'
5353
export { default as ClearCache } from './ClearCache'
5454
export { default as ResetHeroic } from './ResetHeroic'
55+
export { default as BeforeLaunchScriptPath } from './BeforeLaunchScriptPath'
56+
export { default as AfterLaunchScriptPath } from './AfterLaunchScriptPath'

src/frontend/screens/Settings/sections/GamesSettings/index.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import {
3030
WrappersTable,
3131
EnableDXVKFpsLimit,
3232
IgnoreGameUpdates,
33-
Gamescope
33+
Gamescope,
34+
BeforeLaunchScriptPath,
35+
AfterLaunchScriptPath
3436
} from '../../components'
3537
import ContextProvider from 'frontend/state/ContextProvider'
3638
import Tools from '../../components/Tools'
@@ -234,6 +236,11 @@ export default function GamesSettings() {
234236
)}
235237
<AlternativeExe />
236238
<LauncherArgs />
239+
<div className="Field">
240+
<label>Scripts:</label>
241+
<BeforeLaunchScriptPath />
242+
<AfterLaunchScriptPath />
243+
</div>
237244
<WrappersTable />
238245
<EnvVariablesTable />
239246
{!isSideloaded && <PreferedLanguage />}

0 commit comments

Comments
 (0)