Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@
"upscaleWidth": "The width resolution used by gamescope. A 16:9 aspect ratio is assumed."
},
"general": "Sync with EGL if you have a working installation of the Epic Games Launcher elsewhere and want to import your games to avoid downloading them again.",
"hide-window-on-protocol-launch": "Keeps the Heroic window hidden when a game is launched from an external shortcut, like the 'Add to Steam' feature.",
"mangohud": "MangoHUD is an overlay that displays and monitors FPS, temperatures, CPU/GPU load and other system resources.",
"msync": "Msync aims to reduce wineserver overhead in CPU-intensive games. Enabling may improve performance on supported Linux kernels.",
"other": {
Expand Down Expand Up @@ -817,6 +818,7 @@
"warningFlatpak": "We could not find a compatible version of Gamescope. Install Gamescope's flatpak package with runtime {{runtimeVersion}} and restart Heroic."
},
"hdr": "Enable HDR",
"hide-window-on-protocol-launch": "Hide Heroic window when launching games from heroic:// links",
"hideChangelogsOnStartup": "Don't show changelogs on Startup",
"ignoreGameUpdates": "Ignore game updates",
"language": "Choose App Language",
Expand Down
121 changes: 119 additions & 2 deletions src/backend/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ jest.mock('../constants/paths', () => ({
userHome: '/mock/home'
}))

import { handleProtocol } from '../protocol'
// Mock GlobalConfig so we can toggle hideWindowOnProtocolLaunch per test
const mockHideWindowOnProtocolLaunch = jest.fn(() => false)
jest.mock('../config', () => ({
GlobalConfig: {
get: () => ({
getSettings: () => ({
hideWindowOnProtocolLaunch: mockHideWindowOnProtocolLaunch()
})
})
}
}))

