Skip to content
Merged
2 changes: 2 additions & 0 deletions bottles/backend/managers/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
77 changes: 72 additions & 5 deletions bottles/backend/wine/executor.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand All @@ -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, ""]:
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions bottles/backend/wine/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand Down
21 changes: 18 additions & 3 deletions bottles/backend/wine/winecommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions bottles/backend/wine/wineprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
):
Expand Down Expand Up @@ -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,
)
Expand Down
12 changes: 12 additions & 0 deletions bottles/frontend/ui/dialog-launch-options.blp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
21 changes: 21 additions & 0 deletions bottles/frontend/windows/launchoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 (
"",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
Loading