diff --git a/bottles/backend/managers/manager.py b/bottles/backend/managers/manager.py index 5569b66382..d2c4311f0d 100644 --- a/bottles/backend/managers/manager.py +++ b/bottles/backend/managers/manager.py @@ -798,7 +798,9 @@ def get_programs(self, config: BottleConfig) -> List[dict]: "path": _program.get("path"), "icon": "com.usebottles.bottles-program", "pre_script": _program.get("pre_script"), + "pre_script_args": _program.get("pre_script_args"), "post_script": _program.get("post_script"), + "post_script_args": _program.get("post_script_args"), "folder": _program.get("folder", program_folder), "dxvk": _program.get("dxvk"), "vkd3d": _program.get("vkd3d"), diff --git a/bottles/backend/wine/executor.py b/bottles/backend/wine/executor.py index 7c4646e551..0fb14af5dc 100644 --- a/bottles/backend/wine/executor.py +++ b/bottles/backend/wine/executor.py @@ -1,7 +1,8 @@ import os +import re import shlex import uuid -from typing import Optional +from typing import Optional, Pattern from bottles.backend.dlls.dxvk import DXVKComponent from bottles.backend.dlls.nvapi import NVAPIComponent @@ -22,6 +23,15 @@ class WineExecutor: + _PLACEHOLDER_PATTERN: Pattern[str] = re.compile(r"%([A-Z_]+)%") + _KNOWN_PLACEHOLDERS: set[str] = { + "PROGRAM_NAME", + "PROGRAM_PATH", + "PROGRAM_DIR", + "BOTTLE_NAME", + "BOTTLE_PATH", + } + def __init__( self, config: BottleConfig, @@ -33,6 +43,8 @@ def __init__( move_upd_fn: callable = None, pre_script: Optional[str] = None, post_script: Optional[str] = None, + pre_script_args: Optional[str] = None, + post_script_args: Optional[str] = None, cwd: Optional[str] = None, monitoring: Optional[list] = None, program_dxvk: Optional[bool] = None, @@ -62,6 +74,8 @@ def __init__( self.environment = environment self.pre_script = pre_script self.post_script = post_script + self.pre_script_args = pre_script_args + self.post_script_args = post_script_args self.cwd = self.__get_cwd(cwd) self.monitoring = monitoring self.use_gamescope = program_gamescope @@ -116,13 +130,20 @@ def run_program(cls, config: BottleConfig, program: dict, terminal: bool = False if program is None: logging.warning("The program entry is not well formatted.") + placeholders = cls._build_placeholder_map(config, program or {}) + + def _resolve(field: str): + return cls._replace_placeholders((program or {}).get(field), placeholders) + return cls( config=config, exec_path=program.get("path"), - args=program.get("arguments"), - pre_script=program.get("pre_script"), - post_script=program.get("post_script"), - cwd=program.get("folder"), + args=_resolve("arguments"), + pre_script=cls._replace_placeholders(program.get("pre_script"), placeholders), + post_script=cls._replace_placeholders(program.get("post_script"), placeholders), + pre_script_args=_resolve("pre_script_args"), + post_script_args=_resolve("post_script_args"), + cwd=_resolve("folder"), terminal=terminal, program_dxvk=program.get("dxvk"), program_vkd3d=program.get("vkd3d"), @@ -132,6 +153,46 @@ def run_program(cls, config: BottleConfig, program: dict, terminal: bool = False program_virt_desktop=program.get("virtual_desktop"), ).run() + @staticmethod + def _build_placeholder_map(config: BottleConfig, program: dict) -> dict[str, str]: + program_path = program.get("path", "") or "" + program_dir_raw = program.get("folder") + program_dir = program_dir_raw or "" + if isinstance(program_dir_raw, str): + matches = WineExecutor._PLACEHOLDER_PATTERN.findall(program_dir_raw) + if any(match in WineExecutor._KNOWN_PLACEHOLDERS for match in matches): + # ignore unresolved placeholders that reference known tokens + program_dir = "" + if not program_dir and isinstance(program_path, str) and program_path: + program_dir = os.path.dirname(program_path) + + bottle_path = "" + if config: + try: + bottle_path = ManagerUtils.get_bottle_path(config) + except Exception: + bottle_path = "" + + placeholders = { + "PROGRAM_NAME": program.get("name", ""), + "PROGRAM_PATH": program_path, + "PROGRAM_DIR": program_dir, + "BOTTLE_NAME": getattr(config, "Name", "") or "", + "BOTTLE_PATH": bottle_path, + } + return {key: value for key, value in placeholders.items() if isinstance(value, str)} + + @classmethod + def _replace_placeholders(cls, value: Optional[str], placeholders: dict[str, str]) -> Optional[str]: + if not isinstance(value, str) or not value: + return value + + def _sub(match: re.Match[str]) -> str: + key = match.group(1) + return placeholders.get(key, match.group(0)) + + return cls._PLACEHOLDER_PATTERN.sub(_sub, value) + def __get_cwd(self, cwd: str) -> str | None: winepath = WinePath(self.config) if cwd in [None, ""]: @@ -212,6 +273,8 @@ def run_cli(self): environment=self.environment, pre_script=self.pre_script, post_script=self.post_script, + pre_script_args=self.pre_script_args, + post_script_args=self.post_script_args, cwd=self.cwd, ) return Result(status=True, data={"output": res}) @@ -278,6 +341,8 @@ def __launch_exe(self): communicate=True, pre_script=self.pre_script, post_script=self.post_script, + pre_script_args=self.pre_script_args, + post_script_args=self.post_script_args, cwd=self.cwd, ) res = winecmd.run() @@ -316,6 +381,8 @@ def __launch_with_starter(self): environment=self.environment, pre_script=self.pre_script, post_script=self.post_script, + pre_script_args=self.pre_script_args, + post_script_args=self.post_script_args, cwd=self.cwd, ) self.__set_monitors() diff --git a/bottles/backend/wine/start.py b/bottles/backend/wine/start.py index b88738ec11..cb6b55ee47 100644 --- a/bottles/backend/wine/start.py +++ b/bottles/backend/wine/start.py @@ -19,6 +19,8 @@ def run( environment: Optional[dict] = None, pre_script: Optional[str] = None, post_script: Optional[str] = None, + pre_script_args: Optional[str] = None, + post_script_args: Optional[str] = None, cwd: Optional[str] = None, ): winepath = WinePath(self.config) @@ -41,6 +43,8 @@ def run( environment=environment, pre_script=pre_script, post_script=post_script, + pre_script_args=pre_script_args, + post_script_args=post_script_args, cwd=cwd, minimal=False, action_name="run", diff --git a/bottles/backend/wine/winecommand.py b/bottles/backend/wine/winecommand.py index 8d2936c832..f1a24a67f9 100644 --- a/bottles/backend/wine/winecommand.py +++ b/bottles/backend/wine/winecommand.py @@ -99,6 +99,8 @@ def __init__( minimal: bool = False, # avoid gamemode/gamescope usage pre_script: Optional[str] = None, post_script: Optional[str] = None, + pre_script_args: Optional[str] = None, + post_script_args: Optional[str] = None, cwd: Optional[str] = None, ): _environment = environment.copy() @@ -113,7 +115,7 @@ def __init__( else self.config.Parameters.gamescope ) self.command = self.get_cmd( - command, pre_script, post_script, environment=_environment + command, pre_script, post_script, pre_script_args, post_script_args, environment=_environment ) self.terminal = terminal self.env = self.get_env(_environment) @@ -489,6 +491,8 @@ def get_cmd( command, pre_script: Optional[str] = None, post_script: Optional[str] = None, + pre_script_args: Optional[str] = None, + post_script_args: Optional[str] = None, return_steam_cmd: bool = False, return_clean_cmd: bool = False, environment: Optional[dict] = None, @@ -598,10 +602,18 @@ def get_cmd( environment.update(extracted_env) if post_script not in (None, ""): - command = f"{command} ; sh '{post_script}'" + post_cmd_parts = [post_script] + if post_script_args not in (None, ""): + post_cmd_parts.extend(shlex.split(post_script_args)) + post_cmd = " ".join(shlex.quote(part) for part in post_cmd_parts) + command = f"{command} ; sh {post_cmd}" if pre_script not in (None, ""): - command = f"sh '{pre_script}' ; {command}" + pre_cmd_parts = [pre_script] + if pre_script_args not in (None, ""): + pre_cmd_parts.extend(shlex.split(pre_script_args)) + pre_cmd = " ".join(shlex.quote(part) for part in pre_cmd_parts) + command = f"sh {pre_cmd} ; {command}" return command @@ -698,6 +710,9 @@ def run(self) -> Result[Optional[str]]: False, message="runner or env is not ready, Wine command terminated." ) + # Log the final command that will be executed + logging.info(f"Executing command: {self.command}") + if vmtouch_available and self.config.Parameters.vmtouch and not self.terminal: self._vmtouch_preload() diff --git a/bottles/backend/wine/wineprogram.py b/bottles/backend/wine/wineprogram.py index df111ab313..a24b8dbeb3 100644 --- a/bottles/backend/wine/wineprogram.py +++ b/bottles/backend/wine/wineprogram.py @@ -45,6 +45,8 @@ def launch( environment: Optional[dict] = None, pre_script: Optional[str] = None, post_script: Optional[str] = None, + pre_script_args: Optional[str] = None, + post_script_args: Optional[str] = None, cwd: Optional[str] = None, action_name: str = "launch", ): @@ -72,6 +74,8 @@ def launch( environment=environment, pre_script=pre_script, post_script=post_script, + pre_script_args=pre_script_args, + post_script_args=post_script_args, cwd=cwd, arguments=program_args, ) diff --git a/bottles/frontend/ui/dialog-launch-options.blp b/bottles/frontend/ui/dialog-launch-options.blp index ebd35d227e..303b6cc6f6 100644 --- a/bottles/frontend/ui/dialog-launch-options.blp +++ b/bottles/frontend/ui/dialog-launch-options.blp @@ -79,6 +79,12 @@ template $LaunchOptionsDialog: Adw.Window { } } + Adw.EntryRow entry_pre_script_args { + title: _("Pre-run Script Arguments"); + tooltip-text: _("e.g.: ludusavi restore --force \"Game Name\""); + visible: false; + } + Adw.ActionRow action_post_script { activatable-widget: btn_post_script; title: _("Post-run Script"); @@ -110,6 +116,12 @@ template $LaunchOptionsDialog: Adw.Window { } } + Adw.EntryRow entry_post_script_args { + title: _("Post-run Script Arguments"); + tooltip-text: _("e.g.: ludusavi backup --force \"Game Name\""); + visible: false; + } + Adw.ActionRow action_cwd { activatable-widget: btn_cwd; title: _("Working Directory"); diff --git a/bottles/frontend/windows/launchoptions.py b/bottles/frontend/windows/launchoptions.py index 1b1550d013..9344fb261d 100644 --- a/bottles/frontend/windows/launchoptions.py +++ b/bottles/frontend/windows/launchoptions.py @@ -36,8 +36,10 @@ class LaunchOptionsDialog(Adw.Window): btn_save = Gtk.Template.Child() btn_pre_script = Gtk.Template.Child() btn_pre_script_reset = Gtk.Template.Child() + entry_pre_script_args = Gtk.Template.Child() btn_post_script = Gtk.Template.Child() btn_post_script_reset = Gtk.Template.Child() + entry_post_script_args = Gtk.Template.Child() btn_cwd = Gtk.Template.Child() btn_cwd_reset = Gtk.Template.Child() btn_reset_defaults = Gtk.Template.Child() @@ -172,10 +174,16 @@ def __init__(self, parent, config, program, **kwargs): if program.get("pre_script") not in ("", None): self.action_pre_script.set_subtitle(program["pre_script"]) self.btn_pre_script_reset.set_visible(True) + self.entry_pre_script_args.set_visible(True) + if program.get("pre_script_args") not in ("", None): + self.entry_pre_script_args.set_text(program["pre_script_args"]) if program.get("post_script") not in ("", None): self.action_post_script.set_subtitle(program["post_script"]) self.btn_post_script_reset.set_visible(True) + self.entry_post_script_args.set_visible(True) + if program.get("post_script_args") not in ("", None): + self.entry_post_script_args.set_text(program["post_script_args"]) if program.get("folder") not in ( "", @@ -218,6 +226,11 @@ def __idle_save(self, *_args): "virtual_desktop", program_virt_desktop, self.global_virt_desktop ) self.program["arguments"] = self.entry_arguments.get_text() + + pre_args = self.entry_pre_script_args.get_text() + post_args = self.entry_post_script_args.get_text() + self.program["pre_script_args"] = pre_args if pre_args else None + self.program["post_script_args"] = post_args if post_args else None self.config = self.manager.update_config( config=self.config, @@ -247,6 +260,7 @@ def set_path(dialog, result): self.program["pre_script"] = file_path self.action_pre_script.set_subtitle(file_path) self.btn_pre_script_reset.set_visible(True) + self.entry_pre_script_args.set_visible(True) except GLib.Error as error: # also thrown when dialog has been cancelled @@ -278,6 +292,7 @@ def set_path(dialog, result): self.program["post_script"] = file_path self.action_post_script.set_subtitle(file_path) self.btn_post_script_reset.set_visible(True) + self.entry_post_script_args.set_visible(True) except GLib.Error as error: # also thrown when dialog has been cancelled if error.code == 2: @@ -297,13 +312,19 @@ def set_path(dialog, result): def __reset_pre_script(self, *_args): self.program["pre_script"] = None + self.program["pre_script_args"] = None self.action_pre_script.set_subtitle(self.__default_pre_script_msg) self.btn_pre_script_reset.set_visible(False) + self.entry_pre_script_args.set_visible(False) + self.entry_pre_script_args.set_text("") def __reset_post_script(self, *_args): self.program["post_script"] = None + self.program["post_script_args"] = None self.action_post_script.set_subtitle(self.__default_post_script_msg) self.btn_post_script_reset.set_visible(False) + self.entry_post_script_args.set_visible(False) + self.entry_post_script_args.set_text("") def __choose_cwd(self, *_args): def set_path(dialog, result): diff --git a/bottles/tests/backend/wine/test_executor.py b/bottles/tests/backend/wine/test_executor.py new file mode 100644 index 0000000000..712a2eaba2 --- /dev/null +++ b/bottles/tests/backend/wine/test_executor.py @@ -0,0 +1,106 @@ +"""Unit tests for WineExecutor placeholder handling""" + +from bottles.backend.models.config import BottleConfig +from bottles.backend.models.result import Result +from bottles.backend.utils.manager import ManagerUtils +from bottles.backend.wine.executor import WineExecutor + + +def _make_config(name: str = "TestBottle", path: str = "TestBottlePath") -> BottleConfig: + return BottleConfig(Name=name, Path=path, Custom_Path="", Environment="Custom") + + +def test_build_placeholder_map_uses_program_values(): + config = _make_config() + program = { + "name": "My Game", + "path": "/opt/games/my-game.exe", + } + + placeholders = WineExecutor._build_placeholder_map(config, program) + + expected_bottle_path = ManagerUtils.get_bottle_path(config) + assert placeholders["PROGRAM_NAME"] == "My Game" + assert placeholders["PROGRAM_PATH"] == "/opt/games/my-game.exe" + assert placeholders["PROGRAM_DIR"] == "/opt/games" + assert placeholders["BOTTLE_NAME"] == "TestBottle" + assert placeholders["BOTTLE_PATH"] == expected_bottle_path + + +def test_replace_placeholders_handles_unknown_tokens(): + placeholders = {"PROGRAM_NAME": "Example", "BOTTLE_NAME": "Bottle"} + + result = WineExecutor._replace_placeholders( + "run-%PROGRAM_NAME%-on-%BOTTLE_NAME%-%UNKNOWN%", + placeholders, + ) + + assert result == "run-Example-on-Bottle-%UNKNOWN%" + + +def test_run_program_substitutes_placeholders(monkeypatch): + captured: dict[str, object] = {} + + def fake_init( + self, + *, + config, + exec_path, + args="", + terminal=False, + environment=None, + move_file=False, + move_upd_fn=None, + pre_script=None, + post_script=None, + pre_script_args=None, + post_script_args=None, + cwd=None, + monitoring=None, + program_dxvk=None, + program_vkd3d=None, + program_nvapi=None, + program_fsr=None, + program_gamescope=None, + program_virt_desktop=None, + ): + # mimic original __init__ contract enough for run() stub + self.config = config + self.captured = { + "exec_path": exec_path, + "args": args, + "pre_script": pre_script, + "post_script": post_script, + "pre_script_args": pre_script_args, + "post_script_args": post_script_args, + "cwd": cwd, + } + + def fake_run(self): + return Result(True, data=self.captured) + + monkeypatch.setattr(WineExecutor, "__init__", fake_init, raising=False) + monkeypatch.setattr(WineExecutor, "run", fake_run, raising=False) + + config = _make_config(name="Bottle", path="BottlePath") + program = { + "name": "Awesome Game", + "path": "/games/awesome/game.exe", + "arguments": "--title=%PROGRAM_NAME%", + "pre_script": "/scripts/%BOTTLE_NAME%/%PROGRAM_NAME%.sh", + "pre_script_args": "--prefix=%BOTTLE_PATH%", + "post_script": None, + "post_script_args": "--dir=%PROGRAM_DIR%", + "folder": "%PROGRAM_DIR%", + } + + result = WineExecutor.run_program(config=config, program=program, terminal=False) + + assert result.status is True + data = result.data + assert data["exec_path"] == "/games/awesome/game.exe" + assert data["args"] == "--title=Awesome Game" + assert data["pre_script"] == "/scripts/Bottle/Awesome Game.sh" + assert data["pre_script_args"] == f"--prefix={ManagerUtils.get_bottle_path(config)}" + assert data["post_script_args"] == "--dir=/games/awesome" + assert data["cwd"] == "/games/awesome" diff --git a/po/bottles.pot b/po/bottles.pot index 81fb7f976c..4797c4f789 100644 --- a/po/bottles.pot +++ b/po/bottles.pot @@ -384,6 +384,10 @@ msgstr "Reboot" msgid "Launch Options" msgstr "Launch Options" +#: bottles/frontend/ui/dialog-launch-options.blp:53 +msgid "Pre-run Script" +msgstr "" + #: bottles/frontend/ui/details-bottle.blp:135 msgid "Run in Terminal" msgstr "Run in Terminal" @@ -887,6 +891,22 @@ msgstr "" msgid "Working Directory" msgstr "Working Directory" +#: bottles/frontend/ui/dialog-launch-options.blp:83 +msgid "Pre-run Script Arguments" +msgstr "" + +#: bottles/frontend/ui/dialog-launch-options.blp:84 +msgid "e.g.: ludusavi restore --force \"Game Name\"" +msgstr "" + +#: bottles/frontend/ui/dialog-launch-options.blp:118 +msgid "Post-run Script Arguments" +msgstr "" + +#: bottles/frontend/ui/dialog-launch-options.blp:121 +msgid "e.g.: ludusavi backup --force \"Game Name\"" +msgstr "" + #: bottles/frontend/ui/details-preferences.blp:317 #: bottles/frontend/ui/dialog-launch-options.blp:59 #: bottles/frontend/ui/dialog-launch-options.blp:90 diff --git a/po/pt.po b/po/pt.po index 915155a304..521a67457a 100644 --- a/po/pt.po +++ b/po/pt.po @@ -379,6 +379,10 @@ msgstr "Reiniciar" msgid "Launch Options" msgstr "Opções de lançamento" +#: bottles/frontend/ui/dialog-launch-options.blp:53 +msgid "Pre-run Script" +msgstr "Script de pré-execução" + #: bottles/frontend/ui/details-bottle.blp:135 msgid "Run in Terminal" msgstr "Executar no Terminal" @@ -896,6 +900,22 @@ msgstr "" msgid "Working Directory" msgstr "Diretório de trabalho" +#: bottles/frontend/ui/dialog-launch-options.blp:83 +msgid "Pre-run Script Arguments" +msgstr "Argumentos do script de pré-execução" + +#: bottles/frontend/ui/dialog-launch-options.blp:84 +msgid "e.g.: ludusavi restore --force \"Game Name\"" +msgstr "ex.: ludusavi restore --force \"Nome do Jogo\"" + +#: bottles/frontend/ui/dialog-launch-options.blp:118 +msgid "Post-run Script Arguments" +msgstr "Argumentos do script de pós-execução" + +#: bottles/frontend/ui/dialog-launch-options.blp:121 +msgid "e.g.: ludusavi backup --force \"Game Name\"" +msgstr "ex.: ludusavi backup --force \"Nome do Jogo\"" + #: bottles/frontend/ui/details-preferences.blp:318 #: bottles/frontend/ui/dialog-launch-options.blp:59 #: bottles/frontend/ui/dialog-launch-options.blp:90 @@ -1357,7 +1377,7 @@ msgstr "ex.: VAR=valor %comando% -exemplo1 -exemplo2 -exemplo3=olá" #: bottles/frontend/ui/dialog-launch-options.blp:52 msgid "Post-run Script" -msgstr "Script pós-execução" +msgstr "Script de pós-execução" #. endregion #: bottles/frontend/ui/dialog-launch-options.blp:53 @@ -1374,8 +1394,7 @@ msgstr "Escolha um Script" msgid "Choose from where start the program." msgstr "Escolha de onde iniciar o programa." -#: bottles/frontend/ui/dialog-launch-options.blp:101 -#: bottles/frontend/ui/drive-entry.blp:22 +#: bottles/frontend/ui/dialog-launch-options.blp:118 msgid "Choose a Directory" msgstr "Escolher um diretório" diff --git a/po/pt_BR.po b/po/pt_BR.po index 4ed12de63b..e0780f711e 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -380,6 +380,10 @@ msgstr "Reiniciar" msgid "Launch Options" msgstr "Opções de Lançamento" +#: bottles/frontend/ui/dialog-launch-options.blp:53 +msgid "Pre-run Script" +msgstr "Script de pré-execução" + #: bottles/frontend/ui/details-bottle.blp:135 msgid "Run in Terminal" msgstr "Executar no Terminal" @@ -898,6 +902,22 @@ msgstr "" msgid "Working Directory" msgstr "Diretório de trabalho" +#: bottles/frontend/ui/dialog-launch-options.blp:83 +msgid "Pre-run Script Arguments" +msgstr "Argumentos do script de pré-execução" + +#: bottles/frontend/ui/dialog-launch-options.blp:84 +msgid "e.g.: ludusavi restore --force \"Game Name\"" +msgstr "ex.: ludusavi restore --force \"Nome do Jogo\"" + +#: bottles/frontend/ui/dialog-launch-options.blp:118 +msgid "Post-run Script Arguments" +msgstr "Argumentos do script de pós-execução" + +#: bottles/frontend/ui/dialog-launch-options.blp:121 +msgid "e.g.: ludusavi backup --force \"Game Name\"" +msgstr "ex.: ludusavi backup --force \"Nome do Jogo\"" + #: bottles/frontend/ui/details-preferences.blp:318 #: bottles/frontend/ui/dialog-launch-options.blp:59 #: bottles/frontend/ui/dialog-launch-options.blp:90 @@ -1360,7 +1380,7 @@ msgstr "ex.: VAR=valor %comando% -exemplo1 -exemplo2 -exemplo3=olá" #: bottles/frontend/ui/dialog-launch-options.blp:52 msgid "Post-run Script" -msgstr "Script pós-execução" +msgstr "Script de pós-execução" #. endregion #: bottles/frontend/ui/dialog-launch-options.blp:53