Skip to content

Commit 7ba121e

Browse files
authored
[Feat] Passing arguments via protocol URLs (#4186)
* Introduce param-style protocol URLs Using URL parameters (`?foo=bar`) to specify options in protocol URLs makes them easier to extend/change later, and allows us to add features like launch parameters (which may be specified multiple times) "Old-style" URLs of course still work * Add support for passing parameters via protocol The new `arg` parameter can be used (multiple times if necessary) to specify extra parameters to pass to runners/the game
1 parent 74d64b6 commit 7ba121e

File tree

14 files changed

+110
-168
lines changed

14 files changed

+110
-168
lines changed

src/backend/launcher.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ const launchEventCallback: (args: LaunchParams) => StatusPromise = async ({
101101
appName,
102102
launchArguments,
103103
runner,
104-
skipVersionCheck
104+
skipVersionCheck,
105+
args
105106
}) => {
106107
const game = gameManagerMap[runner].getGameInfo(appName)
107108
const gameSettings = await gameManagerMap[runner].getSettings(appName)
@@ -203,6 +204,7 @@ const launchEventCallback: (args: LaunchParams) => StatusPromise = async ({
203204
const command = gameManagerMap[runner].launch(
204205
appName,
205206
launchArguments,
207+
args,
206208
skipVersionCheck
207209
)
208210

src/backend/protocol.ts

Lines changed: 78 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,195 +1,122 @@
11
import { dialog } from 'electron'
22
import { logError, logInfo, LogPrefix } from './logger/logger'
33
import i18next from 'i18next'
4-
import { getInfo } from './utils'
54
import { GameInfo, Runner } from 'common/types'
65
import { getMainWindow, sendFrontendMessage } from './main_window'
76
import { icon } from './constants'
87
import { gameManagerMap } from './storeManagers'
98
import { launchEventCallback } from './launcher'
9+
import { z } from 'zod'
1010

11-
type Command = 'ping' | 'launch'
11+
const RUNNERS = z.enum(['legendary', 'gog', 'nile', 'sideload'])
1212

13-
const RUNNERS = ['legendary', 'gog', 'nile', 'sideload']
13+
export function handleProtocol(args: string[]) {
14+
const urlStr = args.find((arg) => arg.startsWith('heroic://'))
15+
if (!urlStr) return
1416

15-
/**
16-
* Handles a protocol request
17-
* @param args The args to search
18-
* @example
19-
* handleProtocol(['heroic://ping'])
20-
* // => 'Received ping! Arg: undefined'
21-
* handleProtocol(['heroic://launch/gog/123'])
22-
* // => 'Received launch! Runner: gog, Arg: 123'
23-
**/
24-
export async function handleProtocol(args: string[]) {
25-
const mainWindow = getMainWindow()
26-
27-
const url = getUrl(args)
28-
if (!url) {
29-
return
30-
}
31-
32-
const [command, runner, arg = ''] = parseUrl(url)
17+
const url = new URL(urlStr)
3318

34-
logInfo(`received '${url}'`, LogPrefix.ProtocolHandler)
19+
logInfo(['Received', url.href], LogPrefix.ProtocolHandler)
3520

36-
switch (command) {
21+
switch (url.hostname) {
3722
case 'ping':
38-
return handlePing(arg)
23+
return handlePing(url)
3924
case 'launch':
40-
await handleLaunch(runner, arg, mainWindow)
41-
break
25+
return handleLaunch(url)
4226
default:
4327
return
4428
}
4529
}
4630

47-
/**
48-
* Gets the url from the args
49-
* @param args The args to search
50-
* @returns The url if found, undefined otherwise
51-
* @example
52-
* getUrl(['heroic://ping'])
53-
* // => 'heroic://ping'
54-
* getUrl(['heroic://launch/gog/123'])
55-
* // => 'heroic://launch/gog/123'
56-
* getUrl(['heroic://launch/legendary/123'])
57-
* // => 'heroic://launch/legendary/123'
58-
**/
59-
function getUrl(args: string[]): string | undefined {
60-
return args.find((arg) => arg.startsWith('heroic://'))
31+
function handlePing(url: URL) {
32+
logInfo(['Received ping! Args:', url.searchParams], LogPrefix.ProtocolHandler)
6133
}
6234

63-
/**
64-
* Parses a url into a tuple of [Command, Runner?, string?]
65-
* @param url The url to parse
66-
* @returns A tuple of [Command, Runner?, string?]
67-
* @example
68-
* parseUrl('heroic://ping')
69-
* // => ['ping', undefined, undefined]
70-
* parseUrl('heroic://launch/gog/123')
71-
* // => ['launch', 'gog', '123']
72-
* parseUrl('heroic://launch/123')
73-
* // => ['launch', '123']
74-
**/
75-
function parseUrl(url: string): [Command, Runner?, string?] {
76-
const [, fullCommand] = url.split('://')
77-
78-
//check if the second param is a runner or not and adjust parts accordingly
79-
const hasRunner = RUNNERS.includes(fullCommand.split('/')[1] as Runner)
80-
if (hasRunner) {
81-
const [command, runner, arg] = fullCommand.split('/')
82-
return [command as Command, runner as Runner, arg]
35+
async function handleLaunch(url: URL) {
36+
let appName
37+
let runnerStr
38+
let args: string[] = []
39+
40+
if (url.pathname) {
41+
// Old-style pathname URLs:
42+
// - `heroic://launch/Quail`
43+
// - `heroic://launch/legendary/Quail`
44+
const splitPath = url.pathname.split('/').filter(Boolean)
45+
appName = splitPath.pop()
46+
runnerStr = splitPath.pop()
8347
} else {
84-
const [command, arg] = fullCommand.split('/')
85-
return [command as Command, undefined, arg]
48+
// New-style params URL:
49+
// `heroic://launch?appName=Quail&runner=legendary&arg=foo&arg=bar`
50+
appName = url.searchParams.get('appName')
51+
runnerStr = url.searchParams.get('runner')
52+
args = url.searchParams.getAll('arg')
8653
}
87-
}
8854

89-
async function handlePing(arg: string) {
90-
return logInfo(['Received ping! Arg:', arg], LogPrefix.ProtocolHandler)
91-
}
55+
if (!appName) {
56+
logError('No appName in protocol URL', LogPrefix.ProtocolHandler)
57+
return
58+
}
9259

93-
/**
94-
* Handles a launch command
95-
* @param runner The runner to launch the game with
96-
* @param arg The game to launch
97-
* @param mainWindow The main window
98-
* @example
99-
* handleLaunch('gog', '123')
100-
* // => 'Received launch! Runner: gog, Arg: 123'
101-
* handleLaunch('legendary', '123')
102-
* // => 'Received launch! Runner: legendary, Arg: 123'
103-
* handleLaunch('nile', '123')
104-
* // => 'Received launch! Runner: nile, Arg: 123'
105-
**/
106-
async function handleLaunch(
107-
runner: Runner | undefined,
108-
arg: string | undefined,
109-
mainWindow?: Electron.BrowserWindow | null
110-
) {
111-
const game = await findGame(runner, arg)
112-
113-
if (!game) {
60+
let runner: Runner | undefined
61+
const runnerParse = RUNNERS.safeParse(runnerStr)
62+
if (runnerParse.success) {
63+
runner = runnerParse.data
64+
}
65+
const gameInfo = findGame(appName, runner)
66+
if (!gameInfo) {
11467
return logError(
115-
`Could not receive game data for ${arg}!`,
68+
`Could not receive game data for ${appName}!`,
11669
LogPrefix.ProtocolHandler
11770
)
11871
}
11972

120-
const { is_installed, title, app_name, runner: gameRunner } = game
121-
const settings = await gameManagerMap[gameRunner].getSettings(app_name)
122-
123-
if (!is_installed) {
124-
logInfo(`"${title}" not installed.`, LogPrefix.ProtocolHandler)
73+
const { is_installed, title } = gameInfo
74+
const settings = await gameManagerMap[gameInfo.runner].getSettings(appName)
12575

126-
if (!mainWindow) {
127-
return
128-
}
129-
130-
const { response } = await dialog.showMessageBox(mainWindow, {
131-
buttons: [i18next.t('box.yes'), i18next.t('box.no')],
132-
cancelId: 1,
133-
message: `${title} ${i18next.t(
134-
'box.protocol.install.not_installed',
135-
'Is Not Installed, do you wish to Install it?'
136-
)}`,
137-
title: title,
138-
icon: icon
76+
if (is_installed) {
77+
return launchEventCallback({
78+
appName: appName,
79+
runner: gameInfo.runner,
80+
skipVersionCheck: settings.ignoreGameUpdates,
81+
args
13982
})
140-
if (response === 0) {
141-
return sendFrontendMessage('installGame', app_name, gameRunner)
142-
}
143-
if (response === 1) {
144-
return logInfo('Not installing game', LogPrefix.ProtocolHandler)
145-
}
14683
}
14784

148-
mainWindow?.hide()
149-
launchEventCallback({
150-
appName: app_name,
151-
runner: gameRunner,
152-
skipVersionCheck: settings.ignoreGameUpdates
85+
logInfo(`"${title}" not installed.`, LogPrefix.ProtocolHandler)
86+
87+
const mainWindow = getMainWindow()
88+
if (!mainWindow) return
89+
90+
const { response } = await dialog.showMessageBox(mainWindow, {
91+
buttons: [i18next.t('box.yes'), i18next.t('box.no')],
92+
cancelId: 1,
93+
message: `${title} ${i18next.t(
94+
'box.protocol.install.not_installed',
95+
'Is Not Installed, do you wish to Install it?'
96+
)}`,
97+
title: title,
98+
icon: icon
15399
})
100+
if (response === 0) {
101+
sendFrontendMessage('installGame', appName, gameInfo.runner)
102+
} else if (response === 1) {
103+
logInfo('Not installing game', LogPrefix.ProtocolHandler)
104+
}
154105
}
155106

156-
/**
157-
* Finds a game in the runners specified in runnersToSearch
158-
* @param runner The runner to search for the game
159-
* @param arg The game to search
160-
* @returns The game info if found, null otherwise
161-
* @example
162-
* findGame('gog', '123')
163-
* // => { app_name: '123', title: '123', is_installed: true, runner: 'gog' ...}
164-
* findGame('legendary', '123')
165-
* // => { app_name: '123', title: '123', is_installed: true, runner: 'legendary' ...}
166-
* findGame('nile', '123')
167-
* // => { app_name: '123', title: '123', is_installed: true, runner: 'nile' ...}
168-
**/
169-
async function findGame(
170-
runner: Runner | undefined,
171-
arg = ''
172-
): Promise<GameInfo | null> {
173-
if (!arg) {
174-
return null
175-
}
107+
function findGame(
108+
appName?: string | null,
109+
runner?: Runner
110+
): GameInfo | undefined {
111+
if (!appName) return
176112

177113
// If a runner is specified, search for the game in that runner and return it (if found)
178-
if (runner) {
179-
const gameInfo = getInfo(arg, runner)
180-
if (gameInfo.app_name) {
181-
return gameInfo
182-
}
183-
}
114+
if (runner) return gameManagerMap[runner].getGameInfo(appName)
184115

185116
// If no runner is specified, search for the game in all runners and return the first one found
186-
for (const currentRunner of RUNNERS) {
187-
const run = (currentRunner as Runner) || 'legendary'
188-
189-
const gameInfoOrSideload = getInfo(arg, run)
190-
if (gameInfoOrSideload.app_name) {
191-
return gameInfoOrSideload
192-
}
117+
for (const runner of RUNNERS.options) {
118+
const maybeGameInfo = gameManagerMap[runner].getGameInfo(appName)
119+
if (maybeGameInfo.app_name) return maybeGameInfo
193120
}
194-
return null
121+
return
195122
}

src/backend/shortcuts/nonesteamgame/nonesteamgame.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ async function addNonSteamGame(props: {
304304

305305
const { runner, app_name } = props.gameInfo
306306

307-
args.push(`"heroic://launch/${runner}/${app_name}"`)
307+
args.push(`"heroic://launch?appName=${app_name}&runner=${runner}"`)
308308
newEntry.LaunchOptions = args.join(' ')
309309
if (isFlatpak) {
310310
newEntry.LaunchOptions = `run com.heroicgameslauncher.hgl ${newEntry.LaunchOptions}`

src/backend/shortcuts/shortcuts/shortcuts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ async function addShortcuts(gameInfo: GameInfo, fromMenu?: boolean) {
4141
addNonSteamGame({ gameInfo })
4242
}
4343

44-
const launchWithProtocol = `heroic://launch/${runner}/${app_name}`
44+
const launchWithProtocol = `heroic://launch?appName=${app_name}&runner=${runner}`
4545
const [desktopFile, menuFile] = shortcutFiles(gameInfo.title)
4646
if (!desktopFile || !menuFile) {
4747
return
@@ -222,7 +222,7 @@ async function generateMacOsApp(gameInfo: GameInfo) {
222222
// write the run.sh file
223223
const launchCommand = `${app.getPath(
224224
'exe'
225-
)} --no-gui heroic://launch/${runner}/${app_name}`
225+
)} --no-gui heroic://launch?appName=${app_name}&runner=${runner}`
226226
const shortcut = `#!/bin/bash
227227
# autogenerated file - do not edit
228228

src/backend/storeManagers/gog/games.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,8 @@ export async function removeShortcuts(appName: string) {
486486

487487
export async function launch(
488488
appName: string,
489-
launchArguments?: LaunchOption
489+
launchArguments?: LaunchOption,
490+
args: string[] = []
490491
): Promise<boolean> {
491492
const gameSettings = await getSettings(appName)
492493
const gameInfo = getGameInfo(appName)
@@ -602,7 +603,8 @@ export async function launch(
602603
...shlex.split(
603604
(launchArguments as BaseLaunchOption | undefined)?.parameters ?? ''
604605
),
605-
...shlex.split(gameSettings.launcherArgs ?? '')
606+
...shlex.split(gameSettings.launcherArgs ?? ''),
607+
...args
606608
]
607609

608610
if (gameInfo.install.cyberpunk?.modsEnabled) {

src/backend/storeManagers/legendary/games.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ export async function syncSaves(
839839
export async function launch(
840840
appName: string,
841841
launchArguments?: LaunchOption,
842+
args: string[] = [],
842843
skipVersionCheck = false
843844
): Promise<boolean> {
844845
const gameSettings = await getSettings(appName)
@@ -931,6 +932,7 @@ export async function launch(
931932
subcommand: 'launch',
932933
appName: LegendaryAppName.parse(appNameToLaunch),
933934
extraArguments: [
935+
...args,
934936
launchArguments?.type !== 'dlc' ? launchArguments?.parameters : undefined,
935937
gameSettings.launcherArgs
936938
]

src/backend/storeManagers/nile/games.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,8 @@ export async function removeShortcuts(appName: string) {
311311

312312
export async function launch(
313313
appName: string,
314-
launchArguments?: LaunchOption
314+
launchArguments?: LaunchOption,
315+
args: string[] = []
315316
): Promise<boolean> {
316317
const gameSettings = await getSettings(appName)
317318
const gameInfo = getGameInfo(appName)
@@ -409,7 +410,8 @@ export async function launch(
409410
(launchArguments as BaseLaunchOption | undefined)?.parameters ?? ''
410411
),
411412
...shlex.split(gameSettings.launcherArgs ?? ''),
412-
appName
413+
appName,
414+
...args
413415
]
414416
const fullCommand = getRunnerCallWithoutCredentials(
415417
commandParts,

src/backend/storeManagers/sideload/games.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ export async function isGameAvailable(appName: string): Promise<boolean> {
6868

6969
export async function launch(
7070
appName: string,
71-
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
72-
launchArguments?: LaunchOption
71+
launchArguments?: LaunchOption,
72+
args: string[] = []
7373
): Promise<boolean> {
74-
return launchGame(appName, getGameInfo(appName), 'sideload')
74+
return launchGame(appName, getGameInfo(appName), 'sideload', args)
7575
}
7676

7777
export async function stop(appName: string): Promise<void> {

0 commit comments

Comments
 (0)