From 10c1e8e1c7508bc97555b32e821997b07c9beaef Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 00:29:56 +0200 Subject: [PATCH 01/33] Allow for store specific game info props --- src/common/types.ts | 26 ++++++++++++++++++++++++-- src/frontend/hooks/hasStatus.ts | 6 +++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/common/types.ts b/src/common/types.ts index aa914c5780..ba37007e9c 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -151,8 +151,13 @@ export interface ExtraInfo { export type GameConfigVersion = 'auto' | 'v0' | 'v0.1' -export interface GameInfo { - runner: 'legendary' | 'gog' | 'sideload' | 'nile' +export type GameInfo = + | SideloadGameInfo + | LegendaryGameInfo + | GOGGameInfo + | NileGameInfo + +interface BaseGameInfo { store_url?: string app_name: string art_cover: string @@ -188,6 +193,23 @@ export interface GameInfo { launchFullScreen?: boolean } + +export interface SideloadGameInfo extends BaseGameInfo { + runner: 'sideload' +} + +export interface LegendaryGameInfo extends BaseGameInfo { + runner: 'legendary' +} + +export interface GOGGameInfo extends BaseGameInfo { + runner: 'gog' +} + +export interface NileGameInfo extends BaseGameInfo { + runner: 'nile' +} + export interface GameSettings { autoInstallDxvk: boolean autoInstallVkd3d: boolean diff --git a/src/frontend/hooks/hasStatus.ts b/src/frontend/hooks/hasStatus.ts index 576caf4f67..f43004fdc1 100644 --- a/src/frontend/hooks/hasStatus.ts +++ b/src/frontend/hooks/hasStatus.ts @@ -26,10 +26,10 @@ export function hasStatus( const { thirdPartyManagedApp = undefined, - is_installed, + is_installed = false, runner = 'sideload', - isEAManaged - } = { ...newGameInfo } + isEAManaged = false + } = newGameInfo || {} React.useEffect(() => { if (newGameInfo) { From 27f02b80087b83189588a6412ffc4ef1e716a7be Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 11:19:36 +0200 Subject: [PATCH 02/33] Moved gog_save_location to gog game info --- .../storeManagers/gog/electronStores.ts | 4 ++-- src/backend/storeManagers/gog/games.ts | 4 ++-- src/backend/storeManagers/gog/library.ts | 18 +++++++++--------- src/common/types.ts | 13 ++++++------- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/backend/storeManagers/gog/electronStores.ts b/src/backend/storeManagers/gog/electronStores.ts index cbecddb3ec..6b72fe8a04 100644 --- a/src/backend/storeManagers/gog/electronStores.ts +++ b/src/backend/storeManagers/gog/electronStores.ts @@ -1,6 +1,6 @@ import { TypeCheckedStoreBackend } from '../../electron_store' import CacheStore from '../../cache' -import { GameInfo } from 'common/types' +import { GOGGameInfo } from 'common/types' import { GOGSessionSyncQueueItem, GamesDBData, @@ -20,7 +20,7 @@ const configStore = new TypeCheckedStoreBackend('gogConfigStore', { }) const apiInfoCache = new CacheStore('gog_api_info') -const libraryStore = new CacheStore('gog_library', null) +const libraryStore = new CacheStore('gog_library', null) const syncStore = new TypeCheckedStoreBackend('gogSyncStore', { cwd: 'gog_store', name: 'saveTimestamps', diff --git a/src/backend/storeManagers/gog/games.ts b/src/backend/storeManagers/gog/games.ts index f942f400a6..0b31671426 100644 --- a/src/backend/storeManagers/gog/games.ts +++ b/src/backend/storeManagers/gog/games.ts @@ -28,7 +28,7 @@ import { } from '../../utils' import { ExtraInfo, - GameInfo, + GOGGameInfo, GameSettings, ExecResult, InstallArgs, @@ -139,7 +139,7 @@ export async function getExtraInfo(appName: string): Promise { return extra } -export function getGameInfo(appName: string): GameInfo { +export function getGameInfo(appName: string): GOGGameInfo { const info = getGogLibraryGameInfo(appName) if (!info) { logError( diff --git a/src/backend/storeManagers/gog/library.ts b/src/backend/storeManagers/gog/library.ts index ada046f293..7bb9a59674 100644 --- a/src/backend/storeManagers/gog/library.ts +++ b/src/backend/storeManagers/gog/library.ts @@ -2,12 +2,12 @@ import { sendFrontendMessage } from '../../ipc' import axios, { AxiosError, AxiosResponse } from 'axios' import { GOGUser } from './user' import { - GameInfo, InstalledInfo, GOGImportData, ExecResult, CallRunnerOptions, - LaunchOption + LaunchOption, + GOGGameInfo } from 'common/types' import { GOGCloudSavesLocation, @@ -55,7 +55,7 @@ import { runGogdlCommandStub } from './e2eMock' import { gogdlConfigPath } from './constants' import { userDataPath } from 'backend/constants/paths' -const library: Map = new Map() +const library: Map = new Map() const installedGames: Map = new Map() export async function initGOGLibraryManager() { @@ -353,7 +353,7 @@ export async function refresh(): Promise { } refreshInstalled() await loadLocalLibrary() - const redistGameInfo: GameInfo = { + const redistGameInfo: GOGGameInfo = { app_name: 'gog-redist', runner: 'gog', title: 'Galaxy Common Redistributables', @@ -389,7 +389,7 @@ export async function refresh(): Promise { (entry) => entry.platform_id === 'gog' ) - const gamesObjects: GameInfo[] = [redistGameInfo] + const gamesObjects: GOGGameInfo[] = [redistGameInfo] apiInfoCache.use_in_memory() // Prevent blocking operations for (const game of filteredApiArray) { let retries = 5 @@ -472,11 +472,11 @@ export async function refresh(): Promise { return defaultExecResult } -export function getGameInfo(slug: string): GameInfo | undefined { +export function getGameInfo(slug: string): GOGGameInfo | undefined { return library.get(slug) || getInstallAndGameInfo(slug) } -export function getInstallAndGameInfo(slug: string): GameInfo | undefined { +export function getInstallAndGameInfo(slug: string): GOGGameInfo | undefined { const lib = libraryStore.get('games', []) const game = lib.find((value) => value.app_name === slug) @@ -946,7 +946,7 @@ export async function checkForGameUpdate( */ export async function gogToUnifiedInfo( info: GamesDBData | undefined -): Promise { +): Promise { if (!info || info.type !== 'game' || !info.game.visible_in_library) { // @ts-expect-error TODO: Handle this somehow return {} @@ -966,7 +966,7 @@ export async function gogToUnifiedInfo( ?.replace('{formatter}', '') .replace('{ext}', 'jpg') - const object: GameInfo = { + const object: GOGGameInfo = { runner: 'gog', developer: info.game.developers.map((dev) => dev.name).join(', '), app_name: String(info.external_id), diff --git a/src/common/types.ts b/src/common/types.ts index ba37007e9c..5c7d72c1f9 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -151,11 +151,11 @@ export interface ExtraInfo { export type GameConfigVersion = 'auto' | 'v0' | 'v0.1' -export type GameInfo = - | SideloadGameInfo - | LegendaryGameInfo - | GOGGameInfo - | NileGameInfo +export type GameInfo = + | SideloadGameInfo + | LegendaryGameInfo + | GOGGameInfo + | NileGameInfo interface BaseGameInfo { store_url?: string @@ -177,7 +177,6 @@ interface BaseGameInfo { save_folder?: string // ...and this is the folder with them filled in save_path?: string - gog_save_location?: GOGCloudSavesLocation[] title: string canRunOffline: boolean thirdPartyManagedApp?: string @@ -193,7 +192,6 @@ interface BaseGameInfo { launchFullScreen?: boolean } - export interface SideloadGameInfo extends BaseGameInfo { runner: 'sideload' } @@ -204,6 +202,7 @@ export interface LegendaryGameInfo extends BaseGameInfo { export interface GOGGameInfo extends BaseGameInfo { runner: 'gog' + gog_save_location?: GOGCloudSavesLocation[] } export interface NileGameInfo extends BaseGameInfo { From f8a2524353309e05f3b525740f5560d5f4b9532e Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 20:27:12 +0200 Subject: [PATCH 03/33] Use NileGameInfo in nile runner --- src/backend/storeManagers/nile/electronStores.ts | 4 ++-- src/backend/storeManagers/nile/games.ts | 4 ++-- src/backend/storeManagers/nile/library.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/backend/storeManagers/nile/electronStores.ts b/src/backend/storeManagers/nile/electronStores.ts index e85703f601..31a8fb7eba 100644 --- a/src/backend/storeManagers/nile/electronStores.ts +++ b/src/backend/storeManagers/nile/electronStores.ts @@ -1,10 +1,10 @@ import CacheStore from 'backend/cache' import { TypeCheckedStoreBackend } from 'backend/electron_store' -import { GameInfo } from 'common/types' +import { NileGameInfo } from 'common/types' import { NileInstallInfo } from 'common/types/nile' export const installStore = new CacheStore('nile_install_info') -export const libraryStore = new CacheStore( +export const libraryStore = new CacheStore( 'nile_library', null ) diff --git a/src/backend/storeManagers/nile/games.ts b/src/backend/storeManagers/nile/games.ts index b06bfb8593..d8e5a72479 100644 --- a/src/backend/storeManagers/nile/games.ts +++ b/src/backend/storeManagers/nile/games.ts @@ -1,7 +1,7 @@ import { ExecResult, ExtraInfo, - GameInfo, + NileGameInfo, GameSettings, InstallArgs, InstallPlatform, @@ -68,7 +68,7 @@ export async function getSettings(appName: string): Promise { return gameConfig.config || (await gameConfig.getSettings()) } -export function getGameInfo(appName: string): GameInfo { +export function getGameInfo(appName: string): NileGameInfo { const info = nileLibraryGetGameInfo(appName) if (!info) { logError( diff --git a/src/backend/storeManagers/nile/library.ts b/src/backend/storeManagers/nile/library.ts index dd53ffdd53..6a38e5d97b 100644 --- a/src/backend/storeManagers/nile/library.ts +++ b/src/backend/storeManagers/nile/library.ts @@ -6,11 +6,11 @@ import { logInfo, logWarning } from 'backend/logger' -import { CallRunnerOptions, ExecResult, GameInfo } from 'common/types' +import { CallRunnerOptions, ExecResult, NileGameInfo } from 'common/types' import { FuelSchema, NileGameDownloadInfo, - NileGameInfo, + NileGameInfo as NileGameInfoJSON, NileInstallInfo, NileInstallMetadataInfo } from 'common/types/nile' @@ -26,7 +26,7 @@ import { runNileCommandStub } from './e2eMock' import { nileConfigPath, nileInstalled, nileLibrary } from './constants' const installedGames: Map = new Map() -const library: Map = new Map() +const library: Map = new Map() export async function initNileLibraryManager() { // Migrate user data from global Nile config if necessary @@ -46,7 +46,7 @@ function loadGamesInAccount() { if (!existsSync(nileLibrary)) { return } - const libraryJSON: NileGameInfo[] = JSON.parse( + const libraryJSON: NileGameInfoJSON[] = JSON.parse( readFileSync(nileLibrary, 'utf-8') ) libraryJSON.forEach((game) => { @@ -285,7 +285,7 @@ export async function refresh(): Promise { export function getGameInfo( appName: string, forceReload = false -): GameInfo | undefined { +): NileGameInfo | undefined { if (!forceReload) { const gameInMemory = library.get(appName) if (gameInMemory) { From aa528785f775a06cdbddbd0897564001e6a1104b Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 20:27:28 +0200 Subject: [PATCH 04/33] Use SideloadGameInfo in sideload runner --- src/backend/storeManagers/sideload/library.ts | 8 ++++---- src/common/types/ipc.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/backend/storeManagers/sideload/library.ts b/src/backend/storeManagers/sideload/library.ts index 8affe09562..ef1c31de27 100644 --- a/src/backend/storeManagers/sideload/library.ts +++ b/src/backend/storeManagers/sideload/library.ts @@ -1,4 +1,4 @@ -import { ExecResult, GameInfo } from 'common/types' +import { ExecResult, SideloadGameInfo } from 'common/types' import { readdirSync } from 'graceful-fs' import { dirname, join } from 'path' import { libraryStore } from './electronStores' @@ -18,8 +18,8 @@ export function addNewApp({ description, customUserAgent, launchFullScreen -}: GameInfo): void { - const game: GameInfo = { +}: SideloadGameInfo): void { + const game: SideloadGameInfo = { runner: 'sideload', app_name, title, @@ -79,7 +79,7 @@ export async function refresh() { return null } -export function getGameInfo(): GameInfo { +export function getGameInfo(): SideloadGameInfo { logWarning(`getGameInfo not implemented on Sideload Library Manager`) return { app_name: '', diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts index 3db51226f1..532636713a 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -32,6 +32,7 @@ import type { RuntimeName, RunWineCommandArgs, SaveSyncArgs, + SideloadGameInfo, StatusPromise, ToolArgs, Tools, @@ -84,7 +85,7 @@ interface SyncIPCFunctions { showItemInFolder: (item: string) => void clipboardWriteText: (text: string) => void processShortcut: (combination: string) => void - addNewApp: (args: GameInfo) => void + addNewApp: (args: SideloadGameInfo) => void showLogFileInFolder: (args: GetLogFileArgs) => void addShortcut: (appName: string, runner: Runner, fromMenu: boolean) => void removeShortcut: (appName: string, runner: Runner) => void From 2841f015d705303acfff7b0c2d67b31c8ad8888a Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 20:28:04 +0200 Subject: [PATCH 05/33] Use LegendaryGameInfo legendary runner --- src/backend/storeManagers/legendary/electronStores.ts | 4 ++-- src/backend/storeManagers/legendary/games.ts | 6 +++--- src/backend/storeManagers/legendary/library.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/backend/storeManagers/legendary/electronStores.ts b/src/backend/storeManagers/legendary/electronStores.ts index cb2d8f970b..2f5184b8b3 100644 --- a/src/backend/storeManagers/legendary/electronStores.ts +++ b/src/backend/storeManagers/legendary/electronStores.ts @@ -1,11 +1,11 @@ import CacheStore from '../../cache' -import { ExtraInfo, GameInfo } from 'common/types' +import { ExtraInfo, LegendaryGameInfo } from 'common/types' import { GameOverride, LegendaryInstallInfo } from 'common/types/legendary' export const installStore = new CacheStore( 'legendary_install_info' ) -export const libraryStore = new CacheStore( +export const libraryStore = new CacheStore( 'legendary_library', null ) diff --git a/src/backend/storeManagers/legendary/games.ts b/src/backend/storeManagers/legendary/games.ts index 585ed4f02c..4b2cc9465c 100644 --- a/src/backend/storeManagers/legendary/games.ts +++ b/src/backend/storeManagers/legendary/games.ts @@ -4,7 +4,7 @@ import axios from 'axios' import { ExecResult, ExtraInfo, - GameInfo, + LegendaryGameInfo, InstallArgs, InstallPlatform, InstallProgress, @@ -102,7 +102,7 @@ export async function checkGameUpdates() { * * @returns GameInfo */ -export function getGameInfo(appName: string): GameInfo { +export function getGameInfo(appName: string): LegendaryGameInfo { const info = getLegLibraryGameInfo(appName) if (!info) { logError( @@ -666,7 +666,7 @@ export async function install( } async function installEA( - gameInfo: GameInfo, + gameInfo: LegendaryGameInfo, platformToInstall: string ): Promise<{ status: 'done' | 'error' | 'abort' diff --git a/src/backend/storeManagers/legendary/library.ts b/src/backend/storeManagers/legendary/library.ts index 9b27f7a1fd..221ea53f28 100644 --- a/src/backend/storeManagers/legendary/library.ts +++ b/src/backend/storeManagers/legendary/library.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'graceful-fs' import { - GameInfo, + LegendaryGameInfo, InstalledInfo, CallRunnerOptions, ExecResult, @@ -55,7 +55,7 @@ const fallBackImage = 'fallback' const allGames: Set = new Set() let installedGames: Map = new Map() -const library: Map = new Map() +const library: Map = new Map() export async function initLegendaryLibraryManager() { loadGamesInAccount() @@ -183,12 +183,12 @@ export function getListOfGames() { * * @param appName The AppName of the game you want the info of * @param forceReload Discards game info in `library` and always reads info from metadata files - * @returns GameInfo + * @returns LegendaryGameInfo */ export function getGameInfo( appName: string, forceReload = false -): GameInfo | undefined { +): LegendaryGameInfo | undefined { if (!hasGame(appName)) { logWarning( ['Requested game', appName, 'was not found in library'], From 24c932b7e0a6d642948c295eb0f2f0f1ed440e27 Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 11:49:38 +0200 Subject: [PATCH 06/33] Added custom libraries runner --- src/backend/logger/constants.ts | 6 +- .../storeManagers/customLibraries/games.ts | 211 ++++++++++++++++++ .../storeManagers/customLibraries/library.ts | 101 +++++++++ src/backend/storeManagers/index.ts | 9 +- src/backend/wiki_game_info/umu/utils.ts | 7 +- src/common/types.ts | 7 +- src/common/types/electron_store.ts | 3 + src/common/utils.ts | 3 +- .../components/UI/LibraryFilters/index.tsx | 3 +- .../components/UI/LibrarySearchBar/index.tsx | 9 +- src/frontend/helpers/electronStores.ts | 8 +- src/frontend/helpers/index.ts | 2 + src/frontend/helpers/library.ts | 1 + .../components/DownloadManagerItem/index.tsx | 10 +- src/frontend/screens/Game/GamePage/index.tsx | 2 +- src/frontend/screens/Library/index.tsx | 8 +- src/frontend/state/ContextProvider.tsx | 1 + src/frontend/state/GlobalState.tsx | 12 +- src/frontend/types.ts | 12 +- 19 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 src/backend/storeManagers/customLibraries/games.ts create mode 100644 src/backend/storeManagers/customLibraries/library.ts diff --git a/src/backend/logger/constants.ts b/src/backend/logger/constants.ts index b82550b116..bd173682a5 100644 --- a/src/backend/logger/constants.ts +++ b/src/backend/logger/constants.ts @@ -19,7 +19,8 @@ const LogPrefix = { DownloadManager: 'DownloadManager', ExtraGameInfo: 'ExtraGameInfo', Sideload: 'Sideload', - LogUploader: 'LogUploader' + LogUploader: 'LogUploader', + CustomLibrary: 'CustomLibrary' } type LogPrefix = (typeof LogPrefix)[keyof typeof LogPrefix] @@ -31,7 +32,8 @@ const RunnerToLogPrefixMap: Record = { legendary: LogPrefix.Legendary, gog: LogPrefix.Gog, nile: LogPrefix.Nile, - sideload: LogPrefix.Sideload + sideload: LogPrefix.Sideload, + customLibrary: LogPrefix.CustomLibrary } const LogLevel = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] as const diff --git a/src/backend/storeManagers/customLibraries/games.ts b/src/backend/storeManagers/customLibraries/games.ts new file mode 100644 index 0000000000..466a2b2c84 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/games.ts @@ -0,0 +1,211 @@ +import { + ExecResult, + ExtraInfo, + GameSettings, + InstallArgs, + InstallPlatform, + LaunchOption, + CustomLibraryGameInfo +} from 'common/types' +import { GameConfig } from '../../game_config' +import { killPattern, shutdownWine } from '../../utils' +import { existsSync } from 'graceful-fs' +import { + addShortcuts as addShortcutsUtil, + removeShortcuts as removeShortcutsUtil +} from '../../shortcuts/shortcuts/shortcuts' +import { GOGCloudSavesLocation } from 'common/types/gog' +import { InstallResult, RemoveArgs } from 'common/types/game_manager' +import type LogWriter from 'backend/logger/log_writer' +import { isLinux, isMac, isWindows } from 'backend/constants/environment' +import { logWarning } from 'backend/logger' + +export function getGameInfo(appName: string): CustomLibraryGameInfo { + logWarning( + `getGameInfo not implemented on custom library game manager. called for appName = ${appName}` + ) + return { + runner: 'customLibrary', + app_name: appName, + } +} + +export async function getSettings(appName: string): Promise { + return ( + GameConfig.get(appName).config || + (await GameConfig.get(appName).getSettings()) + ) +} + +export async function addShortcuts( + appName: string, + fromMenu?: boolean +): Promise { + return addShortcutsUtil(getGameInfo(appName), fromMenu) +} + +export async function removeShortcuts(appName: string): Promise { + return removeShortcutsUtil(getGameInfo(appName)) +} + +export async function isGameAvailable(appName: string): Promise { + return new Promise((resolve) => { + const gameInfo = getGameInfo(appName) + if (gameInfo && gameInfo.is_installed) { + if ( + gameInfo.install.install_path && + existsSync(gameInfo.install.install_path) + ) { + resolve(true) + } else { + resolve(false) + } + } + resolve(false) + }) +} + +export async function launch( + appName: string, + logWriter: LogWriter, + launchArguments?: LaunchOption, + args: string[] = [] +): Promise { + logWarning( + `launch not implemented on custom library game manager. called for appName = ${appName}` + ) + return false +} + +export async function stop(appName: string): Promise { + const { + install: { executable = undefined } + } = getGameInfo(appName) + + if (executable) { + const split = executable.split('/') + const exe = split[split.length - 1] + killPattern(exe) + + if (!isNative(appName)) { + const gameSettings = await getSettings(appName) + shutdownWine(gameSettings) + } + } +} + +export async function uninstall({ + appName, + shouldRemovePrefix +}: RemoveArgs): Promise { + logWarning( + `uninstall not implemented on custom library game manager. called for appName = ${appName}` + ) + return { stderr: '', stdout: '' } +} + +export async function getExtraInfo(appName: string): Promise { + const game = getGameInfo(appName) + return ( + game.extra || { + about: { + description: '', + shortDescription: '' + }, + reqs: [], + storeUrl: '' + } + ) +} + +/* eslint-disable @typescript-eslint/no-unused-vars */ +export function onInstallOrUpdateOutput( + appName: string, + action: 'installing' | 'updating', + data: string, + totalDownloadSize: number +) { + logWarning( + `onInstallOrUpdateOutput not implemented on Custom Game Manager. called for appName = ${appName}` + ) +} + +export async function moveInstall( + appName: string, + newInstallPath: string +): Promise { + logWarning( + `moveInstall not implemented on Custom Game Manager. called for appName = ${appName}` + ) + return { status: 'error' } +} + +export async function repair(appName: string): Promise { + logWarning( + `repair not implemented on Custom Game Manager. called for appName = ${appName}` + ) + return { stderr: '', stdout: '' } +} + +export async function syncSaves( + appName: string, + arg: string, + path: string, + gogSaves?: GOGCloudSavesLocation[] +): Promise { + logWarning( + `syncSaves not implemented on Custom Game Manager. called for appName = ${appName}` + ) + return '' +} + +export async function forceUninstall(appName: string): Promise { + logWarning( + `forceUninstall not implemented on custom library game manager. called for appName = ${appName}` + ) +} + +// Simple install function that works with the download manager +export async function install( + appName: string, + args: InstallArgs +): Promise { + logWarning( + `install not implemented on custom library game manager. called for appName = ${appName}` + ) + return { status: 'error' } +} + +export function isNative(appName: string): boolean { + const gameInfo = getGameInfo(appName) + if (isWindows) { + return true + } + + if (isMac && gameInfo.install.platform === 'osx') { + return true + } + + if (isLinux && gameInfo.install.platform === 'linux') { + return true + } + + return false +} + +export async function importGame( + appName: string, + path: string, + platform: InstallPlatform +): Promise { + logWarning( + `importGame not implemented on custom library game manager. called for appName = ${appName}` + ) + return { stderr: '', stdout: '' } +} + +export async function update( + appName: string +): Promise<{ status: 'done' | 'error' }> { + return { status: 'error' } +} diff --git a/src/backend/storeManagers/customLibraries/library.ts b/src/backend/storeManagers/customLibraries/library.ts new file mode 100644 index 0000000000..17cbcc0407 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/library.ts @@ -0,0 +1,101 @@ +import { + ExecResult, + LaunchOption, + CustomLibraryGameInfo +} from 'common/types' +import { logWarning } from 'backend/logger' +import type { InstallPlatform } from 'common/types' + +/** + * Loads installed data and adds it into a Map + */ +export function refreshInstalled() { + logWarning( + `refreshInstalled not implemented on custom library library manager` + ) +} + +export async function initCustomLibraryManager() { + logWarning( + `initCustomLibraryManager not implemented on custom library library manager` + ) +} + +export async function refresh(): Promise { + logWarning( + `refresh not implemented on custom library library manager` + ) + return { + stdout: '', + stderr: '', + } +} + +export function getGameInfo(appName: string): CustomLibraryGameInfo { + logWarning( + `getGameInfo not implemented on custom library library manager. called for appName = ${appName}` + ) + return { + runner: 'customLibrary', + app_name: appName, + art_cover: '', + art_square: '', + install: {}, + is_installed: false, + title: '', + canRunOffline: false, + } +} + +export async function getInstallInfo(appName: string): Promise { + logWarning( + `getInstallInfo not implemented on custom library library manager. called for appName = ${appName}` + ) + return { + install_path: '', + executable: '', + } +} + +export async function importGame( + gameInfo: CustomLibraryGameInfo, + installPath: string, + platform: InstallPlatform +): Promise { + logWarning( + `importGame not implemented on custom library library manager. called for gameInfo = ${gameInfo}` + ) +} + +export function installState() { + logWarning(`installState not implemented on Sideload Library Manager`) +} + +export async function listUpdateableGames(): Promise { + logWarning(`listUpdateableGames not implemented on Sideload Library Manager`) + return [] +} + +export async function runRunnerCommand(): Promise { + logWarning(`runRunnerCommand not implemented on Sideload Library Manager`) + return { stdout: '', stderr: '' } +} + +export async function changeGameInstallPath(): Promise { + logWarning( + `changeGameInstallPath not implemented on Sideload Library Manager` + ) +} + +export const getLaunchOptions = (appName: string): LaunchOption[] => { + logWarning( + `getLaunchOptions not implemented on custom library library manager. called for appName = ${appName}` + ) + return [] +} + +export function changeVersionPinnedStatus() { + logWarning( + 'changeVersionPinnedStatus not implemented on Sideload Library Manager' + ) +} diff --git a/src/backend/storeManagers/index.ts b/src/backend/storeManagers/index.ts index 34ee6a265b..c0cfe25df0 100644 --- a/src/backend/storeManagers/index.ts +++ b/src/backend/storeManagers/index.ts @@ -2,11 +2,13 @@ import * as SideloadGameManager from 'backend/storeManagers/sideload/games' import * as GOGGameManager from 'backend/storeManagers/gog/games' import * as LegendaryGameManager from 'backend/storeManagers/legendary/games' import * as NileGameManager from 'backend/storeManagers/nile/games' +import * as CustomGameManager from 'backend/storeManagers/customLibraries/games' import * as SideloadLibraryManager from 'backend/storeManagers/sideload/library' import * as GOGLibraryManager from 'backend/storeManagers/gog/library' import * as LegendaryLibraryManager from 'backend/storeManagers/legendary/library' import * as NileLibraryManager from 'backend/storeManagers/nile/library' +import * as CustomLibraryManager from 'backend/storeManagers/customLibraries/library' import { GameManager, LibraryManager } from 'common/types/game_manager' import { logInfo, RunnerToLogPrefixMap } from 'backend/logger' @@ -21,7 +23,8 @@ export const gameManagerMap: GameManagerMap = { sideload: SideloadGameManager, gog: GOGGameManager, legendary: LegendaryGameManager, - nile: NileGameManager + nile: NileGameManager, + customLibrary: CustomGameManager } type LibraryManagerMap = { @@ -32,7 +35,8 @@ export const libraryManagerMap: LibraryManagerMap = { sideload: SideloadLibraryManager, gog: GOGLibraryManager, legendary: LegendaryLibraryManager, - nile: NileLibraryManager + nile: NileLibraryManager, + customLibrary: CustomLibraryManager } function getDMElement(gameInfo: GameInfo, appName: string) { @@ -81,4 +85,5 @@ export async function initStoreManagers() { await LegendaryLibraryManager.initLegendaryLibraryManager() await GOGLibraryManager.initGOGLibraryManager() await NileLibraryManager.initNileLibraryManager() + await CustomLibraryManager.initCustomLibraryManager() } diff --git a/src/backend/wiki_game_info/umu/utils.ts b/src/backend/wiki_game_info/umu/utils.ts index e850928631..6130881b63 100644 --- a/src/backend/wiki_game_info/umu/utils.ts +++ b/src/backend/wiki_game_info/umu/utils.ts @@ -10,15 +10,16 @@ const storeMapping: Record = { gog: 'gog', legendary: 'egs', nile: 'amazon', - sideload: 'sideload' + sideload: 'sideload', + customLibrary: 'customLibrary' } export async function getUmuId( appName: string, runner: Runner ): Promise { - // if it's a sideload, there won't be any umu id - if (runner === 'sideload') { + // if it's a sideload or customLibrary, there won't be any umu id + if (['sideload', 'customLibrary'].includes(runner)) { return null } diff --git a/src/common/types.ts b/src/common/types.ts index 5c7d72c1f9..52b6d19497 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -15,7 +15,7 @@ import { NileInstallInfo, NileInstallPlatform } from './types/nile' import type { Path } from 'backend/schemas' import type LogWriter from 'backend/logger/log_writer' -export type Runner = 'legendary' | 'gog' | 'sideload' | 'nile' +export type Runner = 'legendary' | 'gog' | 'sideload' | 'nile' | 'customLibrary' // NOTE: Do not put enum's in this module or it will break imports @@ -156,6 +156,7 @@ export type GameInfo = | LegendaryGameInfo | GOGGameInfo | NileGameInfo + | CustomLibraryGameInfo interface BaseGameInfo { store_url?: string @@ -209,6 +210,10 @@ export interface NileGameInfo extends BaseGameInfo { runner: 'nile' } +export interface CustomLibraryGameInfo extends BaseGameInfo { + runner: 'customLibrary' +} + export interface GameSettings { autoInstallDxvk: boolean autoInstallVkd3d: boolean diff --git a/src/common/types/electron_store.ts b/src/common/types/electron_store.ts index 5bc9ee004f..46a6427016 100644 --- a/src/common/types/electron_store.ts +++ b/src/common/types/electron_store.ts @@ -57,6 +57,9 @@ export interface StoreStructure { gogInstalledGamesStore: { installed: InstalledInfo[] } + customLibraryInstalledGamesStore: { + installed: InstalledInfo[] + } timestampStore: { [K: string]: { firstPlayed: string diff --git a/src/common/utils.ts b/src/common/utils.ts index b3fef0112c..1215d9e2a6 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -4,5 +4,6 @@ export const storeMap: { [key in Runner]: string | undefined } = { legendary: 'epic', gog: 'gog', nile: 'amazon', - sideload: undefined + sideload: undefined, + customLibrary: undefined } diff --git a/src/frontend/components/UI/LibraryFilters/index.tsx b/src/frontend/components/UI/LibraryFilters/index.tsx index 421483eeeb..2877f3f4a8 100644 --- a/src/frontend/components/UI/LibraryFilters/index.tsx +++ b/src/frontend/components/UI/LibraryFilters/index.tsx @@ -16,7 +16,8 @@ const RunnerToStore = { export default function LibraryFilters() { const { t } = useTranslation() - const { platform, epic, gog, amazon } = useContext(ContextProvider) + const { platform, epic, gog, amazon, customLibrary } = + useContext(ContextProvider) const { setShowFavourites, setShowHidden, diff --git a/src/frontend/components/UI/LibrarySearchBar/index.tsx b/src/frontend/components/UI/LibrarySearchBar/index.tsx index 088e3a9b42..79c519fa5a 100644 --- a/src/frontend/components/UI/LibrarySearchBar/index.tsx +++ b/src/frontend/components/UI/LibrarySearchBar/index.tsx @@ -14,11 +14,13 @@ function fixFilter(text: string) { const RUNNER_TO_STORE: Partial> = { legendary: 'Epic', gog: 'GOG', - nile: 'Amazon' + nile: 'Amazon', + customLibrary: 'Custom Library' } export default function LibrarySearchBar() { - const { epic, gog, sideloadedLibrary, amazon } = useContext(ContextProvider) + const { epic, gog, sideloadedLibrary, amazon, customLibrary } = + useContext(ContextProvider) const { handleSearch, filterText } = useContext(LibraryContext) const navigate = useNavigate() const { t } = useTranslation() @@ -28,7 +30,8 @@ export default function LibrarySearchBar() { ...(epic.library ?? []), ...(gog.library ?? []), ...(sideloadedLibrary ?? []), - ...(amazon.library ?? []) + ...(amazon.library ?? []), + ...(customLibrary ?? []) ] .filter(Boolean) .filter((el) => { diff --git a/src/frontend/helpers/electronStores.ts b/src/frontend/helpers/electronStores.ts index 6e8df88305..4851bcfa55 100644 --- a/src/frontend/helpers/electronStores.ts +++ b/src/frontend/helpers/electronStores.ts @@ -161,6 +161,11 @@ const downloadManagerStore = new TypeCheckedStoreFrontend('downloadManager', { name: 'download-manager' }) +const customLibraryStore = new CacheStore( + 'custom_library', + null +) + export { configStore, gogLibraryStore, @@ -172,5 +177,6 @@ export { wineDownloaderInfoStore, downloadManagerStore, nileLibraryStore, - nileConfigStore + nileConfigStore, + customLibraryStore } diff --git a/src/frontend/helpers/index.ts b/src/frontend/helpers/index.ts index f2958e1dbc..f91a817b07 100644 --- a/src/frontend/helpers/index.ts +++ b/src/frontend/helpers/index.ts @@ -132,6 +132,8 @@ const getStoreName = (runner: Runner, other: string) => { return 'GOG' case 'nile': return 'Amazon Games' + case 'customLibrary': + return 'Custom Library' default: return other } diff --git a/src/frontend/helpers/library.ts b/src/frontend/helpers/library.ts index 28abcaa6af..7c9077c87f 100644 --- a/src/frontend/helpers/library.ts +++ b/src/frontend/helpers/library.ts @@ -244,5 +244,6 @@ export const epicCategories = ['all', 'legendary', 'epic'] export const gogCategories = ['all', 'gog'] export const sideloadedCategories = ['all', 'sideload'] export const amazonCategories = ['all', 'nile', 'amazon'] +export const customLibraryCategories = ['all', 'customLibrary'] export { handleStopInstallation, install, launch, repair, updateGame } diff --git a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index b910b48f89..8bf0ee56e7 100644 --- a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx +++ b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx @@ -42,7 +42,8 @@ const DownloadManagerItem = ({ state, handleClearItem }: Props) => { - const { amazon, epic, gog, showDialogModal } = useContext(ContextProvider) + const { amazon, epic, gog, customLibrary, showDialogModal } = + useContext(ContextProvider) const { t } = useTranslation('gamepage') const { t: t2 } = useTranslation('translation') const isPaused = state && ['idle', 'paused'].includes(state) @@ -57,7 +58,12 @@ const DownloadManagerItem = ({ ) } - const library = [...epic.library, ...gog.library, ...amazon.library] + const library = [ + ...epic.library, + ...gog.library, + ...amazon.library, + ...customLibrary + ] const { params, addToQueueTime, endTime, type, startTime } = element const { diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index ce59484e05..9164965170 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -190,7 +190,7 @@ export default React.memo(function GamePage(): JSX.Element | null { : 'Windows') if ( - runner !== 'sideload' && + ['sideload', 'customLibrary'].includes(runner) === false && !notSupportedGame && !notInstallable && !thirdPartyManagedApp && diff --git a/src/frontend/screens/Library/index.tsx b/src/frontend/screens/Library/index.tsx index 0371f4be46..993ae4dd4f 100644 --- a/src/frontend/screens/Library/index.tsx +++ b/src/frontend/screens/Library/index.tsx @@ -22,6 +22,7 @@ import ErrorComponent from 'frontend/components/UI/ErrorComponent' import LibraryHeader from './components/LibraryHeader' import { amazonCategories, + customLibraryCategories, epicCategories, gogCategories, sideloadedCategories @@ -49,6 +50,7 @@ export default React.memo(function Library(): JSX.Element { gog, amazon, sideloadedLibrary, + customLibrary, favouriteGames, libraryTopSection, platform, @@ -83,7 +85,8 @@ export default React.memo(function Library(): JSX.Element { legendary: epicCategories.includes(storedCategory), gog: gogCategories.includes(storedCategory), nile: amazonCategories.includes(storedCategory), - sideload: sideloadedCategories.includes(storedCategory) + sideload: sideloadedCategories.includes(storedCategory), + customLibrary: customLibraryCategories.includes(storedCategory) } } @@ -541,7 +544,8 @@ export default React.memo(function Library(): JSX.Element { showSupportOfflineOnly, showThirdPartyManagedOnly, showUpdatesOnly, - gameUpdates + gameUpdates, + customLibrary ]) // select library diff --git a/src/frontend/state/ContextProvider.tsx b/src/frontend/state/ContextProvider.tsx index fd5ceea54b..480fc40873 100644 --- a/src/frontend/state/ContextProvider.tsx +++ b/src/frontend/state/ContextProvider.tsx @@ -27,6 +27,7 @@ const initialContext: ContextType = { }, installingEpicGame: false, sideloadedLibrary: [], + customLibrary: [], error: false, gameUpdates: [], libraryStatus: [], diff --git a/src/frontend/state/GlobalState.tsx b/src/frontend/state/GlobalState.tsx index cc876ccc2d..47356d74cf 100644 --- a/src/frontend/state/GlobalState.tsx +++ b/src/frontend/state/GlobalState.tsx @@ -11,7 +11,8 @@ import { WineVersionInfo, LibraryTopSectionOptions, ExperimentalFeatures, - Status + Status, + CustomLibraryGameInfo } from 'common/types' import { DialogModalOptions, @@ -33,7 +34,8 @@ import { nileConfigStore, nileLibraryStore, wineDownloaderInfoStore, - sideloadLibrary + sideloadLibrary, + customLibraryStore } from '../helpers/electronStores' import { IpcRendererEvent } from 'electron' import { NileRegisterData } from 'common/types/nile' @@ -93,6 +95,7 @@ interface StateProps { dialogModalOptions: DialogModalOptions externalLinkDialogOptions: ExternalLinkDialogOptions sideloadedLibrary: GameInfo[] + customLibrary: CustomLibraryGameInfo[] hideChangelogsOnStartup: boolean lastChangelogShown: string | null showInstallModal: { @@ -199,6 +202,7 @@ class GlobalState extends PureComponent { gameInfo: null }, sideloadedLibrary: sideloadLibrary.get('games', []), + customLibrary: customLibraryStore.get('games', []), dialogModalOptions: { showDialog: false }, externalLinkDialogOptions: { showDialog: false }, hideChangelogsOnStartup: globalSettings?.hideChangelogsOnStartup || false, @@ -613,6 +617,7 @@ class GlobalState extends PureComponent { } const updatedSideload = sideloadLibrary.get('games', []) + const updatedCustomLibrary = customLibraryStore.get('games', []) this.setState({ epic: { @@ -631,7 +636,8 @@ class GlobalState extends PureComponent { gameUpdates: updates, refreshing: false, refreshingInTheBackground: true, - sideloadedLibrary: updatedSideload + sideloadedLibrary: updatedSideload, + customLibrary: updatedCustomLibrary }) if (currentLibraryLength !== epicLibrary.length) { diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 8d42d39f8f..4aef149f21 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -14,11 +14,18 @@ import { WikiInfo, ExtraInfo, Status, - InstallInfo + InstallInfo, + CustomLibraryGameInfo } from 'common/types' import { NileLoginData, NileRegisterData } from 'common/types/nile' -export type Category = 'all' | 'legendary' | 'gog' | 'sideload' | 'nile' +export type Category = + | 'all' + | 'legendary' + | 'gog' + | 'sideload' + | 'nile' + | 'customLibrary' export interface ContextType { error: boolean @@ -100,6 +107,7 @@ export interface ContextType { externalLinkDialogOptions: ExternalLinkDialogOptions handleExternalLinkDialog: (options: ExternalLinkDialogOptions) => void sideloadedLibrary: GameInfo[] + customLibrary: CustomLibraryGameInfo[] hideChangelogsOnStartup: boolean setHideChangelogsOnStartup: (value: boolean) => void lastChangelogShown: string | null From 958b32d23f462606b53d44db9fa0d8e16c58e49c Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 10 Aug 2025 21:16:04 +0200 Subject: [PATCH 07/33] Added settings screen with fields for adding urls & JSON --- public/locales/en/translation.json | 12 +- src/backend/config.ts | 1 + src/common/types.ts | 2 + .../components/UI/LibraryFilters/index.tsx | 86 +++++++--- .../Sidebar/components/SidebarLinks/index.tsx | 7 + src/frontend/screens/Library/index.tsx | 49 ++++-- .../Settings/components/CustomLibraryUrls.css | 38 ++++ .../Settings/components/CustomLibraryUrls.tsx | 162 ++++++++++++++++++ .../screens/Settings/components/index.ts | 1 + src/frontend/screens/Settings/index.tsx | 5 +- .../CustomLibrariesSettings/index.tsx | 16 ++ .../screens/Settings/sections/index.tsx | 1 + src/frontend/types.ts | 2 + 13 files changed, 342 insertions(+), 40 deletions(-) create mode 100644 src/frontend/screens/Settings/components/CustomLibraryUrls.css create mode 100644 src/frontend/screens/Settings/components/CustomLibraryUrls.tsx create mode 100644 src/frontend/screens/Settings/sections/CustomLibrariesSettings/index.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 333be7ec99..669ec0dd98 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -567,6 +567,14 @@ "placeHolderKey": "New Wrapper", "placeHolderValue": "Wrapper Arguments", "title": "Wrapper command:" + }, + "custom_library_urls": { + "info": "Configure custom game library URLs to load additional games into Heroic.", + "example": "Example:: https://raw.githubusercontent.com/unbelievableflavour/games-repo-test/refs/heads/main/freeware_library.json", + "name": "Name", + "url": "URL", + "name_placeholder": "Library Name", + "url_placeholder": "https://example.com/library.json" } }, "other": { @@ -638,6 +646,7 @@ }, "custom_themes_path": "Custom Themes Path", "customWineProton": "Custom Wine/Proton Paths", + "custom_library_urls": "Custom Library URLs", "darktray": "Use Dark Tray Icon", "default-install-path": "Default Installation Path", "default-steam-path": "Default Steam path", @@ -873,7 +882,8 @@ "log": "Log", "other": "Other", "sync": "Cloud Saves Sync", - "systemInformation": "System Information" + "systemInformation": "System Information", + "custom_libraries": "Custom Libraries" }, "offline": { "warning": "This game does not explicitly allow offline mode, turn this on at your own risk. The game may not work." diff --git a/src/backend/config.ts b/src/backend/config.ts index ffe62b6628..073e14e203 100644 --- a/src/backend/config.ts +++ b/src/backend/config.ts @@ -331,6 +331,7 @@ class GlobalConfigV0 extends GlobalConfig { checkForUpdatesOnStartup: !isFlatpak, autoUpdateGames: false, customWinePaths: [], + customLibraryUrls: [], defaultInstallPath: heroicInstallPath, libraryTopSection: 'disabled', defaultSteamPath: getSteamCompatFolder(), diff --git a/src/common/types.ts b/src/common/types.ts index 52b6d19497..82309ed99d 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -96,6 +96,8 @@ export interface AppSettings extends GameSettings { customCSS: string customThemesPath: string customWinePaths: string[] + customLibraryUrls: string[] + customLibraryConfigs: string[] darkTrayIcon: boolean defaultInstallPath: string defaultSteamPath: string diff --git a/src/frontend/components/UI/LibraryFilters/index.tsx b/src/frontend/components/UI/LibraryFilters/index.tsx index 2877f3f4a8..9ce8de3b5c 100644 --- a/src/frontend/components/UI/LibraryFilters/index.tsx +++ b/src/frontend/components/UI/LibraryFilters/index.tsx @@ -1,19 +1,29 @@ -import { useContext } from 'react' +import { useContext, useMemo } from 'react' import ToggleSwitch from '../ToggleSwitch' import { useTranslation } from 'react-i18next' import LibraryContext from 'frontend/screens/Library/LibraryContext' -import { Category, PlatformsFilters } from 'frontend/types' +import { PlatformsFilters, StoresFilters } from 'frontend/types' import ContextProvider from 'frontend/state/ContextProvider' -import type { Runner } from 'common/types' +import type { CustomLibraryGameInfo } from 'common/types' import './index.css' -const RunnerToStore = { +const RunnerToStore: Record = { legendary: 'Epic Games', gog: 'GOG', nile: 'Amazon Games', sideload: 'Other' } +const getCustomLibraries = (customLibrary: CustomLibraryGameInfo[]) => { + const libraries = new Map() + customLibrary.forEach((game) => { + if (game?.customLibraryId && game?.customLibraryName) { + libraries.set(game.customLibraryId, game.customLibraryName) + } + }) + return libraries +} + export default function LibraryFilters() { const { t } = useTranslation() const { platform, epic, gog, amazon, customLibrary } = @@ -39,6 +49,22 @@ export default function LibraryFilters() { setShowUpdatesOnly } = useContext(LibraryContext) + const customLibraries = useMemo( + () => getCustomLibraries(customLibrary), + [customLibrary] + ) + + // Ensure custom library filters exist in storesFilters + const ensureCustomFilters = (filters: Record) => { + const newFilters: Record = { ...filters } + customLibraries.forEach((name, id) => { + if (!(id in newFilters)) { + newFilters[id] = true + } + }) + return newFilters + } + const toggleShowHidden = () => { setShowHidden(!showHidden) } @@ -67,10 +93,11 @@ export default function LibraryFilters() { setShowUpdatesOnly(!showUpdatesOnly) } - const toggleStoreFilter = (store: Runner) => { - const currentValue = storesFilters[store] - const newFilters = { ...storesFilters, [store]: !currentValue } - setStoresFilters(newFilters) + const toggleStoreFilter = (store: string) => { + const currentFilters = ensureCustomFilters(storesFilters) + const currentValue = currentFilters[store] + const newFilters = { ...currentFilters, [store]: !currentValue } + setStoresFilters(newFilters as StoresFilters) } const togglePlatformFilter = (plat: keyof PlatformsFilters) => { @@ -84,15 +111,20 @@ export default function LibraryFilters() { newFilters = { ...newFilters, [plat]: true } setPlatformsFilters(newFilters) } - const setStoreOnly = (store: Category) => { - let newFilters = { - legendary: false, - gog: false, - nile: false, - sideload: false - } - newFilters = { ...newFilters, [store]: true } - setStoresFilters(newFilters) + + const setStoreOnly = (store: string) => { + const currentFilters = ensureCustomFilters(storesFilters) + const newFilters: Record = {} + + // Set all filters to false + Object.keys(currentFilters).forEach((key) => { + newFilters[key] = false + }) + + // Set the selected store to true + newFilters[store] = true + + setStoresFilters(newFilters as StoresFilters) } const toggleWithOnly = (toggle: JSX.Element, onOnlyClicked: () => void) => { @@ -132,14 +164,15 @@ export default function LibraryFilters() { // t('GOG', 'GOG') // t('Amazon Games', 'Amazon Games') // t('Other', 'Other') - const storeToggle = (store: Runner) => { + const storeToggle = (store: string, displayName?: string) => { + const currentFilters = ensureCustomFilters(storesFilters) const toggle = ( toggleStoreFilter(store)} - value={storesFilters[store]} - title={t(RunnerToStore[store])} + value={currentFilters[store] ?? true} + title={displayName || t(RunnerToStore[store])} /> ) const onOnlyClick = () => { @@ -149,12 +182,16 @@ export default function LibraryFilters() { } const resetFilters = () => { - setStoresFilters({ + const baseFilters: Record = { legendary: true, gog: true, nile: true, sideload: true - }) + } + + const newFilters = ensureCustomFilters(baseFilters) + + setStoresFilters(newFilters as StoresFilters) setPlatformsFilters({ win: true, linux: true, @@ -179,6 +216,11 @@ export default function LibraryFilters() { {amazon.user_id && storeToggle('nile')} {storeToggle('sideload')} + {/* Custom library filters */} + {Array.from(customLibraries.entries()).map(([libraryId, libraryName]) => + storeToggle(libraryId, libraryName) + )} +
{platformToggle('win')} diff --git a/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx b/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx index e352a5d336..f7dbf56d9e 100644 --- a/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx +++ b/src/frontend/components/UI/Sidebar/components/SidebarLinks/index.tsx @@ -166,6 +166,13 @@ export default function SidebarLinks() { /> )} + + `${game.app_name}_${game.runner}`) }, [favourites]) - const makeLibrary = () => { - let displayedStores: string[] = [] - if (storesFilters['gog'] && gog.username) { - displayedStores.push('gog') - } - if (storesFilters['legendary'] && epic.username) { - displayedStores.push('legendary') - } - if (storesFilters['nile'] && amazon.username) { - displayedStores.push('nile') - } - if (storesFilters['sideload']) { - displayedStores.push('sideload') - } + const makeLibrary = (): Array => { + // Get all available custom library IDs from the customLibrary games + const customLibraryIds = new Set() + customLibrary.forEach((game) => { + if (game?.customLibraryId) { + customLibraryIds.add(game.customLibraryId) + } + }) + + // Ensure storesFilters includes all custom library filters + const allStoreFilters = { ...storesFilters } + customLibraryIds.forEach((id) => { + if (!(id in allStoreFilters)) { + allStoreFilters[id] = true // Default to true for new custom libraries + } + }) + + let displayedStores = Object.keys(allStoreFilters).filter( + (store) => allStoreFilters[store] + ) if (!displayedStores.length) { - displayedStores = Object.keys(storesFilters) + displayedStores = Object.keys(allStoreFilters) } const showEpic = epic.username && displayedStores.includes('legendary') @@ -405,12 +411,23 @@ export default React.memo(function Library(): JSX.Element { const showAmazon = amazon.user_id && displayedStores.includes('nile') const showSideloaded = displayedStores.includes('sideload') + // Filter custom games based on their library IDs + const customGames = customLibrary.filter((game) => + displayedStores.includes(game.customLibraryId) + ) + const epicLibrary = showEpic ? epic.library : [] const gogLibrary = showGog ? gog.library : [] const sideloadedApps = showSideloaded ? sideloadedLibrary : [] const amazonLibrary = showAmazon ? amazon.library : [] - return [...sideloadedApps, ...epicLibrary, ...gogLibrary, ...amazonLibrary] + return [ + ...sideloadedApps, + ...epicLibrary, + ...gogLibrary, + ...amazonLibrary, + ...customGames + ] } const gamesForAlphabetFilter = useMemo(() => { diff --git a/src/frontend/screens/Settings/components/CustomLibraryUrls.css b/src/frontend/screens/Settings/components/CustomLibraryUrls.css new file mode 100644 index 0000000000..8249bf272c --- /dev/null +++ b/src/frontend/screens/Settings/components/CustomLibraryUrls.css @@ -0,0 +1,38 @@ +.customLibraryUrl { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-sm); + width: 100%; +} + +.removeButton, +.addButton { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: var(--space-xs); +} + +.customLibraryConfig { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + margin-bottom: var(--space-sm); + width: 100%; +} + +.customLibraryConfigTextarea { + flex: 1; + min-height: 200px; + padding: var(--space-sm); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--background); + color: var(--text-primary); + resize: vertical; + overflow-y: scroll; +} diff --git a/src/frontend/screens/Settings/components/CustomLibraryUrls.tsx b/src/frontend/screens/Settings/components/CustomLibraryUrls.tsx new file mode 100644 index 0000000000..782542959d --- /dev/null +++ b/src/frontend/screens/Settings/components/CustomLibraryUrls.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { InfoBox, TextInputField, SvgButton } from 'frontend/components/UI' +import AddBoxIcon from '@mui/icons-material/AddBox' +import RemoveCircleIcon from '@mui/icons-material/RemoveCircle' +import useSetting from 'frontend/hooks/useSetting' +import './CustomLibraryUrls.css' + +const CustomLibraryUrls = () => { + const { t } = useTranslation() + const [customLibraryUrls, setCustomLibraryUrls] = useSetting( + 'customLibraryUrls', + [] + ) + const [customLibraryConfigs, setCustomLibraryConfigs] = useSetting( + 'customLibraryConfigs', + [] + ) + const [newUrl, setNewUrl] = useState('') + const [newJsonConfig, setNewJsonConfig] = useState('') + + const addUrl = () => { + if (newUrl.trim() && newUrl.match(/^https?:\/\/.+/)) { + setCustomLibraryUrls([...customLibraryUrls, newUrl.trim()]) + setNewUrl('') + } + } + + const removeUrl = (index: number) => { + const updated = customLibraryUrls.filter((_, i) => i !== index) + setCustomLibraryUrls(updated) + } + + const updateUrl = (index: number, value: string) => { + const updated = [...customLibraryUrls] + updated[index] = value + setCustomLibraryUrls(updated) + } + + const addJsonConfig = () => { + if (newJsonConfig.trim()) { + try { + // Validate JSON + JSON.parse(newJsonConfig.trim()) + setCustomLibraryConfigs([...customLibraryConfigs, newJsonConfig.trim()]) + setNewJsonConfig('') + } catch (error) { + // Handle invalid JSON - you might want to show an error message + console.error('Invalid JSON:', error) + } + } + } + + const removeJsonConfig = (index: number) => { + const updated = customLibraryConfigs.filter((_, i) => i !== index) + setCustomLibraryConfigs(updated) + } + + const updateJsonConfig = (index: number, value: string) => { + const updated = [...customLibraryConfigs] + updated[index] = value + setCustomLibraryConfigs(updated) + } + + const customLibraryUrlsInfo = ( + + {t( + 'options.custom_library_urls.info', + 'Add URLs to JSON files containing custom game libraries, or paste JSON content directly. The library name will be taken from the JSON file.' + )} +
+ {t( + 'options.custom_library_urls.example', + 'Example URL: https://example.com/my-games-library.json' + )} +
+ ) + + return ( +
+ + + {customLibraryUrlsInfo} + + {/* URLs Section */} +

{t('setting.custom_library_urls', 'Library URLs')}

+ + {/* Existing URLs */} + {customLibraryUrls.map((url: string, index: number) => ( +
+ updateUrl(index, value)} + placeholder="https://example.com/library.json" + extraClass="customLibraryUrlInput" + /> + removeUrl(index)} className="removeButton"> + + +
+ ))} + + {/* Add new URL */} +
+ e.key === 'Enter' && addUrl()} + /> + + + +
+ + {/* JSON Configs Section */} +

+ {t('setting.custom_library_configs', 'Direct JSON Configurations')} +

+ + {/* Existing JSON configs */} + {customLibraryConfigs.map((config: string, index: number) => ( +
+