diff --git a/doc/custom_libraries.md b/doc/custom_libraries.md new file mode 100644 index 0000000000..8c85017e23 --- /dev/null +++ b/doc/custom_libraries.md @@ -0,0 +1,399 @@ +# Custom Game Libraries for Heroic Games Launcher + +## Overview + +This feature adds support for custom JSON-based game libraries to Heroic Games Launcher. You can now add games from any source by providing JSON configuration files that describe your games, their installation tasks, and launch information. + +## How It Works + +Custom libraries are JSON files that contain metadata about your games. Heroic loads these configurations through URLs or direct JSON input in the settings, and adds the games to your library alongside Epic, GOG, Amazon, and sideloaded games. + +## Setup + +Custom libraries are configured through Heroic's settings interface: + +1. **Open Settings**: Go to Settings → General → Custom Libraries +2. **Add Library URLs**: Add HTTP/HTTPS URLs pointing to JSON files OR **Add Direct JSON**: Paste JSON configurations directly into the settings +3. **Save Settings**: Libraries will automatically load after saving (make sure to click the + sign). + +## JSON Schema + +### Library Structure + +```json +{ + "name": "Library Name", + "description": "Optional description of this library", + "games": [ + // Array of game objects + ] +} +``` + +### Game Object Properties + +| Property | Type | Required | Description | +| --------------------- | ------------------- | -------- | -------------------------------------------------------------------- | +| `app_name` | string | ✅ | Unique identifier for the game (will be prefixed with `custom_`) | +| `title` | string | ✅ | Display name of the game | +| `executable` | string | ✅ | Path to the executable or URL for browser games | +| `art_cover` | string | ❌ | URL or file path to cover art image | +| `art_square` | string | ❌ | URL or file path to square icon (fallback to cover art) | +| `description` | string | ❌ | Game description | +| `version` | string | ❌ | Game version | +| `developer` | string | ❌ | Developer name | +| `release_date` | string | ❌ | Release date (ISO format recommended) | +| `platform` | string | ❌ | Platform: `"Windows"`, `"Mac"`, `"Linux"`, or `"Browser"` | +| `install_tasks` | CustomLibraryTask[] | ✅ | Array of tasks to execute during installation | +| `uninstall_tasks` | CustomLibraryTask[] | ✅ | Array of tasks to execute during uninstallation | +| `gamesdb_credentials` | object | ❌ | Overwrite fetched metadata with custom info from GamesDB (see below) | +| `genres` | string[] | ❌ | Array of game genres | +| `launch_options` | LaunchOption[] | ❌ | Array of launch options for the game | +| `parameters` | string | ❌ | Command-line parameters to pass to the game when launching | +| `launch_from_cmd` | boolean | ❌ | Whether to launch the game from command line | + +### Custom Library Tasks + +Custom libraries use tasks to handle installation and uninstallation processes. Each task has a specific type and purpose. + +#### Task Types + +##### Download Task + +Downloads files from a URL during installation. + +| Property | Type | Required | Description | +| ------------- | ------ | -------- | ----------------------------------------------- | +| `type` | string | ✅ | Must be `"download"` | +| `url` | string | ✅ | URL to download from | +| `filename` | string | ❌ | Custom filename (auto-detected if not provided) | +| `destination` | string | ❌ | Destination path within game folder | + +**Example:** + +```json +{ + "type": "download", + "url": "https://some.com/installer.zip", + "filename": "installer.zip", + "destination": "downloads" +} +``` + +##### Extract Task + +Extracts compressed files (ZIP, TAR, etc.). + +| Property | Type | Required | Description | +| ------------- | ------ | -------- | --------------------------------------------- | +| `type` | string | ✅ | Must be `"extract"` | +| `source` | string | ✅ | Path to archive file within game folder | +| `destination` | string | ❌ | Extraction destination (game folder if empty) | + +**Example:** + +```json +{ + "type": "extract", + "source": "downloads/installer.zip", + "destination": "game_files" +} +``` + +##### Run Task + +Executes commands or installers. + +| Property | Type | Required | Description | +| ------------ | -------- | -------- | ------------------------------- | +| `type` | string | ✅ | Must be `"run"` | +| `executable` | string | ✅ | Path to executable or command | +| `args` | string[] | ❌ | Array of command line arguments | + +**Example:** + +```json +{ + "type": "run", + "executable": "game_files/setup.exe", + "args": ["/SILENT", "/NORESTART"] +} +``` + +##### Move Task + +Moves or copies files and folders. + +| Property | Type | Required | Description | +| ------------- | ------ | -------- | ----------------------------------- | +| `type` | string | ✅ | Must be `"move"` | +| `source` | string | ✅ | Source path within game folder | +| `destination` | string | ✅ | Destination path within game folder | + +**Example:** + +```json +{ + "type": "move", + "source": "temp/extracted_files", + "destination": "final_location" +} +``` + +### Launch Options + +Launch options provide different ways to start the game. Each option can be one of three types: + +#### Basic Launch Option + +Adds extra parameters to the default launch command. + +| Property | Type | Required | Description | +| ------------ | ------ | -------- | ------------------------------ | +| `type` | string | ❌ | `"basic"` (default if omitted) | +| `name` | string | ✅ | Display name for the option | +| `parameters` | string | ✅ | Command line parameters | + +#### Alternative Executable Launch Option + +Launches a different executable instead of the default one. + +| Property | Type | Required | Description | +| ------------ | ------ | -------- | ------------------------------ | +| `type` | string | ✅ | Must be `"altExe"` | +| `executable` | string | ✅ | Path to alternative executable | + +### GamesDB Integration + +The `gamesdb_credentials` property allows you to specify a particular store and game ID to fetch metadata from GamesDB. When provided, this will override any automatically discovered metadata, ensuring you get the exact game information from the specified store listing. + +#### GamesDB Credentials Object + +| Property | Type | Required | Description | +| -------- | ------ | -------- | ------------------------------ | +| `store` | string | ✅ | The store platform identifier | +| `id` | string | ✅ | The game's ID on that platform | + +#### Supported Stores + +- **`"steam"`** - Steam Store (use Steam App ID) +- **`"gog"`** - GOG.com (use GOG Game ID) +- **`"epic"`** - Epic Games Store (use Epic namespace/catalog ID) +- **`"itch"`** - itch.io (use itch.io game ID) +- **`"humble"`** - Humble Bundle (use Humble game ID) +- **`"uplay"`** - Ubisoft Connect/Uplay (use Uplay ID) + +#### What GamesDB Provides + +When `gamesdb_credentials` is specified, Heroic will fetch and use: + +✅ **Cover Art** - High-quality game logo/header images +✅ **Square Icons** - Vertical cover art for grid view +✅ **Descriptions** - Game summaries and descriptions +✅ **Genres** - Game category information + +**Note**: Manually specified properties (`art_cover`, `description`, etc.) take precedence over GamesDB data. + +### Platform Support + +- **Windows**: `.exe` files and Windows applications +- **Mac**: `.app` bundles and macOS applications +- **Linux**: Executable files and shell scripts + +## Example + +### Real example + +The following (real) example demonstrates adding the freeware version of Cave Story: + +```json +{ + "name": "Your first custom library", + "games": [ + { + "app_name": "cave_story", + "title": "Cave Story", + "executable": "CaveStory/Doukutsu.exe", + "platform": "Windows", + "version": "1.0.0", + "install_tasks": [ + { + "type": "download", + "url": "https://www.cavestory.org/downloads/cavestoryen.zip" + }, + { "type": "extract", "source": "cavestoryen.zip" } + ], + "launch_options": [ + { + "type": "altExe", + "name": "Org View", + "executable": "CaveStory/OrgView.exe" + }, + { + "type": "altExe", + "name": "Configuration Tool", + "executable": "CaveStory/DoConfig.exe" + } + ] + } + ] +} +``` + +### Fake example with more options + +The following (fake) example demonstrates: + +**Install Tasks:** + +1. **Download**: Downloads the game installer ZIP from a URL to a `downloads` subfolder +2. **Extract**: Extracts the downloaded ZIP file to a `temp` subfolder +3. **Run**: Executes the installer with silent installation arguments +4. **Move**: Moves additional game assets to their final location + +**Uninstall Tasks:** + +1. **Run**: Executes the game's uninstaller silently +2. **Run**: Runs a cleanup script to remove any remaining files + +The tasks are executed in order during installation/uninstallation. All file paths are relative to the game's folder that Heroic creates. + +```json +{ + "name": "Installed Windows Games", + "games": [ + { + "app_name": "my_game", + "title": "My Game", + "executable": "/My Game/game.exe", + "platform": "Windows", + "install_tasks": [ + { + "type": "download", + "url": "https://example.com/releases/my-game-v1.2.3-installer.zip", + "filename": "my-game-installer.zip", + "destination": "downloads" + }, + { + "type": "extract", + "source": "downloads/my-game-installer.zip", + "destination": "temp" + }, + { + "type": "run", + "executable": "temp/setup.exe", + "args": ["/SILENT", "/NORESTART", "/DIR=C:\\Games\\MyGame"] + }, + { + "type": "move", + "source": "temp/game_assets", + "destination": "assets" + } + ], + "uninstall_tasks": [ + { "type": "run", "executable": "uninstall.exe", "args": ["/SILENT"] }, + { "type": "run", "executable": "cleanup.bat" } + ] + } + ] +} +``` + +## Troubleshooting + +When games are not appearing: + +- Check JSON syntax is valid +- Verify file is in correct custom libraries directory +- Ensure `executable` path exists +- Restart Heroic after adding new files + +## Running installers without user interaction and in the background. + +It can be quite tricky to get an installer to run without user interaction. But usually it's possible. You'll often need to determine the correct installation parameters. (Parameters like `silent` and `destination`). + +### Installer Docs + +Some installers have documentation that makes it a little easier. + +- **NSIS Installers**: https://nsis.sourceforge.io/Docs/Chapter3.html#installerusagecommon + +### Installer Manuals + +Most Windows installers support an arg to show all installer options. (IMPORTANT: NOT ALL have. Sometimes you have to guess the args.) + +Manual flag examples: + +```bash +wine installer.exe /? +wine installer.exe --help +wine installer.exe /help +wine installer.exe -h +wine installer.exe /h +wine installer.exe /H +wine installer.exe /HELP +wine installer.exe /CMDHELP +``` + +### Install destination + +It's usually possible to set the destination. + +- **NSIS Installers**: `/D=/your/destination` +- **Inno Setup**: `/DIR=/your/destination` +- **7z self extracting archive**: `-o/your/destination` + +Destination flag examples: + +```bash +wine installer.exe /DIR=/your/destination +wine installer.exe /D=/your/destination +wine installer.exe -o/your/destination +``` + +### Run silently + +Most Windows installers support silent or unattended installation modes. Common silent flags include: + +- **NSIS Installers**: `/S` (capital S) +- **InstallShield**: `/s` or `/silent` +- **MSI Packages**: `/quiet` or `/qn` +- **Inno Setup**: `/SILENT` or `/VERYSILENT` +- **Nullsoft**: `/S` + +Silent flag examples: + +```bash +wine installer.exe /S +wine installer.exe /silent +wine installer.exe /SILENT +wine installer.exe /VERYSILENT +``` + +## Finding Game IDs for GamesDB + +### Steam IDs + +1. Visit the game's Steam store page +2. Look at the URL: `https://store.steampowered.com/app/[ID]/` +3. Use the number as the `id` value + +**Example**: Portal → `https://store.steampowered.com/app/220/` → ID: `"220"` + +### GOG IDs + +1. Visit the game's GOG store page +2. Look at the URL: `https://www.gog.com/game/[game-name]` +3. Use browser developer tools to inspect the page source +4. Search for `"product":{"id":` to find the numeric ID + +### Epic Games IDs + +1. Use Epic's GraphQL API or community databases +2. Look for the catalog item ID or namespace/catalog combination + +### Finding Other Store IDs + +- **itch.io**: Game ID from the URL or API +- **Humble**: Game identifier from Humble Bundle +- **Uplay**: Ubisoft's internal game ID diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 333be7ec99..854044506e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -527,6 +527,13 @@ "title": "Environment Variables", "value": "Value" }, + "custom_library_urls": { + "configs": "Direct JSON Configurations", + "example": "Example:: https://raw.githubusercontent.com/unbelievableflavour/games-repo-test/refs/heads/main/freeware_library.json", + "info": "Configure custom game library URLs to load additional games into Heroic.", + "title": "Custom Libraries", + "urls": "Library URLs" + }, "env_variables": { "error": { "empty_key": "Variable names can't be empty", @@ -867,6 +874,7 @@ }, "navbar": { "advanced": "Advanced", + "custom_libraries": "Custom Libraries", "games_settings_defaults": "Game Defaults", "gamescope": "Gamescope", "general": "General", diff --git a/src/backend/__tests__/storeManagers/customLibraries/downloadTask.test.ts b/src/backend/__tests__/storeManagers/customLibraries/downloadTask.test.ts new file mode 100644 index 0000000000..5fddbb92d8 --- /dev/null +++ b/src/backend/__tests__/storeManagers/customLibraries/downloadTask.test.ts @@ -0,0 +1,176 @@ +import { executeDownloadTask } from 'backend/storeManagers/customLibraries/tasks/downloadTask' +import { DownloadTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { join } from 'path' +import { downloadFile, sendProgressUpdate } from 'backend/utils' + +jest.mock('backend/utils') + +const mockDownloadFile = downloadFile as jest.MockedFunction< + typeof downloadFile +> +const mockSendProgressUpdate = sendProgressUpdate as jest.MockedFunction< + typeof sendProgressUpdate +> + +describe('DownloadTask - executeDownloadTask', () => { + const appName = 'test-game' + const gameFolder = '/path/to/game' + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('downloads file with provided filename', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/file.zip', + filename: 'custom-file.zip', + destination: 'downloads' + } + + mockDownloadFile.mockResolvedValue() + + await executeDownloadTask(appName, task, gameFolder) + + expect(mockDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/file.zip', + dest: join(gameFolder, 'downloads', 'custom-file.zip'), + progressCallback: expect.any(Function) + }) + }) + + test('downloads file without destination (uses game folder)', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/file.zip', + filename: 'file.zip' + } + + mockDownloadFile.mockResolvedValue() + + await executeDownloadTask(appName, task, gameFolder) + + expect(mockDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/file.zip', + dest: join(gameFolder, 'file.zip'), + progressCallback: expect.any(Function) + }) + }) + + test('determines filename from URL when not provided', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/path/to/installer.exe' + } + + mockDownloadFile.mockResolvedValue() + + await executeDownloadTask(appName, task, gameFolder) + + expect(mockDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/path/to/installer.exe', + dest: join(gameFolder, 'installer.exe'), + progressCallback: expect.any(Function) + }) + }) + + test('extracts filename from URL parameters when path filename is a script', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/download.php?url=https%3A//cdn.example.com/game.zip' + } + + mockDownloadFile.mockResolvedValue() + + await executeDownloadTask(appName, task, gameFolder) + + expect(mockDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/download.php?url=https%3A//cdn.example.com/game.zip', + dest: join(gameFolder, 'game.zip'), + progressCallback: expect.any(Function) + }) + }) + + test('uses path filename when available and not a script', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/redirect?url=https%3A//cdn.example.com/game.zip' + } + + mockDownloadFile.mockResolvedValue() + + await executeDownloadTask(appName, task, gameFolder) + + expect(mockDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/redirect?url=https%3A//cdn.example.com/game.zip', + dest: join(gameFolder, 'redirect'), + progressCallback: expect.any(Function) + }) + }) + + test('progress callback sends updates correctly', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/file.zip', + filename: 'file.zip' + } + + let progressCallback: + | (( + bytes: number, + speed: number, + percentage: number, + diskWriteSpeed: number + ) => void) + | undefined + + mockDownloadFile.mockImplementation(({ progressCallback: cb }) => { + progressCallback = cb + return Promise.resolve() + }) + + await executeDownloadTask(appName, task, gameFolder) + + // Simulate progress update + progressCallback!(50 * 1024 * 1024, 1000, 75, 500) // 50MB, 75%, 500 bytes/s write speed + + expect(mockSendProgressUpdate).toHaveBeenCalledWith({ + appName: 'test-game', + runner: 'customLibrary', + status: 'installing', + progress: { + bytes: '50MB', + eta: '', + percent: 75 + } + }) + }) + + test('handles download failure', async () => { + const task: DownloadTask = { + type: 'download', + url: 'https://example.com/file.zip', + filename: 'file.zip' + } + + const downloadError = new Error('Network error') + mockDownloadFile.mockRejectedValue(downloadError) + + await expect( + executeDownloadTask(appName, task, gameFolder) + ).rejects.toThrow('Network error') + }) + + test('handles invalid URL gracefully when determining filename', async () => { + const task: DownloadTask = { + type: 'download', + url: 'invalid-url' + } + + mockDownloadFile.mockResolvedValue() + + await expect( + executeDownloadTask(appName, task, gameFolder) + ).rejects.toThrow('Could not determine filename for download') + }) +}) diff --git a/src/backend/__tests__/storeManagers/customLibraries/extractTask.test.ts b/src/backend/__tests__/storeManagers/customLibraries/extractTask.test.ts new file mode 100644 index 0000000000..6e4a36ed7b --- /dev/null +++ b/src/backend/__tests__/storeManagers/customLibraries/extractTask.test.ts @@ -0,0 +1,163 @@ +import { executeExtractTask } from 'backend/storeManagers/customLibraries/tasks/extractTask' +import { ExtractTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { join } from 'path' +import { existsSync, rmSync } from 'graceful-fs' +import { extractFiles } from 'backend/utils' + +jest.mock('graceful-fs', () => ({ + existsSync: jest.fn(), + rmSync: jest.fn() +})) + +jest.mock('backend/utils', () => ({ + extractFiles: jest.fn() +})) + +const mockExistsSync = existsSync as jest.MockedFunction +const mockRmSync = rmSync as jest.MockedFunction +const mockExtractFiles = extractFiles as jest.MockedFunction< + typeof extractFiles +> + +describe('ExtractTask - executeExtractTask', () => { + const gameFolder = '/path/to/game' + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('extracts file successfully with destination', async () => { + const task: ExtractTask = { + type: 'extract', + source: 'archive.zip', + destination: 'extracted' + } + + mockExistsSync.mockReturnValue(true) + mockExtractFiles.mockResolvedValue({ + status: 'done', + installPath: '/path/to/destination' + }) + + await executeExtractTask(task, gameFolder) + + expect(mockExistsSync).toHaveBeenCalledWith(join(gameFolder, 'archive.zip')) + expect(mockExtractFiles).toHaveBeenCalledWith({ + path: join(gameFolder, 'archive.zip'), + destination: join(gameFolder, 'extracted'), + strip: 0 + }) + expect(mockRmSync).toHaveBeenCalledWith(join(gameFolder, 'archive.zip')) + }) + + test('extracts file successfully without destination', async () => { + const task: ExtractTask = { + type: 'extract', + source: 'archive.tar.gz' + } + + mockExistsSync.mockReturnValue(true) + mockExtractFiles.mockResolvedValue({ + status: 'done', + installPath: '/path/to/destination' + }) + + await executeExtractTask(task, gameFolder) + + expect(mockExistsSync).toHaveBeenCalledWith( + join(gameFolder, 'archive.tar.gz') + ) + expect(mockExtractFiles).toHaveBeenCalledWith({ + path: join(gameFolder, 'archive.tar.gz'), + destination: join(gameFolder, ''), + strip: 0 + }) + expect(mockRmSync).toHaveBeenCalledWith(join(gameFolder, 'archive.tar.gz')) + }) + + test('throws error when source file does not exist', async () => { + const task: ExtractTask = { + type: 'extract', + source: 'nonexistent.zip' + } + + mockExistsSync.mockReturnValue(false) + + await expect(executeExtractTask(task, gameFolder)).rejects.toThrow( + `Source file not found: ${join(gameFolder, 'nonexistent.zip')}` + ) + + expect(mockExtractFiles).not.toHaveBeenCalled() + expect(mockRmSync).not.toHaveBeenCalled() + }) + + test('throws error when extractFiles returns error status', async () => { + const task: ExtractTask = { + type: 'extract', + source: 'archive.zip' + } + + mockExistsSync.mockReturnValue(true) + mockExtractFiles.mockResolvedValue({ + status: 'error', + error: 'Unsupported archive format' + }) + + await expect(executeExtractTask(task, gameFolder)).rejects.toThrow( + 'Extraction failed: Unsupported archive format' + ) + + expect(mockExtractFiles).toHaveBeenCalled() + expect(mockRmSync).not.toHaveBeenCalled() + }) + + test('handles extractFiles throwing an error', async () => { + const task: ExtractTask = { + type: 'extract', + source: 'archive.zip' + } + + mockExistsSync.mockReturnValue(true) + mockExtractFiles.mockRejectedValue(new Error('Extraction command failed')) + + await expect(executeExtractTask(task, gameFolder)).rejects.toThrow( + 'Extraction command failed' + ) + + expect(mockExtractFiles).toHaveBeenCalled() + expect(mockRmSync).not.toHaveBeenCalled() + }) + + test('uses correct paths for different archive types', async () => { + const testCases = [ + { source: 'file.zip', destination: 'output' }, + { source: 'file.tar.gz', destination: undefined }, + { source: 'file.7z', destination: 'extracted' } + ] + + for (const testCase of testCases) { + jest.clearAllMocks() + + const task: ExtractTask = { + type: 'extract', + source: testCase.source, + ...(testCase.destination && { destination: testCase.destination }) + } + + mockExistsSync.mockReturnValue(true) + mockExtractFiles.mockResolvedValue({ + status: 'done', + installPath: '/some/path' + }) + + await executeExtractTask(task, gameFolder) + + const expectedDestination = testCase.destination || '' + expect(mockExtractFiles).toHaveBeenCalledWith({ + path: join(gameFolder, testCase.source), + destination: join(gameFolder, expectedDestination), + strip: 0 + }) + } + }) +}) diff --git a/src/backend/__tests__/storeManagers/customLibraries/moveTask.test.ts b/src/backend/__tests__/storeManagers/customLibraries/moveTask.test.ts new file mode 100644 index 0000000000..c28dce851d --- /dev/null +++ b/src/backend/__tests__/storeManagers/customLibraries/moveTask.test.ts @@ -0,0 +1,413 @@ +const mockEnvironment = { isWindows: false } +jest.mock('backend/constants/environment', () => mockEnvironment) + +import { + getSettings, + isNative, + getGameInfo +} from 'backend/storeManagers/customLibraries/games' +import { MoveTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { join, dirname, isAbsolute } from 'path' +import { existsSync, mkdirSync, rmSync, renameSync } from 'graceful-fs' +import { executeMoveTask } from 'backend/storeManagers/customLibraries/tasks/moveTask' +import { spawnAsync } from 'backend/utils' + +jest.mock('fs-extra') +jest.mock('graceful-fs') +jest.mock('path') + +jest.mock('backend/logger', () => ({ + logInfo: jest.fn(), + logError: jest.fn(), + LogPrefix: { + CustomLibrary: 'CustomLibrary' + } +})) + +jest.mock('backend/storeManagers/customLibraries/games', () => ({ + getSettings: jest.fn(), + isNative: jest.fn(), + getGameInfo: jest.fn() +})) + +jest.mock('backend/utils', () => ({ + spawnAsync: jest.fn() +})) + +const mockJoin = join as jest.MockedFunction +const mockDirname = dirname as jest.MockedFunction +const mockIsAbsolute = isAbsolute as jest.MockedFunction +const mockExistsSync = existsSync as jest.MockedFunction +const mockMkdirSync = mkdirSync as jest.MockedFunction +const mockRmSync = rmSync as jest.MockedFunction +const mockRenameSync = renameSync as jest.MockedFunction +const mockGetSettings = getSettings as jest.MockedFunction +const mockIsNative = isNative as jest.MockedFunction +const mockGetGameInfo = getGameInfo as jest.MockedFunction +const mockSpawnAsync = spawnAsync as jest.MockedFunction + +// Helper function to mock successful spawnAsync calls +const mockSuccessfulSpawn = () => { + mockSpawnAsync.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '' + }) +} + +// Helper function to mock failed spawnAsync calls +const mockFailedSpawn = (exitCode = 1, stderr = 'Mock error') => { + mockSpawnAsync.mockResolvedValue({ + code: exitCode, + stdout: '', + stderr + }) +} + +describe('MoveTask - executeMoveTask', () => { + const gameFolder = '/path/to/game' + const appName = 'test-game' + + beforeEach(() => { + jest.clearAllMocks() + + // Set up path mocks to work like real path module + mockJoin.mockImplementation((...args: string[]) => + args.filter(Boolean).join('/') + ) + mockDirname.mockImplementation((path: string) => { + const parts = path.split('/') + parts.pop() + return parts.join('/') || '/' + }) + + // Set up isAbsolute mock + mockIsAbsolute.mockImplementation((path: string) => { + return ( + path.startsWith('/') || + path.startsWith('C:') || + path.includes('drive_c') || + path.includes('/.wine/') || + path.includes('/steam/proton/') + ) + }) + + mockGetGameInfo.mockReturnValue({ + install: { platform: 'linux' } + } as any) + mockIsNative.mockReturnValue(true) + + // Default to Unix environment + mockEnvironment.isWindows = false + }) + + test('moves file successfully with relative paths on Unix', async () => { + const task: MoveTask = { + type: 'move', + source: 'temp/file.txt', + destination: 'final/file.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder, appName) + + // On Unix, should use renameSync, not spawnAsync + expect(mockRenameSync).toHaveBeenCalledWith( + '/path/to/game/temp/file.txt', + '/path/to/game/final/file.txt' + ) + expect(mockSpawnAsync).not.toHaveBeenCalled() + }) + + test('moves file with absolute paths on Unix', async () => { + const task: MoveTask = { + type: 'move', + source: '/absolute/source/file.txt', + destination: '/absolute/dest/file.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder, appName) + + expect(mockRenameSync).toHaveBeenCalledWith( + '/absolute/source/file.txt', + '/absolute/dest/file.txt' + ) + expect(mockSpawnAsync).not.toHaveBeenCalled() + }) + + test('creates destination directory if it does not exist', async () => { + const task: MoveTask = { + type: 'move', + source: 'file.txt', + destination: 'new/folder/file.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(false) // destination dir doesn't exist + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder, appName) + + expect(mockMkdirSync).toHaveBeenCalledWith('/path/to/game/new/folder', { + recursive: true + }) + expect(mockRenameSync).toHaveBeenCalled() + }) + + test('removes existing destination before moving', async () => { + const task: MoveTask = { + type: 'move', + source: 'source.txt', + destination: 'dest.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(true) // destination file exists + + await executeMoveTask(task, gameFolder, appName) + + expect(mockRmSync).toHaveBeenCalledWith('/path/to/game/dest.txt', { + recursive: true, + force: true + }) + expect(mockRenameSync).toHaveBeenCalled() + }) + + test('substitutes {gameFolder} variable', async () => { + const task: MoveTask = { + type: 'move', + source: '{gameFolder}/temp/file.txt', + destination: '{gameFolder}/final/file.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder, appName) + + expect(mockRenameSync).toHaveBeenCalledWith( + '/path/to/game/temp/file.txt', + '/path/to/game/final/file.txt' + ) + }) + + test('uses robocopy on Windows', async () => { + mockEnvironment.isWindows = true + + const task: MoveTask = { + type: 'move', + source: 'temp/save.dat', + destination: 'final/save.dat' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + .mockReturnValueOnce(false) // source doesn't exist after robocopy (cleanup check) + + mockSuccessfulSpawn() + + await executeMoveTask(task, gameFolder, appName) + + // Should use robocopy on Windows + expect(mockSpawnAsync).toHaveBeenCalledWith('robocopy', [ + '/path/to/game/temp/save.dat', + '/path/to/game/final/save.dat', + '/MOVE', + '/MIR', + '/NJH', + '/NJS', + '/NDL', + '/R:3', + '/W:10' + ]) + expect(mockRenameSync).not.toHaveBeenCalled() + }) + + test('substitutes {C} variable on Windows', async () => { + mockEnvironment.isWindows = true + + const task: MoveTask = { + type: 'move', + source: 'temp/save.dat', + destination: '{C}/Users/Player/Documents/save.dat' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + .mockReturnValueOnce(false) // source cleanup check + + mockSuccessfulSpawn() + + await executeMoveTask(task, gameFolder, appName) + + // Should use robocopy on Windows with C: substitution + expect(mockSpawnAsync).toHaveBeenCalledWith('robocopy', [ + '/path/to/game/temp/save.dat', + 'C:/Users/Player/Documents/save.dat', + '/MOVE', + '/MIR', + '/NJH', + '/NJS', + '/NDL', + '/R:3', + '/W:10' + ]) + }) + + test('substitutes {C} variable with Wine prefix on Linux', async () => { + const task: MoveTask = { + type: 'move', + source: 'save.dat', + destination: '{C}/users/player/Documents/save.dat' + } + + mockGetGameInfo.mockReturnValue({ + install: { platform: 'windows' } + } as any) + mockIsNative.mockReturnValue(false) + mockGetSettings.mockResolvedValue({ + winePrefix: '/home/user/.wine', + wineVersion: { type: 'wine' } + } as any) + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder, appName) + + expect(mockRenameSync).toHaveBeenCalledWith( + '/path/to/game/save.dat', + '/home/user/.wine/drive_c/users/player/Documents/save.dat' + ) + }) + + test('substitutes {C} variable with Proton prefix', async () => { + const task: MoveTask = { + type: 'move', + source: 'save.dat', + destination: '{C}/users/player/save.dat' + } + + mockGetGameInfo.mockReturnValue({ + install: { platform: 'windows' } + } as any) + mockIsNative.mockReturnValue(false) + mockGetSettings.mockResolvedValue({ + winePrefix: '/steam/proton/prefix', + wineVersion: { type: 'proton' } + } as any) + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder, appName) + + expect(mockRenameSync).toHaveBeenCalledWith( + '/path/to/game/save.dat', + '/steam/proton/prefix/pfx/drive_c/users/player/save.dat' + ) + }) + + test('throws error when source does not exist', async () => { + const task: MoveTask = { + type: 'move', + source: 'nonexistent.txt', + destination: 'dest.txt' + } + + mockExistsSync.mockReturnValue(false) // source doesn't exist + + await expect(executeMoveTask(task, gameFolder, appName)).rejects.toThrow( + 'Source path not found: /path/to/game/nonexistent.txt' + ) + + expect(mockRenameSync).not.toHaveBeenCalled() + expect(mockSpawnAsync).not.toHaveBeenCalled() + }) + + test('handles Unix move operation failure', async () => { + const task: MoveTask = { + type: 'move', + source: 'source.txt', + destination: 'dest.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + // Mock renameSync to throw an error + mockRenameSync.mockImplementation(() => { + throw new Error('Permission denied') + }) + + await expect(executeMoveTask(task, gameFolder, appName)).rejects.toThrow( + 'Failed to move /path/to/game/source.txt to /path/to/game/dest.txt: Permission denied' + ) + }) + + test('handles Windows robocopy failure', async () => { + mockEnvironment.isWindows = true + + const task: MoveTask = { + type: 'move', + source: 'source.txt', + destination: 'dest.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + // Mock robocopy failure (exit code >= 8) + mockFailedSpawn(8, 'Access denied') + + await expect(executeMoveTask(task, gameFolder, appName)).rejects.toThrow( + 'Failed to move /path/to/game/source.txt to /path/to/game/dest.txt: Move operation failed: Access denied' + ) + }) + + test('works without appName parameter', async () => { + const task: MoveTask = { + type: 'move', + source: 'file.txt', + destination: 'moved.txt' + } + + mockExistsSync + .mockReturnValueOnce(true) // source exists + .mockReturnValueOnce(true) // destination dir + .mockReturnValueOnce(false) // destination file + + await executeMoveTask(task, gameFolder) + + expect(mockRenameSync).toHaveBeenCalledWith( + '/path/to/game/file.txt', + '/path/to/game/moved.txt' + ) + }) +}) diff --git a/src/backend/__tests__/storeManagers/customLibraries/runTask.test.ts b/src/backend/__tests__/storeManagers/customLibraries/runTask.test.ts new file mode 100644 index 0000000000..0dabbafaac --- /dev/null +++ b/src/backend/__tests__/storeManagers/customLibraries/runTask.test.ts @@ -0,0 +1,255 @@ +import { join } from 'path' +import { executeRunTask } from 'backend/storeManagers/customLibraries/tasks/runTask' +import { RunTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { existsSync } from 'graceful-fs' +import { logInfo, LogPrefix } from 'backend/logger' +import { + getSettings, + isNative, + getGameInfo +} from 'backend/storeManagers/customLibraries/games' +import { runWineCommand } from 'backend/launcher' +import { spawnAsync } from 'backend/utils' + +jest.mock('graceful-fs') + +jest.mock('backend/logger', () => ({ + logInfo: jest.fn(), + logError: jest.fn(), + LogPrefix: { + CustomLibrary: 'CustomLibrary' + } +})) + +jest.mock('backend/constants/environment', () => ({ + isLinux: false, + isMac: false, + isWindows: false +})) + +jest.mock('backend/storeManagers/customLibraries/games', () => ({ + getSettings: jest.fn(), + isNative: jest.fn(), + getGameInfo: jest.fn() +})) + +jest.mock('backend/launcher', () => ({ + runWineCommand: jest.fn() +})) + +jest.mock('backend/utils', () => ({ + spawnAsync: jest.fn() +})) + +const mockExistsSync = existsSync as jest.MockedFunction +const mockSpawnAsync = spawnAsync as jest.MockedFunction +const mockLogInfo = logInfo as jest.MockedFunction +const mockGetSettings = getSettings as jest.MockedFunction +const mockIsNative = isNative as jest.MockedFunction +const mockGetGameInfo = getGameInfo as jest.MockedFunction +const mockRunWineCommand = runWineCommand as jest.MockedFunction< + typeof runWineCommand +> + +describe('RunTask - executeRunTask', () => { + const appName = 'test-game' + const gameFolder = '/path/to/game' + + beforeEach(() => { + jest.clearAllMocks() + + // Default mocks + mockGetGameInfo.mockReturnValue({ + install: { platform: 'linux' } + } as any) + mockIsNative.mockReturnValue(true) + + // Default successful spawnAsync mock + mockSpawnAsync.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '' + }) + }) + + test('runs native executable successfully', async () => { + const task: RunTask = { + type: 'run', + executable: 'installer.exe', + args: ['--silent', '--accept-license'] + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(true) + + await executeRunTask(appName, task, gameFolder) + + expect(mockSpawnAsync).toHaveBeenCalledWith( + 'powershell', + [ + '-Command', + `Start-Process -Wait "${join(gameFolder, 'installer.exe')}" -ArgumentList '--silent','--accept-license' -WorkingDirectory "${gameFolder}" -Verb RunAs` + ], + { stdio: 'inherit' } + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Running: installer.exe (Started)', + LogPrefix.CustomLibrary + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Running: installer.exe (Done)', + LogPrefix.CustomLibrary + ) + }) + + test('runs executable with variable substitution', async () => { + const task: RunTask = { + type: 'run', + executable: 'setup.exe', + args: ['--install-dir', '{gameFolder}/output'] + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(true) + + await executeRunTask(appName, task, gameFolder) + + expect(mockSpawnAsync).toHaveBeenCalledWith( + 'powershell', + [ + '-Command', + `Start-Process -Wait "${join(gameFolder, 'setup.exe')}" -ArgumentList '--install-dir','${gameFolder}/output' -WorkingDirectory "${gameFolder}" -Verb RunAs` + ], + { stdio: 'inherit' } + ) + }) + + test('runs executable without arguments', async () => { + const task: RunTask = { + type: 'run', + executable: 'app.exe' + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(true) + + await executeRunTask(appName, task, gameFolder) + + expect(mockSpawnAsync).toHaveBeenCalledWith( + 'powershell', + [ + '-Command', + `Start-Process -Wait "${join(gameFolder, 'app.exe')}" -WorkingDirectory "${gameFolder}" -Verb RunAs` + ], + { stdio: 'inherit' } + ) + }) + + test('runs Windows executable with Wine', async () => { + const task: RunTask = { + type: 'run', + executable: 'installer.exe', + args: ['--silent'] + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(false) + mockGetGameInfo.mockReturnValue({ + install: { platform: 'windows' } + } as any) + mockGetSettings.mockResolvedValue({ + winePrefix: '/wine/prefix', + wineVersion: { type: 'wine' } + } as any) + mockRunWineCommand.mockResolvedValue({ code: 0, stderr: '' } as any) + + await executeRunTask(appName, task, gameFolder) + + expect(mockRunWineCommand).toHaveBeenCalledWith({ + gameSettings: expect.any(Object), + commandParts: [join(gameFolder, 'installer.exe'), '--silent'], + wait: true, + gameInstallPath: gameFolder, + startFolder: gameFolder + }) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Running in Wine: /path/to/game/installer.exe --silent', + LogPrefix.CustomLibrary + ) + }) + + test('handles Wine execution failure', async () => { + const task: RunTask = { + type: 'run', + executable: 'installer.exe' + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(false) + mockGetGameInfo.mockReturnValue({ + install: { platform: 'windows' } + } as any) + mockGetSettings.mockResolvedValue({} as any) + mockRunWineCommand.mockResolvedValue({ + code: 1, + stderr: 'Wine error' + } as any) + + await expect(executeRunTask(appName, task, gameFolder)).rejects.toThrow( + 'Wine execution failed with code 1: Wine error' + ) + }) + + test('throws error when executable not found', async () => { + const task: RunTask = { + type: 'run', + executable: 'nonexistent.exe' + } + + mockExistsSync.mockReturnValue(false) + + await expect(executeRunTask(appName, task, gameFolder)).rejects.toThrow( + `Executable not found: ${join(gameFolder, 'nonexistent.exe')}` + ) + + expect(mockSpawnAsync).not.toHaveBeenCalled() + }) + + test('handles process execution error', async () => { + const task: RunTask = { + type: 'run', + executable: 'installer.exe' + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(true) + + // Mock spawnAsync to reject with an error + mockSpawnAsync.mockRejectedValue(new Error('Process failed')) + + await expect(executeRunTask(appName, task, gameFolder)).rejects.toThrow( + 'Process failed' + ) + }) + + test('handles process exit with non-zero code', async () => { + const task: RunTask = { + type: 'run', + executable: 'installer.exe' + } + + mockExistsSync.mockReturnValue(true) + mockIsNative.mockReturnValue(true) + + // Mock spawnAsync to return non-zero exit code + mockSpawnAsync.mockResolvedValue({ + code: 1, + stdout: '', + stderr: 'Process error output' + }) + + await expect(executeRunTask(appName, task, gameFolder)).rejects.toThrow( + 'Process failed with code 1: Process error output' + ) + }) +}) diff --git a/src/backend/__tests__/storeManagers/customLibraries/taskExecutor.test.ts b/src/backend/__tests__/storeManagers/customLibraries/taskExecutor.test.ts new file mode 100644 index 0000000000..fe752ca6be --- /dev/null +++ b/src/backend/__tests__/storeManagers/customLibraries/taskExecutor.test.ts @@ -0,0 +1,252 @@ +import { executeTasks } from 'backend/storeManagers/customLibraries/taskExecutor' +import { + CustomLibraryTask, + DownloadTask, + ExtractTask, + RunTask, + MoveTask +} from 'backend/storeManagers/customLibraries/tasks/types' +import { logInfo, logError, LogPrefix } from 'backend/logger' +import { executeDownloadTask } from 'backend/storeManagers/customLibraries/tasks/downloadTask' +import { executeExtractTask } from 'backend/storeManagers/customLibraries/tasks/extractTask' +import { executeRunTask } from 'backend/storeManagers/customLibraries/tasks/runTask' +import { executeMoveTask } from 'backend/storeManagers/customLibraries/tasks/moveTask' +import { showDialogBoxModalAuto } from 'backend/dialog/dialog' +import i18next from 'i18next' + +jest.mock('backend/logger') +jest.mock('backend/storeManagers/customLibraries/tasks/downloadTask') +jest.mock('backend/storeManagers/customLibraries/tasks/extractTask') +jest.mock('backend/storeManagers/customLibraries/tasks/runTask') +jest.mock('backend/storeManagers/customLibraries/tasks/moveTask') +jest.mock('backend/dialog/dialog') +jest.mock('i18next', () => ({ + t: jest.fn() +})) + +const mockLogInfo = logInfo as jest.MockedFunction +const mockLogError = logError as jest.MockedFunction +const mockExecuteDownloadTask = executeDownloadTask as jest.MockedFunction< + typeof executeDownloadTask +> +const mockExecuteExtractTask = executeExtractTask as jest.MockedFunction< + typeof executeExtractTask +> +const mockExecuteRunTask = executeRunTask as jest.MockedFunction< + typeof executeRunTask +> +const mockExecuteMoveTask = executeMoveTask as jest.MockedFunction< + typeof executeMoveTask +> +const mockShowDialogBoxModalAuto = + showDialogBoxModalAuto as jest.MockedFunction +const mockI18next = i18next as jest.Mocked + +describe('TaskExecutor - executeTasks', () => { + const appName = 'test-game' + const gameFolder = '/path/to/game' + + beforeEach(() => { + jest.clearAllMocks() + ;(mockI18next.t as jest.MockedFunction).mockReturnValue( + 'Uncaught Exception occured!' + ) + }) + + test('executes download task successfully', async () => { + const downloadTask: DownloadTask = { + type: 'download', + url: 'https://example.com/file.zip', + filename: 'file.zip' + } + const tasks: CustomLibraryTask[] = [downloadTask] + + mockExecuteDownloadTask.mockResolvedValue() + + await executeTasks(appName, tasks, gameFolder, 'install') + + expect(mockLogInfo).toHaveBeenCalledWith( + 'Starting install tasks for test-game', + LogPrefix.CustomLibrary + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Executing download task (1/1)', + LogPrefix.CustomLibrary + ) + expect(mockExecuteDownloadTask).toHaveBeenCalledWith( + appName, + downloadTask, + gameFolder + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Completed download task', + LogPrefix.CustomLibrary + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Completed all install tasks for test-game', + LogPrefix.CustomLibrary + ) + }) + + test('executes extract task successfully', async () => { + const extractTask: ExtractTask = { + type: 'extract', + source: 'file.zip', + destination: 'extracted/' + } + const tasks: CustomLibraryTask[] = [extractTask] + + mockExecuteExtractTask.mockResolvedValue() + + await executeTasks(appName, tasks, gameFolder, 'install') + + expect(mockExecuteExtractTask).toHaveBeenCalledWith(extractTask, gameFolder) + }) + + test('executes run task successfully', async () => { + const runTask: RunTask = { + type: 'run', + executable: 'installer.exe', + args: ['--silent'] + } + const tasks: CustomLibraryTask[] = [runTask] + + mockExecuteRunTask.mockResolvedValue() + + await executeTasks(appName, tasks, gameFolder, 'install') + + expect(mockExecuteRunTask).toHaveBeenCalledWith( + appName, + runTask, + gameFolder + ) + }) + + test('executes move task successfully', async () => { + const moveTask: MoveTask = { + type: 'move', + source: 'temp/file.txt', + destination: 'final/file.txt' + } + const tasks: CustomLibraryTask[] = [moveTask] + + mockExecuteMoveTask.mockResolvedValue() + + await executeTasks(appName, tasks, gameFolder, 'install') + + expect(mockExecuteMoveTask).toHaveBeenCalledWith( + moveTask, + gameFolder, + appName + ) + }) + + test('executes multiple tasks in order', async () => { + const tasks: CustomLibraryTask[] = [ + { type: 'download', url: 'https://example.com/file.zip' }, + { type: 'extract', source: 'file.zip' }, + { type: 'run', executable: 'installer.exe' } + ] + + mockExecuteDownloadTask.mockResolvedValue() + mockExecuteExtractTask.mockResolvedValue() + mockExecuteRunTask.mockResolvedValue() + + await executeTasks(appName, tasks, gameFolder, 'install') + + expect(mockLogInfo).toHaveBeenCalledWith( + 'Executing download task (1/3)', + LogPrefix.CustomLibrary + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Executing extract task (2/3)', + LogPrefix.CustomLibrary + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Executing run task (3/3)', + LogPrefix.CustomLibrary + ) + }) + + test('handles unknown task type', async () => { + const unknownTask = { type: 'unknown' } as any + const tasks: CustomLibraryTask[] = [unknownTask] + + await expect( + executeTasks(appName, tasks, gameFolder, 'install') + ).rejects.toThrow('Unknown task type: unknown') + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to execute unknown task: Unknown task type: unknown', + LogPrefix.CustomLibrary + ) + expect(mockShowDialogBoxModalAuto).toHaveBeenCalledWith({ + title: 'Uncaught Exception occured!', + message: 'Failed to execute unknown task: Unknown task type: unknown', + type: 'ERROR' + }) + }) + + test('handles task execution failure', async () => { + const downloadTask: DownloadTask = { + type: 'download', + url: 'https://example.com/file.zip' + } + const tasks: CustomLibraryTask[] = [downloadTask] + const errorMessage = 'Network error' + + mockExecuteDownloadTask.mockRejectedValue(new Error(errorMessage)) + + await expect( + executeTasks(appName, tasks, gameFolder, 'install') + ).rejects.toThrow(errorMessage) + + expect(mockLogError).toHaveBeenCalledWith( + `Failed to execute download task: ${errorMessage}`, + LogPrefix.CustomLibrary + ) + expect(mockShowDialogBoxModalAuto).toHaveBeenCalledWith({ + title: 'Uncaught Exception occured!', + message: `Failed to execute download task: ${errorMessage}`, + type: 'ERROR' + }) + }) + + test('executes uninstall tasks with correct context', async () => { + const moveTask: MoveTask = { + type: 'move', + source: 'game/save.dat', + destination: 'backup/save.dat' + } + const tasks: CustomLibraryTask[] = [moveTask] + + mockExecuteMoveTask.mockResolvedValue() + + await executeTasks(appName, tasks, gameFolder, 'uninstall') + + expect(mockLogInfo).toHaveBeenCalledWith( + 'Starting uninstall tasks for test-game', + LogPrefix.CustomLibrary + ) + expect(mockLogInfo).toHaveBeenCalledWith( + 'Completed all uninstall tasks for test-game', + LogPrefix.CustomLibrary + ) + }) + + test('stops execution on first task failure', async () => { + const tasks: CustomLibraryTask[] = [ + { type: 'download', url: 'https://example.com/file.zip' }, + { type: 'extract', source: 'file.zip' } + ] + + mockExecuteDownloadTask.mockRejectedValue(new Error('Download failed')) + + await expect( + executeTasks(appName, tasks, gameFolder, 'install') + ).rejects.toThrow('Download failed') + + expect(mockExecuteDownloadTask).toHaveBeenCalled() + expect(mockExecuteExtractTask).not.toHaveBeenCalled() + }) +}) diff --git a/src/backend/__tests__/storeManagers/customLibraryManager.test.ts b/src/backend/__tests__/storeManagers/customLibraryManager.test.ts new file mode 100644 index 0000000000..dafd7c1651 --- /dev/null +++ b/src/backend/__tests__/storeManagers/customLibraryManager.test.ts @@ -0,0 +1,734 @@ +import { + getCustomLibraries, + getCachedCustomLibraryEntry +} from 'backend/storeManagers/customLibraries/customLibraryManager' +import { GlobalConfig } from 'backend/config' +import { getWikiGameInfo } from 'backend/wiki_game_info/wiki_game_info' +import { + getGamesdbData, + gogToUnifiedInfo +} from 'backend/storeManagers/gog/library' +import { logInfo, logWarning } from 'backend/logger' + +jest.mock('backend/logger') +jest.mock('backend/config') +jest.mock('backend/wiki_game_info/wiki_game_info') +jest.mock('backend/storeManagers/gog/library') +global.fetch = jest.fn() + +let mockGetSettings = jest.fn() +const mockGlobalConfigGet = jest.fn().mockReturnValue({ + getSettings: mockGetSettings +}) + +;(GlobalConfig as any).get = mockGlobalConfigGet + +const mockGetWikiGameInfo = getWikiGameInfo as jest.MockedFunction< + typeof getWikiGameInfo +> +const mockGetGamesdbData = getGamesdbData as jest.MockedFunction< + typeof getGamesdbData +> +const mockGogToUnifiedInfo = gogToUnifiedInfo as jest.MockedFunction< + typeof gogToUnifiedInfo +> +const mockLogInfo = logInfo as jest.MockedFunction +const mockLogWarning = logWarning as jest.MockedFunction +const mockFetch = global.fetch as jest.MockedFunction + +describe('CustomLibraryManager', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockGlobalConfigGet.mockReturnValue({ + getSettings: mockGetSettings + }) + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [] + }) + + mockGetWikiGameInfo.mockResolvedValue(null) + mockGetGamesdbData.mockResolvedValue({ isUpdated: false }) + }) + + describe('getCustomLibraries', () => { + it('should return empty array when no custom libraries configured', async () => { + const result = await getCustomLibraries() + expect(result).toEqual([]) + }) + + it('should fetch library data from URLs', async () => { + const libraryConfig = { + name: 'Test Library', + games: [ + { + app_name: 'test-game', + title: 'Test Game', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: ['https://example.com/library.json'], + customLibraryConfigs: [] + }) + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(libraryConfig) + } as Response) + + const result = await getCustomLibraries() + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/library.json') + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Test Library') + expect(result[0].games[0].app_name).toBe('test_library__test-game') + }) + + it('should parse JSON configs directly', async () => { + const libraryConfig = { + name: 'JSON Library', + games: [ + { + app_name: 'json-game', + title: 'JSON Game', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + const result = await getCustomLibraries() + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('JSON Library') + expect(result[0].games[0].app_name).toBe('json_library__json-game') + }) + + it('should handle fetch failures gracefully', async () => { + mockGetSettings.mockReturnValue({ + customLibraryUrls: ['https://example.com/missing.json'], + customLibraryConfigs: [] + }) + + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + } as Response) + + const result = await getCustomLibraries() + + expect(result).toEqual([]) + expect(mockLogWarning).toHaveBeenCalledWith( + 'Failed to fetch from https://example.com/missing.json: HTTP 404 Not Found' + ) + }) + + it('should handle network errors gracefully', async () => { + mockGetSettings.mockReturnValue({ + customLibraryUrls: ['https://example.com/error.json'], + customLibraryConfigs: [] + }) + + mockFetch.mockRejectedValue(new Error('Network error')) + + const result = await getCustomLibraries() + + expect(result).toEqual([]) + expect(mockLogWarning).toHaveBeenCalledWith( + expect.stringContaining( + 'Error fetching from https://example.com/error.json' + ) + ) + }) + + it('should handle invalid JSON configs gracefully', async () => { + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: ['invalid json'] + }) + + const result = await getCustomLibraries() + + expect(result).toEqual([]) + expect(mockLogWarning).toHaveBeenCalledWith( + expect.stringContaining('Error parsing JSON config') + ) + }) + + it('should skip libraries with empty or invalid games array', async () => { + const invalidConfig = { + name: 'Invalid Library', + games: null + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(invalidConfig)] + }) + + const result = await getCustomLibraries() + + expect(result).toEqual([]) + expect(mockLogWarning).toHaveBeenCalledWith( + 'Invalid or empty games array in Invalid Library' + ) + }) + + it('should skip duplicate library names', async () => { + const library1 = { + name: 'Duplicate Library', + games: [ + { + app_name: 'game1', + title: 'Game 1', + executable: 'game1.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + const library2 = { + name: 'Duplicate Library', + games: [ + { + app_name: 'game2', + title: 'Game 2', + executable: 'game2.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [ + JSON.stringify(library1), + JSON.stringify(library2) + ] + }) + + const result = await getCustomLibraries() + + expect(result).toHaveLength(1) + expect(result[0].games).toHaveLength(1) + expect(result[0].games[0].title).toBe('Game 1') + expect(mockLogInfo).toHaveBeenCalledWith( + 'Skipping JSON Config - library name already exists: Duplicate Library' + ) + }) + + it('should create unique app names for games', async () => { + const libraryConfig = { + name: 'Test Library!@#', + games: [ + { + app_name: 'test-game', + title: 'Test Game', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + const result = await getCustomLibraries() + + expect(result[0].games[0].app_name).toBe('test_library__test-game') + }) + + it('should fetch metadata for games', async () => { + // Use a unique app name to avoid cache conflicts + const libraryConfig = { + name: 'Test Library Metadata', + games: [ + { + app_name: 'test-game-metadata', + title: 'Test Game Metadata', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + const wikiInfo = { + howlongtobeat: { + gameImageUrl: 'https://example.com/cover.jpg' + }, + pcgamingwiki: { + genres: ['Action', 'Adventure'], + steamID: '12345' + } + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + mockGetWikiGameInfo.mockResolvedValue(wikiInfo as any) + mockGetGamesdbData.mockResolvedValue({} as any) + mockGogToUnifiedInfo.mockResolvedValue({ + art_cover: 'https://gamesdb.com/cover.jpg', + art_square: 'https://gamesdb.com/cover.jpg', + extra: { + about: { description: 'A great test game' }, + genres: ['Action', 'Adventure'] + } + } as any) + + const result = await getCustomLibraries() + + expect(mockGetWikiGameInfo).toHaveBeenCalledWith( + 'Test Game Metadata', + 'custom_test_library_metadata__test-game-metadata', + 'customLibrary' + ) + expect(mockGetGamesdbData).toHaveBeenCalledWith('steam', '12345') + + // GamesDB artwork takes precedence over HowLongToBeat + expect(result[0].games[0].art_cover).toBe('https://gamesdb.com/cover.jpg') + expect(result[0].games[0].description).toBe('A great test game') + expect(result[0].games[0].genres).toEqual(['Action', 'Adventure']) + }) + + it('should use HowLongToBeat artwork as fallback', async () => { + const libraryConfig = { + name: 'Test Library HLTB', + games: [ + { + app_name: 'test-game-hltb', + title: 'Test Game HLTB', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + const wikiInfo = { + howlongtobeat: { + gameImageUrl: 'https://howlongtobeat.com/cover.jpg' + }, + pcgamingwiki: { + genres: ['Action'] + } + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + mockGetWikiGameInfo.mockResolvedValue(wikiInfo as any) + + const result = await getCustomLibraries() + + expect(result[0].games[0].art_cover).toBe( + 'https://howlongtobeat.com/cover.jpg' + ) + expect(result[0].games[0].art_square).toBe( + 'https://howlongtobeat.com/cover.jpg' + ) + }) + + it('should use custom gamesdb_credentials when provided', async () => { + const libraryConfig = { + name: 'Test Library GDB', + games: [ + { + app_name: 'test-game-gdb', + title: 'Test Game GDB', + executable: 'game.exe', + gamesdb_credentials: { + store: 'epic', + id: 'test-epic-id' + }, + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + // Mock getWikiGameInfo to return null so custom credentials are used + mockGetWikiGameInfo.mockResolvedValue(null) + mockGetGamesdbData.mockResolvedValue({} as any) + mockGogToUnifiedInfo.mockResolvedValue({ + art_cover: 'https://epic.com/cover.jpg', + art_square: 'https://epic.com/cover.jpg', + extra: { + about: { description: 'Epic game description' }, + genres: ['Action'] + } + } as any) + + const result = await getCustomLibraries() + + expect(mockGetGamesdbData).toHaveBeenCalledWith('epic', 'test-epic-id') + expect(result[0].games[0].art_cover).toBe('https://epic.com/cover.jpg') + expect(result[0].games[0].description).toBe('Epic game description') + }) + + it('should handle wiki info fetch failures gracefully', async () => { + const libraryConfig = { + name: 'Test Library Error', + games: [ + { + app_name: 'test-game-error', + title: 'Test Game Error', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + mockGetWikiGameInfo.mockRejectedValue(new Error('Wiki fetch failed')) + + const result = await getCustomLibraries() + + expect(result).toHaveLength(1) + expect(mockLogWarning).not.toHaveBeenCalled() + }) + + it('should handle gamesdb fetch failures gracefully', async () => { + const libraryConfig = { + name: 'Test Library GDB Error', + games: [ + { + app_name: 'test-game-gdb-error', + title: 'Test Game GDB Error', + executable: 'game.exe', + gamesdb_credentials: { + store: 'steam', + id: 'invalid-id' + }, + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + mockGetWikiGameInfo.mockResolvedValue(null) + mockGetGamesdbData.mockRejectedValue(new Error('GamesDB fetch failed')) + + const result = await getCustomLibraries() + + expect(result).toHaveLength(1) + expect(mockLogWarning).not.toHaveBeenCalled() + }) + + it('should preserve existing game properties', async () => { + const libraryConfig = { + name: 'Test Library Existing', + games: [ + { + app_name: 'test-game-existing', + title: 'Test Game Existing', + executable: 'game.exe', + art_cover: 'https://existing.com/cover.jpg', + description: 'Existing description', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + // Even though we call getWikiGameInfo, existing properties should be preserved + mockGetWikiGameInfo.mockResolvedValue(null) + + const result = await getCustomLibraries() + + // The existing properties should be preserved (they are set in retrieveGameMetadata) + expect(result[0].games[0].art_cover).toBe( + 'https://existing.com/cover.jpg' + ) + expect(result[0].games[0].description).toBe('Existing description') + expect(mockGetWikiGameInfo).toHaveBeenCalled() + }) + + it('should handle concurrent library processing', async () => { + const urlLibrary = { + name: 'URL Library', + games: [ + { + app_name: 'url-game', + title: 'URL Game', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + const jsonLibrary = { + name: 'JSON Library', + games: [ + { + app_name: 'json-game', + title: 'JSON Game', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: ['https://example.com/library.json'], + customLibraryConfigs: [JSON.stringify(jsonLibrary)] + }) + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(urlLibrary) + } as Response) + + const result = await getCustomLibraries() + + expect(result).toHaveLength(2) + expect(result.find((lib) => lib.name === 'URL Library')).toBeDefined() + expect(result.find((lib) => lib.name === 'JSON Library')).toBeDefined() + }) + + it('should create safe library IDs from names with special characters', async () => { + const testCases = [ + { input: 'Test Library!@#', expected: 'test_library' }, + { input: 'My-Game_Collection', expected: 'my_game_collection' }, + { input: ' Spaced Library ', expected: 'spaced_library' }, + { input: 'Multiple___Underscores', expected: 'multiple_underscores' }, + { input: '123Numbers456', expected: '123numbers456' } + ] + + for (const testCase of testCases) { + const libraryConfig = { + name: testCase.input, + games: [ + { + app_name: 'test-game', + title: 'Test Game', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + const result = await getCustomLibraries() + expect(result[0].games[0].app_name).toBe( + `${testCase.expected}__test-game` + ) + + // Reset for next iteration + jest.clearAllMocks() + mockGetSettings = jest.fn() + mockGlobalConfigGet.mockReturnValue({ + getSettings: mockGetSettings + }) + } + }) + }) + + describe('getCachedCustomLibraryEntry', () => { + it('should return undefined for non-existent entries', () => { + const result = getCachedCustomLibraryEntry('non-existent') + expect(result).toBeUndefined() + }) + + it('should return cached entry after library loading', async () => { + const libraryConfig = { + name: 'Test Library Cache', + games: [ + { + app_name: 'test-game-cache', + title: 'Test Game Cache', + executable: 'game.exe', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + await getCustomLibraries() + + const cachedEntry = getCachedCustomLibraryEntry( + 'test_library_cache__test-game-cache' + ) + expect(cachedEntry).toBeDefined() + expect(cachedEntry?.title).toBe('Test Game Cache') + expect(cachedEntry?.app_name).toBe('test_library_cache__test-game-cache') + }) + }) + + describe('version-based caching', () => { + it('should use cached data for games with matching versions', async () => { + // Use a unique library name to avoid conflicts + const libraryConfig = { + name: 'Test Library Cache V1', + games: [ + { + app_name: 'test-game-cache-v1', + title: 'Test Game Cache V1', + executable: 'game.exe', + version: '1.0.0', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + + const wikiInfo = { + howlongtobeat: { + gameImageUrl: 'https://cached.com/cover.jpg' + } + } + + mockGetWikiGameInfo.mockResolvedValue(wikiInfo as any) + + // First call + await getCustomLibraries() + + // Reset mocks but keep the module cache + jest.clearAllMocks() + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfig)] + }) + mockGlobalConfigGet.mockReturnValue({ + getSettings: mockGetSettings + }) + + // Second call should use cache + const result = await getCustomLibraries() + + expect(mockGetWikiGameInfo).not.toHaveBeenCalled() + expect(mockLogInfo).toHaveBeenCalledWith( + 'Skipping metadata fetch for Test Game Cache V1 - already cached with matching version 1.0.0' + ) + expect(result[0].games[0].art_cover).toBe('https://cached.com/cover.jpg') + }) + + it('should refetch metadata when version changes', async () => { + // Use unique library name + const libraryName = 'Test Library Cache V2' + const appName = 'test-game-cache-v2' + + const libraryConfigV1 = { + name: libraryName, + games: [ + { + app_name: appName, + title: 'Test Game Cache V2', + executable: 'game.exe', + version: '1.0.0', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfigV1)] + }) + + mockGetWikiGameInfo.mockResolvedValue({ + howlongtobeat: { gameImageUrl: 'https://old.com/cover.jpg' } + } as any) + + await getCustomLibraries() + + // Second call with version 2.0.0 + const libraryConfigV2 = { + name: libraryName, + games: [ + { + app_name: appName, + title: 'Test Game Cache V2', + executable: 'game.exe', + version: '2.0.0', + install_tasks: [], + uninstall_tasks: [] + } + ] + } + + jest.clearAllMocks() + mockGetSettings.mockReturnValue({ + customLibraryUrls: [], + customLibraryConfigs: [JSON.stringify(libraryConfigV2)] + }) + mockGlobalConfigGet.mockReturnValue({ + getSettings: mockGetSettings + }) + + mockGetWikiGameInfo.mockResolvedValue({ + howlongtobeat: { gameImageUrl: 'https://new.com/cover.jpg' } + } as any) + + const result = await getCustomLibraries() + + expect(mockGetWikiGameInfo).toHaveBeenCalled() + expect(mockLogInfo).toHaveBeenCalledWith( + 'Fetching metadata for Test Game Cache V2 (version: 2.0.0)' + ) + expect(result[0].games[0].art_cover).toBe('https://new.com/cover.jpg') + }) + }) +}) 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/backend/launcher.ts b/src/backend/launcher.ts index a239cff927..d270f8148e 100644 --- a/src/backend/launcher.ts +++ b/src/backend/launcher.ts @@ -1659,6 +1659,10 @@ async function callRunner( shouldUsePowerShell = isWindows && !!(await searchForExecutableOnPath('powershell')) + if (options.launchFromCmd) { + shouldUsePowerShell = false + } + if (shouldUsePowerShell) { const argsAsString = commandParts .map((part) => part.replaceAll('\\', '\\\\')) @@ -1674,6 +1678,20 @@ async function callRunner( if (argsAsString) commandParts.push('-ArgumentList', argsAsString) bin = fullRunnerPath = 'powershell' + } else if (isWindows) { + // Use cmd.exe on Windows when PowerShell is skipped + const { writeFileSync } = await import('fs') + const { tmpdir } = await import('os') + const tempBatPath = join(tmpdir(), `heroic_launch_${Date.now()}.bat`) + const argsString = commandParts.join(' ') + + const batContent = `@echo off +"${fullRunnerPath}" ${argsString} +` + + writeFileSync(tempBatPath, batContent) + commandParts = ['/c', tempBatPath] + bin = fullRunnerPath = 'cmd' } const safeCommand = getRunnerCallWithoutCredentials( 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/save_sync.ts b/src/backend/save_sync.ts index a5d71315e7..cec264b9ec 100644 --- a/src/backend/save_sync.ts +++ b/src/backend/save_sync.ts @@ -30,6 +30,8 @@ async function getDefaultSavePath( return '' case 'sideload': return '' + case 'customLibrary': + return '' } } diff --git a/src/backend/storeManagers/customLibraries/customLibraryManager.ts b/src/backend/storeManagers/customLibraries/customLibraryManager.ts new file mode 100644 index 0000000000..d81bed8fbc --- /dev/null +++ b/src/backend/storeManagers/customLibraries/customLibraryManager.ts @@ -0,0 +1,294 @@ +import { logInfo, logWarning } from 'backend/logger' +import { GlobalConfig } from 'backend/config' +import { LaunchOption } from 'common/types' +import { getWikiGameInfo } from 'backend/wiki_game_info/wiki_game_info' +import { + getGamesdbData, + gogToUnifiedInfo +} from 'backend/storeManagers/gog/library' +import { CustomLibraryTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { axiosClient } from 'backend/utils' + +interface GameMetadata { + art_cover: string + art_square: string + description: string + genres: string[] + install_size_bytes: number +} + +interface CustomLibraryConfig { + name: string + games: Array +} + +interface CustomLibraryConfigGame { + app_name: string + title: string + executable: string + art_cover?: string + art_square?: string + description?: string + version?: string + install_size_bytes?: number + developer?: string + release_date?: string + platform?: 'windows' | 'mac' | 'linux' | 'browser' + install_tasks: CustomLibraryTask[] + uninstall_tasks: CustomLibraryTask[] + gamesdb_credentials?: { + store: string + id: string + } + genres?: string[] + launch_options?: LaunchOption[] + launch_from_cmd?: boolean + parameters?: string +} + +const customLibraryCache: Map = new Map() + +/** + * Retrieves enhanced metadata (art, description, genres) for a custom library game + * by fetching data from various sources including wiki info and games databases + */ +async function retrieveGameMetadata( + game: CustomLibraryConfig['games'][0] +): Promise { + const [wikiInfo, downloadSize] = await Promise.all([ + getWikiGameInfo( + game.title, + `custom_${game.app_name}`, + 'customLibrary' + ).catch(() => null), + calculateTotalDownloadSize(game.install_tasks).catch(() => 0) + ]) + + let art_cover = game.art_cover || '' + let art_square = game.art_square || game.art_cover || '' + let description = game.description || '' + let genres: string[] = [] + let storeId = '' + let gameId = '' + + if (wikiInfo) { + if (!art_cover && wikiInfo.howlongtobeat?.gameImageUrl) { + art_cover = wikiInfo.howlongtobeat.gameImageUrl + art_square = wikiInfo.howlongtobeat.gameImageUrl + } + + genres = wikiInfo.pcgamingwiki?.genres || [] + + if (wikiInfo.pcgamingwiki?.steamID) { + storeId = 'steam' + gameId = wikiInfo.pcgamingwiki?.steamID + } + } + + if (game.gamesdb_credentials) { + storeId = game.gamesdb_credentials.store + gameId = game.gamesdb_credentials.id + } + + if (storeId && gameId) { + const gamesDBResult = await getGamesdbData(storeId, gameId).catch( + () => null + ) + const unifiedInfo = await gogToUnifiedInfo(gamesDBResult?.data || undefined) + + if (unifiedInfo) { + art_cover = unifiedInfo.art_cover + art_square = unifiedInfo.art_square + description = unifiedInfo.extra?.about?.description || '' + genres = unifiedInfo.extra?.genres || [] + } + } + + return { + art_cover, + art_square, + description, + genres, + install_size_bytes: downloadSize + } +} + +// Function to convert library name to a safe ID +function getLibraryId(libraryName: string): string { + return libraryName + .toLowerCase() + .replace(/[^a-z0-9]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') +} + +// Helper function to create unique app name +function createUniqueAppName(libraryName: string, appName: string): string { + const libraryId = getLibraryId(libraryName) + return `${libraryId}__${appName}` +} + +// Function to fetch JSON data from URL +async function fetchLibraryData( + url: string +): Promise { + try { + logInfo(`Fetching library data from: ${url}`) + const response = await fetch(url) + + if (!response.ok) { + logWarning( + `Failed to fetch from ${url}: HTTP ${response.status} ${response.statusText}` + ) + return null + } + + const data = await response.json() + const libraryName = data.name || url.split('/').pop() || 'Unknown Library' + logInfo( + `Successfully fetched "${libraryName}" with ${data.games?.length || 0} games` + ) + return data as CustomLibraryConfig + } catch (error) { + logWarning(`Error fetching from ${url}: ${error}`) + return null + } +} + +async function getCustomLibraries(): Promise { + const customLibraryUrls = + GlobalConfig.get().getSettings().customLibraryUrls || [] + const customLibraryConfigs = + GlobalConfig.get().getSettings().customLibraryConfigs || [] + const libraries: CustomLibraryConfig[] = [] + + // Create a combined array of config promises + const configPromises: Promise<{ + config: CustomLibraryConfig | null + source: string + }>[] = [] + + // Add URL-based configs + for (const libraryUrl of customLibraryUrls) { + configPromises.push( + fetchLibraryData(libraryUrl).then((config) => ({ + config, + source: libraryUrl + })) + ) + } + + // Add direct JSON configs + for (const jsonContent of customLibraryConfigs) { + configPromises.push( + Promise.resolve().then(() => { + try { + const data = JSON.parse(jsonContent) + const libraryName = data.name || 'Custom JSON Library' + logInfo( + `Successfully parsed JSON config "${libraryName}" with ${data.games?.length || 0} games` + ) + return { config: data as CustomLibraryConfig, source: 'JSON Config' } + } catch (error) { + logWarning(`Error parsing JSON config: ${error}`) + return { config: null, source: 'JSON Config' } + } + }) + ) + } + + // Process all configs with the same logic + const configResults = await Promise.allSettled(configPromises) + + for (const result of configResults) { + if (result.status === 'rejected') { + logWarning(`Error processing library config: ${result.reason}`) + continue + } + + const { config, source } = result.value + + if (!config) { + logInfo(`Skipping ${source} - failed to load/parse data`) + continue + } + + const libraryName = + config.name || source.split('/').pop() || 'Unknown Library' + + if (!config.games || !Array.isArray(config.games)) { + logWarning(`Invalid or empty games array in ${libraryName}`) + continue + } + + // Skip if library name is already in libraries + if (libraries.some((library) => library.name === config.name)) { + logInfo( + `Skipping ${source} - library name already exists: ${config.name}` + ) + continue + } + + for (const game of config.games) { + // Create unique app name using library + original app name + const uniqueAppName = createUniqueAppName(config.name, game.app_name) + game.app_name = uniqueAppName + + // Check if game is already in cache with matching version + const cachedGame = customLibraryCache.get(uniqueAppName) + if (cachedGame && cachedGame.version === game.version) { + logInfo( + `Skipping metadata fetch for ${game.title} - already cached with matching version ${game.version}` + ) + + Object.assign(game, cachedGame) + continue + } + + // Retrieve and apply metadata for each game that's not cached or has version mismatch + logInfo( + `Fetching metadata for ${game.title} (version: ${game.version || 'unversioned'})` + ) + const { art_cover, art_square, description, genres, install_size_bytes } = + await retrieveGameMetadata(game) + game.art_cover = art_cover + game.art_square = art_square + game.description = description + game.genres = genres + game.install_size_bytes = install_size_bytes + + customLibraryCache.set(game.app_name, game) + } + + libraries.push(config) + } + + return libraries +} + +function getCachedCustomLibraryEntry( + appName: string +): CustomLibraryConfigGame | undefined { + return customLibraryCache.get(appName) +} + +async function getDownloadSize(url: string): Promise { + try { + const response = await axiosClient.head(url) + return parseInt(response.headers['content-length'], 10) || 0 + } catch { + return 0 + } +} + +async function calculateTotalDownloadSize( + tasks: CustomLibraryTask[] +): Promise { + const downloadTasks = tasks.filter((task) => task.type === 'download') + const sizes = await Promise.all( + downloadTasks.map((task) => getDownloadSize(task.url)) + ) + return sizes.reduce((total, size) => total + size, 0) +} + +export { getCustomLibraries, getCachedCustomLibraryEntry } diff --git a/src/backend/storeManagers/customLibraries/electronStores.ts b/src/backend/storeManagers/customLibraries/electronStores.ts new file mode 100644 index 0000000000..b6a0708c84 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/electronStores.ts @@ -0,0 +1,15 @@ +import { TypeCheckedStoreBackend } from '../../electron_store' +import CacheStore from '../../cache' +import { CustomLibraryGameInfo } from 'common/types' + +export const libraryStore = new CacheStore( + 'custom_library', + null +) +export const installedGamesStore = new TypeCheckedStoreBackend( + 'customLibraryInstalledGamesStore', + { + cwd: 'custom_store', + name: 'installed' + } +) diff --git a/src/backend/storeManagers/customLibraries/games.ts b/src/backend/storeManagers/customLibraries/games.ts new file mode 100644 index 0000000000..cba7c9543a --- /dev/null +++ b/src/backend/storeManagers/customLibraries/games.ts @@ -0,0 +1,494 @@ +import { + ExecResult, + ExtraInfo, + GameSettings, + InstallArgs, + InstallPlatform, + LaunchOption, + InstalledInfo, + CustomLibraryGameInfo +} from 'common/types' +import { libraryStore, installedGamesStore } from './electronStores' +import { GameConfig } from '../../game_config' +import { killPattern, shutdownWine, getFileSize } from '../../utils' +import { sendFrontendMessage } from '../../ipc' +import { logInfo, LogPrefix, logWarning, logError } from 'backend/logger' +import { join } from 'path' +import { existsSync, rmSync } from 'graceful-fs' +import { mkdir } from 'fs/promises' +import { + addShortcuts as addShortcutsUtil, + removeShortcuts as removeShortcutsUtil +} from '../../shortcuts/shortcuts/shortcuts' +import { launchGame } from 'backend/storeManagers/storeManagerCommon/games' +import { GOGCloudSavesLocation } from 'common/types/gog' +import { InstallResult, RemoveArgs } from 'common/types/game_manager' +import { removeNonSteamGame } from 'backend/shortcuts/nonesteamgame/nonesteamgame' +import type LogWriter from 'backend/logger/log_writer' +import { + refreshInstalled, + importGame as importCustomLibraryGame, + getGameInfo as getCustomLibraryGameInfo +} from './library' +import { isLinux, isMac, isWindows } from 'backend/constants/environment' +import { executeTasks } from './taskExecutor' +import { getCachedCustomLibraryEntry } from './customLibraryManager' +import { showDialogBoxModalAuto } from 'backend/dialog/dialog' +import i18next from 'i18next' + +export function getGameInfo(appName: string): CustomLibraryGameInfo { + return getCustomLibraryGameInfo(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 { + try { + // launchGame expects an absolute path to the executable. This is a hack to include the absolute path to the executable. + const gameInfo = getGameInfo(appName) + if (!gameInfo.install.install_path) { + throw new Error(`No install path found for game ${appName}`) + } + if (!gameInfo.install.executable) { + throw new Error(`No executable found for game ${appName}`) + } + + let currentExecutable = join( + gameInfo.install.install_path, + gameInfo.install.executable + ) + const finalArgs = [...args] + + if (gameInfo.parameters) { + finalArgs.push( + ...gameInfo.parameters.split(' ').filter((arg) => arg.trim()) + ) + } + + if (launchArguments) { + if (launchArguments.type === 'basic') { + // Add parameters from basic launch option + if (launchArguments.parameters) { + finalArgs.push( + ...launchArguments.parameters.split(' ').filter((arg) => arg.trim()) + ) + } + } else if (launchArguments.type === 'altExe') { + // Override executable for alternative executable + if (launchArguments.executable && gameInfo.install.install_path) { + currentExecutable = join( + gameInfo.install.install_path, + launchArguments.executable + ) + } + } + } + + // if the executable does not exist, and there is no targetExe set in the game settings, throw an error + const gameSettingsOverrides = await GameConfig.get(appName).getSettings() + if (!gameSettingsOverrides.targetExe && !existsSync(currentExecutable)) { + throw new Error(`Executable not found: ${currentExecutable}`) + } + + gameInfo.install.executable = currentExecutable + + return launchGame( + appName, + logWriter, + gameInfo, + 'customLibrary', + finalArgs, + gameInfo.launchFromCmd + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logError(errorMessage, LogPrefix.CustomLibrary) + + showDialogBoxModalAuto({ + title: i18next.t( + 'box.error.uncaught-exception.title', + 'Uncaught Exception occured!' + ), + message: errorMessage, + type: 'ERROR' + }) + + 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 }: RemoveArgs): Promise { + const array = installedGamesStore.get('installed', []) + const index = array.findIndex((game) => game.appName === appName) + + if (index === -1) { + throw Error("Game isn't installed") + } + + const [object] = array.splice(index, 1) + + try { + const gameInfo = getGameInfo(appName) + + logInfo(`Executing uninstall tasks for ${appName}`, LogPrefix.CustomLibrary) + await executeTasks( + appName, + gameInfo.uninstallTasks, + object.install_path || '', + 'uninstall' + ) + + if (existsSync(object.install_path)) { + logInfo( + `Removing install directory: ${object.install_path}`, + LogPrefix.CustomLibrary + ) + rmSync(object.install_path, { recursive: true }) + } + } catch (error) { + logError( + `Failed to execute uninstall tasks: ${error}`, + LogPrefix.CustomLibrary + ) + } + + installedGamesStore.set('installed', array) + refreshInstalled() + const gameInfo = getGameInfo(appName) + gameInfo.is_installed = false + gameInfo.install = { is_dlc: false } + await removeShortcutsUtil(gameInfo) + await removeNonSteamGame({ gameInfo }) + sendFrontendMessage('pushGameToLibrary', gameInfo) + + return { stdout: '', stderr: '' } +} + +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 { + const installed = installedGamesStore.get('installed', []) + const newInstalled = installed.filter((g) => g.appName !== appName) + installedGamesStore.set('installed', newInstalled) + refreshInstalled() + sendFrontendMessage('pushGameToLibrary', getGameInfo(appName)) +} + +// Updated install function that works with the task system and download manager +export async function install( + appName: string, + args: InstallArgs, + newVersion?: string +): Promise { + try { + logInfo(`Installing custom game ${appName}`, LogPrefix.CustomLibrary) + + const gameInfo = getGameInfo(appName) + if (!gameInfo) { + const error = `Game ${appName} not found in custom library` + logError(error, LogPrefix.CustomLibrary) + return { status: 'error', error: 'Game not found' } + } + + // Check if already installed + if ( + gameInfo.is_installed && + gameInfo.install.install_path && + gameInfo.install.executable + ) { + const executablePath = join( + gameInfo.install.install_path, + gameInfo.install.executable + ) + if (existsSync(executablePath)) { + logInfo( + `Game already installed for ${appName}`, + LogPrefix.CustomLibrary + ) + return { status: 'done' } + } + } + + // Create install directory + await mkdir(args.path, { recursive: true }) + const gameFolder = join(args.path, appName) + await mkdir(gameFolder, { recursive: true }) + + // Execute install tasks using the task system + await executeTasks(appName, gameInfo.installTasks, gameFolder, 'install') + + // Determine platform and executable from tasks or game info + const executable = gameInfo.install.executable || '' + let platform: InstallPlatform = 'linux' + + if (executable.toLowerCase().endsWith('.exe')) { + platform = 'windows' + } + + // Save installation info to persistent store + const installedData: InstalledInfo = { + platform, + executable, + install_path: gameFolder, + install_size: gameInfo.installSizeBytes + ? getFileSize(gameInfo.installSizeBytes) + : 'Unknown', + is_dlc: false, + version: newVersion || gameInfo.install.version || '1.0', + appName: appName + } + + const installedArray = installedGamesStore.get('installed', []) + + // Remove existing entry if it exists + const filteredArray = installedArray.filter( + (item) => item.appName !== appName + ) + filteredArray.push(installedData) + installedGamesStore.set('installed', filteredArray) + + // Update in-memory map and game library + refreshInstalled() + + // Update game as installed in the library + const games = libraryStore.get('games', []) + const gameIndex = games.findIndex((game) => game.app_name === appName) + if (gameIndex !== -1) { + games[gameIndex].is_installed = true + games[gameIndex].install = installedData + libraryStore.set('games', games) + } + + // Add shortcuts + try { + const updatedGameInfo = getGameInfo(appName) + if (updatedGameInfo) { + addShortcutsUtil(updatedGameInfo) + logInfo(`Added shortcuts for ${appName}`, LogPrefix.CustomLibrary) + } + } catch (error) { + logWarning(`Could not add shortcuts: ${error}`, LogPrefix.CustomLibrary) + } + + logInfo( + `Successfully installed custom game ${appName}`, + LogPrefix.CustomLibrary + ) + return { status: 'done' } + } catch (error) { + logError(`Install failed for ${appName}: ${error}`, LogPrefix.CustomLibrary) + return { status: 'error', error: String(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 { + const gameInfo = getGameInfo(appName) + + if (!gameInfo?.app_name) { + const error = `Game ${appName} not found in custom library` + logError(error, LogPrefix.CustomLibrary) + return { stderr: error, stdout: '', error } + } + + try { + await importCustomLibraryGame(gameInfo, path, platform) + addShortcutsUtil(gameInfo) + } catch (error) { + const errorMsg = `Failed to import ${appName}: ${error instanceof Error ? error.message : error}` + logError(errorMsg, LogPrefix.CustomLibrary) + return { stderr: errorMsg, stdout: '', error: errorMsg } + } + + return { stderr: '', stdout: `Successfully imported ${appName}` } +} + +export async function update( + appName: string +): Promise<{ status: 'done' | 'error' }> { + try { + logInfo(`Updating custom game ${appName}`, LogPrefix.CustomLibrary) + const gameInfo = getGameInfo(appName) + + const currentInstallPath = gameInfo.install.install_path + if (!currentInstallPath) { + logError(`No install path found for ${appName}`, LogPrefix.CustomLibrary) + return { status: 'error' } + } + + // Get the parent directory (where we'll reinstall) + const installParentPath = join(currentInstallPath, '..') + + const installArgs: InstallArgs = { + path: installParentPath, + platformToInstall: gameInfo.install.platform as InstallPlatform, + installLanguage: gameInfo.install.language, + build: undefined, + branch: undefined + } + + logInfo(`Uninstalling ${appName} for update`, LogPrefix.CustomLibrary) + + // Uninstall the current version + const uninstallResult = await uninstall({ + appName, + shouldRemovePrefix: false + }) + if (uninstallResult.error) { + logError( + `Failed to uninstall ${appName} during update: ${uninstallResult.error}`, + LogPrefix.CustomLibrary + ) + return { status: 'error' } + } + + logInfo(`Reinstalling ${appName} with new version`, LogPrefix.CustomLibrary) + + const currentVersion = + getCachedCustomLibraryEntry(appName)?.version || '1.0.0' + + // Reinstall with the new version + const installResult = await install(appName, installArgs, currentVersion) + if (installResult.status === 'error') { + logError( + `Failed to reinstall ${appName} during update: ${installResult.error}`, + LogPrefix.CustomLibrary + ) + return { status: 'error' } + } + + logInfo(`Successfully updated ${appName}`, LogPrefix.CustomLibrary) + return { status: 'done' } + } catch (error) { + logError(`Update failed for ${appName}: ${error}`, LogPrefix.CustomLibrary) + 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..b5f9a6b75b --- /dev/null +++ b/src/backend/storeManagers/customLibraries/library.ts @@ -0,0 +1,335 @@ +import { + ExecResult, + InstalledInfo, + LaunchOption, + CustomLibraryGameInfo +} from 'common/types' +import { libraryStore, installedGamesStore } from './electronStores' +import { logInfo, logWarning, LogPrefix } from 'backend/logger' +import { sendFrontendMessage } from 'backend/ipc' +import { getFileSize } from 'backend/utils' +import { backendEvents } from 'backend/backend_events' +import { existsSync } from 'fs' +import type { InstallPlatform } from 'common/types' +import { getCustomLibraries } from 'backend/storeManagers/customLibraries/customLibraryManager' +import { CustomLibraryInstallInfo } from 'common/types/customLibraries' + +const installedGames: Map = new Map() + +/** + * Loads installed data and adds it into a Map + */ +export function refreshInstalled() { + const installedArray = installedGamesStore.get('installed', []) + installedGames.clear() + installedArray.forEach((value) => { + if (!value.appName) { + return + } + installedGames.set(value.appName, value) + }) +} + +export async function initCustomLibraryManager() { + logInfo('Initializing Custom Library Manager with URL-based JSON files') + + // Listen for settings changes + backendEvents.on('settingChanged', ({ key }) => { + if (key === 'customLibraryUrls' || key === 'customLibraryConfigs') { + logInfo(`Custom library ${key} setting changed, refreshing libraries`) + refresh().catch((error) => { + logWarning( + `Failed to refresh custom libraries after settings change: ${error}` + ) + }) + } + }) + + await refresh() +} + +// Add this function like GOG's loadLocalLibrary +async function loadLocalLibrary() { + logInfo('Loading local custom library', LogPrefix.CustomLibrary) + for (const game of libraryStore.get('games', [])) { + const copyObject = { ...game } + if (installedGames.has(game.app_name)) { + logInfo( + `Found installed game in local library: ${game.app_name}`, + LogPrefix.CustomLibrary + ) + copyObject.install = installedGames.get(game.app_name)! + copyObject.is_installed = true + } + // Note: We don't need to store this in a separate library map like GOG does + // since we reconstruct the library each time + } +} + +export async function refresh(): Promise { + try { + refreshInstalled() + await loadLocalLibrary() + + const allGames: CustomLibraryGameInfo[] = [] + let totalLoadedLibraries = 0 + + const libraries = await getCustomLibraries() + + for (const config of libraries) { + try { + totalLoadedLibraries++ + + // Store each game's original config data for quick lookup + for (const game of config.games) { + // Use unique app name for installed games lookup + const installedInfo = installedGames.get(game.app_name) + + logInfo( + `Processing game ${game.app_name}: ${installedInfo ? 'INSTALLED' : 'NOT INSTALLED'}`, + LogPrefix.CustomLibrary + ) + + const gameInfo: CustomLibraryGameInfo = { + runner: 'customLibrary', + app_name: game.app_name, + title: game.title, + art_cover: game.art_cover || '', + art_square: game.art_square || '', + install: { + executable: game.executable, + platform: (game.platform as InstallPlatform) || 'windows', + install_path: `/${game.app_name}`, + install_size: game.install_size_bytes + ? getFileSize(game.install_size_bytes) + : '', + version: game.version, + is_dlc: false + }, + is_installed: false, + canRunOffline: true, + folder_name: game.app_name, // Use unique name for folder + description: game.description, + developer: game.developer, + extra: { + about: { + description: game.description || '', + shortDescription: game.description || '' + }, + reqs: [], + storeUrl: '', + releaseDate: game.release_date, + genres: game.genres + }, + customLibraryId: config.name, + customLibraryName: config.name, + installSizeBytes: game.install_size_bytes, + installTasks: game.install_tasks || [], + uninstallTasks: game.uninstall_tasks || [], + launchOptions: game.launch_options || [], + parameters: game.parameters || '', + launchFromCmd: game.launch_from_cmd || false + } + + // Set installation status + if (installedInfo) { + gameInfo.is_installed = true + gameInfo.install = installedInfo + logInfo( + `Set ${game.app_name} as installed with info:`, + LogPrefix.CustomLibrary + ) + } else { + logInfo( + `${game.app_name} is not installed`, + LogPrefix.CustomLibrary + ) + } + + allGames.push(gameInfo) + logInfo( + `Added game: ${gameInfo.title} (${gameInfo.app_name}) - installed: ${gameInfo.is_installed}` + ) + } + } catch (error) { + logWarning(`Error processing library ${config.name}: ${error}`) + } + } + + libraryStore.set('games', allGames) + sendFrontendMessage('refreshLibrary', 'customLibrary') + + logInfo( + `Successfully loaded ${allGames.length} custom games from ${totalLoadedLibraries} libraries` + ) + return { + stdout: `Loaded ${allGames.length} games from ${totalLoadedLibraries} custom libraries`, + stderr: '' + } + } catch (error) { + logWarning(`Error refreshing custom libraries: ${error}`) + return { stdout: '', stderr: String(error) } + } +} + +export function getGameInfo(appName: string): CustomLibraryGameInfo { + const games = libraryStore.get('games', []) + return games.find( + (game) => game.app_name === appName + ) as CustomLibraryGameInfo +} + +export async function getInstallInfo( + appName: string +): Promise { + const gameInfo = getGameInfo(appName) + + // Default sizes + let installSizeBytes = 100 * 1024 * 1024 // 100MB default + let downloadSizeBytes = 100 * 1024 * 1024 // 100MB default + + // Use install_size_bytes from the original game data if available + if (gameInfo?.installSizeBytes) { + installSizeBytes = gameInfo.installSizeBytes + downloadSizeBytes = gameInfo.installSizeBytes // Assume download size equals install size + } + + // Return install info that satisfies the frontend requirements + return { + game: { + app_name: appName, + title: gameInfo.title, + version: gameInfo?.version || '1.0.0', + owned_dlc: [] + }, + manifest: { + app_name: appName, + disk_size: installSizeBytes, + download_size: downloadSizeBytes, + languages: ['en'], + versionEtag: '' + } + } +} + +export async function importGame( + gameInfo: CustomLibraryGameInfo, + installPath: string, + platform: InstallPlatform +): Promise { + // Basic validation + if (!existsSync(installPath)) { + throw new Error(`Import path does not exist: ${installPath}`) + } + + if (!gameInfo.install?.executable) { + throw new Error( + `Game ${gameInfo.app_name} has no executable defined in library` + ) + } + + // Create installation info + const installedData: InstalledInfo = { + platform, + executable: gameInfo.install.executable, + install_path: installPath, + install_size: gameInfo.install.install_size || '0', + is_dlc: false, + version: gameInfo.install.version || '1.0', + appName: gameInfo.app_name + } + + // Update installed games store + const installedArray = installedGamesStore.get('installed', []) + const filteredArray = installedArray.filter( + (item) => item.appName !== gameInfo.app_name + ) + filteredArray.push(installedData) + installedGamesStore.set('installed', filteredArray) + + // Update game library + const games = libraryStore.get('games', []) + const gameIndex = games.findIndex( + (game) => game.app_name === gameInfo.app_name + ) + if (gameIndex !== -1) { + games[gameIndex].is_installed = true + games[gameIndex].install = installedData + libraryStore.set('games', games) + } + + // Refresh and notify + refreshInstalled() + sendFrontendMessage('pushGameToLibrary', games[gameIndex]) +} + +export function installState() { + logWarning(`installState not implemented on Sideload Library Manager`) +} + +export async function listUpdateableGames(): Promise { + try { + logInfo('Checking for custom library game updates', LogPrefix.CustomLibrary) + + const updateableGames: string[] = [] + const libraries = await getCustomLibraries() + + for (const config of libraries) { + for (const game of config.games) { + const installedInfo = installedGames.get(game.app_name) + + // Only check installed games + if (!installedInfo) { + continue + } + + // Compare library config version with installed version + const libraryVersion = game.version || '1.0.0' + const installedVersion = installedInfo.version || '1.0.0' + + if (libraryVersion !== installedVersion) { + logInfo( + `Update available for ${game.app_name}: ${installedVersion} -> ${libraryVersion}`, + LogPrefix.CustomLibrary + ) + updateableGames.push(game.app_name) + } else { + logInfo( + `${game.app_name} is up to date (${installedVersion})`, + LogPrefix.CustomLibrary + ) + } + } + } + + logInfo( + `Found ${updateableGames.length} custom library game(s) to update`, + LogPrefix.CustomLibrary + ) + + return updateableGames + } catch (error) { + logWarning(`Error checking for custom library updates: ${error}`) + 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[] => + getGameInfo(appName).launchOptions + +export function changeVersionPinnedStatus() { + logWarning( + 'changeVersionPinnedStatus not implemented on Sideload Library Manager' + ) +} diff --git a/src/backend/storeManagers/customLibraries/taskExecutor.ts b/src/backend/storeManagers/customLibraries/taskExecutor.ts new file mode 100644 index 0000000000..fc49334ae8 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/taskExecutor.ts @@ -0,0 +1,75 @@ +import { + CustomLibraryTask, + CustomLibraryTaskType +} from 'backend/storeManagers/customLibraries/tasks/types' +import { logInfo, LogPrefix, logError } from 'backend/logger' +import { executeDownloadTask } from './tasks/downloadTask' +import { executeExtractTask } from './tasks/extractTask' +import { executeRunTask } from './tasks/runTask' +import { executeMoveTask } from './tasks/moveTask' +import { showDialogBoxModalAuto } from 'backend/dialog/dialog' +import i18next from 'i18next' + +/** + * Executes a list of tasks in array order for custom library games + */ +export async function executeTasks( + appName: string, + tasks: CustomLibraryTask[], + gameFolder: string, + context: 'install' | 'uninstall' +): Promise { + logInfo(`Starting ${context} tasks for ${appName}`, LogPrefix.CustomLibrary) + + for (const [index, task] of tasks.entries()) { + logInfo( + `Executing ${task.type} task (${index + 1}/${tasks.length})`, + LogPrefix.CustomLibrary + ) + + try { + switch (task.type) { + case 'download': + await executeDownloadTask(appName, task, gameFolder) + break + case 'extract': + await executeExtractTask(task, gameFolder) + break + case 'run': + await executeRunTask(appName, task, gameFolder) + break + case 'move': + await executeMoveTask(task, gameFolder, appName) + break + default: { + const unknownTask = task as { type: CustomLibraryTaskType } + throw new Error(`Unknown task type: ${unknownTask.type}`) + } + } + + logInfo(`Completed ${task.type} task`, LogPrefix.CustomLibrary) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + logError( + `Failed to execute ${task.type} task: ${errorMessage}`, + LogPrefix.CustomLibrary + ) + + showDialogBoxModalAuto({ + title: i18next.t( + 'box.error.uncaught-exception.title', + 'Uncaught Exception occured!' + ), + message: `Failed to execute ${task.type} task: ${errorMessage}`, + type: 'ERROR' + }) + throw error + } + } + + logInfo( + `Completed all ${context} tasks for ${appName}`, + LogPrefix.CustomLibrary + ) +} diff --git a/src/backend/storeManagers/customLibraries/tasks/downloadTask.ts b/src/backend/storeManagers/customLibraries/tasks/downloadTask.ts new file mode 100644 index 0000000000..01ecd69026 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/tasks/downloadTask.ts @@ -0,0 +1,113 @@ +import { join } from 'path' +import { axiosClient, downloadFile, sendProgressUpdate } from 'backend/utils' +import { DownloadTask } from 'backend/storeManagers/customLibraries/tasks/types' + +export async function executeDownloadTask( + appName: string, + task: DownloadTask, + gameFolder: string +): Promise { + const destination = task.destination || '' + const destinationFolder = join(gameFolder, destination) + + // Determine filename + let filename = task.filename + if (!filename) { + filename = await determineDownloadFilename(task.url) + } + + if (!filename) { + throw new Error('Could not determine filename for download') + } + + const downloadPath = join(destinationFolder, filename) + + await downloadFile({ + url: task.url, + dest: downloadPath, + progressCallback: (bytes: number, speed: number, percentage: number) => { + sendProgressUpdate({ + appName, + runner: 'customLibrary', + status: 'installing', + progress: { + bytes: `${Math.round((bytes / 1024 / 1024) * 100) / 100}MB`, + eta: '', + percent: Math.round(percentage) + } + }) + } + }) +} + +async function determineDownloadFilename(downloadUrl: string): Promise { + let filename = '' + + try { + const response = await axiosClient.head(downloadUrl) + const contentDisposition = response.headers['content-disposition'] + const filenameMatch = contentDisposition?.match( + /filename\*?=(?:"([^"]+)"|([^;]+))/i + ) + if (filenameMatch) { + filename = filenameMatch[1] || filenameMatch[2] + // Handle URL encoding in filename* format + if (contentDisposition.includes('filename*=')) { + try { + filename = decodeURIComponent(filename.split("''")[1] || filename) + } catch { + // Use as-is if decoding fails + } + } + filename = filename.trim().replace(/^"|"$/g, '') // Remove surrounding quotes + } + } catch { + // If HEAD request fails, fall back to URL parsing + } + + if (filename) { + return filename + } + + // Fallback to URL parsing if Content-Disposition didn't provide a filename + try { + const url = new URL(downloadUrl) + const pathFilename = url.pathname.split('/').pop() + + if ( + pathFilename && + pathFilename !== '/' && + !pathFilename.includes('.php') && + !pathFilename.includes('.asp') + ) { + filename = pathFilename + } else { + const allowedExtensions = + /\.(zip|exe|msi|dmg|pkg|tar|gz|rar|7z|deb|rpm|iso|bin|apk)$/i + // Check all URL parameters for potential filenames + for (const [, value] of url.searchParams.entries()) { + if (!value) continue + + try { + // First try to parse as URL to extract filename + const paramUrl = new URL(value) + const paramFilename = paramUrl.pathname.split('/').pop() + if (paramFilename && allowedExtensions.test(paramFilename)) { + filename = paramFilename + break + } + } catch { + // If it's not a URL, check if the value itself looks like a filename + if (allowedExtensions.test(value)) { + filename = value + break + } + } + } + } + } catch { + // Use default filename + } + + return filename +} diff --git a/src/backend/storeManagers/customLibraries/tasks/extractTask.ts b/src/backend/storeManagers/customLibraries/tasks/extractTask.ts new file mode 100644 index 0000000000..571503836b --- /dev/null +++ b/src/backend/storeManagers/customLibraries/tasks/extractTask.ts @@ -0,0 +1,28 @@ +import { join } from 'path' +import { existsSync, rmSync } from 'graceful-fs' +import { ExtractTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { extractFiles } from 'backend/utils' + +export async function executeExtractTask( + task: ExtractTask, + gameFolder: string +): Promise { + const sourcePath = join(gameFolder, task.source) + const destinationPath = join(gameFolder, task.destination || '') + + if (!existsSync(sourcePath)) { + throw new Error(`Source file not found: ${sourcePath}`) + } + + const result = await extractFiles({ + path: sourcePath, + destination: destinationPath, + strip: 0 + }) + + if (result?.status === 'error') { + throw new Error(`Extraction failed: ${result.error}`) + } + + rmSync(sourcePath) +} diff --git a/src/backend/storeManagers/customLibraries/tasks/moveTask.ts b/src/backend/storeManagers/customLibraries/tasks/moveTask.ts new file mode 100644 index 0000000000..ba2e63e96f --- /dev/null +++ b/src/backend/storeManagers/customLibraries/tasks/moveTask.ts @@ -0,0 +1,140 @@ +import { join, isAbsolute, dirname } from 'path' +import { existsSync, mkdirSync, rmSync, renameSync } from 'graceful-fs' +import { logInfo, LogPrefix } from 'backend/logger' +import { MoveTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { isWindows } from 'backend/constants/environment' +import { getSettings, isNative, getGameInfo } from '../games' +import { spawnAsync } from 'backend/utils' + +export async function executeMoveTask( + task: MoveTask, + gameFolder: string, + appName?: string +): Promise { + const source = await substituteVariables(task.source, gameFolder, appName) + const destination = await substituteVariables( + task.destination, + gameFolder, + appName + ) + + // Handle absolute vs relative paths + const sourcePath = isAbsolute(source) ? source : join(gameFolder, source) + const destinationPath = isAbsolute(destination) + ? destination + : join(gameFolder, destination) + + if (!existsSync(sourcePath)) { + throw new Error(`Source path not found: ${sourcePath}`) + } + + logInfo( + `Moving: ${sourcePath} to ${destinationPath} (Started)`, + LogPrefix.CustomLibrary + ) + + try { + // Create destination directory if it doesn't exist + const destinationDir = dirname(destinationPath) + if (destinationDir && !existsSync(destinationDir)) { + mkdirSync(destinationDir, { recursive: true }) + } + + // Check if destination already exists + if (existsSync(destinationPath)) { + logInfo( + `Destination already exists, removing: ${destinationPath}`, + LogPrefix.CustomLibrary + ) + rmSync(destinationPath, { recursive: true, force: true }) + } + + // Use robust move operation based on platform + if (isWindows) { + await moveOnWindowsSimple(sourcePath, destinationPath) + } else { + renameSync(sourcePath, destinationPath) + } + + logInfo( + `Moving: ${sourcePath} to ${destinationPath} (Done)`, + LogPrefix.CustomLibrary + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to move ${sourcePath} to ${destinationPath}: ${errorMessage}` + ) + } +} + +async function moveOnWindowsSimple( + sourcePath: string, + destinationPath: string +): Promise { + // Use robocopy like the existing moveOnWindows function + const { code, stderr } = await spawnAsync('robocopy', [ + sourcePath, + destinationPath, + '/MOVE', + '/MIR', + '/NJH', + '/NJS', + '/NDL', + '/R:3', // Retry 3 times + '/W:10' // Wait 10 seconds between retries + ]) + + // Robocopy exit codes: 0-7 are success, 8+ are errors + if (code !== null && code >= 8) { + throw new Error(`Move operation failed: ${stderr}`) + } +} + +async function substituteVariables( + path: string, + gameFolder: string, + appName?: string +): Promise { + let result = path.replace(/{gameFolder}/g, gameFolder) + + // Handle {C} variable for C: drive + if (result.includes('{C}') && appName) { + const cDrivePath = await getCDrivePath(appName) + result = result.replace(/{C}/g, cDrivePath) + } + + return result +} + +async function getCDrivePath(appName: string): Promise { + // On Windows, C: is just C:/ + if (isWindows) { + return 'C:' + } + + // On Linux/Mac, check if we need wine + const gameInfo = getGameInfo(appName) + const requiresWine = + !isNative(appName) && gameInfo.install.platform === 'windows' + + if (!requiresWine) { + // If not using wine, just return C: (though this might not make sense) + logInfo( + 'Game does not require wine, but {C} variable used. Using C:', + LogPrefix.CustomLibrary + ) + return 'C:' + } + + // Get wine prefix path + const gameSettings = await getSettings(appName) + const { winePrefix, wineVersion } = gameSettings + + // For Proton, the actual prefix is in the 'pfx' subdirectory + const actualPrefix = + wineVersion.type === 'proton' ? join(winePrefix, 'pfx') : winePrefix + + // C: drive is in drive_c folder + return join(actualPrefix, 'drive_c') +} diff --git a/src/backend/storeManagers/customLibraries/tasks/runTask.ts b/src/backend/storeManagers/customLibraries/tasks/runTask.ts new file mode 100644 index 0000000000..c6d1f02342 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/tasks/runTask.ts @@ -0,0 +1,93 @@ +import { join } from 'path' +import { existsSync } from 'graceful-fs' +import { logInfo, LogPrefix } from 'backend/logger' +import { isLinux, isMac } from 'backend/constants/environment' +import { getSettings, isNative, getGameInfo } from '../games' +import { runWineCommand } from 'backend/launcher' +import { spawnAsync } from 'backend/utils' +import { RunTask } from 'backend/storeManagers/customLibraries/tasks/types' + +export async function executeRunTask( + appName: string, + task: RunTask, + gameFolder: string +): Promise { + const executablePath = join(gameFolder, task.executable) + + if (!existsSync(executablePath)) { + throw new Error(`Executable not found: ${executablePath}`) + } + + const gameInfo = getGameInfo(appName) + const requiresWine = + !isNative(appName) && gameInfo.install.platform === 'windows' + const args = substituteVariables(task.args || [], gameFolder) + + logInfo(`Running: ${task.executable} (Started)`, LogPrefix.CustomLibrary) + + if (requiresWine) { + logInfo( + `Running in Wine: ${[executablePath, ...args].join(' ')}`, + LogPrefix.CustomLibrary + ) + + const gameSettings = await getSettings(appName) + const result = await runWineCommand({ + gameSettings, + commandParts: [executablePath, ...args], + wait: true, + gameInstallPath: gameFolder, + startFolder: gameFolder + }) + + if (result.code !== 0) { + throw new Error( + `Wine execution failed with code ${result.code}: ${result.stderr}` + ) + } + } else { + // Make executable on Unix systems + if (isLinux || isMac) { + await makeExecutable(executablePath) + } + + const powershellArgs = [ + '-Command', + [ + 'Start-Process', + '-Wait', + `"${executablePath}"`, + args.length ? `-ArgumentList '${args.join("','")}'` : '', + '-WorkingDirectory', + `"${gameFolder}"`, + '-Verb', + 'RunAs' + ] + .filter(Boolean) + .join(' ') + ] + + const { code, stderr } = await spawnAsync('powershell', powershellArgs, { + stdio: 'inherit' + }) + + if (code !== 0) { + throw new Error(`Process failed with code ${code}: ${stderr}`) + } + } + + logInfo(`Running: ${task.executable} (Done)`, LogPrefix.CustomLibrary) +} + +function substituteVariables(args: string[], gameFolder: string): string[] { + return args.map((arg) => arg.replace(/{gameFolder}/g, gameFolder)) +} + +async function makeExecutable(executablePath: string): Promise { + if (isLinux || isMac) { + const { code, stderr } = await spawnAsync('chmod', ['+x', executablePath]) + if (code !== 0) { + throw new Error(`chmod failed with code ${code}: ${stderr}`) + } + } +} diff --git a/src/backend/storeManagers/customLibraries/tasks/types.ts b/src/backend/storeManagers/customLibraries/tasks/types.ts new file mode 100644 index 0000000000..5bfeb73b92 --- /dev/null +++ b/src/backend/storeManagers/customLibraries/tasks/types.ts @@ -0,0 +1,32 @@ +export type CustomLibraryTaskType = 'download' | 'extract' | 'run' | 'move' + +interface BaseCustomLibraryTask { + type: CustomLibraryTaskType +} + +export interface DownloadTask extends BaseCustomLibraryTask { + type: 'download' + url: string + filename?: string + destination?: string +} + +export interface ExtractTask extends BaseCustomLibraryTask { + type: 'extract' + source: string + destination?: string +} + +export interface RunTask extends BaseCustomLibraryTask { + type: 'run' + executable: string + args?: string[] +} + +export interface MoveTask extends BaseCustomLibraryTask { + type: 'move' + source: string + destination: string +} + +export type CustomLibraryTask = DownloadTask | ExtractTask | RunTask | MoveTask 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/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/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'], 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) { 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/backend/storeManagers/storeManagerCommon/games.ts b/src/backend/storeManagers/storeManagerCommon/games.ts index c313f0d54a..396f85f56e 100644 --- a/src/backend/storeManagers/storeManagerCommon/games.ts +++ b/src/backend/storeManagers/storeManagerCommon/games.ts @@ -123,7 +123,8 @@ export async function launchGame( logWriter: LogWriter, gameInfo: GameInfo, runner: Runner, - args: string[] = [] + args: string[] = [], + launchFromCmd: boolean = false ): Promise { if (!gameInfo) { return false @@ -242,7 +243,8 @@ export async function launchGame( env, wrappers, logWriters: [logFileWriter], - logMessagePrefix: LogPrefix.Backend + logMessagePrefix: LogPrefix.Backend, + launchFromCmd } ) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 302cf9bb69..187c7ba2fd 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -83,6 +83,7 @@ import { import { parse } from '@node-steam/vdf' import type LogWriter from 'backend/logger/log_writer' +import * as fs from 'fs/promises' const execAsync = promisify(exec) @@ -1559,6 +1560,8 @@ interface ExtractOptions { async function extractFiles({ path, destination, strip = 0 }: ExtractOptions) { if (path.includes('.tar')) return extractTarFile({ path, destination, strip }) + if (path.includes('.zip')) return extractZip({ path, destination, strip }) + if (path.includes('.iso')) return extractIso({ path, destination, strip }) logError(['extractFiles: Unsupported file', path], LogPrefix.Backend) return { status: 'error', error: 'Unsupported file type' } @@ -1583,6 +1586,156 @@ async function extractTarFile({ return { status: 'done', installPath: destination } } +async function extractZip({ path, destination }: ExtractOptions) { + if (isWindows) { + const { code, stderr } = await spawnAsync('powershell', [ + '-Command', + `Expand-Archive -Path '${path}' -DestinationPath '${destination}' -Force` + ]) + if (code !== 0) { + logError(`Extracting Error: ${stderr}`, LogPrefix.Backend) + return { status: 'error', error: stderr } + } + return { status: 'done', installPath: destination } + } else { + const { code, stderr } = await spawnAsync('unzip', [ + '-o', + path, + '-d', + destination + ]) + if (code !== 0) { + logError(`Extracting Error: ${stderr}`, LogPrefix.Backend) + return { status: 'error', error: stderr } + } + return { status: 'done', installPath: destination } + } +} + +async function extractIso({ path, destination }: ExtractOptions) { + if (isWindows) { + // Windows 8+ can natively mount ISO files using PowerShell + const mountResult = await spawnAsync('powershell', [ + '-Command', + `$mount = Mount-DiskImage -ImagePath '${path}' -PassThru; $mount | Get-Volume | Select-Object -ExpandProperty DriveLetter` + ]) + + if (mountResult.code !== 0) { + logError(`ISO Mount Error: ${mountResult.stderr}`, LogPrefix.Backend) + return { status: 'error', error: mountResult.stderr } + } + + const driveLetter = mountResult.stdout.trim() + if (!driveLetter) { + return { status: 'error', error: 'Failed to get drive letter' } + } + + const mountPoint = `${driveLetter}:` + + // Ensure destination directory exists, or it will create a file instead of a directory + await fs.mkdir(destination, { recursive: true }) + + // Copy files from mounted drive to destination + const copyResult = await spawnAsync('powershell', [ + '-Command', + `Copy-Item -Path '${mountPoint}\\*' -Destination '${destination}' -Recurse -Force` + ]) + + // Always try to unmount the ISO + await spawnAsync('powershell', [ + '-Command', + `Dismount-DiskImage -ImagePath '${path}'` + ]) + + if (copyResult.code !== 0) { + logError(`ISO Copy Error: ${copyResult.stderr}`, LogPrefix.Backend) + return { status: 'error', error: copyResult.stderr } + } + + // Fix permissions: Remove read-only attribute from all files + await spawnAsync('powershell', [ + '-Command', + `Get-ChildItem -Path '${destination}' -Recurse | ForEach-Object { $_.Attributes = $_.Attributes -band -bnot [System.IO.FileAttributes]::ReadOnly }` + ]) + } else if (isMac) { + // macOS implementation + const tempMount = join(destination, '.temp_mount') + await fs.mkdir(tempMount, { recursive: true }) + + const mountResult = await spawnAsync('hdiutil', [ + 'attach', + path, + '-mountpoint', + tempMount, + '-nobrowse', + '-readonly' + ]) + + if (mountResult.code !== 0) { + logError(`ISO Mount Error: ${mountResult.stderr}`, LogPrefix.Backend) + return { status: 'error', error: mountResult.stderr } + } + + // Copy files from mount point to destination using fs operations + const copyResult = await spawnAsync('sh', [ + '-c', + `cp -R "${tempMount}"/* "${destination}"` + ]) + + // Unmount + await spawnAsync('hdiutil', ['detach', tempMount]) + + // Remove temp mount point + await spawnAsync('rm', ['-rf', tempMount]) + + if (copyResult.code !== 0) { + logError(`ISO Copy Error: ${copyResult.stderr}`, LogPrefix.Backend) + return { status: 'error', error: copyResult.stderr } + } + + // Fix permissions: Make all files writable + await spawnAsync('chmod', ['-R', 'u+w', destination]) + } else if (isLinux) { + // Linux implementation + const tempMount = join(destination, '.temp_mount') + await fs.mkdir(tempMount, { recursive: true }) + + const mountResult = await spawnAsync('mount', [ + '-o', + 'loop,ro', + path, + tempMount + ]) + + if (mountResult.code !== 0) { + logError(`ISO Mount Error: ${mountResult.stderr}`, LogPrefix.Backend) + return { status: 'error', error: mountResult.stderr } + } + + // Copy files + const copyResult = await spawnAsync('sh', [ + '-c', + `cp -R "${tempMount}"/* "${destination}"` + ]) + + // Unmount + await spawnAsync('umount', [tempMount]) + + // Remove temp mount point + await spawnAsync('rm', ['-rf', tempMount]) + + if (copyResult.code !== 0) { + logError(`ISO Copy Error: ${copyResult.stderr}`, LogPrefix.Backend) + return { status: 'error', error: copyResult.stderr } + } + + // Fix permissions: Make all files writable + await spawnAsync('chmod', ['-R', 'u+w', destination]) + } + + return { status: 'done', installPath: destination } +} + const axiosClient = axios.create({ timeout: 10 * 1000, httpsAgent: new https.Agent({ keepAlive: true }) 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 aa914c5780..600e5d1820 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -14,8 +14,10 @@ import type { HowLongToBeatEntry } from 'backend/wiki_game_info/howlongtobeat/ut import { NileInstallInfo, NileInstallPlatform } from './types/nile' import type { Path } from 'backend/schemas' import type LogWriter from 'backend/logger/log_writer' +import { CustomLibraryTask } from 'backend/storeManagers/customLibraries/tasks/types' +import { CustomLibraryInstallInfo } from './types/customLibraries' -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 @@ -96,6 +98,8 @@ export interface AppSettings extends GameSettings { customCSS: string customThemesPath: string customWinePaths: string[] + customLibraryUrls: string[] + customLibraryConfigs: string[] darkTrayIcon: boolean defaultInstallPath: string defaultSteamPath: string @@ -151,8 +155,14 @@ 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 + | CustomLibraryGameInfo + +interface BaseGameInfo { store_url?: string app_name: string art_cover: string @@ -172,7 +182,6 @@ export interface GameInfo { 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 @@ -188,6 +197,35 @@ 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' + gog_save_location?: GOGCloudSavesLocation[] +} + +export interface NileGameInfo extends BaseGameInfo { + runner: 'nile' +} + +export interface CustomLibraryGameInfo extends BaseGameInfo { + runner: 'customLibrary' + customLibraryId: string + customLibraryName: string + installSizeBytes?: number + installTasks: CustomLibraryTask[] + uninstallTasks: CustomLibraryTask[] + launchOptions: LaunchOption[] + parameters: string + launchFromCmd: boolean +} + export interface GameSettings { autoInstallDxvk: boolean autoInstallVkd3d: boolean @@ -406,6 +444,7 @@ export interface CallRunnerOptions { wrappers?: string[] onOutput?: (output: string, child: ChildProcess) => void abortId?: string + launchFromCmd?: boolean } export interface EnviromentVariable { @@ -766,6 +805,7 @@ export type InstallInfo = | LegendaryInstallInfo | GogInstallInfo | NileInstallInfo + | CustomLibraryInstallInfo export interface KnowFixesInfo { title: string diff --git a/src/common/types/customLibraries.ts b/src/common/types/customLibraries.ts new file mode 100644 index 0000000000..de77fcf997 --- /dev/null +++ b/src/common/types/customLibraries.ts @@ -0,0 +1,19 @@ +interface GameInstallInfo { + app_name: string + title: string + version: string + owned_dlc: Array<{ app_name: string; title: string }> +} + +interface GameManifest { + app_name: string + disk_size: number + download_size: number + languages: Array + versionEtag: string +} + +export interface CustomLibraryInstallInfo { + game: GameInstallInfo + manifest: GameManifest +} 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/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 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..9ce8de3b5c 100644 --- a/src/frontend/components/UI/LibraryFilters/index.tsx +++ b/src/frontend/components/UI/LibraryFilters/index.tsx @@ -1,22 +1,33 @@ -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 } = useContext(ContextProvider) + const { platform, epic, gog, amazon, customLibrary } = + useContext(ContextProvider) const { setShowFavourites, setShowHidden, @@ -38,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) } @@ -66,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) => { @@ -83,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) => { @@ -131,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 = () => { @@ -148,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, @@ -178,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/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/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() { /> )} + + ( + '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/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) { 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/Game/ModifyInstallModal/index.tsx b/src/frontend/screens/Game/ModifyInstallModal/index.tsx index 3c93373bd7..431768704d 100644 --- a/src/frontend/screens/Game/ModifyInstallModal/index.tsx +++ b/src/frontend/screens/Game/ModifyInstallModal/index.tsx @@ -12,6 +12,7 @@ import { NileInstallInfo } from 'common/types/nile' import { useTranslation } from 'react-i18next' import LegendaryModifyInstallModal from './Legendary' import GOGModifyInstallModal from './GOG' +import { CustomLibraryInstallInfo } from 'common/types/customLibraries' interface ModifyInstallProps { gameInfo: GameInfo @@ -19,6 +20,7 @@ interface ModifyInstallProps { | LegendaryInstallInfo | GogInstallInfo | NileInstallInfo + | CustomLibraryInstallInfo | null onClose: () => void } diff --git a/src/frontend/screens/Library/index.tsx b/src/frontend/screens/Library/index.tsx index 0371f4be46..e01eea7a63 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) } } @@ -378,23 +381,29 @@ export default React.memo(function Library(): JSX.Element { return favourites.map((game) => `${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') @@ -402,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(() => { @@ -541,7 +561,8 @@ export default React.memo(function Library(): JSX.Element { showSupportOfflineOnly, showThirdPartyManagedOnly, showUpdatesOnly, - gameUpdates + gameUpdates, + customLibrary ]) // select library 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..1f933303f3 --- /dev/null +++ b/src/frontend/screens/Settings/components/CustomLibraryUrls.tsx @@ -0,0 +1,164 @@ +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('options.custom_library_urls.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('options.custom_library_urls.configs', 'Direct JSON Configurations')} +

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