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 && {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 && (
+ handleChangeLog()}
+ className="link button is-text is-link"
+ >
+ {t('button.changelog', 'Show Changelog')}
+
+ )}{' '}
{!isSideloaded && isLinux && (
createNewWindow(protonDBurl)}
@@ -351,12 +369,12 @@ export default function GamesSubmenu({
{t('game.requirements', 'Requirements')}
)}
- {showDlcsItem && (
+ {showModifyItem && (
onShowDlcs()}
+ onClick={async () => onShowModifyInstall()}
className="link button is-text is-link"
>
- {t('game.dlcs', 'DLCs')}
+ {t('game.modify', 'Modify Installation')}
)}
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)
+ }}
+ >
+
+
+ setModsEnabled(!modsEnabled)}
+ title={t('modifyInstall.redMod.enable', 'Enable mods')}
+ />
+
+
+
+
+ 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) => (
+
+
+ {
+ const enabled = enabledModsList.includes(mod)
+ const enabledList = [...enabledModsList]
+ if (enabled) {
+ // We need to have at least one mod enabled for this feature to work
+ if (enabledModsList.length === 1) {
+ return
+ }
+ // Remove
+ const index = enabledList.findIndex(
+ (modL) => modL === mod
+ )
+ enabledList.splice(index, 1)
+ } else {
+ // Add
+ enabledList.push(mod)
+ }
+ setEnabledModsList(enabledList)
+ }}
+ />
+
+
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+
+
+
+
+ {tr('box.apply', 'Apply')}
+
+ >
+ ) : (
+
+ )
+}
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'
+ )}
+ />
+
+ {
+ setShowBranchPasswordInput(false)
+ setBranchPassword(savedBranchPassword)
+ }}
+ >
+ {tr('button.cancel', 'Cancel')}
+
+ {
+ setShowBranchPasswordInput(false)
+ window.api
+ .setPrivateBranchPassword(appName, branchPassword)
+ .finally(() => {
+ onPasswordChange(branchPassword)
+ })
+ }}
+ >
+ {tr('box.ok', 'OK')}
+
+
+
+
+ )}
+
+
{
+ 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) => (
+
+ {branch || t('game.branch.disabled', 'Disabled')}
+
+ ))}
+
+ {t(
+ 'game.branch.setPrivateBranchPassword',
+ 'Set private channel password'
+ )}
+
+
+
+ )
+}
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 (
+ <>
+
+ {
+ if (selectedBuild) {
+ setSelectedBuild(undefined)
+ } else {
+ setSelectedBuild(gameBuilds[0].build_id)
+ }
+ }}
+ />
+
+
+ {!!selectedBuild && !!gameBuilds.length && (
+ setSelectedBuild(e.target.value)}
+ >
+ {gameBuilds.map((build) => (
+
+ <>
+ {t('game.builds.version', 'Version')} {build.version_name} -{' '}
+ {getFormattedDate(build.date_published)}
+ >
+
+ ))}
+
+ )}
+ >
+ )
+}
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 (
-
+
handleAllDlcs()}
title={t('dlc.installDlcs', 'Install all DLCs')}
@@ -60,9 +66,10 @@ const DLCDownloadListing: React.FC = ({
key={title}
className="InstallModal__toggle toggleWrapper"
title={title}
+ htmlFor={`dlcSelector-${index}`}
>
void
+}
+
+export default function GameLanguageSelector({
+ installLanguages,
+ installLanguage,
+ setInstallLanguage,
+ installPlatform
+}: GameLanguageSelectorProps) {
+ const { t, i18n } = useTranslation('gamepage')
+ const getLanguageName = useMemo(() => {
+ return (language: string) => {
+ try {
+ const locale = language.replace('_', '-')
+ const displayNames = new Intl.DisplayNames(
+ [locale, ...i18n.languages, 'en'],
+ {
+ type: 'language',
+ style: 'long'
+ }
+ )
+ return displayNames.of(locale)
+ } catch (e) {
+ return language
+ }
+ }
+ }, [i18n.languages, installPlatform])
+
+ return (
+ setInstallLanguage(e.target.value)}
+ >
+ {installLanguages &&
+ installLanguages.map((value) => (
+
+ {getLanguageName(value)}
+
+ ))}
+
+ )
+}
diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx
index f3142101c1..af160403d7 100644
--- a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx
+++ b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx
@@ -14,12 +14,12 @@ import {
Runner,
WineInstallation
} from 'common/types'
-import { SelectiveDownload } from 'common/types/legendary'
import {
- PathSelectionBox,
- SelectField,
- ToggleSwitch
-} from 'frontend/components/UI'
+ SelectiveDownload,
+ DLCInfo as LegendaryDLCInfo
+} from 'common/types/legendary'
+import { BuildItem, DLCInfo as GOGDLCInfo } from 'common/types/gog'
+import { PathSelectionBox, ToggleSwitch } from 'frontend/components/UI'
import Anticheat from 'frontend/components/UI/Anticheat'
import {
DialogHeader,
@@ -31,7 +31,8 @@ import {
size,
getInstallInfo,
writeConfig,
- install
+ install,
+ getPreferredInstallLanguage
} from 'frontend/helpers'
import ContextProvider from 'frontend/state/ContextProvider'
import { InstallProgress } from 'frontend/types'
@@ -46,7 +47,10 @@ import { useTranslation } from 'react-i18next'
import { AvailablePlatforms } from '../index'
import { configStore } from 'frontend/helpers/electronStores'
import DLCDownloadListing from './DLCDownloadListing'
+import BuildSelector from './BuildSelector'
+import GameLanguageSelector from './GameLanguageSelector'
import { hasAnticheatInfo } from 'frontend/hooks/hasAnticheatInfo'
+import BranchSelector from './BranchSelector'
interface Props {
backdropClick: () => void
@@ -71,24 +75,6 @@ type DiskSpaceInfo = {
const storage: Storage = window.localStorage
-function getInstallLanguage(
- 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]
-}
-
function getUniqueKey(sdl: SelectiveDownload) {
if (sdl.tags) {
return sdl.tags.join(',')
@@ -131,6 +117,15 @@ export default function DownloadDialog({
const [installLanguages, setInstallLanguages] = useState(Array())
const [installLanguage, setInstallLanguage] = useState('')
+ const [diskSize, setDiskSize] = useState(0)
+
+ const [gameBuilds, setBuilds] = useState([])
+ const [selectedBuild, setSelectedBuild] = useState()
+
+ const [savedBranchPassword, setSavedBranchPassword] = useState('')
+ const [branches, setBranches] = useState>([])
+ const [branch, setBranch] = useState()
+
const [installPath, setInstallPath] = useState(
previousProgress.folder || getDefaultInstallPath()
)
@@ -139,7 +134,6 @@ export default function DownloadDialog({
)[0]
const [dlcsToInstall, setDlcsToInstall] = useState([])
- const [installAllDlcs, setInstallAllDlcs] = useState(false)
const [sdls, setSdls] = useState([])
const [selectedSdls, setSelectedSdls] = useState<{ [key: string]: boolean }>(
{}
@@ -177,6 +171,16 @@ export default function DownloadDialog({
return list
}, [selectedSdls, sdls])
+ useEffect(() => {
+ async function get() {
+ const branchPassword = await window.api.getPrivateBranchPassword(
+ gameInfo.app_name
+ )
+ setSavedBranchPassword(branchPassword)
+ }
+ get()
+ }, [])
+
const handleSdl = useCallback(
(sdl: SelectiveDownload, value: boolean) => {
setSelectedSdls({
@@ -187,10 +191,6 @@ export default function DownloadDialog({
[selectedSdls]
)
- function handleDlcs() {
- setInstallAllDlcs(!installAllDlcs)
- }
-
function confirmInstallBrokenAnticheat(path?: string) {
showDialogModal({
title: t('install.anticheat-warning.title', 'Anticheat Broken/Denied'),
@@ -252,48 +252,82 @@ export default function DownloadDialog({
progress: previousProgress,
t,
sdlList,
- installDlcs: runner === 'gog' ? installAllDlcs : dlcsToInstall,
+ installDlcs: dlcsToInstall,
installLanguage,
platformToInstall,
+ build: selectedBuild,
+ branch,
showDialogModal: () => backdropClick()
})
}
useEffect(() => {
- const getIinstInfo = async () => {
+ const getInstInfo = async () => {
try {
+ const fetchedPlatform = platformToInstall
+ const fetchedBuild = selectedBuild
setGettingInstallInfo(true)
const gameInstallInfo = await getInstallInfo(
appName,
runner,
- platformToInstall
+ platformToInstall,
+ selectedBuild,
+ branch
)
setGameInstallInfo(gameInstallInfo)
setGettingInstallInfo(false)
+ // Prevent condition when user changes the platform before we reach this point
if (
- gameInstallInfo &&
- gameInstallInfo.manifest &&
- 'languages' in gameInstallInfo.manifest
+ platformToInstall !== fetchedPlatform ||
+ fetchedBuild !== selectedBuild
) {
- setInstallLanguages(gameInstallInfo.manifest.languages)
- setInstallLanguage(
- getInstallLanguage(
- gameInstallInfo.manifest.languages,
- i18n.languages
- )
- )
+ return
}
+ setDlcsToInstall(
+ (gameInstallInfo?.game.owned_dlc || []).map((dlc) => dlc.app_name)
+ )
+ if (gameInstallInfo && gameInstallInfo.manifest) {
+ setDiskSize(gameInstallInfo.manifest?.disk_size ?? 0)
+ }
+
+ if (gameInstallInfo) {
+ if (
+ gameInstallInfo.manifest &&
+ 'builds' in gameInstallInfo.manifest
+ ) {
+ const builds = (gameInstallInfo.manifest.builds || [])
+ .filter(
+ // We either take branch when both are falsy (null | undefined)
+ // or when they are equal
+ (build) => (!branch && !build.branch) || build.branch === branch
+ )
+ .sort(
+ (a, b) =>
+ new Date(b.date_published).getTime() -
+ new Date(a.date_published).getTime()
+ )
+ setBuilds(builds)
+ }
+
+ if (
+ gameInstallInfo.manifest &&
+ 'languages' in gameInstallInfo.manifest
+ ) {
+ setInstallLanguages(gameInstallInfo.manifest.languages.sort())
+ if (!gameInstallInfo.manifest.languages.includes(installLanguage)) {
+ setInstallLanguage(
+ getPreferredInstallLanguage(
+ gameInstallInfo.manifest.languages,
+ i18n.languages
+ )
+ )
+ }
+ }
- if (platformToInstall === 'linux' && runner === 'gog') {
- setGettingInstallInfo(true)
- const installer_languages =
- await window.api.getGOGLinuxInstallersLangs(appName)
- setInstallLanguages(installer_languages)
- setInstallLanguage(
- getInstallLanguage(installer_languages, i18n.languages)
- )
- setGettingInstallInfo(false)
+ if (gameInstallInfo.manifest && 'branches' in gameInstallInfo.game) {
+ setBranches(gameInstallInfo.game.branches || [])
+ }
}
} catch (error) {
showDialogModal({
@@ -306,8 +340,15 @@ export default function DownloadDialog({
return
}
}
- getIinstInfo()
- }, [appName, i18n.languages, platformToInstall])
+ getInstInfo()
+ }, [
+ appName,
+ i18n.languages,
+ platformToInstall,
+ selectedBuild,
+ branch,
+ savedBranchPassword
+ ])
useEffect(() => {
const getGameSdl = async () => {
@@ -328,19 +369,14 @@ export default function DownloadDialog({
const getSpace = async () => {
const { message, free, validPath, validFlatpakPath } =
await window.api.checkDiskSpace(installPath)
- if (gameInstallInfo?.manifest?.disk_size) {
- let notEnoughDiskSpace = free < gameInstallInfo.manifest.disk_size
- let spaceLeftAfter = size(
- free - Number(gameInstallInfo.manifest.disk_size)
- )
+ if (diskSize) {
+ let notEnoughDiskSpace = free < diskSize
+ let spaceLeftAfter = size(free - Number(diskSize))
if (previousProgress.folder === installPath) {
const progress = 100 - getProgress(previousProgress)
- notEnoughDiskSpace =
- free < (progress / 100) * Number(gameInstallInfo.manifest.disk_size)
+ notEnoughDiskSpace = free < (progress / 100) * diskSize
- spaceLeftAfter = size(
- free - (progress / 100) * Number(gameInstallInfo.manifest.disk_size)
- )
+ spaceLeftAfter = size(free - (progress / 100) * diskSize)
}
setSpaceLeft({
@@ -353,13 +389,37 @@ export default function DownloadDialog({
}
}
getSpace()
- }, [installPath, gameInstallInfo?.manifest?.disk_size])
+ }, [installPath, diskSize])
const haveDLCs =
gameInstallInfo && gameInstallInfo?.game?.owned_dlc?.length > 0
- const DLCList = gameInstallInfo?.game?.owned_dlc
+ const DLCList: Array =
+ gameInstallInfo?.game?.owned_dlc ?? []
+
+ const downloadSize = useMemo(() => {
+ if (
+ gameInstallInfo &&
+ 'perLangSize' in gameInstallInfo.manifest &&
+ gameInstallInfo.manifest.perLangSize
+ ) {
+ const languageSize =
+ gameInstallInfo?.manifest?.perLangSize[installLanguage]
+ ?.download_size ?? 0
+ const universalSize =
+ gameInstallInfo?.manifest?.perLangSize['*']?.download_size ?? 0
+
+ const dlcSize = DLCList.reduce((acc, dlc) => {
+ if (dlcsToInstall.includes(dlc.app_name) && 'perLangSize' in dlc) {
+ const languageSize =
+ dlc.perLangSize[installLanguage]?.download_size ?? 0
+ const universalSize = dlc.perLangSize['*']?.download_size ?? 0
+ acc += languageSize + universalSize
+ }
+ return acc
+ }, 0 as number)
- const downloadSize = () => {
+ return size(languageSize + universalSize + dlcSize)
+ }
if (gameInstallInfo?.manifest?.download_size) {
if (previousProgress.folder === installPath) {
const progress = 100 - getProgress(previousProgress)
@@ -371,29 +431,36 @@ export default function DownloadDialog({
return size(Number(gameInstallInfo?.manifest?.download_size))
}
return ''
- }
+ }, [installPath, gameInstallInfo, installLanguage, dlcsToInstall])
- const installSize =
- gameInstallInfo?.manifest?.disk_size &&
- size(Number(gameInstallInfo?.manifest?.disk_size))
+ const installSize = useMemo(() => {
+ if (
+ gameInstallInfo &&
+ 'perLangSize' in gameInstallInfo.manifest &&
+ gameInstallInfo.manifest.perLangSize
+ ) {
+ const languageSize =
+ gameInstallInfo?.manifest?.perLangSize[installLanguage]?.disk_size ?? 0
+ const universalSize =
+ gameInstallInfo?.manifest?.perLangSize['*']?.disk_size ?? 0
+ const dlcSize = DLCList.reduce((acc, dlc) => {
+ if (dlcsToInstall.includes(dlc.app_name) && 'perLangSize' in dlc) {
+ const languageSize = dlc.perLangSize[installLanguage]?.disk_size ?? 0
+ const universalSize = dlc.perLangSize['*']?.disk_size ?? 0
+ acc += languageSize + universalSize
+ }
+ return acc
+ }, 0)
+ setDiskSize(languageSize + universalSize + dlcSize)
+ return size(languageSize + universalSize + dlcSize)
+ }
- const getLanguageName = useMemo(() => {
- return (language: string) => {
- try {
- const locale = language.replace('_', '-')
- const displayNames = new Intl.DisplayNames(
- [locale, ...i18n.languages, 'en'],
- {
- type: 'language',
- style: 'long'
- }
- )
- return displayNames.of(locale)
- } catch (e) {
- return language
- }
+ if (gameInstallInfo?.manifest?.disk_size) {
+ return size(Number(gameInstallInfo?.manifest?.disk_size))
}
- }, [i18n.languages, platformToInstall])
+
+ return ''
+ }, [gameInstallInfo, installLanguage, platformToInstall, dlcsToInstall])
const {
validPath,
@@ -418,13 +485,10 @@ export default function DownloadDialog({
}
const readyToInstall =
- installPath &&
- gameInstallInfo?.manifest?.download_size &&
- !gettingInstallInfo &&
- validFlatpakPath
+ installPath && !!diskSize && !gettingInstallInfo && validFlatpakPath
const showDlcSelector =
- runner === 'legendary' && DLCList && DLCList?.length > 0
+ ['legendary', 'gog'].includes(runner) && DLCList && DLCList?.length > 0
return (
<>
@@ -444,16 +508,16 @@ export default function DownloadDialog({
- {downloadSize() ? (
+ {downloadSize ? (
<>
{t('game.downloadSize', 'Download Size')}:
-
{downloadSize()}
+
{downloadSize}
>
) : (
`${t('game.getting-download-size', 'Geting download size')}...`
@@ -462,11 +526,11 @@ export default function DownloadDialog({
- {downloadSize() ? (
+ {downloadSize ? (
<>
{t('game.installSize', 'Install Size')}:
@@ -493,19 +557,12 @@ export default function DownloadDialog({
)}
{installLanguages && installLanguages?.length > 1 && (
-
setInstallLanguage(e.target.value)}
- >
- {installLanguages &&
- installLanguages.map((value) => (
-
- {getLanguageName(value)}
-
- ))}
-
+
)}
{validPath && validFlatpakPath && (
<>
@@ -572,6 +629,31 @@ export default function DownloadDialog({
) : null
}
/>
+
+ {platformToInstall !== 'linux' && branches.length > 1 && (
+
+
+ setSavedBranchPassword(newPasswd)
+ }
+ />
+
+ )}
+
+ {platformToInstall !== 'linux' && !!gameBuilds.length && (
+
+
+
+ )}
{children}
{(haveDLCs || haveSDL) && (
@@ -604,22 +686,6 @@ export default function DownloadDialog({
setDlcsToInstall={setDlcsToInstall}
/>
)}
- {haveDLCs && runner === 'gog' && (
-
-
- handleDlcs()}
- title={t('dlc.installDlcs', 'Install all DLCs')}
- />
- {t('dlc.installDlcs', 'Install all DLCs')}:
-
-
- {DLCList?.map(({ title }) => title).join(', ')}
-
-
- )}
{
appName,
status,
folder,
+ context,
progress,
runner
}: GameStatus) => {
@@ -685,13 +686,13 @@ class GlobalState extends PureComponent {
return this.setState({
libraryStatus: [
...libraryStatus,
- { appName, status, folder, progress, runner }
+ { appName, status, folder, context, progress, runner }
]
})
}
// if the app's status didn't change, do nothing
- if (currentApp.status === status) {
+ if (currentApp.status === status && currentApp.context === context) {
return
}
@@ -709,11 +710,18 @@ class GlobalState extends PureComponent {
'extracting',
'launching',
'winetricks',
- 'prerequisites',
+ 'redist',
'queued'
].includes(status)
) {
- newLibraryStatus.push({ appName, status, folder, progress, runner })
+ newLibraryStatus.push({
+ appName,
+ status,
+ folder,
+ context,
+ progress,
+ runner
+ })
this.setState({ libraryStatus: newLibraryStatus })
}
@@ -738,7 +746,9 @@ class GlobalState extends PureComponent {
})
}
- this.refreshLibrary({ runInBackground: true, library: runner })
+ if (runner !== 'gog') {
+ this.refreshLibrary({ runInBackground: true, library: runner })
+ }
this.setState({ libraryStatus: newLibraryStatus })
}
}
@@ -817,11 +827,13 @@ class GlobalState extends PureComponent {
(game) => game.app_name === args.app_name
)
if (index !== -1) {
- library.splice(index, 1)
+ library[index] = args
+ } else {
+ library.push(args)
}
this.setState({
gog: {
- library: [...library, args],
+ library: [...library],
username: this.state.gog.username
}
})
diff --git a/src/frontend/types.ts b/src/frontend/types.ts
index 328b1ffe93..ec35934dcf 100644
--- a/src/frontend/types.ts
+++ b/src/frontend/types.ts
@@ -239,7 +239,7 @@ export interface GameContextType {
is: {
installing: boolean
installingWinetricksPackages: boolean
- installingPrerequisites: boolean
+ installingRedist: boolean
launching: boolean
linux: boolean
linuxNative: boolean
@@ -259,6 +259,7 @@ export interface GameContextType {
updating: boolean
win: boolean
}
+ statusContext?: string
status: Status | undefined
wikiInfo: WikiInfo | null
}
diff --git a/yarn.lock b/yarn.lock
index 7b88e94729..60eafd751b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1586,6 +1586,14 @@
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64"
integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==
+"@types/hoist-non-react-statics@^3.3.0":
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
+ integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
+ dependencies:
+ "@types/react" "*"
+ hoist-non-react-statics "^3.3.0"
+
"@types/http-cache-semantics@*":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz#a3ff232bf7d5c55f38e4e45693eda2ebb545794d"
@@ -1722,6 +1730,13 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d"
integrity sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==
+"@types/react-beautiful-dnd@13.1.4":
+ version "13.1.4"
+ resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.4.tgz#bcec72da719c18c0d8b4a7cb00e7fb443211d6d7"
+ integrity sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==
+ dependencies:
+ "@types/react" "*"
+
"@types/react-dom@18.2.14", "@types/react-dom@^18.0.0":
version "18.2.14"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.14.tgz#c01ba40e5bb57fc1dc41569bb3ccdb19eab1c539"
@@ -1729,6 +1744,16 @@
dependencies:
"@types/react" "*"
+"@types/react-redux@^7.1.20":
+ version "7.1.26"
+ resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.26.tgz#84149f5614e40274bb70fcbe8f7cae6267d548b1"
+ integrity sha512-UKPo7Cm7rswYU6PH6CmTNCRv5NYF3HrgKuHEYTK8g/3czYLrUux50gQ2pkxc9c7ZpQZi+PNhgmI8oNIRoiVIxg==
+ dependencies:
+ "@types/hoist-non-react-statics" "^3.3.0"
+ "@types/react" "*"
+ hoist-non-react-statics "^3.3.0"
+ redux "^4.0.0"
+
"@types/react-router-dom@5.3.3":
version "5.3.3"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83"
@@ -1769,6 +1794,13 @@
dependencies:
"@types/node" "*"
+"@types/sanitize-html@2.9.0":
+ version "2.9.0"
+ resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.9.0.tgz#5b609f7592de22ef80a0930c39670329753dca1b"
+ integrity sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==
+ dependencies:
+ htmlparser2 "^8.0.0"
+
"@types/scheduler@*":
version "0.16.5"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.5.tgz#4751153abbf8d6199babb345a52e1eb4167d64af"
@@ -3110,6 +3142,13 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
+css-box-model@^1.2.0:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
+ integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
+ dependencies:
+ tiny-invariant "^1.0.6"
+
css-select@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
@@ -4837,7 +4876,7 @@ heimdalljs@^0.2.6:
dependencies:
rsvp "~3.2.1"
-hoist-non-react-statics@^3.3.1:
+hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@@ -4868,7 +4907,7 @@ html-parse-stringify@^3.0.1:
dependencies:
void-elements "3.1.0"
-htmlparser2@^8.0.1:
+htmlparser2@^8.0.0, htmlparser2@^8.0.1:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
@@ -5282,6 +5321,11 @@ is-plain-obj@^4.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
+is-plain-object@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
+ integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
+
is-regex@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -6235,6 +6279,11 @@ mdast-util-to-string@^3.1.0:
dependencies:
"@types/mdast" "^3.0.0"
+memoize-one@^5.1.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
+ integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
+
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -6959,6 +7008,11 @@ parse-json@^5.0.0, parse-json@^5.2.0:
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
+parse-srcset@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
+ integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
+
parse5-htmlparser2-tree-adapter@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1"
@@ -7114,6 +7168,15 @@ postcss-value-parser@^3.3.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
+postcss@^8.3.11:
+ version "8.4.28"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.28.tgz#c6cc681ed00109072816e1557f889ef51cf950a5"
+ integrity sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==
+ dependencies:
+ nanoid "^3.3.6"
+ picocolors "^1.0.0"
+ source-map-js "^1.0.2"
+
postcss@^8.4.18:
version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
@@ -7199,7 +7262,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
-prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.8.1:
+prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -7272,6 +7335,24 @@ quick-temp@^0.1.8:
rimraf "^2.5.4"
underscore.string "~3.3.4"
+raf-schd@^4.0.2:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
+ integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
+
+react-beautiful-dnd@^13.1.1:
+ version "13.1.1"
+ resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
+ integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==
+ dependencies:
+ "@babel/runtime" "^7.9.2"
+ css-box-model "^1.2.0"
+ memoize-one "^5.1.1"
+ raf-schd "^4.0.2"
+ react-redux "^7.2.0"
+ redux "^4.0.4"
+ use-memo-one "^1.1.1"
+
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
@@ -7293,7 +7374,7 @@ react-is@^16.10.2, react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
-react-is@^17.0.1:
+react-is@^17.0.1, react-is@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
@@ -7329,6 +7410,18 @@ react-markdown@8.0.5:
unist-util-visit "^4.0.0"
vfile "^5.0.0"
+react-redux@^7.2.0:
+ version "7.2.9"
+ resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"
+ integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==
+ dependencies:
+ "@babel/runtime" "^7.15.4"
+ "@types/react-redux" "^7.1.20"
+ hoist-non-react-statics "^3.3.2"
+ loose-envify "^1.4.0"
+ prop-types "^15.7.2"
+ react-is "^17.0.2"
+
react-resize-detector@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-7.1.2.tgz#8ef975dd8c3d56f9a5160ac382ef7136dcd2d86c"
@@ -7491,6 +7584,13 @@ reduce-css-calc@^2.1.8:
css-unit-converter "^1.1.1"
postcss-value-parser "^3.3.0"
+redux@^4.0.0, redux@^4.0.4:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
+ integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
+ dependencies:
+ "@babel/runtime" "^7.9.2"
+
regenerator-runtime@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
@@ -7753,6 +7853,18 @@ sanitize-filename@1.6.3, sanitize-filename@^1.6.3:
dependencies:
truncate-utf8-bytes "^1.0.0"
+sanitize-html@^2.11.0:
+ version "2.11.0"
+ resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.11.0.tgz#9a6434ee8fcaeddc740d8ae7cd5dd71d3981f8f6"
+ integrity sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==
+ dependencies:
+ deepmerge "^4.2.2"
+ escape-string-regexp "^4.0.0"
+ htmlparser2 "^8.0.0"
+ is-plain-object "^5.0.0"
+ parse-srcset "^1.0.2"
+ postcss "^8.3.11"
+
sass@1.59.2:
version "1.59.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.59.2.tgz#537f6d11614d4f20f97696f23ad358ee398b1937"
@@ -8379,6 +8491,11 @@ through@^2.3.8:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+tiny-invariant@^1.0.6:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
+ integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
+
tmp-promise@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.3.tgz#60a1a1cc98c988674fcbfd23b6e3367bdeac4ce7"
@@ -8816,6 +8933,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
+use-memo-one@^1.1.1:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
+ integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==
+
utf8-byte-length@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61"