diff --git a/package.json b/package.json index b59a78edb0..17f219a95b 100644 --- a/package.json +++ b/package.json @@ -176,12 +176,14 @@ "json5": "2.2.3", "plist": "3.0.5", "react": "18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", "react-i18next": "12.2.0", "react-markdown": "8.0.5", "react-router-dom": "6.9.0", "recharts": "2.4.3", "sanitize-filename": "1.6.3", + "sanitize-html": "^2.11.0", "semver": "7.5.2", "shlex": "2.1.2", "short-uuid": "4.2.2", @@ -237,8 +239,10 @@ "@types/node": "18.15.0", "@types/plist": "3.0.2", "@types/react": "18.2.34", + "@types/react-beautiful-dnd": "13.1.4", "@types/react-dom": "18.2.14", "@types/react-router-dom": "5.3.3", + "@types/sanitize-html": "2.9.0", "@types/tmp": "0.2.3", "@typescript-eslint/eslint-plugin": "5.47.1", "@typescript-eslint/parser": "5.47.1", diff --git a/public/bin/darwin/gogdl b/public/bin/darwin/gogdl index fa862445cc..603d19c872 100755 Binary files a/public/bin/darwin/gogdl and b/public/bin/darwin/gogdl differ diff --git a/public/bin/linux/gogdl b/public/bin/linux/gogdl index 16c9e3d8e4..442c9bab1a 100755 Binary files a/public/bin/linux/gogdl and b/public/bin/linux/gogdl differ diff --git a/public/bin/win32/gogdl.exe b/public/bin/win32/gogdl.exe old mode 100644 new mode 100755 index ba9df1ae8f..a1112dafe1 Binary files a/public/bin/win32/gogdl.exe and b/public/bin/win32/gogdl.exe differ diff --git a/public/locales/en/gamepage.json b/public/locales/en/gamepage.json index cc9c7b2d1d..f18c112488 100644 --- a/public/locales/en/gamepage.json +++ b/public/locales/en/gamepage.json @@ -51,6 +51,7 @@ "button": { "add_to_favourites": "Add To Favourites", "cancel": "Pause/Cancel", + "changelog": "Show Changelog", "continue": "Continue Download", "details": "Details", "finish": "Finish", @@ -76,12 +77,20 @@ }, "cloud_save_unsupported": "Unsupported", "disabled": "Disabled", - "dlc": { - "installDlcs": "Install all DLCs" - }, "enabled": "Enabled", "game": { - "dlcs": "DLCs", + "branch": { + "disabled": "Disabled", + "password": "Set private channel password", + "select": "Select beta channel", + "setPrivateBranchPassword": "Set private channel password" + }, + "builds": { + "buildsSelector": "Select game version", + "toggle": "Keep the game at specific version", + "version": "Version" + }, + "changelogFor": "Changelog for {{gameTitle}}", "downloadSize": "Download Size", "firstPlayed": "First Played", "getting-download-size": "Getting download size", @@ -89,6 +98,7 @@ "installSize": "Install Size", "language": "Language", "lastPlayed": "Last Played", + "modify": "Modify Installation", "neverPlayed": "Never", "platform": "Select Platform Version to Install", "requirements": "System Requirements", @@ -158,7 +168,7 @@ "start": "Play Now", "stop": "Playing (Stop)" }, - "prerequisites": "Installing Prerequisites", + "redist": "Installing Redistributables", "saves": { "syncing": "Syncing Saves" }, @@ -167,6 +177,15 @@ "launch": { "options": "Launch Options..." }, + "modifyInstall": { + "dlcsCollapsible": "DLC", + "nodlcs": "No DLC available", + "redMod": { + "collapsible": "REDmod Integration", + "enable": "Enable mods" + }, + "versionCollapsable": "Game Version" + }, "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:", @@ -222,9 +241,9 @@ "notSupported": "Not supported", "notSupportedGame": "Not Supported", "playing": "Playing", - "prerequisites": "Installing Prerequisites", "processing": "Processing files, please wait", "queued": "Queued", + "redist": "Installing Redistributables ({{redist}})", "reparing": "Repairing Game, please wait", "syncingSaves": "Syncing Saves", "this-game-uses-third-party": "This game uses third party launcher and it is not supported yet", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f58584796a..01a05d5b0f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -234,6 +234,7 @@ }, "favourites": "Favourites", "game": { + "modify": "Modify Installation", "status": "Status", "store": "Store", "title": "Game Title" diff --git a/src/backend/api/downloadmanager.ts b/src/backend/api/downloadmanager.ts index 427f031508..3ae783da12 100644 --- a/src/backend/api/downloadmanager.ts +++ b/src/backend/api/downloadmanager.ts @@ -14,12 +14,15 @@ export const install = async (args: InstallParams) => { ipcRenderer.invoke('addToDMQueue', dmQueueElement) // Add Dlcs to the queue - if (Array.isArray(args.installDlcs) && args.installDlcs.length > 0) { + if ( + Array.isArray(args.installDlcs) && + args.installDlcs.length > 0 && + args.runner === 'legendary' + ) { args.installDlcs.forEach(async (dlc) => { const dlcArgs: InstallParams = { ...args, - appName: dlc, - installDlcs: false + appName: dlc } const dlcQueueElement: DMQueueElement = { params: dlcArgs, diff --git a/src/backend/api/helpers.ts b/src/backend/api/helpers.ts index 4b3718d19d..6a8250183b 100644 --- a/src/backend/api/helpers.ts +++ b/src/backend/api/helpers.ts @@ -73,6 +73,21 @@ export const getExtraInfo = async (appName: string, runner: Runner) => export const getLaunchOptions = async (appName: string, runner: Runner) => ipcRenderer.invoke('getLaunchOptions', appName, runner) +export const getPrivateBranchPassword = async (appName: string) => + ipcRenderer.invoke('getPrivateBranchPassword', appName) +export const setPrivateBranchPassword = async ( + appName: string, + password: string +) => ipcRenderer.invoke('setPrivateBranchPassword', appName, password) + +// REDmod integration +export const getAvailableCyberpunkMods = async () => + ipcRenderer.invoke('getAvailableCyberpunkMods') +export const setCyberpunModConfig = async (props: { + enabled: boolean + modsToLoad: string[] +}) => ipcRenderer.invoke('setCyberpunkModConfig', props) + export const getGameSettings = async ( appName: string, runner: Runner @@ -82,8 +97,18 @@ export const getGameSettings = async ( export const getInstallInfo = async ( appName: string, runner: Runner, - installPlatform: InstallPlatform -) => ipcRenderer.invoke('getInstallInfo', appName, runner, installPlatform) + installPlatform: InstallPlatform, + build?: string, + branch?: string +) => + ipcRenderer.invoke( + 'getInstallInfo', + appName, + runner, + installPlatform, + build, + branch + ) export const runWineCommand = async (args: WineCommandArgs) => ipcRenderer.invoke('runWineCommand', args) diff --git a/src/backend/api/library.ts b/src/backend/api/library.ts index f3ae8b40c6..71cfaf82d4 100644 --- a/src/backend/api/library.ts +++ b/src/backend/api/library.ts @@ -97,6 +97,12 @@ export const handleRecentGamesChanged = (callback: any) => { export const addNewApp = (args: GameInfo) => ipcRenderer.send('addNewApp', args) +export const changeGameVersionPinnedStatus = ( + appName: string, + runner: Runner, + status: boolean +) => ipcRenderer.send('changeGameVersionPinnedStatus', appName, runner, status) + export const getGameOverride = async () => ipcRenderer.invoke('getGameOverride') export const getGameSdl = async (appName: string) => diff --git a/src/backend/constants.ts b/src/backend/constants.ts index 807dcf53bb..eeea8c3ee9 100644 --- a/src/backend/constants.ts +++ b/src/backend/constants.ts @@ -56,10 +56,13 @@ const appFolder = join(configFolder, 'heroic') const legendaryConfigPath = isSnap ? join(env.XDG_CONFIG_HOME!, 'legendary') : join(appFolder, 'legendaryConfig', 'legendary') +const gogdlConfigPath = join(appFolder, 'gogdlConfig', 'heroic_gogdl') +const gogSupportPath = join(gogdlConfigPath, 'gog-support') const nileConfigPath = join(appFolder, 'nile_config', 'nile') const configPath = join(appFolder, 'config.json') const gamesConfigPath = join(appFolder, 'GamesConfig') const toolsPath = join(appFolder, 'tools') +const gogRedistPath = join(toolsPath, 'redist', 'gog') const heroicIconFolder = join(appFolder, 'icons') const runtimePath = join(toolsPath, 'runtimes') const userInfo = join(legendaryConfigPath, 'user.json') @@ -273,6 +276,9 @@ export { wineprefixFAQ, customThemesWikiLink, gogdlAuthConfig, + gogdlConfigPath, + gogSupportPath, + gogRedistPath, vulkanHelperBin, nileConfigPath, nileInstalled, diff --git a/src/backend/downloadmanager/downloadqueue.ts b/src/backend/downloadmanager/downloadqueue.ts index cd1a690256..45630285dd 100644 --- a/src/backend/downloadmanager/downloadqueue.ts +++ b/src/backend/downloadmanager/downloadqueue.ts @@ -8,6 +8,7 @@ import { sendFrontendMessage } from '../main_window' import { callAbortController } from 'backend/utils/aborthandler/aborthandler' import { notify } from '../dialog/dialog' import i18next from 'i18next' +import { createRedistDMQueueElement } from 'backend/storeManagers/gog/redist' const downloadManager = new TypeCheckedStoreBackend('downloadManager', { cwd: 'store', @@ -115,7 +116,9 @@ async function addToQueue(element: DMQueueElement) { const elements = downloadManager.get('queue', []) const elementIndex = elements.findIndex( - (el) => el.params.appName === element.params.appName + (el) => + el.params.appName === element.params.appName && + el.params.runner === element.params.runner ) if (elementIndex >= 0) { @@ -123,11 +126,31 @@ async function addToQueue(element: DMQueueElement) { } else { const installInfo = await libraryManagerMap[ element.params.runner - ].getInstallInfo(element.params.appName, element.params.platformToInstall) + ].getInstallInfo( + element.params.appName, + element.params.platformToInstall, + element.params.branch, + element.params.build + ) element.params.size = installInfo?.manifest?.download_size ? getFileSize(installInfo?.manifest?.download_size) : '?? MB' + + if ( + element.params.runner === 'gog' && + element.params.platformToInstall.toLowerCase() === 'windows' && + installInfo && + 'dependencies' in installInfo.manifest + ) { + const newDependencies = installInfo.manifest.dependencies + if (newDependencies?.length) { + // create redist element + const redistElement = createRedistDMQueueElement() + redistElement.params.dependencies = newDependencies + elements.push(redistElement) + } + } elements.push(element) } @@ -226,6 +249,12 @@ function stopCurrentDownload() { // notify the user based on the status of the element and the status of the queue function processNotification(element: DMQueueElement, status: DMStatus) { const action = element.type === 'install' ? 'Installation' : 'Update' + if ( + element.params.runner === 'gog' && + element.params.appName === 'gog-redist' + ) { + return + } const { title } = gameManagerMap[element.params.runner].getGameInfo( element.params.appName ) diff --git a/src/backend/downloadmanager/utils.ts b/src/backend/downloadmanager/utils.ts index 5b2351fb70..547e62237b 100644 --- a/src/backend/downloadmanager/utils.ts +++ b/src/backend/downloadmanager/utils.ts @@ -26,7 +26,9 @@ async function installQueueElement(params: InstallParams): Promise<{ sdlList = [], runner, installLanguage, - platformToInstall + platformToInstall, + build, + branch } = params const { title } = gameManagerMap[runner].getGameInfo(appName) @@ -82,7 +84,9 @@ async function installQueueElement(params: InstallParams): Promise<{ installDlcs, sdlList: sdlList.filter((el) => el !== ''), platformToInstall, - installLanguage + installLanguage, + build, + branch }) if (status === 'error') { @@ -151,7 +155,13 @@ async function updateQueueElement(params: InstallParams): Promise<{ } try { - const { status } = await gameManagerMap[runner].update(appName) + const { status } = await gameManagerMap[runner].update(appName, { + build: params.build, + branch: params.branch, + language: params.installLanguage, + dlcs: params.installDlcs, + dependencies: params.dependencies + }) if (status === 'error') { errorMessage('') diff --git a/src/backend/launcher.ts b/src/backend/launcher.ts index c7d5e5fed8..fe46eb9851 100644 --- a/src/backend/launcher.ts +++ b/src/backend/launcher.ts @@ -55,7 +55,7 @@ import { import { GlobalConfig } from './config' import { GameConfig } from './game_config' import { DXVK, Winetricks } from './tools' -import setup from './storeManagers/gog/setup' +import gogSetup from './storeManagers/gog/setup' import nileSetup from './storeManagers/nile/setup' import { spawn, spawnSync } from 'child_process' import shlex from 'shlex' @@ -68,6 +68,7 @@ import { readFileSync } from 'fs' import { LegendaryCommand } from './storeManagers/legendary/commands' import { commandToArgsArray } from './storeManagers/legendary/library' import { searchForExecutableOnPath } from './utils/os/path' +import { sendFrontendMessage } from './main_window' import { createAbortController, deleteAbortController @@ -238,7 +239,7 @@ async function prepareLaunch( } } // for native games lets use scout for now - const runtimeType = isNative ? 'scout' : nonNativeRuntime + const runtimeType = isNative ? 'sniper' : nonNativeRuntime const { path, args } = await getSteamRuntime(runtimeType) if (!path) { return { @@ -255,13 +256,7 @@ async function prepareLaunch( } } - steamRuntime = [ - path, - isNative || !gameInfo.install['install_path'] - ? '' - : `--filesystem=${gameInfo.install['install_path']}`, - ...args - ] + steamRuntime = [path, ...args] } return { @@ -337,7 +332,12 @@ async function prepareWineLaunch( LogPrefix.Backend ) if (runner === 'gog') { - await setup(appName) + await gogSetup(appName) + sendFrontendMessage('gameStatusUpdate', { + appName, + runner: 'gog', + status: 'playing' + }) } if (runner === 'nile') { await nileSetup(appName) @@ -378,13 +378,21 @@ async function prepareWineLaunch( await download('battleye_runtime') } - const { folder_name: installFolderName, install } = + if (gameSettings.eacRuntime && !isInstalled('eac_runtime') && isOnline()) { + await download('eac_runtime') + } + + if ( + gameSettings.battlEyeRuntime && + !isInstalled('battleye_runtime') && + isOnline() + ) { + await download('battleye_runtime') + } + + const { folder_name: installFolderName } = gameManagerMap[runner].getGameInfo(appName) - const envVars = setupWineEnvVars( - gameSettings, - installFolderName, - install.install_path - ) + const envVars = setupWineEnvVars(gameSettings, installFolderName) return { success: true, envVars: envVars } } @@ -420,7 +428,7 @@ async function installFixes(appName: string, runner: Runner) { * @param gameSettings The GameSettings to get the environment variables for * @returns A big string of environment variables, structured key=value */ -function setupEnvVars(gameSettings: GameSettings) { +function setupEnvVars(gameSettings: GameSettings, installPath?: string) { const ret: Record = {} if (gameSettings.nvidiaPrime) { ret.DRI_PRIME = '1' @@ -432,6 +440,11 @@ function setupEnvVars(gameSettings: GameSettings) { ret.MTL_HUD_ENABLED = '1' } + if (isLinux && installPath) { + // Used by steam runtime to mount the game directory to the container + ret.STEAM_COMPAT_INSTALL_PATH = installPath + } + if (gameSettings.enviromentOptions) { gameSettings.enviromentOptions.forEach((envEntry: EnviromentVariable) => { ret[envEntry.key] = removeQuoteIfNecessary(envEntry.value) @@ -483,11 +496,7 @@ function setupWrapperEnvVars(wrapperEnv: WrapperEnv) { * @param gameId If Proton and the Steam Runtime are used, the SteamGameId variable will be set to `heroic-gameId` * @returns A Record that can be passed to execAsync/spawn */ -function setupWineEnvVars( - gameSettings: GameSettings, - gameId = '0', - installPath?: string -) { +function setupWineEnvVars(gameSettings: GameSettings, gameId = '0') { const { wineVersion, winePrefix, wineCrossoverBottle } = gameSettings const ret: Record = {} @@ -519,9 +528,6 @@ function setupWineEnvVars( case 'proton': ret.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamInstallPath ret.STEAM_COMPAT_DATA_PATH = winePrefix - if (installPath) { - ret.STEAM_COMPAT_INSTALL_PATH = installPath - } break case 'crossover': ret.CX_BOTTLE = wineCrossoverBottle @@ -530,6 +536,7 @@ function setupWineEnvVars( ret.WINEPREFIX = winePrefix break } + if (gameSettings.showFps) { isMac ? (ret.MTL_HUD_ENABLED = '1') : (ret.DXVK_HUD = 'fps') } @@ -758,7 +765,11 @@ async function runWineCommand({ options, startFolder, skipPrefixCheckIKnowWhatImDoing = false -}: WineCommandArgs): Promise<{ stderr: string; stdout: string }> { +}: WineCommandArgs): Promise<{ + stderr: string + stdout: string + code?: number +}> { const settings = gameSettings ? gameSettings : GlobalConfig.get().getSettings() @@ -802,8 +813,8 @@ async function runWineCommand({ const env_vars = { ...process.env, - ...setupEnvVars(settings), - ...setupWineEnvVars(settings, installFolderName, gameInstallPath) + ...setupEnvVars(settings, gameInstallPath), + ...setupWineEnvVars(settings, installFolderName) } const isProton = wineVersion.type === 'proton' @@ -872,8 +883,12 @@ async function runWineCommand({ stderr.push(data.trim()) }) - child.on('close', async () => { - const response = { stderr: stderr.join(''), stdout: stdout.join('') } + child.on('close', async (code) => { + const response = { + stderr: stderr.join(''), + stdout: stdout.join(''), + code + } if (wait && wineVersion.wineserver) { await new Promise((res_wait) => { diff --git a/src/backend/main.ts b/src/backend/main.ts index 6d884991ba..c485f49a64 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -134,6 +134,9 @@ import { import * as GOGLibraryManager from 'backend/storeManagers/gog/library' import { + getCyberpunkMods, + getBranchPassword, + setBranchPassword, getGOGPlaytime, syncQueuedPlaytimeGOG, updateGOGPlaytime @@ -254,6 +257,15 @@ async function initializeWindow(): Promise { autoUpdater.checkForUpdates() } + // Changelog links workaround + mainWindow.webContents.on('will-navigate', (event, url) => { + const pattern = app.isPackaged ? publicDir : 'localhost:5173' + if (!url.match(pattern)) { + event.preventDefault() + openUrlOrFile(url) + } + }) + mainWindow.webContents.setWindowOpenHandler((details) => { const pattern = app.isPackaged ? publicDir : 'localhost:5173' return { action: !details.url.match(pattern) ? 'allow' : 'deny' } @@ -297,8 +309,8 @@ if (!gotTheLock) { }) app.whenReady().then(async () => { initLogger() - initStoreManagers() initOnlineMonitor() + initStoreManagers() initImagesCache() // Add User-Agent Client hints to behave like Windows @@ -774,11 +786,13 @@ ipcMain.handle('getGOGLinuxInstallersLangs', async (event, appName) => ipcMain.handle( 'getInstallInfo', - async (event, appName, runner, installPlatform) => { + async (event, appName, runner, installPlatform, build, branch) => { try { const info = await libraryManagerMap[runner].getInstallInfo( appName, - installPlatform + installPlatform, + branch, + build ) if (info === undefined) return null return info @@ -1191,7 +1205,7 @@ ipcMain.handle( let uninstalled = false try { - await gameManagerMap[runner].uninstall({ appName }) + await gameManagerMap[runner].uninstall({ appName, shouldRemovePrefix }) uninstalled = true } catch (error) { notify({ @@ -1701,6 +1715,22 @@ ipcMain.handle( } ) +ipcMain.handle('getPrivateBranchPassword', (e, appName) => + getBranchPassword(appName) +) +ipcMain.handle('setPrivateBranchPassword', (e, appName, password) => + setBranchPassword(appName, password) +) + +ipcMain.handle('getAvailableCyberpunkMods', async () => getCyberpunkMods()) +ipcMain.handle('setCyberpunkModConfig', async (e, props) => + GOGLibraryManager.setCyberpunkModConfig(props) +) + +ipcMain.on('changeGameVersionPinnedStatus', (e, appName, runner, status) => { + libraryManagerMap[runner].changeVersionPinnedStatus(appName, status) +}) + /* Other Keys that should go into translation files: t('box.error.generic.title') diff --git a/src/backend/shortcuts/shortcuts/shortcuts.ts b/src/backend/shortcuts/shortcuts/shortcuts.ts index 7b0170c8c9..696c7ff29f 100644 --- a/src/backend/shortcuts/shortcuts/shortcuts.ts +++ b/src/backend/shortcuts/shortcuts/shortcuts.ts @@ -14,7 +14,6 @@ import { IconIcns } from '@shockpkg/icon-encoder' import { join } from 'path' import { logError, logInfo, LogPrefix } from '../../logger/logger' import { GlobalConfig } from '../../config' -import { removeSpecialcharacters } from '../../utils' import { GameInfo } from 'common/types' import { isMac, userHome } from '../../constants' import { getIcon } from '../utils' @@ -52,7 +51,7 @@ async function addShortcuts(gameInfo: GameInfo, fromMenu?: boolean) { case 'linux': { const icon = await getIcon(gameInfo.app_name, gameInfo) const shortcut = `[Desktop Entry] -Name=${removeSpecialcharacters(gameInfo.title)} +Name=${gameInfo.title} Exec=xdg-open ${launchWithProtocol} Terminal=false Type=Application diff --git a/src/backend/shortcuts/utils.ts b/src/backend/shortcuts/utils.ts index 48dd1c1671..7e41b524c3 100644 --- a/src/backend/shortcuts/utils.ts +++ b/src/backend/shortcuts/utils.ts @@ -68,6 +68,20 @@ async function getIcon(appName: string, gameInfo: GameInfo) { let icon = `${iconsFolder}/${appName}.jpg` if (gameInfo.runner === 'gog') { + const icoPath = join( + gameInfo.install.install_path!, + `goggame-${appName}.ico` + ) + const linuxNativePath = join( + gameInfo.install.install_path!, + 'support', + 'icon.png' + ) + if (existsSync(icoPath)) { + return icoPath + } else if (existsSync(linuxNativePath)) { + return linuxNativePath + } const productApiData = await getProductApi(appName) if (productApiData && productApiData.data.images?.icon) { image = 'https:' + productApiData.data.images?.icon diff --git a/src/backend/storeManagers/gog/electronStores.ts b/src/backend/storeManagers/gog/electronStores.ts index dba8d0428f..cbecddb3ec 100644 --- a/src/backend/storeManagers/gog/electronStores.ts +++ b/src/backend/storeManagers/gog/electronStores.ts @@ -29,6 +29,12 @@ const syncStore = new TypeCheckedStoreBackend('gogSyncStore', { const installInfoStore = new CacheStore('gog_install_info') +const privateBranchesStore = new TypeCheckedStoreBackend('gogPrivateBranches', { + cwd: 'gog_store', + name: 'privateBranches', + clearInvalidConfig: true +}) + const playtimeSyncQueue = new CacheStore>( 'gog_playtime_sync_queue' ) @@ -40,5 +46,6 @@ export { libraryStore, syncStore, installInfoStore, - playtimeSyncQueue + playtimeSyncQueue, + privateBranchesStore } diff --git a/src/backend/storeManagers/gog/games.ts b/src/backend/storeManagers/gog/games.ts index ddbfa6687b..862826c571 100644 --- a/src/backend/storeManagers/gog/games.ts +++ b/src/backend/storeManagers/gog/games.ts @@ -8,6 +8,7 @@ import { getGameInfo as getGogLibraryGameInfo, changeGameInstallPath, getMetaResponse, + getProductApi, getGamesData } from './library' import { join } from 'path' @@ -17,13 +18,13 @@ import { errorHandler, getFileSize, getGOGdlBin, - killPattern, spawnAsync, moveOnUnix, moveOnWindows, shutdownWine, sendProgressUpdate, - sendGameStatusUpdate + sendGameStatusUpdate, + getPathDiskSize } from '../../utils' import { ExtraInfo, @@ -37,12 +38,19 @@ import { LaunchOption, BaseLaunchOption } from 'common/types' -import { existsSync, rmSync } from 'graceful-fs' -import { isWindows, isMac, isLinux } from '../../constants' +import { appendFileSync, existsSync, rmSync } from 'graceful-fs' +import { + gogSupportPath, + gogdlConfigPath, + isWindows, + isMac, + isLinux +} from '../../constants' import { configStore, installedGamesStore, playtimeSyncQueue, + privateBranchesStore, syncStore } from './electronStores' import { @@ -62,6 +70,7 @@ import { launchCleanup, prepareLaunch, prepareWineLaunch, + runWineCommand, runWineCommand as runWineCommandUtil, setupEnvVars, setupWrapperEnvVars, @@ -87,6 +96,10 @@ import { RemoveArgs } from 'common/types/game_manager' import { getWineFlagsArray } from 'backend/utils/compatibility_layers' import axios, { AxiosError } from 'axios' import { isOnline, runOnceWhenOnline } from 'backend/online_monitor' +import { readdir, readFile } from 'fs/promises' +import { statSync } from 'fs' +import ini from 'ini' +import { getRequiredRedistList, updateRedist } from './redist' export async function getExtraInfo(appName: string): Promise { const gameInfo = getGameInfo(appName) @@ -101,18 +114,24 @@ export async function getExtraInfo(appName: string): Promise { } const reqs = await createReqsArray(appName, targetPlatform) - const data = await getGamesData(appName) - const storeUrl = data?._links.store.href - const releaseDate = data?._embedded.product?.globalReleaseDate?.substring( - 0, - 19 - ) + const productInfo = await getProductApi(appName, ['changelog']) + + const gamesData = await getGamesData(appName) + + const gogStoreUrl = gamesData?._links?.store.href + const releaseDate = + gamesData?._embedded.product?.globalReleaseDate?.substring(0, 19) + + const storeUrl = new URL(gogStoreUrl) + storeUrl.hostname = 'af.gog.com' + storeUrl.searchParams.set('as', '1838482841') const extra: ExtraInfo = { about: gameInfo.extra?.about, reqs, releaseDate, - storeUrl + storeUrl: storeUrl.toString(), + changelog: productInfo?.data.changelog } return extra } @@ -273,14 +292,27 @@ export function onInstallOrUpdateOutput( export async function install( appName: string, - { path, installDlcs, platformToInstall, installLanguage }: InstallArgs + { + path, + installDlcs, + platformToInstall, + installLanguage, + build, + branch + }: InstallArgs ): Promise<{ status: 'done' | 'error' | 'abort' error?: string }> { const { maxWorkers } = GlobalConfig.get().getSettings() const workers = maxWorkers ? ['--max-workers', `${maxWorkers}`] : [] - const withDlcs = installDlcs ? '--with-dlcs' : '--skip-dlcs' + const privateBranchPassword = privateBranchesStore.get(appName, '') + const withDlcs = installDlcs?.length + ? ['--with-dlcs', '--dlcs', installDlcs.join(',')] + : ['--skip-dlcs'] + + const buildArgs = build ? ['--build', build] : [] + const branchArgs = branch ? ['--branch', branch] : [] const credentials = await GOGUser.getCredentials() @@ -302,14 +334,22 @@ export async function install( appName, '--platform', installPlatform, - `--path=${path}`, - '--token', - `"${credentials.access_token}"`, - withDlcs, - `--lang=${installLanguage}`, + '--path', + path, + '--support', + join(gogSupportPath, appName), + ...withDlcs, + '--lang', + String(installLanguage), + ...buildArgs, + ...branchArgs, ...workers ] + if (privateBranchPassword.length) { + commandParts.push('--password', privateBranchPassword) + } + const onOutput = (data: string) => { onInstallOrUpdateOutput(appName, 'installing', data) } @@ -335,7 +375,12 @@ export async function install( // Installation succeded // Save new game info to installed games store - const installInfo = await getInstallInfo(appName, installPlatform) + const installInfo = await getInstallInfo( + appName, + installPlatform, + branch, + build + ) if (installInfo === undefined) { logError('install info is undefined in GOG install', LogPrefix.Gog) return { status: 'error' } @@ -350,22 +395,28 @@ export async function install( logError('game info folder is undefined in GOG install', LogPrefix.Gog) return { status: 'error' } } + + const sizeOnDisk = await getPathDiskSize(join(path, gameInfo.folder_name)) + const installedData: InstalledInfo = { platform: installPlatform, executable: '', install_path: join(path, gameInfo.folder_name), - install_size: getFileSize(installInfo.manifest.disk_size), + install_size: getFileSize(sizeOnDisk), is_dlc: false, version: additionalInfo ? additionalInfo.version : installInfo.game.version, appName: appName, - installedWithDLCs: Boolean(installDlcs), + installedDLCs: installDlcs, language: installLanguage, versionEtag: isLinuxNative ? '' : installInfo.manifest.versionEtag, - buildId: isLinuxNative ? '' : installInfo.game.buildId + buildId: isLinuxNative ? '' : installInfo.game.buildId, + pinnedVersion: !!build } const array = installedGamesStore.get('installed', []) array.push(installedData) installedGamesStore.set('installed', array) + gameInfo.is_installed = true + gameInfo.install = installedData refreshInstalled() if (isWindows) { logInfo('Windows os, running setup instructions on install', LogPrefix.Gog) @@ -374,7 +425,7 @@ export async function install( } catch (e) { logWarning( [ - `Failed to run setup instructions on install for ${gameInfo.title}, some other step might be needed for the game to work. Check the 'goggame-${appName}.script' file in the game folder`, + `Failed to run setup instructions on install for ${gameInfo.title}`, 'Error:', e ], @@ -461,7 +512,9 @@ export async function launch( let commandEnv = { ...process.env, ...setupWrapperEnvVars({ appName, appRunner: 'gog' }), - ...(isWindows ? {} : setupEnvVars(gameSettings)) + ...(isWindows + ? {} + : setupEnvVars(gameSettings, gameInfo.install.install_path)) } const wrappers = setupWrappers( @@ -514,7 +567,10 @@ export async function launch( 'launch', gameInfo.install.install_path, ...exeOverrideFlag, - gameInfo.app_name, + gameInfo.app_name === '1423049311' && + gameInfo.install.cyberpunk?.modsEnabled + ? '1597316373' + : gameInfo.app_name, ...wineFlag, '--platform', gameInfo.install.platform.toLowerCase(), @@ -524,6 +580,82 @@ export async function launch( ...shlex.split(gameSettings.launcherArgs ?? '') ] + if (gameInfo.install.cyberpunk?.modsEnabled) { + const startFolder = join( + gameInfo.install.install_path, + 'tools', + 'redmod', + 'bin' + ) + + if (existsSync(startFolder)) { + const installDirectory = isWindows + ? gameInfo.install.install_path + : await getWinePath({ + path: gameInfo.install.install_path, + variant: 'win', + gameSettings + }) + + const availableMods = await getCyberpunkMods() + const modsEnabledToLoad = gameInfo.install.cyberpunk.modsToLoad + const modsAbleToLoad: string[] = [] + + for (const mod of modsEnabledToLoad) { + if (availableMods.includes(mod)) { + modsAbleToLoad.push(mod) + } + } + + if (!modsEnabledToLoad.length && !!availableMods.length) { + logWarning('No mods selected to load, loading all in alphabetic order') + modsAbleToLoad.push(...availableMods) + } + + const redModCommand = [ + 'redMod.exe', + 'deploy', + '-reportProgress', + '-root', + installDirectory, + ...modsAbleToLoad.map((mod) => ['-mod', mod]).flat() + ] + + let result: { stdout: string; stderr: string; code?: number | null } = { + stdout: '', + stderr: '' + } + if (isWindows) { + const [bin, ...args] = redModCommand + result = await spawnAsync(bin, args, { cwd: startFolder }) + } else { + result = await runWineCommandUtil({ + commandParts: redModCommand, + wait: true, + gameSettings, + gameInstallPath: gameInfo.install.install_path, + startFolder + }) + } + logInfo(result.stdout, { prefix: LogPrefix.Gog }) + appendFileSync( + logFileLocation(appName), + `\nMods deploy log:\n${result.stdout}\n\n${result.stderr}\n\n\n` + ) + if (result.stderr.includes('deploy has succeeded')) { + showDialogBoxModalAuto({ + title: 'Mod deploy failed', + message: `Following logs are also available in game log\n\nredMod log:\n ${result.stdout}\n\n\n${result.stderr}`, + type: 'ERROR' + }) + return true + } + commandParts.push('--prefer-task', '0') + } else { + logError(['Unable to start modded game'], { prefix: LogPrefix.Gog }) + } + } + const fullCommand = getRunnerCallWithoutCredentials( commandParts, commandEnv, @@ -567,6 +699,7 @@ export async function moveInstall( newInstallPath: string ): Promise<{ status: 'done' } | { status: 'error'; error: string }> { const gameInfo = getGameInfo(appName) + const gameConfig = GameConfig.get(appName).config logInfo(`Moving ${gameInfo.title} to ${newInstallPath}`, LogPrefix.Gog) const moveImpl = isWindows ? moveOnWindows : moveOnUnix @@ -582,12 +715,19 @@ export async function moveInstall( return { status: 'error', error } } - changeGameInstallPath(appName, moveResult.installPath) + await changeGameInstallPath(appName, moveResult.installPath) + if ( + gameInfo.install.platform === 'windows' && + (isWindows || existsSync(gameConfig.winePrefix)) + ) { + await setup(appName, undefined, false) + } return { status: 'done' } } -/** - * Literally installing game, since gogdl verifies files at runtime +/* + * This proces verifies and repairs game files + * verification step doesn't have progress, but download does */ export async function repair(appName: string): Promise { const { installPlatform, gameData, credentials, withDlcs, logPath, workers } = @@ -596,21 +736,29 @@ export async function repair(appName: string): Promise { if (!credentials) { return { stderr: 'Unable to repair game, no credentials', stdout: '' } } + const privateBranchPassword = privateBranchesStore.get(appName, '') + // Most of the data provided here is discarded and read from manifest instead const commandParts = [ 'repair', appName, '--platform', installPlatform!, - `--path=${gameData.install.install_path}`, - '--token', - `"${credentials.access_token}"`, + '--path', + gameData.install.install_path!, + '--support', + join(gogSupportPath, appName), withDlcs, - `--lang=${gameData.install.language || 'en-US'}`, + '--lang', + gameData.install.language || 'en-US', '-b=' + gameData.install.buildId, ...workers ] + if (privateBranchPassword.length) { + commandParts.push('--password', privateBranchPassword) + } + const res = await runGogdlCommand(commandParts, { abortId: appName, logFile: logPath, @@ -651,8 +799,6 @@ export async function syncSaves( 'save-sync', location.location, appName, - '--token', - `"${credentials.refresh_token}"`, '--os', gameInfo.install.platform, '--ts', @@ -684,7 +830,10 @@ export async function syncSaves( return fullOutput } -export async function uninstall({ appName }: RemoveArgs): Promise { +export async function uninstall({ + appName, + shouldRemovePrefix +}: RemoveArgs): Promise { const array = installedGamesStore.get('installed', []) const index = array.findIndex((game) => game.appName === appName) if (index === -1) { @@ -709,19 +858,22 @@ export async function uninstall({ appName }: RemoveArgs): Promise { const command = [ uninstallerPath, - '/verysilent', - `/dir=${shlex.quote(installDirectory)}` + '/VERYSILENT', + `/ProductId=${appName}`, + '/galaxyclient', + '/KEEPSAVES' ] logInfo(['Executing uninstall command', command.join(' ')], LogPrefix.Gog) if (!isWindows) { - await runWineCommandUtil({ - gameSettings, - commandParts: command, - wait: true, - protonVerb: 'waitforexitandrun' - }) + if (existsSync(gameSettings.winePrefix) && !shouldRemovePrefix) { + await runWineCommandUtil({ + gameSettings, + commandParts: command, + wait: true + }) + } } else { const adminCommand = [ 'Start-Process', @@ -729,49 +881,185 @@ export async function uninstall({ appName }: RemoveArgs): Promise { uninstallerPath, '-Verb', 'RunAs', + '-Wait', '-ArgumentList' ] await spawnAsync('powershell', [ ...adminCommand, - `/verysilent /dir=${shlex.quote(installDirectory)}` + `"/verysilent","\`"/dir=${installDirectory}\`""`, + `` ]) } - } else { - if (existsSync(object.install_path)) - rmSync(object.install_path, { recursive: true }) + } + if (existsSync(object.install_path)) { + rmSync(object.install_path, { recursive: true }) + } + const manifestPath = join(gogdlConfigPath, 'manifests', appName) + if (existsSync(manifestPath)) { + rmSync(manifestPath) // Delete manifest so gogdl won't try to patch the not installed game + } + const supportPath = join(gogSupportPath, appName) + if (existsSync(supportPath)) { + rmSync(supportPath, { recursive: true }) // Remove unnecessary support dir } installedGamesStore.set('installed', array) refreshInstalled() const gameInfo = getGameInfo(appName) + gameInfo.is_installed = false + gameInfo.install = { is_dlc: false } await removeShortcutsUtil(gameInfo) syncStore.delete(appName) await removeNonSteamGame({ gameInfo }) - sendFrontendMessage('refreshLibrary', 'gog') + sendFrontendMessage('pushGameToLibrary', gameInfo) return res } export async function update( - appName: string + appName: string, + updateOverwrites?: { + build?: string + branch?: string + language?: string + dlcs?: string[] + dependencies?: string[] + } ): Promise<{ status: 'done' | 'error' }> { - const { installPlatform, gameData, credentials, withDlcs, logPath, workers } = - await getCommandParameters(appName) + if (appName === 'gog-redist') { + const redist = await getRequiredRedistList() + if (updateOverwrites?.dependencies?.length) { + for (const dep of updateOverwrites.dependencies) { + if (!redist.includes(dep)) { + redist.push(dep) + } + } + } + return updateRedist(redist) + } + const { + installPlatform, + gameData, + credentials, + withDlcs, + logPath, + workers, + dlcs, + branch + } = await getCommandParameters(appName) if (!installPlatform || !credentials) { return { status: 'error' } } + const gameConfig = GameConfig.get(appName).config + const installedDlcs = gameData.install.installedDLCs || [] + + if (updateOverwrites?.dlcs) { + const removedDlcs = installedDlcs.filter( + (dlc) => !updateOverwrites.dlcs?.includes(dlc) + ) + if ( + removedDlcs.length && + gameData.install.platform === 'windows' && + (isWindows || existsSync(gameConfig.winePrefix)) + ) { + // Run uninstaller per DLC + // Find uninstallers of dlcs we are looking for first + const listOfFiles = await readdir(gameData.install.install_path!) + const uninstallerIniList = listOfFiles.filter((file) => + file.match(/unins\d{3}\.ini/) + ) + + for (const uninstallerFile of uninstallerIniList) { + // Parse ini and find all uninstallers we need + const rawData = await readFile( + join(gameData.install.install_path!, uninstallerFile), + { encoding: 'utf8' } + ) + const parsedData = ini.parse(rawData) + const productId = parsedData['InstallSettings']['productID'] + if (removedDlcs.includes(productId)) { + // Run uninstall on DLC + const uninstallExeFile = uninstallerFile.replace('ini', 'exe') + if (isWindows) { + const adminCommand = [ + 'Start-Process', + '-FilePath', + uninstallExeFile, + '-Verb', + 'RunAs', + '-Wait', + '-ArgumentList' + ] + await spawnAsync( + 'powershell', + [ + ...adminCommand, + `"/ProductId=${productId}","/VERYSILENT","/galaxyclient","/KEEPSAVES"` + ], + { cwd: gameData.install.install_path } + ) + } else { + await runWineCommand({ + gameSettings: gameConfig, + protonVerb: 'run', + commandParts: [ + uninstallExeFile, + `/ProductId=${productId}`, + '/VERYSILENT', + '/galaxyclient', + '/KEEPSAVES' + ], + startFolder: gameData.install.install_path! + }) + } + } + } + } + } + + const privateBranchPassword = privateBranchesStore.get(appName, '') + + const overwrittenBuild: string[] = updateOverwrites?.build + ? ['--build', updateOverwrites.build] + : [] + + const overwrittenBranch: string[] = updateOverwrites?.branch + ? ['--branch', updateOverwrites.branch] + : branch + + const overwrittenLanguage: string = + updateOverwrites?.language || gameData.install.language || 'en-US' + + const overwrittenDlcs: string[] = updateOverwrites?.dlcs?.length + ? ['--dlcs', updateOverwrites.dlcs.join(',')] + : dlcs + + const overwrittenWithDlcs: string = updateOverwrites?.dlcs + ? updateOverwrites.dlcs.length + ? '--with-dlcs' + : '--skip-dlcs' + : withDlcs + const commandParts = [ 'update', appName, '--platform', installPlatform, - `--path=${gameData.install.install_path}`, - '--token', - `"${credentials.access_token}"`, - withDlcs, - `--lang=${gameData.install.language || 'en-US'}`, - ...workers + '--path', + gameData.install.install_path!, + '--support', + join(gogSupportPath, appName), + overwrittenWithDlcs, + '--lang', + overwrittenLanguage, + ...overwrittenDlcs, + ...workers, + ...overwrittenBuild, + ...overwrittenBranch ] + if (privateBranchPassword.length) { + commandParts.push('--password', privateBranchPassword) + } const onOutput = (data: string) => { onInstallOrUpdateOutput(appName, 'updating', data) @@ -805,7 +1093,13 @@ export async function update( const gameObject = installedArray[gameIndex] if (gameData.install.platform !== 'linux') { - const installInfo = await getInstallInfo(appName) + const installInfo = await getInstallInfo( + appName, + gameData.install.platform ?? 'windows', + updateOverwrites?.branch, + updateOverwrites?.build + ) + // TODO: use installInfo.game.builds const { etag } = await getMetaResponse( appName, gameData.install.platform ?? 'windows', @@ -814,8 +1108,12 @@ export async function update( if (installInfo === undefined) return { status: 'error' } gameObject.buildId = installInfo.game.buildId gameObject.version = installInfo.game.version + gameObject.branch = updateOverwrites?.branch + gameObject.language = overwrittenLanguage + if (updateOverwrites?.dlcs) { + gameObject.installedDLCs = updateOverwrites?.dlcs + } gameObject.versionEtag = etag - gameObject.install_size = getFileSize(installInfo.manifest.disk_size) } else { const installerInfo = await getLinuxInstallerInfo(appName) if (!installerInfo) { @@ -823,13 +1121,26 @@ export async function update( } gameObject.version = installerInfo.version } + const sizeOnDisk = await getPathDiskSize(join(gameObject.install_path)) + gameObject.install_size = getFileSize(sizeOnDisk) installedGamesStore.set('installed', installedArray) refreshInstalled() + const gameSettings = GameConfig.get(appName).config + // Simple check if wine prefix exists and setup can be performed because of an + // update + if ( + gameObject.platform === 'windows' && + (isWindows || existsSync(gameSettings.winePrefix)) + ) { + await setup(appName, gameObject, false) + } sendGameStatusUpdate({ appName: appName, runner: 'gog', status: 'done' }) + gameData.install = gameObject + sendFrontendMessage('pushGameToLibrary', gameData) return { status: 'done' } } @@ -844,9 +1155,21 @@ async function getCommandParameters(appName: string) { const logPath = logFileLocation(appName) const credentials = await GOGUser.getCredentials() - const withDlcs = gameData.install.installedWithDLCs - ? '--with-dlcs' - : '--skip-dlcs' + const numberOfDLCs = gameData.install?.installedDLCs?.length || 0 + + const withDlcs = + gameData.install.installedWithDLCs || numberOfDLCs > 0 + ? '--with-dlcs' + : '--skip-dlcs' + + const dlcs = + gameData.install.installedDLCs && numberOfDLCs > 0 + ? ['--dlcs', gameData.install.installedDLCs.join(',')] + : [] + + const branch = gameData.install.branch + ? ['--branch', gameData.install.branch] + : [] const installPlatform = gameData.install.platform @@ -856,7 +1179,9 @@ async function getCommandParameters(appName: string) { installPlatform, logPath, credentials, - gameData + gameData, + dlcs, + branch } } @@ -864,15 +1189,12 @@ export async function forceUninstall(appName: string): Promise { const installed = installedGamesStore.get('installed', []) const newInstalled = installed.filter((g) => g.appName !== appName) installedGamesStore.set('installed', newInstalled) - sendFrontendMessage('refreshLibrary', 'gog') + refreshInstalled() + sendFrontendMessage('pushGameToLibrary', getGameInfo(appName)) } -// Could be removed if gogdl handles SIGKILL and SIGTERM for us -// which is send via AbortController +// GOGDL now handles the signal, this is no longer needed export async function stop(appName: string, stopWine = true): Promise { - const pattern = isLinux ? appName : 'gogdl' - killPattern(pattern) - if (stopWine && !isNative(appName)) { const gameSettings = await getSettings(appName) await shutdownWine(gameSettings) @@ -1056,3 +1378,37 @@ export async function getGOGPlaytime( return response?.data?.time_sum } + +export function getBranchPassword(appName: string): string { + return privateBranchesStore.get(appName, '') +} + +export function setBranchPassword(appName: string, password: string): void { + privateBranchesStore.set(appName, password) +} + +export async function getCyberpunkMods(): Promise { + const gameInfo = getGogLibraryGameInfo('1423049311') + if (!gameInfo || !gameInfo?.install?.install_path) { + return [] + } + + const modsPath = join(gameInfo.install.install_path, 'mods') + if (!existsSync(modsPath)) { + return [] + } + const modsPathContents = await readdir(modsPath) + + return modsPathContents.reduce((acc, next) => { + const modPath = join(modsPath, next) + const infoFilePath = join(modPath, 'info.json') + + const modStat = statSync(modPath) + + if (modStat.isDirectory() && existsSync(infoFilePath)) { + acc.push(next) + } + + return acc + }, [] as string[]) +} diff --git a/src/backend/storeManagers/gog/library.ts b/src/backend/storeManagers/gog/library.ts index cd7db88904..dd34fc3e17 100644 --- a/src/backend/storeManagers/gog/library.ts +++ b/src/backend/storeManagers/gog/library.ts @@ -19,9 +19,13 @@ import { Library, BuildItem, GalaxyLibraryEntry, - ProductsEndpointData + ProductsEndpointData, + GOGDLInstallInfo, + GOGCredentials, + GOGv1Manifest, + GOGv2Manifest } from 'common/types/gog' -import { basename, join } from 'node:path' +import { dirname, join } from 'node:path' import { existsSync, readFileSync } from 'graceful-fs' import { app } from 'electron' @@ -33,20 +37,109 @@ import { logWarning } from '../../logger/logger' import { getGOGdlBin, getFileSize } from '../../utils' -import { gogdlLogFile } from '../../constants' +import { gogdlConfigPath, gogdlLogFile } from '../../constants' import { libraryStore, installedGamesStore, installInfoStore, - apiInfoCache + apiInfoCache, + privateBranchesStore } from './electronStores' import { callRunner } from '../../launcher' -import { isOnline } from '../../online_monitor' +import { isOnline, runOnceWhenOnline } from '../../online_monitor' import i18next from 'i18next' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { unzipSync } from 'node:zlib' +import { readdirSync, rmSync, writeFileSync } from 'node:fs' +import { checkForRedistUpdates } from './redist' const library: Map = new Map() const installedGames: Map = new Map() +export async function initGOGLibraryManager() { + await refresh() + + // Based on installed games scan for missing manifests and attempt to pull + // them + logInfo('Checking for existing gog manifests', { prefix: LogPrefix.Gog }) + const installedGamesList = Array.from(installedGames.keys()) + const manifestDir = join(gogdlConfigPath, 'manifests') + if (!existsSync(manifestDir)) { + await mkdir(manifestDir, { recursive: true }) + } + + runOnceWhenOnline(async () => { + const credentials = await GOGUser.getCredentials() + for (const appName of installedGamesList) { + await createMissingGogdlManifest(appName, credentials) + } + }) + runOnceWhenOnline(checkForRedistUpdates) +} + +async function createMissingGogdlManifest( + appName: string, + credentials?: GOGCredentials +) { + const manifestDir = join(gogdlConfigPath, 'manifests') + + const manifestPath = join(manifestDir, appName) + const installedData = installedGames.get(appName)! // It exists for sure + if (existsSync(manifestPath) || installedData?.platform === 'linux') { + return + } + // Pull the data, read info file from install dir if possible + const res = await runRunnerCommand(['import', installedData.install_path], { + abortId: `${appName}-manifest-restore`, + logMessagePrefix: `Getting data of ${appName}` + }) + + try { + const importData: GOGImportData = JSON.parse(res.stdout) + const builds = await getBuilds( + appName, + installedData.platform, + credentials?.access_token + ) + const buildItems: BuildItem[] = builds.data.items + + // Find our build in the list + + const currentBuild = importData.buildId + ? buildItems.find((item) => item.build_id === importData.buildId) + : buildItems.find((item) => !item.branch) + if (!currentBuild || !currentBuild.urls) { + logError(`Unable to get current build of ${appName}`, { + prefix: LogPrefix.Gog + }) + return + } + + // Get meta + const url = currentBuild.urls[0] + + const response = await axios.get(url.url, { responseType: 'arraybuffer' }) + let manifestDataRaw = response.data.toString() + if (currentBuild.generation === 2) { + manifestDataRaw = unzipSync(response.data) + } + const manifestData = JSON.parse(manifestDataRaw.toString()) + + manifestData.HGLPlatform = importData.platform + manifestData.HGLInstallLanguage = importData.installedLanguage + manifestData.HGLdlcs = importData.dlcs.map((dlc) => ({ id: dlc })) + + writeFileSync(manifestPath, JSON.stringify(manifestData), { + encoding: 'utf8' + }) + } catch (e) { + logError(`Unable to get data of ${appName} ${e}`, { + prefix: LogPrefix.Gog + }) + return + } +} + export async function getSaveSyncLocation( appName: string, install: InstalledInfo @@ -61,7 +154,27 @@ export async function getSaveSyncLocation( syncPlatform = 'MacOS' break } - const clientId = readInfoFile(appName, install.install_path)?.clientId + + let clientId + + const manifestPath = join(gogdlConfigPath, 'manifests', appName) + if (existsSync(manifestPath)) { + try { + const dataRaw = readFileSync(manifestPath, { encoding: 'utf-8' }) + const data: GOGv1Manifest | GOGv2Manifest = JSON.parse(dataRaw) + if (data.version === 2) { + clientId = data.clientId + } + } catch (err) { + clientId = undefined + logWarning( + 'Was not able to read clientId from manifest, falling back to info file' + ) + clientId = readInfoFile(appName, install.install_path)?.clientId + } + } else { + clientId = readInfoFile(appName, install.install_path)?.clientId + } if (!clientId) { logWarning( @@ -103,6 +216,9 @@ async function getGalaxyLibrary( page_token?: string ): Promise> { const credentials = await GOGUser.getCredentials() + if (!credentials) { + return [] + } const headers = { Authorization: `Bearer ${credentials.access_token}` } @@ -133,25 +249,116 @@ async function getGalaxyLibrary( const nextPageGames = await getGalaxyLibrary(data.next_page_token) if (nextPageGames.length) { objects.push(...nextPageGames) + } else { + return [] } } return objects } -export async function refresh(): Promise { - if (!GOGUser.isLoggedIn()) { - return defaultExecResult - } - refreshInstalled() +async function loadLocalLibrary() { for (const game of libraryStore.get('games', [])) { const copyObject = { ...game } if (installedGames.has(game.app_name)) { + await checkForOfflineInstallerChanges(game.app_name) copyObject.install = installedGames.get(game.app_name)! copyObject.is_installed = true } library.set(game.app_name, copyObject) } + installedGamesStore.set('installed', Array.from(installedGames.values())) +} + +export async function checkForOfflineInstallerChanges(appName: string) { + // game could've been updated + // DLC may've been installed etc.. + const installedGame = installedGames.get(appName) + if ( + !installedGame || + installedGame.platform === 'linux' || + !existsSync(installedGame.install_path) || + existsSync(join(installedGame.install_path, '.gogdl-resume')) + ) { + return + } + + // Update installed DLCs + const installedProducts = listInstalledProducts(appName) + const dlcs = installedProducts.filter((product) => product !== appName).sort() + const installedDLCs = (installedGame.installedDLCs || []).sort() + let dlcChanged = installedDLCs.length !== dlcs.length + + if (!dlcChanged) { + for (const index in installedDLCs) { + dlcChanged = installedDLCs[index] !== dlcs[index] + if (dlcChanged) { + break + } + } + } + + if (dlcChanged) { + installedGame.installedDLCs = dlcs + // Update gogdl manifest + try { + const manifestPath = join(gogdlConfigPath, 'manifests', appName) + if (existsSync(manifestPath)) { + const manifestDataRaw = await readFile(manifestPath, { + encoding: 'utf8' + }) + const manifestData = JSON.parse(manifestDataRaw) + manifestData['HGLdlcs'] = dlcs.map((dlc) => ({ id: dlc })) + const newData = JSON.stringify(manifestData) + await writeFile(manifestPath, newData, { encoding: 'utf8' }) + } + } catch (e) { + logWarning(['Failed to update gogdl manifest', e], { + prefix: LogPrefix.Gog + }) + } + installedGames.set(appName, installedGame) + } + // Check buildId + const data = readInfoFile(appName) + if (!data?.buildId) { + return + } + + if (data.buildId !== installedGame.buildId) { + // The game was updated, remove gogdl + // manifest and re-import it + const manifestPath = join(gogdlConfigPath, 'manifests', appName) + if (existsSync(manifestPath)) { + rmSync(manifestPath) + } + const credentials = await GOGUser.getCredentials() + await createMissingGogdlManifest(appName, credentials) + installedGame.buildId = data.buildId + } + installedGames.set(appName, installedGame) +} + +export async function refresh(): Promise { + if (!GOGUser.isLoggedIn()) { + return defaultExecResult + } + refreshInstalled() + await loadLocalLibrary() + const redistGameInfo: GameInfo = { + app_name: 'gog-redist', + runner: 'gog', + title: 'Galaxy Common Redistributables', + canRunOffline: true, + install: { is_dlc: true }, + is_installed: true, + art_cover: + 'https://images.gog-statics.com/516af877f6a03199526d1ce5a76358b8f85f6b828764cf46c820f77ae8832fc5.jpg', + art_square: + 'https://cdn2.steamgriddb.com/file/sgdb-cdn/grid/5fa80a0fb5ff0b2aaca6730ba213219b.png' + } + + library.set('gog-redist', redistGameInfo) if (!isOnline()) { return defaultExecResult @@ -174,20 +381,26 @@ export async function refresh(): Promise { (entry) => entry.platform_id === 'gog' ) - const gamesObjects: GameInfo[] = [] + const gamesObjects: GameInfo[] = [redistGameInfo] apiInfoCache.use_in_memory() // Prevent blocking operations const promises = filteredApiArray.map(async (game): Promise => { let retries = 5 while (retries > 0) { - const { data } = await getGamesdbData( - 'gog', - game.external_id, - false, - game.certificate, - credentials.access_token - ).catch(() => ({ - data: null - })) + let gdbData + try { + const { data } = await getGamesdbData( + 'gog', + game.external_id, + false, + game.certificate, + credentials.access_token + ) + gdbData = data + } catch { + await new Promise((resolve) => setTimeout(resolve, 2000)) + retries -= 1 + continue + } const product = await getProductApi( game.external_id, @@ -195,12 +408,7 @@ export async function refresh(): Promise { credentials.access_token ).catch(() => null) - if (!data) { - await new Promise((resolve) => setTimeout(resolve, 2000)) - retries -= 1 - continue - } - const unifiedObject = await gogToUnifiedInfo(data, product?.data) + const unifiedObject = await gogToUnifiedInfo(gdbData, product?.data) if (unifiedObject.app_name) { const oldData = library.get(unifiedObject.app_name) if (oldData) { @@ -282,7 +490,8 @@ export function getInstallAndGameInfo(slug: string): GameInfo | undefined { /** * Gets data metadata about game using gogdl info for current system, - * when os is Linux: gets Windows build data. + * gogdl data for linux contains different fields than windows and mac + * this is handled but some fields may be unexepectedly empty, so watch out * Contains data like download size * @param appName * @param installPlatform @@ -292,18 +501,20 @@ export function getInstallAndGameInfo(slug: string): GameInfo | undefined { export async function getInstallInfo( appName: string, installPlatform = 'windows', - lang = 'en-US' + branch = 'null', + build?: string ): Promise { installPlatform = installPlatform.toLowerCase() - if (installPlatform === 'linux') { - installPlatform = 'windows' - } if (installPlatform === 'mac') { installPlatform = 'osx' } - if (installInfoStore.has(`${appName}_${installPlatform}`)) { - const cache = installInfoStore.get(`${appName}_${installPlatform}`) + const privateBranchPassword = privateBranchesStore.get(appName, '') + + const installInfoStoreKey = `${appName}_${installPlatform}_${branch}_${build}_${privateBranchPassword}` + + if (installInfoStore.has(installInfoStoreKey)) { + const cache = installInfoStore.get(installInfoStoreKey) if (cache) { logInfo( [ @@ -329,7 +540,7 @@ export async function getInstallInfo( logError('No credentials, cannot get install info', LogPrefix.Gog) return } - const gameData = library.get(appName) + const gameData = getGameInfo(appName) if (!gameData) { logError('Game data falsy in getInstallInfo', LogPrefix.Gog) @@ -340,13 +551,16 @@ export async function getInstallInfo( const commandParts = [ 'info', appName, - '--token', - `"${credentials.access_token}"`, - `--lang=${lang}`, '--os', - installPlatform === 'linux' ? 'windows' : installPlatform + installPlatform, + ...(branch !== 'null' ? ['--branch', branch] : []), + ...(build ? ['--build', build] : []) ] + if (privateBranchPassword.length) { + commandParts.push('--password', privateBranchPassword) + } + const res = await runRunnerCommand(commandParts, { abortId: appName, logMessagePrefix: 'Getting game metadata' @@ -371,7 +585,7 @@ export async function getInstallInfo( errorMessage(res.error) } - let gogInfo + let gogInfo: GOGDLInstallInfo try { gogInfo = JSON.parse(res.stdout) } catch (error) { @@ -382,18 +596,6 @@ export async function getInstallInfo( return } - // some games don't support `en-US` - if (!gogInfo.languages && gogInfo.languages.includes(lang)) { - // if the game supports `en-us`, use it, else use the first valid language - const newLang = gogInfo.languages.includes('en-us') - ? 'en-us' - : gogInfo.languages[0] - - // call itself with the new language and return - const infoWithLang = await getInstallInfo(appName, installPlatform, newLang) - return infoWithLang - } - let libraryArray = libraryStore.get('games', []) let gameObjectIndex = libraryArray.findIndex( (value) => value.app_name === appName @@ -430,24 +632,70 @@ export async function getInstallInfo( gameData.folder_name = gogInfo.folder_name libraryStore.set('games', libraryArray) library.set(appName, gameData) + + let language = gogInfo.languages[0] + const foundPreffered = i18next.languages.find((plang) => + gogInfo.languages.some((alang) => alang.startsWith(plang)) + ) + if (foundPreffered) { + const foundAvailable = gogInfo.languages.find((alang) => + alang.startsWith(foundPreffered) + ) + if (foundAvailable) { + language = foundAvailable + } + } + + // Calculate highest possible size (with DLCs) for display on game page + const download_size = gogInfo.download_size + ? gogInfo.download_size + : (gogInfo.size['*']?.download_size || 0) + // Universal depot + (gogInfo.size[language]?.download_size || 0) + // Language depot + gogInfo.dlcs.reduce( + (acc, dlc) => + acc + + (dlc.size['*']?.download_size || 0) + // Universal + (dlc.size[language]?.download_size || 0), // Lanuage + 0 + ) + const disk_size = gogInfo.disk_size + ? gogInfo.disk_size + : (gogInfo.size['*']?.disk_size || 0) + + (gogInfo.size[language]?.disk_size || 0) + + gogInfo.dlcs.reduce( + (acc, dlc) => + acc + + (dlc.size['*']?.disk_size || 0) + + (dlc.size[language]?.disk_size || 0), + 0 + ) + const info: GogInstallInfo = { game: { app_name: appName, title: gameData.title, - owned_dlc: gogInfo.dlcs, + owned_dlc: gogInfo.dlcs.map((dlc) => ({ + app_name: dlc.id, + title: dlc.title, + perLangSize: dlc.size + })), version: gogInfo.versionName, launch_options: [], - buildId: gogInfo!.buildId + branches: gogInfo.available_branches, + buildId: gogInfo.buildId }, manifest: { - disk_size: Number(gogInfo.disk_size), - download_size: Number(gogInfo.download_size), + download_size: download_size, + disk_size: disk_size, + perLangSize: gogInfo.size, app_name: appName, languages: gogInfo.languages, - versionEtag: gogInfo.versionEtag + versionEtag: gogInfo.versionEtag, + builds: gogInfo?.builds?.items, + dependencies: gogInfo.dependencies } } - installInfoStore.set(`${appName}_${installPlatform}`, info) + installInfoStore.set(installInfoStoreKey, info) if (!info) { logWarning( [ @@ -527,7 +775,9 @@ export async function importGame(data: GOGImportData, executablePath: string) { version: data.versionName, platform: data.platform, buildId: data.buildId, - installedWithDLCs: data.installedWithDlcs + language: data.installedLanguage, + installedDLCs: data.dlcs, + installedWithDLCs: !!data.dlcs.length } installedGames.set(data.appName, installInfo) const gameData = library.get(data.appName)! @@ -535,20 +785,33 @@ export async function importGame(data: GOGImportData, executablePath: string) { gameData.is_installed = true library.set(data.appName, gameData) installedGamesStore.set('installed', Array.from(installedGames.values())) + refreshInstalled() + await createMissingGogdlManifest(data.appName) + await checkForRedistUpdates() + sendFrontendMessage('pushGameToLibrary', gameData) } // This checks for updates of Windows and Mac titles // Linux installers need to be checked differently export async function listUpdateableGames(): Promise { - if (!isOnline()) { + if (!isOnline() || !GOGUser.isLoggedIn()) { return [] } + const credentials = await GOGUser.getCredentials() const installed = Array.from(installedGames.values()) const updateable: Array = [] for (const game of installed) { if (!game.appName) { continue } + + if (game.pinnedVersion) { + logWarning( + ['Game', game.appName, 'has pinned version, update check skipped'], + { prefix: LogPrefix.Gog } + ) + continue + } // use different check for linux games if (game.platform === 'linux') { if (!(await checkForLinuxInstallerUpdate(game.appName, game.version))) @@ -558,7 +821,8 @@ export async function listUpdateableGames(): Promise { const hasUpdate = await checkForGameUpdate( game.appName, game.platform, - game?.versionEtag + game?.versionEtag, + credentials?.access_token ) if (hasUpdate) { updateable.push(game.appName) @@ -584,14 +848,35 @@ export async function checkForLinuxInstallerUpdate( return false } -export async function getMetaResponse( +export async function getBuilds( appName: string, platform: string, - etag?: string + access_token?: string ) { - const buildData = await axios.get( + const url = new URL( `https://content-system.gog.com/products/${appName}/os/${platform}/builds?generation=2&_version=2` ) + const password = privateBranchesStore.get(appName, '') + + if (password.length) { + url.searchParams.set('password', password) + } + + const headers: AxiosRequestHeaders = {} + if (access_token) { + headers.Authorization = `Bearer ${access_token}` + } + + return axios.get(url.toString(), { headers }) +} + +export async function getMetaResponse( + appName: string, + platform: string, + etag?: string, + access_token?: string +) { + const buildData = await getBuilds(appName, platform, access_token) const headers = etag ? { 'If-None-Match': etag @@ -627,9 +912,15 @@ export async function getMetaResponse( export async function checkForGameUpdate( appName: string, platform: string, - etag?: string + etag?: string, + access_token?: string ) { - const metaResponse = await getMetaResponse(appName, platform, etag) + const metaResponse = await getMetaResponse( + appName, + platform, + etag, + access_token + ) return metaResponse.status === 200 && metaResponse.etag !== etag } @@ -774,6 +1065,32 @@ export async function createReqsArray( return returnValue } +/* Get product ids installed in for given game + */ +export function listInstalledProducts(appName: string): string[] { + const installedData = installedGames.get(appName) + if (!installedData) { + return [] + } + + let root = installedData.install_path + if (installedData.platform === 'osx') { + root = join(root, 'Contents', 'Resources') + } + if (!existsSync(root)) { + return [] + } + + const files = readdirSync(root) + return files.reduce((acc, file) => { + const matcher = file.match(/goggame-(\d+)\.info/) + if (matcher) { + acc.push(matcher[1]) + } + return acc + }, [] as string[]) +} + /** * Reads goggame-appName.info file and returns JSON object of it * @param appName @@ -818,7 +1135,7 @@ export function readInfoFile( } if (!infoFileData.buildId) { - const idFilePath = join(basename(infoFilePath), `goggame-${appName}.id`) + const idFilePath = join(dirname(infoFilePath), `goggame-${appName}.id`) if (existsSync(idFilePath)) { try { const { buildId }: GOGGameDotIdFile = JSON.parse( @@ -1017,6 +1334,14 @@ export async function runRunnerCommand( const { dir, bin } = getGOGdlBin() const authConfig = join(app.getPath('userData'), 'gog_store', 'auth.json') + if (!options) { + options = {} + } + if (!options.env) { + options.env = {} + } + options.env.GOGDL_CONFIG_PATH = dirname(gogdlConfigPath) + return callRunner( ['--auth-config-path', authConfig, ...commandParts], { name: 'gog', logPrefix: LogPrefix.Gog, bin, dir }, @@ -1054,3 +1379,56 @@ export function getLaunchOptions(appName: string): LaunchOption[] { return newLaunchOptions } + +export function changeVersionPinnedStatus(appName: string, status: boolean) { + const game = library.get(appName) + const installed = installedGames.get(appName) + if (!game || !installed) { + return + } + game.install.pinnedVersion = status + installed.pinnedVersion = status + library.set(appName, game) + installedGames.set(appName, installed) + + const installedArray = installedGamesStore.get('installed', []) + + const index = installedArray.findIndex((iGame) => iGame.appName === appName) + + if (index > -1) { + installedArray.splice(index, 1, installed) + } + installedGamesStore.set('installed', installedArray) + sendFrontendMessage('pushGameToLibrary', game) +} + +export function setCyberpunkModConfig(props: { + enabled: boolean + modsToLoad: string[] +}) { + const cpId = '1423049311' + const game = library.get(cpId) + const installed = installedGames.get(cpId) + if (!game || !installed) { + return + } + + installed.cyberpunk = { + modsEnabled: props.enabled, + modsToLoad: props.modsToLoad + } + + game.install = installed + const installedArray = installedGamesStore.get('installed', []) + + const index = installedArray.findIndex((iGame) => iGame.appName === cpId) + + if (index > -1) { + installedArray.splice(index, 1, installed) + } + + library.set(cpId, game) + installedGames.set(cpId, installed) + installedGamesStore.set('installed', installedArray) + sendFrontendMessage('pushGameToLibrary', game) +} diff --git a/src/backend/storeManagers/gog/redist.ts b/src/backend/storeManagers/gog/redist.ts new file mode 100644 index 0000000000..d7b9129a9a --- /dev/null +++ b/src/backend/storeManagers/gog/redist.ts @@ -0,0 +1,220 @@ +// Manage redist required by games and push them to download queue +// as Galaxy Common Redistributables + +import { + gamesConfigPath, + gogdlConfigPath, + gogRedistPath +} from 'backend/constants' +import path from 'path' +import { existsSync } from 'fs' +import { readdir, readFile } from 'fs/promises' +import { logError, logInfo, LogPrefix } from 'backend/logger/logger' +import { + GOGRedistManifest, + GOGv1Manifest, + GOGv2Manifest +} from 'common/types/gog' +import { getGameInfo, onInstallOrUpdateOutput } from './games' +import { runRunnerCommand as runGogdlCommand } from './library' +import { GlobalConfig } from 'backend/config' +import { + addToQueue, + getQueueInformation +} from 'backend/downloadmanager/downloadqueue' +import { DMQueueElement } from 'common/types' +import axios from 'axios' +import { GOGUser } from './user' +import { isOnline } from 'backend/online_monitor' + +export async function checkForRedistUpdates() { + if (!GOGUser.isLoggedIn() || !isOnline()) { + return + } + const manifestPath = path.join(gogRedistPath, '.gogdl-redist-manifest') + let shouldUpdate = false + if (existsSync(manifestPath)) { + // Check if newer buildId is available + try { + const fileData = await readFile(manifestPath, { encoding: 'utf8' }) + const manifest: GOGRedistManifest = JSON.parse(fileData) + + const requiredRedistList = await getRequiredRedistList() + const requiredRedist = requiredRedistList.filter((redist) => { + const foundRedist = manifest.depots.find( + (dep) => dep.dependencyId === redist + ) + return foundRedist && foundRedist.executable.path.startsWith('__redist') // Filter redist that are installed into game directory + }) + // Filter redist with those only + const installed = manifest.HGLInstalled || [] + // Something is no longer required or new redist is needed + if (requiredRedist.length !== installed.length) { + logInfo('Updating redist, reason - different number of redist', { + prefix: LogPrefix.Gog + }) + shouldUpdate = true + } else { + // Check if we need new redist + const sortedReq = requiredRedist.sort() + const sortedInst = installed.sort() + + for (const index in sortedReq) { + if (sortedReq[index] !== sortedInst[index]) { + logInfo('Updating redist, reason - different redist required', { + prefix: LogPrefix.Gog + }) + shouldUpdate = true + break + } + } + } + + // Check if manifest itself changed + if (!shouldUpdate) { + const buildId = manifest?.build_id + const response = await axios.get( + 'https://content-system.gog.com/dependencies/repository?generation=2' + ) + const newBuildId = response.data.build_id + shouldUpdate = buildId !== newBuildId + if (shouldUpdate) { + logInfo('Updating redist, reason - new buildId', { + prefix: LogPrefix.Gog + }) + } + } + } catch (e) { + logError(['Failed to read gog redist manifest', e], { + prefix: LogPrefix.Gog + }) + return + } + } else { + shouldUpdate = true + } + if (!shouldUpdate) { + return + } + pushRedistUpdateToQueue() +} + +async function pushRedistUpdateToQueue() { + const currentQueue = getQueueInformation() + + const currentRedistElement = currentQueue.elements.find( + (element) => element.params.appName === 'gog-redist' + ) + + if (currentRedistElement) { + return + } + const newElement = createRedistDMQueueElement() + + await addToQueue(newElement) +} + +export function createRedistDMQueueElement(): DMQueueElement { + const gameInfo = getGameInfo('gog-redist') + const newElement: DMQueueElement = { + params: { + appName: 'gog-redist', + runner: 'gog', + path: gogRedistPath, + platformToInstall: 'windows', + gameInfo, + size: '?? MB' + }, + addToQueueTime: new Date().getTime(), + startTime: 0, + endTime: 0, + type: 'update' + } + return newElement +} + +export async function getRequiredRedistList(): Promise { + // Scan manifests in gogdl directory to obtain list of redist that will be + // required + const manifestsDir = path.join(gogdlConfigPath, 'manifests') + if (!existsSync(manifestsDir)) { + return ['ISI'] + } + + const manifests = await readdir(manifestsDir) + const redist: string[] = ['ISI'] + // Iterate over files + for (const manifest of manifests) { + const manifestDataRaw = await readFile(path.join(manifestsDir, manifest), { + encoding: 'utf8' + }) + try { + const manifestData: GOGv1Manifest | GOGv2Manifest = + JSON.parse(manifestDataRaw) + + // Get list from manifest and merge with global redist variable + if (manifestData.version === 1) { + const dependencies = manifestData.product.depots.reduce((acc, next) => { + if ('redist' in next) { + acc.push(next.redist) + } + return acc + }, [] as string[]) + for (const dependency of dependencies) { + if (!redist.includes(dependency)) { + redist.push(dependency) + } + } + } else if (manifestData.version === 2) { + for (const dependency of manifestData.dependencies || []) { + if (!redist.includes(dependency)) { + redist.push(dependency) + } + } + } + } catch (e) { + logError(['REDIST:', 'Unable to parse manifest', manifest, String(e)], { + prefix: LogPrefix.Gog + }) + continue + } + } + + return redist +} + +export async function updateRedist(redistToSync: string[]): Promise<{ + status: 'done' | 'error' +}> { + const { maxWorkers } = GlobalConfig.get().getSettings() + const workers = maxWorkers ? ['--max-workers', `${maxWorkers}`] : [] + const logPath = path.join(gamesConfigPath, 'gog-redist.log') + + const commandParts = [ + 'redist', + '--ids', + redistToSync.join(','), + '--path', + gogRedistPath, + ...workers + ] + + logInfo('Updating GOG redistributables', { + prefix: LogPrefix.Gog + }) + + const res = await runGogdlCommand(commandParts, { + abortId: 'gog-redist', + logMessagePrefix: 'GOG REDIST:', + logFile: logPath, + onOutput: (output) => + onInstallOrUpdateOutput('gog-redist', 'updating', output) + }) + + if (res.error) { + logError(['Failed to update redist', res.error], LogPrefix.Gog) + return { status: 'error' } + } + + return { status: 'done' } +} diff --git a/src/backend/storeManagers/gog/setup.ts b/src/backend/storeManagers/gog/setup.ts index 64f9aa5c78..bca3e4bd82 100644 --- a/src/backend/storeManagers/gog/setup.ts +++ b/src/backend/storeManagers/gog/setup.ts @@ -1,34 +1,79 @@ -import axios from 'axios' -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync -} from 'graceful-fs' -import { copySync } from 'fs-extra' +import { existsSync } from 'graceful-fs' import path from 'node:path' -import { GameInfo, InstalledInfo } from 'common/types' -import { checkWineBeforeLaunch, getShellPath, spawnAsync } from '../../utils' +import { InstalledInfo, WineCommandArgs } from 'common/types' +import { + checkWineBeforeLaunch, + sendGameStatusUpdate, + spawnAsync +} from '../../utils' import { GameConfig } from '../../game_config' import { logError, logInfo, LogPrefix, logWarning } from '../../logger/logger' -import { isWindows } from '../../constants' -import ini from 'ini' -import { isOnline } from '../../online_monitor' +import { + gogdlConfigPath, + gogRedistPath, + gogSupportPath, + isWindows +} from '../../constants' import { getWinePath, runWineCommand, verifyWinePrefix } from '../../launcher' import { getGameInfo as getGogLibraryGameInfo } from 'backend/storeManagers/gog/library' -const nonNativePathSeparator = path.sep === '/' ? '\\' : '/' +import { readFile } from 'node:fs/promises' +import shlex from 'shlex' +import { + GOGRedistManifest, + GOGv1Manifest, + GOGv2Manifest +} from 'common/types/gog' + +/* + * Automatially executes command properly according to operating system + * on Windows pre-appends powershell command to spawn UAC prompt + */ +async function runSetupCommand(wineArgs: WineCommandArgs) { + if (isWindows) { + // Run shell + const [exe, ...args] = wineArgs.commandParts + return spawnAsync( + 'powershell', + [ + 'Start-Process', + '-FilePath', + exe, + '-Verb', + 'RunAs', + '-Wait', + '-ArgumentList', + // TODO: Verify how Powershell will handle those + args + .map((argument) => { + if (argument.includes(' ')) { + // Add super quotes:tm: + argument = '`"' + argument + '`"' + } + // Add normal quotes + argument = '"' + argument + '"' + return argument + }) + .join(',') + ], + { cwd: wineArgs.startFolder } + ) + } else { + return runWineCommand(wineArgs) + } +} /** * Handles setup instructions like create folders, move files, run exe, create registry entry etc... * For Galaxy games only (Windows) - * This relies on root file system mounted at Z: in prefixes (We need better approach to access game path from prefix) + * As long as wine menu builder is disabled we shouldn't have trash application + * menu entries * @param appName * @param installInfo Allows passing install instructions directly */ async function setup( appName: string, - installInfo?: InstalledInfo + installInfo?: InstalledInfo, + installRedist = true ): Promise { const gameInfo = getGogLibraryGameInfo(appName) if (installInfo && gameInfo) { @@ -37,15 +82,6 @@ async function setup( if (!gameInfo || gameInfo.install.platform !== 'windows') { return } - const instructions = await obtainSetupInstructions(gameInfo) - if (!instructions) { - logInfo('Setup: No instructions', LogPrefix.Gog) - return - } - logWarning( - 'Running setup instructions, if you notice issues with launching a game, please report it on our Discord server', - LogPrefix.Gog - ) const gameSettings = GameConfig.get(appName).config if (!isWindows) { @@ -61,508 +97,279 @@ async function setup( // Make sure prefix is initalized correctly await verifyWinePrefix(gameSettings) } - // Funny part begins here - // Deterimine if it's basically from .script file or from manifest - if (instructions[0]?.install) { - // It's from .script file - // Parse actions - const supportDir = path.join( - gameInfo.install.install_path!, - 'support', - appName - ) - - const [localAppData, documentsPath, installPath] = await Promise.all([ - isWindows - ? getShellPath('%APPDATA%') - : getWinePath({ path: '%APPDATA%', gameSettings }), - isWindows - ? getShellPath('%USERPROFILE%/Documents') - : getWinePath({ path: '%USERPROFILE%/Documents', gameSettings }), + // Read game manifest + const manifestPath = path.join(gogdlConfigPath, 'manifests', appName) - isWindows - ? gameInfo.install.install_path || '' - : getWinePath({ - path: gameInfo.install.install_path || '', - gameSettings, - variant: 'win' - }) - ]) - - // In the future we need to find more path cases - const pathsValues = new Map([ - ['productid', appName], - ['app', installPath], - ['support', supportDir], - ['supportdir', supportDir], - ['localappdata', localAppData], - ['userdocs', documentsPath] - ]) + if (!existsSync(manifestPath)) { + logWarning( + [ + 'SETUP: no manifest for', + gameInfo.title, + "unable to continue setup, this shouldn't cause much issues with modern games, but some older titles may need special registry keys" + ], + { prefix: LogPrefix.Gog } + ) + return + } - for (const action of instructions) { - const actionArguments = action.install?.arguments - switch (action.install.action) { - case 'setRegistry': { - const registryPath = - actionArguments.root + - '\\' + - handlePathVars(actionArguments.subkey, pathsValues).replaceAll( - path.sep, - '\\' - ) + const manifestDataRaw = await readFile(manifestPath, { encoding: 'utf8' }) - let valueData = handlePathVars( - actionArguments?.valueData, - pathsValues - )?.replaceAll(path.sep, '\\') + let manifestData: GOGv1Manifest | GOGv2Manifest + try { + manifestData = JSON.parse(manifestDataRaw) + } catch (e) { + logError(['SETUP: Failed to parse game manifest', e], { + prefix: LogPrefix.Gog + }) + return + } - let valueName = actionArguments?.valueName || '' - const valueType = actionArguments?.valueType + const gameSupportDir = path.join(gogSupportPath, appName) // This doesn't need to exist, scriptinterpreter.exe will handle it gracefully + + const installLanguage = gameInfo.install.language?.split('-')[0] + const languages = new Intl.DisplayNames(['en'], { type: 'language' }) + const lang: string | undefined = languages.of(installLanguage!) + + const dependencies: string[] = [] + const gameDirectoryPath = await getWinePath({ + path: gameInfo.install.install_path!, + variant: 'win', + gameSettings + }) + + sendGameStatusUpdate({ + appName, + runner: 'gog', + status: 'redist', + context: 'GOG' + }) + if (manifestData.version === 1) { + if (existsSync(gameSupportDir)) { + for (const supportCommand of manifestData.product?.support_commands || + []) { + if ( + !supportCommand.languages.includes( + gameInfo.install.language ?? 'English' + ) || + supportCommand.languages.includes('Neutral') + ) { + const absPath = path.join( + gameSupportDir, + supportCommand.gameID, + supportCommand.executable + ) + const language = (lang || installLanguage || 'English').toLowerCase() - let keyCommand: string[] = [] - if (valueName) { - valueName = handlePathVars(valueName, pathsValues).replaceAll( - path.sep, - '\\' - ) - } - if (valueData && valueType) { - const regType = getRegDataType(valueType) - if (!regType) { - logError( - `Setup: Unsupported registry type ${valueType}, skipping this key` - ) - break - } - if (valueType === 'binary') { - valueData = Buffer.from(valueData, 'base64').toString('hex') - } - valueData = valueData.replaceAll('\\', '/') - keyCommand = ['/d', valueData, '/v', valueName, '/t', regType] - } - // Now create a key - const command = [ - 'reg', - 'add', - registryPath, - ...keyCommand, - '/f', - '/reg:32' + const exeArgs = [ + '/VERYSILENT', + `/DIR=${gameDirectoryPath}`, + `/Language=${language}`, + `/LANG=${language}`, + `/ProductId=${language}`, + `/galaxyclient`, + `/buildId=${manifestData.product.timestamp}`, + `/versionName=${gameInfo.install.version}`, + '/nodesktopshorctut', // YES THEY MADE A TYPO + '/nodesktopshortcut' ] - - logInfo( - [ - 'Setup: Adding a registry key', - registryPath, - valueName, - valueData - ], - LogPrefix.Gog - ) - if (isWindows) { - await spawnAsync('reg', [ - 'add', - registryPath, - ...keyCommand, - '/f', - '/reg:32' - ]) - break - } - await runWineCommand({ + await runSetupCommand({ + commandParts: [absPath, ...exeArgs], gameSettings, - gameInstallPath: gameInfo.install.install_path, - commandParts: command, - wait: true, - protonVerb: 'runinprefix' + wait: false, + protonVerb: 'run', + skipPrefixCheckIKnowWhatImDoing: true, + startFolder: path.join(gameSupportDir, supportCommand.gameID) }) - break } - case 'Execute': { - const executableName = actionArguments.executable - const infoPath = path.join( - gameInfo.install.install_path!, - `goggame-${appName}.info` - ) - let Language = 'english' - // Load game language data - if (existsSync(infoPath)) { - const contents = readFileSync(infoPath, 'utf-8') - Language = JSON.parse(contents).language - Language = Language.toLowerCase() + } + } + // Find redist depots and push to dependency installer + for (const depot of manifestData.product.depots) { + if ('redist' in depot && !dependencies.includes(depot.redist)) { + dependencies.push(depot.redist) + } + } + } else { + // check if scriptinterpreter is required based on manifest + if (manifestData.scriptInterpreter) { + const wineGameSupportDir = await getWinePath({ + path: gameSupportDir, + variant: 'win', + gameSettings + }) + const isiPath = path.join( + gogRedistPath, + '__redist/ISI/scriptinterpreter.exe' + ) + if (!existsSync(isiPath)) { + logError( + [ + "Script interpreter couldn't be found", + isiPath, + 'to try again restart Heroic and', + isWindows ? 'reinstall the game' : 'delete wine prefix of the game' + ], + { + prefix: LogPrefix.Gog + } + ) + } else { + // Run scriptinterpreter for every installed product + for (const manifestProduct of manifestData.products) { + if ( + manifestProduct.productId !== appName && + !(gameInfo.install.installedDLCs || []).includes( + manifestProduct.productId + ) + ) { + continue } + const language = lang || 'English' - // Please don't fix any typos here, everything is intended - const exeArguments = [ + const exeArgs = [ '/VERYSILENT', - `/DIR=${installPath}`, - `/Language=${Language}`, - `/LANG=${Language}`, - `/ProductId=${appName}`, + `/DIR=${gameDirectoryPath}`, + `/Language=${language}`, + `/LANG=${language}`, + `/ProductId=${manifestProduct.productId}`, `/galaxyclient`, `/buildId=${gameInfo.install.buildId}`, `/versionName=${gameInfo.install.version}`, - `/nodesktopshorctut`, // Disable desktop shortcuts but misspelled :/ - `/nodesktopshortcut`, // Disable desktop shortcuts - '/NOICONS' // Disable start menu icons + `/supportDir=${wineGameSupportDir}`, + '/nodesktopshorctut', + '/nodesktopshortcut' ] - const workingDir = handlePathVars( - actionArguments?.workingDir?.replace( - '{app}', - gameInfo.install.install_path - ), - pathsValues - ) - - let executablePath = path.join( - handlePathVars( - executableName.replace('{app}', gameInfo.install.install_path), - pathsValues - ) - ) - - // If exectuable doesn't exist in desired location search in supportDir - if (!existsSync(executablePath)) { - const alternateLocation = path.join(supportDir, executableName) - if (existsSync(alternateLocation)) { - executablePath = alternateLocation - } else { - logError( - [ - "Couldn't find executable, skipping this step, tried: ", - executablePath, - 'and', - alternateLocation - ], - LogPrefix.Gog - ) - break - } - } - - // Requires testing - if (isWindows) { - const command = [ - 'Start-Process', - '-FilePath', - executablePath, - '-Verb', - 'RunAs', - '-ArgumentList' - ] - logInfo( - [ - 'Setup: Executing', - command, - `${workingDir || gameInfo.install.install_path}` - ], - LogPrefix.Gog - ) - await spawnAsync( - 'powershell', - [...command, exeArguments.join(' ')], - { cwd: workingDir || gameInfo.install.install_path } - ) - break - } - logInfo( - [ - 'Setup: Executing', - [executablePath, ...exeArguments].join(' '), - `${workingDir || gameInfo.install.install_path}` - ], - LogPrefix.Gog - ) - - await runWineCommand({ + await runSetupCommand({ + commandParts: [isiPath, ...exeArgs], gameSettings, - gameInstallPath: gameInfo.install.install_path, - commandParts: [executablePath, ...exeArguments], - wait: true, - protonVerb: 'waitforexitandrun', - startFolder: workingDir || gameInfo.install.install_path + wait: false, + protonVerb: 'run', + skipPrefixCheckIKnowWhatImDoing: true, + startFolder: gogRedistPath }) - - break } - case 'supportData': { - const targetPath = handlePathVars( - actionArguments.target.replace( - '{app}', - gameInfo.install.install_path - ), - pathsValues - ).replaceAll(nonNativePathSeparator, path.sep) - const type = actionArguments.type - const sourcePath = handlePathVars( - actionArguments?.source?.replace( - '{app}', - gameInfo.install.install_path - ), - pathsValues - ).replaceAll(nonNativePathSeparator, path.sep) - if (type === 'folder') { - if (!actionArguments?.source) { - logInfo(['Setup: Creating directory', targetPath], LogPrefix.Gog) - mkdirSync(targetPath, { recursive: true }) - } else { - logInfo( - ['Setup: Copying directory', sourcePath, 'to', targetPath], - LogPrefix.Gog - ) - - if (!existsSync(sourcePath)) { - logWarning( - ['Source path', sourcePath, "doesn't exist, skipping..."], - LogPrefix.Gog - ) - - break - } - copySync(sourcePath, targetPath, { - overwrite: actionArguments?.overwrite, - recursive: true - }) - } - } else if (type === 'file') { - if (sourcePath && existsSync(sourcePath)) { - logInfo( - ['Setup: Copying file', sourcePath, 'to', targetPath], - LogPrefix.Gog - ) - copyFileSync(sourcePath, targetPath) - } else { - logWarning( - ['Setup: sourcePath:', sourcePath, 'does not exist.'], - LogPrefix.Gog - ) - } - } else { - logError( - ['Setup: Unsupported supportData type:', type], - LogPrefix.Gog - ) - } - break - } - case 'setIni': { - const filePath = handlePathVars( - actionArguments?.filename?.replace( - '{app}', - gameInfo.install.install_path - ), - pathsValues - ).replaceAll(nonNativePathSeparator, path.sep) - if (!filePath || !existsSync(filePath)) { - logError("Setup: setIni file doesn't exists", LogPrefix.Gog) - break - } - const encoding = actionArguments?.utf8 ? 'utf-8' : 'ascii' - const fileData = readFileSync(filePath, { - encoding - }) - const config = ini.parse(fileData) - const section: string | undefined = actionArguments?.section - const keyName = actionArguments?.keyName - if (!section || !keyName) { - logError( - "Setup: Missing section and key values, this message shouldn't appear for you. Please report it on our Discord or GitHub", - LogPrefix.Gog - ) - break - } - - let leaf = config - // section can be in the format `Key.SubKey1.SubKey2.etc` - // Alien Breed: Impact is one example of such game - section.split('.').forEach((key) => { - leaf = leaf[key] - }) - - leaf[keyName] = handlePathVars(actionArguments.keyValue, pathsValues) - - writeFileSync(filePath, ini.stringify(config), { encoding }) - break - } - default: { - logError( - [ - 'Setup: Looks like you have found new setup instruction, please report it on our Discord or GitHub', - `appName: ${appName}, action: ${action.install.action}` - ], - LogPrefix.Gog + } + } else { + // Check for temp executables + for (const manifestProduct of manifestData.products) { + if ( + manifestProduct.productId !== appName && + !(gameInfo.install.installedDLCs || []).includes( + manifestProduct.productId ) + ) { + continue + } + if (!manifestProduct.temp_executable?.length) { + continue } + const absPath = path.join( + gameSupportDir, + manifestProduct.productId, + manifestProduct.temp_executable + ) + const language = (lang || 'English').toLowerCase() + + const exeArgs = [ + '/VERYSILENT', + `/DIR=${gameDirectoryPath}`, + `/Language=${language}`, + `/LANG=${language}`, + `/lang-code=${gameInfo.install.language || 'en-US'}`, + `/ProductId=${manifestProduct.productId}`, + `/galaxyclient`, + `/buildId=${gameInfo.install.buildId}`, + `/versionName=${gameInfo.install.version}`, + '/nodesktopshorctut', + '/nodesktopshortcut' + ] + await runSetupCommand({ + commandParts: [absPath, ...exeArgs], + gameSettings, + wait: false, + protonVerb: 'run', + skipPrefixCheckIKnowWhatImDoing: true, + startFolder: path.join(gameSupportDir, manifestProduct.productId) + }) } } - } else { - // I's from V1 game manifest - // Sample - /* - "support_commands": [ - { - "languages": [ - "Neutral" - ], - "executable": "/galaxy_akalabeth_2.0.0.1.exe", - "gameID": "1207666073", - "systems": [ - "Windows" - ], - "argument": "" - } - ], - */ - if (instructions[0]?.gameID !== appName) { - logError('Setup: Unexpected instruction gameID missmatch', LogPrefix.Gog) - return + for (const dep of manifestData.dependencies || []) { + if (!dependencies.includes(dep)) { + dependencies.push(dep) + } } + } - const supportDir = path.join( - gameInfo.install.install_path!, - 'support', - appName - ) - const infoPath = path.join( - gameInfo.install.install_path!, - `goggame-${appName}.info` - ) + // Install redistributables according to redist manifest + const gogRedistManifestPath = path.join( + gogRedistPath, + '.gogdl-redist-manifest' + ) - const installPath = isWindows - ? gameInfo.install.install_path || '' - : await getWinePath({ - path: gameInfo.install.install_path || '', - gameSettings, - variant: 'win' - }) - let Language = 'english' - // Load game language data - if (existsSync(infoPath)) { - const contents = readFileSync(infoPath, 'utf-8') - Language = JSON.parse(contents).language - Language = Language.toLowerCase() + if (existsSync(gogRedistManifestPath) && installRedist) { + const gogRedistManifestDataRaw = await readFile(gogRedistManifestPath, { + encoding: 'utf-8' + }) + let gogRedistManifestData: GOGRedistManifest + try { + gogRedistManifestData = JSON.parse(gogRedistManifestDataRaw) + } catch (e) { + logError('SETUP: Failed to parse redist manifest', { + prefix: LogPrefix.Gog + }) + return } - const exeArguments = [ - '/VERYSILENT', - `/DIR=${installPath}`, - `/Language=${Language}`, - `/LANG=${Language}`, - `/ProductId=${appName}`, - `/galaxyclient`, - `/buildId=${gameInfo.install.buildId}`, - `/versionName=${gameInfo.install.version}`, - `/nodesktopshorctut`, // Disable desktop shortcuts but misspelled :/ - `/nodesktopshortcut`, // Disable desktop shortcuts - '/NOICONS' // Disable start menu icons - ] + for (const dep of dependencies) { + const foundDep = gogRedistManifestData.depots.find( + (depot) => depot.dependencyId === dep + ) + if (!foundDep) { + logWarning(['SETUP: Was not able to find redist data for', dep], { + prefix: LogPrefix.Gog + }) + continue + } - const executablePath = path.join(supportDir, instructions[0].executable) + if (!foundDep.executable.path.length) { + logInfo(['SETUP: skipping redist', dep], { prefix: LogPrefix.Gog }) + continue + } - let command = [executablePath, ...exeArguments] + const exePath = path.join(gogRedistPath, foundDep.executable.path) + const exeArguments = foundDep.executable.arguments.length + ? shlex.split(foundDep.executable.arguments) + : [] - // Requires testing - if (isWindows) { - command = [ - 'Start-Process', - '-FilePath', - executablePath, - '-Verb', - 'RunAs', - '-ArgumentList' - ] - logInfo(['Setup: Executing', command, supportDir], LogPrefix.Gog) - await spawnAsync('powershell', [...command, exeArguments.join(' ')], { - cwd: supportDir + const commandParts = [exePath, ...exeArguments] + + logInfo(['SETUP: Installing redist', foundDep.readableName], { + prefix: LogPrefix.Gog }) - } else { - logInfo(['Setup: Executing', command, `${supportDir}`], LogPrefix.Gog) - await runWineCommand({ + + sendGameStatusUpdate({ + appName, + runner: 'gog', + status: 'redist', + context: foundDep.readableName + }) + + await runSetupCommand({ + commandParts, gameSettings, - gameInstallPath: gameInfo.install.install_path, - commandParts: command, - wait: true, - protonVerb: 'waitforexitandrun', - startFolder: supportDir + startFolder: gogRedistPath, + wait: false, + protonVerb: 'run', + skipPrefixCheckIKnowWhatImDoing: true, // We are running those commands after we check if prefix is valid, this shouldn't cause issues + gameInstallPath: gameInfo.install.install_path! }) } } - logInfo('Setup: Finished', LogPrefix.Gog) -} - -async function obtainSetupInstructions(gameInfo: GameInfo) { - const { buildId, appName, install_path } = gameInfo.install - const scriptPath = path.join(install_path!, `goggame-${appName}.script`) - if (existsSync(scriptPath)) { - const data = readFileSync(scriptPath, { encoding: 'utf-8' }) - return JSON.parse(data).actions - } - // No .script is present, check for support_commands in repository.json of V1 games - if (!isOnline()) { - logWarning( - "Setup: App is offline, couldn't check if there are any support_commands in manifest", - LogPrefix.Gog - ) - return null - } - const buildResponse = await axios.get( - `https://content-system.gog.com/products/${appName}/os/windows/builds` - ) - const buildData = buildResponse.data - const buildItem = buildData.items.find( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (value: any) => - value.legacy_build_id === buildId || value.build_id === buildId - ) - // Get data only if it's V1 depot game - if (buildItem?.generation === 1) { - const metaResponse = await axios.get(buildItem.link) - return metaResponse.data.product?.support_commands - } - - // TODO: find if there are V2 games with something like support_commands in manifest - return null -} - -const registryDataTypes = new Map([ - ['string', 'REG_SZ'], - ['dword', 'REG_DWORD'], - ['binary', 'REG_BINARY'] - // If needed please add those values REG_NONE REG_EXPAND_SZ REG_MULTI_SZ -]) -const getRegDataType = (dataType: string): string | undefined => - registryDataTypes.get(dataType.toLowerCase()) - -/** - * Handles getting a path variable from possibleValues Map - * Every key is lower cased to avoid edge cases - * @returns - */ -const handlePathVars = ( - path: string, - possibleValues: Map -): string => { - if (!path) { - return path - } - const variables = path.match(/{[a-zA-Z]+}|%[a-zA-Z]+%/g) - if (!variables) { - return path - } - for (const value of variables) { - const trimmedValue = value.slice(1, -1) - - return path.replace( - value, - possibleValues.get(trimmedValue.toLowerCase()) ?? '' - ) - } - - return '' + logInfo('Setup: Finished', LogPrefix.Gog) } export default setup diff --git a/src/backend/storeManagers/gog/user.ts b/src/backend/storeManagers/gog/user.ts index a093991273..19000d8fea 100644 --- a/src/backend/storeManagers/gog/user.ts +++ b/src/backend/storeManagers/gog/user.ts @@ -4,7 +4,7 @@ import { logError, logInfo, LogPrefix, logWarning } from '../../logger/logger' import { GOGLoginData } from 'common/types' import { configStore } from './electronStores' import { isOnline } from '../../online_monitor' -import { UserData } from 'common/types/gog' +import { GOGCredentials, UserData } from 'common/types/gog' import { runRunnerCommand } from './library' import { gogdlAuthConfig } from 'backend/constants' import { clearCache } from 'backend/utils' @@ -101,7 +101,7 @@ export class GOGUser { * if needed refreshes token and returns new credentials * @returns user credentials */ - public static async getCredentials() { + public static async getCredentials(): Promise { if (!isOnline()) { logWarning('Unable to get credentials - app is offline', { prefix: LogPrefix.Gog diff --git a/src/backend/storeManagers/index.ts b/src/backend/storeManagers/index.ts index b3a2216688..23d9c16f59 100644 --- a/src/backend/storeManagers/index.ts +++ b/src/backend/storeManagers/index.ts @@ -79,6 +79,6 @@ export function autoUpdate(runner: Runner, gamesToUpdate: string[]) { export async function initStoreManagers() { await LegendaryLibraryManager.initLegendaryLibraryManager() - await GOGLibraryManager.refresh() + await GOGLibraryManager.initGOGLibraryManager() await NileLibraryManager.initNileLibraryManager() } diff --git a/src/backend/storeManagers/legendary/games.ts b/src/backend/storeManagers/legendary/games.ts index 7d087a3dbf..b66110d3e5 100644 --- a/src/backend/storeManagers/legendary/games.ts +++ b/src/backend/storeManagers/legendary/games.ts @@ -789,7 +789,9 @@ export async function launch( let commandEnv = { ...process.env, ...setupWrapperEnvVars({ appName, appRunner: 'legendary' }), - ...(isWindows ? {} : setupEnvVars(gameSettings)) + ...(isWindows + ? {} + : setupEnvVars(gameSettings, gameInfo.install.install_path)) } const wrappers = setupWrappers( diff --git a/src/backend/storeManagers/legendary/library.ts b/src/backend/storeManagers/legendary/library.ts index 0e9ae95eab..e9818725bd 100644 --- a/src/backend/storeManagers/legendary/library.ts +++ b/src/backend/storeManagers/legendary/library.ts @@ -896,3 +896,10 @@ export async function getLaunchOptions( return launchOptions } + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +export function changeVersionPinnedStatus(appName: string, status: boolean) { + logWarning( + 'changeVersionPinnedStatus not implemented on Legendary Library Manager' + ) +} diff --git a/src/backend/storeManagers/legendary/setup.ts b/src/backend/storeManagers/legendary/setup.ts index 8a711ed191..738b80e341 100644 --- a/src/backend/storeManagers/legendary/setup.ts +++ b/src/backend/storeManagers/legendary/setup.ts @@ -15,7 +15,8 @@ export const legendarySetup = async (appName: string) => { sendGameStatusUpdate({ appName, runner: 'legendary', - status: 'prerequisites' + status: 'redist', + context: 'EPIC' }) // Fixes games like Fallout New Vegas and Dishonored: Death of the Outsider diff --git a/src/backend/storeManagers/nile/games.ts b/src/backend/storeManagers/nile/games.ts index 47cfe80491..673c803520 100644 --- a/src/backend/storeManagers/nile/games.ts +++ b/src/backend/storeManagers/nile/games.ts @@ -335,7 +335,9 @@ export async function launch( let commandEnv = { ...process.env, ...setupWrapperEnvVars({ appName, appRunner: 'nile' }), - ...(isWindows ? {} : setupEnvVars(gameSettings)) + ...(isWindows + ? {} + : setupEnvVars(gameSettings, gameInfo.install.install_path)) } const wrappers = setupWrappers( diff --git a/src/backend/storeManagers/nile/library.ts b/src/backend/storeManagers/nile/library.ts index a3fbb8f4be..6bd9ffd8ee 100644 --- a/src/backend/storeManagers/nile/library.ts +++ b/src/backend/storeManagers/nile/library.ts @@ -474,3 +474,10 @@ export async function runRunnerCommand( } export const getLaunchOptions = () => [] + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +export function changeVersionPinnedStatus(appName: string, status: boolean) { + logWarning( + 'changeVersionPinnedStatus not implemented on Nile Library Manager' + ) +} diff --git a/src/backend/storeManagers/nile/setup.ts b/src/backend/storeManagers/nile/setup.ts index bd6a2263f4..49c5a35b77 100644 --- a/src/backend/storeManagers/nile/setup.ts +++ b/src/backend/storeManagers/nile/setup.ts @@ -81,7 +81,12 @@ export default async function setup( logDebug(['PostInstall:', fuel.PostInstall], LogPrefix.Nile) - sendGameStatusUpdate({ appName, runner: 'nile', status: 'prerequisites' }) + sendGameStatusUpdate({ + appName, + runner: 'nile', + status: 'redist', + context: 'AMAZON' + }) // Actual setup logic for (const action of fuel.PostInstall) { diff --git a/src/backend/storeManagers/sideload/library.ts b/src/backend/storeManagers/sideload/library.ts index 0dd7b5f1fc..006447e71d 100644 --- a/src/backend/storeManagers/sideload/library.ts +++ b/src/backend/storeManagers/sideload/library.ts @@ -128,3 +128,9 @@ export async function getInstallInfo( } export const getLaunchOptions = () => [] + +export function changeVersionPinnedStatus(appName: string, status: boolean) { + logWarning( + 'changeVersionPinnedStatus not implemented on Sideload Library Manager' + ) +} diff --git a/src/backend/storeManagers/storeManagerCommon/games.ts b/src/backend/storeManagers/storeManagerCommon/games.ts index 16f6ad2f13..fc904f147b 100644 --- a/src/backend/storeManagers/storeManagerCommon/games.ts +++ b/src/backend/storeManagers/storeManagerCommon/games.ts @@ -212,7 +212,7 @@ export async function launchGame( const env = { ...process.env, ...setupWrapperEnvVars({ appName, appRunner: runner }), - ...setupEnvVars(gameSettings) + ...setupEnvVars(gameSettings, gameInfo.install.install_path) } await callRunner( @@ -247,7 +247,8 @@ export async function launchGame( await runWineCommand({ commandParts: [executable, launcherArgs ?? ''], gameSettings, - wait: false, + wait: true, + protonVerb: 'waitforexitandrun', startFolder: dirname(executable), options: { wrappers, diff --git a/src/backend/tools/ipc_handler.ts b/src/backend/tools/ipc_handler.ts index 839409a889..d4a0eaac24 100644 --- a/src/backend/tools/ipc_handler.ts +++ b/src/backend/tools/ipc_handler.ts @@ -4,6 +4,8 @@ import { Winetricks, runWineCommandOnGame } from '.' import path from 'path' import { isWindows } from 'backend/constants' import { execAsync, sendGameStatusUpdate } from 'backend/utils' +import * as GOGLibraryManager from 'backend/storeManagers/gog/library' +import { sendFrontendMessage } from 'backend/main_window' ipcMain.handle( 'runWineCommandForGame', @@ -48,6 +50,14 @@ ipcMain.handle('callTool', async (event, { tool, exe, appName, runner }) => { } break } + if (runner === 'gog') { + // Check if game was modified by offline installer / wine uninstaller + await GOGLibraryManager.checkForOfflineInstallerChanges(appName) + sendFrontendMessage( + 'pushGameToLibrary', + GOGLibraryManager.getGameInfo(appName) + ) + } sendGameStatusUpdate({ appName, runner, status: 'done' }) }) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 96dcd21151..73d410a79f 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -75,6 +75,7 @@ import { updateWineVersionInfos, wineDownloaderInfoStore } from './wine/manager/utils' +import { readdir, stat } from 'fs/promises' import { getHeroicVersion } from './utils/systeminfo/heroicVersion' import { backendEvents } from './backend_events' import { wikiGameInfoStore } from './wiki_game_info/electronStore' @@ -475,12 +476,12 @@ async function getSteamRuntime( const steamLibraries = await getSteamLibraries() const runtimeTypes: SteamRuntime[] = [ { - path: 'steamapps/common/SteamLinuxRuntime_sniper/run', + path: 'steamapps/common/SteamLinuxRuntime_sniper/_v2-entry-point', type: 'sniper', args: ['--'] }, { - path: 'steamapps/common/SteamLinuxRuntime_soldier/run', + path: 'steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point', type: 'soldier', args: ['--'] }, @@ -1171,6 +1172,22 @@ function removeFolder(path: string, folderName: string) { return } +async function getPathDiskSize(path: string): Promise { + const statData = await stat(path) + let size = 0 + if (statData.isDirectory()) { + const contents = await readdir(path) + + for (const item of contents) { + const itemPath = join(path, item) + size += await getPathDiskSize(itemPath) + } + return size + } + + return statData.size +} + function sendGameStatusUpdate(payload: GameStatus) { sendFrontendMessage('gameStatusUpdate', payload) backendEvents.emit('gameStatusUpdate', payload) @@ -1450,6 +1467,7 @@ export { getFileSize, memoryLog, removeFolder, + getPathDiskSize, sendGameStatusUpdate, sendProgressUpdate, calculateEta, diff --git a/src/common/typedefs/ipcBridge.d.ts b/src/common/typedefs/ipcBridge.d.ts index f59e0fdb49..341aaed72e 100644 --- a/src/common/typedefs/ipcBridge.d.ts +++ b/src/common/typedefs/ipcBridge.d.ts @@ -114,6 +114,11 @@ interface SyncIPCFunctions { appName: string, component: string }) => void + changeGameVersionPinnedStatus: ( + appName: string, + runner: Runner, + status: boolean + ) => void } // ts-prune-ignore-next @@ -160,7 +165,9 @@ interface AsyncIPCFunctions { getInstallInfo: ( appName: string, runner: Runner, - installPlatform: InstallPlatform + installPlatform: InstallPlatform, + branch?: string, + build?: string ) => Promise getUserInfo: () => Promise getAmazonUserInfo: () => Promise @@ -286,6 +293,15 @@ interface AsyncIPCFunctions { ) => Promise getAmazonLoginData: () => Promise hasExecutable: (executable: string) => Promise + + setPrivateBranchPassword: (appName: string, password: string) => void + getPrivateBranchPassword: (appName: string) => string + + getAvailableCyberpunkMods: () => Promise + setCyberpunkModConfig: (props: { + enabled: boolean + modsToLoad: string[] + }) => 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 c4670cca5d..0ffb986423 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -120,6 +120,7 @@ export interface ExtraInfo { reqs: Reqs[] releaseDate?: string storeUrl?: string + changelog?: string } export type GameConfigVersion = 'auto' | 'v0' | 'v0.1' @@ -211,7 +212,7 @@ export type Status = | 'notSupportedGame' | 'notInstalled' | 'installed' - | 'prerequisites' + | 'redist' | 'extracting' | 'winetricks' @@ -219,6 +220,7 @@ export interface GameStatus { appName: string progress?: InstallProgress folder?: string + context?: string // Additional context e.g current step runner?: Runner status: Status } @@ -241,10 +243,19 @@ export interface InstalledInfo { version: string platform: InstallPlatform appName?: string - installedWithDLCs?: boolean // For verifing GOG games - language?: string // For verifing GOG games + installedWithDLCs?: boolean // OLD DLC boolean (all dlcs installed) + installedDLCs?: string[] // New installed GOG DLCs array + language?: string // For GOG games versionEtag?: string // Checksum for checking GOG updates - buildId?: string // For verifing GOG games + buildId?: string // For verifing and version pinning of GOG games + branch?: string // GOG beta channels + // Whether to skip update check for this title (currently only used for GOG as it is the only platform actively supporting version rollback) + pinnedVersion?: boolean + cyberpunk?: { + // Cyberpunk compatibility options + modsEnabled: boolean + modsToLoad: string[] // If this is empty redmod will load mods in alphabetic order + } } export interface Reqs { @@ -272,9 +283,12 @@ export interface WineInstallation { export interface InstallArgs { path: string platformToInstall: InstallPlatform - installDlcs?: Array | boolean + installDlcs?: Array sdlList?: string[] installLanguage?: string + branch?: string + build?: string + dependencies?: string[] } export interface InstallParams extends InstallArgs { @@ -286,8 +300,12 @@ export interface InstallParams extends InstallArgs { export interface UpdateParams { appName: string - gameInfo: GameInfo runner: Runner + gameInfo: GameInfo + installDlcs?: Array + installLanguage?: string + build?: string + branch?: string } export interface GOGLoginData { @@ -316,7 +334,7 @@ export interface GOGImportData { installedLanguage: string platform: GogInstallPlatform versionName: string - installedWithDlcs: boolean + dlcs: string[] } export type GamepadInputEvent = diff --git a/src/common/types/electron_store.ts b/src/common/types/electron_store.ts index 13c2983668..17fadf6fee 100644 --- a/src/common/types/electron_store.ts +++ b/src/common/types/electron_store.ts @@ -86,6 +86,9 @@ export interface StoreStructure { [saveName: string]: string } } + gogPrivateBranches: { + [appName: string]: string + } wineManagerConfigStore: { 'wine-manager-settings': WineManagerUISettings[] 'wine-releases': WineVersionInfo[] diff --git a/src/common/types/game_manager.ts b/src/common/types/game_manager.ts index 5d84124278..6d6ad43074 100644 --- a/src/common/types/game_manager.ts +++ b/src/common/types/game_manager.ts @@ -57,7 +57,16 @@ export interface GameManager { gogSaves?: GOGCloudSavesLocation[] ) => Promise uninstall: (args: RemoveArgs) => Promise - update: (appName: string) => Promise + update: ( + appName: string, + updateOverwrites?: { + build?: string + branch?: string + language?: string + dlcs?: string[] + dependencies?: string[] + } + ) => Promise forceUninstall: (appName: string) => Promise stop: (appName: string, stopWine?: boolean) => Promise isGameAvailable: (appName: string) => Promise @@ -69,10 +78,12 @@ export interface LibraryManager { getInstallInfo: ( appName: string, installPlatform: InstallPlatform, - lang?: string + branch?: string, + build?: string ) => Promise listUpdateableGames: () => Promise changeGameInstallPath: (appName: string, newPath: string) => Promise + changeVersionPinnedStatus: (appName: string, status: boolean) => void installState: (appName: string, state: boolean) => void getLaunchOptions: ( appName: string diff --git a/src/common/types/gog.ts b/src/common/types/gog.ts index 22beff917e..92ae1c4196 100644 --- a/src/common/types/gog.ts +++ b/src/common/types/gog.ts @@ -20,20 +20,53 @@ interface GameInstallInfo { owned_dlc: Array title: string version: string + branches: Array buildId: string } -interface DLCInfo { +type PerLanguageSize = { + '*': { download_size: number; disk_size: number } + [key: string]: { download_size: number; disk_size: number } +} + +// Raw output of gogdl info command +export interface GOGDLInstallInfo { + size: PerLanguageSize + download_size?: number // only linux native + disk_size?: number + languages: Array + dlcs: Array<{ title: string; id: string; size: PerLanguageSize }> + buildId: string + os: GogInstallPlatform + branch: string | null + dependencies: Array + versionName: string + versionEtag: string + folder_name: string + available_branches: Array + builds: { + items: Array + total_count: number + count: number + has_private_branches: boolean + } +} + +export interface DLCInfo { app_name: string title: string + perLangSize: PerLanguageSize } interface GameManifest { app_name: string disk_size: number download_size: number + perLangSize: PerLanguageSize languages: string[] versionEtag: string + dependencies: string[] + builds?: BuildItem[] } export interface GOGCloudSavesLocation { @@ -309,7 +342,17 @@ export interface BuildItem { public: boolean date_published: string generation: number - link: string + link?: string + // Visible only with _version=2 parameter + urls?: { + endpoint_name: string + url: string + url_format: string + parameters: string + fallback_only: boolean + max_fails: number + priority: number + }[] } interface ProductsEndpointFile { @@ -396,4 +439,94 @@ export interface ProductsEndpointData { language_packs: Array bonus_content: Array } + changelog?: string +} + +// MANIFESTS + +export interface GOGv1Manifest { + version: 1 + product: { + timestamp: number + depots: Array< + | { + languages: string[] + size: string + gameIDs: string[] + systems: string[] + manifest: string + } + | { redist: string; executable: string; argument: string; size: string } + > + + support_commands: { + languages: string[] + executable: string + gameID: string + argument: string + systems: string[] + }[] + installDirectory: string + rootGameID: string + gameIDs: { + gameID: string + name: { [lang: string]: string } + dependencies: string[] + standalone: boolean + }[] + projectName: string + } +} + +export interface GOGv2Manifest { + version: 2 + baseProductId: string + buildId: string + clientId?: string + clientSecret?: string + dependencies?: string[] + depots: Array<{ + compressedSize: number + languages: string[] + manifest: string + productId: string + size: number + isGogDepot?: boolean + }> + platform: GogInstallPlatform + installDirectory: string + products: Array<{ + name: string + productId: string + temp_arguments: string + temp_executable: string + }> + tags?: string[] + scriptInterpreter?: boolean +} + +export interface GOGRedistManifest { + depots: Array<{ + compressedSize: number + dependencyId: string + executable: { arguments: string; path: string } + internal: boolean + readableName: string + manifest: string + signature: string + size: number + }> + build_id?: string + HGLInstalled?: string[] +} + +export interface GOGCredentials { + access_token: string + expires_in: number + token_type: string + scope: string + session_id: string + refresh_token: string + user_id: string + loginType: number } diff --git a/src/frontend/components/UI/TextInputField/index.css b/src/frontend/components/UI/TextInputField/index.css index 9f7aed7588..173e5c4622 100644 --- a/src/frontend/components/UI/TextInputField/index.css +++ b/src/frontend/components/UI/TextInputField/index.css @@ -3,7 +3,8 @@ grid-template-areas: 'label' 'input'; } -.textInputFieldWrapper input[type='text'] { +.textInputFieldWrapper input[type='text'], +.textInputFieldWrapper input[type='password'] { border-radius: var(--space-3xs); grid-area: input; width: 100%; diff --git a/src/frontend/components/UI/TextInputField/index.tsx b/src/frontend/components/UI/TextInputField/index.tsx index 247a1c7f47..20da22b1a6 100644 --- a/src/frontend/components/UI/TextInputField/index.tsx +++ b/src/frontend/components/UI/TextInputField/index.tsx @@ -1,36 +1,28 @@ -import React, { ChangeEvent, FocusEvent, ReactNode, useContext } from 'react' +import React, { ReactNode, useContext } from 'react' import classnames from 'classnames' import ContextProvider from 'frontend/state/ContextProvider' import './index.css' -interface TextInputFieldProps { +interface TextInputFieldProps + extends React.InputHTMLAttributes { htmlId: string - value: string - onChange: (event: ChangeEvent) => void inputIcon?: ReactNode afterInput?: ReactNode label?: string placeholder?: string - disabled?: boolean extraClass?: string warning?: ReactNode - onBlur?: (event: FocusEvent) => void - maxLength?: number } const TextInputField = ({ htmlId, - value, - onChange, label, - placeholder, - disabled = false, extraClass = '', inputIcon, afterInput, warning, - onBlur, - maxLength + value, + ...inputProps }: TextInputFieldProps) => { const { isRTL } = useContext(ContextProvider) @@ -42,16 +34,7 @@ const TextInputField = ({ > {label && } {inputIcon} - + {value && warning} {afterInput} diff --git a/src/frontend/components/UI/TextInputWithIconField/index.tsx b/src/frontend/components/UI/TextInputWithIconField/index.tsx index caa92fcb00..fd088f4ef1 100644 --- a/src/frontend/components/UI/TextInputWithIconField/index.tsx +++ b/src/frontend/components/UI/TextInputWithIconField/index.tsx @@ -16,13 +16,17 @@ interface TextInputWithIconFieldProps { onBlur?: (event: FocusEvent) => void } -const TextInputWithIconField = (props: TextInputWithIconFieldProps) => { +const TextInputWithIconField = ({ + icon, + onIconClick, + ...props +}: TextInputWithIconFieldProps) => { return ( - {props.icon} + + {icon} } /> diff --git a/src/frontend/helpers/index.ts b/src/frontend/helpers/index.ts index 6c44027172..258fb23f98 100644 --- a/src/frontend/helpers/index.ts +++ b/src/frontend/helpers/index.ts @@ -74,12 +74,16 @@ const getGameSettings = async ( const getInstallInfo = async ( appName: string, runner: Runner, - installPlatform: InstallPlatform + installPlatform: InstallPlatform, + build?: string, + branch?: string ): Promise => { return window.api.getInstallInfo( appName, runner, - handleRunnersPlatforms(installPlatform, runner) + handleRunnersPlatforms(installPlatform, runner), + build, + branch ) } @@ -95,9 +99,6 @@ function handleRunnersPlatforms( return 'osx' case 'Windows': return 'windows' - // GOG doesn't have a linux platform, so we need to get the information as windows - case 'linux': - return 'windows' default: return platform } @@ -138,6 +139,24 @@ const getStoreName = (runner: Runner, other: string) => { } } +function getPreferredInstallLanguage( + availableLanguages: string[], + preferredLanguages: readonly string[] +) { + const foundPreffered = preferredLanguages.find((plang) => + availableLanguages.some((alang) => alang.startsWith(plang)) + ) + if (foundPreffered) { + const foundAvailable = availableLanguages.find((alang) => + alang.startsWith(foundPreffered) + ) + if (foundAvailable) { + return foundAvailable + } + } + return availableLanguages[0] +} + export { createNewWindow, getGameInfo, @@ -159,5 +178,6 @@ export { updateGame, writeConfig, removeSpecialcharacters, - getStoreName + getStoreName, + getPreferredInstallLanguage } diff --git a/src/frontend/helpers/library.ts b/src/frontend/helpers/library.ts index 1a2df4d2cb..8eb949a539 100644 --- a/src/frontend/helpers/library.ts +++ b/src/frontend/helpers/library.ts @@ -20,13 +20,15 @@ type InstallArgs = { isInstalling: boolean previousProgress: InstallProgress | null progress: InstallProgress - installDlcs?: Array | boolean + installDlcs?: Array t: TFunction<'gamepage'> showDialogModal: (options: DialogModalOptions) => void setInstallPath?: (path: string) => void platformToInstall?: InstallPlatform sdlList?: Array installLanguage?: string + build?: string + branch?: string } async function install({ @@ -38,9 +40,11 @@ async function install({ previousProgress, setInstallPath, sdlList = [], - installDlcs = false, + installDlcs = [], installLanguage = 'en-US', platformToInstall = 'Windows', + build, + branch, showDialogModal }: InstallArgs) { if (!installPath) { @@ -114,7 +118,9 @@ async function install({ installLanguage, runner, platformToInstall, - gameInfo + gameInfo, + build, + branch }) } diff --git a/src/frontend/hooks/constants.ts b/src/frontend/hooks/constants.ts index e2d614c19a..c41f649480 100644 --- a/src/frontend/hooks/constants.ts +++ b/src/frontend/hooks/constants.ts @@ -5,12 +5,14 @@ type StatusArgs = { status: Status t: TFunction<'gamepage', undefined> runner: Runner + statusContext?: string percent?: number size?: string } export function getStatusLabel({ status, + statusContext, t, runner, size, @@ -36,9 +38,10 @@ export function getStatusLabel({ notInstalled: t('gamepage:status.notinstalled'), launching: t('gamepage:status.launching', 'Launching'), winetricks: t('gamepage:status.winetricks', 'Applying Winetricks fixes'), - prerequisites: t( - 'gamepage:status.prerequisites', - 'Installing Prerequisites' + redist: t( + 'gamepage:status.redist', + 'Installing Redistributables ({{redist}})', + { redist: statusContext || '' } ) } diff --git a/src/frontend/hooks/hasStatus.ts b/src/frontend/hooks/hasStatus.ts index d66c6eb900..830d8af425 100644 --- a/src/frontend/hooks/hasStatus.ts +++ b/src/frontend/hooks/hasStatus.ts @@ -16,6 +16,7 @@ export function hasStatus( const [gameStatus, setGameStatus] = React.useState<{ status?: Status + statusContext?: string folder?: string label: string }>({ label: '' }) @@ -28,8 +29,12 @@ export function hasStatus( React.useEffect(() => { const checkGameStatus = async () => { - const { status, folder } = - libraryStatus.find((game: GameStatus) => game.appName === appName) || {} + const { + status, + folder, + context: statusContext + } = libraryStatus.find((game: GameStatus) => game.appName === appName) || + {} if (status && status !== 'done') { const label = getStatusLabel({ @@ -37,9 +42,10 @@ export function hasStatus( t, runner, size: gameSize, + statusContext, percent: progress.percent }) - return setGameStatus({ status, folder, label }) + return setGameStatus({ status, folder, label, statusContext }) } if (thirdPartyManagedApp === 'Origin') { @@ -48,7 +54,11 @@ export function hasStatus( t, runner }) - return setGameStatus({ status: 'notSupportedGame', label }) + return setGameStatus({ + status: 'notSupportedGame', + label, + statusContext + }) } if (is_installed) { @@ -59,7 +69,7 @@ export function hasStatus( t, runner }) - return setGameStatus({ status: 'notAvailable', label }) + return setGameStatus({ status: 'notAvailable', label, statusContext }) } const label = getStatusLabel({ status: 'installed', @@ -67,7 +77,7 @@ export function hasStatus( runner, size: gameSize }) - return setGameStatus({ status: 'installed', label }) + return setGameStatus({ status: 'installed', label, statusContext }) } const label = getStatusLabel({ @@ -75,7 +85,7 @@ export function hasStatus( t, runner }) - return setGameStatus({ status: 'notInstalled', label }) + return setGameStatus({ status: 'notInstalled', label, statusContext }) } checkGameStatus() }, [ diff --git a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index 33b389263b..14e2dfaaa1 100644 --- a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx +++ b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx @@ -208,7 +208,9 @@ const DownloadManagerItem = ({ return current ? 'var(--text-default)' : 'var(--accent)' } - const currentApp = library.find((val) => val.app_name === appName) + const currentApp = library.find( + (val) => val.app_name === appName && val.runner === runner + ) if (!currentApp) { return null diff --git a/src/frontend/screens/Game/GameChangeLog/index.tsx b/src/frontend/screens/Game/GameChangeLog/index.tsx new file mode 100644 index 0000000000..c67f03e416 --- /dev/null +++ b/src/frontend/screens/Game/GameChangeLog/index.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react' +import { + Dialog, + DialogContent, + DialogHeader +} from 'frontend/components/UI/Dialog' +import sanitizeHtml from 'sanitize-html' +import { useTranslation } from 'react-i18next' + +interface GameChangeLogProps { + title: string + changelog: string + backdropClick: () => void +} + +export default function GameChangeLog({ + title, + changelog, + backdropClick +}: GameChangeLogProps) { + const { t } = useTranslation('gamepage') + const santiziedChangeLog = useMemo(() => { + const sanitized = sanitizeHtml(changelog, { + disallowedTagsMode: 'discard' + }) + return { __html: sanitized } + }, [changelog]) + + return ( + + + {t('game.changelogFor', 'Changelog for {{gameTitle}}', { + gameTitle: title + })} + + +
+ +
+ ) +} diff --git a/src/frontend/screens/Game/GameContext.tsx b/src/frontend/screens/Game/GameContext.tsx index 1e7f4fe4dc..8286063e86 100644 --- a/src/frontend/screens/Game/GameContext.tsx +++ b/src/frontend/screens/Game/GameContext.tsx @@ -12,7 +12,7 @@ const initialContext: GameContextType = { is: { installing: false, installingWinetricksPackages: false, - installingPrerequisites: false, + installingRedist: false, launching: false, linux: false, linuxNative: false, diff --git a/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx b/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx index 9998535283..0729db3f99 100644 --- a/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx +++ b/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx @@ -11,8 +11,8 @@ import { } from 'frontend/components/UI/Dialog' import GameRequirements from '../../GameRequirements' import { useTranslation } from 'react-i18next' -import DLCList from 'frontend/components/UI/DLCList' -import { UpdateComponent } from 'frontend/components/UI' +import GameChangeLog from '../../GameChangeLog' +import ModifyInstallModal from '../../ModifyInstallModal' interface Props { gameInfo: GameInfo @@ -21,12 +21,13 @@ interface Props { const DotsMenu = ({ gameInfo, handleUpdate }: Props) => { const { t } = useTranslation('gamepage') - const { appName, gameExtraInfo, gameInstallInfo, runner, is } = + const { appName, gameExtraInfo, gameInstallInfo, is } = useContext(GameContext) const [showRequirements, setShowRequirements] = useState(false) - const [showDlcs, setShowDlcs] = useState(false) + const [showChangelog, setShowChangelog] = useState(false) + const [showModifyInstallModal, setShowModifyInstallModal] = useState(false) - const { is_installed, title } = gameInfo + const { is_installed, title, install } = gameInfo const hasRequirements = (gameExtraInfo?.reqs || []).length > 0 @@ -47,13 +48,16 @@ const DotsMenu = ({ gameInfo, handleUpdate }: Props) => { ? gameInfo.store_url : '') } + changelog={gameExtraInfo?.changelog} runner={gameInfo.runner} + installPlatform={install.platform} handleUpdate={handleUpdate} + handleChangeLog={() => setShowChangelog(true)} disableUpdate={is.installing || is.updating} onShowRequirements={ hasRequirements ? () => setShowRequirements(true) : undefined } - onShowDlcs={() => setShowDlcs(true)} + onShowModifyInstall={() => setShowModifyInstallModal(true)} gameInfo={gameInfo} /> @@ -69,24 +73,22 @@ const DotsMenu = ({ gameInfo, handleUpdate }: Props) => { )} - {showDlcs && ( - setShowDlcs(false)}> - setShowDlcs(false)}> -
{t('game.dlcs', 'DLCs')}
-
- - {gameInstallInfo ? ( - setShowDlcs(false)} - /> - ) : ( - - )} - -
+ {showModifyInstallModal && ( + setShowModifyInstallModal(false)} + /> + )} + + {gameExtraInfo?.changelog && showChangelog && ( + { + setShowChangelog(false) + }} + /> )} ) diff --git a/src/frontend/screens/Game/GamePage/components/GameStatus.tsx b/src/frontend/screens/Game/GamePage/components/GameStatus.tsx index 1b6d0a1bdd..03e1643413 100644 --- a/src/frontend/screens/Game/GamePage/components/GameStatus.tsx +++ b/src/frontend/screens/Game/GamePage/components/GameStatus.tsx @@ -14,11 +14,12 @@ interface Props { const GameStatus = ({ gameInfo, progress, handleUpdate, hasUpdate }: Props) => { const { t } = useTranslation('gamepage') - const { runner, is } = useContext(GameContext) + const { runner, is, statusContext } = useContext(GameContext) function getInstallLabel( is_installed: boolean, - notAvailable?: boolean + notAvailable?: boolean, + statusContext?: string ): React.ReactNode { const { eta, bytes, percent, file } = progress @@ -40,6 +41,12 @@ const GameStatus = ({ gameInfo, progress, handleUpdate, hasUpdate }: Props) => { return t('status.gameNotAvailable', 'Game not available') } + if (is.installingRedist) { + return t('status.redist', 'Installing Redistributables ({{redist}})', { + redist: statusContext || '' + }) + } + if (is.uninstalling) { return t('status.uninstalling', 'Uninstalling') } @@ -132,7 +139,11 @@ const GameStatus = ({ gameInfo, progress, handleUpdate, hasUpdate }: Props) => { )} {!is.installing && - getInstallLabel(gameInfo.is_installed, is.notAvailable)} + getInstallLabel( + gameInfo.is_installed, + is.notAvailable, + statusContext + )}

) diff --git a/src/frontend/screens/Game/GamePage/components/MainButton.tsx b/src/frontend/screens/Game/GamePage/components/MainButton.tsx index c31fa95403..0d8788ca06 100644 --- a/src/frontend/screens/Game/GamePage/components/MainButton.tsx +++ b/src/frontend/screens/Game/GamePage/components/MainButton.tsx @@ -39,8 +39,8 @@ const MainButton = ({ gameInfo, handlePlay, handleInstall }: Props) => { ) } - if (is.installingPrerequisites) { - return t('label.prerequisites', 'Installing Prerequisites') + if (is.installingRedist) { + return t('label.redist', 'Installing Redistributables') } if (is.installingWinetricksPackages) { return t('label.winetricks', 'Installing Winetricks Packages') @@ -140,7 +140,7 @@ const MainButton = ({ gameInfo, handlePlay, handleInstall }: Props) => { is.syncing || is.launching || is.installingWinetricksPackages || - is.installingPrerequisites + is.installingRedist } autoFocus={true} onClick={async () => handlePlay(gameInfo)} diff --git a/src/frontend/screens/Game/GamePage/index.scss b/src/frontend/screens/Game/GamePage/index.scss index 88d11790ef..04c967f328 100644 --- a/src/frontend/screens/Game/GamePage/index.scss +++ b/src/frontend/screens/Game/GamePage/index.scss @@ -472,7 +472,7 @@ .mainInfo, .tabContent { outline: 3px solid #8a8a8a; - outline-offset: -4px; + outline-offset: -3px; border-radius: 15px; isolation: isolate; position: relative; diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index 1a27004dde..debc536f40 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -105,7 +105,7 @@ export default React.memo(function GamePage(): JSX.Element | null { const [gameInfo, setGameInfo] = useState(locationGameInfo) const [gameSettings, setGameSettings] = useState(null) - const { status, folder } = hasStatus(appName, gameInfo) + const { status, folder, statusContext } = hasStatus(appName, gameInfo) const gameAvailable = gameInfo.is_installed && status !== 'notAvailable' const [progress, previousProgress] = hasProgress(appName) @@ -140,7 +140,7 @@ export default React.memo(function GamePage(): JSX.Element | null { const isSyncing = status === 'syncing-saves' const isLaunching = status === 'launching' const isInstallingWinetricksPackages = status === 'winetricks' - const isInstallingPrerequisites = status === 'prerequisites' + const isInstallingRedist = status === 'redist' const notAvailable = !gameAvailable && gameInfo.is_installed const notInstallable = gameInfo.installable !== undefined && !gameInfo.installable @@ -295,7 +295,7 @@ export default React.memo(function GamePage(): JSX.Element | null { is: { installing: isInstalling, installingWinetricksPackages: isInstallingWinetricksPackages, - installingPrerequisites: isInstallingPrerequisites, + installingRedist: isInstallingRedist, launching: isLaunching, linux: isLinux, linuxNative: isLinuxNative, @@ -315,6 +315,7 @@ export default React.memo(function GamePage(): JSX.Element | null { updating: isUpdating, win: isWin }, + statusContext, status, wikiInfo } diff --git a/src/frontend/screens/Game/GameSubMenu/index.tsx b/src/frontend/screens/Game/GameSubMenu/index.tsx index 23e8fc84f1..c45ba27a50 100644 --- a/src/frontend/screens/Game/GameSubMenu/index.tsx +++ b/src/frontend/screens/Game/GameSubMenu/index.tsx @@ -18,11 +18,14 @@ interface Props { isInstalled: boolean title: string storeUrl: string + changelog?: string + installPlatform?: string runner: Runner handleUpdate: () => void + handleChangeLog: () => void disableUpdate: boolean onShowRequirements?: () => void - onShowDlcs?: () => void + onShowModifyInstall?: () => void gameInfo: GameInfo } @@ -31,11 +34,14 @@ export default function GamesSubmenu({ isInstalled, title, storeUrl, + changelog, runner, + installPlatform, handleUpdate, + handleChangeLog, disableUpdate, onShowRequirements, - onShowDlcs, + onShowModifyInstall, gameInfo }: Props) { const { @@ -222,7 +228,11 @@ export default function GamesSubmenu({ return } - const showDlcsItem = onShowDlcs && runner === 'legendary' && isInstalled + const showModifyItem = + onShowModifyInstall && + ['legendary', 'gog'].includes(runner) && + isInstalled && + installPlatform !== 'linux' return ( <> @@ -335,6 +345,14 @@ export default function GamesSubmenu({ {t('submenu.store')} )} + {!isSideloaded && !!changelog?.length && ( + + )}{' '} {!isSideloaded && isLinux && ( )} - {showDlcsItem && ( + {showModifyItem && ( )} diff --git a/src/frontend/screens/Game/ModifyInstallModal/GOG/index.tsx b/src/frontend/screens/Game/ModifyInstallModal/GOG/index.tsx new file mode 100644 index 0000000000..e703780828 --- /dev/null +++ b/src/frontend/screens/Game/ModifyInstallModal/GOG/index.tsx @@ -0,0 +1,433 @@ +import { GameInfo } from 'common/types' +import { BuildItem, GogInstallInfo } from 'common/types/gog' +import { InfoBox, ToggleSwitch, UpdateComponent } from 'frontend/components/UI' +import { getInstallInfo, getPreferredInstallLanguage } from 'frontend/helpers' +import DLCDownloadListing from 'frontend/screens/Library/components/InstallModal/DownloadDialog/DLCDownloadListing' +import React, { useEffect, useState } from 'react' +import { Tabs, Tab } from '@mui/material' +import { useTranslation } from 'react-i18next' +import BuildSelector from 'frontend/screens/Library/components/InstallModal/DownloadDialog/BuildSelector' +import GameLanguageSelector from 'frontend/screens/Library/components/InstallModal/DownloadDialog/GameLanguageSelector' +import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd' +import classNames from 'classnames' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faGripLines } from '@fortawesome/free-solid-svg-icons' +import { faXmarkCircle } from '@fortawesome/free-regular-svg-icons' +import BranchSelector from 'frontend/screens/Library/components/InstallModal/DownloadDialog/BranchSelector' + +interface GOGModifyInstallModal { + gameInfo: GameInfo + onClose: () => void +} + +type TabPanelProps = { + children?: React.ReactNode + index: string + value: string +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props + + return ( +
+ {value === index &&
{children}
} +
+ ) +} + +export default function GOGModifyInstallModal({ + gameInfo, + onClose +}: GOGModifyInstallModal) { + const { t, i18n } = useTranslation('gamepage') + const { t: tr } = useTranslation() + const [gameInstallInfo, setGameInstallInfo] = useState() + + const [installLanguages, setInstallLanguages] = useState([]) + const [installLanguage, setInstallLanguage] = useState( + gameInfo.install.language ?? 'en-US' + ) + + const [branches, setBranches] = useState>([]) + const [branch, setBranch] = useState( + gameInfo.install.branch + ) + const [savedBranchPassword, setSavedBranchPassword] = useState('') + + // undefined means latest from current branch + const [selectedBuild, setSelectedBuild] = useState( + gameInfo.install.pinnedVersion ? gameInfo.install.buildId : undefined + ) + const [builds, setBuilds] = useState([]) + + const [installedDlcs, setInstalledDlcs] = useState([]) + + const [detectedMods, setDetectedMods] = useState([]) + const [enabledModsList, setEnabledModsList] = useState( + gameInfo.install.cyberpunk?.modsToLoad || [] + ) + const [modsEnabled, setModsEnabled] = useState( + gameInfo.install.cyberpunk?.modsEnabled || false + ) + + const redModInstalled = gameInfo.install.installedDLCs?.includes('1597316373') + + const [currentTab, setCurrentTab] = useState('updates') + + const handleConfirm = () => { + const gameBuild = selectedBuild || builds[0].build_id + const versionPinned = !!selectedBuild + const buildModified = gameBuild !== gameInfo.install.buildId + const languageModified = installLanguage !== gameInfo.install.language + const branchModified = branch !== gameInfo.install.branch + + const currentDlcs = (gameInfo.install.installedDLCs || []).sort() + const sortedChoice = installedDlcs.sort() + const dlcLengthModified = installedDlcs.length !== currentDlcs.length + let dlcIdsModified = false + if (!dlcLengthModified) { + for (const index in currentDlcs) { + if (currentDlcs[index] !== sortedChoice[index]) { + dlcIdsModified = true + break + } + } + } + + const dlcModified = dlcLengthModified || dlcIdsModified + + if (redModInstalled) { + const sortedMods = [] + for (const mod of detectedMods) { + if (enabledModsList.includes(mod)) { + sortedMods.push(mod) + } + } + window.api.setCyberpunModConfig({ + enabled: modsEnabled, + modsToLoad: sortedMods + }) + } + + const gameModified = + buildModified || branchModified || languageModified || dlcModified + + if (gameModified) { + // Create update + window.api.updateGame({ + gameInfo, + appName: gameInfo.app_name, + runner: gameInfo.runner, + installDlcs: installedDlcs, + installLanguage: installLanguage, + branch: branch, + build: selectedBuild + }) + } + + // Update version pin + window.api.changeGameVersionPinnedStatus( + gameInfo.app_name, + gameInfo.runner, + versionPinned + ) + + onClose() + } + + useEffect(() => { + async function get() { + const branchPassword = await window.api.getPrivateBranchPassword( + gameInfo.app_name + ) + setSavedBranchPassword(branchPassword) + } + get() + }, []) + + useEffect(() => { + async function get() { + const installInfo = (await getInstallInfo( + gameInfo.app_name, + 'gog', + gameInfo.install.platform || 'windows', + undefined, + branch || gameInfo.install.branch + )) as GogInstallInfo | null + if (!installInfo) { + // TODO: Handle error + return + } + setGameInstallInfo(installInfo) + } + get() + }, [branch, savedBranchPassword]) + + useEffect(() => { + if (gameInstallInfo && 'builds' in gameInstallInfo.manifest) { + const currentBranch = branch || null + const newBuilds = (gameInstallInfo.manifest.builds || []) + .filter((build) => build.branch === currentBranch) + .sort( + (a, b) => + new Date(b.date_published).getTime() - + new Date(a.date_published).getTime() + ) + const currentBuildInList = newBuilds.find( + (build) => build.build_id === gameInfo.install.buildId + ) + /*if (branch === gameInfo.install.branch) { + setCurrentBuildNotAvailable(!currentBuildInList) + }*/ + if (newBuilds.length > 0) { + const newBuild = currentBuildInList + ? gameInfo.install.buildId + : newBuilds[0].build_id + setSelectedBuild(selectedBuild ? newBuild : undefined) + } + setBuilds(newBuilds) + } + }, [gameInstallInfo, branch, gameInfo.install]) + + useEffect(() => { + if (gameInstallInfo) { + if ('languages' in gameInstallInfo.manifest) { + setInstallLanguages(gameInstallInfo.manifest.languages.sort()) + if (!gameInstallInfo.manifest.languages.includes(installLanguage)) { + setInstallLanguage( + getPreferredInstallLanguage( + gameInstallInfo.manifest.languages, + i18n.languages + ) + ) + } + } + + if ('branches' in gameInstallInfo.game) { + setBranches(gameInstallInfo.game.branches || []) + } + + setInstalledDlcs(gameInfo.install.installedDLCs || []) + } + }, [gameInstallInfo, gameInfo.install]) + + // Mods + useEffect(() => { + const get = async () => { + if (redModInstalled) { + const mods = await window.api.getAvailableCyberpunkMods() + const sortedMods: string[] = [] + // Apply sorting of enabled mods + for (const mod of enabledModsList) { + if (mods.includes(mod)) { + sortedMods.push(mod) + } + } + const rest = mods.filter((mod) => !sortedMods.includes(mod)) + const detMods = [...sortedMods, ...rest] + setDetectedMods(detMods) + if (!enabledModsList.length) { + setEnabledModsList(detMods) + } + } + } + get() + }, [redModInstalled]) + + const DLCList = gameInstallInfo?.game.owned_dlc || [] + + return gameInstallInfo ? ( + <> + setCurrentTab(newVal)} + aria-label="settings tabs" + variant="scrollable" + > + + + {redModInstalled && ( + + )} + + +
+ setSavedBranchPassword(newPasswd)} + /> +
+ + {installLanguages.length > 1 && ( +
+ +
+ )} + +
+ +
+
+ +
+ {DLCList.length > 0 ? ( + + ) : ( +
+ +

{t('modifyInstall.nodlcs', 'No DLC available')}

+
+ )} +
+
+ + {/* REDMod compatibility */} + + { + const { source, destination } = result + + if (!destination) { + return + } + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return + } + + const newModsArray = [...detectedMods] + const removed = newModsArray.splice(source.index, 1) + newModsArray.splice(destination.index, 0, ...removed) + + setDetectedMods(newModsArray) + }} + > +
+ + +
+ +

