Skip to content

Commit

Permalink
[EPIC]: Support third-party managed applications (#3763)
Browse files Browse the repository at this point in the history
* feat: support Epic's third party managers

this allows for EA games to be installed with Heroic, the EA App will be downloaded and installed accordingly

* i18n: generate locale keys

* fix: match by lowercase app in downloadqueue

* improv: add GameInfo.isEAManaged boolean for better maintainability
  • Loading branch information
imLinguin authored Jun 9, 2024
1 parent 1711f06 commit 19ff381
Show file tree
Hide file tree
Showing 23 changed files with 539 additions and 64 deletions.
6 changes: 6 additions & 0 deletions public/locales/en/gamepage.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"size": "Size",
"steamdeck-compatibility-info": "SteamDeck Compatibility",
"syncsaves": "Sync Saves",
"third-party-app": "Third-Party Manager",
"version": "Version"
},
"install": {
Expand Down Expand Up @@ -273,5 +274,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"
}
}
1 change: 1 addition & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 8 additions & 1 deletion src/backend/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -279,10 +284,12 @@ export {
gogdlConfigPath,
gogSupportPath,
gogRedistPath,
epicRedistPath,
vulkanHelperBin,
nileConfigPath,
nileInstalled,
nileLibrary,
nileUserData,
fixesPath
fixesPath,
thirdPartyInstalled
}
55 changes: 33 additions & 22 deletions src/backend/downloadmanager/downloadqueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
86 changes: 80 additions & 6 deletions src/backend/storeManagers/legendary/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,24 @@ import {
} from './library'
import { LegendaryUser } from './user'
import {
downloadFile,
getLegendaryBin,
killPattern,
moveOnUnix,
moveOnWindows,
sendGameStatusUpdate,
sendProgressUpdate,
shutdownWine
shutdownWine,
spawnAsync
} from '../../utils'
import {
isMac,
isWindows,
installed,
configStore,
isCLINoGui,
isLinux
isLinux,
epicRedistPath
} from '../../constants'
import {
appendGamePlayLog,
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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'
})

Expand Down Expand Up @@ -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'
})

Expand Down Expand Up @@ -289,6 +302,7 @@ const emptyExtraInfo = {
export async function getExtraInfo(appName: string): Promise<ExtraInfo> {
const { namespace, title } = getGameInfo(appName)
if (namespace === undefined) return emptyExtraInfo

const cachedExtraInfo = gameInfoStore.get(namespace)
if (cachedExtraInfo) {
return cachedExtraInfo
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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<ExecResult> {
const gameInfo = getGameInfo(appName)
if (gameInfo.thirdPartyManagedApp) {
await thirdParty.removeInstalledGame(appName)
return { stdout: '', stderr: '' }
}

const command: LegendaryCommand = {
subcommand: 'uninstall',
appName: LegendaryAppName.parse(appName),
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 17 additions & 6 deletions src/backend/storeManagers/legendary/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set()
Expand Down Expand Up @@ -129,22 +130,29 @@ async function refreshLegendary(): Promise<ExecResult> {
*/
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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -867,7 +878,7 @@ export async function getLaunchOptions(
): Promise<LaunchOption[]> {
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
Expand Down
24 changes: 22 additions & 2 deletions src/backend/storeManagers/legendary/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit 19ff381

Please sign in to comment.