From 6105bf2dddfd7286d69f557b7ef9e44e17187d15 Mon Sep 17 00:00:00 2001 From: Etaash Mathamsetty <45927311+Etaash-mathamsetty@users.noreply.github.com> Date: Sat, 3 Aug 2024 13:56:54 -0400 Subject: [PATCH] [Exp]: UMU support (#3724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Exp]: ULWGL support * improv: fetch ulwgl id only on Linux * add fallback gameid * less agressive 6h caching * sideload support, additional checks for ulwgl support * use run verb in shutdownWine * cache: allow custom data validity checks to be defined useful for use cases where we want to make sure that data won't be removed when offline etc.. in case of ULWGL we invalidate entries that are null * use ulwgl-run * lint * update from ulwgl to umu * fix lint * automatically download umu runtime * review suggestions Co-authored-by: Ariel Juodziukynas * fix import * review suggestions Co-authored-by: Ariel Juodziukynas * bug fixes * Update launcher.ts * Update utils.ts * small refactoring + bug fixes * Update src/backend/launcher.ts Co-authored-by: Ariel Juodziukynas * fixes * introduce getUmuPath function * initial broken winetricks implementation * Fixup auto-update - `isInstalled('umu')` was missing an await - `isUmuSupported` would return false if UMU wasn't installed, so the auto-install would never actually install UMU if it was missing * Make `getUmuPath` search for the `umu-run` binary on $PATH * Remove `PROTON_VERB` env var from Winetricks env This seems to completely break it, not sure why yet but it's not necessary anyways * Call umu-run with an empty executable when running Winetricks GUI Running it with "" makes it try to run a file called "", which we don't want * fix getWineFlagsArray * fixes for winetricks * disable umu runtime updates when running winetricks --------- Co-authored-by: Paweł Lidwin Co-authored-by: Ariel Juodziukynas Co-authored-by: Mathis Dröge --- public/locales/en/translation.json | 3 +- src/backend/cache.ts | 17 ++++- src/backend/constants.ts | 2 + src/backend/launcher.ts | 75 +++++++++++-------- src/backend/storeManagers/gog/games.ts | 21 ++++-- src/backend/storeManagers/legendary/games.ts | 18 +++-- src/backend/storeManagers/nile/games.ts | 21 ++++-- src/backend/tools/index.ts | 39 +++++++--- src/backend/utils.ts | 12 ++- src/backend/utils/compatibility_layers.ts | 35 +++++++-- src/backend/wiki_game_info/electronStore.ts | 4 + src/backend/wiki_game_info/umu/utils.ts | 47 ++++++++++++ src/backend/wiki_game_info/wiki_game_info.ts | 9 ++- src/backend/wine/runtimes/runtimes.ts | 8 +- src/backend/wine/runtimes/util.ts | 1 - src/common/types.ts | 4 +- .../components/ExperimentalFeatures.tsx | 8 +- src/frontend/state/ContextProvider.tsx | 3 +- src/frontend/state/GlobalState.tsx | 6 +- 19 files changed, 237 insertions(+), 96 deletions(-) create mode 100644 src/backend/wiki_game_info/umu/utils.ts diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f013a7b25f..d7b46e40bf 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -635,7 +635,8 @@ "experimental_features": { "automaticWinetricksFixes": "Apply known fixes automatically", "enableHelp": "Help component", - "enableNewDesign": "New design" + "enableNewDesign": "New design", + "umuSupport": "Use UMU as Proton runtime" }, "frameless-window": { "confirmation": { diff --git a/src/backend/cache.ts b/src/backend/cache.ts index ddf63bf6d0..a571d85717 100644 --- a/src/backend/cache.ts +++ b/src/backend/cache.ts @@ -6,6 +6,7 @@ export default class CacheStore { private using_in_memory: boolean private current_store: Store | Map private readonly lifespan: number | null + private invalidateCheck: (data: ValueType) => boolean /** * Creates a new cache store @@ -13,7 +14,11 @@ export default class CacheStore { * @param max_value_lifespan How long an individual entry in the store will * be cached (in minutes) */ - constructor(filename: string, max_value_lifespan: number | null = 60 * 6) { + constructor( + filename: string, + max_value_lifespan: number | null = 60 * 6, + options?: { invalidateCheck?: (data: ValueType) => boolean } + ) { this.store = new Store({ cwd: 'store_cache', name: filename, @@ -23,6 +28,11 @@ export default class CacheStore { this.using_in_memory = false this.current_store = this.store this.lifespan = max_value_lifespan + if (options && options.invalidateCheck) { + this.invalidateCheck = options.invalidateCheck + } else { + this.invalidateCheck = () => true + } } /** @@ -54,7 +64,10 @@ export default class CacheStore { const updateDate = new Date(lastUpdateTimestamp) const msSinceUpdate = Date.now() - updateDate.getTime() const minutesSinceUpdate = msSinceUpdate / 1000 / 60 - if (minutesSinceUpdate > this.lifespan) { + if ( + minutesSinceUpdate > this.lifespan && + this.invalidateCheck(this.current_store.get(key) as ValueType) + ) { this.current_store.delete(key) this.current_store.delete(`__timestamp.${key}`) return fallback diff --git a/src/backend/constants.ts b/src/backend/constants.ts index c3a8ae9f5b..226e0538a2 100644 --- a/src/backend/constants.ts +++ b/src/backend/constants.ts @@ -66,6 +66,7 @@ const epicRedistPath = join(toolsPath, 'redist', 'legendary') const gogRedistPath = join(toolsPath, 'redist', 'gog') const heroicIconFolder = join(appFolder, 'icons') const runtimePath = join(toolsPath, 'runtimes') +const defaultUmuPath = join(runtimePath, 'umu', 'umu_run.py') const userInfo = join(legendaryConfigPath, 'user.json') const heroicInstallPath = join(userHome, 'Games', 'Heroic') const defaultWinePrefixDir = join(userHome, 'Games', 'Heroic', 'Prefixes') @@ -278,6 +279,7 @@ export { fontsStore, isSteamDeckGameMode, runtimePath, + defaultUmuPath, isCLIFullscreen, isCLINoGui, publicDir, diff --git a/src/backend/launcher.ts b/src/backend/launcher.ts index d9a6d66dc8..580da0bb5d 100644 --- a/src/backend/launcher.ts +++ b/src/backend/launcher.ts @@ -18,7 +18,7 @@ import { import i18next from 'i18next' import { existsSync, mkdirSync } from 'graceful-fs' -import { join, normalize } from 'path' +import { join, dirname } from 'path' import { defaultWinePrefix, @@ -29,7 +29,8 @@ import { isWindows, isSteamDeckGameMode, runtimePath, - userHome + userHome, + defaultUmuPath } from './constants' import { constructAndUpdateRPC, @@ -71,7 +72,6 @@ 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 @@ -79,6 +79,8 @@ import { import { download, isInstalled } from './wine/runtimes/runtimes' import { storeMap } from 'common/utils' import { runWineCommandOnGame } from './storeManagers/legendary/games' +import { sendFrontendMessage } from './main_window' +import { getUmuPath, isUmuSupported } from './utils/compatibility_layers' async function prepareLaunch( gameSettings: GameSettings, @@ -228,17 +230,27 @@ async function prepareLaunch( } } + if ( + (await isUmuSupported(gameSettings.wineVersion.type, false)) && + !(await isInstalled('umu')) && + isOnline() && + (await getUmuPath()) === defaultUmuPath + ) { + await download('umu') + } + // If the Steam Runtime is enabled, find a valid one let steamRuntime: string[] = [] const shouldUseRuntime = gameSettings.useSteamRuntime && - (isNative || gameSettings.wineVersion.type === 'proton') + (isNative || !isUmuSupported(gameSettings.wineVersion.type)) + if (shouldUseRuntime) { // Determine which runtime to use based on toolmanifest.vdf which is shipped with proton let nonNativeRuntime: SteamRuntime['type'] = 'soldier' if (!isNative) { try { - const parentPath = normalize(join(gameSettings.wineVersion.bin, '..')) + const parentPath = dirname(gameSettings.wineVersion.bin) const requiredAppId = VDF.parse( readFileSync(join(parentPath, 'toolmanifest.vdf'), 'utf-8') ).manifest?.require_tool_appid @@ -250,8 +262,8 @@ async function prepareLaunch( ) } } - // for native games lets use scout for now - const runtimeType = isNative ? 'sniper' : nonNativeRuntime + + const runtimeType = isNative ? 'scout' : nonNativeRuntime const { path, args } = await getSteamRuntime(runtimeType) if (!path) { return { @@ -268,6 +280,8 @@ async function prepareLaunch( } } + logInfo(`Using Steam ${runtimeType} Runtime`, LogPrefix.Backend) + steamRuntime = [path, ...args] } @@ -302,14 +316,6 @@ async function prepareWineLaunch( } } - // Log warning about Proton - if (gameSettings.wineVersion.type === 'proton') { - logWarning( - 'You are using Proton, this can lead to some bugs. Please do not open issues with bugs related to games', - LogPrefix.Backend - ) - } - // Verify that the CrossOver bottle exists if (isMac && gameSettings.wineVersion.type === 'crossover') { const bottleExists = existsSync( @@ -497,16 +503,20 @@ function setupWrapperEnvVars(wrapperEnv: WrapperEnv) { ret.HEROIC_APP_NAME = wrapperEnv.appName ret.HEROIC_APP_RUNNER = wrapperEnv.appRunner + ret.GAMEID = 'umu-0' switch (wrapperEnv.appRunner) { case 'gog': ret.HEROIC_APP_SOURCE = 'gog' + ret.STORE = 'gog' break case 'legendary': ret.HEROIC_APP_SOURCE = 'epic' + ret.STORE = 'egs' break case 'nile': ret.HEROIC_APP_SOURCE = 'amazon' + ret.STORE = 'amazon' break case 'sideload': ret.HEROIC_APP_SOURCE = 'sideload' @@ -527,9 +537,6 @@ function setupWineEnvVars(gameSettings: GameSettings, gameId = '0') { const ret: Record = {} - ret.DOTNET_BUNDLE_EXTRACT_BASE_DIR = '' - ret.DOTNET_ROOT = '' - // Add WINEPREFIX / STEAM_COMPAT_DATA_PATH / CX_BOTTLE const steamInstallPath = join(flatPakHome, '.steam', 'steam') switch (wineVersion.type) { @@ -544,7 +551,7 @@ function setupWineEnvVars(gameSettings: GameSettings, gameId = '0') { ) if (dllOverridesVar) { ret[dllOverridesVar.key] = - dllOverridesVar.value + ',' + wmbDisableString + dllOverridesVar.value + ';' + wmbDisableString } else { ret.WINEDLLOVERRIDES = wmbDisableString } @@ -553,7 +560,9 @@ function setupWineEnvVars(gameSettings: GameSettings, gameId = '0') { } case 'proton': ret.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamInstallPath + ret.WINEPREFIX = winePrefix ret.STEAM_COMPAT_DATA_PATH = winePrefix + ret.PROTONPATH = dirname(gameSettings.wineVersion.bin) break case 'crossover': ret.CX_BOTTLE = wineCrossoverBottle @@ -757,7 +766,7 @@ export async function verifyWinePrefix( return { res: { stdout: '', stderr: '' }, updated: false } } - if (!existsSync(winePrefix)) { + if (!existsSync(winePrefix) && !(await isUmuSupported(wineVersion.type))) { mkdirSync(winePrefix, { recursive: true }) } @@ -769,9 +778,12 @@ export async function verifyWinePrefix( const haveToWait = !existsSync(systemRegPath) const command = runWineCommand({ - commandParts: ['wineboot', '--init'], + commandParts: (await isUmuSupported(wineVersion.type)) + ? ['createprefix'] + : ['wineboot', '--init'], wait: haveToWait, gameSettings: settings, + protonVerb: 'run', skipPrefixCheckIKnowWhatImDoing: true }) @@ -801,7 +813,6 @@ function launchCleanup(rpcClient?: RpcClient) { async function runWineCommand({ gameSettings, commandParts, - gameInstallPath, wait, protonVerb = 'run', installFolderName, @@ -856,27 +867,25 @@ async function runWineCommand({ const env_vars = { ...process.env, - ...setupEnvVars(settings, gameInstallPath), - ...setupWineEnvVars(settings, installFolderName) - } - - const isProton = wineVersion.type === 'proton' - if (isProton) { - commandParts.unshift(protonVerb) + GAMEID: 'umu-0', + ...setupEnvVars(settings), + ...setupWineEnvVars(settings, installFolderName), + PROTON_VERB: protonVerb } const wineBin = wineVersion.bin.replaceAll("'", '') + const umuSupported = await isUmuSupported(wineVersion.type) + const runnerBin = umuSupported ? await getUmuPath() : wineBin logDebug(['Running Wine command:', commandParts.join(' ')], LogPrefix.Backend) return new Promise<{ stderr: string; stdout: string }>((res) => { const wrappers = options?.wrappers || [] - let bin = '' + let bin = runnerBin + if (wrappers.length) { bin = wrappers.shift()! - commandParts.unshift(...wrappers, wineBin) - } else { - bin = wineBin + commandParts.unshift(...wrappers, runnerBin) } const child = spawn(bin, commandParts, { diff --git a/src/backend/storeManagers/gog/games.ts b/src/backend/storeManagers/gog/games.ts index 36bf506275..c0441da6f6 100644 --- a/src/backend/storeManagers/gog/games.ts +++ b/src/backend/storeManagers/gog/games.ts @@ -94,13 +94,17 @@ import { t } from 'i18next' import { showDialogBoxModalAuto } from '../../dialog/dialog' import { sendFrontendMessage } from '../../main_window' import { RemoveArgs } from 'common/types/game_manager' -import { getWineFlagsArray } from 'backend/utils/compatibility_layers' +import { + getWineFlagsArray, + isUmuSupported +} 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' +import { getUmuId } from 'backend/wiki_game_info/umu/utils' export async function getExtraInfo(appName: string): Promise { const gameInfo = getGameInfo(appName) @@ -559,13 +563,20 @@ export async function launch( const { bin: wineExec, type: wineType } = gameSettings.wineVersion + if (await isUmuSupported(wineType)) { + const umuId = await getUmuId(gameInfo.app_name, gameInfo.runner) + if (umuId) { + commandEnv['GAMEID'] = umuId + } + } + // Fix for people with old config const wineBin = wineExec.startsWith("'") && wineExec.endsWith("'") ? wineExec.replaceAll("'", '') : wineExec - wineFlag = getWineFlagsArray(wineBin, wineType, shlex.join(wrappers)) + wineFlag = await getWineFlagsArray(wineBin, wineType, shlex.join(wrappers)) } const commandParts = [ @@ -670,12 +681,6 @@ export async function launch( sendGameStatusUpdate({ appName, runner: 'gog', status: 'playing' }) - sendGameStatusUpdate({ - appName, - runner: 'gog', - status: 'playing' - }) - const { error, abort } = await runGogdlCommand(commandParts, { abortId: appName, env: commandEnv, diff --git a/src/backend/storeManagers/legendary/games.ts b/src/backend/storeManagers/legendary/games.ts index 7d7bb66a8b..9418fe9cca 100644 --- a/src/backend/storeManagers/legendary/games.ts +++ b/src/backend/storeManagers/legendary/games.ts @@ -77,7 +77,8 @@ import { sendFrontendMessage } from '../../main_window' import { RemoveArgs } from 'common/types/game_manager' import { AllowedWineFlags, - getWineFlags + getWineFlags, + isUmuSupported } from 'backend/utils/compatibility_layers' import { LegendaryAppName, @@ -86,6 +87,7 @@ import { PositiveInteger } from './commands/base' import { LegendaryCommand } from './commands' +import { getUmuId } from 'backend/wiki_game_info/umu/utils' import thirdParty from './thirdParty' import { Path } from 'backend/schemas' import { mkdirSync } from 'fs' @@ -912,13 +914,19 @@ export async function launch( const { bin: wineExec, type: wineType } = gameSettings.wineVersion + if (await isUmuSupported(wineType)) { + const umuId = await getUmuId(gameInfo.app_name, gameInfo.runner) + if (umuId) { + commandEnv['GAMEID'] = umuId + } + } // Fix for people with old config const wineBin = wineExec.startsWith("'") && wineExec.endsWith("'") ? wineExec.replaceAll("'", '') : wineExec - wineFlags = getWineFlags(wineBin, wineType, shlex.join(wrappers)) + wineFlags = await getWineFlags(wineBin, wineType, shlex.join(wrappers)) } const appNameToLaunch = @@ -952,12 +960,6 @@ export async function launch( sendGameStatusUpdate({ appName, runner: 'legendary', status: 'playing' }) - sendGameStatusUpdate({ - appName, - runner: 'legendary', - status: 'playing' - }) - const { error } = await runLegendaryCommand(command, { abortId: appName, env: commandEnv, diff --git a/src/backend/storeManagers/nile/games.ts b/src/backend/storeManagers/nile/games.ts index d14c2f0a09..9481109983 100644 --- a/src/backend/storeManagers/nile/games.ts +++ b/src/backend/storeManagers/nile/games.ts @@ -42,7 +42,10 @@ import { import { existsSync } from 'graceful-fs' import { showDialogBoxModalAuto } from 'backend/dialog/dialog' import { t } from 'i18next' -import { getWineFlagsArray } from 'backend/utils/compatibility_layers' +import { + getWineFlagsArray, + isUmuSupported +} from 'backend/utils/compatibility_layers' import shlex from 'shlex' import { join } from 'path' import { @@ -62,6 +65,7 @@ import { import { removeNonSteamGame } from 'backend/shortcuts/nonesteamgame/nonesteamgame' import { sendFrontendMessage } from 'backend/main_window' import setup from './setup' +import { getUmuId } from 'backend/wiki_game_info/umu/utils' export async function getSettings(appName: string): Promise { const gameConfig = GameConfig.get(appName) @@ -383,6 +387,13 @@ export async function launch( const { bin: wineExec, type: wineType } = gameSettings.wineVersion + if (await isUmuSupported(wineType)) { + const umuId = await getUmuId(gameInfo.app_name, gameInfo.runner) + if (umuId) { + commandEnv['GAMEID'] = umuId + } + } + // Fix for people with old config const wineBin = wineExec.startsWith("'") && wineExec.endsWith("'") @@ -390,7 +401,7 @@ export async function launch( : wineExec wineFlag = [ - ...getWineFlagsArray(wineBin, wineType, shlex.join(wrappers)), + ...(await getWineFlagsArray(wineBin, wineType, shlex.join(wrappers))), '--wine-prefix', gameSettings.winePrefix ] @@ -415,12 +426,6 @@ export async function launch( sendGameStatusUpdate({ appName, runner: 'nile', status: 'playing' }) - sendGameStatusUpdate({ - appName, - runner: 'nile', - status: 'playing' - }) - const { error } = await runNileCommand(commandParts, { abortId: appName, env: commandEnv, diff --git a/src/backend/tools/index.ts b/src/backend/tools/index.ts index 18f24a2ec1..47d1bd5513 100644 --- a/src/backend/tools/index.ts +++ b/src/backend/tools/index.ts @@ -54,6 +54,7 @@ import { DAYS, downloadFile as downloadFileInet } from '../utils/inet/downloader' +import { getUmuPath, isUmuSupported } from 'backend/utils/compatibility_layers' interface Tool { name: string @@ -504,15 +505,32 @@ export const Winetricks = { if (!(await validWine(wineVersion))) { return } - const winetricks = `${toolsPath}/winetricks` + + let winetricks = `${toolsPath}/winetricks` + const gui = args.includes('--gui') if (!existsSync(winetricks)) { await Winetricks.download() } + if (await isUmuSupported(wineVersion.type)) { + winetricks = await getUmuPath() + + if (args.includes('-q')) { + args.splice(args.indexOf('-q'), 1) + } + + if (gui) { + args.splice(args.indexOf('--gui'), 1) + args.unshift('') + } else { + args.unshift('winetricks') + } + } + + const { winePrefix, wineVersion: alwaysWine_wineVersion } = + await getWineFromProton(wineVersion, baseWinePrefix) return new Promise((resolve) => { - const { winePrefix, wineVersion: alwaysWine_wineVersion } = - getWineFromProton(wineVersion, baseWinePrefix) const wineBin = alwaysWine_wineVersion.bin // We have to run Winetricks with an actual `wine` binary, meaning we // might need to set some environment variables differently than normal @@ -531,7 +549,9 @@ export const Winetricks = { ...setupEnvVars(settingsWithWineVersion), ...setupWineEnvVars(settingsWithWineVersion, appName), WINEPREFIX: winePrefix, - PATH: `${winepath}:${process.env.PATH}` + PATH: `${winepath}:${process.env.PATH}`, + GAMEID: gui ? 'winetricks-gui' : 'umu-0', + UMU_RUNTIME_UPDATE: '0' } const wineServer = join(winepath, 'wineserver') @@ -589,12 +609,7 @@ export const Winetricks = { } }) - logInfo( - `Running WINEPREFIX='${winePrefix}' PATH='${winepath}':$PATH ${winetricks} ${args.join( - ' ' - )}`, - LogPrefix.WineTricks - ) + logInfo(`Running ${winetricks} ${args.join(' ')}`, LogPrefix.WineTricks) const child = spawn(winetricks, args, { env: envs }) @@ -648,7 +663,7 @@ export const Winetricks = { }) }, run: async (runner: Runner, appName: string) => { - await Winetricks.runWithArgs(runner, appName, ['--force', '-q']) + await Winetricks.runWithArgs(runner, appName, ['-q', '--gui']) }, listAvailable: async (runner: Runner, appName: string) => { try { @@ -684,7 +699,7 @@ export const Winetricks = { }, listInstalled: async (runner: Runner, appName: string) => { const gameSettings = await gameManagerMap[runner].getSettings(appName) - const { winePrefix } = getWineFromProton( + const { winePrefix } = await getWineFromProton( gameSettings.wineVersion, gameSettings.winePrefix ) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 7e6005cad2..b91f07d7b8 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -88,6 +88,7 @@ import { vendorNameCache } from './utils/systeminfo/gpu/pci_ids' import type { WineManagerStatus } from 'common/types' +import { isUmuSupported } from './utils/compatibility_layers' const execAsync = promisify(exec) @@ -142,11 +143,14 @@ function semverGt(target: string, base: string) { const getFileSize = fileSize.partial({ base: 2 }) as (arg: unknown) => string -function getWineFromProton( +async function getWineFromProton( wineVersion: WineInstallation, winePrefix: string -): { winePrefix: string; wineVersion: WineInstallation } { - if (wineVersion.type !== 'proton') { +): Promise<{ winePrefix: string; wineVersion: WineInstallation }> { + if ( + wineVersion.type !== 'proton' || + (await isUmuSupported(wineVersion.type)) + ) { return { winePrefix, wineVersion } } @@ -821,7 +825,7 @@ async function shutdownWine(gameSettings: GameSettings) { gameSettings, commandParts: ['wineboot', '-k'], wait: true, - protonVerb: 'waitforexitandrun' + protonVerb: 'run' }) } } diff --git a/src/backend/utils/compatibility_layers.ts b/src/backend/utils/compatibility_layers.ts index 4336ddf917..41ac69771e 100644 --- a/src/backend/utils/compatibility_layers.ts +++ b/src/backend/utils/compatibility_layers.ts @@ -1,6 +1,7 @@ import { GlobalConfig } from 'backend/config' import { configPath, + defaultUmuPath, getSteamLibraries, isMac, toolsPath, @@ -17,6 +18,7 @@ import { PlistObject, parse as plistParse } from 'plist' import LaunchCommand from '../storeManagers/legendary/commands/launch' import { NonEmptyString } from '../storeManagers/legendary/commands/base' import { Path } from 'backend/schemas' +import { searchForExecutableOnPath } from './os/path' /** * Loads the default wine installation path and version. @@ -476,12 +478,13 @@ export type AllowedWineFlags = Pick< * @param wineType The type of the Wine version * @param wrapper Any wrappers to be used, may be `''` */ -export function getWineFlags( +export async function getWineFlags( wineBin: string, wineType: WineInstallation['type'], wrapper: string -): AllowedWineFlags { +): Promise { let partialCommand: AllowedWineFlags = {} + const umuSupported = await isUmuSupported(wineType) switch (wineType) { case 'wine': case 'toolkit': @@ -492,7 +495,12 @@ export function getWineFlags( partialCommand = { '--no-wine': true, '--wrapper': NonEmptyString.parse( - `${wrapper} '${wineBin}' waitforexitandrun` + `${wrapper} "${wineBin}" waitforexitandrun` + ) + } + if (umuSupported) { + partialCommand['--wrapper'] = NonEmptyString.parse( + (wrapper ? `${wrapper} ` : '') + `"${await getUmuPath()}"` ) } break @@ -511,12 +519,12 @@ export function getWineFlags( /** * Like {@link getWineFlags}, but returns a `string[]` with the flags instead */ -export function getWineFlagsArray( +export async function getWineFlagsArray( wineBin: string, wineType: WineInstallation['type'], wrapper: string -): string[] { - const partialCommand = getWineFlags(wineBin, wineType, wrapper) +): Promise { + const partialCommand = await getWineFlags(wineBin, wineType, wrapper) const commandArray: string[] = [] for (const [key, value] of Object.entries(partialCommand)) { @@ -525,3 +533,18 @@ export function getWineFlagsArray( } return commandArray } + +export const getUmuPath = async () => + searchForExecutableOnPath('umu-run').then((path) => path ?? defaultUmuPath) + +export async function isUmuSupported( + wineType: WineInstallation['type'], + checkUmuInstalled = true +): Promise { + const umuEnabled = + GlobalConfig.get().getSettings().experimentalFeatures?.umuSupport !== false + const wineVersionSupported = wineType === 'proton' + const umuInstalled = checkUmuInstalled ? existsSync(await getUmuPath()) : true + + return umuEnabled && wineVersionSupported && umuInstalled +} diff --git a/src/backend/wiki_game_info/electronStore.ts b/src/backend/wiki_game_info/electronStore.ts index ba7ebed259..ade9cab0aa 100644 --- a/src/backend/wiki_game_info/electronStore.ts +++ b/src/backend/wiki_game_info/electronStore.ts @@ -5,3 +5,7 @@ export const wikiGameInfoStore = new CacheStore( 'wikigameinfo', 60 * 24 * 30 ) + +export const umuStore = new CacheStore('umu', 60 * 6, { + invalidateCheck: (data) => !data +}) diff --git a/src/backend/wiki_game_info/umu/utils.ts b/src/backend/wiki_game_info/umu/utils.ts new file mode 100644 index 0000000000..e850928631 --- /dev/null +++ b/src/backend/wiki_game_info/umu/utils.ts @@ -0,0 +1,47 @@ +import axios from 'axios' +import { Runner } from 'common/types' +import { umuStore } from '../electronStore' + +interface GameObject { + title: string + umu_id: string +} +const storeMapping: Record = { + gog: 'gog', + legendary: 'egs', + nile: 'amazon', + sideload: 'sideload' +} + +export async function getUmuId( + appName: string, + runner: Runner +): Promise { + // if it's a sideload, there won't be any umu id + if (runner === 'sideload') { + return null + } + + const store = storeMapping[runner] + const key = `${runner}_${appName}` + const cachedValue = umuStore.get(key) + if (cachedValue) { + return cachedValue + } + const response = await axios + .get('https://umu.openwinecomponents.org/umu_api.php', { + params: { codename: appName.toLowerCase(), store } + }) + .catch(() => null) + + if (!response || response.status !== 200) { + return null + } + if (response.data.length === 0) { + umuStore.set(key, null) + return null + } + const umuId = response.data[0].umu_id + umuStore.set(key, umuId) + return umuId +} diff --git a/src/backend/wiki_game_info/wiki_game_info.ts b/src/backend/wiki_game_info/wiki_game_info.ts index 599b2b81b8..725f5a253f 100644 --- a/src/backend/wiki_game_info/wiki_game_info.ts +++ b/src/backend/wiki_game_info/wiki_game_info.ts @@ -9,6 +9,7 @@ import { getInfoFromAppleGamingWiki } from './applegamingwiki/utils' import { getHowLongToBeat } from './howlongtobeat/utils' import { getInfoFromPCGamingWiki } from './pcgamingwiki/utils' import { isMac, isLinux } from '../constants' +import { getUmuId } from './umu/utils' export async function getWikiGameInfo( title: string, @@ -30,12 +31,13 @@ export async function getWikiGameInfo( logInfo(`Getting ExtraGameInfo data for ${title}`, LogPrefix.ExtraGameInfo) - const [pcgamingwiki, howlongtobeat, gamesdb, applegamingwiki] = + const [pcgamingwiki, howlongtobeat, gamesdb, applegamingwiki, umuId] = await Promise.all([ getInfoFromPCGamingWiki(title, runner === 'gog' ? appName : undefined), getHowLongToBeat(title), getInfoFromGamesDB(title, appName, runner), - isMac ? getInfoFromAppleGamingWiki(title) : null + isMac ? getInfoFromAppleGamingWiki(title) : null, + isLinux ? getUmuId(appName, runner) : null ]) let steamInfo = null @@ -61,7 +63,8 @@ export async function getWikiGameInfo( applegamingwiki, howlongtobeat, gamesdb, - steamInfo + steamInfo, + umuId } wikiGameInfoStore.set(title, wikiGameInfo) diff --git a/src/backend/wine/runtimes/runtimes.ts b/src/backend/wine/runtimes/runtimes.ts index 1078209ddc..543aefcbf5 100644 --- a/src/backend/wine/runtimes/runtimes.ts +++ b/src/backend/wine/runtimes/runtimes.ts @@ -10,7 +10,7 @@ import { join } from 'path' import { runtimePath } from './../../constants' import { logError, logInfo, LogPrefix } from './../../logger/logger' import { Runtime, RuntimeName } from 'common/types' -import { downloadFile, extractTarFile, getAssetDataFromDownload } from './util' +import { downloadFile, extractTarFile } from './util' import { axiosClient } from 'backend/utils' async function _get(): Promise { @@ -36,9 +36,7 @@ async function download(name: RuntimeName): Promise { LogPrefix.Runtime ) - const { name: tarFileName, content_type } = await getAssetDataFromDownload( - runtime.url - ) + const tarFileName = runtime.url.split('/').pop()! const tarFilePath = join(runtimePath, tarFileName) await downloadFile(runtime.url, tarFilePath) @@ -48,7 +46,7 @@ async function download(name: RuntimeName): Promise { rmSync(extractedFolderPath, { recursive: true }) } - await extractTarFile(tarFilePath, content_type, { + await extractTarFile(tarFilePath, { extractedPath: extractedFolderPath, strip: 1 }) diff --git a/src/backend/wine/runtimes/util.ts b/src/backend/wine/runtimes/util.ts index 452611ddfd..eff253ed4f 100644 --- a/src/backend/wine/runtimes/util.ts +++ b/src/backend/wine/runtimes/util.ts @@ -74,7 +74,6 @@ async function downloadFile(url: string, filePath: string) { async function extractTarFile( filePath: string, - contentType: string, options?: { extractedPath?: string; strip?: number } ) { if (!existsSync(filePath)) { diff --git a/src/common/types.ts b/src/common/types.ts index ae5d5243fd..0e7235a190 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -65,6 +65,7 @@ export type ExperimentalFeatures = { enableNewDesign: boolean enableHelp: boolean automaticWinetricksFixes: boolean + umuSupport: boolean } export interface AppSettings extends GameSettings { @@ -473,7 +474,7 @@ export interface Runtime { url: string } -export type RuntimeName = 'eac_runtime' | 'battleye_runtime' +export type RuntimeName = 'eac_runtime' | 'battleye_runtime' | 'umu' export type RecentGame = { appName: string @@ -671,6 +672,7 @@ export interface WikiInfo { howlongtobeat: HowLongToBeatEntry | null gamesdb: GamesDBInfo | null steamInfo: SteamInfo | null + umuId: string | null } /** diff --git a/src/frontend/screens/Settings/components/ExperimentalFeatures.tsx b/src/frontend/screens/Settings/components/ExperimentalFeatures.tsx index 6204d76e39..2598c1e702 100644 --- a/src/frontend/screens/Settings/components/ExperimentalFeatures.tsx +++ b/src/frontend/screens/Settings/components/ExperimentalFeatures.tsx @@ -13,13 +13,18 @@ const ExperimentalFeatures = () => { FEATURES.push('automaticWinetricksFixes') } + if (platform === 'linux') { + FEATURES.push('umuSupport') + } + const { t } = useTranslation() const [experimentalFeatures, setExperimentalFeatures] = useSetting( 'experimentalFeatures', { enableNewDesign: false, enableHelp: false, - automaticWinetricksFixes: true + automaticWinetricksFixes: true, + umuSupport: true } ) const { handleExperimentalFeatures } = useContext(ContextProvider) @@ -38,6 +43,7 @@ const ExperimentalFeatures = () => { t('setting.experimental_features.enableNewDesign', 'New design') t('setting.experimental_features.enableHelp', 'Help component') t('setting.experimental_features.automaticWinetricksFixes', 'Apply known fixes automatically') + t('setting.experimental_features.umuSupport', 'Use UMU as Proton runtime') */ return ( diff --git a/src/frontend/state/ContextProvider.tsx b/src/frontend/state/ContextProvider.tsx index a933475ce3..2bfd5c3ff4 100644 --- a/src/frontend/state/ContextProvider.tsx +++ b/src/frontend/state/ContextProvider.tsx @@ -97,7 +97,8 @@ const initialContext: ContextType = { experimentalFeatures: { enableNewDesign: false, enableHelp: false, - automaticWinetricksFixes: true + automaticWinetricksFixes: true, + umuSupport: true }, handleExperimentalFeatures: () => null, disableDialogBackdropClose: false, diff --git a/src/frontend/state/GlobalState.tsx b/src/frontend/state/GlobalState.tsx index f87fe79f90..6c06057a15 100644 --- a/src/frontend/state/GlobalState.tsx +++ b/src/frontend/state/GlobalState.tsx @@ -207,10 +207,12 @@ class GlobalState extends PureComponent { lastChangelogShown: JSON.parse(storage.getItem('last_changelog') || 'null'), settingsModalOpen: { value: false, type: 'settings', gameInfo: undefined }, helpItems: {}, - experimentalFeatures: globalSettings?.experimentalFeatures || { + experimentalFeatures: { enableNewDesign: false, enableHelp: false, - automaticWinetricksFixes: true + automaticWinetricksFixes: true, + umuSupport: true, + ...(globalSettings?.experimentalFeatures || {}) }, disableDialogBackdropClose: configStore.get( 'disableDialogBackdropClose',