The list below contains all mods detected by REDmod.

+

+ Mods can be reordered, which will alter the load order. E.g if + two mods modify same file, the mod that is lower in the list + will overwrite the changes of the other one. +

+

At least one mod has to be enabled

+

+ Checkbox "Enable mods" decides whether the game + should be launched with mods. Mod deployment log can be found + within the game log +

+
+
+ + + {(provided, snapshot) => ( +
+ {detectedMods.map((mod, index) => ( + + {(provided) => ( +
+ +
+ +
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
+
+ + + + ) : ( + + ) +} diff --git a/src/frontend/screens/Game/ModifyInstallModal/Legendary/index.tsx b/src/frontend/screens/Game/ModifyInstallModal/Legendary/index.tsx new file mode 100644 index 0000000000..c3e401d7dc --- /dev/null +++ b/src/frontend/screens/Game/ModifyInstallModal/Legendary/index.tsx @@ -0,0 +1,25 @@ +import { GameInfo } from 'common/types' +import { DLCInfo } from 'common/types/legendary' +import DLCList from 'frontend/components/UI/DLCList' +import React from 'react' + +interface LegendaryModifyInstallModalProps { + dlcs: DLCInfo[] + gameInfo: GameInfo + onClose: () => void +} + +export default function LegendaryModifyInstallModal({ + dlcs, + gameInfo, + onClose +}: LegendaryModifyInstallModalProps) { + return ( + onClose()} + /> + ) +} diff --git a/src/frontend/screens/Game/ModifyInstallModal/index.scss b/src/frontend/screens/Game/ModifyInstallModal/index.scss new file mode 100644 index 0000000000..f42aac33d0 --- /dev/null +++ b/src/frontend/screens/Game/ModifyInstallModal/index.scss @@ -0,0 +1,61 @@ +.ModifyInstall { + &__dialog { + min-width: fit-content; + max-height: 95vh; + overflow-x: hidden; + + & .Dialog__content { + width: 65vw; + max-width: 800px; + } + + & .button { + margin: 0 var(--space-md); + min-width: 40%; + } + } + + &__gogDlcs .emptyState { + text-align: center; + margin: var(--space-lg) 0; + opacity: 0.5; + & svg { + width: 40px; + height: 40px; + } + } + + &__redMod { + margin-bottom: var(--space-md); + & .modsHelpWrapper { + margin: 0 var(--space-md); + } + & .modDraggable { + top: auto !important; + left: auto !important; + margin: var(--space-xs); + padding: 5px; + padding-right: 13px; + background: var(--input-background); + border-radius: var(--space-3xs); + font-size: var(--text-md); + display: flex; + align-items: center; + justify-content: space-between; + } + } + &__branchPassword { + width: 30vw !important; + & .controls { + display: flex; + align-items: center; + justify-content: space-evenly; + } + } + + &__version, + &__languages, + &__branch { + margin: 0 var(--space-md); + } +} diff --git a/src/frontend/screens/Game/ModifyInstallModal/index.tsx b/src/frontend/screens/Game/ModifyInstallModal/index.tsx new file mode 100644 index 0000000000..1fde38ecc5 --- /dev/null +++ b/src/frontend/screens/Game/ModifyInstallModal/index.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import './index.scss' +import { + Dialog, + DialogContent, + DialogHeader +} from 'frontend/components/UI/Dialog' +import { UpdateComponent } from 'frontend/components/UI' +import { GameInfo } from 'common/types' +import { LegendaryInstallInfo } from 'common/types/legendary' +import { GogInstallInfo } from 'common/types/gog' +import { NileInstallInfo } from 'common/types/nile' +import { useTranslation } from 'react-i18next' +import LegendaryModifyInstallModal from './Legendary' +import GOGModifyInstallModal from './GOG' + +interface ModifyInstallProps { + gameInfo: GameInfo + gameInstallInfo: + | LegendaryInstallInfo + | GogInstallInfo + | NileInstallInfo + | null + onClose: () => void +} + +export default function ModifyInstallModal({ + gameInfo, + gameInstallInfo, + onClose +}: ModifyInstallProps) { + const { t } = useTranslation() + + return ( + onClose()} + className={'ModifyInstall__dialog'} + > + onClose()}> +
{t('game.modify', 'Modify Installation')}
+
+ + {gameInstallInfo ? ( + <> + {gameInfo.runner === 'gog' && ( + + )} + {gameInfo.runner === 'legendary' && ( + + )} + + ) : ( + + )} + +
+ ) +} diff --git a/src/frontend/screens/Library/components/GameCard/constants.ts b/src/frontend/screens/Library/components/GameCard/constants.ts index bb004e2719..12777adba6 100644 --- a/src/frontend/screens/Library/components/GameCard/constants.ts +++ b/src/frontend/screens/Library/components/GameCard/constants.ts @@ -31,7 +31,7 @@ export function getCardStatus( const syncingSaves = status === 'syncing-saves' const isLaunching = status === 'launching' const isInstallingWinetricksPackages = status === 'winetricks' - const isInstallingPrerequisites = status === 'prerequisites' + const isInstallingRedist = status === 'redist' const haveStatus = isMoving || @@ -46,7 +46,7 @@ export function getCardStatus( syncingSaves || isLaunching || isInstallingWinetricksPackages || - isInstallingPrerequisites || + isInstallingRedist || (isInstalled && layout !== 'grid') return { isInstalling, @@ -58,7 +58,7 @@ export function getCardStatus( isUpdating, isLaunching, isInstallingWinetricksPackages, - isInstallingPrerequisites, + isInstallingRedist, haveStatus } } diff --git a/src/frontend/screens/Library/components/GameCard/index.css b/src/frontend/screens/Library/components/GameCard/index.css index 24a82dc87e..b2564518c3 100644 --- a/src/frontend/screens/Library/components/GameCard/index.css +++ b/src/frontend/screens/Library/components/GameCard/index.css @@ -337,7 +337,7 @@ .playIcon > span { font-family: var(--secondary-font-family); border-radius: 5px; - color: #161616; + color: #1d1d1d; cursor: pointer; padding: var(--space-xs-fixed); background: var(--success); @@ -347,6 +347,15 @@ text-align: center; } +.playIcon:hover > span { + transition: 300ms; + background: var(--success-hover); +} + +.playIcon[disabled] > span { + background: var(--icon-disabled); +} + .icons path, .icons circle { transition: 300ms; diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 7821a3a0a3..97a52bc99d 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -227,9 +227,7 @@ const GameCard = ({ if (isInstalled) { const disabled = isLaunching || - ['syncing-saves', 'launching', 'prerequisites', 'winetricks'].includes( - status! - ) + ['syncing-saves', 'launching', 'winetricks', 'redist'].includes(status!) return ( + branch?: string + savedBranchPassword: string + setBranch: (branch?: string) => void + onPasswordChange: (password: string) => void +} + +export default function BranchSelector({ + appName, + branches, + branch, + savedBranchPassword, + setBranch, + onPasswordChange +}: BranchSelectorProps) { + const { t } = useTranslation('gamepage') + const { t: tr } = useTranslation() + + const [showBranchPasswordInput, setShowBranchPasswordInput] = + useState(false) + const [branchPassword, setBranchPassword] = useState('') + + return ( +
+ {showBranchPasswordInput && ( + setShowBranchPasswordInput(false)} + > + + setBranchPassword(e.target.value)} + placeholder={t( + 'game.branch.password', + 'Set private channel password' + )} + /> +
+ + +
+
+
+ )} + + { + const value = e.target.value + if (value === 'null') { + setBranch() + } else if (value === 'heroic-update-passwordOption') { + setShowBranchPasswordInput(true) + } else { + setBranch(e.target.value) + } + }} + > + {branches.map((branch) => ( + + ))} + + +
+ ) +} diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/BuildSelector.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/BuildSelector.tsx new file mode 100644 index 0000000000..7cbe6db2c0 --- /dev/null +++ b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/BuildSelector.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { SelectField, ToggleSwitch } from 'frontend/components/UI' +import { useTranslation } from 'react-i18next' +import { BuildItem } from 'common/types/gog' + +interface BuildSelectorProps { + gameBuilds: BuildItem[] + selectedBuild?: string + setSelectedBuild: (build?: string) => void +} + +export default function BuildSelector({ + gameBuilds, + selectedBuild, + setSelectedBuild +}: BuildSelectorProps) { + const { i18n, t } = useTranslation('gamepage') + + const getFormattedDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(i18n.languages) + } + + return ( + <> + + + {!!selectedBuild && !!gameBuilds.length && ( + setSelectedBuild(e.target.value)} + > + {gameBuilds.map((build) => ( + + ))} + + )} + + ) +} diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/DLCDownloadListing.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/DLCDownloadListing.tsx index 6d7c5c5da3..23e5a89fe7 100644 --- a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/DLCDownloadListing.tsx +++ b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/DLCDownloadListing.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import ToggleSwitch from 'frontend/components/UI/ToggleSwitch' import { useTranslation } from 'react-i18next' import { DLCInfo } from 'common/types/legendary' @@ -21,6 +21,10 @@ const DLCDownloadListing: React.FC = ({ return null } + useEffect(() => { + setInstallAllDlcs(dlcsToInstall.length === DLCList.length) + }, [dlcsToInstall]) + const handleAllDlcs = () => { setInstallAllDlcs(!installAllDlcs) if (!installAllDlcs) { @@ -28,7 +32,7 @@ const DLCDownloadListing: React.FC = ({ } if (installAllDlcs) { - setDlcsToInstall([...DLCList.map(({ app_name }) => app_name)]) + setDlcsToInstall([]) } } @@ -41,14 +45,16 @@ const DLCDownloadListing: React.FC = ({ newDlcsToInstall.push(app_name) } setDlcsToInstall(newDlcsToInstall) - setInstallAllDlcs(newDlcsToInstall.length === DLCList.length) } return (
-