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
11 changed files with 438 additions and 42 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
@@ -4012,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
@@ -4193,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
@@ -4219,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
@@ -4237,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
+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 "Добавить ярлык:"
@@ -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
+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()
+77 -6
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
@@ -232,3 +237,69 @@ 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_run_after_batch_preserves_duplicate_entries(tmp_path: Path) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
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 = '''
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], 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"},
)
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
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()
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", "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.stdout.splitlines() == ["first", "second", "first", "game"]
def test_run_after_wrapper_preserves_command_with_comma(tmp_path: Path) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
"export -f pw_launch_wrapper", 1,
)[0]
result = subprocess.run(
["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 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()