diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f9a683bf3..c93cad1a9 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -167,6 +167,9 @@ "loading_save_preview": "Searching for save games…", "wine_prefix": "Wine Prefix", "wine_prefix_description": "The Wine prefix used to run this game", + "launch_options": "Launch Options", + "launch_options_description": "Advanced users may choose to enter modifications to their launch options", + "launch_options_placeholder": "No parameter specified", "no_download_option_info": "No information available", "backup_deletion_failed": "Failed to delete backup", "max_number_of_artifacts_reached": "Maximum number of backups reached for this game", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 46f7e70f7..1c8801765 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -155,6 +155,9 @@ "loading_save_preview": "Buscando por arquivos de salvamento…", "wine_prefix": "Prefixo Wine", "wine_prefix_description": "O prefixo Wine que foi utilizado para instalar o jogo", + "launch_options": "Opções de Inicialização", + "launch_options_description": "Usuários avançados podem adicionar opções de inicialização no jogo", + "launch_options_placeholder": "Nenhum parâmetro informado", "no_download_option_info": "Sem informações disponíveis", "backup_deletion_failed": "Falha ao apagar backup", "max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo", diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 8dfc4fae4..0fcdcc77d 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -37,6 +37,9 @@ export class Game { @Column("text", { nullable: true }) executablePath: string | null; + @Column("text", { nullable: true }) + launchOptions: string | null; + @Column("text", { nullable: true }) winePrefixPath: string | null; diff --git a/src/main/events/helpers/parse-launch-options.ts b/src/main/events/helpers/parse-launch-options.ts new file mode 100644 index 000000000..e1b562590 --- /dev/null +++ b/src/main/events/helpers/parse-launch-options.ts @@ -0,0 +1,9 @@ +export const parseLaunchOptions = (params: string | null): string[] => { + if (params == null || params == "") { + return []; + } + + const paramsSplit = params.split(" "); + + return paramsSplit; +}; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 86b149887..d40539746 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -22,6 +22,7 @@ import "./library/open-game-executable-path"; import "./library/open-game-installer"; import "./library/open-game-installer-path"; import "./library/update-executable-path"; +import "./library/update-launch-options"; import "./library/verify-executable-path"; import "./library/remove-game"; import "./library/remove-game-from-library"; diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index de68cc533..f43dd1a9d 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -2,18 +2,31 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { shell } from "electron"; +import { spawn } from "child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; +import { parseLaunchOptions } from "../helpers/parse-launch-options"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, gameId: number, - executablePath: string + executablePath: string, + launchOptions: string | null ) => { const parsedPath = parseExecutablePath(executablePath); + const parsedParams = parseLaunchOptions(launchOptions); - await gameRepository.update({ id: gameId }, { executablePath: parsedPath }); + await gameRepository.update( + { id: gameId }, + { executablePath: parsedPath, launchOptions } + ); - shell.openPath(parsedPath); + if (process.platform === "linux" || process.platform === "darwin") { + shell.openPath(parsedPath); + } + + if (process.platform === "win32") { + spawn(parsedPath, parsedParams, { shell: false, detached: true }); + } }; registerEvent("openGame", openGame); diff --git a/src/main/events/library/update-launch-options.ts b/src/main/events/library/update-launch-options.ts new file mode 100644 index 000000000..b33d031c8 --- /dev/null +++ b/src/main/events/library/update-launch-options.ts @@ -0,0 +1,19 @@ +import { gameRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; + +const updateLaunchOptions = async ( + _event: Electron.IpcMainInvokeEvent, + id: number, + launchOptions: string | null +) => { + return gameRepository.update( + { + id, + }, + { + launchOptions: launchOptions?.trim() != "" ? launchOptions : null, + } + ); +}; + +registerEvent("updateLaunchOptions", updateLaunchOptions); diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts index 2c09a7b0b..821efc808 100644 --- a/src/main/knex-client.ts +++ b/src/main/knex-client.ts @@ -16,6 +16,7 @@ import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disab import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum"; import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download"; import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column "; +import { AddLaunchOptionsColumnToGame } from "./migrations/20241226044022_add_launch_options_column_to_game"; export type HydraMigration = Knex.Migration & { name: string }; @@ -37,6 +38,7 @@ class MigrationSource implements Knex.MigrationSource { AddShouldSeedColumn, AddSeedAfterDownloadColumn, AddHiddenAchievementDescriptionColumn, + AddLaunchOptionsColumnToGame, ]); } getMigrationName(migration: HydraMigration): string { diff --git a/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts b/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts new file mode 100644 index 000000000..417eeb63f --- /dev/null +++ b/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts @@ -0,0 +1,17 @@ +import type { HydraMigration } from "@main/knex-client"; +import type { Knex } from "knex"; + +export const AddLaunchOptionsColumnToGame: HydraMigration = { + name: "AddLaunchOptionsColumnToGame", + up: (knex: Knex) => { + return knex.schema.alterTable("game", (table) => { + return table.string("launchOptions").nullable(); + }); + }, + + down: async (knex: Knex) => { + return knex.schema.alterTable("game", (table) => { + return table.dropColumn("launchOptions"); + }); + }, +}; diff --git a/src/preload/index.ts b/src/preload/index.ts index e56e6797d..2a8ed69e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -104,6 +104,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("createGameShortcut", id), updateExecutablePath: (id: number, executablePath: string | null) => ipcRenderer.invoke("updateExecutablePath", id, executablePath), + updateLaunchOptions: (id: number, launchOptions: string | null) => + ipcRenderer.invoke("updateLaunchOptions", id, launchOptions), selectGameWinePrefix: (id: number, winePrefixPath: string | null) => ipcRenderer.invoke("selectGameWinePrefix", id, winePrefixPath), verifyExecutablePathInUse: (executablePath: string) => @@ -115,8 +117,11 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("openGameInstallerPath", gameId), openGameExecutablePath: (gameId: number) => ipcRenderer.invoke("openGameExecutablePath", gameId), - openGame: (gameId: number, executablePath: string) => - ipcRenderer.invoke("openGame", gameId, executablePath), + openGame: ( + gameId: number, + executablePath: string, + launchOptions: string | null + ) => ipcRenderer.invoke("openGame", gameId, executablePath, launchOptions), closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId), removeGameFromLibrary: (gameId: number) => ipcRenderer.invoke("removeGameFromLibrary", gameId), diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index f487681cd..355d04b20 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -154,7 +154,11 @@ export function Sidebar() { if (event.detail === 2) { if (game.executablePath) { - window.electron.openGame(game.id, game.executablePath); + window.electron.openGame( + game.id, + game.executablePath, + game.launchOptions + ); } else { showWarningToast(t("game_has_no_executable")); } diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index a6b6011b7..feec8284e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -93,6 +93,10 @@ declare global { id: number, executablePath: string | null ) => Promise; + updateLaunchOptions: ( + id: number, + launchOptions: string | null + ) => Promise; selectGameWinePrefix: ( id: number, winePrefixPath: string | null @@ -102,7 +106,11 @@ declare global { openGameInstaller: (gameId: number) => Promise; openGameInstallerPath: (gameId: number) => Promise; openGameExecutablePath: (gameId: number) => Promise; - openGame: (gameId: number, executablePath: string) => Promise; + openGame: ( + gameId: number, + executablePath: string, + launchOptions: string | null + ) => Promise; closeGame: (gameId: number) => Promise; removeGameFromLibrary: (gameId: number) => Promise; removeGame: (gameId: number) => Promise; diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index c1b8cff30..7027d1139 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -55,13 +55,21 @@ export function HeroPanelActions() { const openGame = async () => { if (game) { if (game.executablePath) { - window.electron.openGame(game.id, game.executablePath); + window.electron.openGame( + game.id, + game.executablePath, + game.launchOptions + ); return; } const gameExecutablePath = await selectGameExecutable(); if (gameExecutablePath) - window.electron.openGame(game.id, gameExecutablePath); + window.electron.openGame( + game.id, + gameExecutablePath, + game.launchOptions + ); } }; diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index e5c83ec48..69b459d1a 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Modal, TextField } from "@renderer/components"; import type { Game } from "@types"; @@ -8,6 +8,7 @@ import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { useDownload, useToast } from "@renderer/hooks"; import { RemoveGameFromLibraryModal } from "./remove-from-library-modal"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; +import { debounce } from "lodash-es"; export interface GameOptionsModalProps { visible: boolean; @@ -29,6 +30,7 @@ export function GameOptionsModal({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [showRemoveGameModal, setShowRemoveGameModal] = useState(false); + const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? ""); const { removeGameInstaller, @@ -44,6 +46,13 @@ export function GameOptionsModal({ const isGameDownloading = game.status === "active" && lastPacket?.game.id === game.id; + const debounceUpdateLaunchOptions = useRef( + debounce(async (value: string) => { + await window.electron.updateLaunchOptions(game.id, value); + updateGame(); + }, 1000) + ).current; + const handleRemoveGameFromLibrary = async () => { if (isGameDownloading) { await cancelDownload(game.id); @@ -116,9 +125,25 @@ export function GameOptionsModal({ updateGame(); }; + const handleChangeLaunchOptions = async (event) => { + const value = event.target.value; + + setLaunchOptions(value); + debounceUpdateLaunchOptions(value); + }; + + const handleClearLaunchOptions = async () => { + setLaunchOptions(""); + + window.electron.updateLaunchOptions(game.id, null).then(updateGame); + }; + const shouldShowWinePrefixConfiguration = window.electron.platform === "linux"; + const shouldShowLaunchOptionsConfiguration = + window.electron.platform === "win32"; + return ( <> )} + {shouldShowLaunchOptionsConfiguration && ( +
+

{t("launch_options")}

+

+ {t("launch_options_description")} +

+ + {t("clear")} + + ) + } + /> +
+ )} +

{t("downloads_secion_title")}

diff --git a/src/types/index.ts b/src/types/index.ts index e6ca334b6..d995426ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -115,6 +115,7 @@ export interface Game { downloader: Downloader; winePrefixPath: string | null; executablePath: string | null; + launchOptions: string | null; lastTimePlayed: Date | null; uri: string | null; fileSize: number;