Merge pull request #1355 from dvsouto/feat/launch-options
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled

feat: add custom launch options to game
This commit is contained in:
Zamitto 2024-12-28 13:14:32 -03:00 committed by GitHub
commit 9060d435cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 153 additions and 10 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -0,0 +1,9 @@
export const parseLaunchOptions = (params: string | null): string[] => {
if (params == null || params == "") {
return [];
}
const paramsSplit = params.split(" ");
return paramsSplit;
};

View File

@ -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";

View File

@ -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);

View File

@ -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);

View File

@ -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<HydraMigration> {
AddShouldSeedColumn,
AddSeedAfterDownloadColumn,
AddHiddenAchievementDescriptionColumn,
AddLaunchOptionsColumnToGame,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

@ -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");
});
},
};

View File

@ -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),

View File

@ -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"));
}

View File

@ -93,6 +93,10 @@ declare global {
id: number,
executablePath: string | null
) => Promise<void>;
updateLaunchOptions: (
id: number,
launchOptions: string | null
) => Promise<void>;
selectGameWinePrefix: (
id: number,
winePrefixPath: string | null
@ -102,7 +106,11 @@ declare global {
openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
openGameExecutablePath: (gameId: number) => Promise<void>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
openGame: (
gameId: number,
executablePath: string,
launchOptions: string | null
) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>;
removeGame: (gameId: number) => Promise<void>;

View File

@ -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
);
}
};

View File

@ -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 (
<>
<DeleteGameModal
@ -226,6 +251,28 @@ export function GameOptionsModal({
</div>
)}
{shouldShowLaunchOptionsConfiguration && (
<div className={styles.gameOptionHeader}>
<h2>{t("launch_options")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("launch_options_description")}
</h4>
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
)}
<div className={styles.gameOptionHeader}>
<h2>{t("downloads_secion_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>

View File

@ -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;