From 83097b2d3a6b6e5e29661095123d018b581323b6 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Fri, 9 Oct 2026 17:04:24 +0500 Subject: [PATCH] feat(tray): add Wine tools submenu Signed-off-by: Boris Yumankulov --- .../share/portproton/scripts/functions_helper | 76 ++++++++++ build-aux/share/portproton/scripts/start.sh | 67 ++++----- dev-scripts/generate-completions.sh | 1 - portprotonqt/tabs/wine_tab.py | 24 +-- portprotonqt/tray_manager.py | 40 +++++ tests/test_main_window.py | 105 ++++++++++++- tests/test_portproton_scripts.py | 138 ++++++++++++++++++ 7 files changed, 394 insertions(+), 57 deletions(-) diff --git a/build-aux/share/portproton/scripts/functions_helper b/build-aux/share/portproton/scripts/functions_helper index b46d6389..1c31cefe 100755 --- a/build-aux/share/portproton/scripts/functions_helper +++ b/build-aux/share/portproton/scripts/functions_helper @@ -1572,6 +1572,7 @@ pw_kill_autostart () { export -f pw_kill_autostart stop_portproton () { + pw_remove_session_env [[ "$int_xneur" == "1" ]] && xneur & if [[ "$PW_USE_US_LAYOUT" == "1" ]] \ @@ -3845,6 +3846,7 @@ start_portproton () { pw_mangohud_check pw_vkbasalt_check pw_lsfg_vk_check + pw_save_session_env || print_warning "Could not save Wine session environment" } pw_run () { @@ -3996,6 +3998,80 @@ pw_run () { } export -f pw_run +pw_save_session_env () { + local session_dir="${PORT_DATA_PATH}/data/tmp/wine-session-$(id -u)" + local session_tmp + + ( umask 077; mkdir -p "$session_dir" ) || return 1 + [[ ! -L "$session_dir" && -O "$session_dir" ]] || return 1 + [[ "$(stat -c %a "$session_dir")" == 700 ]] || return 1 + + session_tmp="$(mktemp "$session_dir/env.XXXXXX")" || return 1 + if ( export PW_TERM; export -p > "$session_tmp" ) \ + && mv -fT "$session_tmp" "$session_dir/session.env" ; then + return 0 + fi + + rm -f "$session_tmp" + return 1 +} + +pw_remove_session_env () { + local session_dir="${PORT_DATA_PATH}/data/tmp/wine-session-$(id -u)" + [[ ! -L "$session_dir" && -O "$session_dir" ]] || return 0 + local session_env="$session_dir/session.env" + [[ ! -L "$session_env" && -O "$session_env" ]] || return 0 + if grep -qsx "declare -x PW_START_PID=\"${PW_START_PID}\"" "$session_env" ; then + rm -f "$session_env" + fi +} + +pw_session_tool () { + local tool="$1" session_pid session_env + local session_dir="${PORT_DATA_PATH}/data/tmp/wine-session-$(id -u)" + local session_wait_seconds="${2:-30}" wait_left + wait_left="$session_wait_seconds" + + case "$tool" in + winefile|taskmgr|winecfg|regedit|cmd|uninstaller) ;; + *) print_error "Unsupported Wine tool: $tool" ; return 1 ;; + esac + + session_env="$session_dir/session.env" + while (( wait_left > 0 )) ; do + if [[ ! -L "$session_dir" && -O "$session_dir" ]] \ + && [[ "$(stat -c %a "$session_dir")" == 700 ]] \ + && [[ ! -L "$session_env" && -O "$session_env" ]] \ + && [[ "$(stat -c %a "$session_env")" == 600 ]] \ + && session_pid="$(sed -n 's/^declare -x PW_START_PID="\([0-9]*\)"$/\1/p' "$session_env")" \ + && [[ -n "$session_pid" ]] \ + && kill -0 "$session_pid" 2>/dev/null ; then + break + fi + + sleep 1 + (( --wait_left )) + done + + if (( wait_left == 0 )) ; then + print_error "Wine session unavailable after ${session_wait_seconds} seconds" + return 2 + fi + + source "$session_env" || return 1 + PATH_TO_GAME="${WINEPREFIX}/drive_c" + [[ -d "$PATH_TO_GAME" ]] || return 1 + + unset LAUNCH_PARAMETERS PW_RUN_AFTER_EXE PW_RUN_GAMESCOPE PW_USE_TERMINAL + PW_TMPFS_PATH="$(mktemp -d "$session_dir/tool.XXXXXX")" || return 1 + export PW_TMPFS_PATH + export PW_LOG_FILE="$PW_TMPFS_PATH/wine.log" + + [[ "$tool" == cmd ]] && PW_USE_TERMINAL=1 + [[ "$tool" == winecfg || "$tool" == regedit ]] && export GST_PLUGIN_SYSTEM_PATH_1_0="" + pw_run "$tool" || return 1 +} + pw_launch_wrapper () { if [[ -z "${PW_RUN_AFTER_EXE:-}" ]] ; then "$@" diff --git a/build-aux/share/portproton/scripts/start.sh b/build-aux/share/portproton/scripts/start.sh index b593dfb1..6b7f56f3 100755 --- a/build-aux/share/portproton/scripts/start.sh +++ b/build-aux/share/portproton/scripts/start.sh @@ -67,6 +67,21 @@ export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log" # shellcheck source=/dev/null source "$PORT_SCRIPTS_PATH/functions_helper" +if [[ "${1:-}" == "cli" && "${2:-}" == "--wine-session-tool" ]] ; then + session_unavailable_status=2 + session_wait_seconds=30 + [[ -n "${4:-}" && -n "${5:-}" ]] && session_wait_seconds=1 + if pw_session_tool "${3:-}" "$session_wait_seconds" ; then + exit 0 + else + session_tool_status=$? + fi + if [[ "$session_tool_status" != "$session_unavailable_status" || -z "${4:-}" || -z "${5:-}" ]] ; then + python_module dbus_tools notify -i error "Wine" "Failed to start Wine tool: ${3:-}" &>/dev/null + exit 1 + fi +fi + export PORT_WINE_TMP_PATH="${PORT_DATA_PATH}/data/tmp" create_new_dir "$PORT_WINE_TMP_PATH" rm -f "$PORT_WINE_TMP_PATH"/*.{exe,msi,tar}* @@ -280,11 +295,7 @@ case "$1" in --show-ppdb Show the content of .ppdb file for specified .exe file --backup-prefix Backup specified prefix to a file --restore-prefix Restore prefix from backup file ---winefile Open wine file explorer, requires WINE version and prefix name ---winecfg Open wine configuration, requires WINE version and prefix name ---winecmd Open wine command prompt, requires WINE version and prefix name ---winereg Open wine registry editor, requires WINE version and prefix name ---wine_uninstaller Open wine uninstaller, requires WINE version and prefix name +--wine-session-tool Open a Wine tool in the current session, or use optional WINE and prefix --winetricks-list-all List winetricks DLLs, fonts, and settings --winetricks-install Install winetricks items, requires WINE version and prefix name --clear_pfx Clear specified prefix, requires WINE version and prefix name @@ -300,7 +311,7 @@ Usage examples: portproton cli --del-user-conf PW_MANGOHUD portproton cli --backup-prefix DEFAULT /path/to/backup/directory portproton cli --restore-prefix /path/to/backup/file.ppack - portproton cli --winecfg WINE_LG DEFAULT + portproton cli --wine-session-tool winecfg WINE_LG DEFAULT portproton cli --winetricks-list-all WINE_LG DEFAULT portproton cli --winetricks-install WINE_LG DEFAULT --no-force corefonts portproton cli --mangohud-preview \"fps,frametime,cpu_temp,gpu_temp\" @@ -462,37 +473,21 @@ Usage examples: pw_unpack_prefix "$2" exit $? ;; - --winefile) - get_wine_and_pfx "$2" "$3" + --wine-session-tool) + tool="$2" + case "$tool" in + winefile|taskmgr|winecfg|regedit|cmd|uninstaller) ;; + *) print_error "Unsupported Wine tool: $tool" ; exit 1 ;; + esac + get_wine_and_pfx "$3" "$4" start_portproton - pw_run winefile - stop_portproton - ;; - --winecfg) - get_wine_and_pfx "$2" "$3" - start_portproton - export GST_PLUGIN_SYSTEM_PATH_1_0="" - pw_run winecfg - stop_portproton - ;; - --winecmd) - get_wine_and_pfx "$2" "$3" - start_portproton - cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found" - PW_USE_TERMINAL=1 pw_run cmd - stop_portproton - ;; - --winereg) - get_wine_and_pfx "$2" "$3" - start_portproton - export GST_PLUGIN_SYSTEM_PATH_1_0="" - pw_run regedit - stop_portproton - ;; - --wine_uninstaller) - get_wine_and_pfx "$2" "$3" - start_portproton - pw_run uninstaller + [[ "$tool" == winecfg || "$tool" == regedit ]] && export GST_PLUGIN_SYSTEM_PATH_1_0="" + if [[ "$tool" == cmd ]] ; then + cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found" + PW_USE_TERMINAL=1 pw_run "$tool" + else + pw_run "$tool" + fi stop_portproton ;; --winetricks-list-all) diff --git a/dev-scripts/generate-completions.sh b/dev-scripts/generate-completions.sh index d43b2e19..e9355ca4 100755 --- a/dev-scripts/generate-completions.sh +++ b/dev-scripts/generate-completions.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash # Generate shell completion files for bash, zsh, and fish - set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/portprotonqt/tabs/wine_tab.py b/portprotonqt/tabs/wine_tab.py index cc00317a..947c5518 100644 --- a/portprotonqt/tabs/wine_tab.py +++ b/portprotonqt/tabs/wine_tab.py @@ -160,11 +160,12 @@ class MainWindowWineTabMixin(_MainWindowTypingBase): tools_grid.setSpacing(6) tools = [ - ("--winecfg", _("Wine Configuration")), - ("--winereg", _("Registry Editor")), - ("--winefile", _("File Explorer")), - ("--winecmd", _("Command Prompt")), - ("--wine_uninstaller", _("Uninstaller")), + ("winecfg", _("Wine Configuration")), + ("regedit", _("Registry Editor")), + ("winefile", _("File Explorer")), + ("cmd", _("Command Prompt")), + ("uninstaller", _("Uninstaller")), + ("taskmgr", _("Task Manager")), ] for i, (tool_cmd, tool_name) in enumerate(tools): @@ -237,7 +238,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase): env_vars = os.environ.copy() self._check_missing_prefix_by_name_before_launch(prefix, env_vars) start_sh = self.start_sh - cmd = start_sh + ["cli", cli_arg, wine, prefix] + cmd = start_sh + ["cli", "--wine-session-tool", cli_arg, wine, prefix] # Show progress bar before launch self.wine_progress_bar.setVisible(True) @@ -269,16 +270,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase): def _check_wine_process(self, cli_arg): """Check if target .exe process started.""" - exe_map = { - "--winecfg": "winecfg.exe", - "--winereg": "regedit.exe", - "--winefile": "winefile.exe", - "--winecmd": "cmd.exe", - "--wine_uninstaller": "uninstaller.exe", - } - target_exe = exe_map.get(cli_arg, "") - if not target_exe: - return + target_exe = f"{cli_arg}.exe" # Check processes via psutil for proc in psutil.process_iter(attrs=["name"]): diff --git a/portprotonqt/tray_manager.py b/portprotonqt/tray_manager.py index b34fa552..02c69ea3 100644 --- a/portprotonqt/tray_manager.py +++ b/portprotonqt/tray_manager.py @@ -3,6 +3,7 @@ import subprocess import signal import psutil import os +from PySide6.QtCore import QProcess from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox from PySide6.QtGui import QIcon, QAction from portprotonqt.logger import get_logger @@ -45,6 +46,39 @@ def _restart_args(args: list[str]) -> list[str]: return restart_args +def create_wine_tools_menu(parent: QMenu, start_sh: list[str]) -> QMenu: + """Create tools that join the running game's Wine session.""" + menu = QMenu("Wine", parent) + tools = ( + ("winefile", _("File Explorer")), + ("taskmgr", _("Task Manager")), + ("winecfg", _("Wine Configuration")), + ("regedit", _("Registry Editor")), + ("cmd", _("Command Prompt")), + ("uninstaller", _("Uninstaller")), + ) + for tool, title in tools: + action = menu.addAction(title) + action.triggered.connect( + lambda checked=False, name=tool: launch_session_wine_tool(start_sh, name) + ) + return menu + + +def launch_session_wine_tool(start_sh: list[str], tool: str) -> None: + """Start a Wine tool without running the game's startup or cleanup.""" + if not start_sh: + logger.error("Cannot launch Wine tool: PortProton command is missing") + QMessageBox.warning(None, _("Error"), _("Failed to start process.")) + return + started, _pid = QProcess.startDetached( + start_sh[0], start_sh[1:] + ["cli", "--wine-session-tool", tool] + ) + if not started: + logger.error("Failed to start Wine tool %s", tool) + QMessageBox.warning(None, _("Error"), _("Failed to start process.")) + + class TrayManager: """Tray management module for PortProtonQt. @@ -83,8 +117,12 @@ class TrayManager: self.pause_game_action.setEnabled(False) self.pause_game_action.triggered.connect(self.toggle_game_pause) + self.wine_tools_menu = create_wine_tools_menu(self.tray_menu, self.main_window.start_sh) + self.wine_tools_menu.setEnabled(False) + self.tray_menu.addAction(self.pause_game_action) self.tray_menu.addAction(self.stop_game_action) + self.tray_menu.addMenu(self.wine_tools_menu) self.tray_menu.addSeparator() self.tray_menu.addSeparator() @@ -103,6 +141,7 @@ class TrayManager: self.tray_menu.addAction(self.pause_game_action) self.tray_menu.addAction(self.stop_game_action) + self.tray_menu.addMenu(self.wine_tools_menu) self.update_game_actions() if self.minimal_mode: return @@ -151,6 +190,7 @@ class TrayManager: target_exe = getattr(self.main_window, "target_exe", None) has_running_game = bool(game_processes or target_exe) self.stop_game_action.setEnabled(has_running_game) + self.wine_tools_menu.setEnabled(has_running_game) self.pause_game_action.setEnabled(bool(processes)) paused = self._are_processes_paused(processes) self.pause_game_action.setText(_("Resume Game") if paused else _("Pause Game")) diff --git a/tests/test_main_window.py b/tests/test_main_window.py index ff3c78ae..bcb5e7cc 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -42,7 +42,7 @@ from portprotonqt.game_library_manager import FullLibraryTile, GameLibraryManage from portprotonqt.input_manager import InputManager from portprotonqt.main_window import MainWindow from portprotonqt.search_utils import SearchOptimizer, search_index -from portprotonqt.tray_manager import TrayManager +from portprotonqt.tray_manager import TrayManager, create_wine_tools_menu, launch_session_wine_tool from portprotonqt.themes.standart.styles.constants import GAME_CARD_ANIMATION from portprotonqt.portproton_api import remove_empty_custom_data_dirs import portprotonqt.tabs.autoinstall_tab as autoinstall_tab_module @@ -374,18 +374,71 @@ def test_minimal_tray_contains_game_actions() -> None: _application = QApplication.instance() or QApplication([]) manager = TrayManager.__new__(TrayManager) manager.tray_menu = QMenu() - manager.pause_game_action = QAction("Pause Game", manager.tray_menu) - manager.stop_game_action = QAction("Stop Game", manager.tray_menu) + owner = QWidget() + manager.pause_game_action = QAction("Pause Game", owner) + manager.stop_game_action = QAction("Stop Game", owner) + manager.wine_tools_menu = create_wine_tools_menu(manager.tray_menu, ["portproton"]) manager.minimal_mode = True manager.update_game_actions = MagicMock() + manager.refresh_tray_menu() manager.refresh_tray_menu() assert manager.tray_menu.actions() == [ manager.pause_game_action, manager.stop_game_action, + manager.wine_tools_menu.menuAction(), ] - manager.update_game_actions.assert_called_once_with() + assert manager.update_game_actions.call_count == 2 + + +@mark.parametrize("tool_index,tool", list(enumerate( + ("winefile", "taskmgr", "winecfg", "regedit", "cmd", "uninstaller") +))) +def test_wine_tools_menu_launches_session_tool( + monkeypatch: MonkeyPatch, tool_index: int, tool: str, +) -> None: + _application = QApplication.instance() or QApplication([]) + parent = QMenu() + start = MagicMock(return_value=(True, 123)) + monkeypatch.setattr("portprotonqt.tray_manager.QProcess.startDetached", start) + menu = create_wine_tools_menu(parent, ["bash", "/path with spaces/start.sh"]) + + assert len(menu.actions()) == 6 + menu.actions()[tool_index].trigger() + + start.assert_called_once_with( + "bash", ["/path with spaces/start.sh", "cli", "--wine-session-tool", tool] + ) + + +def test_wine_tool_start_failure_is_reported(monkeypatch: MonkeyPatch) -> None: + _application = QApplication.instance() or QApplication([]) + warning = MagicMock() + monkeypatch.setattr("portprotonqt.tray_manager.QMessageBox.warning", warning) + monkeypatch.setattr( + "portprotonqt.tray_manager.QProcess.startDetached", lambda *args: (False, 0) + ) + + launch_session_wine_tool(["portproton"], "winefile") + + warning.assert_called_once() + + +@mark.parametrize("running", [False, True]) +def test_wine_tools_menu_requires_running_game(running: bool) -> None: + _application = QApplication.instance() or QApplication([]) + manager = TrayManager.__new__(TrayManager) + manager.main_window = SimpleNamespace(game_processes=[], target_exe="game.exe" if running else None) + manager.stop_game_action = QAction() + manager.pause_game_action = QAction() + manager.wine_tools_menu = QMenu() + manager._game_process_tree = MagicMock(return_value=[]) + + manager.update_game_actions() + + assert manager.wine_tools_menu.isEnabled() is running + def test_tray_pauses_game_process_tree(monkeypatch: MonkeyPatch) -> None: child = MagicMock(pid=12) @@ -3378,3 +3431,47 @@ def test_custom_combo_opens_readonly_field_on_mouse_release(readonly: bool) -> N assert combo.view().isVisible() is readonly combo.hidePopup() combo.close() + + +@mark.parametrize("tool", ["winecfg", "regedit", "winefile", "cmd", "uninstaller", "taskmgr"]) +@mark.parametrize("running", [False, True]) +def test_wine_settings_tools_use_unified_command( + monkeypatch: MonkeyPatch, tool: str, running: bool, +) -> None: + process = MagicMock() + monkeypatch.setattr("portprotonqt.tabs.wine_tab.QProcess", MagicMock(return_value=process)) + window = MagicMock() + window.game_processes = [MagicMock()] if running else [] + window.target_exe = "game.exe" if running else None + window.start_sh = ["bash", "/path with spaces/start.sh"] + window.wineCombo.currentText.return_value = "WINE_LG" + window.prefixCombo.currentText.return_value = "my prefix" + + WineMixin.launch_generic_tool(window, tool) + + process.start.assert_called_once_with( + "bash", ["/path with spaces/start.sh", "cli", "--wine-session-tool", tool, "WINE_LG", "MY_PREFIX"], + ) + window._start_wine_process_monitor.assert_called_once_with(tool) + + + +def test_silent_tray_includes_wine_tools_menu(monkeypatch: MonkeyPatch) -> None: + _application = QApplication.instance() or QApplication([]) + manager = TrayManager.__new__(TrayManager) + manager.tray_menu = QMenu() + owner = QWidget() + manager.pause_game_action = QAction("Pause Game", owner) + manager.stop_game_action = QAction("Stop Game", owner) + manager.wine_tools_menu = create_wine_tools_menu(manager.tray_menu, ["portproton"]) + manager.update_game_actions = MagicMock() + start = MagicMock(return_value=(True, 123)) + monkeypatch.setattr("portprotonqt.tray_manager.QProcess.startDetached", start) + + manager.set_minimal_mode() + + tools = manager.tray_menu.actions()[2].menu() + assert isinstance(tools, QMenu) + assert len(tools.actions()) == 6 + tools.actions()[1].trigger() + start.assert_called_once_with("portproton", ["cli", "--wine-session-tool", "taskmgr"]) diff --git a/tests/test_portproton_scripts.py b/tests/test_portproton_scripts.py index b9935927..d3b4719a 100644 --- a/tests/test_portproton_scripts.py +++ b/tests/test_portproton_scripts.py @@ -232,3 +232,141 @@ def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback( if expected_calls == ["pe"]: assert "File Description: Description" in metadata assert "Company Name: Sony" in metadata + + +def test_wine_session_missing_returns_distinct_status(tmp_path: Path) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definition = helper.split("pw_session_tool () {", 1)[1].split("pw_launch_wrapper () {", 1)[0] + script = "pw_session_tool () {" + definition + ''' +print_error () { echo "$@" >&2; } +sleep () { return 0; } +pw_session_tool taskmgr 1 +''' + result = subprocess.run( + ["bash", "-c", script], cwd=tmp_path, capture_output=True, text=True, check=False, + env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, + ) + + assert result.returncode == 2 + + +@mark.parametrize("tool", ["winecfg", "regedit", "winefile", "cmd", "uninstaller", "taskmgr"]) +def test_wine_session_tool_standalone_uses_selected_wine_and_prefix(tmp_path: Path, tool: str) -> None: + start = Path("build-aux/share/portproton/scripts/start.sh").read_text() + block = start.split(" --wine-session-tool)\n", 1)[1].split(" --winetricks-list-all)", 1)[0] + script = ''' +get_wine_and_pfx () { printf '%s\\n' "$@"; } +start_portproton () { mkdir -p "$PORT_DATA_PATH/data/prefixes/$PW_PREFIX_NAME/drive_c"; echo start; } +pw_run () { printf '%s\\n' "$@"; } +stop_portproton () { echo stop; } +''' + 'case "$1" in --wine-session-tool)\n' + block + '\nesac\n' + result = subprocess.run( + ["bash", "-c", script, "bash", "--wine-session-tool", tool, "WINE_LG", "MY_PREFIX"], + cwd=tmp_path, capture_output=True, text=True, check=False, + env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path), "PW_PREFIX_NAME": "MY_PREFIX"}, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["WINE_LG", "MY_PREFIX", "start", tool, "stop"] + + +@mark.parametrize("tool", ["winefile", "taskmgr", "winecfg", "regedit", "cmd", "uninstaller"]) +@mark.parametrize("tool_exit_code", [0, 2]) +def test_wine_session_tools_reuse_prefix_without_game_options( + tmp_path: Path, tool: str, tool_exit_code: int, +) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definitions = helper.split("pw_save_session_env () {", 1)[1].split("pw_launch_wrapper () {", 1)[0] + script = "pw_save_session_env () {" + definitions + ''' +print_error () { echo "$@" >&2; } +pw_run () { + printf '%s\\n' "$1" "$WINEPREFIX" "${PW_USE_TERMINAL:-0}" "${LAUNCH_PARAMETERS:-}" \\ + "${PW_RUN_AFTER_EXE:-}" "${PW_RUN_GAMESCOPE:-}" "$PW_TMPFS_PATH" + return "$TOOL_EXIT_CODE" +} +PW_TERM="test-terminal" +export PW_START_PID="$$" WINEPREFIX="$PORT_DATA_PATH/prefix with spaces" +export LAUNCH_PARAMETERS="--game-only" PW_RUN_AFTER_EXE=trainer.exe PW_RUN_GAMESCOPE=gamescope +pw_save_session_env || exit 2 +pw_session_tool "$1" +''' + (tmp_path / "prefix with spaces/drive_c").mkdir(parents=True) + result = subprocess.run( + ["bash", "-c", script, "bash", tool], capture_output=True, text=True, + env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path), + "TOOL_EXIT_CODE": str(tool_exit_code)}, check=False, + ) + + assert result.returncode == int(tool_exit_code != 0), result.stderr + lines = result.stdout.splitlines() + assert lines[:6] == [tool, str(tmp_path / "prefix with spaces"), "1" if tool == "cmd" else "0", "", "", ""] + assert Path(lines[6]).is_dir() + assert Path(lines[6]).stat().st_mode & 0o777 == 0o700 + session = next((tmp_path / "data/tmp").glob("wine-session-*/session.env")) + assert session.stat().st_mode & 0o777 == 0o600 + assert 'declare -x PW_TERM="test-terminal"' in session.read_text() + + +@mark.parametrize("unsafe_directory", [False, True]) +def test_wine_session_save_does_not_follow_symlinks(tmp_path: Path, unsafe_directory: bool) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definition = helper.split("pw_save_session_env () {", 1)[1].split("pw_remove_session_env () {", 1)[0] + session_dir = tmp_path / "data/tmp" / f"wine-session-{os.getuid()}" + session_dir.parent.mkdir(parents=True) + victim = tmp_path / "victim" + victim.write_text("keep this") + if unsafe_directory: + target = tmp_path / "other-directory" + target.mkdir(mode=0o700) + session_dir.symlink_to(target, target_is_directory=True) + else: + session_dir.mkdir(mode=0o700) + (session_dir / "session.env").symlink_to(victim) + result = subprocess.run( + ["bash", "-c", "pw_save_session_env () {" + definition + "\npw_save_session_env"], + capture_output=True, text=True, + env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, check=False, + ) + + assert victim.read_text() == "keep this" + assert result.returncode == (1 if unsafe_directory else 0), result.stderr + if not unsafe_directory: + assert not (session_dir / "session.env").is_symlink() + + +def test_wine_session_tool_rejects_unknown_tool_before_reading_environment(tmp_path: Path) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definition = helper.split("pw_session_tool () {", 1)[1].split("pw_launch_wrapper () {", 1)[0] + script = "pw_session_tool () {" + definition + ''' +print_error () { echo "$@" >&2; } +sleep () { exit 99; } +pw_session_tool '../../other-command' +''' + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, check=False, + ) + + assert result.returncode == 1 + assert "Unsupported Wine tool" in result.stderr + + +def test_wine_session_cleanup_preserves_newer_session(tmp_path: Path) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definitions = helper.split("pw_save_session_env () {", 1)[1].split("pw_session_tool () {", 1)[0] + script = "pw_save_session_env () {" + definitions + ''' +export PW_START_PID="$$" +pw_save_session_env || exit 2 +PW_START_PID=99999999 +pw_remove_session_env +[[ -f "$PORT_DATA_PATH/data/tmp/wine-session-$(id -u)/session.env" ]] || exit 3 +PW_START_PID="$$" +pw_remove_session_env +[[ ! -e "$PORT_DATA_PATH/data/tmp/wine-session-$(id -u)/session.env" ]] +''' + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, check=False, + ) + + assert result.returncode == 0, result.stderr -- 2.54.0