Compare commits

..
2 Commits
Author SHA1 Message Date
Codeberg Translateandadem 56e103793e Translated using Weblate (Russian)
Currently translated at 100.0% (808 of 808 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-30 19:43:10 +00:00
Boria138 0391b894ee feat: support multiple Run After executables and scripts
Code check / Check code (push) Successful in 2m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-31 00:41:47 +05:00
16 changed files with 479 additions and 420 deletions

No files matched your search

+2
View File
@@ -7,6 +7,7 @@
### Added
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
@@ -20,6 +21,7 @@
### Changed
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
@@ -1572,7 +1572,6 @@ pw_kill_autostart () {
export -f pw_kill_autostart
stop_portproton () {
pw_remove_session_env
[[ "$int_xneur" == "1" ]] && xneur &
if [[ "$PW_USE_US_LAYOUT" == "1" ]] \
@@ -3846,7 +3845,6 @@ 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 () {
@@ -3998,80 +3996,6 @@ 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
"$@"
@@ -4088,6 +4012,25 @@ pw_launch_wrapper () {
return
fi
local -a wrapper_scripts=()
local wrapper_script
local wrapper_scripts_valid=1
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
for wrapper_script in "${wrapper_scripts[@]}" ; do
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
wrapper_scripts_valid=0
fi
done
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
for wrapper_script in "${wrapper_scripts[@]}" ; do
"${wrapper_script}"
done
"$@"
return
fi
fi
local -a wrapper_cmd
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
@@ -4269,9 +4212,22 @@ portwine_launch () {
fi
done
else
local -a run_after_exes=() run_after_environment=()
local run_after_entry run_after_index
local run_after_valid=1
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
run_after_exes=("${PW_RUN_AFTER_EXE}")
else
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
fi
for run_after_entry in "${run_after_exes[@]}" ; do
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
run_after_valid=0
fi
done
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
&& [[ "${run_after_valid}" == 1 ]] \
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
then
local wait_delay wait_delay_int run_after_bat
@@ -4295,6 +4251,7 @@ portwine_launch () {
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
fi
pw_exe_file_win="${pw_exe_file_win//\//\\}"
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
@@ -4313,10 +4270,30 @@ timeout /t ${wait_delay_int} /nobreak
start "" "%PW_RUN_AFTER_SECOND_PATH%"
EOF
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
local run_after_first_win="${run_after_exe_win}"
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
run_after_entry="${run_after_exes[run_after_index]}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
else
run_after_exe_win="z:${run_after_entry//\//\\}"
fi
fi
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
done
(
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
export "${run_after_environment[@]}"
fi
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
)
try_remove_file "${run_after_bat}"
else
case "${PW_EXE_FILE,,}" in
+36 -31
View File
@@ -67,21 +67,6 @@ 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}*
@@ -295,7 +280,11 @@ 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
--wine-session-tool Open a Wine tool in the current session, or use optional WINE and prefix
--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
--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
@@ -311,7 +300,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 --wine-session-tool winecfg WINE_LG DEFAULT
portproton cli --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\"
@@ -473,21 +462,37 @@ Usage examples:
pw_unpack_prefix "$2"
exit $?
;;
--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"
--winefile)
get_wine_and_pfx "$2" "$3"
start_portproton
[[ "$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
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
stop_portproton
;;
--winetricks-list-all)
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Generate shell completion files for bash, zsh, and fish
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+3 -1
View File
@@ -607,7 +607,9 @@ class FileExplorer(DraggableDialog):
if not self.directory_only:
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter:
if isinstance(self.file_filter, str):
if callable(self.file_filter):
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
elif isinstance(self.file_filter, str):
files = [f for f in files if f.lower().endswith(self.file_filter)]
elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
+103 -16
View File
@@ -290,6 +290,11 @@ class ExeSettingsDialog(
"""Resolve run-after executable relative to the main executable."""
if not exe_path:
return exe_path
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
return ",".join(
self._resolve_run_after_exe_path(path.strip())
for path in exe_path.split(",") if path.strip()
)
normalized = os.path.normpath(os.path.expanduser(exe_path))
if os.path.isabs(normalized):
return normalized
@@ -300,6 +305,70 @@ class ExeSettingsDialog(
return normalized
return os.path.normpath(os.path.join(game_dir, normalized))
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
table = self.advanced_table
row = next(index for index in range(table.rowCount())
if table.cellWidget(index, 1) is target.parentWidget())
expanded = bool(target.property("ppqt_run_after_expanded"))
target.setProperty("ppqt_run_after_expanded", not expanded)
if expanded:
while row + 1 < table.rowCount():
item = table.item(row + 1, 0)
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
break
table.removeRow(row + 1)
return
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
if not target.text():
entries = [""]
for index, path in enumerate(entries):
entry_row = row + index + 1
table.insertRow(entry_row)
item = QTableWidgetItem(path)
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
table.setItem(entry_row, 0, item)
entry_edit = QLineEdit(path)
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
entry_edit.installEventFilter(self)
entry_edit.textChanged.connect(
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
)
table.setCellWidget(entry_row, 0, entry_edit)
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
)
table.setCellWidget(entry_row, 1, button)
table.resizeRowsToContents()
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
entries = target.text().split(",")
if index < len(entries):
entries[index] = value
target.setText(",".join(entries))
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
entries.pop(index)
self._toggle_run_after_entries(target)
target.setText(",".join(entries))
if entries:
self._toggle_run_after_entries(target)
target.setFocus(Qt.FocusReason.OtherFocusReason)
def _clear_run_after_entries(self, target: QLineEdit) -> None:
table = self.advanced_table
for row in reversed(range(table.rowCount())):
item = table.item(row, 0)
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
table.removeRow(row)
target.setProperty("ppqt_run_after_expanded", False)
target.clear()
if target.isVisible():
target.setFocus(Qt.FocusReason.OtherFocusReason)
def _get_setting_file_selector_path(self, current_path: str) -> str:
"""Get initial path for setting file selectors."""
initial_path = os.path.expanduser("~")
@@ -403,7 +472,7 @@ class ExeSettingsDialog(
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.advanced_table.horizontalHeader().resizeSection(1, 230)
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
self.advanced_table.setWordWrap(True)
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
@@ -951,10 +1020,12 @@ class ExeSettingsDialog(
if setting['key'] == 'PW_RUN_AFTER_EXE':
text_container = QWidget()
text_container.setProperty("ppqt_run_after_exe_widget", True)
text_layout = QHBoxLayout(text_container)
text_layout.setContentsMargins(0, 0, 0, 0)
text_layout.setSpacing(6)
text_layout.addWidget(line_edit)
container_layout = QVBoxLayout(text_container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.addWidget(line_edit)
line_edit.hide()
text_layout = QHBoxLayout()
container_layout.addLayout(text_layout)
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
@@ -966,26 +1037,38 @@ class ExeSettingsDialog(
def open_run_after_exe_selector(
_checked: bool = False,
target_line_edit=line_edit,
file_filter="",
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
):
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = self._get_setting_file_selector_path(
target_line_edit.text().strip()
)
file_explorer = FileExplorer(
self,
theme=self.theme,
file_filter=file_filter,
initial_path=initial_path,
initial_path=self._get_setting_file_selector_path(
target_line_edit.text().rsplit(",", 1)[-1].strip()
),
)
file_explorer.file_signal.file_selected.connect(
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
lambda path, target=target_line_edit: target.setText(
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
)
)
file_explorer.exec()
browse_button.clicked.connect(open_run_after_exe_selector)
expand_button = AutoSizeButton("≡")
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
expand_button.clicked.connect(
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
)
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
clear_button.clicked.connect(
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
)
text_layout.addWidget(clear_button)
text_layout.addWidget(expand_button)
text_layout.addWidget(browse_button)
self.advanced_table.setCellWidget(row, 1, text_container)
else:
@@ -1732,15 +1815,20 @@ class ExeSettingsDialog(
item = table.item(row, 0)
if item is None:
continue
widget = table.cellWidget(row, 0)
if widget is not None and not isinstance(widget, QLabel):
continue
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
if text is None:
text = item.text()
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
label = cast(QLabel | None, table.cellWidget(row, 0))
if label is None:
text = item.text()
label = QLabel()
label.setTextFormat(Qt.TextFormat.RichText)
label.setWordWrap(True)
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
table.setCellWidget(row, 0, label)
label.setAutoFillBackground(False)
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
@@ -1755,7 +1843,6 @@ class ExeSettingsDialog(
label.setPalette(palette)
if label.foregroundRole() != QPalette.ColorRole.Text:
label.setForegroundRole(QPalette.ColorRole.Text)
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
name, separator, description = text.partition("\n")
formatted_text = escape(name)
if separator:
+33 -9
View File
@@ -476,7 +476,14 @@ class SettingsInputMixin(InputMixin):
if table != self.settings_dialog.advanced_table:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
entry_widget = table.cellWidget(row, 0)
if isinstance(entry_widget, QLineEdit):
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, QPushButton):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if isinstance(cell_widget, QComboBox):
line_edit = cell_widget.lineEdit()
if cell_widget.isEditable() and line_edit is not None:
@@ -492,15 +499,33 @@ class SettingsInputMixin(InputMixin):
and cell_widget.property("ppqt_run_after_exe_widget")
):
line_edit = cell_widget.findChild(QLineEdit)
if line_edit and line_edit.isEnabled():
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return
button = cell_widget.findChild(QPushButton)
if button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
"""Move focus between inline line edit and button in settings cell."""
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
return False
focused = QApplication.focusWidget()
for row in range(table.rowCount()):
entry_widget = table.cellWidget(row, 0)
delete_button = table.cellWidget(row, 1)
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
continue
if focused not in (entry_widget, delete_button):
continue
widget = delete_button if focused == entry_widget else entry_widget
if not widget.isEnabled():
return False
table.setCurrentCell(row, 1)
widget.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if table.currentColumn() != 1:
return False
@@ -508,15 +533,14 @@ class SettingsInputMixin(InputMixin):
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
return False
line_edit = cell_widget.findChild(QLineEdit)
button = cell_widget.findChild(QPushButton)
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
focused = QApplication.focusWidget()
if value > 0 and focused == line_edit and button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if focused in widgets:
index = widgets.index(focused) + (1 if value > 0 else -1)
if 0 <= index < len(widgets):
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
return False
def _focus_first_row_in_current_settings_table(self):
@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
"PO-Revision-Date: 2026-10-26 12:27+0000\n"
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/ru/>\n"
@@ -996,9 +996,8 @@ msgstr "Пользовательская обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
#, fuzzy
msgid "Custom Icon:"
msgstr "Пользовательская обложка:"
msgstr "Пользовательский значок:"
msgid "Add shortcut to:"
msgstr "Добавить ярлык:"
+16 -8
View File
@@ -160,12 +160,11 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
tools_grid.setSpacing(6)
tools = [
("winecfg", _("Wine Configuration")),
("regedit", _("Registry Editor")),
("winefile", _("File Explorer")),
("cmd", _("Command Prompt")),
("uninstaller", _("Uninstaller")),
("taskmgr", _("Task Manager")),
("--winecfg", _("Wine Configuration")),
("--winereg", _("Registry Editor")),
("--winefile", _("File Explorer")),
("--winecmd", _("Command Prompt")),
("--wine_uninstaller", _("Uninstaller")),
]
for i, (tool_cmd, tool_name) in enumerate(tools):
@@ -238,7 +237,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", "--wine-session-tool", cli_arg, wine, prefix]
cmd = start_sh + ["cli", cli_arg, wine, prefix]
# Show progress bar before launch
self.wine_progress_bar.setVisible(True)
@@ -270,7 +269,16 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
def _check_wine_process(self, cli_arg):
"""Check if target .exe process started."""
target_exe = f"{cli_arg}.exe"
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
# Check processes via psutil
for proc in psutil.process_iter(attrs=["name"]):
@@ -370,3 +370,5 @@ WINETRICKS_CHECK_COLUMN_WIDTH = 80
WINETRICKS_ROW_HEIGHT = 36
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
RUN_AFTER_VALUE_WIDTH = 230
-40
View File
@@ -3,7 +3,6 @@ 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
@@ -46,39 +45,6 @@ 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.
@@ -117,12 +83,8 @@ 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()
@@ -141,7 +103,6 @@ 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
@@ -190,7 +151,6 @@ 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"))
+25
View File
@@ -1,5 +1,6 @@
"""Tests for themed file explorer icons."""
import os
from pathlib import Path
from types import SimpleNamespace
from typing import cast
@@ -29,6 +30,30 @@ from portprotonqt.dialogs.file_explorer import (
_application = QApplication.instance() or QApplication([])
def test_executable_filter_keeps_exe_and_linux_executables(tmp_path: Path) -> None:
from PySide6.QtCore import QMimeDatabase
from PySide6.QtWidgets import QLabel
for name in ("Game.EXE", "launcher", "notes.txt"):
(tmp_path / name).touch()
(tmp_path / "launcher").chmod(0o755)
(tmp_path / "folder").mkdir()
explorer = SimpleNamespace(
file_list=QListWidget(), current_path=str(tmp_path), directory_only=False,
thumbnail_cache={}, pending_thumbnails={}, deferred_thumbnails={}, file_items={},
path_history={}, path_label=QLabel(), mime_db=QMimeDatabase(),
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
_get_list_icon=lambda name: None, _get_file_type_icon=lambda path: None,
schedule_thumbnail_loading=lambda: None,
)
FileExplorer.update_file_list(cast(FileExplorer, explorer))
assert [explorer.file_list.item(i).text() for i in range(explorer.file_list.count())] == [
"../", "folder/", "Game.EXE", "launcher",
]
def test_gamepad_opens_file_explorer_context_menu(monkeypatch: MonkeyPatch) -> None:
explorer = FileExplorer.__new__(FileExplorer)
QDialog.__init__(explorer)
+25
View File
@@ -1632,3 +1632,28 @@ def test_confirm_on_open_combo_activates_highlighted_row() -> None:
assert not combo.view().isVisible()
combo.close()
application.processEvents()
@mark.parametrize("direction", [-1, 1])
@mark.parametrize("column", [0, 1])
def test_run_after_dpad_reaches_delete_from_entry(direction: int, column: int) -> None:
from portprotonqt.input_manager.settings import SettingsInputMixin
app = QApplication.instance() or QApplication([])
table = QTableWidget(1, 2)
entry = QLineEdit("game.exe")
remove = QPushButton("delete")
table.setCellWidget(0, 0, entry)
table.setCellWidget(0, 1, remove)
manager = SimpleNamespace(settings_dialog=SimpleNamespace(advanced_table=table))
table.show()
table.activateWindow()
table.setCurrentCell(0, column)
entry.setFocus()
app.processEvents()
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, direction)
assert QApplication.focusWidget() == remove
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, -direction)
assert QApplication.focusWidget() == entry
table.close()
table.deleteLater()
+4 -101
View File
@@ -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, create_wine_tools_menu, launch_session_wine_tool
from portprotonqt.tray_manager import TrayManager
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,71 +374,18 @@ def test_minimal_tray_contains_game_actions() -> 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.pause_game_action = QAction("Pause Game", manager.tray_menu)
manager.stop_game_action = QAction("Stop Game", manager.tray_menu)
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(),
]
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
manager.update_game_actions.assert_called_once_with()
def test_tray_pauses_game_process_tree(monkeypatch: MonkeyPatch) -> None:
child = MagicMock(pid=12)
@@ -3431,47 +3378,3 @@ 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"])
+61 -128
View File
@@ -43,20 +43,22 @@ gui_config () {
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
@mark.parametrize("copies", [1, 3])
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 1)[1].split(
condition = helper.split(' local -a run_after_exes=', 1)[1].split(
" then", 1,
)[0]
condition = ' local -a run_after_exes=' + condition
main_exe = tmp_path / "Game.exe"
second_exe = tmp_path / second_name
main_exe.touch()
second_exe.touch()
result = subprocess.run(
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
+ "then exit 0; else exit 1; fi"],
["bash", "-c", 'check_run_after() {\n' + condition
+ "then return 0; else return 1; fi; }; check_run_after"],
env={**os.environ, "PW_EXE_FILE": str(main_exe),
"PW_RUN_AFTER_EXE": str(second_exe)},
"PW_RUN_AFTER_EXE": ",".join([str(second_exe)] * copies)},
capture_output=True, text=True, check=False,
)
assert result.returncode == 0, result.stderr
@@ -112,7 +114,9 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
)[0]
main_path = "D:\\Games\\Test Game\\Test Game.exe"
result = subprocess.run(
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
["bash", "-c", 'pw_run() { env; }\nlaunch() {\n'
'local -a run_after_exes=(unused) run_after_environment=()\n'
'cat > "$run_after_bat" <<EOF\n' + block + '\n}; launch'],
env={**os.environ, "run_after_bat": str(tmp_path / "launch.bat"),
"pw_exe_file_win": main_path, "run_after_exe_win": trainer_path,
"wait_delay_int": "3", "LAUNCH_PARAMETERS": ""},
@@ -120,6 +124,7 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
)
assert f"PW_RUN_AFTER_MAIN_PATH={main_path}" in result.stdout
assert f"PW_RUN_AFTER_SECOND_PATH={trainer_path}" in result.stdout
assert "PW_RUN_AFTER_EXE=\n" in result.stdout
batch = (tmp_path / "launch.bat").read_text(encoding="ascii")
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in batch
@@ -234,139 +239,67 @@ def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback(
assert "Company Name: Sony" in metadata
def test_wine_session_missing_returns_distinct_status(tmp_path: Path) -> None:
def test_run_after_batch_preserves_duplicate_entries(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]
block = helper.split(' cat > "${run_after_bat}" <<EOF\n', 1)[1].split(
' try_remove_file "${run_after_bat}"', 1,
)[0]
exe = tmp_path / "EVE online.exe"
exe.touch()
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'
loader() { printf 'Z:%s' "$3"; }
pw_run() { env; }
launch() {
local -a run_after_exes=("$EXE" "$EXE" "$EXE") run_after_environment=()
local run_after_index run_after_entry
WINELOADER=loader
cat > "$run_after_bat" <<EOF
''' + block + '\n}; launch'
batch_path = tmp_path / "launch.bat"
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"},
["bash", "-c", script], capture_output=True, text=True, check=True,
env={**os.environ, "EXE": str(exe), "run_after_bat": str(batch_path),
"pw_exe_file_win": "Z:\\EVE online.exe",
"run_after_exe_win": "Z:\\EVE online.exe", "wait_delay_int": "3"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == ["WINE_LG", "MY_PREFIX", "start", tool, "stop"]
batch = batch_path.read_text()
assert batch.count('start ""') == 4
assert '%PW_RUN_AFTER_PATH_1%' in batch
assert '%PW_RUN_AFTER_PATH_2%' in batch
expected = "Z:" + str(exe).replace("/", "\\")
assert f"PW_RUN_AFTER_PATH_1={expected}" in result.stdout
assert f"PW_RUN_AFTER_PATH_2={expected}" in result.stdout
@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:
def test_run_after_wrapper_runs_script_list_with_duplicates(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_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)
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
"export -f pw_launch_wrapper", 1,
)[0]
first = tmp_path / "first script.sh"
second = tmp_path / "second.sh"
for script, message in [(first, "first"), (second, "second")]:
script.write_text(f"#!/bin/bash\nprintf '{message}\\n'\n")
script.chmod(0o700)
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,
["bash", "-c", "pw_launch_wrapper () {" + wrapper
+ "\npw_launch_wrapper printf 'game\\n'"],
env={**os.environ, "PW_RUN_AFTER_EXE": f"{first},{second},{first}"},
capture_output=True, text=True, check=True,
)
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()
assert result.stdout.splitlines() == ["first", "second", "first", "game"]
@mark.parametrize("unsafe_directory", [False, True])
def test_wine_session_save_does_not_follow_symlinks(tmp_path: Path, unsafe_directory: bool) -> None:
def test_run_after_wrapper_preserves_command_with_comma(tmp_path: Path) -> 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)
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
"export -f pw_launch_wrapper", 1,
)[0]
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,
["bash", "-c", "pw_launch_wrapper () {" + wrapper
+ "\npw_launch_wrapper printf 'game\\n'"],
cwd=tmp_path,
env={**os.environ, "PW_RUN_AFTER_EXE": "printf '%s\\n' first,second"},
capture_output=True, text=True, check=True,
)
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
assert result.stdout.splitlines() == ["first,second", "game"]
+107 -1
View File
@@ -3,7 +3,7 @@
import subprocess
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
import pytest
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
@@ -458,6 +458,22 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
table.selectRow(1)
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
assert isinstance(table.cellWidget(1, 0), QLabel)
label = table.cellWidget(1, 0)
ExeSettingsDialog._set_favorite_text_cells(
cast(ExeSettingsDialog, dialog), 1, "key", "Updated", "New <description>",
)
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
assert table.cellWidget(1, 0) is label
assert isinstance(label, QLabel)
assert label.text() == theme.SETTINGS_SELECTED_TEXT_HTML.format(
text="Updated<br>" + theme.SETTINGS_SELECTED_DESCRIPTION_HTML.format(
description="New &lt;description&gt;",
),
)
updated_item = table.item(1, 0)
assert updated_item is not None
assert updated_item.data(Qt.ItemDataRole.AccessibleTextRole) == "Updated\nNew <description>"
assert updated_item.text() == ""
table.close()
@@ -925,3 +941,93 @@ def test_settings_close_waits_for_pending_save(monkeypatch) -> None:
dialog.reject()
assert dialog.close_after_save
dialog.deleteLater()
def test_run_after_normalization_preserves_duplicates(tmp_path: Path) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
dialog = SimpleNamespace(exe_path=str(tmp_path / "Game.exe"))
dialog._resolve_run_after_exe_path = lambda value: ExeSettingsDialog._resolve_run_after_exe_path(cast(ExeSettingsDialog, dialog), value)
value = "EVE.exe,EVE.exe,Trainer.exe"
assert dialog._resolve_run_after_exe_path(value) == ",".join(
str(tmp_path / name) for name in value.split(",")
)
def test_run_after_inline_entries_preserve_duplicates(tmp_path: Path) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
owner = cast(Any, QWidget())
owner.advanced_table = QTableWidget(2, 3)
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
container = QWidget()
target = QLineEdit(container)
owner.advanced_table.setCellWidget(0, 1, container)
from PySide6.QtWidgets import QTableWidgetItem
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
exe = tmp_path / "Game.exe"
exe.touch()
target.setText(f"{exe},{exe}")
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
assert owner.advanced_table.rowCount() == 4
item = owner.advanced_table.item(1, 0)
assert item is not None and item.text() == str(exe)
item = owner.advanced_table.item(2, 0)
assert item is not None and item.text() == str(exe)
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
assert owner.advanced_table.rowCount() == 2
item = owner.advanced_table.item(1, 0)
assert item is not None and item.text() == "next setting"
owner.advanced_table.deleteLater()
def test_run_after_entry_paint_preserves_text_and_height() -> None:
from PySide6.QtWidgets import QTableWidgetItem
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
table = QTableWidget(1, 2)
table.viewport().setProperty("ppqt_settings_values", True)
table.setItem(0, 0, QTableWidgetItem("Game.exe"))
entry = QLineEdit("Game.exe")
table.setCellWidget(0, 0, entry)
table.resizeRowsToContents()
table.show()
QApplication.processEvents()
height = table.rowHeight(0)
for _ in range(2):
ExeSettingsDialog.eventFilter(
cast(ExeSettingsDialog, SimpleNamespace()),
table.viewport(), QEvent(QEvent.Type.Paint),
)
assert entry.text() == "Game.exe"
item = table.item(0, 0)
assert item is not None and item.text() == "Game.exe"
assert table.rowHeight(0) == height
assert entry.height() >= entry.sizeHint().height()
table.close()
table.deleteLater()
def test_run_after_removing_only_entry_clears_field(tmp_path: Path) -> None:
from PySide6.QtWidgets import QTableWidgetItem
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
owner = cast(Any, QWidget())
owner.advanced_table = QTableWidget(2, 3)
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
owner._toggle_run_after_entries = lambda target: ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
container = QWidget()
target = QLineEdit(container)
owner.advanced_table.setCellWidget(0, 1, container)
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
exe = tmp_path / "Game.exe"
exe.touch()
target.setText(str(exe))
owner._toggle_run_after_entries(target)
ExeSettingsDialog._remove_run_after_entry(cast(ExeSettingsDialog, owner), target, 0)
assert target.text() == ""
assert owner.advanced_table.rowCount() == 2
owner.advanced_table.deleteLater()