diff --git a/README.md b/README.md index 0cc78fd84e..e7da66273b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![kofi](https://img.shields.io/badge/Ko--Fi-Donate-orange?style=for-the-badge&logo=ko-fi)](https://ko-fi.com/heroicgames) Heroic is an Open Source Game Launcher for Linux, Windows and macOS. -Right now it supports launching games from the Epic Games Store using [Legendary](https://github.com/derrod/legendary) and GOG Games using our custom implementation with [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl). +Right now it supports launching games from the Epic Games Store using [Legendary](https://github.com/derrod/legendary), GOG Games using our custom implementation with [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) and Amazon Games using [Nile](https://github.com/imLinguin/nile). Heroic is built with Web Technologies: [![Typescript](https://img.shields.io/badge/Typescript-3178c6?style=for-the-badge&logo=typescript&labelColor=gray)](https://www.typescriptlang.org/) @@ -52,7 +52,7 @@ Heroic is built with Web Technologies: ## Features available right now -- Login with an existing Epic Games account or GOG account +- Login with an existing Epic Games, GOG or Amazon account - Install, uninstall, update, repair and move Games - Import an already installed game - Play Epic games online [AntiCheat on macOS and on Linux depends on the game] @@ -66,11 +66,11 @@ Heroic is built with Web Technologies: - Sync saves with the cloud - Custom Theming Support - Download queue -- Add Games and Applications outside GOG and Epic Games +- Add Games and Applications outside GOG, Epic Games and Amazon Games ## Planned features -- Support Other Store (Amazon Gaming, IndieGala, etc) +- Support Other Store (IndieGala, etc) - Play GOG games online ## Supported Operating Systems diff --git a/package.json b/package.json index 0405b61c07..039a1a4deb 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "icon": "build/win_icon.ico", "asarUnpack": [ "build/bin/win32/legendary.exe", - "build/bin/win32/gogdl.exe" + "build/bin/win32/gogdl.exe", + "build/bin/win32/nile.exe" ], "files": [ "build/bin/win32/*" @@ -69,7 +70,8 @@ }, "asarUnpack": [ "build/bin/darwin/legendary", - "build/bin/darwin/gogdl" + "build/bin/darwin/gogdl", + "build/bin/darwin/nile" ], "files": [ "build/bin/darwin/*" @@ -106,6 +108,7 @@ "asarUnpack": [ "build/bin/linux/legendary", "build/bin/linux/gogdl", + "build/bin/linux/nile", "build/bin/linux/vulkan-helper" ], "files": [ @@ -176,6 +179,7 @@ "i18next-fs-backend": "^2.1.1", "i18next-http-backend": "^2.1.1", "ini": "^3.0.0", + "json5": "^2.2.3", "plist": "^3.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/public/bin/darwin/nile b/public/bin/darwin/nile new file mode 100755 index 0000000000..5f32220c6e Binary files /dev/null and b/public/bin/darwin/nile differ diff --git a/public/bin/linux/nile b/public/bin/linux/nile new file mode 100755 index 0000000000..eec2cc5159 Binary files /dev/null and b/public/bin/linux/nile differ diff --git a/public/bin/win32/nile.exe b/public/bin/win32/nile.exe new file mode 100644 index 0000000000..40e49e2a02 Binary files /dev/null and b/public/bin/win32/nile.exe differ diff --git a/public/locales/en/gamepage.json b/public/locales/en/gamepage.json index 6a6974aaa6..66bfc102f6 100644 --- a/public/locales/en/gamepage.json +++ b/public/locales/en/gamepage.json @@ -152,6 +152,7 @@ "options": "Launch Options..." }, "not_logged_in": { + "amazon": "You are not logged in with an Amazon account in Heroic. Don't use the store page to login, click the following button instead:", "epic": "You are not logged in with an Epic account in Heroic. Don't use the store page to login, click the following button instead:", "gog": "You are not logged in with a GOG account in Heroic. Don't use the store page to login, click the following button instead:", "login": "Log in", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c41fa51813..aea3e2e14a 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -9,6 +9,8 @@ }, "add_game": "Add Game", "All": "All", + "amazon": "Amazon", + "Amazon Games": "Amazon Games", "anticheat": { "anticheats": "Anticheats", "may_not_work": "It may not work due to denied or broken anticheat support.", @@ -26,6 +28,7 @@ "choose-egs-prefix": "Choose Prefix where EGS is installed", "choose-gogdl-binary": "Select GOGDL Binary (needs restart)", "choose-legendary-binary": "Select Legendary binary", + "choose-nile-binary": "Select Nile binary", "customWine": "Select the Wine or Proton binary", "default-install-path": "Choose Default Install Path", "default-steam-path": "Steam path.", @@ -330,6 +333,7 @@ }, "login": { "alternative_method": "Alternative Login Method", + "amazon": "Amazon Login", "epic": "Epic Games Login", "gog": "GOG Login", "message": "Login with your platform. You can login to more than one platform at the same time." @@ -426,12 +430,14 @@ "other": { "gogdl-version": "GOGDL Version: ", "legendary-version": "Legendary Version: ", + "nile-version": "Nile Version: ", "weblate": "Help translate Heroic." }, "Other": "Other", "placeholder": { "alt-gogdl-bin": "Using built-in GOGDL binary...", "alt-legendary-bin": "Using built-in Legendary binary...", + "alt-nile-bin": "Using built-in Nile binary...", "custom_themes_path": "Select the path to look for custom CSS files", "dxvkfpsvalue": "Positive integer value (e.g. 30, 60, ...)", "egs-prefix": "Prefix where EGS is installed", @@ -444,6 +450,7 @@ "win": "Windows" }, "please-wait": "Please wait...", + "prime-gaming": "Prime Gaming", "progress": "Progress", "queue": { "label": { @@ -467,6 +474,7 @@ "addgamestosteam": "Add games to Steam automatically", "alt-gogdl-bin": "Choose an Alternative GOGDL Binary to use", "alt-legendary-bin": "Choose an Alternative Legendary Binary", + "alt-nile-bin": "Choose an Alternative Nile Binary", "autodxvk": "Auto Install/Update DXVK on Prefix", "autosync": "Autosync Saves", "autoUpdateGames": "Automatically update games", @@ -614,6 +622,7 @@ "status": { "installing": "Installing", "logging": "Logging In...", + "preparing_login": "Preparing Login... ", "processing": "Processing files, please wait" }, "store": "Epic Store", diff --git a/src/backend/api/helpers.ts b/src/backend/api/helpers.ts index 9885d4f882..431045b880 100644 --- a/src/backend/api/helpers.ts +++ b/src/backend/api/helpers.ts @@ -44,6 +44,9 @@ export const abort = (id: string) => ipcRenderer.send('abort', id) export const getUserInfo = async () => ipcRenderer.invoke('getUserInfo') +export const getAmazonUserInfo = async () => + ipcRenderer.invoke('getAmazonUserInfo') + export const syncSaves = async (args: { arg: string | undefined path: string diff --git a/src/backend/api/misc.ts b/src/backend/api/misc.ts index 2e33fc6930..57714fdaeb 100644 --- a/src/backend/api/misc.ts +++ b/src/backend/api/misc.ts @@ -7,6 +7,7 @@ import { ButtonOptions, GamepadActionArgs } from 'common/types' +import { NileRegisterData } from 'common/types/nile' export const clearCache = (showDialog?: boolean) => ipcRenderer.send('clearCache', showDialog) @@ -43,6 +44,11 @@ export const logoutLegendary = async () => ipcRenderer.invoke('logoutLegendary') export const authGOG = async (token: string) => ipcRenderer.invoke('authGOG', token) export const logoutGOG = () => ipcRenderer.send('logoutGOG') +export const getAmazonLoginData = async () => + ipcRenderer.invoke('getAmazonLoginData') +export const authAmazon = async (data: NileRegisterData) => + ipcRenderer.invoke('authAmazon', data) +export const logoutAmazon = async () => ipcRenderer.invoke('logoutAmazon') export const checkGameUpdates = async () => ipcRenderer.invoke('checkGameUpdates') export const refreshLibrary = async (library?: Runner | 'all') => diff --git a/src/backend/api/settings.ts b/src/backend/api/settings.ts index ad781f3be6..60770353fa 100644 --- a/src/backend/api/settings.ts +++ b/src/backend/api/settings.ts @@ -16,6 +16,7 @@ export const setSetting = (args: { export const getLegendaryVersion = async () => ipcRenderer.invoke('getLegendaryVersion') export const getGogdlVersion = async () => ipcRenderer.invoke('getGogdlVersion') +export const getNileVersion = async () => ipcRenderer.invoke('getNileVersion') export const getEosOverlayStatus = async () => ipcRenderer.invoke('getEosOverlayStatus') export const getLatestEosOverlayVersion = async () => diff --git a/src/backend/constants.ts b/src/backend/constants.ts index ccd9e7d831..2fd4e46c37 100644 --- a/src/backend/constants.ts +++ b/src/backend/constants.ts @@ -41,6 +41,7 @@ const userHome = homedir() const configFolder = app.getPath('appData') const appFolder = join(configFolder, 'heroic') const legendaryConfigPath = join(appFolder, 'legendaryConfig', 'legendary') +const nileConfigPath = join(appFolder, 'nile_config', 'nile') const configPath = join(appFolder, 'config.json') const gamesConfigPath = join(appFolder, 'GamesConfig') const toolsPath = join(appFolder, 'tools') @@ -58,8 +59,13 @@ const cachedUbisoftInstallerPath = join( 'UbisoftConnectInstaller.exe' ) -const { currentLogFile, lastLogFile, legendaryLogFile, gogdlLogFile } = - createNewLogFileAndClearOldOnes() +const { + currentLogFile, + lastLogFile, + legendaryLogFile, + gogdlLogFile, + nileLogFile +} = createNewLogFileAndClearOldOnes() const publicDir = resolve(__dirname, '..', app.isPackaged ? '' : '../public') const gogdlAuthConfig = join(app.getPath('userData'), 'gog_store', 'auth.json') @@ -71,6 +77,9 @@ const iconDark = fixAsarPath(join(publicDir, 'icon-dark.png')) const iconLight = fixAsarPath(join(publicDir, 'icon-light.png')) const installed = join(legendaryConfigPath, 'installed.json') const legendaryMetadata = join(legendaryConfigPath, 'metadata') +const nileInstalled = join(nileConfigPath, 'installed.json') +const nileLibrary = join(nileConfigPath, 'library.json') +const nileUserData = join(nileConfigPath, 'user.json') const fallBackImage = 'fallback' const epicLoginUrl = 'https://legendary.gl/epiclogin' const sidInfoUrl = @@ -205,6 +214,7 @@ export { lastLogFile, legendaryLogFile, gogdlLogFile, + nileLogFile, discordLink, execOptions, fixAsarPath, @@ -252,5 +262,9 @@ export { customThemesWikiLink, cachedUbisoftInstallerPath, gogdlAuthConfig, - vulkanHelperBin + vulkanHelperBin, + nileConfigPath, + nileInstalled, + nileLibrary, + nileUserData } diff --git a/src/backend/launcher.ts b/src/backend/launcher.ts index 7764339cb7..4848f77f1c 100644 --- a/src/backend/launcher.ts +++ b/src/backend/launcher.ts @@ -39,6 +39,7 @@ import { GlobalConfig } from './config' import { GameConfig } from './game_config' import { DXVK } from './tools' import setup from './storeManagers/gog/setup' +import nileSetup from './storeManagers/nile/setup' import { CallRunnerOptions, GameInfo, @@ -217,6 +218,9 @@ async function prepareWineLaunch( if (runner === 'gog') { await setup(appName) } + if (runner === 'nile') { + await nileSetup(appName) + } if (runner === 'legendary') { await setupUbisoftConnect(appName) } diff --git a/src/backend/logger/__tests__/logfile.test.ts b/src/backend/logger/__tests__/logfile.test.ts index be8b69079a..dbef7d4fdc 100644 --- a/src/backend/logger/__tests__/logfile.test.ts +++ b/src/backend/logger/__tests__/logfile.test.ts @@ -53,7 +53,8 @@ describeSkipOnWindows('logger/logfile.ts', () => { currentLogFile: 'old/log/path/file.log', lastLogFile: '', legendaryLogFile: '', - gogdlLogFile: '' + gogdlLogFile: '', + nileLogFile: '' }) const data = logfile.createNewLogFileAndClearOldOnes() @@ -63,7 +64,8 @@ describeSkipOnWindows('logger/logfile.ts', () => { currentLogFile: expect.any(String), lastLogFile: 'old/log/path/file.log', legendaryLogFile: expect.any(String), - gogdlLogFile: expect.any(String) + gogdlLogFile: expect.any(String), + nileLogFile: expect.any(String) }) }) diff --git a/src/backend/logger/logfile.ts b/src/backend/logger/logfile.ts index eb90ba5466..5a88deb006 100644 --- a/src/backend/logger/logfile.ts +++ b/src/backend/logger/logfile.ts @@ -21,6 +21,7 @@ interface createLogFileReturn { lastLogFile: string legendaryLogFile: string gogdlLogFile: string + nileLogFile: string } let longestPrefix = 0 @@ -49,10 +50,12 @@ export function createNewLogFileAndClearOldOnes(): createLogFileReturn { const newLogFile = join(logDir, `heroic-${fmtDate}.log`) const newLegendaryLogFile = join(logDir, `legendary-${fmtDate}.log`) const newGogdlLogFile = join(logDir, `gogdl-${fmtDate}.log`) + const newNileLogFile = join(logDir, `nile-${fmtDate}.log`) createLogFile(newLogFile) createLogFile(newLegendaryLogFile) createLogFile(newGogdlLogFile) + createLogFile(newNileLogFile) // Clean out logs that are more than a month old if (existsSync(logDir)) { @@ -67,9 +70,9 @@ export function createNewLogFileAndClearOldOnes(): createLogFileReturn { .map((dirent) => dirent.name) logs.forEach((log) => { - if (log.match(/(heroic|legendary|gogdl)-/)) { + if (log.match(/(heroic|legendary|gogdl|nile)-/)) { const dateString = log - .replace(/(heroic|legendary|gogdl)-/, '') + .replace(/(heroic|legendary|gogdl|nile)-/, '') .replace('.log', '') .replaceAll('_', ':') const logDate = new Date(dateString) @@ -90,13 +93,15 @@ export function createNewLogFileAndClearOldOnes(): createLogFileReturn { currentLogFile: '', lastLogFile: '', legendaryLogFile: '', - gogdlLogFile: '' + gogdlLogFile: '', + nileLogFile: '' }) logs.lastLogFile = logs.currentLogFile logs.currentLogFile = newLogFile logs.legendaryLogFile = newLegendaryLogFile logs.gogdlLogFile = newGogdlLogFile + logs.nileLogFile = newNileLogFile configStore.set('general-logs', logs) diff --git a/src/backend/logger/logger.ts b/src/backend/logger/logger.ts index 98c0b0b900..56f39c5d1b 100644 --- a/src/backend/logger/logger.ts +++ b/src/backend/logger/logger.ts @@ -16,6 +16,7 @@ export enum LogPrefix { General = '', Legendary = 'Legendary', Gog = 'Gog', + Nile = 'Nile', WineDownloader = 'WineDownloader', DXVKInstaller = 'DXVKInstaller', GlobalConfig = 'GlobalConfig', diff --git a/src/backend/main.ts b/src/backend/main.ts index 08c139fcbd..cc82451abc 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -48,7 +48,9 @@ import { GameConfig } from './game_config' import { GlobalConfig } from './config' import { LegendaryUser } from 'backend/storeManagers/legendary/user' import { GOGUser } from './storeManagers/gog/user' +import { NileUser } from './storeManagers/nile/user' import setup from './storeManagers/gog/setup' +import nileSetup from './storeManagers/nile/setup' import { clearCache, execAsync, @@ -70,7 +72,8 @@ import { getCurrentChangelog, checkWineBeforeLaunch, removeFolder, - downloadDefaultWine + downloadDefaultWine, + getNileVersion } from './utils' import { configStore, @@ -643,6 +646,7 @@ ipcMain.handle('getMaxCpus', () => cpus().length) ipcMain.handle('getHeroicVersion', app.getVersion) ipcMain.handle('getLegendaryVersion', getLegendaryVersion) ipcMain.handle('getGogdlVersion', getGogdlVersion) +ipcMain.handle('getNileVersion', getNileVersion) ipcMain.handle('isFullscreen', () => isSteamDeckGameMode || isCLIFullscreen) ipcMain.handle('isFlatpak', () => isFlatpak) ipcMain.handle('getGameOverride', async () => getGameOverride()) @@ -752,6 +756,8 @@ ipcMain.handle('getUserInfo', async () => { return LegendaryUser.getUserInfo() }) +ipcMain.handle('getAmazonUserInfo', async () => NileUser.getUserData()) + // Checks if the user have logged in with Legendary already ipcMain.handle('isLoggedIn', LegendaryUser.isLoggedIn) @@ -763,6 +769,10 @@ ipcMain.handle('getLocalPeloadPath', async () => { return fixAsarPath(join('file://', publicDir, 'webviewPreload.js')) }) +ipcMain.handle('getAmazonLoginData', NileUser.getLoginData) +ipcMain.handle('authAmazon', async (event, data) => NileUser.login(data)) +ipcMain.handle('logoutAmazon', NileUser.logout) + ipcMain.handle('getAlternativeWine', async () => GlobalConfig.get().getAlternativeWine() ) @@ -1585,6 +1595,9 @@ ipcMain.handle( if (runner === 'gog' && updated) { await setup(appName) } + if (runner === 'nile' && updated) { + await nileSetup(appName) + } if (runner === 'legendary' && updated) { await setupUbisoftConnect(appName) } diff --git a/src/backend/protocol.ts b/src/backend/protocol.ts index a566118574..1a180fd2f2 100644 --- a/src/backend/protocol.ts +++ b/src/backend/protocol.ts @@ -8,7 +8,7 @@ import { icon } from './constants' type Command = 'ping' | 'launch' -const RUNNERS = ['legendary', 'gog', 'sideload'] +const RUNNERS = ['legendary', 'gog', 'nile', 'sideload'] /** * Handles a protocol request @@ -98,6 +98,8 @@ async function handlePing(arg: string) { * // => 'Received launch! Runner: gog, Arg: 123' * handleLaunch('legendary', '123') * // => 'Received launch! Runner: legendary, Arg: 123' + * handleLaunch('nile', '123') + * // => 'Received launch! Runner: nile, Arg: 123' **/ async function handleLaunch( runner: Runner | undefined, @@ -157,6 +159,8 @@ async function handleLaunch( * // => { app_name: '123', title: '123', is_installed: true, runner: 'gog' ...} * findGame('legendary', '123') * // => { app_name: '123', title: '123', is_installed: true, runner: 'legendary' ...} + * findGame('nile', '123') + * // => { app_name: '123', title: '123', is_installed: true, runner: 'nile' ...} **/ async function findGame( runner: Runner | undefined, diff --git a/src/backend/save_sync.ts b/src/backend/save_sync.ts index 1f9d571758..91abda406d 100644 --- a/src/backend/save_sync.ts +++ b/src/backend/save_sync.ts @@ -36,6 +36,8 @@ async function getDefaultSavePath( return getDefaultLegendarySavePath(appName) case 'gog': return getDefaultGogSavePaths(appName, alreadyDefinedGogSaves) + case 'nile': + return '' case 'sideload': return '' } diff --git a/src/backend/storeManagers/index.ts b/src/backend/storeManagers/index.ts index ab0272f8fb..02cfd352f2 100644 --- a/src/backend/storeManagers/index.ts +++ b/src/backend/storeManagers/index.ts @@ -1,10 +1,12 @@ 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 NileGameManager from 'backend/storeManagers/nile/games' 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 NileLibraryManager from 'backend/storeManagers/nile/library' import { GameManager, LibraryManager } from 'common/types/game_manager' import { logInfo, RunnerToLogPrefixMap } from 'backend/logger/logger' @@ -18,7 +20,8 @@ interface GameManagerMap { export const gameManagerMap: GameManagerMap = { sideload: SideloadGameManager, gog: GOGGameManager, - legendary: LegendaryGameManager + legendary: LegendaryGameManager, + nile: NileGameManager } interface LibraryManagerMap { @@ -28,7 +31,8 @@ interface LibraryManagerMap { export const libraryManagerMap: LibraryManagerMap = { sideload: SideloadLibraryManager, gog: GOGLibraryManager, - legendary: LegendaryLibraryManager + legendary: LegendaryLibraryManager, + nile: NileLibraryManager } function getDMElement(gameInfo: GameInfo, appName: string) { @@ -75,4 +79,5 @@ export function autoUpdate(runner: string, gamesToUpdate: string[]) { export async function initStoreManagers() { await LegendaryLibraryManager.initLegendaryLibraryManager() await GOGLibraryManager.refresh() + await NileLibraryManager.initNileLibraryManager() } diff --git a/src/backend/storeManagers/nile/electronStores.ts b/src/backend/storeManagers/nile/electronStores.ts new file mode 100644 index 0000000000..e85703f601 --- /dev/null +++ b/src/backend/storeManagers/nile/electronStores.ts @@ -0,0 +1,14 @@ +import CacheStore from 'backend/cache' +import { TypeCheckedStoreBackend } from 'backend/electron_store' +import { GameInfo } from 'common/types' +import { NileInstallInfo } from 'common/types/nile' + +export const installStore = new CacheStore('nile_install_info') +export const libraryStore = new CacheStore( + 'nile_library', + null +) + +export const configStore = new TypeCheckedStoreBackend('nileConfigStore', { + cwd: 'nile_store' +}) diff --git a/src/backend/storeManagers/nile/games.ts b/src/backend/storeManagers/nile/games.ts new file mode 100644 index 0000000000..044562b7ab --- /dev/null +++ b/src/backend/storeManagers/nile/games.ts @@ -0,0 +1,591 @@ +import { + ExecResult, + ExtraInfo, + GameInfo, + GameSettings, + InstallArgs, + InstallPlatform, + InstallProgress +} from 'common/types' +import { InstallResult, RemoveArgs } from 'common/types/game_manager' +import { + runRunnerCommand as runNileCommand, + getGameInfo as nileLibraryGetGameInfo, + changeGameInstallPath, + installState, + removeFromInstalledConfig, + getInstallMetadata +} from './library' +import { + LogPrefix, + logDebug, + logError, + logInfo, + logsDisabled +} from 'backend/logger/logger' +import { gamesConfigPath, isWindows } from 'backend/constants' +import { GameConfig } from 'backend/game_config' +import { + createAbortController, + deleteAbortController +} from 'backend/utils/aborthandler/aborthandler' +import { + getRunnerCallWithoutCredentials, + launchCleanup, + prepareLaunch, + prepareWineLaunch, + setupEnvVars, + setupWrappers +} from 'backend/launcher' +import { appendFileSync, existsSync } from 'graceful-fs' +import { logFileLocation } from 'backend/storeManagers/storeManagerCommon/games' +import { showDialogBoxModalAuto } from 'backend/dialog/dialog' +import { t } from 'i18next' +import { getWineFlags } from 'backend/utils/compatibility_layers' +import shlex from 'shlex' +import { join } from 'path' +import { + getNileBin, + killPattern, + moveOnUnix, + moveOnWindows, + shutdownWine +} from 'backend/utils' +import { GlobalConfig } from 'backend/config' +import { + addShortcuts as addShortcutsUtil, + removeShortcuts as removeShortcutsUtil +} from '../../shortcuts/shortcuts/shortcuts' +import { removeNonSteamGame } from 'backend/shortcuts/nonesteamgame/nonesteamgame' +import { sendFrontendMessage } from 'backend/main_window' +import setup from './setup' + +export async function getSettings(appName: string): Promise { + const gameConfig = GameConfig.get(appName) + return gameConfig.config || (await gameConfig.getSettings()) +} + +export function getGameInfo(appName: string): GameInfo { + const info = nileLibraryGetGameInfo(appName) + if (!info) { + logError( + [ + 'Could not get game info for', + `${appName},`, + 'returning empty object. Something is probably gonna go wrong soon' + ], + LogPrefix.Nile + ) + return { + app_name: '', + runner: 'nile', + art_cover: '', + art_square: '', + install: {}, + is_installed: false, + title: '', + canRunOffline: false + } + } + return info +} + +export async function getExtraInfo(appName: string): Promise { + const info = nileLibraryGetGameInfo(appName) + return { + reqs: [], + about: info?.description + ? { + description: info.description, + shortDescription: info.description + } + : undefined + } +} + +export async function importGame( + appName: string, + folderPath: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + platform: InstallPlatform +): Promise { + const logPath = join(gamesConfigPath, `${appName}.log`) + const res = await runNileCommand( + ['import', '--path', folderPath, appName], + createAbortController(appName), + { + logFile: logPath, + logMessagePrefix: `Importing ${appName}` + } + ) + deleteAbortController(appName) + + if (res.abort) { + return res + } + + if (res.error) { + logError(['Failed to import', `${appName}:`, res.error], LogPrefix.Nile) + return res + } + + const errorMatch = res.stderr.match(/ERROR \[IMPORT]:\t(.*)/) + if (errorMatch) { + logError(['Failed to import', `${appName}:`, errorMatch[1]], LogPrefix.Nile) + return { + ...res, + error: errorMatch[1] + } + } + + try { + addShortcuts(appName) + installState(appName, true) + } catch (error) { + logError(['Failed to import', `${appName}:`, error], LogPrefix.Nile) + } + + return res +} + +interface tmpProgressMap { + [key: string]: InstallProgress +} + +function defaultTmpProgress() { + return { + bytes: '', + eta: '', + percent: undefined, + diskSpeed: undefined, + downSpeed: undefined + } +} +const tmpProgress: tmpProgressMap = {} + +export function onInstallOrUpdateOutput( + appName: string, + action: 'installing' | 'updating', + data: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + totalDownloadSize = -1 +) { + if (!Object.hasOwn(tmpProgress, appName)) { + tmpProgress[appName] = defaultTmpProgress() + } + const progress = tmpProgress[appName] + + // parse log for percent + if (!progress.percent) { + const percentMatch = data.match(/Progress: (\d+\.\d+) /m) + + progress.percent = !Number.isNaN(Number(percentMatch?.at(1))) + ? Number(percentMatch?.at(1)) + : undefined + } + + // parse log for eta + if (progress.eta === '') { + const etaMatch = data.match(/ETA: (\d\d:\d\d:\d\d)/m) + progress.eta = etaMatch && etaMatch?.length >= 2 ? etaMatch[1] : '' + } + + // parse log for game download progress + if (progress.bytes === '') { + const bytesMatch = data.match(/Downloaded: (\S+) MiB/m) + progress.bytes = + bytesMatch && bytesMatch?.length >= 2 ? `${bytesMatch[1]}MB` : '' + } + + // parse log for download speed + if (!progress.downSpeed) { + const downSpeedMBytes = data.match(/Download\t- (\S+.) MiB/m) + progress.downSpeed = !Number.isNaN(Number(downSpeedMBytes?.at(1))) + ? Number(downSpeedMBytes?.at(1)) + : undefined + } + + // parse disk write speed + if (!progress.diskSpeed) { + const diskSpeedMBytes = data.match(/Disk\t- (\S+.) MiB/m) + progress.diskSpeed = !Number.isNaN(Number(diskSpeedMBytes?.at(1))) + ? Number(diskSpeedMBytes?.at(1)) + : undefined + } + + // only send to frontend if all values are updated + if ( + Object.values(progress).every( + (value) => !(value === undefined || value === '') + ) + ) { + logInfo( + [ + `Progress for ${getGameInfo(appName).title}:`, + `${progress.percent}%/${progress.bytes}/${progress.eta}`.trim(), + `Down: ${progress.downSpeed}MB/s / Disk: ${progress.diskSpeed}MB/s` + ], + LogPrefix.Nile + ) + + sendFrontendMessage(`progressUpdate-${appName}`, { + appName, + runner: 'nile', + status: action, + progress + }) + + // reset + tmpProgress[appName] = defaultTmpProgress() + } +} + +export async function install( + appName: string, + { path }: InstallArgs +): Promise { + const { maxWorkers } = GlobalConfig.get().getSettings() + const workers = maxWorkers ? ['--max-workers', `${maxWorkers}`] : [] + + const logPath = join(gamesConfigPath, `${appName}.log`) + + const commandParts = ['install', '--base-path', path, ...workers, appName] + + const onOutput = (data: string) => { + onInstallOrUpdateOutput(appName, 'installing', data) + } + + const res = await runNileCommand( + commandParts, + createAbortController(appName), + { + logFile: logPath, + onOutput, + logMessagePrefix: `Installing ${appName}` + } + ) + + deleteAbortController(appName) + + if (res.abort) { + return { status: 'abort' } + } + + if (res.error) { + if (!res.error.includes('signal')) { + logError(['Failed to install', appName, res.error], LogPrefix.Nile) + } + return { status: 'error', error: res.error } + } + addShortcuts(appName) + installState(appName, true) + const metadata = getInstallMetadata(appName) + await setup(appName, metadata?.path) + + return { status: 'done' } +} + +export function isNative(): boolean { + return isWindows +} + +/** + * Adds a desktop shortcut to $HOME/Desktop and to /usr/share/applications + * so that the game can be opened from the start menu and the desktop folder. + * Both can be disabled with addDesktopShortcuts and addStartMenuShortcuts + * @async + * @public + */ +export async function addShortcuts(appName: string, fromMenu?: boolean) { + return addShortcutsUtil(getGameInfo(appName), fromMenu) +} + +/** + * Removes a desktop shortcut from $HOME/Desktop and to $HOME/.local/share/applications + * @async + * @public + */ +export async function removeShortcuts(appName: string) { + return removeShortcutsUtil(getGameInfo(appName)) +} + +export async function launch( + appName: string, + launchArguments?: string +): Promise { + const gameSettings = await getSettings(appName) + const gameInfo = getGameInfo(appName) + + const { + success: launchPrepSuccess, + failureReason: launchPrepFailReason, + rpcClient, + mangoHudCommand, + gameModeBin, + steamRuntime + } = await prepareLaunch(gameSettings, gameInfo, isNative()) + + if (!launchPrepSuccess) { + appendFileSync( + logFileLocation(appName), + `Launch aborted: ${launchPrepFailReason}` + ) + showDialogBoxModalAuto({ + title: t('box.error.launchAborted', 'Launch aborted'), + message: launchPrepFailReason!, + type: 'ERROR' + }) + return false + } + const exeOverrideFlag = gameSettings.targetExe + ? ['--override-exe', gameSettings.targetExe] + : [] + + let commandEnv = isWindows + ? process.env + : { ...process.env, ...setupEnvVars(gameSettings) } + let wineFlag: string[] = [] + + if (!isNative()) { + // -> We're using Wine/Proton on Linux or CX on Mac + const { + success: wineLaunchPrepSuccess, + failureReason: wineLaunchPrepFailReason, + envVars: wineEnvVars + } = await prepareWineLaunch('nile', appName) + if (!wineLaunchPrepSuccess) { + appendFileSync( + logFileLocation(appName), + `Launch aborted: ${wineLaunchPrepFailReason}` + ) + if (wineLaunchPrepFailReason) { + showDialogBoxModalAuto({ + title: t('box.error.launchAborted', 'Launch aborted'), + message: wineLaunchPrepFailReason, + type: 'ERROR' + }) + } + return false + } + + commandEnv = { + ...commandEnv, + ...wineEnvVars + } + + const { bin: wineExec, type: wineType } = gameSettings.wineVersion + + // Fix for people with old config + const wineBin = + wineExec.startsWith("'") && wineExec.endsWith("'") + ? wineExec.replaceAll("'", '') + : wineExec + + wineFlag = [ + ...getWineFlags(wineBin, gameSettings, wineType), + '--wine-prefix', + gameSettings.winePrefix + ] + } + + const commandParts = [ + 'launch', + ...exeOverrideFlag, // Check if this works + ...wineFlag, + ...shlex.split(launchArguments ?? ''), + ...shlex.split(gameSettings.launcherArgs ?? ''), + appName + ] + const wrappers = setupWrappers( + gameSettings, + mangoHudCommand, + gameModeBin, + steamRuntime?.length ? [...steamRuntime] : undefined + ) + + const fullCommand = getRunnerCallWithoutCredentials( + commandParts, + commandEnv, + wrappers, + join(...Object.values(getNileBin())) + ) + appendFileSync( + logFileLocation(appName), + `Launch Command: ${fullCommand}\n\nGame Log:\n` + ) + + const { error } = await runNileCommand( + commandParts, + createAbortController(appName), + { + env: commandEnv, + wrappers, + logMessagePrefix: `Launching ${gameInfo.title}`, + onOutput(output) { + if (!logsDisabled) appendFileSync(logFileLocation(appName), output) + } + } + ) + + deleteAbortController(appName) + + if (error) { + logError(['Error launching game:', error], LogPrefix.Nile) + } + + launchCleanup(rpcClient) + + return !error +} + +export async function moveInstall( + appName: string, + newInstallPath: string +): Promise { + const gameInfo = getGameInfo(appName) + logInfo(`Moving ${gameInfo.title} to ${newInstallPath}`, LogPrefix.Nile) + + const moveImpl = isWindows ? moveOnWindows : moveOnUnix + const moveResult = await moveImpl(newInstallPath, gameInfo) + + if (moveResult.status === 'error') { + const { error } = moveResult + logError( + ['Error moving', gameInfo.title, 'to', newInstallPath, error], + LogPrefix.Nile + ) + return { status: 'error', error } + } + + await changeGameInstallPath(appName, moveResult.installPath) + return { status: 'done' } +} + +export async function repair(appName: string): Promise { + const installInfo = getGameInfo(appName) + const { install_path } = installInfo.install ?? {} + + if (!install_path) { + const error = `Could not find install path for ${appName}` + logError(error, LogPrefix.Nile) + return { + stderr: '', + stdout: '', + error + } + } + + logDebug([appName, 'is installed at', install_path], LogPrefix.Nile) + const logPath = join(gamesConfigPath, `${appName}.log`) + const res = await runNileCommand( + ['verify', '--path', install_path, appName], + createAbortController(appName), + { + logFile: logPath, + logMessagePrefix: `Repairing ${appName}` + } + ) + deleteAbortController(appName) + + if (res.error) { + logError(['Failed to repair', `${appName}:`, res.error], LogPrefix.Nile) + } + + return res +} + +export async function syncSaves(): Promise { + // Amazon Games doesn't support cloud saves + return '' +} + +export async function uninstall({ appName }: RemoveArgs): Promise { + const commandParts = ['uninstall', appName] + + const res = await runNileCommand( + commandParts, + createAbortController(appName), + { + logMessagePrefix: `Uninstalling ${appName}` + } + ) + deleteAbortController(appName) + + if (res.error) { + logError(['Failed to uninstall', `${appName}:`, res.error], LogPrefix.Nile) + } else if (!res.abort) { + const gameInfo = getGameInfo(appName) + await removeShortcutsUtil(gameInfo) + await removeNonSteamGame({ gameInfo }) + installState(appName, false) + } + sendFrontendMessage('refreshLibrary', 'nile') + return res +} + +export async function update(appName: string): Promise { + const { maxWorkers } = GlobalConfig.get().getSettings() + const workers = maxWorkers ? ['--max-workers', `${maxWorkers}`] : [] + + const logPath = join(gamesConfigPath, `${appName}.log`) + + const commandParts = ['update', ...workers, appName] + + const onOutput = (data: string) => { + onInstallOrUpdateOutput(appName, 'updating', data) + } + + const res = await runNileCommand( + commandParts, + createAbortController(appName), + { + logFile: logPath, + onOutput, + logMessagePrefix: `Updating ${appName}` + } + ) + + deleteAbortController(appName) + + if (res.abort) { + return { status: 'abort' } + } + + if (res.error) { + if (!res.error.includes('signal')) { + logError(['Failed to update', appName, res.error], LogPrefix.Nile) + } + return { status: 'error', error: res.error } + } + + sendFrontendMessage('gameStatusUpdate', { + appName, + runner: 'nile', + status: 'done' + }) + + return { status: 'done' } +} + +export async function forceUninstall(appName: string) { + removeFromInstalledConfig(appName) +} + +export async function stop(appName: string, stopWine = true) { + const pattern = process.platform === 'linux' ? appName : 'nile' + killPattern(pattern) + + if (stopWine && !isNative()) { + const gameSettings = await getSettings(appName) + await shutdownWine(gameSettings) + } +} + +export function isGameAvailable(appName: string): boolean { + const info = getGameInfo(appName) + return Boolean( + info?.is_installed && + info.install.install_path && + existsSync(info.install.install_path) + ) +} diff --git a/src/backend/storeManagers/nile/library.ts b/src/backend/storeManagers/nile/library.ts new file mode 100644 index 0000000000..2267d86d58 --- /dev/null +++ b/src/backend/storeManagers/nile/library.ts @@ -0,0 +1,467 @@ +import JSON5 from 'json5' +import { + nileConfigPath, + nileInstalled, + nileLibrary, + nileLogFile +} from 'backend/constants' +import { + LogPrefix, + logDebug, + logError, + logInfo, + logWarning +} from 'backend/logger/logger' +import { + createAbortController, + deleteAbortController +} from 'backend/utils/aborthandler/aborthandler' +import { CallRunnerOptions, ExecResult, GameInfo } from 'common/types' +import { + FuelSchema, + NileGameDownloadInfo, + NileGameInfo, + NileInstallInfo, + NileInstallMetadataInfo +} from 'common/types/nile' +import { existsSync, readFileSync, writeFileSync } from 'graceful-fs' +import { installStore, libraryStore } from './electronStores' +import { getNileBin } from 'backend/utils' +import { callRunner } from 'backend/launcher' +import { dirname, join } from 'path' +import { app } from 'electron' +import { copySync } from 'fs-extra' +import { NileUser } from './user' + +const installedGames: Map = new Map() +const library: Map = new Map() + +export async function initNileLibraryManager() { + // Migrate user data from global Nile config if necessary + const globalNileConfig = join(app.getPath('appData'), 'nile') + if (!existsSync(nileConfigPath) && existsSync(globalNileConfig)) { + copySync(globalNileConfig, nileConfigPath) + await NileUser.getUserData() + } + + refresh() +} + +/** + * Loads all the user's games into `library` + */ +function loadGamesInAccount() { + if (!existsSync(nileLibrary)) { + return + } + const libraryJSON: NileGameInfo[] = JSON.parse( + readFileSync(nileLibrary, 'utf-8') + ) + libraryJSON.forEach((game) => { + const { product } = game + const { title, productDetail } = product + const { + details: { shortDescription, developer }, + iconUrl + } = productDetail + + const info = installedGames.get(product.id) + + library.set(product.id, { + app_name: product.id, + art_cover: iconUrl, + art_square: iconUrl, + canRunOffline: true, // Amazon Games only has offline games + install: info + ? { + install_path: info.path, + version: info.version, + platform: 'Windows' // Amazon Games only supports Windows + } + : {}, + folder_name: title, + is_installed: info !== undefined, + runner: 'nile', + title: title ?? '???', + description: shortDescription, + developer, + is_linux_native: false, + is_mac_native: false + }) + }) +} + +/** + * Removes a game entry directly from Niles' installed.json config file + * + * @param appName The id of the app entry to remove + */ +export function removeFromInstalledConfig(appName: string) { + installedGames.clear() + if (existsSync(nileInstalled)) { + try { + const installed: NileInstallMetadataInfo[] = JSON.parse( + readFileSync(nileInstalled, 'utf-8') + ) + const newInstalled = installed.filter((game) => game.id !== appName) + writeFileSync(nileInstalled, JSON.stringify(newInstalled), 'utf-8') + } catch (error) { + logError( + ['Corrupted installed.json file, cannot load installed games', error], + LogPrefix.Nile + ) + } + } +} + +/** + * Fetches and parses the game's `fuel.json` file + */ +export function fetchFuelJSON( + appName: string, + installedPath?: string +): FuelSchema | null { + const game = getGameInfo(appName) + const basePath = installedPath ?? game?.install.install_path + if (!basePath) { + logError(['Could not find install path for', appName], LogPrefix.Nile) + return null + } + + const fuelJSONPath = join(basePath, 'fuel.json') + logDebug(['fuel.json path:', fuelJSONPath], LogPrefix.Nile) + + if (!existsSync(fuelJSONPath)) { + return null + } + + try { + return JSON5.parse(readFileSync(fuelJSONPath, 'utf-8')) + } catch (error) { + logError(['Could not read', `${fuelJSONPath}:`, error], LogPrefix.Nile) + } + + return null +} + +/** + * Obtain a list of updateable games. + * + * @returns App names of updateable games. + */ +export async function listUpdateableGames(): Promise { + logInfo('Looking for updates...', LogPrefix.Nile) + + const abortID = 'nile-list-updates' + const { stdout: output } = await runRunnerCommand( + ['list-updates', '--json'], + createAbortController(abortID) + ) + deleteAbortController(abortID) + + const updates: string[] = JSON.parse(output) + if (updates.length) { + logInfo(['Found', `${updates.length}`, 'games to update'], LogPrefix.Nile) + } + return updates +} + +/** + * Refresh games in the user's library + */ +async function refreshNile(): Promise { + logInfo('Refreshing Amazon Games...', LogPrefix.Nile) + + const abortID = 'nile-refresh' + const res = await runRunnerCommand( + ['library', 'sync'], + createAbortController(abortID) + ) + + deleteAbortController(abortID) + + if (res.error) { + logError(['Failed to refresh library:', res.error], LogPrefix.Nile) + } + return { + stderr: '', + stdout: '' + } +} + +export function getInstallMetadata( + appName: string +): NileInstallMetadataInfo | undefined { + if (!existsSync(nileInstalled)) { + return + } + + try { + const installed: NileInstallMetadataInfo[] = JSON.parse( + readFileSync(nileInstalled, 'utf-8') + ) + return installed.find((game) => game.id === appName) + } catch (error) { + logError( + ['Corrupted installed.json file, cannot load installed games', error], + LogPrefix.Nile + ) + } + return +} + +/** + * Refresh `installedGames` from file. + */ +export function refreshInstalled() { + installedGames.clear() + if (existsSync(nileInstalled)) { + try { + const installed: NileInstallMetadataInfo[] = JSON.parse( + readFileSync(nileInstalled, 'utf-8') + ) + installed.forEach((metadata) => { + installedGames.set(metadata.id, metadata) + }) + } catch (error) { + logError( + ['Corrupted installed.json file, cannot load installed games', error], + LogPrefix.Nile + ) + } + } +} + +/** + * Get the game info of all games in the library + * + * @returns Array of objects. + */ +export async function refresh(): Promise { + logInfo('Refreshing library...', LogPrefix.Nile) + + refreshNile() + refreshInstalled() + loadGamesInAccount() + + const arr = Array.from(library.values()) + libraryStore.set('library', arr) + logInfo(['Game list updated, got', `${arr.length}`, 'games'], LogPrefix.Nile) + + return { + stderr: '', + stdout: '' + } +} + +/** + * Get game info for a particular game. + * + * @param appName The AppName of the game you want the info of + * @param forceReload Discards game info in `library` and always reads info from metadata files + * @returns GameInfo + */ +export function getGameInfo( + appName: string, + forceReload = false +): GameInfo | undefined { + if (!forceReload) { + const gameInMemory = library.get(appName) + if (gameInMemory) { + return gameInMemory + } + } + + logInfo(['Loading', appName, 'from metadata files'], LogPrefix.Nile) + refreshInstalled() + loadGamesInAccount() + + const game = library.get(appName) + if (!game) { + logError( + ['Could not find game with id', appName, `in user's library`], + LogPrefix.Nile + ) + return + } + return game +} + +/** + * Get game info for a particular game. + */ +export async function getInstallInfo( + appName: string +): Promise { + const cache = installStore.get(appName) + if (cache) { + logDebug('Using cached install info', LogPrefix.Nile) + return cache + } + + logInfo('Getting more details', LogPrefix.Nile) + refreshInstalled() + + const game = library.get(appName) + if (game) { + const metadata = installedGames.get(appName) + // Get size info from Nile + const { stdout: output } = await runRunnerCommand( + ['install', '--info', '--json', appName], + createAbortController(appName) + ) + deleteAbortController(appName) + + const { download_size }: NileGameDownloadInfo = JSON.parse(output) + const installInfo = { + game: { + id: appName, + path: '', + version: '', + launch_options: [], + owned_dlc: [], + app_name: game.app_name, + cloud_saves_supported: false, + external_activation: '', + is_dlc: false, + platform_versions: { + Windows: metadata?.version ?? '' + }, + title: game.title, + ...metadata + }, + manifest: { + download_size, + disk_size: download_size + } + } + installStore.set(appName, installInfo) + return installInfo + } + + logError(['Could not find game with id', appName], LogPrefix.Nile) + return { + game: { + app_name: '', + cloud_saves_supported: false, + external_activation: '', + id: '', + is_dlc: false, + launch_options: [], + owned_dlc: [], + path: '', + platform_versions: { + Windows: '' + }, + title: '', + version: '' + }, + manifest: { + disk_size: 0, + download_size: 0 + } + } +} + +/** + * Change the install path for a given game. + * + * @param appName + * @param newPath + */ +export async function changeGameInstallPath( + appName: string, + newAppPath: string +) { + const libraryGameInfo = library.get(appName) + if (libraryGameInfo) libraryGameInfo.install.install_path = newAppPath + else { + logWarning( + `library game info not found in changeGameInstallPath for ${appName}`, + LogPrefix.Nile + ) + } + + updateInstalledPathInJSON(appName, newAppPath) +} + +function updateInstalledPathInJSON(appName: string, newAppPath: string) { + // Make sure we get the latest installed info + refreshInstalled() + + const installedGameInfo = installedGames.get(appName) + if (installedGameInfo) installedGameInfo.path = newAppPath + else { + logWarning( + `installed game info not found in changeGameInstallPath for ${appName}`, + LogPrefix.Nile + ) + } + + if (!existsSync(nileInstalled)) { + logError( + ['Could not find installed.json in', nileInstalled], + LogPrefix.Nile + ) + return + } + + writeFileSync( + nileInstalled, + JSON.stringify(Array.from(installedGames.values())), + 'utf-8' + ) + logInfo( + ['Updated install path for', appName, 'in', nileInstalled], + LogPrefix.Nile + ) +} + +/** + * Change the install state of a game without a complete library reload. + * + * @param appName + * @param state true if its installed, false otherwise. + */ +export function installState(appName: string, state: boolean) { + if (!state) { + installedGames.delete(appName) + installStore.delete(appName) + return + } + + const metadata = getInstallMetadata(appName) + if (!metadata) { + logError(['Could not find install metadata for', appName], LogPrefix.Nile) + return + } + installedGames.set(appName, metadata) +} + +export async function runRunnerCommand( + commandParts: string[], + abortController: AbortController, + options?: CallRunnerOptions +): Promise { + const { dir, bin } = getNileBin() + + // Set XDG_CONFIG_HOME to a custom, Heroic-specific location so user-made + // changes to Legendary's main config file don't affect us + if (!options) { + options = {} + } + if (!options.env) { + options.env = {} + } + options.env.XDG_CONFIG_HOME = dirname(nileConfigPath) + + return callRunner( + commandParts, + { name: 'nile', logPrefix: LogPrefix.Nile, bin, dir }, + abortController, + { + ...options, + verboseLogFile: nileLogFile + } + ) +} diff --git a/src/backend/storeManagers/nile/setup.ts b/src/backend/storeManagers/nile/setup.ts new file mode 100644 index 0000000000..7fa61a2df3 --- /dev/null +++ b/src/backend/storeManagers/nile/setup.ts @@ -0,0 +1,108 @@ +import { + LogPrefix, + logDebug, + logError, + logInfo, + logWarning +} from 'backend/logger/logger' +import { fetchFuelJSON, getGameInfo } from './library' +import { GameConfig } from 'backend/game_config' +import { isWindows } from 'backend/constants' +import { checkWineBeforeLaunch, spawnAsync } from 'backend/utils' +import { logFileLocation } from '../storeManagerCommon/games' +import { runWineCommand, verifyWinePrefix } from 'backend/launcher' + +/** + * Handles installing dependencies for games that include PostInstall scripts + */ +export default async function setup( + appName: string, + installedPath?: string +): Promise { + const gameInfo = getGameInfo(appName) + const basePath = installedPath ?? gameInfo?.install.install_path + if (!basePath) { + logError([ + `Could not find install path for ${ + gameInfo?.title ?? appName + }. Skipping setup` + ]) + return + } + + const fuel = fetchFuelJSON(appName, basePath) + if (!fuel) { + logError( + [ + 'Cannot install dependencies for', + gameInfo?.title ?? appName, + 'without a fuel.json' + ], + LogPrefix.Nile + ) + return + } + + if (!fuel.PostInstall.length) { + logInfo( + ['No PostInstall instructions for', gameInfo?.title ?? appName], + LogPrefix.Nile + ) + return + } + + logWarning( + 'Running setup instructions, if you notice issues with launching a game, please report it on our Discord server', + LogPrefix.Nile + ) + + const gameSettings = GameConfig.get(appName).config + if (!isWindows) { + const isWineOkToLaunch = await checkWineBeforeLaunch( + appName, + gameSettings, + logFileLocation(appName) + ) + + if (!isWineOkToLaunch) { + logError( + ['Was not possible to run setup using', gameSettings.wineVersion.name], + LogPrefix.Nile + ) + return + } + // Make sure prefix is initialized correctly + await verifyWinePrefix(gameSettings) + } + + logDebug(['PostInstall:', fuel.PostInstall], LogPrefix.Nile) + // Actual setup logic + for (const action of fuel.PostInstall) { + const exeArguments = action.Args ?? [] + + if (isWindows) { + const command = ['Start-Process', '-FilePath', action.Command] + if (exeArguments.length) { + command.push('-ArgumentList', ...exeArguments) + } + logInfo(['Setup: Executing', command.join(' ')], LogPrefix.Nile) + await spawnAsync('powershell', command, { + cwd: basePath + }) + continue + } + + logInfo( + ['Setup: Executing', [action.Command, ...exeArguments].join(' ')], + LogPrefix.Nile + ) + + await runWineCommand({ + gameSettings, + commandParts: [action.Command, ...exeArguments], + wait: true, + protonVerb: 'waitforexitandrun', + startFolder: basePath + }) + } +} diff --git a/src/backend/storeManagers/nile/user.ts b/src/backend/storeManagers/nile/user.ts new file mode 100644 index 0000000000..8bbb09424c --- /dev/null +++ b/src/backend/storeManagers/nile/user.ts @@ -0,0 +1,118 @@ +import { LogPrefix, logDebug, logError, logInfo } from 'backend/logger/logger' +import { + createAbortController, + deleteAbortController +} from 'backend/utils/aborthandler/aborthandler' +import { + NileLoginData, + NileRegisterData, + NileUserData +} from 'common/types/nile' +import { runRunnerCommand } from './library' +import { existsSync, readFileSync } from 'graceful-fs' +import { nileUserData } from 'backend/constants' +import { configStore } from './electronStores' +import { clearCache } from 'backend/utils' + +export class NileUser { + static async getLoginData(): Promise { + logDebug('Getting login data from Nile', LogPrefix.Nile) + const { stdout } = await runRunnerCommand( + ['auth', '--login', '--non-interactive'], + createAbortController('nile-auth') + ) + deleteAbortController('nile-auth') + const output: NileLoginData = JSON.parse(stdout) + + logInfo(['Register data is:', output], LogPrefix.Nile) + return output + } + + static async login( + data: NileRegisterData + ): Promise<{ status: 'done' | 'failed'; user: NileUserData | undefined }> { + logDebug(['Got register data:', data], LogPrefix.Nile) + const { code, code_verifier, serial, client_id } = data + // Nile prints output to stderr + const { stderr: output } = await runRunnerCommand( + [ + 'register', + '--code', + code, + '--code-verifier', + code_verifier, + '--serial', + serial, + '--client-id', + client_id + ], + createAbortController('nile-login') + ) + deleteAbortController('nile-login') + + const successRegex = /\[AUTH_MANAGER]:.*Succesfully registered a device/ + if (!successRegex.test(output)) { + // Authentication failed + logError(['Authentication failed:', output], LogPrefix.Nile) + return { + status: 'failed', + user: undefined + } + } + + logInfo('Authentication successful', LogPrefix.Nile) + const user = await this.getUserData() + if (!user) { + return { + status: 'failed', + user: undefined + } + } + + return { + status: 'done', + user + } + } + + static async logout() { + const commandParts = ['auth', '--logout'] + + const abortID = 'nile-logout' + const res = await runRunnerCommand( + commandParts, + createAbortController(abortID) + ) + deleteAbortController(abortID) + + if (res.abort) { + logError('Failed to logout: abort by user'), LogPrefix.Nile + return + } + + configStore.delete('userData') + clearCache('nile') + } + + static async getUserData(): Promise { + if (!existsSync(nileUserData)) { + logError('user.json does not exist', LogPrefix.Nile) + configStore.delete('userData') + return + } + + const user: { extensions: { customer_info: NileUserData } } = JSON.parse( + readFileSync(nileUserData, 'utf-8') + ) + if (!Object.keys(user).length) { + logInfo('user.json is empty', LogPrefix.Nile) + configStore.delete('userData') + return + } + + configStore.set('userData', user.extensions.customer_info) + logInfo('Saved user data to config file', LogPrefix.Nile) + + return user.extensions.customer_info + } +} diff --git a/src/backend/utils.ts b/src/backend/utils.ts index e426c2fdc7..c7e719d1eb 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -51,6 +51,7 @@ import { import { basename, dirname, join, normalize } from 'path' import { runRunnerCommand as runLegendaryCommand } from 'backend/storeManagers/legendary/library' import { runRunnerCommand as runGogdlCommand } from './storeManagers/gog/library' +import { runRunnerCommand as runNileCommand } from './storeManagers/nile/library' import { gameInfoStore, installStore, @@ -61,6 +62,10 @@ import { installInfoStore as GOGinstallInfoStore, libraryStore as GOGlibraryStore } from './storeManagers/gog/electronStores' +import { + installStore as nileInstallStore, + libraryStore as nileLibraryStore +} from './storeManagers/nile/electronStores' import * as fileSize from 'filesize' import makeClient from 'discord-rich-presence-typescript' import { notify, showDialogBoxModalAuto } from './dialog/dialog' @@ -237,6 +242,20 @@ const getGogdlVersion = async () => { return stdout } +const getNileVersion = async () => { + const abortID = 'nile-version' + const { stdout, error } = await runNileCommand( + ['--version'], + createAbortController(abortID) + ) + deleteAbortController(abortID) + + if (error) { + return 'invalid' + } + return stdout +} + const getHeroicVersion = () => { const VERSION_NUMBER = app.getVersion() // One Piece reference @@ -299,6 +318,7 @@ const getSystemInfoInternal = async (): Promise => { const heroicVersion = getHeroicVersion() const legendaryVersion = await getLegendaryVersion() const gogdlVersion = await getGogdlVersion() + const nileVersion = await getNileVersion() const electronVersion = process.versions.electron || 'unknown' const chromeVersion = process.versions.chrome || 'unknown' @@ -335,6 +355,7 @@ const getSystemInfoInternal = async (): Promise => { const systemInfo = `Heroic Version: ${heroicVersion} Legendary Version: ${legendaryVersion} GOGdl Version: ${gogdlVersion} +Nile Version: ${nileVersion} Electron Version: ${electronVersion} Chrome Version: ${chromeVersion} @@ -481,7 +502,7 @@ async function openUrlOrFile(url: string): Promise { return shell.openPath(url) } -function clearCache(library?: 'gog' | 'legendary') { +function clearCache(library?: 'gog' | 'legendary' | 'nile') { if (library === 'gog' || !library) { GOGapiInfoCache.clear() GOGlibraryStore.clear() @@ -496,6 +517,10 @@ function clearCache(library?: 'gog' | 'legendary') { deleteAbortController(abortID) ) } + if (library === 'nile' || !library) { + nileInstallStore.clear() + nileLibraryStore.clear() + } } function resetHeroic() { @@ -555,6 +580,17 @@ function getGOGdlBin(): { dir: string; bin: string } { fixAsarPath(join(publicDir, 'bin', process.platform, 'gogdl')) ) } + +function getNileBin(): { dir: string; bin: string } { + const settings = GlobalConfig.get().getSettings() + if (settings?.altNileBin) { + return splitPathAndName(settings.altNileBin) + } + return splitPathAndName( + fixAsarPath(join(publicDir, 'bin', process.platform, 'nile')) + ) +} + function getFormattedOsName(): string { switch (process.platform) { case 'linux': @@ -1302,6 +1338,7 @@ export { resetHeroic, getLegendaryBin, getGOGdlBin, + getNileBin, formatEpicStoreUrl, searchForExecutableOnPath, getSteamRuntime, @@ -1320,6 +1357,7 @@ export { getFileSize, getLegendaryVersion, getGogdlVersion, + getNileVersion, memoryLog, removeFolder } diff --git a/src/common/typedefs/ipcBridge.d.ts b/src/common/typedefs/ipcBridge.d.ts index 3af74e11dd..5814bb8f80 100644 --- a/src/common/typedefs/ipcBridge.d.ts +++ b/src/common/typedefs/ipcBridge.d.ts @@ -34,6 +34,12 @@ import { } from 'common/types' import { LegendaryInstallInfo, SelectiveDownload } from 'common/types/legendary' import { GOGCloudSavesLocation, GogInstallInfo } from 'common/types/gog' +import { + NileInstallInfo, + NileLoginData, + NileRegisterData, + NileUserData +} from 'common/types/nile' /** * Some notes here: @@ -115,6 +121,7 @@ interface AsyncIPCFunctions { getHeroicVersion: () => string getLegendaryVersion: () => Promise getGogdlVersion: () => Promise + getNileVersion: () => Promise isFullscreen: () => boolean isFlatpak: () => boolean getPlatform: () => NodeJS.Platform @@ -132,8 +139,9 @@ interface AsyncIPCFunctions { appName: string, runner: Runner, installPlatform: InstallPlatform - ) => Promise + ) => Promise getUserInfo: () => Promise + getAmazonUserInfo: () => Promise isLoggedIn: () => boolean login: (sid: string) => Promise<{ status: 'done' | 'failed' @@ -143,7 +151,12 @@ interface AsyncIPCFunctions { status: 'done' | 'error' data?: UserData }> + authAmazon: (data: NileRegisterData) => Promise<{ + status: 'done' | 'failed' + user: NileUserData | undefined + }> logoutLegendary: () => Promise + logoutAmazon: () => Promise getAlternativeWine: () => Promise getLocalPeloadPath: () => Promise readConfig: (config_class: 'library' | 'user') => Promise @@ -247,6 +260,7 @@ interface AsyncIPCFunctions { runner: Runner, appName: string ) => Promise + getAmazonLoginData: () => Promise } // This is quite ugly & throws a lot of errors in a regular .ts file diff --git a/src/common/types.ts b/src/common/types.ts index 4e09eab968..a3b8a67a2e 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -3,8 +3,9 @@ import { LegendaryInstallPlatform, GameMetadataInner } from './types/legendary' import { IpcRendererEvent } from 'electron' import { ChildProcess } from 'child_process' import { HowLongToBeatEntry } from 'howlongtobeat' +import { NileInstallPlatform } from './types/nile' -export type Runner = 'legendary' | 'gog' | 'sideload' +export type Runner = 'legendary' | 'gog' | 'sideload' | 'nile' // NOTE: Do not put enum's in this module or it will break imports @@ -48,6 +49,7 @@ export interface AppSettings extends GameSettings { addSteamShortcuts: boolean altGogdlBin: string altLegendaryBin: string + altNileBin: string autoUpdateGames: boolean checkForUpdatesOnStartup: boolean checkUpdatesInterval: number @@ -96,7 +98,7 @@ export interface ExtraInfo { export type GameConfigVersion = 'auto' | 'v0' | 'v0.1' export interface GameInfo { - runner: 'legendary' | 'gog' | 'sideload' + runner: 'legendary' | 'gog' | 'sideload' | 'nile' store_url?: string app_name: string art_cover: string @@ -490,6 +492,7 @@ export type WebviewType = HTMLWebViewElement & ElWebview export type InstallPlatform = | LegendaryInstallPlatform | GogInstallPlatform + | NileInstallPlatform | 'Browser' export type ConnectivityChangedCallback = ( diff --git a/src/common/types/electron_store.ts b/src/common/types/electron_store.ts index 471d859334..5f26fe1d36 100644 --- a/src/common/types/electron_store.ts +++ b/src/common/types/electron_store.ts @@ -16,6 +16,7 @@ import { GameInfo } from 'common/types' import { UserData } from 'common/types/gog' +import { NileUserData } from './nile' export interface StoreStructure { configStore: { @@ -37,6 +38,7 @@ export interface StoreStructure { lastLogFile: string legendaryLogFile: string gogdlLogFile: string + nileLogFile: string } 'window-props': Electron.Rectangle settings: AppSettings @@ -63,6 +65,9 @@ export interface StoreStructure { credentials?: GOGLoginData isLoggedIn: boolean } + nileConfigStore: { + userData?: NileUserData + } sideloadedStore: { games: GameInfo[] // FIXME: Not sure if this is correct, seems like this key is only used once diff --git a/src/common/types/game_manager.ts b/src/common/types/game_manager.ts index fc482d21a5..a227ec0f0e 100644 --- a/src/common/types/game_manager.ts +++ b/src/common/types/game_manager.ts @@ -9,6 +9,7 @@ import { } from 'common/types' import { GOGCloudSavesLocation, GogInstallInfo } from './gog' import { LegendaryInstallInfo } from './legendary' +import { NileInstallInfo } from './nile' export interface InstallResult { status: 'done' | 'error' | 'abort' @@ -66,7 +67,9 @@ export interface LibraryManager { appName: string, installPlatform: InstallPlatform, lang?: string - ) => Promise + ) => Promise< + LegendaryInstallInfo | GogInstallInfo | NileInstallInfo | undefined + > listUpdateableGames: () => Promise changeGameInstallPath: (appName: string, newPath: string) => Promise installState: (appName: string, state: boolean) => void diff --git a/src/common/types/nile.ts b/src/common/types/nile.ts new file mode 100644 index 0000000000..7f9f3d43c2 --- /dev/null +++ b/src/common/types/nile.ts @@ -0,0 +1,127 @@ +import { LaunchOption } from 'common/types' + +interface GameManifest { + download_size: number + disk_size: number +} + +interface DLCInfo { + app_name: string + title: string +} + +interface GameInstallInfo { + id: string + version: string + path: string + app_name: string + cloud_save_folder?: string + cloud_save_folder_mac?: string + cloud_saves_supported: boolean + external_activation: string + is_dlc: boolean + launch_options: Array + owned_dlc: Array + platform_versions: Record + title: string +} + +export interface NileInstallMetadataInfo { + id: string + version: string + path: string +} + +export interface NileInstallInfo { + manifest: GameManifest + game: GameInstallInfo +} + +// Amazon Games only supports Windows games +export type NileInstallPlatform = 'Windows' + +export interface NileGameInfo { + id: string + product: NileGameProduct +} + +interface NileGameProduct { + id: string + // For some reason, some games might not have a title + title?: string + productDetail: NileGameProductDetails +} + +interface NileGameProductDetails { + iconUrl: string + details: { + backgroundUrl1: string + backgroundUrl2: string + developer: string + esrbRatins: string + gameModes: string[] + genres: string[] + keywords: string[] + legacyProductIds: string[] + logoUrl: string + otherDevelopers: string[] + pegiRating: string + pgCrownImageUrl: string + publisher: string + releaseDate: string + screenshots: string[] + shortDescription: string + trailerImageUrl: string + uskRating: string + videos: string[] + websites: { + official: string | null + steam: string | null + support: string | null + gog: string | null + } + } +} + +interface FuelPostInstall { + Command: string + Args: string[] + ValidReturns?: number[] + AlwaysRun?: boolean + HideWindow?: boolean +} + +export interface FuelSchema { + SchemaVersion: string + PostInstall: FuelPostInstall[] + Main: { + Command: string + Args: string[] + } +} + +export interface NileUserData { + account_pool: string + user_id: string + home_region: string + name: string + given_name: string +} + +export interface NileLoginData { + url: string + code_verifier: string + serial: string + client_id: string +} + +export interface NileRegisterData { + code: string + code_verifier: string + serial: string + client_id: string +} + +export interface NileGameDownloadInfo { + download_size: number +} diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index eca2a78b25..1effdf67de 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -46,6 +46,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/frontend/assets/amazon-logo.svg b/src/frontend/assets/amazon-logo.svg new file mode 100644 index 0000000000..980f9652d4 --- /dev/null +++ b/src/frontend/assets/amazon-logo.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/components/UI/PlatformFilter/index.tsx b/src/frontend/components/UI/PlatformFilter/index.tsx index 05dc2e3187..0662245756 100644 --- a/src/frontend/components/UI/PlatformFilter/index.tsx +++ b/src/frontend/components/UI/PlatformFilter/index.tsx @@ -16,7 +16,8 @@ export default function PlatformFilter() { const isMac = platform === 'darwin' const isLinux = platform === 'linux' const isWindows = platform === 'win32' - const disabledIcon = isLinux && category === 'legendary' + const disabledIcon = + (isLinux && category === 'legendary') || category === 'nile' // Amazon Games only offers Windows games if (isWindows) { return null @@ -54,6 +55,7 @@ export default function PlatformFilter() { active: filterPlatform === 'mac' })} title={`${t('header.platform')}: ${t('platforms.mac')}`} + disabled={disabledIcon} > { @@ -46,7 +48,7 @@ export default React.memo(function SearchBar() { ) }) .sort((g1, g2) => (g1.title < g2.title ? -1 : 1)) - }, [epic.library, gog.library, filterText]) + }, [amazon.library, epic.library, gog.library, filterText]) // we have to use an event listener instead of the react // onChange callback so it works with the virtual keyboard diff --git a/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx b/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx index bacee9d5e5..99d7d0952f 100644 --- a/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx +++ b/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx @@ -39,8 +39,14 @@ export default function SidebarLinks() { const location = useLocation() as { pathname: string } const [, , runner, appName, type] = location.pathname.split('/') as PathSplit - const { epic, gog, platform, refreshLibrary, handleExternalLinkDialog } = - useContext(ContextProvider) + const { + amazon, + epic, + gog, + platform, + refreshLibrary, + handleExternalLinkDialog + } = useContext(ContextProvider) const isStore = location.pathname.includes('store') const isSettings = location.pathname.includes('settings') @@ -48,14 +54,15 @@ export default function SidebarLinks() { const settingsPath = '/settings/app/default/general' - const loggedIn = epic.username || gog.username + const loggedIn = epic.username || gog.username || amazon.username async function handleRefresh() { localStorage.setItem('scrollPosition', '0') const shouldRefresh = (epic.username && !epic.library.length) || - (gog.username && !gog.library.length) + (gog.username && !gog.library.length) || + (amazon.username && !amazon.library.length) if (shouldRefresh) { return refreshLibrary({ runInBackground: true }) } @@ -73,6 +80,16 @@ export default function SidebarLinks() { } } + // By default, open Epic Store + let defaultStore = '/epicstore' + if (!epic.username && !gog.username && amazon.username) { + // If only logged in to Amazon Games, open Amazon Gaming + defaultStore = '/amazonstore' + } else if (!epic.username && gog.username) { + // Otherwise, if not logged in to Epic Games, open GOG Store + defaultStore = '/gogstore' + } + return (
{!loggedIn && ( @@ -116,8 +133,7 @@ export default function SidebarLinks() { active: isActive || location.pathname.includes('store') }) } - //open gog store if only gog account logged in, otherwise by default open epic store - to={gog.username && !epic.username ? '/gogstore' : '/epicstore'} + to={defaultStore} > <>
@@ -150,6 +166,17 @@ export default function SidebarLinks() { > {t('gog-store', 'GOG Store')} + + classNames('Sidebar__item', 'SidebarLinks__subItem', { + active: isActive + }) + } + to="/amazonstore" + > + {t('prime-gaming', 'Prime Gaming')} +
)}
diff --git a/src/frontend/components/UI/StoreFilter/index.tsx b/src/frontend/components/UI/StoreFilter/index.tsx index 7264827f65..a18b0b2df9 100644 --- a/src/frontend/components/UI/StoreFilter/index.tsx +++ b/src/frontend/components/UI/StoreFilter/index.tsx @@ -5,11 +5,13 @@ import FormControl from 'frontend/components/UI/FormControl' import ContextProvider from 'frontend/state/ContextProvider' export default React.memo(function StoreFilter() { - const { category, handleCategory, gog, epic } = useContext(ContextProvider) + const { category, handleCategory, gog, epic, amazon } = + useContext(ContextProvider) const { t } = useTranslation() const isGOGLoggedin = gog.username const isEpicLoggedin = epic.username + const isAmazonLoggedin = amazon.username return (
@@ -45,6 +47,17 @@ export default React.memo(function StoreFilter() { GOG )} + {isAmazonLoggedin && ( + + )}