From 520eb91a55de6c287c37f2b3e0d62d0bc7132d45 Mon Sep 17 00:00:00 2001 From: Davi Souto Date: Thu, 26 Dec 2024 20:43:16 -0300 Subject: [PATCH 1/4] feat: add custom launch options to game --- src/locales/en/translation.json | 3 ++ src/locales/pt-BR/translation.json | 3 ++ src/main/entity/game.entity.ts | 3 ++ .../events/helpers/parse-launch-options.ts | 9 ++++ src/main/events/index.ts | 1 + src/main/events/library/open-game.ts | 23 +++++++++- .../events/library/update-launch-options.ts | 19 ++++++++ src/main/knex-client.ts | 2 + ...44022_add_launch_options_column_to_game.ts | 17 +++++++ src/preload/index.ts | 9 +++- .../src/components/sidebar/sidebar.tsx | 6 ++- src/renderer/src/declaration.d.ts | 10 ++++- .../game-details/hero/hero-panel-actions.tsx | 12 ++++- .../modals/game-options-modal.tsx | 45 +++++++++++++++++++ src/types/index.ts | 1 + 15 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 src/main/events/helpers/parse-launch-options.ts create mode 100644 src/main/events/library/update-launch-options.ts create mode 100644 src/main/migrations/20241226044022_add_launch_options_column_to_game.ts 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..20ac34b9d 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -2,18 +2,37 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { shell } from "electron"; +import { exec } from "child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; +import { parseLaunchOptions } from "../helpers/parse-launch-options"; +import { logger } from "@main/services"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, gameId: number, - executablePath: string + executablePath: string, + launchOptions: string | null ) => { const parsedPath = parseExecutablePath(executablePath); + const parsedParams = parseLaunchOptions(launchOptions); + const executeCommand = `"${parsedPath}" ${parsedParams}`; await gameRepository.update({ id: gameId }, { executablePath: parsedPath }); - shell.openPath(parsedPath); + if (process.platform === "linux" || process.platform === "darwin") { + shell.openPath(parsedPath); + } + + if (process.platform === "win32") { + exec(executeCommand.trim(), (err) => { + if (err) { + logger.error( + `Error opening game #${gameId} with command ${executeCommand}`, + err + ); + } + }); + } }; 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..ad0360039 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 @@ -29,6 +29,7 @@ export function GameOptionsModal({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [showRemoveGameModal, setShowRemoveGameModal] = useState(false); + const [launchOptions, setLaunchOptions] = useState(""); const { removeGameInstaller, @@ -116,9 +117,26 @@ export function GameOptionsModal({ updateGame(); }; + const handleChangeLaunchOptions = async (event) => { + const value = event.target.value; + + setLaunchOptions(value); + + window.electron.updateLaunchOptions(game.id, value).then(updateGame); + }; + + 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")} +

+ + {game.launchOptions && ( + + )} + + } + /> +
+ )} +

{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; From e1904b853e95ec4bb3899a5c03be252f9cc7cf72 Mon Sep 17 00:00:00 2001 From: Davi Souto Date: Thu, 26 Dec 2024 21:45:18 -0300 Subject: [PATCH 2/4] fix: security and persistence adjustments --- src/main/events/library/open-game.ts | 15 +++------------ .../game-details/modals/game-options-modal.tsx | 2 +- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index 20ac34b9d..054a7cb50 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -2,10 +2,9 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { shell } from "electron"; -import { exec } from "child_process"; +import { spawn } from "child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; import { parseLaunchOptions } from "../helpers/parse-launch-options"; -import { logger } from "@main/services"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -15,23 +14,15 @@ const openGame = async ( ) => { const parsedPath = parseExecutablePath(executablePath); const parsedParams = parseLaunchOptions(launchOptions); - const executeCommand = `"${parsedPath}" ${parsedParams}`; - await gameRepository.update({ id: gameId }, { executablePath: parsedPath }); + await gameRepository.update({ id: gameId }, { executablePath: parsedPath, launchOptions }); if (process.platform === "linux" || process.platform === "darwin") { shell.openPath(parsedPath); } if (process.platform === "win32") { - exec(executeCommand.trim(), (err) => { - if (err) { - logger.error( - `Error opening game #${gameId} with command ${executeCommand}`, - err - ); - } - }); + spawn(parsedPath, parsedParams, { shell: false }); } }; 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 ad0360039..4fed0de1d 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 @@ -29,7 +29,7 @@ export function GameOptionsModal({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [showRemoveGameModal, setShowRemoveGameModal] = useState(false); - const [launchOptions, setLaunchOptions] = useState(""); + const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? ""); const { removeGameInstaller, From c098d8ffcf5a8951ef2b8df2633623c79913a737 Mon Sep 17 00:00:00 2001 From: Davi Souto Date: Thu, 26 Dec 2024 22:17:22 -0300 Subject: [PATCH 3/4] fix: added detached to the spawn to fix the game closing with the launcher --- src/main/events/library/open-game.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index 054a7cb50..f43dd1a9d 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -15,14 +15,17 @@ const openGame = async ( const parsedPath = parseExecutablePath(executablePath); const parsedParams = parseLaunchOptions(launchOptions); - await gameRepository.update({ id: gameId }, { executablePath: parsedPath, launchOptions }); + await gameRepository.update( + { id: gameId }, + { executablePath: parsedPath, launchOptions } + ); if (process.platform === "linux" || process.platform === "darwin") { shell.openPath(parsedPath); } if (process.platform === "win32") { - spawn(parsedPath, parsedParams, { shell: false }); + spawn(parsedPath, parsedParams, { shell: false, detached: true }); } }; From 423693040b433122b0735c4710aa5a0b90ed7bb8 Mon Sep 17 00:00:00 2001 From: Davi Souto Date: Fri, 27 Dec 2024 00:53:13 -0300 Subject: [PATCH 4/4] feat: added debounce to the launch options input and removed unnecessary fragment in the clear button --- .../modals/game-options-modal.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) 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 4fed0de1d..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; @@ -45,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); @@ -121,8 +129,7 @@ export function GameOptionsModal({ const value = event.target.value; setLaunchOptions(value); - - window.electron.updateLaunchOptions(game.id, value).then(updateGame); + debounceUpdateLaunchOptions(value); }; const handleClearLaunchOptions = async () => { @@ -256,16 +263,11 @@ export function GameOptionsModal({ placeholder={t("launch_options_placeholder")} onChange={handleChangeLaunchOptions} rightContent={ - <> - {game.launchOptions && ( - - )} - + game.launchOptions && ( + + ) } />