diff --git a/public/locales/en/gamepage.json b/public/locales/en/gamepage.json index 1433f09e99..b49e889093 100644 --- a/public/locales/en/gamepage.json +++ b/public/locales/en/gamepage.json @@ -140,6 +140,7 @@ "size": "Size", "steamdeck-compatibility-info": "SteamDeck Compatibility", "syncsaves": "Sync Saves", + "third-party-app": "Third-Party Manager", "version": "Version" }, "install": { @@ -271,5 +272,10 @@ "settings": "Settings", "store": "Store Page", "verify": "Verify and Repair" + }, + "third-party-managed": { + "header": "This game is managed by a third-party application", + "notice1": "This game is managed by a third-party application: \"{{application_name}}\"", + "notice2": "After clicking Install, Heroic will run the application in order to complete the installation process" } } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5e640cf8f3..f013a7b25f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -287,6 +287,7 @@ "show_hidden": "Show Hidden", "show_installed_only": "Show Installed only", "show_support_offline_only": "Show offline-supported only", + "show_third_party_managed_only": "Show third-party managed only", "uncategorized": "Uncategorized" }, "help": { diff --git a/src/backend/constants.ts b/src/backend/constants.ts index 4e9d8382bd..ef00ccb925 100644 --- a/src/backend/constants.ts +++ b/src/backend/constants.ts @@ -62,6 +62,7 @@ const nileConfigPath = join(appFolder, 'nile_config', 'nile') const configPath = join(appFolder, 'config.json') const gamesConfigPath = join(appFolder, 'GamesConfig') const toolsPath = join(appFolder, 'tools') +const epicRedistPath = join(toolsPath, 'redist', 'legendary') const gogRedistPath = join(toolsPath, 'redist', 'gog') const heroicIconFolder = join(appFolder, 'icons') const runtimePath = join(toolsPath, 'runtimes') @@ -90,6 +91,10 @@ const icon = fixAsarPath(join(publicDir, 'icon.png')) const iconDark = fixAsarPath(join(publicDir, 'icon-dark.png')) const iconLight = fixAsarPath(join(publicDir, 'icon-light.png')) const installed = join(legendaryConfigPath, 'installed.json') +const thirdPartyInstalled = join( + legendaryConfigPath, + 'third-party-installed.json' +) const legendaryMetadata = join(legendaryConfigPath, 'metadata') const nileInstalled = join(nileConfigPath, 'installed.json') const nileLibrary = join(nileConfigPath, 'library.json') @@ -279,10 +284,12 @@ export { gogdlConfigPath, gogSupportPath, gogRedistPath, + epicRedistPath, vulkanHelperBin, nileConfigPath, nileInstalled, nileLibrary, nileUserData, - fixesPath + fixesPath, + thirdPartyInstalled } diff --git a/src/backend/downloadmanager/downloadqueue.ts b/src/backend/downloadmanager/downloadqueue.ts index 2080656a5f..8e637029eb 100644 --- a/src/backend/downloadmanager/downloadqueue.ts +++ b/src/backend/downloadmanager/downloadqueue.ts @@ -126,30 +126,41 @@ async function addToQueue(element: DMQueueElement) { if (elementIndex >= 0) { elements[elementIndex] = element } else { - const installInfo = await libraryManagerMap[ - element.params.runner - ].getInstallInfo(element.params.appName, element.params.platformToInstall, { - branch: element.params.branch, - build: element.params.build - }) + const gameInfo = libraryManagerMap[element.params.runner].getGameInfo( + element.params.appName + ) + if (!gameInfo?.isEAManaged) { + const installInfo = await libraryManagerMap[ + element.params.runner + ].getInstallInfo( + element.params.appName, + element.params.platformToInstall, + { + branch: element.params.branch, + build: element.params.build + } + ) - element.params.size = installInfo?.manifest?.download_size - ? getFileSize(installInfo?.manifest?.download_size) - : '?? MB' - - if ( - element.params.runner === 'gog' && - element.params.platformToInstall.toLowerCase() === 'windows' && - installInfo && - 'dependencies' in installInfo.manifest - ) { - const newDependencies = installInfo.manifest.dependencies || [] - if (newDependencies?.length || !existsSync(gogRedistPath)) { - // create redist element - const redistElement = createRedistDMQueueElement() - redistElement.params.dependencies = newDependencies - elements.push(redistElement) + element.params.size = installInfo?.manifest?.download_size + ? getFileSize(installInfo?.manifest?.download_size) + : '?? MB' + + if ( + element.params.runner === 'gog' && + element.params.platformToInstall.toLowerCase() === 'windows' && + installInfo && + 'dependencies' in installInfo.manifest + ) { + const newDependencies = installInfo.manifest.dependencies || [] + if (newDependencies?.length || !existsSync(gogRedistPath)) { + // create redist element + const redistElement = createRedistDMQueueElement() + redistElement.params.dependencies = newDependencies + elements.push(redistElement) + } } + } else { + element.params.size = '?? MB' } elements.push(element) } diff --git a/src/backend/storeManagers/legendary/games.ts b/src/backend/storeManagers/legendary/games.ts index d4e240c246..8e98563b2a 100644 --- a/src/backend/storeManagers/legendary/games.ts +++ b/src/backend/storeManagers/legendary/games.ts @@ -23,13 +23,15 @@ import { } from './library' import { LegendaryUser } from './user' import { + downloadFile, getLegendaryBin, killPattern, moveOnUnix, moveOnWindows, sendGameStatusUpdate, sendProgressUpdate, - shutdownWine + shutdownWine, + spawnAsync } from '../../utils' import { isMac, @@ -37,7 +39,8 @@ import { installed, configStore, isCLINoGui, - isLinux + isLinux, + epicRedistPath } from '../../constants' import { appendGamePlayLog, @@ -83,7 +86,9 @@ import { PositiveInteger } from './commands/base' import { LegendaryCommand } from './commands' +import thirdParty from './thirdParty' import { Path } from 'backend/schemas' +import { mkdirSync } from 'fs' /** * Alias for `LegendaryLibrary.listUpdateableGames` @@ -144,9 +149,13 @@ async function getProductSlug(namespace: string, title: string) { } try { - const result = await axios('https://www.epicgames.com/graphql', { + const result = await axios('https://launcher.store.epicgames.com/graphql', { data: graphql, - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EpicGamesLauncher' + }, method: 'POST' }) @@ -233,9 +242,13 @@ async function getExtraFromGraphql( } try { - const result = await axios('https://www.epicgames.com/graphql', { + const result = await axios('https://launcher.store.epicgames.com/graphql', { data: graphql, - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EpicGamesLauncher' + }, method: 'POST' }) @@ -289,6 +302,7 @@ const emptyExtraInfo = { export async function getExtraInfo(appName: string): Promise { const { namespace, title } = getGameInfo(appName) if (namespace === undefined) return emptyExtraInfo + const cachedExtraInfo = gameInfoStore.get(namespace) if (cachedExtraInfo) { return cachedExtraInfo @@ -571,6 +585,18 @@ export async function install( status: 'done' | 'error' | 'abort' error?: string }> { + const gameInfo = getGameInfo(appName) + if (gameInfo.thirdPartyManagedApp) { + if (!gameInfo.isEAManaged) { + logError( + ['Third party app', gameInfo.thirdPartyManagedApp, 'not supported'], + LogPrefix.Legendary + ) + return { status: 'error' } + } + + return installEA(gameInfo, platformToInstall) + } const { maxWorkers, downloadNoHttps } = GlobalConfig.get().getSettings() const info = await getInstallInfo(appName, platformToInstall) @@ -634,7 +660,54 @@ export async function install( return { status: 'done' } } +async function installEA( + gameInfo: GameInfo, + platformToInstall: string +): Promise<{ + status: 'done' | 'error' | 'abort' + error?: string +}> { + logInfo('Getting EA App installer', LogPrefix.Legendary) + const installerPath = join(epicRedistPath, 'EAappInstaller.exe') + + if (!existsSync(epicRedistPath)) { + mkdirSync(epicRedistPath, { recursive: true }) + } + + if (!existsSync(installerPath)) { + try { + await downloadFile({ + url: 'https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe', + dest: installerPath + }) + } catch (e) { + return { status: 'error', error: `${e}` } + } + } + + if (isWindows) { + const process = await spawnAsync(installerPath, [ + 'EAX_LAUNCH_CLIENT=0', + 'IGNORE_INSTALLED=1' + ]) + + if (process.code !== null && process.code === 3) { + return { status: 'abort' } + } + } + + await thirdParty.addInstalledGame(gameInfo.app_name, platformToInstall) + + return { status: 'done' } +} + export async function uninstall({ appName }: RemoveArgs): Promise { + const gameInfo = getGameInfo(appName) + if (gameInfo.thirdPartyManagedApp) { + await thirdParty.removeInstalledGame(appName) + return { stdout: '', stderr: '' } + } + const command: LegendaryCommand = { subcommand: 'uninstall', appName: LegendaryAppName.parse(appName), @@ -867,6 +940,7 @@ export async function launch( command['--override-exe'] = Path.parse(gameSettings.targetExe) if (offlineMode) command['--offline'] = true if (isCLINoGui) command['--skip-version-check'] = true + if (gameInfo.isEAManaged) command['--origin'] = true const fullCommand = getRunnerCallWithoutCredentials( command, diff --git a/src/backend/storeManagers/legendary/library.ts b/src/backend/storeManagers/legendary/library.ts index e18089f840..2dac3206b5 100644 --- a/src/backend/storeManagers/legendary/library.ts +++ b/src/backend/storeManagers/legendary/library.ts @@ -56,6 +56,7 @@ import { LegendaryCommand } from './commands' import { LegendaryAppName, LegendaryPlatform } from './commands/base' import { Path } from 'backend/schemas' import shlex from 'shlex' +import thirdParty from './thirdParty' import { Entries } from 'type-fest' const allGames: Set = new Set() @@ -129,22 +130,29 @@ async function refreshLegendary(): Promise { */ export function refreshInstalled() { const installedJSON = join(legendaryConfigPath, 'installed.json') + + let installedCache: [string, InstalledJsonMetadata][] = [] if (existsSync(installedJSON)) { try { - installedGames = new Map( - Object.entries(JSON.parse(readFileSync(installedJSON, 'utf-8'))) - ) + installedCache = Object.entries( + JSON.parse(readFileSync(installedJSON, 'utf-8')) + ) as [string, InstalledJsonMetadata][] } catch (error) { // disabling log here because its giving false positives on import command logError( ['Corrupted installed.json file, cannot load installed games', error], LogPrefix.Legendary ) - installedGames = new Map() + installedCache = [] } } else { - installedGames = new Map() + installedCache = [] } + + const thirdPartyGames = thirdParty.getInstalledGames() + installedCache.push(...thirdPartyGames) + + installedGames = new Map(installedCache) } const defaultExecResult = { @@ -623,6 +631,9 @@ function loadFile(app_name: string): boolean { title, canRunOffline, thirdPartyManagedApp, + isEAManaged: + !!thirdPartyManagedApp && + ['origin', 'the ea app'].includes(thirdPartyManagedApp.toLowerCase()), is_linux_native: false, runner: 'legendary', store_url: formatEpicStoreUrl(title) @@ -867,7 +878,7 @@ export async function getLaunchOptions( ): Promise { const gameInfo = getGameInfo(appName) const installPlatform = gameInfo?.install.platform - if (!installPlatform) return [] + if (!installPlatform || gameInfo.thirdPartyManagedApp) return [] const installInfo = await getInstallInfo(appName, installPlatform) const launchOptions: LaunchOption[] = installInfo.game.launch_options diff --git a/src/backend/storeManagers/legendary/setup.ts b/src/backend/storeManagers/legendary/setup.ts index a186a5e5fc..5064df8fc1 100644 --- a/src/backend/storeManagers/legendary/setup.ts +++ b/src/backend/storeManagers/legendary/setup.ts @@ -4,9 +4,10 @@ import { getInstallInfo } from './library' import { sendGameStatusUpdate } from 'backend/utils' import { enable, getStatus, isEnabled } from './eos_overlay/eos_overlay' import { split } from 'shlex' -import { logError } from 'backend/logger/logger' +import { logError, LogPrefix } from 'backend/logger/logger' import { runWineCommand } from 'backend/launcher' import { GameConfig } from 'backend/game_config' +import { epicRedistPath } from 'backend/constants' export const legendarySetup = async (appName: string) => { const gameInfo = getGameInfo(appName) @@ -39,7 +40,8 @@ export const legendarySetup = async (appName: string) => { const winPlatforms = ['Windows', 'Win32', 'windows'] if ( gameInfo.install.platform && - winPlatforms.includes(gameInfo.install.platform) + winPlatforms.includes(gameInfo.install.platform) && + !gameInfo.isEAManaged ) { try { const info = await getInstallInfo(appName, gameInfo.install.platform) @@ -65,6 +67,24 @@ export const legendarySetup = async (appName: string) => { } } + if (gameInfo.isEAManaged) { + const installerPath = join(epicRedistPath, 'EAappInstaller.exe') + try { + await runWineCommand({ + gameSettings, + commandParts: [ + installerPath, + 'EAX_LAUNCH_CLIENT=0', + 'IGNORE_INSTALLED=1' + ], + wait: true, + protonVerb: 'run' + }) + } catch (e) { + logError(`Failed to run EA App installer ${e}`, LogPrefix.Legendary) + } + } + const isOverlayEnabled = await isEnabled(appName) if (getStatus().isInstalled && !isOverlayEnabled) { diff --git a/src/backend/storeManagers/legendary/thirdParty.ts b/src/backend/storeManagers/legendary/thirdParty.ts new file mode 100644 index 0000000000..0d718232c1 --- /dev/null +++ b/src/backend/storeManagers/legendary/thirdParty.ts @@ -0,0 +1,83 @@ +import { thirdPartyInstalled } from 'backend/constants' +import { LogPrefix, logWarning, logError } from 'backend/logger/logger' +import { InstalledJsonMetadata } from 'common/types/legendary' +import { existsSync, readFileSync } from 'fs' +import { readFile, writeFile } from 'fs/promises' + +const getInstalledGames = (): [string, InstalledJsonMetadata][] => { + if (existsSync(thirdPartyInstalled)) { + try { + const thirdPartyData = JSON.parse( + readFileSync(thirdPartyInstalled, 'utf-8') + ) as [string, string][] + + const installObjects: [string, InstalledJsonMetadata][] = + thirdPartyData.map(([app_name, platform]) => [ + app_name, + { app_name, platform } as InstalledJsonMetadata + ]) + + return installObjects + } catch (error) { + logWarning( + ['Failed to read third-party-installed.json', error], + LogPrefix.Legendary + ) + } + } + return [] +} + +const addInstalledGame = async (appName: string, platform: string) => { + const installedAppNames = [] + + if (existsSync(thirdPartyInstalled)) { + try { + const buffer = await readFile(thirdPartyInstalled, 'utf-8') + installedAppNames.push(...(JSON.parse(buffer) as [string, string][])) + } catch (err) { + logWarning( + `Failed to read third-party-installed.json ${err}`, + LogPrefix.Legendary + ) + } + } + installedAppNames.push([appName, platform]) + + try { + await writeFile( + thirdPartyInstalled, + JSON.stringify(installedAppNames), + 'utf-8' + ) + } catch (err) { + logError( + `Failed to write third-party-installed.json ${err}`, + LogPrefix.Legendary + ) + } +} + +const removeInstalledGame = async (appName: string) => { + const installedAppNames = [] + try { + const buffer = await readFile(thirdPartyInstalled, 'utf-8') + installedAppNames.push(...(JSON.parse(buffer) as [string, string][])) + } catch (err) { + logWarning('Failed to read third-party-installed.json', LogPrefix.Legendary) + } + const index = installedAppNames.findIndex((a) => a[0] === appName) + installedAppNames.splice(index, 1) + + try { + await writeFile( + thirdPartyInstalled, + JSON.stringify(installedAppNames), + 'utf-8' + ) + } catch (e) { + logError('Failed to write third-party-installed.json', LogPrefix.Legendary) + } +} + +export default { getInstalledGames, addInstalledGame, removeInstalledGame } diff --git a/src/common/types.ts b/src/common/types.ts index 7134a2d705..6faef86a29 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -151,7 +151,8 @@ export interface GameInfo { gog_save_location?: GOGCloudSavesLocation[] title: string canRunOffline: boolean - thirdPartyManagedApp?: string | undefined + thirdPartyManagedApp?: string + isEAManaged?: boolean is_mac_native?: boolean is_linux_native?: boolean browserUrl?: string diff --git a/src/frontend/components/UI/Anticheat/index.scss b/src/frontend/components/UI/Anticheat/index.scss index 595bd39b24..3f1aac7b17 100644 --- a/src/frontend/components/UI/Anticheat/index.scss +++ b/src/frontend/components/UI/Anticheat/index.scss @@ -11,7 +11,7 @@ .statusInfo { flex-direction: column; display: flex; - margin-inline: auto; + margin-inline: var(--space-lg); text-align: start; } } diff --git a/src/frontend/components/UI/LibraryFilters/index.tsx b/src/frontend/components/UI/LibraryFilters/index.tsx index fde3303aa5..3e1bdcba48 100644 --- a/src/frontend/components/UI/LibraryFilters/index.tsx +++ b/src/frontend/components/UI/LibraryFilters/index.tsx @@ -30,7 +30,9 @@ export default function LibraryFilters() { platformsFilters, setPlatformsFilters, showSupportOfflineOnly, - setShowSupportOfflineOnly + setShowSupportOfflineOnly, + showThirdPartyManagedOnly, + setShowThirdPartyManagedOnly } = useContext(LibraryContext) const toggleShowHidden = () => { @@ -53,6 +55,10 @@ export default function LibraryFilters() { setShowSupportOfflineOnly(!showSupportOfflineOnly) } + const toggleThirdParty = () => { + setShowThirdPartyManagedOnly(!showThirdPartyManagedOnly) + } + const toggleStoreFilter = (store: Category) => { const currentValue = storesFilters[store] const newFilters = { ...storesFilters, [store]: !currentValue } @@ -209,6 +215,16 @@ export default function LibraryFilters() { 'Show offline-supported only' )} /> + toggleThirdParty()} + value={showThirdPartyManagedOnly} + title={t( + 'header.show_third_party_managed_only', + 'Show third-party managed only' + )} + />
{' '} - {!isSideloaded && ( + {!isSideloaded && !isThirdPartyManaged && ( )}{' '} - {!isSideloaded && ( + {!isSideloaded && !isThirdPartyManaged && ( )}{' '} - {!isSideloaded && ( + {!isSideloaded && !isThirdPartyManaged && ( )}{' '} - {!isSideloaded && ( + {!isSideloaded && !isThirdPartyManaged && ( + + + ) +} diff --git a/src/frontend/screens/Library/components/InstallModal/WineSelector/index.tsx b/src/frontend/screens/Library/components/InstallModal/WineSelector/index.tsx index 52873e96c6..e8fdf4ed18 100644 --- a/src/frontend/screens/Library/components/InstallModal/WineSelector/index.tsx +++ b/src/frontend/screens/Library/components/InstallModal/WineSelector/index.tsx @@ -23,6 +23,7 @@ type Props = { wineVersion: WineInstallation | undefined title?: string appName: string + initiallyOpen?: boolean } export default function WineSelector({ @@ -34,10 +35,12 @@ export default function WineSelector({ title = 'sideload', crossoverBottle, setCrossoverBottle, + initiallyOpen, appName }: Props) { const { t, i18n } = useTranslation('gamepage') + const [detailsOpen, setDetailsOpen] = React.useState(!!initiallyOpen) const [useDefaultSettings, setUseDefaultSettings] = React.useState(false) const [description, setDescription] = React.useState('') @@ -79,7 +82,7 @@ export default function WineSelector({ return ( <> -
+
setDetailsOpen(detailsOpen)}> {t('setting.show-wine-settings', 'Show Wine settings')} diff --git a/src/frontend/screens/Library/components/InstallModal/index.tsx b/src/frontend/screens/Library/components/InstallModal/index.tsx index 6074189f26..62ccee3f9e 100644 --- a/src/frontend/screens/Library/components/InstallModal/index.tsx +++ b/src/frontend/screens/Library/components/InstallModal/index.tsx @@ -19,6 +19,7 @@ import SideloadDialog from './SideloadDialog' import WineSelector from './WineSelector' import { SelectField } from 'frontend/components/UI' import { useTranslation } from 'react-i18next' +import ThirdPartyDialog from './ThirdPartyDialog' type Props = { appName: string @@ -152,6 +153,7 @@ export default React.memo(function InstallModal({ } const showDownloadDialog = !isSideload && gameInfo + const isThirdPartyManagedApp = gameInfo && !!gameInfo.thirdPartyManagedApp return (
@@ -160,7 +162,35 @@ export default React.memo(function InstallModal({ showCloseButton className="InstallModal__dialog" > - {showDownloadDialog ? ( + {isThirdPartyManagedApp ? ( + + {platformSelection()} + {hasWine ? ( + + ) : null} + + ) : showDownloadDialog ? ( { + storage.setItem('show_third_party_managed_only', JSON.stringify(value)) + setShowThirdPartyManagedOnly(value) + } + const [showCategories, setShowCategories] = useState(false) const [showModal, setShowModal] = useState({ @@ -436,6 +445,10 @@ export default React.memo(function Library(): JSX.Element { library = library.filter((game) => game.canRunOffline) } + if (showThirdPartyManagedOnly) { + library = library.filter((game) => !!game.thirdPartyManagedApp) + } + if (!showNonAvailable) { const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]' const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames) @@ -525,7 +538,8 @@ export default React.memo(function Library(): JSX.Element { showFavouritesLibrary, showInstalledOnly, showNonAvailable, - showSupportOfflineOnly + showSupportOfflineOnly, + showThirdPartyManagedOnly ]) // we need this to do proper `position: sticky` of the Add Game area @@ -597,6 +611,8 @@ export default React.memo(function Library(): JSX.Element { setSortInstalled: handleSortInstalled, showSupportOfflineOnly, setShowSupportOfflineOnly: handleShowSupportOfflineOnly, + showThirdPartyManagedOnly, + setShowThirdPartyManagedOnly: handleShowThirdPartyOnly, sortDescending, sortInstalled, handleAddGameButtonClick: () => handleModal('', 'sideload', null), diff --git a/src/frontend/types.ts b/src/frontend/types.ts index f3b83a4440..b80f2f8c28 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -231,6 +231,8 @@ export interface LibraryContextType { setSortInstalled: (value: boolean) => void showSupportOfflineOnly: boolean setShowSupportOfflineOnly: (value: boolean) => void + showThirdPartyManagedOnly: boolean + setShowThirdPartyManagedOnly: (value: boolean) => void handleAddGameButtonClick: () => void setShowCategories: (value: boolean) => void }