import { handleProtocol, shouldHideWindowForProtocolArgs } from '../protocol'
import { app, dialog } from 'electron'
import { gameManagerMap } from '../storeManagers'
import { getMainWindow } from '../main_window'
Expand Down Expand Up @@ -93,7 +105,9 @@ jest.mock('process', () => ({

describe('protocol.ts --no-gui behavior', () => {
const mockMainWindow = {
show: jest.fn()
show: jest.fn(),
hide: jest.fn(),
isVisible: jest.fn(() => true)
}

const mockGameInfo = {
Expand All @@ -109,6 +123,8 @@ describe('protocol.ts --no-gui behavior', () => {

beforeEach(() => {
jest.clearAllMocks()
mockHideWindowOnProtocolLaunch.mockReturnValue(false)
mockMainWindow.isVisible.mockReturnValue(true)
;(getMainWindow as jest.Mock).mockReturnValue(mockMainWindow)
;(gameManagerMap.legendary.getGameInfo as jest.Mock).mockReturnValue(
mockGameInfo
Expand Down Expand Up @@ -239,4 +255,105 @@ describe('protocol.ts --no-gui behavior', () => {
expect(app.quit).not.toHaveBeenCalled()
})
})

describe('hide window on protocol launch', () => {
beforeEach(() => {
mockIsCLINoGui.mockReturnValue(false)
mockGameInfo.is_installed = true
})

test('hides window when URL carries gui=false', async () => {
await handleProtocol([
'heroic://launch?appName=test-game&runner=legendary&gui=false'
])

expect(mockMainWindow.hide).toHaveBeenCalled()
})

test('hides window when hideWindowOnProtocolLaunch setting is enabled', async () => {
mockHideWindowOnProtocolLaunch.mockReturnValue(true)

await handleProtocol(['heroic://launch/test-game'])

expect(mockMainWindow.hide).toHaveBeenCalled()
})

test('does not hide window when neither setting nor URL param is set', async () => {
await handleProtocol(['heroic://launch/test-game'])

expect(mockMainWindow.hide).not.toHaveBeenCalled()
})

test('does not hide window that is not visible', async () => {
mockMainWindow.isVisible.mockReturnValue(false)
mockHideWindowOnProtocolLaunch.mockReturnValue(true)

await handleProtocol(['heroic://launch/test-game'])

expect(mockMainWindow.hide).not.toHaveBeenCalled()
})

test('shows window when not-installed game needs install dialog, regardless of hide setting', async () => {
mockGameInfo.is_installed = false
mockHideWindowOnProtocolLaunch.mockReturnValue(true)
;(dialog.showMessageBox as jest.Mock).mockResolvedValue({ response: 0 })

await handleProtocol(['heroic://launch/test-game'])

expect(mockMainWindow.show).toHaveBeenCalled()
expect(sendFrontendMessage).toHaveBeenCalledWith(
'installGame',
'test-game',
'legendary'
)
})
})

describe('shouldHideWindowForProtocolArgs', () => {
beforeEach(() => {
mockHideWindowOnProtocolLaunch.mockReturnValue(false)
})

test('returns true when URL has gui=false', () => {
expect(
shouldHideWindowForProtocolArgs([
'heroic://launch?appName=foo&gui=false'
])
).toBe(true)
})

test('accepts gui=0 and gui=no as equivalent to false', () => {
expect(
shouldHideWindowForProtocolArgs(['heroic://launch?appName=foo&gui=0'])
).toBe(true)
expect(
shouldHideWindowForProtocolArgs(['heroic://launch?appName=foo&gui=no'])
).toBe(true)
})

test('returns true when setting is enabled', () => {
mockHideWindowOnProtocolLaunch.mockReturnValue(true)
expect(
shouldHideWindowForProtocolArgs(['heroic://launch/test-game'])
).toBe(true)
})

test('returns false when setting is off and URL has no gui param', () => {
expect(
shouldHideWindowForProtocolArgs(['heroic://launch/test-game'])
).toBe(false)
})

test('returns false for non-launch protocol URLs even with setting on', () => {
mockHideWindowOnProtocolLaunch.mockReturnValue(true)
expect(shouldHideWindowForProtocolArgs(['heroic://ping'])).toBe(false)
})

test('returns false when no heroic URL is present', () => {
mockHideWindowOnProtocolLaunch.mockReturnValue(true)
expect(
shouldHideWindowForProtocolArgs(['/path/to/heroic', '--some-flag'])
).toBe(false)
})
})
})
1 change: 1 addition & 0 deletions src/backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ class GlobalConfigV0 extends GlobalConfig {
eacRuntime: isLinux,
battlEyeRuntime: isLinux,
framelessWindow: false,
hideWindowOnProtocolLaunch: false,
beforeLaunchScriptPath: '',
afterLaunchScriptPath: '',
disableUMU: false,
Expand Down
14 changes: 11 additions & 3 deletions src/backend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
import { Path } from './schemas'

import { uninstallGameCallback } from './utils/uninstaller'
import { handleProtocol } from './protocol'
import { handleProtocol, shouldHideWindowForProtocolArgs } from './protocol'
import {
init as initLogger,
logDebug,
Expand Down Expand Up @@ -326,7 +326,9 @@ if (!gotTheLock) {
app.on('second-instance', (event, argv) => {
// Someone tried to run a second instance, we should focus our window.
const mainWindow = getMainWindow()
mainWindow?.show()
if (!shouldHideWindowForProtocolArgs(argv)) {
mainWindow?.show()
}

handleProtocol(argv)
})
Expand Down Expand Up @@ -421,8 +423,14 @@ if (!gotTheLock) {
logWarning('Protocol already registered.', LogPrefix.Backend)
}

const hideForProtocol = shouldHideWindowForProtocolArgs([
openUrlArgument,
...process.argv
])
const headless =
isCLINoGui || (settings.startInTray && !settings.noTrayIcon)
isCLINoGui ||
hideForProtocol ||
(settings.startInTray && !settings.noTrayIcon)
if (!headless) {
const isWayland = Boolean(process.env.WAYLAND_DISPLAY)
const showWindow = () => {
Expand Down
50 changes: 46 additions & 4 deletions src/backend/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,42 @@ import { z } from 'zod'
import { windowIcon } from './constants/paths'
import { Path } from './schemas'
import { isCLINoGui } from './constants/environment'
import { GlobalConfig } from './config'

const RUNNERS = z.enum(['legendary', 'gog', 'nile', 'sideload'])

export function handleProtocol(args: string[]) {
function parseHeroicUrl(args: string[]): URL | undefined {
const urlStr = args.find((arg) => arg.startsWith('heroic://'))
if (!urlStr) return
try {
return new URL(urlStr)
} catch {
return
}
}

function urlRequestsNoGui(url: URL): boolean {
const guiParam = url.searchParams.get('gui')
return guiParam === 'false' || guiParam === '0' || guiParam === 'no'
}

// Returns true when a `heroic://launch/...` URL in `args` should suppress
// the main window: either the URL carries `gui=false` (or `0`/`no`), or the
// user enabled the `hideWindowOnProtocolLaunch` setting.
export function shouldHideWindowForProtocolArgs(args: string[]): boolean {
const url = parseHeroicUrl(args)
if (!url || url.hostname !== 'launch') return false
if (urlRequestsNoGui(url)) return true
try {
return GlobalConfig.get().getSettings().hideWindowOnProtocolLaunch === true
} catch {
return false
}
}

const url = new URL(urlStr)
export function handleProtocol(args: string[]) {
const url = parseHeroicUrl(args)
if (!url) return

logInfo(['Received', url.href], LogPrefix.ProtocolHandler)

Expand Down Expand Up @@ -83,6 +111,9 @@ async function handleLaunch(url: URL) {

const { is_installed, title } = gameInfo
const settings = await gameManagerMap[gameInfo.runner].getSettings(appName)
const hideForThisLaunch =
urlRequestsNoGui(url) ||
GlobalConfig.get().getSettings().hideWindowOnProtocolLaunch === true

if (is_installed) {
let launchOption: LaunchOption | undefined = undefined
Expand All @@ -92,6 +123,17 @@ async function handleLaunch(url: URL) {
executable: altExe
}

if (hideForThisLaunch) {
const mainWindow = getMainWindow()
if (mainWindow?.isVisible()) {
logInfo(
'Hiding main window for protocol launch',
LogPrefix.ProtocolHandler
)
mainWindow.hide()
}
}

return launchEventCallback({
appName: appName,
runner: gameInfo.runner,
Expand All @@ -117,9 +159,9 @@ async function handleLaunch(url: URL) {
icon: windowIcon
})
if (response === 0) {
if (isCLINoGui) {
if (isCLINoGui || hideForThisLaunch) {
logInfo(
'--no-gui flag detected but user wants to install, showing GUI',
'Window was hidden but user wants to install, showing GUI',
LogPrefix.ProtocolHandler
)
mainWindow.show()
Expand Down
1 change: 1 addition & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export interface AppSettings extends GameSettings {
experimentalFeatures?: ExperimentalFeatures
framelessWindow: boolean
hideChangelogsOnStartup: boolean
hideWindowOnProtocolLaunch: boolean
libraryTopSection: LibraryTopSectionOptions
maxRecentGames: number
maxWorkers: number
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next'
import { ToggleSwitch } from 'frontend/components/UI'
import useSetting from 'frontend/hooks/useSetting'
import InfoIcon from 'frontend/components/UI/InfoIcon'

const HideWindowOnProtocolLaunch = () => {
const { t } = useTranslation()
const [hideWindowOnProtocolLaunch, setHideWindowOnProtocolLaunch] =
useSetting('hideWindowOnProtocolLaunch', false)

return (
<div className="toggleRow">
<ToggleSwitch
htmlId="hideWindowOnProtocolLaunch"
value={hideWindowOnProtocolLaunch}
handleChange={() =>
setHideWindowOnProtocolLaunch(!hideWindowOnProtocolLaunch)
}
title={t(
'setting.hide-window-on-protocol-launch',
'Hide Heroic window when launching games from heroic:// links'
)}
/>
<InfoIcon
text={t(
'help.hide-window-on-protocol-launch',
"Keeps the Heroic window hidden when a game is launched from an external shortcut, like the 'Add to Steam' feature."
)}
/>
</div>
)
}

export default HideWindowOnProtocolLaunch
1 change: 1 addition & 0 deletions src/frontend/screens/Settings/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export { default as ExperimentalFeatures } from './ExperimentalFeatures'
export { default as GameMode } from './GameMode'
export { default as IgnoreGameUpdates } from './IgnoreGameUpdates'
export { default as HideChangelogOnStartup } from './HideChangelogOnStartup'
export { default as HideWindowOnProtocolLaunch } from './HideWindowOnProtocolLaunch'
export { default as LauncherArgs } from './LauncherArgs'
export { default as LaunchOptionSelector } from './LaunchOptionSelector'
export { default as LibraryTopSection } from './LibraryTopSection'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
DisableLogs,
DownloadNoHTTPS,
ExperimentalFeatures,
HideWindowOnProtocolLaunch,
ResetHeroic,
SteamGridDbApiKey
} from '../../components'
Expand Down Expand Up @@ -185,6 +186,8 @@ export default function AdvancedSetting() {

<AllowInstallationBrokenAnticheat />

<HideWindowOnProtocolLaunch />

{isLinux && <ShowValveProton />}

<hr />
Expand Down
Loading