diff --git a/package.json b/package.json index 1a654e51c4..ace46a8b72 100644 --- a/package.json +++ b/package.json @@ -71,9 +71,9 @@ "@fortawesome/react-fontawesome": "^0.2.2", "@hyperplay/chains": "^0.6.1", "@hyperplay/check-disk-space": "^3.5.2", - "@hyperplay/quests-ui": "^0.4.0", + "@hyperplay/quests-ui": "^0.4.4", "@hyperplay/ui": "^1.32.1", - "@hyperplay/utils": "^0.3.14", + "@hyperplay/utils": "^0.3.19", "@mantine/carousel": "^7.12.0", "@mantine/core": "^7.12.0", "@mantine/dropzone": "^7.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34ed4e138d..e300b3019f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,14 +47,14 @@ importers: specifier: ^3.5.2 version: 3.5.2 '@hyperplay/quests-ui': - specifier: ^0.4.0 - version: 0.4.0(d62ffe8307db8df9e4122430dcbcc97e) + specifier: ^0.4.4 + version: 0.4.4(b9e2cecb4bf34c1a8385d6086266bf1f) '@hyperplay/ui': specifier: ^1.32.1 version: 1.32.1(@mantine/carousel@7.15.1(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(embla-carousel-react@8.5.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/dropzone@7.15.1(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(dayjs@1.11.13)(embla-carousel-autoplay@8.5.1(embla-carousel@8.5.1))(embla-carousel-react@8.5.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-markdown@10.1.0(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) '@hyperplay/utils': - specifier: ^0.3.14 - version: 0.3.14 + specifier: ^0.3.19 + version: 0.3.19 '@mantine/carousel': specifier: ^7.12.0 version: 7.15.1(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(embla-carousel-react@8.5.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1980,10 +1980,10 @@ packages: '@hyperplay/proxy-server@0.0.17': resolution: {integrity: sha512-CNiwfyyKJ5SqaVgMzNltI9fmOYoPsRz8+GZ1yN8Sl+GWwWFVqE1INpmZa/SVCS/iRipNfg4LrsXmZ06U+wTXoA==} - '@hyperplay/quests-ui@0.4.0': - resolution: {integrity: sha512-AVmcAWTh8LfNRO1i30Iqcz81cVWe0kjnqQXXlU145ktwm/6NKoOnd4BX0t4AE3ISz3wTeL3E2JMTwHZpysVu7A==} + '@hyperplay/quests-ui@0.4.4': + resolution: {integrity: sha512-xc2Wt9M7o3L0rijhVdd+Q/cKRtEFlv+1oo70/g458oL+VtnvKLN4UsORsPyRldWJMZaJu/bluNaXDsLajIDzHg==} peerDependencies: - '@hyperplay/chains': ^0.5.0 + '@hyperplay/chains': ^0.6.5 '@hyperplay/ui': ^1.30.0 '@hyperplay/utils': ^0.3.17 '@mantine/core': ^8.1.0 @@ -1991,15 +1991,15 @@ packages: '@tabler/icons-react': ^2.46.0 '@tanstack/query-core': ^5.59.20 '@tanstack/react-query': ^5.59.20 - '@wagmi/core': 2.15.2 + '@wagmi/core': ^2.17.3 dayjs: ^1.11.13 i18next: ^23.12.3 mobx: ^6.13.3 react: ^19.1.0 react-i18next: ^15.0.1 - viem: ^2.19.4 + viem: ^2.31.7 vite-plugin-svgr: ^2.4.0 - wagmi: ^2.14.12 + wagmi: ^2.15.6 '@hyperplay/ui@1.32.1': resolution: {integrity: sha512-JepMbHNKGe6vzbHBxmOxI0qzHIO9aVadgoadi8w7BH4VCiE441iDvfzbo0H2ZsqZKv4VWrBR4Mvec2ZlQu4DCQ==} @@ -2018,8 +2018,8 @@ packages: '@hyperplay/utils@0.0.9': resolution: {integrity: sha512-gMAa6gdFXfLrJUjPAD2is06qNo4MHzpb6Cbzt4xfNd7wI9chOblQ+piwTI1oFWs44GgfacsMq6i5esNly3hELg==} - '@hyperplay/utils@0.3.14': - resolution: {integrity: sha512-sqcS850HDmL144y02zz7UgEEmj8ykxegRPsLi+0nMquKsLqznjUXabtKfHMqzo+1+viNPXMlWDWsQJiLy1HRaQ==} + '@hyperplay/utils@0.3.19': + resolution: {integrity: sha512-7xqoyWG5uviZb3YDErMXA8rArChBgBjCMgnpDkn8etTLnwybHNakwLEGSI6XUpfeAiG4R70ZogjD4cK8IuvcvA==} '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} @@ -3494,6 +3494,9 @@ packages: '@valist/sdk@2.10.14': resolution: {integrity: sha512-x/tvlhIRuwMq0oAFpzqiVCEYjeujHrmy5OM7z7dK18umXHG+eedV0hm/beDblbzs4NQOoukeX5HwMYc/D7E7pA==} + '@valist/sdk@2.11.4': + resolution: {integrity: sha512-2nFNU6h4WUiRG50RQuds5DTSu9OmlzqOrnjimeSuynTASGBWjcJs6f3mL2XjPd6X/BGfE/pCnncyRf3JP3rmcg==} + '@vitejs/plugin-react@4.5.2': resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==} engines: {node: ^14.18.0 || >=16.0.0} @@ -11597,7 +11600,7 @@ snapshots: '@hyperplay/extension-provider@0.1.4(electron@33.2.0)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))': dependencies: - '@hyperplay/utils': 0.3.14 + '@hyperplay/utils': 0.3.19 electron: 33.2.0 ethers: 6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10) fs-extra: 11.2.0 @@ -11621,7 +11624,7 @@ snapshots: '@hyperplay/overlay@1.0.3(@hyperplay/providers@0.1.7(@types/react@19.1.8)(bufferutil@4.0.8)(encoding@0.1.13)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ioredis@5.4.1)(react@19.1.0)(typescript@5.3.3)(utf-8-validate@5.0.10)(zod@3.24.1))(electron@33.2.0)': dependencies: '@hyperplay/providers': 0.1.7(@types/react@19.1.8)(bufferutil@4.0.8)(encoding@0.1.13)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ioredis@5.4.1)(react@19.1.0)(typescript@5.3.3)(utf-8-validate@5.0.10)(zod@3.24.1) - '@hyperplay/utils': 0.3.14 + '@hyperplay/utils': 0.3.19 electron: 33.2.0 mobx: 6.13.5 optional: true @@ -11686,17 +11689,17 @@ snapshots: - utf-8-validate optional: true - '@hyperplay/quests-ui@0.4.0(d62ffe8307db8df9e4122430dcbcc97e)': + '@hyperplay/quests-ui@0.4.4(b9e2cecb4bf34c1a8385d6086266bf1f)': dependencies: '@hyperplay/chains': 0.6.1(bufferutil@4.0.8)(typescript@5.3.3)(utf-8-validate@5.0.10)(zod@3.24.1) '@hyperplay/ui': 1.32.1(@mantine/carousel@7.15.1(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(embla-carousel-react@8.5.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/dropzone@7.15.1(@mantine/core@7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.15.1(react@19.1.0))(dayjs@1.11.13)(embla-carousel-autoplay@8.5.1(embla-carousel@8.5.1))(embla-carousel-react@8.5.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-markdown@10.1.0(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) - '@hyperplay/utils': 0.3.14 + '@hyperplay/utils': 0.3.19 '@mantine/core': 7.15.1(@mantine/hooks@7.15.1(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mantine/hooks': 7.15.1(react@19.1.0) '@tabler/icons-react': 2.47.0(react@19.1.0) '@tanstack/query-core': 5.80.7 '@tanstack/react-query': 5.80.7(react@19.1.0) - '@valist/sdk': 2.10.14(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) + '@valist/sdk': 2.11.4(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) '@wagmi/core': 2.15.2(@tanstack/query-core@5.80.7)(@types/react@19.1.8)(react@19.1.0)(typescript@5.3.3)(use-sync-external-store@1.4.0(react@19.1.0))(viem@2.30.6(bufferutil@4.0.8)(typescript@5.3.3)(utf-8-validate@5.0.10)(zod@3.24.1)) classnames: 2.5.1 dayjs: 1.11.13 @@ -11731,7 +11734,7 @@ snapshots: bignumber.js: 9.1.2 optional: true - '@hyperplay/utils@0.3.14': + '@hyperplay/utils@0.3.19': dependencies: bignumber.js: 9.1.2 @@ -13965,6 +13968,19 @@ snapshots: - encoding - utf-8-validate + '@valist/sdk@2.11.4(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10)': + dependencies: + '@ethersproject/contracts': 5.8.0 + axios: 1.9.0 + axios-retry: 4.5.0(axios@1.9.0) + ethers: 6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10) + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - utf-8-validate + '@vitejs/plugin-react@4.5.2(vite@5.4.11(@types/node@20.17.10)(sass@1.83.0)(terser@5.41.0))': dependencies: '@babel/core': 7.27.4 diff --git a/public/locales/en/gamepage.json b/public/locales/en/gamepage.json index d910fb407d..48cab14f3c 100644 --- a/public/locales/en/gamepage.json +++ b/public/locales/en/gamepage.json @@ -139,6 +139,7 @@ "installedPlatform": "Installed Platform", "path": "Install Path", "size": "Size", + "steam-game": "This is a Steam game, for more information check the game page on the Steam App.", "syncsaves": "Sync Saves", "version": "Version", "web3-supported": "Has Web3 features" diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 57af3825c1..0debac0540 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -950,6 +950,7 @@ "download-no-https": "Download games without HTTPS (useful for CDNs e.g. LanCache)", "dxvkfpslimit": "Limit FPS (DX9, 10 and 11)", "egs-sync": "Sync with Installed Epic Games", + "enable-steam-integration": "Enable Steam Integration (experimental)", "enableFSRHack": "Enable FSR Hack (Wine version needs to support it)", "eosOverlay": { "cancelInstall": "Cancel", @@ -1128,6 +1129,27 @@ "logging": "Logging In...", "processing": "Processing files, please wait" }, + "steam": { + "install": { + "later": "Install Later", + "launch": "Launch Steam", + "message": "Please install this game from Steam and refresh the library afterwards to play on HyperPlay.", + "title": "Install from Steam" + }, + "integration": { + "requirements": { + "part1": "To ensure this integration works properly and HyperPlay can access your games, you must have", + "part2": "Steam installed", + "part3": "and be", + "part4": "Logged into", + "part5": "your account.", + "part6": "Additionally, your profile and game details should be set to ", + "part7": "Public", + "part8": "Steam > Profile > Edit Profile > Privacy Settings", + "title": "Steam Integration Requirements" + } + } + }, "Steam Installation": "", "steam-install": { "compatibility-layer-not-available": "Compatibility layer not available. Please install one from \"Settings > Wine Manager\" first.", diff --git a/src/backend/cache.ts b/src/backend/cache.ts index 6733fc1518..d642319fac 100644 --- a/src/backend/cache.ts +++ b/src/backend/cache.ts @@ -3,7 +3,7 @@ import Store from 'electron-store' export default class CacheStore { private readonly store: Store private in_memory_store: Map - private using_in_memory: boolean + private using_in_memory: number private current_store: Store | Map private readonly lifespan: number | null @@ -20,7 +20,7 @@ export default class CacheStore { clearInvalidConfig: true }) this.in_memory_store = new Map() - this.using_in_memory = false + this.using_in_memory = 0 this.current_store = this.store this.lifespan = max_value_lifespan } @@ -31,7 +31,7 @@ export default class CacheStore { */ public use_in_memory() { // Mirror store to memory map - this.using_in_memory = true + this.using_in_memory += 1 this.in_memory_store = new Map(this.store) as Map this.current_store = this.in_memory_store } @@ -85,8 +85,10 @@ export default class CacheStore { public commit() { if (this.using_in_memory) { this.store.store = Object.fromEntries(this.in_memory_store) - this.using_in_memory = false - this.current_store = this.store + this.using_in_memory -= 1 + if (this.using_in_memory === 0) { + this.current_store = this.store + } } } } diff --git a/src/backend/config.ts b/src/backend/config.ts index 38d56c08e1..53eaa62657 100644 --- a/src/backend/config.ts +++ b/src/backend/config.ts @@ -323,7 +323,8 @@ class GlobalConfigV0 extends GlobalConfig { ldUser: { kind: 'user', key: uuid() - } + }, + enableSteamIntegration: false } as AppSettings } diff --git a/src/backend/constants.ts b/src/backend/constants.ts index 202c1a5db6..8abf389d0f 100644 --- a/src/backend/constants.ts +++ b/src/backend/constants.ts @@ -80,8 +80,13 @@ const cachedUbisoftInstallerPath = join( const ipdtPatcher = join(toolsPath, 'ipdt') const ipdtManifestsPath = join(appConfigFolder, 'manifests') -const { currentLogFile, lastLogFile, legendaryLogFile, gogdlLogFile } = - createNewLogFileAndClearOldOnes() +const { + currentLogFile, + lastLogFile, + legendaryLogFile, + gogdlLogFile, + steamLogFile +} = createNewLogFileAndClearOldOnes() const gogdlAuthConfig = join(app.getPath('userData'), 'gog_store', 'auth.json') const iconDark = fixAsarPath(join(publicDir, 'trayIconDark24x24.png')) @@ -251,6 +256,7 @@ export { lastLogFile, legendaryLogFile, gogdlLogFile, + steamLogFile, discordLink, twitterLink, execOptions, diff --git a/src/backend/launcher.ts b/src/backend/launcher.ts index 069778cd77..b3c6a72eba 100644 --- a/src/backend/launcher.ts +++ b/src/backend/launcher.ts @@ -810,7 +810,7 @@ async function callRunner( shouldUsePowerShell = isWindows && powershellExists } - if (shouldUsePowerShell && runner.name !== 'gog') { + if (shouldUsePowerShell && !['gog', 'steam'].includes(runner.name)) { const argsAsString = commandParts .map((part) => part.replaceAll('\\', '\\\\')) .map((part) => `"\`"${part}\`""`) diff --git a/src/backend/logger/__tests__/logfile.test.ts b/src/backend/logger/__tests__/logfile.test.ts index 392fd34c02..142bfa8c88 100644 --- a/src/backend/logger/__tests__/logfile.test.ts +++ b/src/backend/logger/__tests__/logfile.test.ts @@ -70,7 +70,8 @@ describe('logger/logfile.ts', () => { currentLogFile: 'old/log/path/file.log', lastLogFile: '', legendaryLogFile: '', - gogdlLogFile: '' + gogdlLogFile: '', + steamLogFile: '' }) jest.spyOn(app, 'requestSingleInstanceLock').mockImplementation(() => true) @@ -81,7 +82,8 @@ describe('logger/logfile.ts', () => { currentLogFile: expect.any(String), lastLogFile: expect.any(String), legendaryLogFile: expect.any(String), - gogdlLogFile: expect.any(String) + gogdlLogFile: expect.any(String), + steamLogFile: expect.any(String) }) }) diff --git a/src/backend/logger/logfile.ts b/src/backend/logger/logfile.ts index 724845b6c3..7d3d46b37d 100644 --- a/src/backend/logger/logfile.ts +++ b/src/backend/logger/logfile.ts @@ -16,6 +16,7 @@ interface createLogFileReturn { lastLogFile: string legendaryLogFile: string gogdlLogFile: string + steamLogFile: string } let longestPrefix = 0 @@ -47,10 +48,12 @@ export function createNewLogFileAndClearOldOnes(): createLogFileReturn { const newLogFile = join(logDir, `hyperplay-${fmtDate}.log`) const newLegendaryLogFile = join(logDir, `legendary-${fmtDate}.log`) const newGogdlLogFile = join(logDir, `gogdl-${fmtDate}.log`) + const newSteamLogFile = join(logDir, `steam-${fmtDate}.log`) createLogFile(newLogFile) createLogFile(newLegendaryLogFile) createLogFile(newGogdlLogFile) + createLogFile(newSteamLogFile) // Clean out logs that are more than a month old if (existsSync(logDir)) { @@ -88,7 +91,8 @@ export function createNewLogFileAndClearOldOnes(): createLogFileReturn { currentLogFile: '', lastLogFile: '', legendaryLogFile: '', - gogdlLogFile: '' + gogdlLogFile: '', + steamLogFile: '' }) if (!isNewInstance) { @@ -99,6 +103,7 @@ export function createNewLogFileAndClearOldOnes(): createLogFileReturn { logs.currentLogFile = newLogFile logs.legendaryLogFile = newLegendaryLogFile logs.gogdlLogFile = newGogdlLogFile + logs.steamLogFile = newSteamLogFile configStore.set('general-logs', logs) // get longest prefix to log lines in a kind of table @@ -121,7 +126,8 @@ export function getLogFile(appNameOrRunner: string): string { currentLogFile: '', lastLogFile: '', legendaryLogFile: '', - gogdlLogFile: '' + gogdlLogFile: '', + steamLogFile: '' }) switch (appNameOrRunner) { diff --git a/src/backend/logger/logger.ts b/src/backend/logger/logger.ts index a241e6d905..305d18aead 100644 --- a/src/backend/logger/logger.ts +++ b/src/backend/logger/logger.ts @@ -35,14 +35,16 @@ export enum LogPrefix { Sideload = 'Sideload', Achievements = 'Achievements', Auth = 'Auth', - AutoUpdater = 'AutoUpdater' + AutoUpdater = 'AutoUpdater', + Steam = 'Steam' } export const RunnerToLogPrefixMap = { legendary: LogPrefix.Legendary, gog: LogPrefix.Gog, hyperplay: LogPrefix.HyperPlay, - sideload: LogPrefix.Sideload + sideload: LogPrefix.Sideload, + steam: LogPrefix.Steam } type LogInputType = unknown[] | unknown diff --git a/src/backend/main.ts b/src/backend/main.ts index 224d30c752..16a52ae5bc 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -891,16 +891,18 @@ ipcMain.on('clearCache', (event, showDialog, fromVersionChange = false) => { clearCache(undefined, fromVersionChange) sendFrontendMessage('refreshLibrary') - showDialogBoxModalAuto({ - event, - title: i18next.t('box.cache-cleared.title', 'Cache Cleared'), - message: i18next.t( - 'box.cache-cleared.message', - 'HyperPlay Cache Was Cleared!' - ), - type: 'MESSAGE', - buttons: [{ text: i18next.t('box.ok', 'Ok') }] - }) + if (showDialog) { + showDialogBoxModalAuto({ + event, + title: i18next.t('box.cache-cleared.title', 'Cache Cleared'), + message: i18next.t( + 'box.cache-cleared.message', + 'HyperPlay Cache Was Cleared!' + ), + type: 'MESSAGE', + buttons: [{ text: i18next.t('box.ok', 'Ok') }] + }) + } }) ipcMain.on('resetApp', async () => { diff --git a/src/backend/save_sync.ts b/src/backend/save_sync.ts index 6984320a7a..4a015c0171 100644 --- a/src/backend/save_sync.ts +++ b/src/backend/save_sync.ts @@ -37,6 +37,7 @@ async function getDefaultSavePath( return getDefaultLegendarySavePath(appName) case 'gog': return getDefaultGogSavePaths(appName, alreadyDefinedGogSaves) + case 'steam': case 'sideload': return '' } diff --git a/src/backend/shortcuts/nonesteamgame/constants.ts b/src/backend/shortcuts/nonesteamgame/constants.ts index 9be36aa3e3..256dbab0b4 100644 --- a/src/backend/shortcuts/nonesteamgame/constants.ts +++ b/src/backend/shortcuts/nonesteamgame/constants.ts @@ -5,11 +5,13 @@ const pictureExt = '.jpg' const coverArtSufix = 'p' + pictureExt const backGroundArtSufix = '_hero' + pictureExt const logoArtSufix = '_logo' + pictureExt +const steamDBBaseURL = 'https://cdn.steamstatic.com/steam/apps' export { transparentSteamLogoHex, coverArtSufix, backGroundArtSufix, logoArtSufix, - pictureExt + pictureExt, + steamDBBaseURL } diff --git a/src/backend/shortcuts/nonesteamgame/nonesteamgame.ts b/src/backend/shortcuts/nonesteamgame/nonesteamgame.ts index 96108e5bfd..74dd1a5197 100644 --- a/src/backend/shortcuts/nonesteamgame/nonesteamgame.ts +++ b/src/backend/shortcuts/nonesteamgame/nonesteamgame.ts @@ -217,6 +217,10 @@ async function addNonSteamGame(props: { bkgDataUrl?: string bigPicDataUrl?: string }): Promise { + if (props.gameInfo?.install?.is_dlc || props.gameInfo.runner === 'steam') { + return false + } + const steamUserdataDir = props.steamUserdataDir || (await getSteamUserdataDir()) diff --git a/src/backend/shortcuts/shortcuts/shortcuts.ts b/src/backend/shortcuts/shortcuts/shortcuts.ts index c30cff9f3b..a5d7cea4bb 100644 --- a/src/backend/shortcuts/shortcuts/shortcuts.ts +++ b/src/backend/shortcuts/shortcuts/shortcuts.ts @@ -29,7 +29,7 @@ import * as GogLibraryManager from '../../storeManagers/gog/library' * @public */ async function addShortcuts(gameInfo: GameInfo, fromMenu?: boolean) { - if (gameInfo.install.is_dlc) { + if (gameInfo.install.is_dlc || gameInfo.runner === 'steam') { return } logInfo(`Adding shortcuts for ${gameInfo.title}`, LogPrefix.Backend) diff --git a/src/backend/storeManagers/index.ts b/src/backend/storeManagers/index.ts index 09165a87e6..54592e25ca 100644 --- a/src/backend/storeManagers/index.ts +++ b/src/backend/storeManagers/index.ts @@ -2,11 +2,13 @@ import * as HyperPlayGameManager from 'backend/storeManagers/hyperplay/games' import * as SideloadGameManager from 'backend/storeManagers/sideload/games' import * as GOGGameManager from 'backend/storeManagers/gog/games' import * as LegendaryGameManager from 'backend/storeManagers/legendary/games' +import * as SteamGameManager from 'backend/storeManagers/steam/games' import * as HyperPlayLibraryManager from 'backend/storeManagers/hyperplay/library' import * as SideloadLibraryManager from 'backend/storeManagers/sideload/library' import * as GOGLibraryManager from 'backend/storeManagers/gog/library' import * as LegendaryLibraryManager from 'backend/storeManagers/legendary/library' +import * as SteamLibraryManager from 'backend/storeManagers/steam/library' import { GameManager, LibraryManager } from 'common/types/game_manager' import { logInfo, RunnerToLogPrefixMap } from 'backend/logger/logger' @@ -27,14 +29,16 @@ export const gameManagerMap: Record = { hyperplay: HyperPlayGameManager, sideload: SideloadGameManager, gog: GOGGameManager, - legendary: LegendaryGameManager + legendary: LegendaryGameManager, + steam: SteamGameManager } export const libraryManagerMap: Record = { hyperplay: HyperPlayLibraryManager, legendary: LegendaryLibraryManager, gog: GOGLibraryManager, - sideload: SideloadLibraryManager + sideload: SideloadLibraryManager, + steam: SteamLibraryManager } function getDMElement(gameInfo: GameInfo, appName: string) { @@ -157,6 +161,7 @@ export async function sendGameUpdatesNotifications() { export async function initStoreManagers() { await LegendaryLibraryManager.initLegendaryLibraryManager() await GOGLibraryManager.refresh() + await SteamLibraryManager.refresh() loadEpicHyperPlayGameInfoMap() } diff --git a/src/backend/storeManagers/steam/electronStores.ts b/src/backend/storeManagers/steam/electronStores.ts new file mode 100644 index 0000000000..ff07f9d0c3 --- /dev/null +++ b/src/backend/storeManagers/steam/electronStores.ts @@ -0,0 +1,13 @@ +import CacheStore from 'backend/cache' +import { TypeCheckedStoreBackend } from 'backend/electron_store' +import { GameInfo } from 'common/types' + +export const steamEnabledUsers = new TypeCheckedStoreBackend( + 'steamEnabledUsersConfig', + { cwd: 'steam_store' } +) + +export const libraryCache = new CacheStore( + 'steam_library', + null +) diff --git a/src/backend/storeManagers/steam/games.ts b/src/backend/storeManagers/steam/games.ts new file mode 100644 index 0000000000..34420889a4 --- /dev/null +++ b/src/backend/storeManagers/steam/games.ts @@ -0,0 +1,248 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { dirname, join } from 'node:path' +import { GlobalConfig } from 'backend/config' +import { isWindows, steamLogFile, userHome } from 'backend/constants' + +import { getGameInfo as getSteamLibraryGameInfo } from './library' +import { logError, logInfo, LogPrefix, logWarning } from 'backend/logger/logger' +import { + GameInfo, + GameSettings, + ExtraInfo, + InstallPlatform, + ExecResult, + InstallArgs, + UpdateArgs +} from 'common/types' +import { existsSync } from 'graceful-fs' +import { GOGCloudSavesLocation } from 'common/types/gog' +import { InstallResult, RemoveArgs } from 'common/types/game_manager' +import { callRunner, setupEnvVars } from 'backend/launcher' +import { createAbortController } from 'backend/utils/aborthandler/aborthandler' + +export function getGameInfo(appName: string): GameInfo { + const info = getSteamLibraryGameInfo(appName) + if (!info) { + logError( + [ + 'Could not get game info for', + `${appName},`, + 'returning empty object. Something is probably gonna go wrong soon' + ], + LogPrefix.Gog + ) + return { + app_name: '', + runner: 'steam', + art_cover: '', + art_square: '', + install: {}, + is_installed: false, + title: '', + canRunOffline: false + } + } + return info +} + +export async function isGameAvailable(appName: string): Promise { + const info = getGameInfo(appName) + if (info && info.is_installed) { + if (info.install.install_path && existsSync(info.install.install_path!)) { + return true + } else { + return false + } + } + return false +} + +export async function getSettings(appName: string): Promise { + // not used + return {} as GameSettings +} + +export async function getExtraInfo(appName: string): Promise { + // not used + return {} as ExtraInfo +} + +export async function importGame( + appName: string, + path: string, + platform: InstallPlatform +): Promise { + // not used + return { stderr: '', stdout: '' } +} + +export function onInstallOrUpdateOutput( + appName: string, + action: 'installing' | 'updating', + data: string, + totalDownloadSize: number +): void { + // not used +} + +export async function install( + appName: string, + args: InstallArgs +): Promise { + // not used + return { status: 'error', error: 'Not implemented' } +} + +export function isNative(appName: string): boolean { + // Steam games are considered native if they run on the Steam client + return true +} + +export async function addShortcuts( + appName: string, + fromMenu?: boolean +): Promise { + // not used +} + +export async function removeShortcuts(appName: string): Promise { + // not used +} + +export async function launch( + appName: string, + launchArguments?: string +): Promise { + const gameInfo = getGameInfo(appName) + if (!gameInfo || !gameInfo.is_installed) { + logWarning(`Game ${appName} is not installed or does not exist`, { + prefix: LogPrefix.Steam + }) + return false + } + + const steamBinaryPath = getSteamBinaryPath() + if (!existsSync(steamBinaryPath)) { + logError('Steam binary not found', { prefix: LogPrefix.Steam }) + return false + } + + const bin = getSteamBinaryPath() + const dir = dirname(bin) + const commandParts = [bin, '-applaunch', gameInfo.app_name] + const gameSettings = await getSettings(appName) + const commandEnv = isWindows + ? process.env + : { ...process.env, ...setupEnvVars(gameSettings) } + const options = { + env: { + ...commandEnv + }, + logMessagePrefix: `Launching ${gameInfo.title}` + } + const abortController = createAbortController(appName) + + const { error, abort } = await callRunner( + [...commandParts], + { name: 'steam', logPrefix: LogPrefix.Steam, bin, dir }, + abortController, + { + ...options, + verboseLogFile: steamLogFile + }, + gameInfo + ) + + if (error) { + logError(`Failed to launch game ${appName}: ${error}`, { + prefix: LogPrefix.Steam + }) + return false + } + + if (abort) { + logWarning(`Game ${appName} launch aborted`, { prefix: LogPrefix.Steam }) + return false + } + logInfo(`Game ${appName} launched successfully`, { prefix: LogPrefix.Steam }) + return true +} + +export async function moveInstall( + appName: string, + newInstallPath: string +): Promise { + // not used + return { status: 'error', error: 'Not implemented' } +} + +export async function repair(appName: string): Promise { + // not used + return { stderr: '', stdout: '' } +} + +export async function syncSaves( + appName: string, + arg: string, + path: string, + gogSaves?: GOGCloudSavesLocation[] +): Promise { + // not used + return '' +} + +export async function uninstall(args: RemoveArgs): Promise { + // not used + return { stderr: '', stdout: '' } +} + +export async function update( + appName: string, + args?: UpdateArgs +): Promise { + // not used + return { status: 'error', error: 'Not implemented' } +} + +export async function forceUninstall(appName: string): Promise { + // not used +} + +export async function stop(appName: string): Promise { + // not used +} + +export async function pause(appName: string): Promise { + // not used +} + +function getSteamBinaryPath(): string { + const { defaultSteamPath } = GlobalConfig.get().getSettings() + const steamPath = defaultSteamPath.replaceAll("'", '') + + if (process.platform === 'win32') { + return join(steamPath, 'steam.exe') + } else if (process.platform === 'darwin') { + const steamApp = join('/Applications', 'Steam.app') + if (existsSync(steamApp)) { + return join(steamApp, 'Contents', 'MacOS', 'steam_osx') + } + } else { + // For Linux it could be on /usr/bin/steam or in the flatpak path from home/.var/app/com.valvesoftware.Steam/.steam/steam + const flatpakSteamPath = join( + userHome, + '.var', + 'app', + 'com.valvesoftware.Steam', + '.steam', + 'steam' + ) + + if (existsSync(flatpakSteamPath)) { + return flatpakSteamPath + } + return join('/usr', 'bin', 'steam') + } + return '' +} diff --git a/src/backend/storeManagers/steam/library.ts b/src/backend/storeManagers/steam/library.ts new file mode 100644 index 0000000000..7ed6b7f730 --- /dev/null +++ b/src/backend/storeManagers/steam/library.ts @@ -0,0 +1,231 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import axios from 'axios' +import path from 'node:path' +import { parse } from '@node-steam/vdf' +import { existsSync, readFileSync } from 'graceful-fs' +import { readdir } from 'node:fs/promises' +import { getSteamLibraries, isMac } from 'backend/constants' +import { logDebug, LogPrefix, logWarning } from 'backend/logger/logger' +import { isOnline } from 'backend/online_monitor' +import { steamDBBaseURL } from 'backend/shortcuts/nonesteamgame/constants' +import { + AppManifest, + OwnedGame, + SteamInstallInfo, + SteamLoginUser +} from 'common/types/steam' +import { libraryCache, steamEnabledUsers } from './electronStores' +import { loadUsers } from './user' +import { GameInfo, InstallPlatform } from 'common/types' +import { getGamesdbData } from '../gog/library' +import { apiInfoCache } from '../gog/electronStores' +import { GlobalConfig } from 'backend/config' + +const library = new Map() +const installed = new Map() + +export async function getOwnedGames( + userId: string +): Promise { + if (!GlobalConfig.get().getSettings().enableSteamIntegration) { + logDebug('Steam integration is disabled in settings', { + prefix: LogPrefix.Steam + }) + return [] + } + + const url = new URL( + 'https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?format=json&include_appinfo=true' + ) + + // Not sure if we want to give people ability to change key, as if it's useful + url.searchParams.set('key', '10D00637D7AE33A263D4C05EBF6CA573') + url.searchParams.set('steamid', userId) + + const response = await axios.get(url.toString()) + + return response.data?.response.games +} + +const ignoredAppIds = [ + '221410', // Steam for Linux + '228980', // Steamworks Common Redistributables + '1070560' // Steam Linux Runtime +] + +export async function getInstalledGames() { + if (!GlobalConfig.get().getSettings().enableSteamIntegration) { + logDebug('Steam integration is disabled in settings', { + prefix: LogPrefix.Steam + }) + return + } + const steamLibraries = await getSteamLibraries() + const steamAppsDirs = steamLibraries.map((lib) => path.join(lib, 'steamapps')) + + logDebug(['Steam libraries found:', steamAppsDirs.join(', ')], { + prefix: LogPrefix.Steam + }) + + installed.clear() + for (const steamApps of steamAppsDirs) { + if (!existsSync(steamApps)) { + continue + } + const files = await readdir(steamApps) + + for (const file of files) { + if (file.startsWith('appmanifest_')) { + const data = readFileSync(path.join(steamApps, file), { + encoding: 'utf-8' + }) + try { + const parsedManifest: AppManifest = parse(data).AppState + installed.set(parsedManifest.appid.toString(), { + ...parsedManifest, + install_dir: path.join( + steamApps, + 'common', + parsedManifest.installdir + ) + }) + } catch (e) { + logWarning(['Error parsing appmanifest of', steamApps, file, e], { + prefix: LogPrefix.Steam + }) + } + } + } + } +} + +export async function refresh(): Promise { + if (!GlobalConfig.get().getSettings().enableSteamIntegration) { + logDebug('Steam integration is disabled in settings', { + prefix: LogPrefix.Steam + }) + return null + } + const steamUsers = await loadUsers() + + // TODO: All users are enabled for now, a setting could be added in the future to control which should be enabled or not. + const enabledSteamUsers = steamUsers.reduce((acc, val) => { + if (steamEnabledUsers.get(val.id, true)) { + acc.push(val) + } + return acc + }, [] as Array) + + libraryCache.get('games', []).forEach((game) => { + library.set(game.app_name, game) + }) + + await getInstalledGames() + // Get all user owned games + + if (!isOnline()) { + logDebug('App offline, skipping steam sync', { prefix: LogPrefix.Steam }) + return null + } + + for (const user of enabledSteamUsers) { + logDebug(['Loading owned games for user', user.id], { + prefix: LogPrefix.Steam + }) + const ownedGames = await getOwnedGames(user.id) + if (!ownedGames?.length) { + continue + } + apiInfoCache.use_in_memory() + for (const steamGame of ownedGames) { + if (ignoredAppIds.includes(steamGame.appid.toString())) { + continue + } + const data = await getGamesdbData( + 'steam', + steamGame.appid.toString(), + false + ) + if (!data) { + continue + } + + let artSquare = `${steamDBBaseURL}/${steamGame.appid}/library_600x900.jpg` + let artCover = `${steamDBBaseURL}/${steamGame.appid}/header.jpg` + + try { + await axios.head(artSquare) + } catch (e) { + artSquare = 'fallback' + artCover = 'fallback' + } + + const newGameObject: GameInfo = { + app_name: steamGame.appid.toString(), + runner: 'steam', + art_square: artSquare, + art_cover: artCover, + canRunOffline: false, + title: steamGame.name, + is_installed: false, + install: { + is_dlc: false + } + } + + const installedGame = installed.get(steamGame.appid.toString()) + if (installedGame) { + newGameObject.is_installed = true + newGameObject.install.install_path = installedGame.install_dir + newGameObject.install.install_size = installedGame.SizeOnDisk + } + library.set(steamGame.appid.toString(), newGameObject) + } + apiInfoCache.commit() + logDebug(['Loaded', ownedGames.length, 'games for user', user.id], { + prefix: LogPrefix.Steam + }) + libraryCache.set('games', Array.from(library.values())) + logDebug(['Loaded', Array.from(library.values()).length], { + prefix: LogPrefix.Steam + }) + } + + return null +} + +export function getGameInfo(appName: string): GameInfo | undefined { + return library.get(appName) +} + +export async function getInstallInfo( + appName: string, + installPlatform: InstallPlatform, + lang?: string +): Promise { + // We can't fetch such info from steam + return undefined +} + +export async function listUpdateableGames(): Promise { + return [] +} + +export function installState(appName: string, state: boolean) { + logWarning(`installState not implemented on Steam Library Manager`) +} + +export async function changeGameInstallPath( + appName: string, + newAppPath: string +) { + logWarning(`changeGameInstallPath not implemented on Steam Library Manager`) +} + +export async function runRunnerCommand() { + logWarning(`runRunnerCommand not implemented on Steam Library Manager`) + return null +} + +export const getLaunchOptions = () => [] diff --git a/src/backend/storeManagers/steam/user.ts b/src/backend/storeManagers/steam/user.ts new file mode 100644 index 0000000000..5246083e85 --- /dev/null +++ b/src/backend/storeManagers/steam/user.ts @@ -0,0 +1,37 @@ +import { SteamLoginUser } from 'common/types/steam' +import { existsSync } from 'graceful-fs' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { parse } from '@node-steam/vdf' + +import { logError, LogPrefix } from 'backend/logger/logger' +import { GlobalConfig } from 'backend/config' + +// Supports multiple Steam accounts +export async function loadUsers(): Promise> { + const { defaultSteamPath } = GlobalConfig.get().getSettings() + const steamPath = defaultSteamPath.replaceAll("'", '') + + const loginUsersConfigPath = path.join(steamPath, 'config', 'loginusers.vdf') + + if (!existsSync(loginUsersConfigPath)) { + return [] + } + + const fileData = await readFile(loginUsersConfigPath, { encoding: 'utf8' }) + + try { + const loginUsers = parse(fileData) + if (!loginUsers.users) { + return [] + } + + return Object.keys(loginUsers.users).map((userId: string) => ({ + id: userId, + ...loginUsers.users[userId] + })) + } catch (e) { + logError('Failed to load steam users', { prefix: LogPrefix.Steam }) + return [] + } +} diff --git a/src/backend/utils.ts b/src/backend/utils.ts index db653c1ac2..e133b7efb7 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -347,7 +347,7 @@ function removeSpecialcharacters(text: string): string { } async function openUrlOrFile(url: string): Promise { - if (url.startsWith('http')) { + if (url.includes('://')) { return shell.openExternal(url) } return shell.openPath(url) @@ -1205,14 +1205,15 @@ export const processIsClosed = async (pid: number) => { } type RunnerStore = { - [key in Runner]: 'Epic Games' | 'GOG' | 'HyperPlay' | 'Sideloaded' + [key in Runner]: 'Epic Games' | 'GOG' | 'HyperPlay' | 'Sideloaded' | 'Steam' } const runnerStore: RunnerStore = { legendary: 'Epic Games', gog: 'GOG', hyperplay: 'HyperPlay', - sideload: 'Sideloaded' + sideload: 'Sideloaded', + steam: 'Steam' } export const getStoreName = (runner: Runner) => { diff --git a/src/backend/wiki_game_info/gamesdb/utils.ts b/src/backend/wiki_game_info/gamesdb/utils.ts index 447c080695..9451597519 100644 --- a/src/backend/wiki_game_info/gamesdb/utils.ts +++ b/src/backend/wiki_game_info/gamesdb/utils.ts @@ -14,6 +14,7 @@ export async function getInfoFromGamesDB( legendary: 'epic', gog: 'gog', hyperplay: 'hyperplay', + steam: 'steam', sideload: undefined } const storeName = storeMap[runner] diff --git a/src/common/typedefs/ipcBridge.d.ts b/src/common/typedefs/ipcBridge.d.ts index 472a772b4a..886776c920 100644 --- a/src/common/typedefs/ipcBridge.d.ts +++ b/src/common/typedefs/ipcBridge.d.ts @@ -330,7 +330,7 @@ interface HyperPlayAsyncIPCFunctions { updateActiveWallet: (walletId: number) => Promise getExternalEligibility: (questId: number) => Promise<{ walletOrEmail: string - amount: number + amount: string questId: number } | null> getListingById: (projectId: string) => Promisediff --git a/src/common/types.ts b/src/common/types.ts index f1e535b315..189d2fa4e2 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -51,7 +51,7 @@ export type WrapRendererCallback< ...args: [...Parameters] ) => ReturnType -export type Runner = 'legendary' | 'gog' | 'sideload' | 'hyperplay' +export type Runner = 'legendary' | 'gog' | 'sideload' | 'hyperplay' | 'steam' // NOTE: Do not put enum's in this module or it will break imports @@ -114,6 +114,7 @@ export interface AppSettings extends GameSettings { userInfo: UserInfo steamId: string ldUser: LDUser + enableSteamIntegration: boolean } export type LibraryTopSectionOptions = @@ -141,7 +142,7 @@ export type GameConfigVersion = 'auto' | 'v0' | 'v0.1' export type GameType = 'native' | 'mod' | 'browser' export interface GameInfo { - runner: 'legendary' | 'gog' | 'hyperplay' | 'sideload' + runner: 'legendary' | 'gog' | 'hyperplay' | 'sideload' | 'steam' store_url?: string app_name: string art_cover: string @@ -925,7 +926,7 @@ export type OverlayType = 'native' | 'browser' | 'mainWindow' export interface Reward { id: number - amount_per_user: number | null + amount_per_user: string | null chain_id: number | null marketplace_url: string | null reward_type: 'ERC20' | 'ERC721' | 'ERC1155' | 'POINTS' | 'EXTERNAL-TASKS' diff --git a/src/common/types/electron_store.ts b/src/common/types/electron_store.ts index 08ceb8630e..fe550bbeeb 100644 --- a/src/common/types/electron_store.ts +++ b/src/common/types/electron_store.ts @@ -39,6 +39,7 @@ export interface StoreStructure { lastLogFile: string legendaryLogFile: string gogdlLogFile: string + steamLogFile: string } 'window-props': Electron.Rectangle settings: AppSettings @@ -79,6 +80,9 @@ export interface StoreStructure { [saveName: string]: string } } + steamEnabledUsersConfig: { + [userId: string]: boolean + } wineManagerConfigStore: { 'wine-manager-settings': WineManagerUISettings[] 'wine-releases': WineVersionInfo[] diff --git a/src/common/types/steam.ts b/src/common/types/steam.ts new file mode 100644 index 0000000000..8713f1a8fa --- /dev/null +++ b/src/common/types/steam.ts @@ -0,0 +1,54 @@ +// Struct we use for loading users from loginusers.vdf +export interface SteamLoginUser { + id: string + PersonaName: string + RememberPassword: string + WantsOfflineMode: string + AllowAutoLogin: string + MostRecent: string + Timestamp: string +} + +// Game object from IPlayerService/GetOwnedGames +export interface OwnedGame { + appid: number + name: string + playtime_forever: number + playtime_windows_forever: number + playtime_mac_forever: number + playtime_linux_forever: number + rtime_last_played: number + playtime_disconnected: number +} + +export interface AppManifest { + appid: string + Universe: string + name: string + StateFlags: string + installdir: string + LastUpdated: string + SizeOnDisk: string + StagingSize: string + buildid: string + LastOwner: string + UpdateResult: string + BytesToDownload: string + BytesDownloaded: string + BytesStaged: string + TargetBuildID: string + AutoUpdateBehavior: string + AllowOtherDownloadsWhileRunnning: string + ScheduledAutoUpdate: string + FullValidateAfterNextUpdate: string + InstalledDepots: { + [key: string]: { manifest: string; size: string } + } + InstallScripts?: { + [key: string]: string + } +} + +export interface SteamInstallInfo extends AppManifest { + install_dir: string +} diff --git a/src/frontend/assets/steam-fallback-card.png b/src/frontend/assets/steam-fallback-card.png new file mode 100644 index 0000000000..98c4e04b75 Binary files /dev/null and b/src/frontend/assets/steam-fallback-card.png differ diff --git a/src/frontend/assets/steam-logo.svg b/src/frontend/assets/steam-logo.svg new file mode 100644 index 0000000000..db7b108fd5 --- /dev/null +++ b/src/frontend/assets/steam-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/index.tsx b/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/index.tsx index 02b2b00549..1b53ccc01b 100644 --- a/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/index.tsx +++ b/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/index.tsx @@ -1,5 +1,5 @@ import './index.css' -import React from 'react' +import React, { ReactElement } from 'react' import { Dialog, DialogContent, @@ -13,7 +13,7 @@ import { Button } from '@hyperplay/ui' interface MessageBoxModalProps { title: string - message: string + message: string | ReactElement onClose: () => void buttons: Array type: DialogType @@ -55,9 +55,11 @@ const MessageBoxModal: React.FC = function (props) { {t('error', 'Error')}:
- {props.message.split('\n').map((line, key) => { - return

{line}

- })} + {typeof props.message === 'string' + ? props.message.split('\n').map((line, key) => { + return

{line}

+ }) + : props.message}
) diff --git a/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/showSteamInstallDialog.ts b/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/showSteamInstallDialog.ts new file mode 100644 index 0000000000..e95d682f72 --- /dev/null +++ b/src/frontend/components/UI/DialogHandler/components/MessageBoxModal/showSteamInstallDialog.ts @@ -0,0 +1,33 @@ +import { DialogType, ButtonOptions } from 'common/types' +import { DialogModalOptions } from 'frontend/types' +import { TFunction } from 'i18next' + +export function showSteamInstallDialog( + showDialogModal: (options: DialogModalOptions) => void, + t: TFunction, + appName: string +) { + showDialogModal({ + type: 'MESSAGE', + title: t('steam.install.title', 'Install from Steam'), + message: t( + 'steam.install.message', + 'Please install this game from Steam and refresh the library afterwards to play on HyperPlay.' + ), + buttons: [ + { + text: t('steam.install.launch', 'Launch Steam'), + onClick: () => window.api.openExternalUrl(`steam://install/${appName}`) + }, + { + text: t('steam.install.later', 'Install Later'), + onClick: () => {} + } + ] + } as { + type: DialogType + title: string + message: string + buttons: ButtonOptions[] + }) +} diff --git a/src/frontend/components/UI/StoreLogos/index.tsx b/src/frontend/components/UI/StoreLogos/index.tsx index 24ca2e9463..0550187fc3 100644 --- a/src/frontend/components/UI/StoreLogos/index.tsx +++ b/src/frontend/components/UI/StoreLogos/index.tsx @@ -3,6 +3,7 @@ import { Runner } from 'common/types' import { ReactComponent as EpicLogo } from 'frontend/assets/epic-logo.svg' import { ReactComponent as GOGLogo } from 'frontend/assets/gog-logo.svg' import { ReactComponent as SideLoad } from 'frontend/assets/sideload.svg' +import { ReactComponent as SteamLogo } from 'frontend/assets/steam-logo.svg' type Props = { runner: Runner; className?: string } @@ -15,6 +16,8 @@ export default function StoreLogos({ return case 'gog': return + case 'steam': + return default: return } diff --git a/src/frontend/helpers/electronStores.ts b/src/frontend/helpers/electronStores.ts index c22b84417b..4ea4da6977 100644 --- a/src/frontend/helpers/electronStores.ts +++ b/src/frontend/helpers/electronStores.ts @@ -129,6 +129,10 @@ const wineDownloaderInfoStore = new TypeCheckedStoreFrontend( ) const gogLibraryStore = new CacheStore('gog_library', null) +const steamLibraryStore = new CacheStore( + 'steam_library', + null +) const gogInstalledGamesStore = new TypeCheckedStoreFrontend( 'gogInstalledGamesStore', { @@ -188,5 +192,6 @@ export { metricsStore, onboardingStore, newsLetterStore, - hyperPlayLibraryStore + hyperPlayLibraryStore, + steamLibraryStore } diff --git a/src/frontend/helpers/library.ts b/src/frontend/helpers/library.ts index a006d04795..8a792d8791 100644 --- a/src/frontend/helpers/library.ts +++ b/src/frontend/helpers/library.ts @@ -57,6 +57,11 @@ async function install({ return } + if (gameInfo.runner === 'steam') { + window.api.openExternalUrl(`steam://install/${gameInfo.app_name}`) + return + } + const { folder_name, is_installed, app_name: appName, runner } = gameInfo if (isInstalling) { @@ -284,5 +289,6 @@ export const epicCategories = ['all', 'legendary', 'epic'] export const gogCategories = ['all', 'gog'] export const sideloadedCategories = ['all', 'sideload'] export const hyperPlayCategories = ['all', 'hyperplay'] +export const steamCategories = ['all', 'steam'] export { install, launch, repair, updateGame } diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index 2ae170b07e..bf97c4a266 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -20,6 +20,7 @@ import { import { NavLink, useLocation, useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import ContextProvider from 'frontend/state/ContextProvider' + import { UpdateComponent, SelectField } from 'frontend/components/UI' import walletStore from 'frontend/state/WalletState' import onboardingStore from 'frontend/store/OnboardingStore' @@ -69,6 +70,7 @@ import libraryState from 'frontend/state/libraryState' import DMQueueState from 'frontend/state/DMQueueState' import { useEstimatedUncompressedSize } from 'frontend/hooks/useEstimatedUncompressedSize' import authState from 'frontend/state/authState' +import { showSteamInstallDialog } from 'frontend/components/UI/DialogHandler/components/MessageBoxModal/showSteamInstallDialog' type locationState = { fromDM?: boolean @@ -125,6 +127,7 @@ export default observer(function GamePage(): React.JSX.Element | null { const isLinux = platform === 'linux' const isMac = platform === 'darwin' const isSideloaded = runner === 'sideload' + const isSteam = runner === 'steam' const isInstalling = DMQueueState.isInstalling(appName) const isPlaying = status === 'playing' @@ -249,7 +252,7 @@ export default observer(function GamePage(): React.JSX.Element | null { runner === 'hyperplay' ? hpPlatforms : othersPlatforms if ( - runner !== 'sideload' && + !['sideload', 'steam'].includes(runner) && !notSupportedGame && !notInstallable && !isOffline @@ -398,6 +401,8 @@ export default observer(function GamePage(): React.JSX.Element | null { gameInfo.extra?.about?.description || t('generic.noDescription', 'No description available') + const showSettingsButton = is_installed && !isBrowserGame && !isSteam + return (
{showStopInstallModal ? ( @@ -438,7 +443,7 @@ export default observer(function GamePage(): React.JSX.Element | null {

{title}

- {is_installed && !isBrowserGame && ( + {showSettingsButton && ( @@ -449,155 +454,186 @@ export default observer(function GamePage(): React.JSX.Element | null { )} -
- - - updateGame(gameInfo)} - disableUpdate={showProgress} - setShowExtraInfo={setShowExtraInfo} - onShowRequirements={ - hasRequirements - ? () => setShowRequirements(true) - : undefined - } - onShowDlcs={ - DLCs.length ? () => setShowDlcs(true) : undefined - } - /> -
+ {isSteam ? null : ( +
+ + + updateGame(gameInfo)} + disableUpdate={showProgress} + setShowExtraInfo={setShowExtraInfo} + onShowRequirements={ + hasRequirements + ? () => setShowRequirements(true) + : undefined + } + onShowDlcs={ + DLCs.length ? () => setShowDlcs(true) : undefined + } + /> +
+ )}
-
{developer}
-
{description}
+ {!isSteam ? ( + <> +
{developer}
+
{description}
+ + ) : ( + <> +
+ {t( + 'info.steam-game', + 'This is a Steam game, for more information check the game page on the Steam App.' + )} +
+ + )}
- {!is_installed && !isSideloaded && ( - <> - {downloadSize !== 0 ? ( - <> -
- {t('game.downloadSize', 'Download Size')} -
-
- {downloadSize ?? '...'} -
- - ) : null} - {installSize !== 0 ? ( - <> -
- {t('game.installSize', 'Install Size')} -
-
- {installSize ?? '...'} -
- - ) : null} - - )} -
- {t('info.web3-supported', 'Has Web3 features')} -
-
- {supportsWeb3 ? t('box.yes') : t('box.no')} -
- {is_installed && !isBrowserGame && ( + {!isSteam && ( <> - {showCloudSaveInfo && ( - <> -
- {t('info.syncsaves')} -
-
- {!isCloudSaveSupported && - t('cloud_save_unsupported', 'Unsupported')} - {isCloudSaveSupported && - (autoSyncSaves ? t('enabled') : t('disabled'))} -
- - )} - {!isSideloaded && installSize ? ( - <> -
{t('info.size')}
-
{installSize}
- - ) : null} -
- {t('info.installedPlatform', 'Installed Platform')}: -
-
- {installPlatform === 'osx' - ? 'MacOS' - : getPlatformName(installPlatform || '')} -
- {!isSideloaded && ( + {!is_installed && !isSideloaded && ( <> -
{t('info.version')}
-
{version}
+ {downloadSize !== 0 ? ( + <> +
+ {t('game.downloadSize', 'Download Size')} +
+
+ {downloadSize ?? '...'} +
+ + ) : null} + {installSize !== 0 ? ( + <> +
+ {t('game.installSize', 'Install Size')} +
+
+ {installSize ?? '...'} +
+ + ) : null} )}
- {t('info.canRunOffline', 'Online Required')}: + {t('info.web3-supported', 'Has Web3 features')}
- {t(canRunOffline ? 'box.no' : 'box.yes')} + {supportsWeb3 ? t('box.yes') : t('box.no')}
-
- {t('info.path', 'Install Path')} -
- {is_installed && appLocation && ( -
+ {is_installed && !isBrowserGame && ( + <> + {showCloudSaveInfo && ( + <> +
+ {t('info.syncsaves')} +
+
+ {!isCloudSaveSupported && + t('cloud_save_unsupported', 'Unsupported')} + {isCloudSaveSupported && + (autoSyncSaves + ? t('enabled') + : t('disabled'))} +
+ + )} + {!isSideloaded && installSize ? ( + <> +
+ {t('info.size')} +
+
+ {installSize} +
+ + ) : null} +
+ {t('info.installedPlatform', 'Installed Platform')}: +
window.api.openFolder(appLocation)} + style={{ textTransform: 'capitalize' }} + className="col2-item" > - {appLocation} + {installPlatform === 'osx' + ? 'MacOS' + : getPlatformName(installPlatform || '')} +
+ {!isSideloaded && ( + <> +
+ {t('info.version')} +
+
{version}
+ + )} +
+ {t('info.canRunOffline', 'Online Required')}:
-
- )} - {!isWin && !isNative && !isBrowserGame && ( - <> -
Wine
- {wineVersion?.name} + {t(canRunOffline ? 'box.no' : 'box.yes')}
-
Prefix:
-
window.api.openFolder(winePrefix)} - > - {winePrefix} +
+ {t('info.path', 'Install Path')}
+ {is_installed && appLocation && ( +
+
+ window.api.openFolder(appLocation) + } + > + {appLocation} +
+
+ )} + {!isWin && !isNative && !isBrowserGame && ( + <> +
Wine
+
+ {wineVersion?.name} +
+
Prefix:
+
+ window.api.openFolder(winePrefix) + } + > + {winePrefix} +
+ + )} )} + )} -
@@ -931,7 +967,9 @@ export default observer(function GamePage(): React.JSX.Element | null { } async function mainAction(is_installed: boolean) { - // TODO: Add a way to pause download from the game page + if (isSteam) { + return showSteamInstallDialog(showDialogModal, t, appName) + } // resume download if (isPaused) { @@ -957,6 +995,11 @@ export default observer(function GamePage(): React.JSX.Element | null { // open install dialog if (!is_installed) { + if (isSteam) { + return window.api.openExternalUrl( + `steam://install/${gameInfo.app_name}` + ) + } return handleModal() } diff --git a/src/frontend/screens/Game/GamePicture/index.tsx b/src/frontend/screens/Game/GamePicture/index.tsx index d6527c9219..0b539c4bfc 100644 --- a/src/frontend/screens/Game/GamePicture/index.tsx +++ b/src/frontend/screens/Game/GamePicture/index.tsx @@ -3,6 +3,7 @@ import { CachedImage } from 'frontend/components/UI' import './index.css' import fallbackImage from 'frontend/assets/fallback_card.jpg' +import steamFallBackImage from 'frontend/assets/steam-fallback-card.png' type Props = { art_square: string @@ -10,8 +11,12 @@ type Props = { } export function getImageFormattingForArtSquare({ art_square, store }: Props) { + const isSteam = store === 'steam' if (art_square === 'fallback' || !art_square) - return { src: fallbackImage, fallback: fallbackImage } + return { + src: isSteam ? steamFallBackImage : fallbackImage, + fallback: fallbackImage + } if (store === 'legendary') { return { src: `${art_square}?h=800&resize=1&w=600`, diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 1a657d2a5a..d3ed317e6a 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -28,6 +28,7 @@ import libraryState from 'frontend/state/libraryState' import DMQueueState from 'frontend/state/DMQueueState' import authState from 'frontend/state/authState' import { getImageFormattingForArtSquare } from 'frontend/screens/Game/GamePicture' +import { showSteamInstallDialog } from 'frontend/components/UI/DialogHandler/components/MessageBoxModal/showSteamInstallDialog' interface Card { buttonClick: () => void @@ -434,10 +435,13 @@ const GameCard = ({ ) async function mainAction(runner: Runner) { + if (runner === 'steam') { + return showSteamInstallDialog(showDialogModal, t, appName) + } + if (isInstalling || isExtracting || isPaused) { return setShowStopInstallModal(true) } - // ask to install if the game is not installed if (!isInstalled && !isQueued && gameInfo.runner !== 'sideload') { return install({ diff --git a/src/frontend/screens/Library/components/LibraryTopBar/index.tsx b/src/frontend/screens/Library/components/LibraryTopBar/index.tsx index 32039dedf5..72d7fcbaa2 100644 --- a/src/frontend/screens/Library/components/LibraryTopBar/index.tsx +++ b/src/frontend/screens/Library/components/LibraryTopBar/index.tsx @@ -17,6 +17,7 @@ import { Category } from 'frontend/types' import { observer } from 'mobx-react-lite' import libraryState from '../../../../state/libraryState' import storeAuthState from 'frontend/state/storeAuthState' +import { configStore } from 'frontend/helpers/electronStores' export interface LibraryTopBarInterface { filters: DropdownItemType[] @@ -42,6 +43,9 @@ export const LibraryTopBar = observer( const isGOGLoggedin = storeAuthState.gog.username const isEpicLoggedin = storeAuthState.epic.username + const enableSteamIntegration = + configStore.get_nodefault('settings.enableSteamIntegration') ?? false + return ( { @@ -72,6 +76,11 @@ export const LibraryTopBar = observer(
GOG
) : null} + {enableSteamIntegration ? ( + +
Steam
+
+ ) : null}
{t('Other')}
diff --git a/src/frontend/screens/Library/index.tsx b/src/frontend/screens/Library/index.tsx index 77ba6b114c..0d54b8e10b 100644 --- a/src/frontend/screens/Library/index.tsx +++ b/src/frontend/screens/Library/index.tsx @@ -9,6 +9,7 @@ import React, { import { UpdateComponent } from 'frontend/components/UI' import { useTranslation } from 'react-i18next' import ContextProvider from 'frontend/state/ContextProvider' +import { showSteamInstallDialog } from 'frontend/components/UI/DialogHandler/components/MessageBoxModal/showSteamInstallDialog' import GamesList from './components/GamesList' import { FilterItem, GameInfo, Runner } from 'common/types' import ErrorComponent from 'frontend/components/UI/ErrorComponent' @@ -37,7 +38,7 @@ type ModalState = { } export default observer(function Library(): React.JSX.Element { - const { layout, epic, gog, platform, connectivity } = + const { layout, epic, gog, platform, connectivity, showDialogModal } = useContext(ContextProvider) const { t } = useTranslation() @@ -111,6 +112,9 @@ export default observer(function Library(): React.JSX.Element { runner: Runner, gameInfo: GameInfo | null ) { + if (runner === 'steam') { + return showSteamInstallDialog(showDialogModal, t, appName) + } setShowModal({ game: appName, show: true, runner, gameInfo }) } diff --git a/src/frontend/screens/Settings/components/EnableSteamIntegration.tsx b/src/frontend/screens/Settings/components/EnableSteamIntegration.tsx new file mode 100644 index 0000000000..9ff3451093 --- /dev/null +++ b/src/frontend/screens/Settings/components/EnableSteamIntegration.tsx @@ -0,0 +1,78 @@ +import React, { useContext } from 'react' +import { useTranslation } from 'react-i18next' +import ToggleSwitch from 'frontend/components/UI/ToggleSwitch' +import useSetting from 'frontend/hooks/useSetting' +import libraryState from 'frontend/state/libraryState' +import ContextProvider from 'frontend/state/ContextProvider' + +export default function EnableSteamIntegration() { + const { t } = useTranslation() + const [enabled, setEnabled] = useSetting('enableSteamIntegration', false) + const { showDialogModal } = useContext(ContextProvider) + + const handleChange = () => { + const newValue = !enabled + if (newValue) { + showDialogModal({ + title: t( + 'steam.integration.requirements.title', + 'Steam Integration Requirements' + ), + message: ( + + {t( + 'steam.integration.requirements.part1', + 'To ensure this integration works properly and HyperPlay can access your games, you must have' + )}{' '} + + {t('steam.integration.requirements.part2', 'Steam installed')} + {' '} + {t('steam.integration.requirements.part3', 'and be')}{' '} + {t('steam.integration.requirements.part4', 'Logged into')}{' '} + {t('steam.integration.requirements.part5', 'your account.')}{' '} + {t( + 'steam.integration.requirements.part6', + 'Additionally, your profile and game details should be set to ' + )} + {t('steam.integration.requirements.part7', 'Public')} ( + + {t( + 'steam.integration.requirements.part8', + 'Steam > Profile > Edit Profile > Privacy Settings' + )} + + ) + + ), + buttons: [ + { + text: t('box.ok', 'OK'), + onClick: () => { + setEnabled(true) + window.api.refreshLibrary('steam') + } + } + ], + type: 'MESSAGE' + }) + } else { + setEnabled(false) + if (libraryState.category === 'steam') { + libraryState.category = 'all' + } + window.api.clearCache(false) + } + } + + return ( + + ) +} diff --git a/src/frontend/screens/Settings/components/index.ts b/src/frontend/screens/Settings/components/index.ts index b241c2990d..de5335c665 100644 --- a/src/frontend/screens/Settings/components/index.ts +++ b/src/frontend/screens/Settings/components/index.ts @@ -41,3 +41,4 @@ export { default as WinePrefixesBasePath } from './WinePrefixesBasePath' export { default as WineVersionSelector } from './WineVersionSelector' export { default as WrappersTable } from './WrappersTable' export { default as HyperPlayAnalytics } from './HyperPlayAnalytics' +export { default as EnableSteamIntegration } from './EnableSteamIntegration' diff --git a/src/frontend/screens/Settings/sections/GeneralSettings/index.tsx b/src/frontend/screens/Settings/sections/GeneralSettings/index.tsx index 236fc472bf..547b60c3c7 100644 --- a/src/frontend/screens/Settings/sections/GeneralSettings/index.tsx +++ b/src/frontend/screens/Settings/sections/GeneralSettings/index.tsx @@ -14,7 +14,8 @@ import { Shortcuts, TraySettings, UseDarkTrayIcon, - WinePrefixesBasePath + WinePrefixesBasePath, + EnableSteamIntegration } from '../../components' import AutoLaunchHyperPlay from '../../components/AutoLaunchHyperPlay' import styles from './index.module.scss' @@ -32,6 +33,7 @@ export default function GeneralSettings() { + @@ -40,7 +42,7 @@ export default function GeneralSettings() { - {/* + {/* disabled until we fix the controller navigation in hyperplay */} diff --git a/src/frontend/state/__tests__/libraryState.test.ts b/src/frontend/state/__tests__/libraryState.test.ts index 10172164f0..003a2c075a 100644 --- a/src/frontend/state/__tests__/libraryState.test.ts +++ b/src/frontend/state/__tests__/libraryState.test.ts @@ -6,7 +6,9 @@ Object.defineProperty(window, 'api', { install: jest.fn(), storeNew: jest.fn(), storeGet: jest.fn(), - storeSet: jest.fn() + storeSet: jest.fn(), + storeHas: jest.fn().mockReturnValue(false), + storeDelete: jest.fn() } }) diff --git a/src/frontend/state/libraryState.ts b/src/frontend/state/libraryState.ts index b399409822..89a73be746 100644 --- a/src/frontend/state/libraryState.ts +++ b/src/frontend/state/libraryState.ts @@ -18,13 +18,15 @@ import { libraryStore, sideloadLibrary, hyperPlayLibraryStore, + steamLibraryStore, configStore } from 'frontend/helpers/electronStores' import { epicCategories, gogCategories, hyperPlayCategories, - sideloadedCategories + sideloadedCategories, + steamCategories } from 'frontend/helpers/library' import storeAuthState from './storeAuthState' @@ -54,6 +56,7 @@ class LibraryState { gogLibrary: GameInfo[] = [] sideloadedLibrary: GameInfo[] = [] hyperPlayLibrary: GameInfo[] = [] + steamLibrary: GameInfo[] = [] nonAvailableGames: GameInfo[] = [] // array of appName's for games that need updating gameUpdates: string[] = [] @@ -108,6 +111,8 @@ class LibraryState { this.sideloadedLibrary = newLibrary } else if (runner === 'hyperplay') { this.hyperPlayLibrary = newLibrary + } else if (runner === 'steam') { + this.steamLibrary = newLibrary } }) } @@ -153,6 +158,13 @@ class LibraryState { this.refreshGogLibrary() } + this.refreshSteamLibrary() + if (!this.steamLibrary.length || !this.steamLibrary.length) { + window.api.logInfo('No cache found, getting data from steam...') + await window.api.refreshLibrary('steam') + this.refreshSteamLibrary() + } + this.refreshSideloadedLibrary() this.hiddenGames.list = configStore.get('games.hidden', []) @@ -197,6 +209,7 @@ class LibraryState { this.refreshGogLibrary() this.refreshSideloadedLibrary() this.refreshHyperplayLibrary() + this.refreshSteamLibrary() } refreshEpicLibrary() { @@ -219,6 +232,10 @@ class LibraryState { this.gogLibrary = games } + refreshSteamLibrary() { + this.steamLibrary = steamLibraryStore.get('games', []) + } + refreshHyperplayLibrary() { this.hyperPlayLibrary = hyperPlayLibraryStore.get('games', []) } @@ -314,6 +331,9 @@ class LibraryState { this.hyperPlayLibrary.forEach((game) => { if (favouriteAppNames.includes(game.app_name)) tempArray.push(game) }) + this.steamLibrary.forEach((game) => { + if (favouriteAppNames.includes(game.app_name)) tempArray.push(game) + }) } return tempArray } @@ -328,8 +348,10 @@ class LibraryState { } else { const isEpic = epicCategories.includes(this.category) const isGog = gogCategories.includes(this.category) + const isSteam = steamCategories.includes(this.category) const epicLibrary = isEpic ? this.epicLibrary : [] const gogLibrary = isGog ? this.gogLibrary : [] + const steamLibrary = isSteam ? steamLibraryStore.get('games', []) : [] const sideloadedApps = sideloadedCategories.includes(this.category) ? this.sideloadedLibrary : [] @@ -337,7 +359,13 @@ class LibraryState { ? this.hyperPlayLibrary : [] - library = [...HPLibrary, ...sideloadedApps, ...epicLibrary, ...gogLibrary] + library = [ + ...HPLibrary, + ...sideloadedApps, + ...epicLibrary, + ...gogLibrary, + ...steamLibrary + ] if (!this.showNonAvailable) { const nonAvailableAppNames = Object.fromEntries( diff --git a/src/frontend/state/storeAuthState.ts b/src/frontend/state/storeAuthState.ts index 0f02d1229e..dfb22332f4 100644 --- a/src/frontend/state/storeAuthState.ts +++ b/src/frontend/state/storeAuthState.ts @@ -1,3 +1,4 @@ +import { SteamLoginUser } from 'common/types/steam' import { configStore, gogConfigStore } from 'frontend/helpers/electronStores' import { makeAutoObservable } from 'mobx' @@ -8,6 +9,9 @@ class StoreAuthState { gog = { username: '' } + steam = { + enabledUsers: [] as SteamLoginUser[] + } constructor() { makeAutoObservable(this) @@ -16,6 +20,8 @@ class StoreAuthState { init() { this.epic.username = configStore.get_nodefault('userInfo.displayName') ?? '' this.gog.username = gogConfigStore.get_nodefault('userData.username') ?? '' + this.steam.enabledUsers = + configStore.get_nodefault('steamEnabledUsers') ?? [] } } diff --git a/src/frontend/types.ts b/src/frontend/types.ts index acfcceb589..fd7d117c9f 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -10,8 +10,15 @@ import { MetricsOptInStatus, DownloadManagerState } from 'common/types' +import { ReactElement } from 'react' -export type Category = 'all' | 'legendary' | 'gog' | 'sideload' | 'hyperplay' +export type Category = + | 'all' + | 'legendary' + | 'gog' + | 'sideload' + | 'hyperplay' + | 'steam' export type Platform = 'win' | 'mac' | 'linux' | 'browser' @@ -69,7 +76,7 @@ export interface ContextType { export type DialogModalOptions = { showDialog?: boolean title?: string - message?: string + message?: string | ReactElement buttons?: Array type?: DialogType onClose?: () => void