diff --git a/package.json b/package.json index 44655abf1e..839e88314a 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "i18next-fs-backend": "2.1.1", "i18next-http-backend": "2.1.1", "ini": "3.0.0", + "js-yaml": "^4.1.0", "json5": "2.2.3", "plist": "3.0.5", "react": "18.2.0", @@ -236,6 +237,7 @@ "@types/i18next-fs-backend": "1.1.4", "@types/ini": "1.3.31", "@types/jest": "29.4.0", + "@types/js-yaml": "^4.0.9", "@types/node": "18.15.0", "@types/plist": "3.0.2", "@types/react": "18.2.34", diff --git a/public/bin/linux/freecarnival b/public/bin/linux/freecarnival new file mode 100755 index 0000000000..70a93c23e1 Binary files /dev/null and b/public/bin/linux/freecarnival differ diff --git a/public/locales/en/gamepage.json b/public/locales/en/gamepage.json index f18c112488..ac25dbff96 100644 --- a/public/locales/en/gamepage.json +++ b/public/locales/en/gamepage.json @@ -128,6 +128,9 @@ "main-story": "Main Story" }, "howLongToBeat": "How Long To Beat", + "import": { + "indiegala": "Import is not supported on IndieGala" + }, "info": { "apple-gaming-wiki": "AppleGamingWiki Rating", "canRunOffline": "Online Required", @@ -190,6 +193,7 @@ "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:", + "indieGala": "You are not logged in with an indieGala account in Heroic. Don't use the store page to login, click the following button instead:", "login": "Log in", "title": "You are NOT logged in" }, diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 01a05d5b0f..269cbd6647 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -339,6 +339,7 @@ "part2": "For other places, use a symbolic link to one of these folders" } }, + "indie-gala": "IndieGala", "info": { "heroic": { "beta": "Beta", @@ -391,6 +392,7 @@ "amazon": "Amazon Login", "epic": "Epic Games Login", "gog": "GOG Login", + "indiegala": "indieGala Login", "message": "Login with your platform. You can login to more than one platform at the same time.", "old-mac": "Your macOS version is {{version}}. macOS 12 or newer is required to log in." }, diff --git a/src/backend/api/helpers.ts b/src/backend/api/helpers.ts index 6a8250183b..61219f8595 100644 --- a/src/backend/api/helpers.ts +++ b/src/backend/api/helpers.ts @@ -47,6 +47,9 @@ export const getUserInfo = async () => ipcRenderer.invoke('getUserInfo') export const getAmazonUserInfo = async () => ipcRenderer.invoke('getAmazonUserInfo') +export const getIndieGalaUserInfo = async () => + ipcRenderer.invoke('getIndieGalaUserInfo') + 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 00457485da..4636341a8b 100644 --- a/src/backend/api/misc.ts +++ b/src/backend/api/misc.ts @@ -79,6 +79,8 @@ export const getAmazonLoginData = async () => export const authAmazon = async (data: NileRegisterData) => ipcRenderer.invoke('authAmazon', data) export const logoutAmazon = async () => ipcRenderer.invoke('logoutAmazon') +export const logoutCarnival = async () => ipcRenderer.invoke('logoutCarnival') +export const authCarnival = async () => ipcRenderer.invoke('authCarnival') export const checkGameUpdates = async () => ipcRenderer.invoke('checkGameUpdates') export const refreshLibrary = async (library?: Runner | 'all') => @@ -89,6 +91,7 @@ export const gamepadAction = async (args: GamepadActionArgs) => export const logError = (error: string) => ipcRenderer.send('logError', error) export const logInfo = (info: string) => ipcRenderer.send('logInfo', info) +export const logDebug = (debug: string) => ipcRenderer.send('logDebug', debug) export const showConfigFileInFolder = (appName: string) => ipcRenderer.send('showConfigFileInFolder', appName) export const openFolder = (installPath: string) => diff --git a/src/backend/constants.ts b/src/backend/constants.ts index eeea8c3ee9..b698a3500c 100644 --- a/src/backend/constants.ts +++ b/src/backend/constants.ts @@ -73,6 +73,13 @@ const anticheatDataPath = join(appFolder, 'areweanticheatyet.json') const imagesCachePath = join(appFolder, 'images-cache') const fixesPath = join(appFolder, 'fixes') +const carnivalConfigPath = join(appFolder, 'carnival_config') +const carnivalInstalled = join(carnivalConfigPath, 'installed.yml') +const carnivalLibrary = join(carnivalConfigPath, 'library.yml') +const carnivalLogFile = '' +const carnivalCookieData = join(carnivalConfigPath, 'cookies.yml') +const carnivalUserData = join(carnivalConfigPath, 'user.yml') + const { currentLogFile, lastLogFile, @@ -86,6 +93,7 @@ const gogdlAuthConfig = join(app.getPath('userData'), 'gog_store', 'auth.json') const vulkanHelperBin = fixAsarPath( join(publicDir, 'bin', process.platform, 'vulkan-helper') ) + const icon = fixAsarPath(join(publicDir, 'icon.png')) const iconDark = fixAsarPath(join(publicDir, 'icon-dark.png')) const iconLight = fixAsarPath(join(publicDir, 'icon-light.png')) @@ -284,5 +292,11 @@ export { nileInstalled, nileLibrary, nileUserData, + carnivalConfigPath, + carnivalInstalled, + carnivalLibrary, + carnivalLogFile, + carnivalUserData, + carnivalCookieData, fixesPath } diff --git a/src/backend/logger/logger.ts b/src/backend/logger/logger.ts index f7b00b31fd..1b8652264f 100644 --- a/src/backend/logger/logger.ts +++ b/src/backend/logger/logger.ts @@ -22,6 +22,7 @@ export enum LogPrefix { Legendary = 'Legendary', Gog = 'Gog', Nile = 'Nile', + Carnival = 'Carnival', WineDownloader = 'WineDownloader', DXVKInstaller = 'DXVKInstaller', GlobalConfig = 'GlobalConfig', diff --git a/src/backend/main.ts b/src/backend/main.ts index c485f49a64..e86046f2a3 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -156,6 +156,7 @@ import { getGameSdl } from 'backend/storeManagers/legendary/library' import { storeMap } from 'common/utils' +import { CarnivalUser } from './storeManagers/carnival/user' app.commandLine?.appendSwitch('ozone-platform-hint', 'auto') @@ -812,6 +813,8 @@ ipcMain.handle('getUserInfo', async () => { ipcMain.handle('getAmazonUserInfo', async () => NileUser.getUserData()) +ipcMain.handle('getIndieGalaUserInfo', async () => CarnivalUser.getUserData()) + // Checks if the user have logged in with Legendary already ipcMain.handle('isLoggedIn', LegendaryUser.isLoggedIn) @@ -819,6 +822,9 @@ ipcMain.handle('login', async (event, sid) => LegendaryUser.login(sid)) ipcMain.handle('authGOG', async (event, code) => GOGUser.login(code)) ipcMain.handle('logoutLegendary', LegendaryUser.logout) ipcMain.on('logoutGOG', GOGUser.logout) +ipcMain.handle('logoutCarnival', CarnivalUser.logout) +ipcMain.handle('authCarnival', async () => CarnivalUser.login()) + ipcMain.handle('getLocalPeloadPath', async () => { return fixAsarPath(join('file://', publicDir, 'webviewPreload.js')) }) @@ -974,7 +980,7 @@ ipcMain.handle('refreshLibrary', async (e, library?) => { }) ipcMain.on('logError', (e, err) => logError(err, LogPrefix.Frontend)) - +ipcMain.on('logDebug', (e, debug) => logDebug(debug, LogPrefix.Frontend)) ipcMain.on('logInfo', (e, info) => logInfo(info, LogPrefix.Frontend)) let powerDisplayId: number | null diff --git a/src/backend/save_sync.ts b/src/backend/save_sync.ts index 4ce1398dee..01053f5a5b 100644 --- a/src/backend/save_sync.ts +++ b/src/backend/save_sync.ts @@ -35,6 +35,8 @@ async function getDefaultSavePath( return getDefaultGogSavePaths(appName, alreadyDefinedGogSaves) case 'nile': return '' + case 'carnival': + return '' case 'sideload': return '' } diff --git a/src/backend/storeManagers/carnival/electronStores.ts b/src/backend/storeManagers/carnival/electronStores.ts new file mode 100644 index 0000000000..9e33c5432b --- /dev/null +++ b/src/backend/storeManagers/carnival/electronStores.ts @@ -0,0 +1,16 @@ +import CacheStore from 'backend/cache' +import { TypeCheckedStoreBackend } from 'backend/electron_store' +import { GameInfo } from 'common/types' +import { CarnivalInstallInfo } from 'common/types/carnival' + +export const installStore = new CacheStore( + 'carnival_install_info' +) +export const libraryStore = new CacheStore( + 'carnival_library', + null +) + +export const configStore = new TypeCheckedStoreBackend('carnivalConfigStore', { + cwd: 'carnival_store' +}) diff --git a/src/backend/storeManagers/carnival/games.ts b/src/backend/storeManagers/carnival/games.ts new file mode 100644 index 0000000000..8c7ae88820 --- /dev/null +++ b/src/backend/storeManagers/carnival/games.ts @@ -0,0 +1,598 @@ +import { + ExecResult, + ExtraInfo, + GameInfo, + GameSettings, + InstallArgs, + InstallPlatform, + InstallProgress +} from 'common/types' +import { InstallResult, RemoveArgs } from 'common/types/game_manager' +import { + runRunnerCommand as runCarnivalCommand, + getGameInfo as carnivalLibraryGetGameInfo, + changeGameInstallPath, + installState, + removeFromInstalledConfig, + getInstallMetadata, + getGameFromLibrary +} from './library' +import { + LogPrefix, + logDebug, + logError, + logFileLocation, + 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 { + launchCleanup, + prepareLaunch, + prepareWineLaunch, + setupEnvVars, + setupWrapperEnvVars, + setupWrappers +} from 'backend/launcher' +import { appendFileSync, existsSync } from 'graceful-fs' +import { showDialogBoxModalAuto } from 'backend/dialog/dialog' +import { t } from 'i18next' +import { getWineFlagsArray } from 'backend/utils/compatibility_layers' +import shlex from 'shlex' +import { join } from 'path' +import { + 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 = carnivalLibraryGetGameInfo(appName) + if (!info) { + logError( + [ + 'Could not get game info for', + `${appName},`, + 'returning empty object. Something is probably gonna go wrong soon' + ], + LogPrefix.Carnival + ) + return { + app_name: '', + runner: 'carnival', + art_cover: '', + art_square: '', + install: {}, + is_installed: false, + title: '', + canRunOffline: false + } + } + return info +} + +export async function getExtraInfo(appName: string): Promise { + const info = carnivalLibraryGetGameInfo(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 runCarnivalCommand( + ['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.Carnival) + return res + } + + const errorMatch = res.stderr.match(/ERROR \[IMPORT]:\t(.*)/) + if (errorMatch) { + logError( + ['Failed to import', `${appName}:`, errorMatch[1]], + LogPrefix.Carnival + ) + return { + ...res, + error: errorMatch[1] + } + } + + try { + addShortcuts(appName) + installState(appName, true) + } catch (error) { + logError(['Failed to import', `${appName}:`, error], LogPrefix.Carnival) + } + + 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.Carnival + ) + + sendFrontendMessage(`progressUpdate-${appName}`, { + appName, + runner: 'carnival', + 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-download-workers', `${maxWorkers}`] : [] + + const logPath = join(gamesConfigPath, `${appName}.log`) + + const game = getGameFromLibrary(appName) + const commandParts = [ + 'install', + '--base-path', + path, + ...workers, + game?.folder_name + ] + + const onOutput = (data: string) => { + onInstallOrUpdateOutput(appName, 'installing', data) + } + + const res = await runCarnivalCommand( + 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.Carnival) + } + return { status: 'error', error: res.error } + } + addShortcuts(appName) + installState(appName, true) + const metadata = getInstallMetadata(appName) + + if (isWindows) { + await setup(appName, metadata?.install_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): 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 = { + ...process.env, + ...setupWrapperEnvVars({ appName, appRunner: 'carnival' }), + ...(isWindows ? {} : setupEnvVars(gameSettings)) + } + + const wrappers = setupWrappers( + gameSettings, + mangoHudCommand, + gameModeBin, + steamRuntime?.length ? [...steamRuntime] : undefined + ) + + let wineFlag: string[] = wrappers.length + ? ['--wrapper', shlex.join(wrappers)] + : [] + + if (!isNative()) { + // -> We're using Wine/Proton on Linux or CX on Mac + const { + success: wineLaunchPrepSuccess, + failureReason: wineLaunchPrepFailReason, + envVars: wineEnvVars + } = await prepareWineLaunch('carnival', 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 = [ + ...getWineFlagsArray(wineBin, wineType, shlex.join(wrappers)), + '--wine-prefix', + gameSettings.winePrefix + ] + } + + const commandParts = [ + 'launch', + ...exeOverrideFlag, // Check if this works + ...wineFlag, + ...shlex.split(gameSettings.launcherArgs ?? ''), + gameInfo.unique_name + ] + + const { error } = await runCarnivalCommand( + 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.Carnival) + } + + launchCleanup(rpcClient) + + return !error +} + +export async function moveInstall( + appName: string, + newInstallPath: string +): Promise { + const gameInfo = getGameInfo(appName) + logInfo(`Moving ${gameInfo.title} to ${newInstallPath}`, LogPrefix.Carnival) + + 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.Carnival + ) + 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.Carnival) + return { + stderr: '', + stdout: '', + error + } + } + + logDebug([appName, 'is installed at', install_path], LogPrefix.Carnival) + const logPath = join(gamesConfigPath, `${appName}.log`) + const res = await runCarnivalCommand( + ['verify', installInfo.unique_name], + createAbortController(appName), + { + logFile: logPath, + logMessagePrefix: `Repairing ${appName}` + } + ) + deleteAbortController(appName) + + if (res.error) { + logError(['Failed to repair', `${appName}:`, res.error], LogPrefix.Carnival) + } + + return res +} + +export async function syncSaves(): Promise { + // indieGala doesn't support cloud saves + return '' +} + +export async function uninstall({ appName }: RemoveArgs): Promise { + const commandParts = ['uninstall', appName] + + const res = await runCarnivalCommand( + commandParts, + createAbortController(appName), + { + logMessagePrefix: `Uninstalling ${appName}` + } + ) + deleteAbortController(appName) + + if (res.error) { + logError( + ['Failed to uninstall', `${appName}:`, res.error], + LogPrefix.Carnival + ) + } else if (!res.abort) { + const gameInfo = getGameInfo(appName) + await removeShortcutsUtil(gameInfo) + await removeNonSteamGame({ gameInfo }) + installState(appName, false) + } + sendFrontendMessage('refreshLibrary', 'carnival') + 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 runCarnivalCommand( + 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.Carnival) + } + return { status: 'error', error: res.error } + } + + sendFrontendMessage('gameStatusUpdate', { + appName, + runner: 'carnival', + 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 : 'carnival' + killPattern(pattern) + + if (stopWine && !isNative()) { + const gameSettings = await getSettings(appName) + await shutdownWine(gameSettings) + } +} + +export async function isGameAvailable(appName: string): Promise { + const info = getGameInfo(appName) + return Boolean( + info?.is_installed && + info.install.install_path && + existsSync(info.install.install_path) + ) +} diff --git a/src/backend/storeManagers/carnival/library.ts b/src/backend/storeManagers/carnival/library.ts new file mode 100644 index 0000000000..9fb960dd3a --- /dev/null +++ b/src/backend/storeManagers/carnival/library.ts @@ -0,0 +1,561 @@ +import JSON5 from 'json5' +import { + carnivalConfigPath, + carnivalInstalled, + carnivalLibrary, + carnivalLogFile +} 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, + CarnivalGameInfo, + CarnivalInstallInfo, + CarnivalInstallMetadataInfo +} from 'common/types/carnival' +import { existsSync, readFileSync, writeFileSync } from 'graceful-fs' +import { installStore, libraryStore } from './electronStores' +import { + getFileSize, + getCarnivalBin, + removeSpecialcharacters +} from 'backend/utils' +import { callRunner } from 'backend/launcher' +import { join } from 'path' +import { app } from 'electron' +import { copySync } from 'fs-extra' +import { CarnivalUser } from './user' +import { JSON_SCHEMA, load, dump } from 'js-yaml' +import { getGamesdbData } from '../gog/library' + +const installedGames: Map = new Map() +const library: Map = new Map() + +interface libraryYAMLInterface { + collection: CarnivalGameInfo[] +} + +export async function initCarnivalLibraryManager() { + // Migrate user data from global Carnival config if necessary + const globalCarnivalConfig = join(app.getPath('appData'), 'carnival') + if (!existsSync(carnivalConfigPath) && existsSync(globalCarnivalConfig)) { + copySync(globalCarnivalConfig, carnivalConfigPath) + await CarnivalUser.getUserData() + } + + refresh() +} + +/** + * Loads all the user's games into `library` + */ +async function loadGamesInAccount() { + if (!existsSync(carnivalLibrary)) { + return + } + const libraryYAML = load(readFileSync(carnivalLibrary, 'utf-8'), { + schema: JSON_SCHEMA + }) as libraryYAMLInterface + + const libraryJSON = libraryYAML.collection + libraryJSON.forEach(async (game) => { + const info = installedGames.get(game.slugged_name) + // Create save folder name like carnival + const meta = await getGamesdbData('indiegala', game.slugged_name, false) + + const developers_list: string[] = [] + if (meta?.data?.game?.developers) { + meta?.data?.game?.developers.forEach((dev) => { + developers_list.push(dev.name) + }) + } + + const safeFolderName = removeSpecialcharacters(game.slugged_name ?? '') + const install_data = installStore.get(game.slugged_name) + library.set(game.name, { + app_name: game.name, + art_cover: meta?.data?.game?.cover + ? meta?.data?.game?.cover.url_format.replace( + '{formatter}.{ext}', + '.png' + ) + : '', + art_square: meta?.data?.game?.square_icon + ? meta?.data?.game?.square_icon.url_format.replace( + '{formatter}.{ext}', + '.png' + ) + : '', + canRunOffline: true, // indieGala only has offline games + install: info + ? { + install_path: info.install_path, + // For some time size was undefined in installed.json, that's why we + // need to keep this fallback to 0 + install_size: getFileSize( + install_data?.manifest.download_size ?? 0 + ), + version: info.version, + platform: 'Windows' // indieGala only supports Windows + } + : {}, + folder_name: safeFolderName, + unique_name: game.slugged_name, + is_installed: info !== undefined, + runner: 'carnival', + title: game.name, + description: meta?.data?.game?.summary + ? meta?.data?.game?.summary['*'] + : '', + developer: developers_list ? developers_list.join(', ') : '', + is_linux_native: false, + is_mac_native: false + }) + }) +} + +/** + * Removes a game entry directly from Carnivals' installed.json config file + * + * @param appName The id of the app entry to remove + */ +export function removeFromInstalledConfig(appName: string) { + installedGames.clear() + if (existsSync(carnivalInstalled)) { + try { + logDebug(appName, LogPrefix.Carnival) + // const _installed = load( + // readFileSync(carnivalInstalled, 'utf-8') + // ) + // const newInstalled = installed.filter((game) => game.id !== appName) + // writeFileSync(carnivalInstalled, JSON.stringify(newInstalled), 'utf-8') + } catch (error) { + logError( + ['Corrupted installed.yml file, cannot load installed games', error], + LogPrefix.Carnival + ) + } + } +} + +/** + * 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.Carnival) + return null + } + + const fuelJSONPath = join(basePath, 'fuel.json') + logDebug(['fuel.json path:', fuelJSONPath], LogPrefix.Carnival) + + if (!existsSync(fuelJSONPath)) { + return null + } + + try { + return JSON5.parse(readFileSync(fuelJSONPath, 'utf-8')) + } catch (error) { + logError(['Could not read', `${fuelJSONPath}:`, error], LogPrefix.Carnival) + } + + return null +} + +/** + * Obtain a list of updateable games. + * + * @returns App names of updateable games. + */ +export async function listUpdateableGames(): Promise { + if (!CarnivalUser.isLoggedIn()) { + return [] + } + logInfo('Looking for updates...', LogPrefix.Carnival) + + const abortID = 'carnival-list-updates' + const { stdout: output } = await runRunnerCommand( + ['list-updates', '--json'], + createAbortController(abortID) + ) + deleteAbortController(abortID) + + if (!output) { + /* + * Nothing installed: nothing to update, output will be empty and JSON.parse can't + * handle empty strings (they aren't proper JSON). + */ + return [] + } + + const updates: string[] = JSON.parse(output) + if (updates.length) { + logInfo( + ['Found', `${updates.length}`, 'games to update'], + LogPrefix.Carnival + ) + } + return updates +} + +/** + * Refresh games in the user's library + */ +async function refreshCarnival(): Promise { + logInfo('Refreshing indieGala Games...', LogPrefix.Carnival) + + const abortID = 'carnival-refresh' + const res = await runRunnerCommand( + ['library'], + createAbortController(abortID) + ) + + deleteAbortController(abortID) + + if (res.error) { + logError(['Failed to refresh library:', res.error], LogPrefix.Carnival) + } + return { + stderr: '', + stdout: '' + } +} + +export function getInstallMetadata( + appName: string +): CarnivalInstallMetadataInfo | undefined { + if (!existsSync(carnivalInstalled)) { + return + } + + try { + const installed = load(readFileSync(carnivalInstalled, 'utf-8')) as object + const game = getGameFromLibrary(appName) + + return game && game.unique_name && installed[game.unique_name] + ? installed[game.unique_name] + : undefined + } catch (error) { + logError( + ['Corrupted installed.yml file, cannot load installed games', error], + LogPrefix.Carnival + ) + } + return +} + +/** + * Refresh `installedGames` from file. + */ +export function refreshInstalled() { + installedGames.clear() + if (existsSync(carnivalInstalled)) { + try { + const installed = load(readFileSync(carnivalInstalled, 'utf-8')) as object + + Object.getOwnPropertyNames(installed).forEach((unique_name) => { + installedGames.set( + unique_name, + installed[unique_name] as CarnivalInstallMetadataInfo + ) + }) + } catch (error) { + logError( + ['Corrupted installed.yml file, cannot load installed games', error], + LogPrefix.Carnival + ) + } + } +} + +const defaultExecResult = { + stderr: '', + stdout: '' +} + +/** + * Get the game info of all games in the library + * + * @returns Array of objects. + */ +export async function refresh(force = false): Promise { + if (!force && !CarnivalUser.isLoggedIn()) { + return defaultExecResult + } + logInfo('Refreshing library...', LogPrefix.Carnival) + + await refreshCarnival() + refreshInstalled() + await loadGamesInAccount() + + const arr = Array.from(library.values()) + libraryStore.set('library', arr) + logInfo( + ['Game list updated, got', `${arr.length}`, 'games'], + LogPrefix.Carnival + ) + + return defaultExecResult +} + +/** + * 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.Carnival) + refreshInstalled() + loadGamesInAccount() + + const game = library.get(appName) + if (!game) { + logError( + ['Could not find game with id', appName, `in user's library`], + LogPrefix.Carnival + ) + 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.Carnival) + return cache + } + + logInfo('Getting more details', LogPrefix.Carnival) + refreshInstalled() + + const game = library.get(appName) + if (game) { + const metadata = installedGames.get(appName) + // Get size info from Carnival + const { stdout: output } = await runRunnerCommand( + ['install', '--info', game.unique_name ? game.unique_name : appName], + createAbortController(appName) + ) + deleteAbortController(appName) + + const ds_regex = /Download Size: ([0-9]+\.*[0-9]*) ([MKGT])B/ + const human_to_factor = { + K: 1024, + M: 1024 * 1024, + G: 1024 * 1024 * 1024, + T: 1024 * 1024 * 1024 * 1024 + } + const download_size = ds_regex.test(output) + ? (ds_regex!.exec(output)![1] as unknown as number) * + human_to_factor[ds_regex!.exec(output)![2]] + : 0 + 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.Carnival) + 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.unique_name) { + libraryGameInfo.install.install_path = newAppPath + updateInstalledPathInJSON(libraryGameInfo.unique_name, newAppPath) + } else { + logWarning( + `library game info not found in changeGameInstallPath for ${appName}`, + LogPrefix.Carnival + ) + } +} + +function updateInstalledPathInJSON(appUniqueName: string, newAppPath: string) { + // Make sure we get the latest installed info + refreshInstalled() + + const installedGameInfo = installedGames.get(appUniqueName) + if (installedGameInfo) installedGameInfo.install_path = newAppPath + else { + logWarning( + `installed game info not found in updateInstalledPathInJSON for ${appUniqueName}`, + LogPrefix.Carnival + ) + } + + if (!existsSync(carnivalInstalled)) { + logError( + ['Could not find installed.json in', carnivalInstalled], + LogPrefix.Carnival + ) + return + } + + writeFileSync( + carnivalInstalled, + dump(Object.fromEntries(installedGames.entries())), + 'utf-8' + ) + logInfo( + ['Updated install path for', appUniqueName, 'in', carnivalInstalled], + LogPrefix.Carnival + ) +} + +/** + * 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.Carnival + ) + return + } + installedGames.set(appName, metadata) +} + +export async function runRunnerCommand( + commandParts: (string | undefined)[], + abortController: AbortController, + options?: CallRunnerOptions +): Promise { + const { dir, bin } = getCarnivalBin() + + // Set CARNIVAL_CONFIG_PATH to a custom, Heroic-specific location so user-made + // changes to Carnival's main config file don't affect us + if (!options) { + options = {} + } + if (!options.env) { + options.env = {} + } + options.env.CARNIVAL_CONFIG_PATH = carnivalConfigPath + const cleanCommandParts: string[] = [] + for (const part in commandParts) { + if (commandParts[part]?.length) { + cleanCommandParts.push(commandParts[part] as string) + } + } + + return callRunner( + cleanCommandParts, + { name: 'carnival', logPrefix: LogPrefix.Carnival, bin, dir }, + { + ...options, + verboseLogFile: carnivalLogFile + } + ) +} + +export function getGameFromLibrary(gameName: string): GameInfo | undefined { + for (const game of libraryStore.get('library', [])) { + if (game.app_name === gameName) return game + } + return undefined +} + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +export function changeVersionPinnedStatus(appName: string, status: boolean) { + logWarning( + 'changeVersionPinnedStatus not implemented on Carnival Library Manager' + ) +} + +export const getLaunchOptions = () => [] diff --git a/src/backend/storeManagers/carnival/setup.ts b/src/backend/storeManagers/carnival/setup.ts new file mode 100644 index 0000000000..427b8441ac --- /dev/null +++ b/src/backend/storeManagers/carnival/setup.ts @@ -0,0 +1,117 @@ +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 { 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.Carnival + ) + return + } + + if (!fuel.PostInstall.length) { + logInfo( + ['No PostInstall instructions for', gameInfo?.title ?? appName], + LogPrefix.Carnival + ) + return + } + + logWarning( + 'Running setup instructions, if you notice issues with launching a game, please report it on our Discord server', + LogPrefix.Carnival + ) + + const gameSettings = GameConfig.get(appName).config + if (!isWindows) { + const isWineOkToLaunch = await checkWineBeforeLaunch( + { + runner: 'carnival', + app_name: appName, + art_cover: '', + art_square: '', + is_installed: true, + title: appName, + canRunOffline: true, + install: {} + }, + gameSettings + // logFileLocation(appName) + ) + + if (!isWineOkToLaunch) { + logError( + ['Was not possible to run setup using', gameSettings.wineVersion.name], + LogPrefix.Carnival + ) + return + } + // Make sure prefix is initialized correctly + await verifyWinePrefix(gameSettings) + } + + logDebug(['PostInstall:', fuel.PostInstall], LogPrefix.Carnival) + // 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.Carnival) + await spawnAsync('powershell', command, { + cwd: basePath + }) + continue + } + + logInfo( + ['Setup: Executing', [action.Command, ...exeArguments].join(' ')], + LogPrefix.Carnival + ) + + await runWineCommand({ + gameSettings, + gameInstallPath: basePath, + commandParts: [action.Command, ...exeArguments], + wait: true, + protonVerb: 'waitforexitandrun', + startFolder: basePath + }) + } +} diff --git a/src/backend/storeManagers/carnival/user.ts b/src/backend/storeManagers/carnival/user.ts new file mode 100644 index 0000000000..cd83e13d5e --- /dev/null +++ b/src/backend/storeManagers/carnival/user.ts @@ -0,0 +1,163 @@ +import { LogPrefix, logDebug, logError, logInfo } from 'backend/logger/logger' +import { + createAbortController, + deleteAbortController +} from 'backend/utils/aborthandler/aborthandler' +import { + CarnivalLoginData, + CarnivalCookieData, + CarnivalUserData, + CarnivalUserDataFile +} from 'common/types/carnival' +import { runRunnerCommand, refresh } from './library' +import { existsSync, readFileSync, mkdirSync } from 'graceful-fs' +import { + carnivalUserData, + carnivalCookieData, + carnivalConfigPath +} from 'backend/constants' +import { configStore } from './electronStores' +import { clearCache } from 'backend/utils' +import { load, dump } from 'js-yaml' +import { session } from 'electron' +import { writeFileSync } from 'fs' + +export class CarnivalUser { + static async getLoginData(): Promise { + logDebug('Getting login data from Carnival', LogPrefix.Carnival) + const { stdout } = await runRunnerCommand( + ['auth', '--login', '--non-interactive'], + createAbortController('carnival-auth') + ) + deleteAbortController('carnival-auth') + const output: CarnivalLoginData = JSON.parse(stdout) + + logInfo(['Register data is:', output], LogPrefix.Carnival) + return output + } + + static async login(): Promise<{ + status: 'done' | 'failed' + user: CarnivalUserData | undefined + }> { + const mySession = session.fromPartition('persist:epicstore') + if (CarnivalUser.isLoggedIn()) { + logDebug('Getting user data', LogPrefix.Carnival) + const user = await this.getUserData() + if (user) { + return { + status: 'done', + user + } + } + } + try { + logDebug('Using cookie', LogPrefix.Carnival) + const auth_cookie = await mySession.cookies.get({ + domain: '.indiegala.com', + name: 'auth' + }) + if (auth_cookie.length !== 1) { + throw new Error( + "Too many auth cookies or none, this doesn't make sense" + ) + } + if (!existsSync(carnivalConfigPath)) { + mkdirSync(carnivalConfigPath, { recursive: true }) + } + + const replaceRegEx = /^\./ + const expiry_date = new Date(Number(auth_cookie[0].expirationDate) * 1000) + const cookie: CarnivalCookieData = { + raw_cookie: `${auth_cookie[0].name}=${auth_cookie[0].value}; Path=${ + auth_cookie[0].path + }; Domain=${ + auth_cookie[0].domain + }; Expires=${expiry_date.toUTCString()}`, + domain: { + Suffix: auth_cookie[0].domain + ? auth_cookie[0].domain.replace(replaceRegEx, '') + : '' + }, + path: [auth_cookie[0].path ? auth_cookie[0].path : '', true], + expires: { AtUtc: expiry_date.toISOString() } + } + logDebug(`Cookies: ${JSON.stringify(cookie)}`) + writeFileSync(carnivalCookieData, dump([cookie])) + logInfo('Authentication successful', LogPrefix.Carnival) + } catch (error) { + logError(`Error getting cookies: ${error}`, LogPrefix.Carnival) + } + + try { + logDebug('Refreshing', LogPrefix.Carnival) + await refresh(true) + + const user = await CarnivalUser.getUserData() + if (!user) { + return { + status: 'failed', + user: undefined + } + } + return { + status: 'done', + user + } + } catch (error) { + return { + status: 'failed', + user: undefined + } + } + } + + static async logout() { + const commandParts = ['logout'] + + const abortID = 'carnival-logout' + const res = await runRunnerCommand( + commandParts, + createAbortController(abortID) + ) + deleteAbortController(abortID) + + if (res.abort) { + logError('Failed to logout: abort by user'), LogPrefix.Carnival + return + } + + configStore.delete('userData') + clearCache('carnival') + } + + static async getUserData(): Promise { + logInfo('Getting user data', LogPrefix.Carnival) + if (!existsSync(carnivalUserData)) { + logError('user.yml does not exist', LogPrefix.Carnival) + configStore.delete('userData') + return + } + try { + const user: CarnivalUserDataFile = load( + readFileSync(carnivalUserData, 'utf-8') + ) as CarnivalUserDataFile + + configStore.set('userData', user.user_info) + logInfo('Saved user data to global config', LogPrefix.Carnival) + logDebug(['username: ', user.user_info.username], LogPrefix.Carnival) + + return user.user_info + } catch (error) { + logInfo('user.json is empty', LogPrefix.Carnival) + configStore.delete('userData') + return + } + } + + public static isLoggedIn() { + const userData = configStore.get_nodefault('userData') + logDebug(['userData:', userData], LogPrefix.Carnival) + return userData ? userData.user_id : false + } +} diff --git a/src/backend/storeManagers/index.ts b/src/backend/storeManagers/index.ts index 23d9c16f59..c4acfca529 100644 --- a/src/backend/storeManagers/index.ts +++ b/src/backend/storeManagers/index.ts @@ -2,6 +2,7 @@ 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 CarnivalGameManager from 'backend/storeManagers/carnival/games' import * as SideloadLibraryManager from 'backend/storeManagers/sideload/library' import * as GOGLibraryManager from 'backend/storeManagers/gog/library' @@ -13,6 +14,7 @@ import { logInfo, RunnerToLogPrefixMap } from 'backend/logger/logger' import { addToQueue } from 'backend/downloadmanager/downloadqueue' import { DMQueueElement, GameInfo, Runner } from 'common/types' +import * as CarnivalLibraryManager from 'backend/storeManagers/carnival/library' type GameManagerMap = { [key in Runner]: GameManager } @@ -21,6 +23,7 @@ export const gameManagerMap: GameManagerMap = { sideload: SideloadGameManager, gog: GOGGameManager, legendary: LegendaryGameManager, + carnival: CarnivalGameManager, nile: NileGameManager } @@ -32,6 +35,7 @@ export const libraryManagerMap: LibraryManagerMap = { sideload: SideloadLibraryManager, gog: GOGLibraryManager, legendary: LegendaryLibraryManager, + carnival: CarnivalLibraryManager, nile: NileLibraryManager } @@ -81,4 +85,5 @@ export async function initStoreManagers() { await LegendaryLibraryManager.initLegendaryLibraryManager() await GOGLibraryManager.initGOGLibraryManager() await NileLibraryManager.initNileLibraryManager() + await CarnivalLibraryManager.initCarnivalLibraryManager() } diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 73d410a79f..552dd5b5ae 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -62,6 +62,10 @@ import { installStore as nileInstallStore, libraryStore as nileLibraryStore } from './storeManagers/nile/electronStores' +import { + installStore as carnivalInstallStore, + libraryStore as carnivalLibraryStore +} from './storeManagers/carnival/electronStores' import * as fileSize from 'filesize' import makeClient from 'discord-rich-presence-typescript' import { notify, showDialogBoxModalAuto } from './dialog/dialog' @@ -367,7 +371,7 @@ async function openUrlOrFile(url: string): Promise { return shell.openPath(url) } -function clearCache(library?: 'gog' | 'legendary' | 'nile') { +function clearCache(library?: 'gog' | 'legendary' | 'nile' | 'carnival') { wikiGameInfoStore.clear() if (library === 'gog' || !library) { GOGapiInfoCache.clear() @@ -387,6 +391,10 @@ function clearCache(library?: 'gog' | 'legendary' | 'nile') { nileInstallStore.clear() nileLibraryStore.clear() } + if (library === 'carnival' || !library) { + carnivalInstallStore.clear() + carnivalLibraryStore.clear() + } } function resetHeroic() { @@ -457,6 +465,16 @@ function getNileBin(): { dir: string; bin: string } { ) } +function getCarnivalBin(): { dir: string; bin: string } { + const settings = GlobalConfig.get().getSettings() + if (settings?.altCarnivalBin) { + return splitPathAndName(settings.altCarnivalBin) + } + return splitPathAndName( + fixAsarPath(join(publicDir, 'bin', process.platform, 'freecarnival')) + ) +} + function getFormattedOsName(): string { switch (process.platform) { case 'linux': @@ -1451,6 +1469,7 @@ export { getLegendaryBin, getGOGdlBin, getNileBin, + getCarnivalBin, formatEpicStoreUrl, getSteamRuntime, constructAndUpdateRPC, diff --git a/src/common/typedefs/ipcBridge.d.ts b/src/common/typedefs/ipcBridge.d.ts index 341aaed72e..2857ff7e2c 100644 --- a/src/common/typedefs/ipcBridge.d.ts +++ b/src/common/typedefs/ipcBridge.d.ts @@ -45,6 +45,7 @@ import { NileUserData } from 'common/types/nile' import type { SystemInformation } from 'backend/utils/systeminfo' +import { CarnivalUserData } from 'common/types/carnival' /** * Some notes here: @@ -86,6 +87,7 @@ interface SyncIPCFunctions { logoutGOG: () => void logError: (message: unknown) => void logInfo: (message: unknown) => void + logDebug: (message: unknown) => void showItemInFolder: (item: string) => void clipboardWriteText: (text: string) => void processShortcut: (combination: string) => void @@ -170,6 +172,7 @@ interface AsyncIPCFunctions { build?: string ) => Promise getUserInfo: () => Promise + getIndieGalaUserInfo: () => Promise getAmazonUserInfo: () => Promise isLoggedIn: () => boolean login: (sid: string) => Promise<{ @@ -184,8 +187,13 @@ interface AsyncIPCFunctions { status: 'done' | 'failed' user: NileUserData | undefined }> + authCarnival: () => Promise<{ + status: 'done' | 'failed' + user: CarnivalUserData | undefined + }> logoutLegendary: () => Promise logoutAmazon: () => Promise + logoutCarnival: () => Promise getAlternativeWine: () => Promise getLocalPeloadPath: () => Promise readConfig: (config_class: 'library' | 'user') => Promise diff --git a/src/common/types.ts b/src/common/types.ts index 0ffb986423..1d43374b2c 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -13,7 +13,7 @@ import { ChildProcess } from 'child_process' import type { HowLongToBeatEntry } from 'backend/wiki_game_info/howlongtobeat/utils' import { NileInstallInfo, NileInstallPlatform } from './types/nile' -export type Runner = 'legendary' | 'gog' | 'sideload' | 'nile' +export type Runner = 'legendary' | 'gog' | 'sideload' | 'nile' | 'carnival' // NOTE: Do not put enum's in this module or it will break imports @@ -74,6 +74,7 @@ export interface AppSettings extends GameSettings { altGogdlBin: string altLegendaryBin: string altNileBin: string + altCarnivalBin: string autoUpdateGames: boolean checkForUpdatesOnStartup: boolean checkUpdatesInterval: number @@ -126,7 +127,7 @@ export interface ExtraInfo { export type GameConfigVersion = 'auto' | 'v0' | 'v0.1' export interface GameInfo { - runner: 'legendary' | 'gog' | 'sideload' | 'nile' + runner: 'legendary' | 'gog' | 'sideload' | 'nile' | 'carnival' store_url?: string app_name: string art_cover: string @@ -141,6 +142,7 @@ export interface GameInfo { installable?: boolean is_installed: boolean namespace?: string + unique_name?: string // NOTE: This is the save folder without any variables filled in... save_folder?: string // ...and this is the folder with them filled in diff --git a/src/common/types/carnival.ts b/src/common/types/carnival.ts new file mode 100644 index 0000000000..0d0bfa5a95 --- /dev/null +++ b/src/common/types/carnival.ts @@ -0,0 +1,141 @@ +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 CarnivalInstallMetadataInfo { + version: string + install_path: string + os: string +} + +export interface CarnivalInstallInfo { + manifest: GameManifest + game: GameInstallInfo +} + +// Amazon Games only supports Windows games +export type CarnivalInstallPlatform = 'Windows' + +export interface CarnivalGameInfo { + id: string + namespace: string + slugged_name: string + name: string + id_key_name: string + version: CarnivalGameVersion +} + +interface CarnivalGameVersion { + status: number + enabled: number + version: string + os: string + date: string + text: string +} + +// interface CarnivalGameProductDetails { +// 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 CarnivalUserData { + status: string + user_id: string + user_found: boolean + username: string + email: string +} + +export interface CarnivalUserDataFile { + user_info: CarnivalUserData +} + +export interface CarnivalLoginData { + url: string + code_verifier: string + serial: string + client_id: string +} + +export interface CarnivalCookieData { + raw_cookie: string + path: [string, boolean] + domain: { + Suffix: string + } + expires: { + AtUtc: string + } +} + +export interface CarnivalGameDownloadInfo { + download_size: number +} diff --git a/src/common/types/electron_store.ts b/src/common/types/electron_store.ts index 17fadf6fee..2b99b2e883 100644 --- a/src/common/types/electron_store.ts +++ b/src/common/types/electron_store.ts @@ -18,6 +18,7 @@ import { } from 'common/types' import { UserData } from 'common/types/gog' import { NileUserData } from './nile' +import { CarnivalUserData } from './carnival' export interface StoreStructure { configStore: { @@ -72,6 +73,9 @@ export interface StoreStructure { nileConfigStore: { userData?: NileUserData } + carnivalConfigStore: { + userData?: CarnivalUserData + } sideloadedStore: { games: GameInfo[] // FIXME: Not sure if this is correct, seems like this key is only used once diff --git a/src/common/utils.ts b/src/common/utils.ts index b3fef0112c..24d8ab9692 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -4,5 +4,6 @@ export const storeMap: { [key in Runner]: string | undefined } = { legendary: 'epic', gog: 'gog', nile: 'amazon', + carnival: 'indiegala', sideload: undefined } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 971cf474da..c8e4e523b4 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -61,6 +61,10 @@ function App() { } /> } /> } /> + } + /> } /> diff --git a/src/frontend/assets/indiegala-logo.svg b/src/frontend/assets/indiegala-logo.svg new file mode 100644 index 0000000000..baee78f0af --- /dev/null +++ b/src/frontend/assets/indiegala-logo.svg @@ -0,0 +1,18 @@ + + + + diff --git a/src/frontend/components/UI/LibraryFilters/index.tsx b/src/frontend/components/UI/LibraryFilters/index.tsx index 9888d00359..1f1f7fb6a7 100644 --- a/src/frontend/components/UI/LibraryFilters/index.tsx +++ b/src/frontend/components/UI/LibraryFilters/index.tsx @@ -10,6 +10,7 @@ const RunnerToStore = { legendary: 'Epic Games', gog: 'GOG', nile: 'Amazon Games', + carnival: 'indieGala', sideload: 'Other' } @@ -75,6 +76,7 @@ export default function LibraryFilters() { legendary: false, gog: false, nile: false, + carnival: false, sideload: false } newFilters = { ...newFilters, [store]: true } @@ -139,6 +141,7 @@ export default function LibraryFilters() { legendary: true, gog: true, nile: true, + carnival: true, sideload: true }) setPlatformsFilters({ @@ -160,6 +163,7 @@ export default function LibraryFilters() { {epic.username && storeToggle('legendary')} {gog.username && storeToggle('gog')} {amazon.username && storeToggle('nile')} + {storeToggle('carnival')} {storeToggle('sideload')}
diff --git a/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx b/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx index 35e217ac11..5c6ba32196 100644 --- a/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx +++ b/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx @@ -44,6 +44,7 @@ export default function SidebarLinks() { epic, gog, platform, + indieGala, refreshLibrary, handleExternalLinkDialog } = useContext(ContextProvider) @@ -56,7 +57,8 @@ export default function SidebarLinks() { const settingsPath = '/settings/app/default/general' - const loggedIn = epic.username || gog.username || amazon.user_id + const loggedIn = + epic.username || gog.username || amazon.user_id || indieGala.username async function handleRefresh() { localStorage.setItem('scrollPosition', '0') @@ -64,7 +66,8 @@ export default function SidebarLinks() { const shouldRefresh = (epic.username && !epic.library.length) || (gog.username && !gog.library.length) || - (amazon.user_id && !amazon.library.length) + (amazon.user_id && !amazon.library.length) || + (indieGala.username && !indieGala.library.length) if (shouldRefresh) { return refreshLibrary({ runInBackground: true }) } @@ -84,9 +87,22 @@ export default function SidebarLinks() { // By default, open Epic Store let defaultStore = '/epicstore' - if (!epic.username && !gog.username && amazon.user_id) { + if ( + !epic.username && + !gog.username && + !indieGala.username && + amazon.user_id + ) { // If only logged in to Amazon Games, open Amazon Gaming defaultStore = '/amazonstore' + } else if ( + indieGala.username && + !epic.username && + !gog.username && + !amazon.user_id + ) { + // If only logged in to indieGala, open that + defaultStore = '/indiegalastore' } else if (!epic.username && gog.username) { // Otherwise, if not logged in to Epic Games, open GOG Store defaultStore = '/gogstore' @@ -185,6 +201,17 @@ export default function SidebarLinks() { > {t('prime-gaming', 'Prime Gaming')} + + classNames('Sidebar__item', 'SidebarLinks__subItem', { + active: isActive + }) + } + to="/indiegalastore" + > + {t('indie-gala', 'IndieGala')} + )} diff --git a/src/frontend/components/UI/StoreLogos/index.tsx b/src/frontend/components/UI/StoreLogos/index.tsx index 923766307e..1081650513 100644 --- a/src/frontend/components/UI/StoreLogos/index.tsx +++ b/src/frontend/components/UI/StoreLogos/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Runner } from 'common/types' import { ReactComponent as EpicLogo } from 'frontend/assets/epic-logo.svg' import { ReactComponent as GOGLogo } from 'frontend/assets/gog-logo.svg' +import { ReactComponent as IndieGalaLogo } from 'frontend/assets/indiegala-logo.svg' import { ReactComponent as SideLoad } from 'frontend/assets/heroic-icon.svg' import { ReactComponent as AmazonLogo } from 'frontend/assets/amazon-logo.svg' @@ -18,6 +19,8 @@ export default function StoreLogos({ return case 'nile': return + case 'carnival': + return default: return } diff --git a/src/frontend/helpers/electronStores.ts b/src/frontend/helpers/electronStores.ts index 9c9055badd..0fc823d2ab 100644 --- a/src/frontend/helpers/electronStores.ts +++ b/src/frontend/helpers/electronStores.ts @@ -148,6 +148,18 @@ const nileConfigStore = new TypeCheckedStoreFrontend('nileConfigStore', { cwd: 'nile_store' }) +const carnivalConfigStore = new TypeCheckedStoreFrontend( + 'carnivalConfigStore', + { + cwd: 'carnival_store' + } +) + +const carnivalLibraryStore = new CacheStore( + 'carnival_library', + null +) + const timestampStore = new TypeCheckedStoreFrontend('timestampStore', { cwd: 'store', name: 'timestamp' @@ -173,6 +185,8 @@ export { sideloadLibrary, wineDownloaderInfoStore, downloadManagerStore, + carnivalConfigStore, + carnivalLibraryStore, nileLibraryStore, nileConfigStore } diff --git a/src/frontend/helpers/library.ts b/src/frontend/helpers/library.ts index 8eb949a539..c4494e6ba5 100644 --- a/src/frontend/helpers/library.ts +++ b/src/frontend/helpers/library.ts @@ -240,5 +240,6 @@ export const epicCategories = ['all', 'legendary', 'epic'] export const gogCategories = ['all', 'gog'] export const sideloadedCategories = ['all', 'sideload'] export const amazonCategories = ['all', 'nile', 'amazon'] +export const indieGalaCatagories = ['all', 'carnival'] export { handleStopInstallation, install, launch, repair, updateGame } diff --git a/src/frontend/screens/Library/LibraryContext.tsx b/src/frontend/screens/Library/LibraryContext.tsx index d1d5d88c5d..e018af4360 100644 --- a/src/frontend/screens/Library/LibraryContext.tsx +++ b/src/frontend/screens/Library/LibraryContext.tsx @@ -3,7 +3,13 @@ import React from 'react' import { LibraryContextType } from 'frontend/types' const initialContext: LibraryContextType = { - storesFilters: { legendary: true, gog: true, nile: true, sideload: true }, + storesFilters: { + legendary: true, + gog: true, + nile: true, + carnival: true, + sideload: true + }, platformsFilters: { win: true, linux: true, mac: true, browser: true }, filterText: '', setStoresFilters: () => null, diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx index af160403d7..4a54bcd04d 100644 --- a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx +++ b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx @@ -690,7 +690,13 @@ export default function DownloadDialog({ diff --git a/src/frontend/screens/Library/index.tsx b/src/frontend/screens/Library/index.tsx index 263e1c61eb..adc6474218 100644 --- a/src/frontend/screens/Library/index.tsx +++ b/src/frontend/screens/Library/index.tsx @@ -30,6 +30,7 @@ import { amazonCategories, epicCategories, gogCategories, + indieGalaCatagories, sideloadedCategories } from 'frontend/helpers/library' import RecentlyPlayed from './components/RecentlyPlayed' @@ -57,6 +58,7 @@ export default React.memo(function Library(): JSX.Element { epic, gog, amazon, + indieGala, sideloadedLibrary, favouriteGames, libraryTopSection, @@ -91,6 +93,7 @@ export default React.memo(function Library(): JSX.Element { legendary: epicCategories.includes(storedCategory), gog: gogCategories.includes(storedCategory), nile: amazonCategories.includes(storedCategory), + carnival: indieGalaCatagories.includes(storedCategory), sideload: sideloadedCategories.includes(storedCategory) } } @@ -371,6 +374,9 @@ export default React.memo(function Library(): JSX.Element { if (storesFilters['nile'] && amazon.username) { displayedStores.push('nile') } + if (storesFilters['carnival'] && indieGala.username) { + displayedStores.push('carnival') + } if (storesFilters['sideload']) { displayedStores.push('sideload') } @@ -382,14 +388,23 @@ export default React.memo(function Library(): JSX.Element { const showEpic = epic.username && displayedStores.includes('legendary') const showGog = gog.username && displayedStores.includes('gog') const showAmazon = amazon.user_id && displayedStores.includes('nile') + const showIndieGala = + indieGala.username && displayedStores.includes('carnival') const showSideloaded = displayedStores.includes('sideload') const epicLibrary = showEpic ? epic.library : [] const gogLibrary = showGog ? gog.library : [] const sideloadedApps = showSideloaded ? sideloadedLibrary : [] const amazonLibrary = showAmazon ? amazon.library : [] - - return [...sideloadedApps, ...epicLibrary, ...gogLibrary, ...amazonLibrary] + const indieGalaLibrary = showIndieGala ? indieGala.library : [] + + return [ + ...sideloadedApps, + ...epicLibrary, + ...gogLibrary, + ...amazonLibrary, + ...indieGalaLibrary + ] } // select library @@ -520,6 +535,7 @@ export default React.memo(function Library(): JSX.Element { epic.library, gog.library, amazon.library, + indieGala.library, filterText, sortDescending, sortInstalled, diff --git a/src/frontend/screens/Login/components/LoginWarning/index.tsx b/src/frontend/screens/Login/components/LoginWarning/index.tsx index 97bdb440e1..70d5597f60 100644 --- a/src/frontend/screens/Login/components/LoginWarning/index.tsx +++ b/src/frontend/screens/Login/components/LoginWarning/index.tsx @@ -6,11 +6,16 @@ import { DialogHeader } from 'frontend/components/UI/Dialog' import { useTranslation } from 'react-i18next' -import { amazonLoginPath, epicLoginPath, gogLoginPath } from '../..' +import { + amazonLoginPath, + epicLoginPath, + gogLoginPath, + indieGalaLoginPath +} from '../..' import { NavLink } from 'react-router-dom' interface LoginWarningProps { - warnLoginForStore: null | 'epic' | 'gog' | 'amazon' + warnLoginForStore: null | 'epic' | 'gog' | 'amazon' | 'indieGala' onClose: () => void } @@ -44,6 +49,12 @@ const LoginWarning = function ({ "You are not logged in with an Amazon account in Heroic. Don't use the store page to login, click the following button instead:" ) loginPath = amazonLoginPath + } else if (warnLoginForStore === 'indieGala') { + textContent = t( + 'not_logged_in.indieGala', + "You are not logged in with an indieGala account in Heroic. Don't use the store page to login, click the following button instead:" + ) + loginPath = indieGalaLoginPath } return ( diff --git a/src/frontend/screens/Login/index.tsx b/src/frontend/screens/Login/index.tsx index dc99fffa60..5f9ce19399 100644 --- a/src/frontend/screens/Login/index.tsx +++ b/src/frontend/screens/Login/index.tsx @@ -8,6 +8,7 @@ import { ReactComponent as EpicLogo } from 'frontend/assets/epic-logo.svg' import { ReactComponent as GOGLogo } from 'frontend/assets/gog-logo.svg' import { ReactComponent as HeroicLogo } from 'frontend/assets/heroic-icon.svg' import { ReactComponent as AmazonLogo } from 'frontend/assets/amazon-logo.svg' +import { ReactComponent as IndieGalaLogo } from 'frontend/assets/indiegala-logo.svg' import { LanguageSelector, UpdateComponent } from '../../components/UI' import { FlagPosition } from '../../components/UI/LanguageSelector' @@ -19,9 +20,11 @@ import { hasHelp } from 'frontend/hooks/hasHelp' export const epicLoginPath = '/loginweb/legendary' export const gogLoginPath = '/loginweb/gog' export const amazonLoginPath = '/loginweb/nile' +export const indieGalaLoginPath = '/loginweb/carnival' export default React.memo(function NewLogin() { - const { epic, gog, amazon, refreshLibrary } = useContext(ContextProvider) + const { epic, gog, amazon, indieGala, refreshLibrary } = + useContext(ContextProvider) const { t } = useTranslation() hasHelp( @@ -38,6 +41,9 @@ export default React.memo(function NewLogin() { const [isAmazonLoggedIn, setIsAmazonLoggedIn] = useState( Boolean(amazon.user_id) ) + const [isIndieGalaLoggedIn, setIsindieGalaLoggedIn] = useState( + Boolean(indieGala?.username) + ) const systemInfo = useAwaited(window.api.systemInfo.get) @@ -68,7 +74,8 @@ export default React.memo(function NewLogin() { setIsEpicLoggedIn(Boolean(epic.username)) setIsGogLoggedIn(Boolean(gog.username)) setIsAmazonLoggedIn(Boolean(amazon.user_id)) - }, [epic.username, gog.username, amazon.user_id, t]) + setIsindieGalaLoggedIn(Boolean(indieGala.username)) + }, [epic.username, gog.username, amazon.user_id, indieGala.username, t]) async function handleLibraryClick() { await refreshLibrary({ runInBackground: false }) @@ -145,6 +152,16 @@ export default React.memo(function NewLogin() { logoutAction={amazon.logout} disabled={oldMac} /> + } + loginUrl={indieGalaLoginPath} + isLoggedIn={isIndieGalaLoggedIn} + user={indieGala?.username || 'Unknown'} + logoutAction={indieGala.logout} + disabled={oldMac} + /> +
diff --git a/src/frontend/screens/WebView/index.tsx b/src/frontend/screens/WebView/index.tsx index 594700ec1c..13281c590c 100644 --- a/src/frontend/screens/WebView/index.tsx +++ b/src/frontend/screens/WebView/index.tsx @@ -17,10 +17,13 @@ import LoginWarning from '../Login/components/LoginWarning' import { NileLoginData } from 'common/types/nile' interface Props { - store?: 'epic' | 'gog' | 'amazon' + store?: 'epic' | 'gog' | 'amazon' | 'indieGala' } -const validStoredUrl = (url: string, store: 'epic' | 'gog' | 'amazon') => { +const validStoredUrl = ( + url: string, + store: 'epic' | 'gog' | 'amazon' | 'indieGala' +) => { switch (store) { case 'epic': return url.includes('epicgames.com') @@ -28,6 +31,8 @@ const validStoredUrl = (url: string, store: 'epic' | 'gog' | 'amazon') => { return url.includes('gog.com') case 'amazon': return url.includes('gaming.amazon.com') + case 'indieGala': + return url.includes('indiegala.com') default: return false } @@ -61,12 +66,14 @@ export default function WebView({ store }: Props) { const epicStore = `https://www.epicgames.com/store/${lang}/` const gogStore = `https://af.gog.com?as=1838482841` const amazonStore = `https://gaming.amazon.com` + const indieGalaStore = `https://www.indiegala.com/` const wikiURL = 'https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/wiki' const gogEmbedRegExp = new RegExp('https://embed.gog.com/on_login_success?') const gogLoginUrl = 'https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=galaxy' + const indieGalaLoginUrl = 'https://www.indiegala.com/login' const trueAsStr = 'true' as unknown as boolean | undefined const { runner } = useParams() as { runner: Runner } @@ -74,11 +81,13 @@ export default function WebView({ store }: Props) { '/epicstore': epicStore, '/gogstore': gogStore, '/amazonstore': amazonStore, + '/indiegalastore': indieGalaStore, '/wiki': wikiURL, '/loginEpic': epicLoginUrl, '/loginGOG': gogLoginUrl, '/loginweb/legendary': epicLoginUrl, '/loginweb/gog': gogLoginUrl, + '/loginweb/carnival': indieGalaLoginUrl, '/loginweb/nile': amazonLoginData ? amazonLoginData.url : '' } let startUrl = urls[pathname] @@ -86,7 +95,8 @@ export default function WebView({ store }: Props) { if (store) { sessionStorage.setItem('last-store', `/${store}store`) const lastUrl = sessionStorage.getItem(`last-url-${store}`) - if (lastUrl && validStoredUrl(lastUrl, store)) { + if (lastUrl && validStoredUrl(lastUrl, store) && lastUrl !== startUrl) { + window.api.logDebug(`lastUrl: ${lastUrl}, startUrl: ${startUrl}`) startUrl = lastUrl } } @@ -243,6 +253,20 @@ export default function WebView({ store }: Props) { const onNavigate = () => { const url = webview.getURL() if (validStoredUrl(url, store)) { + if (store === 'indieGala') { + const prevURL = sessionStorage.getItem('last-url-indieGala') + const pageURL = webview.getURL() + const parsedURL = new URL(pageURL) + const parsedPrevURL = new URL( + prevURL ? prevURL : 'https://example.com/example' + ) + if ( + parsedPrevURL.pathname === '/login' && + parsedURL.pathname === '/' + ) { + window.api.authCarnival() + } + } sessionStorage.setItem(`last-url-${store}`, webview.getURL()) } } @@ -262,7 +286,7 @@ export default function WebView({ store }: Props) { }, [webviewRef.current, store]) const [showLoginWarningFor, setShowLoginWarningFor] = useState< - null | 'epic' | 'gog' | 'amazon' + null | 'epic' | 'gog' | 'amazon' | 'indieGala' >(null) useEffect(() => { diff --git a/src/frontend/state/ContextProvider.tsx b/src/frontend/state/ContextProvider.tsx index 646431d97b..5366045886 100644 --- a/src/frontend/state/ContextProvider.tsx +++ b/src/frontend/state/ContextProvider.tsx @@ -25,6 +25,23 @@ const initialContext: ContextType = { login: async () => Promise.resolve(''), logout: async () => Promise.resolve() }, + indieGala: { + getIndieGalaUserData: async () => + Promise.resolve({ + status: '', + user_id: '', + user_found: false, + username: '', + email: '' + }), + library: [], + login: async function (): Promise { + throw new Error('Function not implemented.') + }, + logout: async function (): Promise { + throw new Error('Function not implemented.') + } + }, installingEpicGame: false, sideloadedLibrary: [], error: false, diff --git a/src/frontend/state/GlobalState.tsx b/src/frontend/state/GlobalState.tsx index 292d37b6d4..6fe6d1aacf 100644 --- a/src/frontend/state/GlobalState.tsx +++ b/src/frontend/state/GlobalState.tsx @@ -32,6 +32,8 @@ import ContextProvider from './ContextProvider' import { InstallModal } from 'frontend/screens/Library/components' import { + carnivalConfigStore, + carnivalLibraryStore, configStore, gogConfigStore, gogInstalledGamesStore, @@ -72,6 +74,10 @@ interface StateProps { user_id?: string username?: string } + indieGala: { + library: GameInfo[] + username?: string + } wineVersions: WineVersionInfo[] error: boolean gameUpdates: string[] @@ -152,6 +158,12 @@ class GlobalState extends PureComponent { return games } + + loaIndieGalaLibrary = (): Array => { + const games = carnivalLibraryStore.get('library', []) + + return games + } state: StateProps = { epic: { library: libraryStore.get('library', []), @@ -166,6 +178,10 @@ class GlobalState extends PureComponent { user_id: nileConfigStore.get_nodefault('userData.user_id'), username: nileConfigStore.get_nodefault('userData.name') }, + indieGala: { + library: this.loaIndieGalaLibrary(), + username: carnivalConfigStore.get_nodefault('userData.username') + }, wineVersions: wineDownloaderInfoStore.get('wine-releases', []), error: false, gameUpdates: [], @@ -537,6 +553,31 @@ class GlobalState extends PureComponent { getAmazonLoginData = async () => window.api.getAmazonLoginData() + getIndieGalaUserInfo = async () => window.api.getIndieGalaUserInfo() + carnivalLogout = async () => { + await window.api.logoutCarnival() + this.setState({ + indieGala: { + library: [], + username: null + } + }) + } + + carnivalLogin = async () => { + await window.api.authCarnival() + const userInfo = await window.api.getIndieGalaUserInfo() + if (userInfo && userInfo.username) { + this.setState({ + indieGala: { + library: this.loaIndieGalaLibrary(), + username: userInfo.username + } + }) + return userInfo.username + } + return '' + } handleSettingsModalOpen = ( value: boolean, type?: 'settings' | 'log' | 'category', @@ -559,7 +600,7 @@ class GlobalState extends PureComponent { ): Promise => { console.log('refreshing') - const { epic, gog, amazon, gameUpdates } = this.state + const { epic, gog, amazon, indieGala, gameUpdates } = this.state let updates = gameUpdates if (checkUpdates) { @@ -593,6 +634,16 @@ class GlobalState extends PureComponent { amazonLibrary = this.loadAmazonLibrary() } + let carnivalLibrary = carnivalLibraryStore.get('library', []) + if ( + indieGala.username && + (!carnivalLibrary.length || !indieGala.library.length) + ) { + window.api.logInfo('No cache found, getting data from freecarival...') + await window.api.refreshLibrary('carnival') + carnivalLibrary = this.loaIndieGalaLibrary() + } + const updatedSideload = sideloadLibrary.get('games', []) this.setState({ @@ -609,6 +660,10 @@ class GlobalState extends PureComponent { user_id: amazon.user_id, username: amazon.username }, + indieGala: { + library: carnivalLibrary, + username: indieGala.username + }, gameUpdates: updates, refreshing: false, refreshingInTheBackground: true, @@ -863,6 +918,12 @@ class GlobalState extends PureComponent { await window.api.getAmazonUserInfo() } + const indieGalaUser = carnivalConfigStore.has('userData') + + if (indieGalaUser) { + await window.api.getIndieGalaUserInfo() + } + if (!gameUpdates.length) { const storedGameUpdates = JSON.parse(storage.getItem('updates') || '[]') this.setState({ gameUpdates: storedGameUpdates }) @@ -870,7 +931,7 @@ class GlobalState extends PureComponent { this.setState({ platform }) - if (legendaryUser || gogUser || amazonUser) { + if (legendaryUser || gogUser || amazonUser || indieGalaUser) { this.refreshLibrary({ checkForUpdates: true, runInBackground: Boolean(epic.library?.length) @@ -951,6 +1012,7 @@ class GlobalState extends PureComponent { epic, gog, amazon, + indieGala, favouriteGames, customCategories, hiddenGames, @@ -989,6 +1051,13 @@ class GlobalState extends PureComponent { login: this.amazonLogin, logout: this.amazonLogout }, + indieGala: { + getIndieGalaUserData: this.getIndieGalaUserInfo, + username: indieGala.username, + library: indieGala.library, + login: this.carnivalLogin, + logout: this.carnivalLogout + }, installingEpicGame, setLanguage: this.setLanguage, isRTL, diff --git a/src/frontend/types.ts b/src/frontend/types.ts index ec35934dcf..038aaa43ae 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -16,9 +16,16 @@ import { Status, InstallInfo } from 'common/types' +import { CarnivalUserData } from 'common/types/carnival' import { NileLoginData, NileRegisterData } from 'common/types/nile' -export type Category = 'all' | 'legendary' | 'gog' | 'sideload' | 'nile' +export type Category = + | 'all' + | 'legendary' + | 'gog' + | 'sideload' + | 'nile' + | 'carnival' export interface ContextType { error: boolean @@ -81,6 +88,13 @@ export interface ContextType { login: (data: NileRegisterData) => Promise logout: () => Promise } + indieGala: { + library: GameInfo[] + username?: string + login: () => Promise + logout: () => Promise + getIndieGalaUserData: () => Promise + } installingEpicGame: boolean allTilesInColor: boolean setAllTilesInColor: (value: boolean) => void @@ -194,6 +208,7 @@ export interface StoresFilters { legendary: boolean gog: boolean nile: boolean + carnival: boolean sideload: boolean } diff --git a/yarn.lock b/yarn.lock index 3f90c97408..235092d183 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1646,6 +1646,11 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/json-schema@^7.0.9": version "7.0.14" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.14.tgz#74a97a5573980802f32c8e47b663530ab3b6b7d1"