Skip to content

Commit a884a64

Browse files
committed
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
1 parent 4b07b52 commit a884a64

File tree

4 files changed

+79
-155
lines changed

4 files changed

+79
-155
lines changed

src/backend/protocol.ts

Lines changed: 75 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,195 +1,119 @@
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+
39+
if (url.pathname) {
40+
// Old-style pathname URLs:
41+
// - `heroic://launch/Quail`
42+
// - `heroic://launch/legendary/Quail`
43+
const splitPath = url.pathname.split('/').filter(Boolean)
44+
appName = splitPath.pop()
45+
runnerStr = splitPath.pop()
8346
} else {
84-
const [command, arg] = fullCommand.split('/')
85-
return [command as Command, undefined, arg]
47+
// New-style params URL:
48+
// `heroic://launch?appName=Quail&runner=legendary&arg=foo&arg=bar`
49+
appName = url.searchParams.get('appName')
50+
runnerStr = url.searchParams.get('runner')
8651
}
87-
}
8852

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

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) {
58+
let runner: Runner | undefined
59+
const runnerParse = RUNNERS.safeParse(runnerStr)
60+
if (runnerParse.success) {
61+
runner = runnerParse.data
62+
}
63+
const gameInfo = findGame(appName, runner)
64+
if (!gameInfo) {
11465
return logError(
115-
`Could not receive game data for ${arg}!`,
66+
`Could not receive game data for ${appName}!`,
11667
LogPrefix.ProtocolHandler
11768
)
11869
}
11970

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)
71+
const { is_installed, title } = gameInfo
72+
const settings = await gameManagerMap[gameInfo.runner].getSettings(appName)
12573

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
74+
if (is_installed) {
75+
return launchEventCallback({
76+
appName: appName,
77+
runner: gameInfo.runner,
78+
skipVersionCheck: settings.ignoreGameUpdates
13979
})
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-
}
14680
}
14781

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

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-
}
104+
function findGame(
105+
appName?: string | null,
106+
runner?: Runner
107+
): GameInfo | undefined {
108+
if (!appName) return
176109

177110
// 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-
}
111+
if (runner) return gameManagerMap[runner].getGameInfo(appName)
184112

185113
// 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-
}
114+
for (const runner of RUNNERS.options) {
115+
const maybeGameInfo = gameManagerMap[runner].getGameInfo(appName)
116+
if (maybeGameInfo.app_name) return maybeGameInfo
193117
}
194-
return null
118+
return
195119
}

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/tray_icon/tray_icon.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const contextMenu = (
8686
const recentsMenu = recentGames.map((game) => {
8787
return {
8888
click: function () {
89-
handleProtocol([`heroic://launch/${game.appName}`])
89+
handleProtocol([`heroic://launch?appName=${game.appName}`])
9090
},
9191
label: game.title
9292
}

0 commit comments

Comments
 (0)