Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9f935222
|
No files matched your search
@@ -31,24 +31,9 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -89,24 +74,9 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
|
||||
@@ -24,24 +24,9 @@ jobs:
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install CI dependencies
|
||||
|
||||
@@ -83,24 +83,9 @@ jobs:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -213,24 +198,9 @@ jobs:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -59,14 +58,12 @@
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
- При выключенной опции «Fix video in game» больше не блокируется создание DXGI Device Manager в Wine/Proton
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @iodnoorov
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2999,7 +2999,7 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
|
||||
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
|
||||
else unset WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER
|
||||
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
|
||||
fi
|
||||
|
||||
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
|
||||
|
||||
@@ -84,8 +84,8 @@ _portprotonqt() {
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
'--silent[Launch .exe file without GUI]'
|
||||
'--log[Enable PortProton logging]'
|
||||
'--silent[Launch Windows file or native game in tray]'
|
||||
'--log[Enable game logging]'
|
||||
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
|
||||
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
@@ -120,8 +120,8 @@ complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
|
||||
complete -c portprotonqt -l log -d "Enable PortProton logging"
|
||||
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
|
||||
complete -c portprotonqt -l log -d "Enable game logging"
|
||||
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
|
||||
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
|
||||
complete -c portprotonqt -s h -l help -d "Show help message"
|
||||
|
||||
+34
-8
@@ -25,6 +25,7 @@ from PySide6.QtGui import QIcon
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
display_config,
|
||||
find_game_by_exe,
|
||||
get_portproton_start_command,
|
||||
get_portproton_location,
|
||||
save_portdata_path_to_config,
|
||||
@@ -33,6 +34,7 @@ from portprotonqt.config import (
|
||||
window_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
|
||||
from portprotonqt.cli import (
|
||||
parse_args,
|
||||
is_portproton_url,
|
||||
@@ -271,6 +273,17 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parsed_args
|
||||
native_launch_uri = None
|
||||
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
|
||||
native_launch_uri = args.file_or_url
|
||||
elif args.file_or_url and not is_launch_file(args.file_or_url):
|
||||
target = normalize_launch_path(args.file_or_url)
|
||||
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
|
||||
entry = find_game_by_exe(target)
|
||||
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
|
||||
result = create_native_desktop_file(target, name)
|
||||
if result and os.path.isfile(result[1]):
|
||||
native_launch_uri = NATIVE_URI_PREFIX + result[1]
|
||||
setup_logger(args.debug_level)
|
||||
logger = get_logger(__name__)
|
||||
if consume_download_counter_skip():
|
||||
@@ -302,6 +315,9 @@ def main():
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif native_launch_uri:
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
@@ -399,7 +415,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -484,7 +500,7 @@ def main():
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri
|
||||
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
@@ -494,9 +510,9 @@ def main():
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
@@ -510,7 +526,7 @@ def main():
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16],
|
||||
"protondb_appid": game[16] if len(game) > 16 else "",
|
||||
})
|
||||
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
@@ -538,6 +554,12 @@ def main():
|
||||
restart_target = target
|
||||
app.quit()
|
||||
return
|
||||
if target.startswith(NATIVE_URI_PREFIX):
|
||||
if silent and not log_mode:
|
||||
window.toggleGame(target)
|
||||
else:
|
||||
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
|
||||
return
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
@@ -552,7 +574,11 @@ def main():
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if exe_path:
|
||||
if native_launch_uri:
|
||||
def handle_native_launch() -> None:
|
||||
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_native_launch)
|
||||
elif exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
@@ -633,8 +659,8 @@ def main():
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
|
||||
handle_game_request(unquote(launch_path), False, log_mode)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
|
||||
+3
-3
@@ -89,12 +89,12 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--silent",
|
||||
action="store_true",
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
help="Launch supported Windows file or native game in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
help="Enable logging for the launched Windows file or native game"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
@@ -111,7 +111,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
'file_or_url',
|
||||
nargs='?',
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
|
||||
)
|
||||
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
|
||||
separator_index = sys.argv.index("--")
|
||||
|
||||
@@ -2,6 +2,8 @@ import os
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
|
||||
@@ -9,6 +11,7 @@ from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
get_portproton_location,
|
||||
extract_exec_target_path,
|
||||
favorites_config,
|
||||
favorites_folders_config,
|
||||
@@ -29,6 +32,7 @@ from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
@@ -127,6 +131,8 @@ class ContextMenuManager:
|
||||
Returns:
|
||||
bool: True if the game is running, False otherwise.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
|
||||
if game_card.game_source == "steam":
|
||||
return False
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -731,6 +737,9 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "native":
|
||||
self._add_native_actions(menu, game_card)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -829,6 +838,96 @@ class ContextMenuManager:
|
||||
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
|
||||
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
for directory, add_text, remove_text, icon in (
|
||||
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
|
||||
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
|
||||
):
|
||||
if not directory:
|
||||
continue
|
||||
exists = (Path(directory) / source.name).is_file()
|
||||
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
|
||||
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(card.name)
|
||||
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
|
||||
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
|
||||
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
|
||||
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
action.triggered.connect(lambda: self._edit_native_shortcut(card))
|
||||
if managed:
|
||||
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
|
||||
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
|
||||
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
|
||||
|
||||
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
target = Path(directory) / source.name
|
||||
try:
|
||||
if target.is_file():
|
||||
if target == source:
|
||||
retained = Path(get_portproton_location() or source.parent) / source.name
|
||||
retained.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, retained)
|
||||
target.unlink()
|
||||
else:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(source.read_text(encoding="utf-8"))
|
||||
command = quote_native_arguments([exec_line])
|
||||
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
|
||||
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
|
||||
section = f"Desktop Action {action}"
|
||||
if parser.has_section(section):
|
||||
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open("w", encoding="utf-8") as output:
|
||||
parser.write(output, space_around_delimiters=False)
|
||||
target.chmod(0o755)
|
||||
if directory == self._get_applications_dir():
|
||||
self._update_desktop_database()
|
||||
self.parent.refreshGames()
|
||||
except Exception as error:
|
||||
logger.warning("Cannot update native shortcut: %s", error)
|
||||
self.signals.show_warning_dialog.emit(_("Error"), str(error))
|
||||
|
||||
def _edit_native_shortcut(self, card: "GameCard") -> None:
|
||||
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=card.name, exe_path=source, cover_path=card.cover_path)
|
||||
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
name = dialog.nameEdit.text().strip()
|
||||
if not name:
|
||||
return
|
||||
cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(cover) and dialog.last_cover_path:
|
||||
cover = dialog.last_cover_path
|
||||
try:
|
||||
self._save_local_shortcut_name(source, name)
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
|
||||
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
|
||||
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
|
||||
if os.path.abspath(cover) != str(target):
|
||||
self._remove_old_cover_files(directory)
|
||||
shutil.copyfile(cover, target)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(card.name):
|
||||
self.add_to_steam(name, card.exec_line, cover)
|
||||
elif is_game_in_steam(card.name):
|
||||
self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
self.parent.refreshGames()
|
||||
except OSError as error:
|
||||
logger.warning("Cannot edit native metadata: %s", error)
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
Launch or stop a game based on its current state.
|
||||
@@ -836,6 +935,9 @@ class ContextMenuManager:
|
||||
Args:
|
||||
game_card: The GameCard instance containing game data.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@@ -996,6 +1098,9 @@ class ContextMenuManager:
|
||||
|
||||
def _get_exec_line(self, game_name, exec_line):
|
||||
"""Retrieve and validate exec_line from .desktop file if necessary."""
|
||||
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
return entry.get("Exec") if entry else None
|
||||
if exec_line and exec_line.strip() != "full":
|
||||
return exec_line
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1127,6 +1232,20 @@ class ContextMenuManager:
|
||||
|
||||
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
|
||||
card_exec_line = exec_line
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
|
||||
str(Path(self._get_applications_dir()) / source.name)]
|
||||
if desktop:
|
||||
paths.append(str(Path(desktop) / source.name))
|
||||
self._remove_installed_shortcuts(game_name, paths)
|
||||
self._remove_statistics_entry(str(source), game_name)
|
||||
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||
for key in ("all", "native", "favorites"):
|
||||
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1754,10 +1873,11 @@ class ContextMenuManager:
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
if not exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
if not exec_line.startswith((NATIVE_URI_PREFIX, "gog://launch/", "egs://launch/")):
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
@@ -1812,11 +1932,11 @@ class ContextMenuManager:
|
||||
_(message).format(game_name=game_name)
|
||||
)
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
|
||||
@@ -6,6 +6,9 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import IO
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import get_portproton_location
|
||||
@@ -18,7 +21,7 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DebugLogManager:
|
||||
"""Manages debug log creation with game launch and Wine output capture."""
|
||||
"""Manage debug logs for Wine and native game launches."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
@@ -26,15 +29,19 @@ class DebugLogManager:
|
||||
self.start_command: list[str] | None = None
|
||||
self.wine_output: list[str] = []
|
||||
self.runtime_env: dict[str, str] | None = None
|
||||
self.native_output: IO[str] | None = None
|
||||
self.native_directory: str | None = None
|
||||
self.is_running = False
|
||||
self.output_queue = queue.Queue()
|
||||
self.output_thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, exe_path: str, start_command: list[str]) -> bool:
|
||||
"""Start game with PW_LOG=1 and capture output."""
|
||||
"""Capture game output, enabling PW_LOG for PortProton launches."""
|
||||
if self.is_running:
|
||||
return False
|
||||
if exe_path.startswith("native://"):
|
||||
return self._start_native(exe_path)
|
||||
|
||||
self.exe_path = exe_path
|
||||
self.start_command = start_command
|
||||
@@ -86,6 +93,52 @@ class DebugLogManager:
|
||||
logger.error("Failed to start debug session: %s", e)
|
||||
return False
|
||||
|
||||
def _start_native(self, uri: str) -> bool:
|
||||
from portprotonqt.native_games import get_native_launch
|
||||
|
||||
try:
|
||||
command, directory = get_native_launch(uri.removeprefix("native://"))
|
||||
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
|
||||
self.process = subprocess.Popen(
|
||||
command, cwd=directory, stdout=self.native_output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to start native debug session: %s", error)
|
||||
if self.native_output is not None:
|
||||
self.native_output.close()
|
||||
self.native_output = None
|
||||
return False
|
||||
self.exe_path = uri.removeprefix("native://")
|
||||
self.start_command = command
|
||||
self.native_directory = directory
|
||||
self.is_running = True
|
||||
return True
|
||||
|
||||
def _save_native_log(self) -> str | None:
|
||||
output = self.native_output
|
||||
if output is None:
|
||||
return None
|
||||
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
|
||||
|
||||
try:
|
||||
settings = load_native_settings(str(self.exe_path))
|
||||
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.seek(0)
|
||||
with destination.open("w", encoding="utf-8") as log:
|
||||
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
|
||||
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
|
||||
shutil.copyfileobj(output, log)
|
||||
return str(destination)
|
||||
except OSError as error:
|
||||
logger.error("Failed to save native debug log: %s", error)
|
||||
return None
|
||||
finally:
|
||||
output.close()
|
||||
self.native_output = None
|
||||
self.native_directory = None
|
||||
|
||||
def _read_output(self):
|
||||
"""Read output from subprocess in a separate thread."""
|
||||
if self.process and self.process.stdout:
|
||||
@@ -98,19 +151,20 @@ class DebugLogManager:
|
||||
logger.debug("Error reading output: %s", e)
|
||||
|
||||
def stop(self) -> str | None:
|
||||
"""Stop game and save debug log with captured Wine output."""
|
||||
self._capture_env_vars()
|
||||
"""Stop the game and save its diagnostic log."""
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
self._stop_event.set()
|
||||
|
||||
if self.is_running and self.process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.process.poll() is None:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -123,7 +177,7 @@ class DebugLogManager:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
log_file = self._save_log()
|
||||
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
|
||||
|
||||
if self.process and self.process.stdout:
|
||||
try:
|
||||
@@ -146,7 +200,8 @@ class DebugLogManager:
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
self._capture_env_vars()
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
return True
|
||||
|
||||
def _capture_env_vars(self) -> None:
|
||||
|
||||
@@ -290,7 +290,8 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
|
||||
game_dir = os.path.dirname(exe_path)
|
||||
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
|
||||
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
if portproton_path:
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
|
||||
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -313,8 +314,17 @@ def generate_system_info(
|
||||
get_d3d_extras_status,
|
||||
)
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
|
||||
|
||||
native = bool(exe_path and exe_path.startswith("native://"))
|
||||
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
|
||||
if desktop_path:
|
||||
runtime_env = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
exe_path = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
|
||||
portproton_path = get_portproton_location() or ""
|
||||
if not portproton_path and not native:
|
||||
return "Error: PortProton location not found"
|
||||
|
||||
lines = []
|
||||
@@ -344,16 +354,17 @@ def generate_system_info(
|
||||
lines.append(get_libc_version())
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
if not native:
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Date and time of start debug for PortProton:")
|
||||
lines.append(datetime.now().strftime("%c %z"))
|
||||
@@ -419,26 +430,28 @@ def generate_system_info(
|
||||
lines.append(locale_avail)
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
if not native:
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
if exe_path:
|
||||
ppdb_content = get_ppdb_content(exe_path, start_cmd)
|
||||
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
|
||||
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
|
||||
if ppdb_content:
|
||||
lines.append(f"Use {exe_path}.ppdb db file:")
|
||||
lines.append(f"Use {ppdb_path}.ppdb db file:")
|
||||
if ppdb_log_vars:
|
||||
lines.append("\n".join(ppdb_log_vars))
|
||||
lines.append(ppdb_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
user_overrides = get_user_overrides(portproton_path)
|
||||
user_overrides = "" if native else get_user_overrides(portproton_path)
|
||||
if user_overrides:
|
||||
lines.append(user_overrides)
|
||||
lines.append("-" * 61)
|
||||
|
||||
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.native_games import get_native_launch, load_native_settings
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
create_scroll_area,
|
||||
create_back_button,
|
||||
@@ -655,7 +657,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
str(game_source).lower() not in {"gog", "egs", "native"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -670,7 +672,24 @@ class DetailPageManager:
|
||||
buttons_layout.addWidget(add_button)
|
||||
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if str(game_source).lower() == "portproton":
|
||||
if source == "native":
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
|
||||
buttons_layout.addWidget(settings_button)
|
||||
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
if os.path.dirname(exec_line.removeprefix("native://")) in (
|
||||
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
|
||||
):
|
||||
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
|
||||
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
source in {"gog", "egs"}
|
||||
@@ -725,6 +744,8 @@ class DetailPageManager:
|
||||
|
||||
def _get_current_exe(self, exec_line: str) -> str | None:
|
||||
"""Extract current executable from exec line."""
|
||||
if exec_line.startswith("native://"):
|
||||
return os.path.basename(exec_line.removeprefix("native://"))
|
||||
file_to_check = extract_exec_target_path(exec_line)
|
||||
return os.path.basename(file_to_check) if file_to_check else None
|
||||
|
||||
@@ -822,9 +843,26 @@ class DetailPageManager:
|
||||
|
||||
def _open_executable_folder(self, exe_path: str | None) -> None:
|
||||
"""Open the folder containing an executable."""
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
if not exe_path:
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
if exe_path.startswith("native://"):
|
||||
try:
|
||||
desktop_path = exe_path.removeprefix("native://")
|
||||
settings = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
executable = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
|
||||
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
raise ValueError("Game directory is not available")
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native game folder: %s", error)
|
||||
QMessageBox.warning(self.main_window, _("Error"), str(error))
|
||||
return
|
||||
else:
|
||||
if not os.path.exists(exe_path):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
|
||||
def _has_game_shortcut(self, game_name: str) -> bool:
|
||||
@@ -1454,14 +1492,15 @@ class DetailPageManager:
|
||||
"""Start debug logging session."""
|
||||
if exe_path is None:
|
||||
return
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
native = exe_path.startswith("native://")
|
||||
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
start_command = [] if native else get_portproton_start_command()
|
||||
if start_command is None:
|
||||
return
|
||||
|
||||
if self.debug_log_manager.start(resolved_exe_path, start_command):
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any, cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
import psutil
|
||||
@@ -31,6 +31,7 @@ from portprotonqt.config import (
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.native_games import create_native_desktop_file, is_native_executable
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -483,6 +484,9 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
|
||||
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
|
||||
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
|
||||
self.add_to_menu_checkbox.setChecked(True)
|
||||
self.add_to_desktop_checkbox.setChecked(True)
|
||||
|
||||
self.coverPreview = QLabel(self)
|
||||
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -620,7 +624,10 @@ class AddGameDialog(DraggableDialog):
|
||||
try:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
def file_filter(path: str) -> bool:
|
||||
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
|
||||
|
||||
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
@@ -960,6 +967,13 @@ class AddGameDialog(DraggableDialog):
|
||||
if not name:
|
||||
return None, None
|
||||
|
||||
if is_native_executable(exe_path):
|
||||
cover = self.last_cover_path or self.coverEdit.text().strip()
|
||||
result = create_native_desktop_file(exe_path, name, cover)
|
||||
if result is None:
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
|
||||
return result or (None, None)
|
||||
|
||||
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
|
||||
parent_widget = cast("MainWindow | None", self.parentWidget())
|
||||
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
|
||||
|
||||
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
|
||||
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
thumbnail_source = self.file_path
|
||||
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
|
||||
resolved_path = self.resolve_launch_file_path(self.file_path)
|
||||
@@ -125,6 +125,14 @@ class FileExplorer(DraggableDialog):
|
||||
return
|
||||
thumbnail_source = resolved_path
|
||||
|
||||
if thumbnail_source.lower().endswith(".appimage"):
|
||||
icon_path = get_exe_icon_cache_path(thumbnail_source)
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
image = QImage()
|
||||
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
|
||||
image = QImage(icon_path)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
return
|
||||
if not thumbnail_source.lower().endswith(".exe"):
|
||||
return
|
||||
|
||||
@@ -142,6 +150,8 @@ class FileExplorer(DraggableDialog):
|
||||
os.unlink(tmp_name)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
if self.file_path.lower().endswith(".appimage"):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QImage())
|
||||
|
||||
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
|
||||
super().__init__(parent)
|
||||
@@ -214,7 +224,7 @@ class FileExplorer(DraggableDialog):
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
resolver = None
|
||||
main_window = cast("MainWindow | None", self.main_window)
|
||||
@@ -246,6 +256,8 @@ class FileExplorer(DraggableDialog):
|
||||
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
|
||||
icon = QIcon(QPixmap.fromImage(image))
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
if image.isNull():
|
||||
return
|
||||
item = self.file_items.get(file_path)
|
||||
if item:
|
||||
item.setData(THEMED_ICON_ROLE, None)
|
||||
|
||||
@@ -60,6 +60,7 @@ from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, Gamescop
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_TOGGLE_KEYS, NATIVE_ADVANCED_KEYS, load_native_settings, save_native_settings, get_native_ppdb_path
|
||||
from portprotonqt.preloader import Preloader
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
from portprotonqt.settings_manager import (
|
||||
@@ -166,11 +167,11 @@ class ExeSettingsDialog(
|
||||
if not self.user_conf and not self.exe_path and not self.appid:
|
||||
return
|
||||
self.portproton_path = get_portproton_location()
|
||||
if self.portproton_path is None:
|
||||
if self.portproton_path is None and self.game_source != "native":
|
||||
logger.error("PortProton location not found")
|
||||
return
|
||||
self.start_sh = get_portproton_start_command()
|
||||
if self.start_sh is None:
|
||||
if self.start_sh is None and self.game_source != "native":
|
||||
logger.error("PortProton start command not found")
|
||||
return
|
||||
|
||||
@@ -178,7 +179,7 @@ class ExeSettingsDialog(
|
||||
self.lg_dist_aliases = {}
|
||||
self.plugins_ver = ""
|
||||
self.prefix_options = []
|
||||
if self.portproton_path:
|
||||
if self.portproton_path and self.game_source != "native":
|
||||
scripts_path = get_portproton_scripts_path()
|
||||
if scripts_path:
|
||||
var_path = os.path.join(scripts_path, "var")
|
||||
@@ -229,6 +230,9 @@ class ExeSettingsDialog(
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
self.toggle_settings = get_toggle_settings()
|
||||
if self.game_source == "native":
|
||||
self.toggle_settings = {key: description for key, description in self.toggle_settings.items() if key in NATIVE_TOGGLE_KEYS}
|
||||
self.setWindowTitle(_("Native") + " — " + _("Settings"))
|
||||
|
||||
self.setup_ui()
|
||||
if self.user_conf:
|
||||
@@ -581,6 +585,10 @@ class ExeSettingsDialog(
|
||||
self.settings_container.setCurrentIndex(0)
|
||||
self.advanced_container.setCurrentIndex(0)
|
||||
|
||||
if self.game_source == "native":
|
||||
self.on_show_ppdb_finished(0, QProcess.ExitStatus.NormalExit)
|
||||
return
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_show_ppdb_finished)
|
||||
command = ["cli", "--get-user-conf"] if self.user_conf else [
|
||||
@@ -590,7 +598,7 @@ class ExeSettingsDialog(
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
"""Handle --show-ppdb output."""
|
||||
"""Populate shared settings controls from the selected game's data."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.current_settings = {}
|
||||
self.blocked_keys = set()
|
||||
@@ -605,7 +613,15 @@ class ExeSettingsDialog(
|
||||
self.reject()
|
||||
return
|
||||
|
||||
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
self.current_settings = load_native_settings(cast(str, self.exe_path))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot load native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
self.reject()
|
||||
return
|
||||
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
|
||||
for line in output.splitlines():
|
||||
line_stripped = line.strip()
|
||||
@@ -707,6 +723,16 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
if not os.path.exists(db_path):
|
||||
save_native_settings(self.exe_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.critical(self, _("Error"), _("PPDB file does not exist at: ") + db_path)
|
||||
@@ -730,6 +756,14 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.information(self, _("Information"), _("PPDB file does not exist at: ") + db_path)
|
||||
self.load_current_settings()
|
||||
@@ -890,6 +924,14 @@ class ExeSettingsDialog(
|
||||
dist_options=self.dist_options,
|
||||
prefix_options=self.prefix_options
|
||||
)
|
||||
if self.game_source == "native":
|
||||
advanced_settings = [setting for setting in advanced_settings if setting['key'] in NATIVE_ADVANCED_KEYS]
|
||||
for setting in advanced_settings:
|
||||
if setting["key"] == "PW_WINE_CPU_TOPOLOGY":
|
||||
setting["options"] = [disabled_text] + [str(count) for count in range(1, len(os.sched_getaffinity(0)) + 1)]
|
||||
for setting in advanced_settings:
|
||||
if setting['key'] == 'LAUNCH_PARAMETERS':
|
||||
setting['description'] = _("Launch Arguments")
|
||||
if self.user_conf:
|
||||
for setting in advanced_settings:
|
||||
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
|
||||
@@ -1672,6 +1714,17 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
values = dict(change.split('=', 1) for change in changes)
|
||||
save_native_settings(cast(str, self.exe_path), values)
|
||||
except Exception as error:
|
||||
logger.warning("Cannot save native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
self.saving = True
|
||||
self.clear_ppdb_button.setEnabled(False)
|
||||
self.saved_current_settings = self.current_settings.copy()
|
||||
|
||||
@@ -402,6 +402,7 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.native_visible = str(game_source).lower() == "native"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
@@ -515,6 +516,13 @@ class GameCard(AnimatedCard):
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
self.nativeLabel = SourceCorner(
|
||||
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
icon=portproton_icon,
|
||||
@@ -661,6 +669,7 @@ class GameCard(AnimatedCard):
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.nativeLabel, "badge_linux"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
@@ -882,6 +891,7 @@ class GameCard(AnimatedCard):
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.egs_visible, self.egsLabel),
|
||||
(self.native_visible, self.nativeLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
@@ -905,6 +915,7 @@ class GameCard(AnimatedCard):
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.nativeLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1046,6 +1057,7 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.native_visible = str(self.game_source).lower() == "native"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
@@ -1056,6 +1068,7 @@ class GameCard(AnimatedCard):
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.nativeLabel.setVisible(self.native_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Unified icon extractor for NE and PE files."""
|
||||
"""Unified icon extractor for NE, PE and type-2 AppImages."""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
import posixpath
|
||||
import glob
|
||||
import pefile
|
||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
|
||||
from PySide6.QtGui import QImage, QImageWriter
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
@@ -16,6 +23,82 @@ logger = get_logger(__name__)
|
||||
RT_ICON = 3
|
||||
RT_GROUP_ICON = 14
|
||||
THUMBNAIL_SIZE = 128
|
||||
APPIMAGE_EXTRACT_TIMEOUT = 10
|
||||
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
|
||||
|
||||
|
||||
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
|
||||
"""Read an icon and resolve archive-local links without extracting to disk."""
|
||||
filename = ".DirIcon"
|
||||
seen: set[str] = set()
|
||||
while filename not in seen:
|
||||
seen.add(filename)
|
||||
result = subprocess.run(
|
||||
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
|
||||
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
|
||||
"--skip-devices", "--skip-specials", "--log-level", "error"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
|
||||
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
|
||||
if member is None:
|
||||
return b""
|
||||
if member.issym() or member.islnk():
|
||||
target = member.linkname
|
||||
base = posixpath.dirname(filename) if member.issym() else ""
|
||||
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
|
||||
else posixpath.join(base, target))
|
||||
if filename == ".." or filename.startswith("../"):
|
||||
return b""
|
||||
continue
|
||||
if not member.isfile():
|
||||
return b""
|
||||
stream = archive.extractfile(member)
|
||||
return stream.read() if stream is not None else b""
|
||||
return b""
|
||||
|
||||
|
||||
def _get_appimage_icon(path: str) -> io.BytesIO | None:
|
||||
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
|
||||
with open(path, "rb") as source:
|
||||
header = source.read(64)
|
||||
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
|
||||
return None
|
||||
layout = ELF_SECTION_LAYOUTS.get(header[4])
|
||||
if layout is None or header[5] not in (1, 2):
|
||||
return None
|
||||
endian = "<" if header[5] == 1 else ">"
|
||||
section_offset, offset_format, size_offset = layout
|
||||
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
|
||||
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
|
||||
offset += entry_size * entry_count
|
||||
source.seek(offset)
|
||||
magic = source.read(6)
|
||||
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
|
||||
return None
|
||||
try:
|
||||
if magic == b"DWARFS":
|
||||
data = _read_dwarfs_icon(path, offset)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
data = result.stdout
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
image = QImage.fromData(data)
|
||||
if image.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
|
||||
return None
|
||||
return io.BytesIO(buffer.data().data())
|
||||
|
||||
|
||||
def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
@@ -54,6 +137,8 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
if exe_path.lower().endswith(".appimage"):
|
||||
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
"images",
|
||||
@@ -64,7 +149,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
|
||||
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
|
||||
"""
|
||||
Generates a thumbnail for an .exe file.
|
||||
Generates a thumbnail for a Windows executable or type-2 AppImage.
|
||||
|
||||
inputfile: the input file path
|
||||
outfile: output filename
|
||||
@@ -117,6 +202,8 @@ class IconExtractor:
|
||||
"""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
if f.read(4) == b"\x7fELF":
|
||||
return _get_appimage_icon(self.file_path)
|
||||
if not self._parse_file(f):
|
||||
return None
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"PO-Revision-Date: 2026-10-17 23:27+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -32,8 +32,6 @@ msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
|
||||
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
@@ -151,8 +149,9 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar vários"
|
||||
msgstr "Selecionar Tudo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
@@ -482,13 +481,9 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
|
||||
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
|
||||
"caracteres cirílicos:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Não lembrar novamente"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
@@ -969,19 +964,19 @@ msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Verificado: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Verificando os atalhos existentes: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Adicionando: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar atalho: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
@@ -998,8 +993,9 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Ícone personalizado:"
|
||||
msgstr "Capa Personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
@@ -1013,28 +1009,29 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Selecionar pasta"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Processando jogos…"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Processados: {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "Nenhum jogo encontrado na pasta selecionada."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Selecionar jogos"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
@@ -2404,7 +2401,7 @@ msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Desativar aviso de caminho em cirílico"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
@@ -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-26 12:27+0000\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -996,8 +996,9 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательский значок:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Chinese (Simplified Han script) <https://"
|
||||
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid ""
|
||||
@@ -29,7 +29,7 @@ msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
@@ -143,8 +143,9 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "多选"
|
||||
msgstr "全选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
@@ -155,8 +156,9 @@ msgstr "停止游戏"
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "修复"
|
||||
msgstr "已配对"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "从桌面移除"
|
||||
@@ -174,7 +176,7 @@ msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Import"
|
||||
msgstr "导入"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
@@ -258,9 +260,9 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "未找到可执行文件"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
@@ -454,14 +456,14 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "另一个 Epic 操作正在运行中"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
@@ -473,12 +475,9 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
|
||||
"PortProton 移动到不包含西里尔字母字符的路径:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "不再提示"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
@@ -931,19 +930,19 @@ msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "正在扫描:{folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "正在检查现有快捷方式:{path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "正在添加:{name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建快捷方式失败:{name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
@@ -980,22 +979,21 @@ msgstr "图像无效"
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "正在处理游戏…"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"已处理:{completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "在所选文件夹中未找到任何游戏。"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
@@ -1015,10 +1013,10 @@ msgid "No cover selected"
|
||||
msgstr "未选择封面"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "兼容性报告"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr "程序已停止。已自动收集兼容性详细信息。"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
@@ -1244,7 +1242,7 @@ msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "编辑 user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
@@ -1270,8 +1268,9 @@ msgstr "编辑 PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载全局游戏设置失败"
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
@@ -1282,8 +1281,9 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开文件失败:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
@@ -1303,8 +1303,9 @@ msgstr "默认"
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "下载 Wine/Proton…"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
@@ -2103,11 +2104,13 @@ msgstr "全部清除"
|
||||
msgid "Now Downloading"
|
||||
msgstr "正在下载"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "请稍候…"
|
||||
msgstr "警告"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "磁盘: "
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
@@ -2121,10 +2124,10 @@ msgid "Store"
|
||||
msgstr "存储"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr "将链接复制到剪贴板"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr "退出 GOG 账号失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
@@ -2146,27 +2149,27 @@ msgid "Log out"
|
||||
msgstr "注销"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "打开登录页面"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr "未连接 GOG 账号"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "退出 Epic 账号失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "将授权码复制到剪贴板"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Epic 授权中…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
@@ -2180,33 +2183,33 @@ msgstr "已连接"
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "选择安装文件夹"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "另一个 GOG 操作正在运行中"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr "未找到 GOG 安装"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "所选文件夹不是此 GOG 游戏"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
@@ -2218,11 +2221,11 @@ msgstr "正在下载… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr "磁盘:{0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr "ETA: {0}"
|
||||
msgstr ",预计剩余时间:{}秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
@@ -2276,7 +2279,7 @@ msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "网格"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
@@ -2286,17 +2289,21 @@ msgstr "浅色"
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平块"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平卡片"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平无限块"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平无限卡片"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Layout"
|
||||
@@ -2324,7 +2331,7 @@ msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "账号"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
@@ -2352,7 +2359,7 @@ msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "连接手柄时自动全屏"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "禁用开机动画"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "关闭时最小化到系统托盘"
|
||||
@@ -2367,22 +2374,22 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "隐藏操作提示"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "强制使用英语"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "游戏崩溃后显示兼容性报告"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "禁用西里尔字母路径警告"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "禁用 ALT Linux i586 依赖项检查"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
@@ -2391,7 +2398,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "禁用 Steam Proton 扫描"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
@@ -2480,7 +2487,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr "AppImage 已集成于:{path}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
@@ -2628,12 +2635,13 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "删除主题“{0}”?"
|
||||
msgstr "选择主题:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "删除主题失败。"
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
+136
-11
@@ -29,6 +29,7 @@ from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
|
||||
from portprotonqt.debug_utils import get_prefix_name
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
@@ -92,6 +93,7 @@ from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
GAME_PROCESS_CHECK_INTERVAL_MS = 500
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
@@ -1036,7 +1038,7 @@ class MainWindow(
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
# Load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def check_completion():
|
||||
@@ -1066,13 +1068,19 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
self._load_native_games_async(native_callback)
|
||||
else:
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
@@ -1119,9 +1127,14 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback,
|
||||
"gog": gog_callback, "egs": egs_callback, "native": native_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
@@ -1130,6 +1143,90 @@ class MainWindow(
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
|
||||
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
self._native_games_callback = callback
|
||||
self.native_library_worker = NativeLibraryWorker()
|
||||
self.native_library_worker.finished.connect(self._on_native_games_loaded)
|
||||
self.native_library_worker.start()
|
||||
|
||||
def open_native_settings(self, exec_line: str) -> None:
|
||||
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
try:
|
||||
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
|
||||
except OSError as error:
|
||||
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
dialog.exec()
|
||||
dialog.deleteLater()
|
||||
|
||||
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
|
||||
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
if self.game_processes:
|
||||
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
|
||||
self.stop_running_game(button)
|
||||
else:
|
||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||
return
|
||||
try:
|
||||
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
|
||||
raise ValueError("Invalid native desktop path")
|
||||
command, directory = get_native_launch(str(desktop_path))
|
||||
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
|
||||
return
|
||||
self.native_game_running = True
|
||||
self.game_stopped_by_user = False
|
||||
self.game_processes = [process]
|
||||
self.current_running_button = button
|
||||
self.target_exe = desktop_path.name
|
||||
self.game_start_exe = str(desktop_path)
|
||||
self.game_start_exact_path = True
|
||||
self.game_start_time = datetime.now()
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_launch_started = True
|
||||
self.wine_download_seen = False
|
||||
save_last_launch(desktop_path.stem, self.game_start_time)
|
||||
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
|
||||
self.launch_output_queue = Queue()
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
SoundManager().play("game_launch")
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
|
||||
|
||||
@Slot()
|
||||
def _on_native_games_loaded(self) -> None:
|
||||
worker = self.native_library_worker
|
||||
if worker is None:
|
||||
return
|
||||
self.native_library_worker = None
|
||||
games = worker.games
|
||||
worker.deleteLater()
|
||||
if not games:
|
||||
self._native_games_callback([])
|
||||
return
|
||||
completed = []
|
||||
|
||||
def on_info(info: dict, game: tuple) -> None:
|
||||
updated = list(game)
|
||||
updated[1] = info.get("description", "") or game[1]
|
||||
updated[2] = game[2] or info.get("cover", "")
|
||||
updated[3] = info.get("appid", "")
|
||||
updated[4] = info.get("controller_support", "")
|
||||
completed.append(tuple(updated))
|
||||
if len(completed) == len(games):
|
||||
self._native_games_callback(completed)
|
||||
|
||||
for game in games:
|
||||
if ui_config.get_economy_mode():
|
||||
on_info(get_cached_steam_game_info(game[0], game[5]), game)
|
||||
else:
|
||||
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
|
||||
|
||||
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
api = getattr(self, "gog_api", None) or GOGAPI()
|
||||
installed = api.load_installed()
|
||||
@@ -1463,7 +1560,7 @@ class MainWindow(
|
||||
callback: Callable[[tuple | None], None],
|
||||
):
|
||||
entry = parse_desktop_entry(file_path)
|
||||
if not entry:
|
||||
if not entry or read_native_game(Path(file_path)) is not None:
|
||||
callback(None)
|
||||
return
|
||||
desktop_name = entry.get("Name", _("Unknown Application"))
|
||||
@@ -1805,11 +1902,13 @@ class MainWindow(
|
||||
self.detail_page_manager.openGameDetailPage(game_data)
|
||||
pending_log_exe = self._pending_log_exe
|
||||
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
detail_exe = exec_line
|
||||
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
|
||||
if pending_log_exe and detail_exe:
|
||||
if os.path.abspath(detail_exe) == pending_log_exe:
|
||||
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_exe:
|
||||
self._pending_log_exe = None
|
||||
log_button = self.detail_page_manager._debug_log_button
|
||||
if log_button:
|
||||
@@ -2126,7 +2225,7 @@ class MainWindow(
|
||||
|
||||
|
||||
def checkTargetExe(self):
|
||||
"""Update launch state from the marker and PortProton process."""
|
||||
"""Update launch state and reset controls after the game exits."""
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
child_running = self._has_running_game_process()
|
||||
|
||||
@@ -2251,6 +2350,7 @@ class MainWindow(
|
||||
self.game_processes = [] if launcher_only else [
|
||||
proc for proc in self.game_processes if proc.poll() is None
|
||||
]
|
||||
self.native_game_running = False
|
||||
if not getattr(self, "_animated_covers_suspended", False):
|
||||
self.input_manager.resume_gamepad_polling()
|
||||
self._refresh_portproton_shortcuts()
|
||||
@@ -2345,6 +2445,8 @@ class MainWindow(
|
||||
|
||||
def _get_game_executable(self, exec_line: str) -> str:
|
||||
"""Resolve a library launch entry to its tracked executable."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
return exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
if exec_line.startswith("gog://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.gog_api.get_launch_target(app_id) or "")
|
||||
@@ -2354,6 +2456,19 @@ class MainWindow(
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
if getattr(self, "native_game_running", False):
|
||||
if any(process.poll() is None for process in self.game_processes):
|
||||
return True
|
||||
groups = {process.pid for process in self.game_processes}
|
||||
for process in psutil.process_iter(attrs=["pid", "status"]):
|
||||
try:
|
||||
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except PermissionError:
|
||||
continue
|
||||
return False
|
||||
if self._observe_game_processes():
|
||||
return True
|
||||
process_alive = any(proc.poll() is None for proc in self.game_processes)
|
||||
@@ -2475,6 +2590,8 @@ class MainWindow(
|
||||
self._start_launch_output_reader(game_process)
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if getattr(self, "native_game_running", False):
|
||||
return
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
logger.info("Compatibility report skipped: crash reports disabled")
|
||||
return
|
||||
@@ -2546,11 +2663,12 @@ class MainWindow(
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
for proc in self.game_processes:
|
||||
if proc.poll() is not None:
|
||||
if proc.poll() is not None and not getattr(self, "native_game_running", False):
|
||||
continue
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.kill()
|
||||
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if not getattr(self, "native_game_running", False):
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as e:
|
||||
@@ -2569,11 +2687,14 @@ class MainWindow(
|
||||
return True
|
||||
|
||||
def stop_running_game(self, button=None) -> bool:
|
||||
"""Stop current game via PortProton CLI stop command."""
|
||||
"""Stop the current game's owned processes and launcher."""
|
||||
if button is not None:
|
||||
self.current_running_button = button
|
||||
self.game_stopped_by_user = True
|
||||
self.egs_launch_cancelled = True
|
||||
if getattr(self, "native_game_running", False):
|
||||
self._terminate_game_processes()
|
||||
return True
|
||||
if getattr(self, "launcher_process_only", False):
|
||||
if self._run_portproton_stop_command():
|
||||
return True
|
||||
@@ -3081,6 +3202,10 @@ class MainWindow(
|
||||
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
def toggleGame(self, exec_line, button=None, game_name=None):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
self._launch_native_game(exec_line, button)
|
||||
return
|
||||
|
||||
# Handle Steam games
|
||||
if exec_line.startswith("steam://"):
|
||||
self._launch_steam_game(exec_line)
|
||||
|
||||
@@ -22,6 +22,7 @@ install_data(
|
||||
'localization.py',
|
||||
'logger.py',
|
||||
'main_window.py',
|
||||
'native_games.py',
|
||||
'port_data_path_selector.py',
|
||||
'portproton_api.py',
|
||||
'preloader.py',
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Discover installed native games from desktop application entries."""
|
||||
|
||||
import configparser
|
||||
import platform
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
from portprotonqt.time_utils import (
|
||||
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
|
||||
format_last_launch, format_playtime,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
NATIVE_URI_PREFIX = "native://"
|
||||
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
|
||||
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
|
||||
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
|
||||
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
|
||||
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
|
||||
|
||||
|
||||
def is_native_executable(path: str) -> bool:
|
||||
"""Recognize native launch files while preserving Windows and disc formats."""
|
||||
return (
|
||||
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
|
||||
and os.path.isfile(path)
|
||||
and os.access(path, os.X_OK)
|
||||
)
|
||||
|
||||
|
||||
def quote_native_arguments(arguments: list[str]) -> str:
|
||||
"""Quote arguments using desktop entry escaping, without a shell."""
|
||||
escaped = []
|
||||
for argument in arguments:
|
||||
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
|
||||
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
|
||||
return " ".join(escaped)
|
||||
|
||||
|
||||
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
|
||||
"""Use the installed application launcher for native desktop shortcuts."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID")
|
||||
appimage = os.getenv("APPIMAGE", "").strip()
|
||||
launcher = ["portprotonqt"]
|
||||
if flatpak_id:
|
||||
launcher = ["flatpak", "run", flatpak_id]
|
||||
elif appimage and os.path.isfile(appimage):
|
||||
launcher = [appimage]
|
||||
if mode:
|
||||
launcher.append(mode)
|
||||
return quote_native_arguments(launcher) + " " + command
|
||||
|
||||
|
||||
def get_native_source_command(command: str) -> str:
|
||||
"""Unwrap a PortProtonQt desktop command before applying native settings."""
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
|
||||
target = extract_exec_target_path(parts)
|
||||
if target:
|
||||
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
|
||||
return quote_native_arguments([target])
|
||||
return command
|
||||
|
||||
|
||||
def get_native_tool_paths() -> dict[str, str]:
|
||||
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
|
||||
scripts = get_portproton_scripts_path()
|
||||
location = get_portproton_location()
|
||||
if not scripts or not location:
|
||||
return {}
|
||||
values = {}
|
||||
try:
|
||||
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.removeprefix("export ").partition("=")
|
||||
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
|
||||
values[key] = value.strip().strip('"\'')
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native runtime versions: %s", error)
|
||||
return {}
|
||||
root = Path(location) / "data/tmp"
|
||||
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
|
||||
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
|
||||
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
|
||||
|
||||
|
||||
def read_native_variables(path: Path) -> dict[str, str]:
|
||||
"""Read literal var/PPDB assignments without executing shell commands."""
|
||||
values = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip().startswith(("export ", "check_variables ")):
|
||||
continue
|
||||
parts = shlex.split(line, comments=True)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0] == "check_variables" and len(parts) == 3:
|
||||
values[parts[1]] = parts[2]
|
||||
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
|
||||
key, value = parts[1].split("=", 1)
|
||||
values[key] = value.replace("\\$", "$").replace("\\`", "`")
|
||||
return values
|
||||
|
||||
|
||||
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
|
||||
"""Keep native settings and logs in a named per-game user data directory."""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
|
||||
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
|
||||
|
||||
|
||||
def get_native_defaults() -> dict[str, str]:
|
||||
"""Read supported Linux defaults from the existing PortProton var file."""
|
||||
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
|
||||
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
|
||||
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
|
||||
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
|
||||
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
|
||||
scripts = get_portproton_scripts_path()
|
||||
if scripts and (Path(scripts) / "var").is_file():
|
||||
defaults = read_native_variables(Path(scripts) / "var")
|
||||
values.update({key: value for key, value in defaults.items()
|
||||
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
|
||||
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
|
||||
if item not in {"wine", "winesync"})
|
||||
return values
|
||||
|
||||
|
||||
def load_native_settings(desktop_path: str) -> dict[str, str]:
|
||||
"""Load var defaults, existing desktop settings, and the game PPDB."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
settings = get_native_defaults()
|
||||
if parser.has_section(NATIVE_SETTINGS_SECTION):
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
|
||||
database = Path(desktop_path + ".ppdb")
|
||||
if database.is_file():
|
||||
content = database.read_text(encoding="utf-8")
|
||||
if content.lstrip().startswith("["):
|
||||
parser.read_string(content)
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
else:
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
|
||||
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
|
||||
executable = next((part.replace("%%", "%") for part in reversed(parts)
|
||||
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
|
||||
if executable is None and parts:
|
||||
executable = shutil.which(parts[0])
|
||||
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
|
||||
for legacy in legacy_paths:
|
||||
if legacy.is_file():
|
||||
settings.update(read_native_variables(legacy))
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
if database.is_file():
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
|
||||
return settings
|
||||
|
||||
|
||||
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
|
||||
paths = get_native_tool_paths()
|
||||
runtime = Path(paths.get("runtime", ""))
|
||||
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
|
||||
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
|
||||
raise ValueError("PortProton container runtime is not installed")
|
||||
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
|
||||
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
|
||||
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
|
||||
if platform.freedesktop_os_release().get("ID") == "altlinux":
|
||||
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
|
||||
if path.is_dir()]
|
||||
if drivers:
|
||||
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
|
||||
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
|
||||
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
|
||||
portable = Path(paths.get("plugins", "")) / "portable"
|
||||
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
|
||||
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
|
||||
layers = portable / "share/vulkan/implicit_layer.d"
|
||||
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
|
||||
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
|
||||
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
|
||||
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
|
||||
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
|
||||
raise ValueError("PortProton portable MangoHud is not installed")
|
||||
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
|
||||
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
|
||||
return command, environment
|
||||
|
||||
|
||||
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
|
||||
"""Compose Linux launch wrappers and environment for a desktop entry."""
|
||||
prefix = []
|
||||
environment = []
|
||||
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
|
||||
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
|
||||
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
|
||||
if settings.get(key) == "1":
|
||||
environment.append(variable)
|
||||
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
|
||||
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
|
||||
if settings.get(key) not in (None, "", "disabled"):
|
||||
environment.append(f"{variable}={settings[key]}")
|
||||
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
|
||||
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
|
||||
if settings.get("PW_GAMESCOPE") == "1":
|
||||
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
|
||||
if settings.get("PW_USE_RUNTIME") == "1":
|
||||
runtime_command, runtime_env = build_native_runtime(settings)
|
||||
prefix += runtime_command
|
||||
environment += runtime_env
|
||||
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
|
||||
if cores not in ("", "disabled"):
|
||||
available = sorted(os.sched_getaffinity(0))
|
||||
count = int(cores)
|
||||
if not 1 <= count <= len(available):
|
||||
raise ValueError("Invalid CPU cores limit")
|
||||
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
|
||||
if settings.get("PW_USE_GAMEMODE") == "1":
|
||||
prefix.append("gamemoderun")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if settings.get("PW_USE_RUNTIME") != "1":
|
||||
prefix += ["mangohud", "--dlsym"]
|
||||
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
|
||||
config = settings.get("MANGOHUD_CONFIG", "")
|
||||
if settings.get("FPS_LIMIT"):
|
||||
config += f",fps_limit={settings['FPS_LIMIT']}"
|
||||
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
|
||||
if settings.get("PW_VKBASALT") == "1":
|
||||
environment.append("ENABLE_VKBASALT=1")
|
||||
if settings.get("VKBASALT_CONFIG_FILE"):
|
||||
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
|
||||
if environment:
|
||||
prefix = ["env", *environment, *prefix]
|
||||
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
|
||||
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
|
||||
|
||||
|
||||
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
|
||||
"""Keep native vkBasalt configuration isolated from global settings."""
|
||||
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
|
||||
settings.pop("VKBASALT_CONFIG_FILE", None)
|
||||
return
|
||||
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
|
||||
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
|
||||
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
|
||||
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
|
||||
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
|
||||
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
|
||||
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
|
||||
if shader and shader != "cas":
|
||||
if Path(shader).name != shader:
|
||||
raise ValueError("Invalid vkBasalt shader name")
|
||||
shader_path = plugins / "shaders" / (shader + ".fx")
|
||||
if not shader_path.is_file():
|
||||
raise ValueError(f"vkBasalt shader is not installed: {shader}")
|
||||
lines.append(f"{shader} = {shader_path}")
|
||||
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
settings["VKBASALT_CONFIG_FILE"] = str(config)
|
||||
|
||||
|
||||
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
|
||||
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
|
||||
settings = load_native_settings(desktop_path)
|
||||
settings.update(changes)
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
command, directory = settings["Exec"], settings.get("Path", "")
|
||||
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
|
||||
raise ValueError("Invalid native launch command or directory")
|
||||
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
|
||||
raise ValueError("Working directory must be an existing absolute path")
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_native_vkbasalt_config(desktop_path, settings)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
for key, value in sorted(settings.items()):
|
||||
if key in {"Name", "Icon", "Exec", "Path"}:
|
||||
continue
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
|
||||
output.write(f'export {key}="{escaped}"\n')
|
||||
os.replace(temporary, database)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
|
||||
"""Build a native launcher through PortProtonQt with the game's directory."""
|
||||
path = Path(executable).expanduser().absolute()
|
||||
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
|
||||
logger.warning("Not an executable native game: %s", path)
|
||||
return None
|
||||
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
|
||||
logger.warning("Invalid native game name or path")
|
||||
return None
|
||||
name = " ".join(name.splitlines()).strip()
|
||||
# Desktop Exec uses double quoting and doubles literal percent signs.
|
||||
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
|
||||
directory = str(path.parent).replace("\\", "\\\\")
|
||||
icon = get_custom_icon_path(str(path)) or icon
|
||||
if not icon and path.suffix.lower() == ".appimage":
|
||||
icon = get_exe_icon_cache_path(str(path))
|
||||
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
|
||||
comment = _('Launch "{name}" with PortProton').format(name=name)
|
||||
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
|
||||
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
|
||||
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
|
||||
("RunLog", _("Run in logging mode"), "--log")):
|
||||
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
|
||||
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
logger.warning("PortProton data directory is unavailable")
|
||||
return None
|
||||
filename = name.replace("/", "_") + ".desktop"
|
||||
destination = Path(location) / filename
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot create native library directory: %s", error)
|
||||
return None
|
||||
return content, str(destination)
|
||||
|
||||
|
||||
def read_native_game(path: Path) -> tuple | None:
|
||||
"""Read a game entry without modifying the installed desktop file."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read_string(path.read_text(encoding="utf-8"))
|
||||
if not parser.has_section("Desktop Entry"):
|
||||
return None
|
||||
entry = parser["Desktop Entry"]
|
||||
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
|
||||
return None
|
||||
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
|
||||
return None
|
||||
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
|
||||
return None
|
||||
name, command = entry.get("Name", ""), entry.get("Exec", "")
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if not name or not parts:
|
||||
return None
|
||||
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
|
||||
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
|
||||
return None
|
||||
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
|
||||
return None
|
||||
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
|
||||
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
root = str(data_home / "PortProtonQt/custom_data")
|
||||
custom = Path(resolve_custom_data_dir(root, str(path)))
|
||||
if not custom.is_dir() and executable:
|
||||
custom = Path(resolve_custom_data_dir(root, executable))
|
||||
description = entry.get("Comment", "")
|
||||
metadata = custom / "metadata.txt"
|
||||
if metadata.is_file():
|
||||
translations = read_metadata_translations(str(metadata), get_metadata_language())
|
||||
name = translations.get("name") or name
|
||||
description = translations.get("description") or description
|
||||
cover = next((str(path) for path in custom.glob("cover.*") if path.is_file()), "")
|
||||
if not cover:
|
||||
from portprotonqt.steam_api.api import get_cached_sgdb_cover
|
||||
|
||||
cover = get_cached_sgdb_cover(name)
|
||||
if not cover and executable.lower().endswith(".appimage"):
|
||||
cached_icon = get_exe_icon_cache_path(executable)
|
||||
if os.path.isfile(cached_icon):
|
||||
cover = cached_icon
|
||||
return (name, description, cover,
|
||||
"", "", NATIVE_URI_PREFIX + str(path), _("Never"), "", "", "", 0, 0,
|
||||
"native", "", "", "")
|
||||
except configparser.Error as error:
|
||||
logger.warning("Invalid native desktop entry %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native desktop entry %s: %s", path, error)
|
||||
except UnicodeError as error:
|
||||
logger.warning("Invalid native desktop encoding %s: %s", path, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native desktop command %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def get_native_launch(desktop_path: str) -> tuple[list[str], str]:
|
||||
"""Resolve desktop field codes into a direct Linux process command."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
entry = parser["Desktop Entry"]
|
||||
settings = load_native_settings(desktop_path)
|
||||
entry["Exec"] = settings["Exec"]
|
||||
if get_native_ppdb_path(desktop_path, settings).is_file():
|
||||
entry["Exec"] = build_native_launch_command(settings["Exec"], settings)
|
||||
entry["Path"] = settings.get("Path", "").replace("\\", "\\\\")
|
||||
escapes = {"\\": "\\", "s": " ", "n": "\n", "t": "\t", "r": "\r"}
|
||||
command = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Exec", ""))
|
||||
arguments = []
|
||||
for part in shlex.split(command):
|
||||
part = part.replace("\\$", "$").replace("\\`", "`")
|
||||
if part == "%i":
|
||||
if entry.get("Icon"):
|
||||
arguments.extend(["--icon", entry["Icon"]])
|
||||
continue
|
||||
value = re.sub(r"%%|%[fFuUdDnNvmick]", lambda match:
|
||||
{"%%": "%", "%c": entry.get("Name", ""), "%k": desktop_path}.get(match[0], ""), part)
|
||||
if value:
|
||||
arguments.append(value)
|
||||
if not arguments:
|
||||
raise ValueError("Empty native launch command")
|
||||
directory = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Path", ""))
|
||||
if directory and not os.path.isdir(directory):
|
||||
raise ValueError(f"Working directory does not exist: {directory}")
|
||||
executable = next((part for part in reversed(arguments)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), arguments[0])
|
||||
fallback = Path(executable).parent if os.path.isabs(executable) else Path.home()
|
||||
return arguments, directory or str(fallback)
|
||||
|
||||
|
||||
def load_native_game_times(games: list[tuple]) -> list[tuple]:
|
||||
"""Read shared statistics once for the native library batch."""
|
||||
statistics, launches = {}, {}
|
||||
try:
|
||||
path = get_statistics_path()
|
||||
if os.path.isfile(path):
|
||||
statistics = parse_playtime_file(path)
|
||||
path = get_last_launch_path()
|
||||
if os.path.isfile(path):
|
||||
launches = dict(parsed for line in Path(path).read_text(encoding="utf-8").splitlines()
|
||||
if (parsed := _parse_last_launch_line(line)))
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native game statistics: %s", error)
|
||||
updated_games = []
|
||||
for game in games:
|
||||
path = Path(game[5].removeprefix(NATIVE_URI_PREFIX))
|
||||
updated = list(game)
|
||||
updated[11] = statistics.get(str(path).replace(" ", "#@_@#"), 0)
|
||||
updated[7] = format_playtime(updated[11])
|
||||
if path.stem in launches:
|
||||
try:
|
||||
launched = datetime.fromisoformat(launches[path.stem])
|
||||
updated[6], updated[10] = format_last_launch(launched), launched.timestamp()
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native last launch time for %s: %s", path, error)
|
||||
updated_games.append(tuple(updated))
|
||||
return updated_games
|
||||
|
||||
|
||||
class NativeLibraryWorker(QThread):
|
||||
"""Scan application directories outside the UI thread."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.games: list[tuple] = []
|
||||
|
||||
def run(self) -> None:
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(os.pathsep)
|
||||
roots = [data_home, *(Path(directory) for directory in data_dirs if directory),
|
||||
data_home / "flatpak/exports/share", Path("/var/lib/flatpak/exports/share")]
|
||||
directories = [root / "applications" for root in roots]
|
||||
directories.insert(0, data_home / "PortProtonQt/native_games")
|
||||
location = get_portproton_location()
|
||||
if location:
|
||||
directories.insert(0, Path(location))
|
||||
seen: set[str] = set()
|
||||
commands: set[tuple[str, str, str]] = set()
|
||||
for applications in directories:
|
||||
try:
|
||||
for path in sorted(applications.glob("*.desktop") if location and applications == Path(location)
|
||||
else applications.rglob("*.desktop")):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
desktop_id = str(path.relative_to(applications)).replace(os.sep, "-")
|
||||
if desktop_id in seen:
|
||||
continue
|
||||
seen.add(desktop_id)
|
||||
game = read_native_game(path)
|
||||
if not game:
|
||||
continue
|
||||
entry = parse_desktop_entry(str(path)) or {}
|
||||
command = get_native_source_command(entry.get("Exec", ""))
|
||||
identity = (command, entry.get("Path", ""), entry.get("Name", ""))
|
||||
if identity in commands:
|
||||
continue
|
||||
commands.add(identity)
|
||||
self.games.append(game)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot scan native games in %s: %s", applications, error)
|
||||
self.games = load_native_game_times(self.games)
|
||||
@@ -575,6 +575,23 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
|
||||
)
|
||||
|
||||
|
||||
def get_cached_sgdb_cover(game_name: str) -> str:
|
||||
"""Read a cached SGDB grid URL without creating a download worker."""
|
||||
cache = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
key = f"sgdb_{safe_name}_grids"
|
||||
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
|
||||
return ""
|
||||
cover = cache.load_json(key)
|
||||
if not isinstance(cover, str):
|
||||
return ""
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
|
||||
return cover
|
||||
|
||||
|
||||
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
"""Return Steam metadata from local cache only."""
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
@@ -582,7 +599,8 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
game_exe = extract_exec_target_path(exec_line) or exec_line
|
||||
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
|
||||
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
|
||||
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
|
||||
candidates = remove_duplicates(filter_candidates(names))
|
||||
apps_path = CacheManager().cache_dir / "steam_apps.json"
|
||||
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
|
||||
if not steam_apps:
|
||||
@@ -660,6 +678,7 @@ def get_steam_game_info_async(
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
is_native = exec_line.startswith("native://")
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -681,7 +700,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if cached_match is not None or is_store_uri:
|
||||
if cached_match is not None or is_store_uri or is_native:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -705,9 +724,9 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not is_store_uri:
|
||||
if exe_name and not (is_store_uri or is_native):
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not is_store_uri:
|
||||
if folder_name and not (is_store_uri or is_native):
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
@@ -724,6 +743,9 @@ def get_steam_game_info_async(
|
||||
|
||||
if matching_app is None and (not steam_apps or not steam_apps_index):
|
||||
game_name = desktop_name or exe_name
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -762,6 +784,9 @@ def get_steam_game_info_async(
|
||||
game_name = desktop_name or exe_name.capitalize()
|
||||
|
||||
if not matching_app:
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
def on_sgdb_cover_non_steam(cover: str) -> None:
|
||||
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -790,6 +815,22 @@ def get_steam_game_info_async(
|
||||
cache_manager.save_json(match_key, matching_app)
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if is_native:
|
||||
info = app_info or {}
|
||||
result = {"appid": appid, "name": info.get("name", game_name),
|
||||
"description": info.get("short_description", ""),
|
||||
"controller_support": info.get("controller_support", "")}
|
||||
|
||||
def on_native_cover(cover: str) -> None:
|
||||
result["cover"] = cover
|
||||
callback(result)
|
||||
|
||||
local_cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if local_cover:
|
||||
on_native_cover(local_cover)
|
||||
else:
|
||||
fetch_sgdb_cover_async(result["name"], on_native_cover)
|
||||
return
|
||||
if not app_info:
|
||||
cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if not cover and not has_custom_data:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Steam shortcuts management for PortProtonQt."""
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
@@ -16,7 +17,10 @@ import requests
|
||||
import vdf
|
||||
import websocket
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from PySide6.QtGui import QIcon, QImageReader
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -171,7 +175,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
|
||||
"""Parse exec_line to extract executable path."""
|
||||
try:
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
logger.error("Failed to parse exec_line: %s", exec_line)
|
||||
return None, "Failed to parse executable command: no target path"
|
||||
@@ -224,6 +228,33 @@ export LD_PRELOAD=
|
||||
return script_path, ""
|
||||
|
||||
|
||||
def _resolve_desktop_icon(source: str) -> QIcon:
|
||||
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
|
||||
if not source:
|
||||
return QIcon()
|
||||
if os.path.isfile(source):
|
||||
return QIcon(source)
|
||||
icon = QIcon.fromTheme(source)
|
||||
if not icon.isNull() or os.path.basename(source) != source:
|
||||
return icon
|
||||
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
|
||||
filename = glob.escape(source)
|
||||
if Path(source).suffix.lstrip(".").lower() not in formats:
|
||||
filename += ".*"
|
||||
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
|
||||
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
|
||||
f"icons/{filename}", f"pixmaps/{filename}")
|
||||
for root in roots:
|
||||
for pattern in patterns:
|
||||
for path in sorted(Path(root).glob(pattern)):
|
||||
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
|
||||
continue
|
||||
icon = QIcon(str(path))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
"""Generate thumbnail icon for the game."""
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
|
||||
@@ -236,7 +267,13 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
if os.path.exists(generated_icon_path):
|
||||
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if exe_path.endswith(".desktop"):
|
||||
entry = parse_desktop_entry(exe_path) or {}
|
||||
source = entry.get("Icon", "")
|
||||
icon = _resolve_desktop_icon(source)
|
||||
success = icon.pixmap(128, 128).save(generated_icon_path)
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"Failed to generate thumbnail for {exe_path}")
|
||||
return ""
|
||||
@@ -424,7 +461,8 @@ def add_to_steam(
|
||||
return (False, error)
|
||||
|
||||
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
|
||||
if not is_store_uri and not os.path.exists(exe_path):
|
||||
local_path = exe_path.removeprefix("native://")
|
||||
if not is_store_uri and not os.path.exists(local_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -438,8 +476,8 @@ def add_to_steam(
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(exe_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else exe_path)
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(local_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else local_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
|
||||
steam_home = get_steam_home()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -40,6 +42,7 @@ from portprotonqt.dialogs.proton_manager import (
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, save_native_settings, is_native_executable, read_native_game
|
||||
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
|
||||
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
|
||||
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
|
||||
@@ -117,7 +120,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
))
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
)
|
||||
self._position_library_controls_widget()
|
||||
self._on_only_installed_changed(game_config.get_only_installed())
|
||||
@@ -362,13 +365,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"Epic Games",
|
||||
"PortProton",
|
||||
_("Native"),
|
||||
_("Favorites"),
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
@@ -406,7 +410,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
)
|
||||
|
||||
self.badge_view_keys = ["detailed", "compact", "hidden"]
|
||||
@@ -652,7 +656,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
path = url.toLocalFile().lower()
|
||||
if path.endswith(
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
) or (os.path.isfile(url.toLocalFile()) and is_native_executable(url.toLocalFile())):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
@@ -676,7 +680,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.open_local_autoinstall_card(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
|
||||
self.openAddGameDialog(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
@@ -755,6 +759,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
name = dialog.nameEdit.text().strip()
|
||||
exe_path = dialog.exeEdit.text().strip()
|
||||
user_cover = dialog.coverEdit.text().strip()
|
||||
native = is_native_executable(exe_path)
|
||||
if native:
|
||||
user_cover = dialog.last_cover_path or user_cover
|
||||
|
||||
if not name or not exe_path:
|
||||
return
|
||||
@@ -789,14 +796,30 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
entry = parse_desktop_entry(desktop_path)
|
||||
if not entry:
|
||||
return
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
if not native:
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
elif cover_path:
|
||||
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
|
||||
self._write_desktop_file(desktop_entry, desktop_path)
|
||||
if native:
|
||||
try:
|
||||
save_native_settings(desktop_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
for source in ("all", "native", "favorites"):
|
||||
getattr(self, "_loaded_library_cache", {}).pop(source, None)
|
||||
description = entry.get("Comment", "")
|
||||
exec_line = entry.get("Exec", exe_path)
|
||||
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
|
||||
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
|
||||
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
|
||||
|
||||
# User cover fallback
|
||||
user_cover_path = cover_path # Already set if user provided
|
||||
native_game = read_native_game(Path(desktop_path)) if native else None
|
||||
if native_game:
|
||||
user_cover_path = native_game[2] or user_cover_path
|
||||
|
||||
# Statistics (playtime, last launch - defaults for new)
|
||||
playtime_seconds = 0
|
||||
@@ -845,13 +868,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"portproton",
|
||||
"native" if native else "portproton",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""),
|
||||
steam_info.get("ppdb_rating", ""),
|
||||
)
|
||||
|
||||
# Incremental add
|
||||
if native and game_config.get_display_filter() not in ("all", "native"):
|
||||
return
|
||||
self.game_library_manager.add_game_incremental(game_data)
|
||||
|
||||
# Trigger visible images load
|
||||
@@ -873,7 +898,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
cached_steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"portproton",
|
||||
"native" if native else "portproton",
|
||||
cached_steam_info.get("anticheat_slug", ""),
|
||||
cached_steam_info.get("ppdb_id", ""),
|
||||
cached_steam_info.get("ppdb_rating", ""),
|
||||
@@ -885,6 +910,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
|
||||
"""Apply shortcut options selected in add/edit game dialog."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
for directory, checkbox in (
|
||||
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
|
||||
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
|
||||
):
|
||||
if directory and checkbox.isChecked():
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
shutil.copyfile(source, Path(directory) / source.name)
|
||||
os.chmod(Path(directory) / source.name, 0o755)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(game_name):
|
||||
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
|
||||
elif is_game_in_steam(game_name):
|
||||
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
|
||||
return
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
if dialog.add_to_menu_checkbox.isChecked():
|
||||
|
||||
@@ -32,6 +32,7 @@ class MainWindowWorkersMixin:
|
||||
"egs_auth_worker": None,
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"native_library_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0008 1a6.9996 6.9996 0 0 0-7.0008 6.9992 6.9996 6.9996 0 0 0 7.0008 7.0008 6.9996 6.9996 0 0 0 6.9992-7.0008 6.9996 6.9996 0 0 0-6.9992-6.9992zm0.03052 1.8345c0.49138 0 0.68069 0.026991 1.0522 0.151 0.77261 0.25794 1.4608 0.88663 1.857 1.698 0.36432 0.74592 0.47136 1.542 0.34538 2.5446-0.02796 0.2226-0.0498 0.49442-0.0498 0.60402v0.1992l0.24578 0.24739c0.8226 0.82687 1.5016 1.8186 1.8892 2.7614 0.0076 0.01852 0.01187 0.0377 0.01928 0.05622-0.49301 0.83904-1.1794 1.5485-2.0016 2.0691-0.04447-0.3775-0.13313-0.76334-0.26667-1.147-0.33295-0.95658-0.86947-1.775-1.5422-2.3534-0.22323-0.19199-0.53694-0.41582-0.75181-0.53655-0.0648-0.036441-0.11405-0.069295-0.11084-0.072289 0.0035-0.00285 0.10384-0.043441 0.22329-0.08996 0.47005-0.18305 0.84651-0.41233 1.1438-0.69558 0.4802-0.45754 0.7046-0.96565 0.70361-1.5984-0.0012-0.60346-0.212-1.0975-0.64418-1.5133-0.37522-0.36091-0.8574-0.54398-1.3639-0.53815-0.30388 0.003495-0.61702 0.07465-0.92048 0.21687-0.40675 0.1906-0.86555 0.57266-1.2353 1.0265-0.19966 0.24497-0.64341 0.92641-0.61687 1.0008 0.17687 0.49573 0.05014 0.89774-0.68755 1.1213-0.14863 0.00956-0.2752 0.022977-0.28112 0.028916-0.01608 0.016029 0.06854 0.1696 0.16707 0.30843 0.14523 0.20473 0.39623 0.4208 0.6763 0.57992 0.0689 0.039149 0.12571 0.076335 0.1253 0.081928 0 0.00577-0.07689 0.054243-0.16867 0.10763-0.66042 0.38426-1.248 1.1985-1.6 2.2201-0.14407 0.4181-0.23536 0.77816-0.28755 1.1454-0.55629-0.51797-1.0112-1.1354-1.3414-1.8201 0.18238-0.35341 0.3666-0.624 0.69237-1.0217 0.32937-0.40208 0.89278-0.92816 1.3494-1.2594l0.17671-0.12851v-0.12209c1e-6 -0.11617-0.0027-0.11963-0.04659-0.10602-0.13353 0.041394-0.41447 0.20677-0.53012 0.31165l-0.05783 0.053012 0.01125-0.24418c0.01809-0.40899 0.1444-0.77469 0.40482-1.1647l0.13815-0.20562-0.0498-0.1751c-0.1341-0.46267-0.11978-1.0338 0.04016-1.5133 0.15572-0.46694 0.38428-0.82639 0.76144-1.2032 0.5179-0.51738 1.0636-0.80828 1.8201-0.96867 0.24284-0.051483 0.30063-0.056286 0.71165-0.056225zm0.45783 3.1325c0.39072-0.00731 0.65853 0.36199 0.5494 0.75663-0.03707 0.13416-0.22104 0.31614-0.36466 0.35823-0.13428 0.039318-0.26887 0.039149-0.38072 0v-0.00161c-0.19556-0.069357-0.35076-0.27993-0.36787-0.5012-0.01367-0.17688 0.04474-0.32481 0.17831-0.4498 0.12845-0.12009 0.21938-0.15913 0.38554-0.16225zm-0.14297 0.33574c-0.06851 0.00128-0.10608 0.017958-0.15904 0.06747-0.05507 0.051532-0.07953 0.11181-0.0739 0.18474 0.0071 0.091227 0.07199 0.17863 0.15261 0.20723 0.04612 0.016141 0.10046 0.01621 0.15582 0 0.05921-0.017353 0.13572-0.090873 0.151-0.14618 0.045-0.1627-0.06542-0.31627-0.22651-0.31325zm-3.0185 0.55904c-0.01687-1.298e-4 -0.03412 0.00176-0.05301 0.00321-0.12511 0.00961-0.15006 0.020885-0.27149 0.12048-0.15317 0.12564-0.3168 0.33226-0.45301 0.57349-0.10345 0.18332-0.24198 0.53868-0.2008 0.51566 0.0143-0.00799 0.11568-0.087633 0.22329-0.17671 0.26056-0.21554 0.35801-0.28499 0.67148-0.47068 0.36751-0.21785 0.41804-0.28276 0.32932-0.42892-0.05724-0.094184-0.12772-0.13564-0.24578-0.13655z" fill="#fff" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -49,6 +49,8 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
|
||||
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
|
||||
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
|
||||
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
|
||||
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
|
||||
@@ -232,6 +234,59 @@ def test_other_launch_modes_keep_main_window(
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [[], ["--silent"], ["--log"], ["--silent", "--log"]])
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_native_shortcut_launch_modes(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, flags: list[str], existing: bool,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
from PySide6.QtCore import QThread
|
||||
from portprotonqt.native_games import create_native_desktop_file
|
||||
|
||||
startup = silent_startup
|
||||
executable = startup.exe.with_name("native game")
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
result = create_native_desktop_file(str(executable), "Native")
|
||||
assert result is not None
|
||||
content, destination = result
|
||||
Path(destination).write_text(content)
|
||||
uri = "native://" + destination
|
||||
startup.socket.waitForConnected.return_value = existing
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(executable)])
|
||||
mode = "log" if "--log" in flags else "silent" if "--silent" in flags else "open"
|
||||
if existing:
|
||||
startup.app.main()
|
||||
startup.socket.write.assert_called_once_with(f"{mode}:{quote(uri, safe='')}".encode())
|
||||
startup.window.assert_not_called()
|
||||
return
|
||||
window, timers = MagicMock(), MagicMock()
|
||||
window.game_processes = []
|
||||
startup.window.side_effect, startup.window.return_value = None, window
|
||||
monkeypatch.setattr(startup.app, "QTimer", timers)
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
def event_loop() -> int:
|
||||
callback = next(call.args[1] for call in timers.singleShot.call_args_list
|
||||
if getattr(call.args[1], "__name__", "") == "handle_native_launch")
|
||||
callback()
|
||||
return 0
|
||||
startup.qt_app.exec.side_effect = event_loop
|
||||
with pytest.raises(SystemExit):
|
||||
startup.app.main()
|
||||
assert window.silent_launch_mode is (mode == "silent")
|
||||
if mode == "silent":
|
||||
window.toggleGame.assert_called_once_with(uri)
|
||||
else:
|
||||
window._load_native_games_async.assert_called_once()
|
||||
window._load_native_games_async.call_args.args[0]([
|
||||
("Native", "", "", "", "", uri, "", "", "", "", 0, 0, "native", "", "", "")])
|
||||
assert window.openGameDetailPage.call_args.args[0]["exec_line"] == uri
|
||||
if mode == "log":
|
||||
assert window._pending_log_exe == uri
|
||||
|
||||
|
||||
class TestNormalizeLaunchPath:
|
||||
def test_plain_path(self):
|
||||
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
|
||||
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from pytest import MonkeyPatch, mark
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
import portprotonqt.detail_pages.utils as detail_utils
|
||||
@@ -701,3 +701,23 @@ def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
|
||||
add_count = sum(1 for t in button_texts if "Add" in t or "Добавить" in t)
|
||||
assert edit_count == 1
|
||||
assert add_count == 0
|
||||
|
||||
|
||||
@mark.parametrize("managed", [False, True])
|
||||
def test_native_detail_folder_button_only_for_managed_games(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, managed: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
data = tmp_path / "data"
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(data))
|
||||
directory = data / "PortProtonQt/native_games" if managed else tmp_path / "usr/share/applications"
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
manager._create_game_buttons_layout({
|
||||
"game_source": "native",
|
||||
"exec_line": "native://" + str(directory / "hytale.desktop"),
|
||||
"name": "Hytale Launcher",
|
||||
})
|
||||
buttons = _get_added_buttons(manager)
|
||||
assert "Settings" in buttons
|
||||
assert "Create Log" in buttons
|
||||
assert ("Open Folder" in buttons) is managed
|
||||
@@ -147,3 +147,61 @@ def test_list_icon_uses_theme_state_colors(
|
||||
QIcon.Mode.Selected: "folder-#222222.svg",
|
||||
QIcon.Mode.Disabled: "folder-#333333.svg",
|
||||
}
|
||||
|
||||
|
||||
def test_appimage_thumbnail_worker_caches_icon_and_finishes(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
calls = []
|
||||
|
||||
def generate(path: str, output: str) -> bool:
|
||||
calls.append(path)
|
||||
image = QImage(16, 16, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("red"))
|
||||
return image.save(output)
|
||||
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", generate)
|
||||
pool = QThreadPool()
|
||||
for _attempt in range(2):
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image, received=received: received.append((path, image)))
|
||||
pool.start(loader)
|
||||
assert pool.waitForDone(5000)
|
||||
_application.processEvents()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert not received[0][1].isNull()
|
||||
assert calls == [str(source)]
|
||||
assert pool.activeThreadCount() == 0
|
||||
|
||||
|
||||
def test_appimage_thumbnail_failure_completes_without_replacing_icon(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", lambda *_args: False)
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image: received.append((path, image)))
|
||||
loader.run()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert received[0][1].isNull()
|
||||
item = MagicMock()
|
||||
explorer = SimpleNamespace(thumbnail_cache={}, file_items={str(source): item})
|
||||
FileExplorer._set_thumbnail_icon(cast(FileExplorer, explorer), str(source), QImage())
|
||||
assert str(source) in explorer.thumbnail_cache
|
||||
item.setIcon.assert_not_called()
|
||||
@@ -678,3 +678,75 @@ def test_custom_icon_overrides_extracted_icon(tmp_path: Path, monkeypatch: Monke
|
||||
icon.unlink()
|
||||
assert get_custom_icon_path(executable) == ""
|
||||
assert get_exe_icon_cache_path(executable) != str(icon)
|
||||
|
||||
|
||||
@mark.parametrize("elf_class,endian", [(1, "<"), (2, "<"), (2, ">")])
|
||||
@mark.parametrize("svg", [False, True])
|
||||
def test_appimage_icon_extraction(tmp_path: Path, elf_class: int, endian: str, svg: bool) -> None:
|
||||
from portprotonqt.icon_extractor import APPIMAGE_EXTRACT_TIMEOUT, ELF_SECTION_LAYOUTS
|
||||
|
||||
header = bytearray(64)
|
||||
header[:4] = b"\x7fELF"
|
||||
header[4:6] = bytes([elf_class, 1 if endian == "<" else 2])
|
||||
header[8:11] = b"AI\x02"
|
||||
offset_position, offset_format, size_position = ELF_SECTION_LAYOUTS[elf_class]
|
||||
struct.pack_into(endian + offset_format, header, offset_position, len(header))
|
||||
struct.pack_into(endian + "HH", header, size_position, 0, 0)
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
output = tmp_path / "icon.png"
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">'
|
||||
b'<rect width="16" height="16" fill="red"/></svg>') if svg else icon.getvalue()
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", return_value=SimpleNamespace(stdout=payload)) as run:
|
||||
assert generate_thumbnail(str(source), str(output))
|
||||
run.assert_called_once_with(
|
||||
["unsquashfs", "-cat", "-o", "64", str(source), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with Image.open(output) as image:
|
||||
assert image.size == (128, 128)
|
||||
assert image.convert("RGBA").getpixel((64, 64)) == (255, 0, 0, 255)
|
||||
|
||||
|
||||
def test_appimage_icon_missing_tool(tmp_path: Path) -> None:
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert not generate_thumbnail(str(source), str(tmp_path / "icon.png"))
|
||||
|
||||
|
||||
@mark.parametrize("target", ["icon.png", "../outside", ".DirIcon"])
|
||||
def test_dwarfs_icon_symlink(tmp_path: Path, target: str) -> None:
|
||||
import tarfile
|
||||
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"DWARFS")
|
||||
link_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=link_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo(".DirIcon")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = target
|
||||
archive.addfile(member)
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
icon_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=icon_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo("icon.png")
|
||||
member.size = len(icon.getvalue())
|
||||
archive.addfile(member, io.BytesIO(icon.getvalue()))
|
||||
results = [SimpleNamespace(stdout=data.getvalue()) for data in (link_archive, icon_archive)]
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=results) as run:
|
||||
assert generate_thumbnail(str(source), str(tmp_path / "icon.png")) is (target == "icon.png")
|
||||
assert run.call_count == (2 if target == "icon.png" else 1)
|
||||
assert run.call_args_list[0].args[0][0] == "dwarfsextract"
|
||||
assert "--skip-specials" in run.call_args_list[0].args[0]
|
||||
@@ -13,7 +13,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
|
||||
from portprotonqt import image_utils
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
LOAD_TIMEOUT_MS = 30000
|
||||
|
||||
@@ -54,6 +54,23 @@ def library_scene(
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def test_native_card_uses_linux_source_corner(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr(ThemeManager(), "current_theme_name", "standart")
|
||||
card = manager._create_game_card((
|
||||
"Native", "", "", "", "", "native:///game.desktop", "", "", "", "", 0, 0, "native",
|
||||
))
|
||||
assert card.native_visible
|
||||
assert isinstance(card.nativeLabel._icon, str)
|
||||
assert Path(card.nativeLabel._icon).name == "badge_linux.svg"
|
||||
assert Path(card.nativeLabel._icon).is_file()
|
||||
assert not card.portproton_visible
|
||||
card.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_new_horizontal_cards_fit_before_layout_insertion(
|
||||
|
||||
+21
-13
@@ -316,7 +316,7 @@ def _tile_theme() -> Any:
|
||||
|
||||
@mark.parametrize(
|
||||
"worker_name",
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "native_library_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
)
|
||||
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
|
||||
worker = MagicMock()
|
||||
@@ -435,14 +435,16 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
|
||||
quit_app.assert_called_once_with()
|
||||
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
def test_source_ribbon_visibility(
|
||||
monkeypatch: MonkeyPatch,
|
||||
vertical_layout: bool,
|
||||
ribbon_visible: bool,
|
||||
source: str,
|
||||
) -> None:
|
||||
card = MagicMock()
|
||||
card.coverLabel = MagicMock()
|
||||
card.game_source = "portproton"
|
||||
card.game_source = source
|
||||
card.ppdb_id = "report-id"
|
||||
card.protondb_tier = "Platinum"
|
||||
card.anticheat_status = "Supported"
|
||||
@@ -453,9 +455,9 @@ def test_source_ribbon_visibility(
|
||||
card.parentWidget.return_value = None
|
||||
monkeypatch.setattr("portprotonqt.game_card.ui_config.get_economy_mode", lambda: False)
|
||||
|
||||
GameCard.update_badge_visibility(card, "portproton")
|
||||
GameCard.update_badge_visibility(card, source)
|
||||
|
||||
card.portprotonLabel.setVisible.assert_called_with(ribbon_visible)
|
||||
getattr(card, f"{source}Label").setVisible.assert_called_with(ribbon_visible)
|
||||
card.ppdbLabel.setVisible.assert_called_with(False)
|
||||
card.protondbLabel.setVisible.assert_called_with(False)
|
||||
card.anticheatLabel.setVisible.assert_called_with(False)
|
||||
@@ -1707,7 +1709,8 @@ def test_open_local_autoinstall_card_uses_autoinstall_page(tmp_path: Path) -> No
|
||||
assert game_data["exec_line"] == f"autoinstall:{shlex.quote(str(script_path))}"
|
||||
assert return_tab_index == 0
|
||||
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
@mark.parametrize("native", [False, True])
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.touch()
|
||||
started = []
|
||||
@@ -1732,11 +1735,12 @@ def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
window.detail_page_manager = FakeDetailPageManager()
|
||||
window.stackedWidget = FakeStackedWidget()
|
||||
window.currentDetailPage = None
|
||||
window._pending_log_exe = str(exe_path)
|
||||
target = "native://" + str(tmp_path / "Native Game.desktop") if native else str(exe_path)
|
||||
window._pending_log_exe = target
|
||||
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": str(exe_path)})
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": target})
|
||||
|
||||
assert started == [(str(exe_path), window.detail_page_manager._debug_log_button)]
|
||||
assert started == [(target, window.detail_page_manager._debug_log_button)]
|
||||
assert window._pending_log_exe is None
|
||||
|
||||
def test_launch_exe_skips_library_load_for_ppai() -> None:
|
||||
@@ -2734,6 +2738,7 @@ def test_add_game_dialog_does_not_reprocess_its_shortcut(
|
||||
exeEdit=SimpleNamespace(text=lambda: "/games/praest.exe"),
|
||||
coverEdit=SimpleNamespace(text=lambda: ""),
|
||||
getDesktopEntryData=lambda: (entry, str(desktop_path)),
|
||||
native_checkbox=SimpleNamespace(isChecked=lambda: False),
|
||||
last_cover_path="",
|
||||
)
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -3280,27 +3285,30 @@ def test_library_source_multiselection(
|
||||
)
|
||||
|
||||
|
||||
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch) -> None:
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch, source: str) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog,portproton")
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: f"gog,{source}")
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
callbacks = {}
|
||||
loaded = MagicMock()
|
||||
window = cast(Any, SimpleNamespace(
|
||||
_loading_games=False, launch_exe=None, games_loaded=SimpleNamespace(emit=loaded),
|
||||
_load_gog_games_async=lambda callback: callbacks.update(gog=callback),
|
||||
_load_portproton_games_async=lambda callback: callbacks.update(portproton=callback),
|
||||
_load_portproton_games_async=MagicMock(), _load_native_games_async=MagicMock(),
|
||||
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
|
||||
))
|
||||
setattr(window, f"_load_{source}_games_async", lambda callback: callbacks.update({source: callback}))
|
||||
|
||||
MainWindow.loadGames(window, force_load=True)
|
||||
pp_game = ("PP", "", "", "", "", "pp.exe")
|
||||
gog_game = ("GOG", "", "", "", "", "gog://launch/1")
|
||||
callbacks["gog"]([gog_game])
|
||||
loaded.assert_not_called()
|
||||
callbacks["portproton"]([pp_game])
|
||||
callbacks[source]([pp_game])
|
||||
|
||||
loaded.assert_called_once_with([pp_game, gog_game])
|
||||
loaded.assert_called_once_with([pp_game, gog_game] if source == "portproton" else [gog_game, pp_game])
|
||||
getattr(window, f"_load_{'native' if source == 'portproton' else 'portproton'}_games_async").assert_not_called()
|
||||
window._load_steam_games_async.assert_not_called()
|
||||
window._load_egs_games_async.assert_not_called()
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -42,29 +42,6 @@ gui_config () {
|
||||
assert ppdb.read_text() == expected
|
||||
|
||||
|
||||
@mark.parametrize("enabled", ["0", "1", ""])
|
||||
@mark.parametrize("inherited", [None, "0", "1"])
|
||||
def test_video_fix_controls_dxgi_device_manager(
|
||||
tmp_path: Path, enabled: str, inherited: str | None,
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
block = ' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]' + helper.split(
|
||||
' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]', 1,
|
||||
)[1].split(" fi", 1)[0] + " fi\n"
|
||||
env = {"PATH": os.defpath, "PW_FIX_VIDEO_IN_GAME": enabled}
|
||||
if inherited is not None:
|
||||
env["WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER"] = inherited
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", block
|
||||
+ 'printf "%s" "${WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER-unset}"'],
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == ("1" if enabled == "1" else "unset")
|
||||
|
||||
|
||||
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
|
||||
@mark.parametrize("copies", [1, 3])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
|
||||
|
||||
+86
-7
@@ -69,7 +69,7 @@ def test_game_card_context_menu_does_not_block_gamepad_polling(monkeypatch) -> N
|
||||
assert app is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id"])
|
||||
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id", "native"])
|
||||
def test_add_to_steam_for_all_accounts(
|
||||
tmp_path: Path,
|
||||
monkeypatch: Any,
|
||||
@@ -77,8 +77,10 @@ def test_add_to_steam_for_all_accounts(
|
||||
) -> None:
|
||||
steam_home = tmp_path / "steam"
|
||||
user_ids = ("76561197960265729", "76561197960265730")
|
||||
executable = tmp_path / "game.exe"
|
||||
executable = tmp_path / ("Hytale Launcher.desktop" if target == "native" else "game.exe")
|
||||
executable.touch()
|
||||
if target == "native":
|
||||
target = "native://" + str(executable)
|
||||
monkeypatch.setattr(steam_shortcuts, "get_steam_home", lambda: steam_home)
|
||||
monkeypatch.setattr(
|
||||
steam_shortcuts,
|
||||
@@ -112,6 +114,9 @@ def test_add_to_steam_for_all_accounts(
|
||||
)
|
||||
|
||||
assert result is True
|
||||
if target.startswith("native://"):
|
||||
script = tmp_path / "steam_scripts" / "Test Game.sh"
|
||||
assert "'" + target + "'" in script.read_text()
|
||||
generate_icon.assert_not_called()
|
||||
assert custom_icon_lookup.call_args_list[0].args == (
|
||||
str(tmp_path / "steam_scripts" / "Test Game.sh"),
|
||||
@@ -1568,7 +1573,8 @@ def test_local_shortcut_name_keeps_metadata_on_write_failure(
|
||||
assert list(game_dir.iterdir()) == [metadata]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id"])
|
||||
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id",
|
||||
"native:///usr/share/applications/com.hypixel.HytaleLauncher.desktop"])
|
||||
@pytest.mark.parametrize("batch,result,expected", [
|
||||
(True, (True, "restart"), (0, 0)),
|
||||
(False, (True, "Game added to Steam via CEF API"), (0, 0)),
|
||||
@@ -1582,7 +1588,8 @@ def test_steam_addition_dialogs(monkeypatch: Any, batch: bool, result: tuple[boo
|
||||
manager = object.__new__(ContextMenuManager)
|
||||
manager._batch_action = batch
|
||||
manager._check_portproton = lambda: True
|
||||
monkeypatch.setattr(manager, "_get_exec_line", lambda game_name, exec_line: exec_line)
|
||||
resolve_exec = MagicMock(side_effect=lambda game_name, exec_line: exec_line)
|
||||
monkeypatch.setattr(manager, "_get_exec_line", resolve_exec)
|
||||
if target == "game.exe":
|
||||
monkeypatch.setattr(manager, "_parse_exe_path", lambda exec_line, game_name: exec_line)
|
||||
manager.signals = MagicMock()
|
||||
@@ -1591,7 +1598,11 @@ def test_steam_addition_dialogs(monkeypatch: Any, batch: bool, result: tuple[boo
|
||||
add_shortcut = MagicMock(return_value=result)
|
||||
monkeypatch.setattr("portprotonqt.context_menu_manager.add_to_steam", add_shortcut)
|
||||
manager.add_to_steam("Game", target, "cover")
|
||||
if target != "game.exe":
|
||||
if target.startswith("native://"):
|
||||
resolve_exec.assert_not_called()
|
||||
api.get_launch_target.assert_not_called()
|
||||
add_shortcut.assert_called_once_with("Game", target, "cover")
|
||||
elif target != "game.exe":
|
||||
api.get_launch_target.assert_called_once_with(target.rsplit("/", 1)[-1])
|
||||
add_shortcut.assert_called_once_with("Game", target, "cover", "installed.exe")
|
||||
assert manager.signals.show_info_dialog.emit.call_count == expected[0]
|
||||
@@ -2015,7 +2026,7 @@ def test_game_folder_import_completion_refreshes_completed_games(
|
||||
|
||||
@pytest.mark.parametrize("edit_mode", [False, True])
|
||||
def test_add_game_file_picker_offers_folder_selection_only_for_adding(
|
||||
monkeypatch: pytest.MonkeyPatch, edit_mode: bool,
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, edit_mode: bool,
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QHBoxLayout
|
||||
import portprotonqt.dialogs.base as module
|
||||
@@ -2023,7 +2034,8 @@ def test_add_game_file_picker_offers_folder_selection_only_for_adding(
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
explorer = SimpleNamespace(file_signal=MagicMock(), button_layout=QHBoxLayout(), show=MagicMock())
|
||||
monkeypatch.setattr(explorer_module, "FileExplorer", lambda *_args, **_kwargs: explorer)
|
||||
create_explorer = MagicMock(return_value=explorer)
|
||||
monkeypatch.setattr(explorer_module, "FileExplorer", create_explorer)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
|
||||
dialog = SimpleNamespace(
|
||||
last_exe_path=None, edit_mode=edit_mode, onExeSelected=MagicMock(),
|
||||
@@ -2033,6 +2045,17 @@ def test_add_game_file_picker_offers_folder_selection_only_for_adding(
|
||||
|
||||
module.AddGameDialog.browseExe(cast(Any, dialog))
|
||||
|
||||
file_filter = create_explorer.call_args.kwargs["file_filter"]
|
||||
assert file_filter("/game.exe")
|
||||
for filename in ("game", "game.AppImage", "game.sh", "game.txt"):
|
||||
path = tmp_path / filename
|
||||
path.touch()
|
||||
path.chmod(0o644)
|
||||
assert not file_filter(str(path))
|
||||
path.chmod(0o755)
|
||||
assert file_filter(str(path))
|
||||
assert not file_filter(str(tmp_path))
|
||||
assert not file_filter(str(tmp_path / "missing"))
|
||||
explorer.file_signal.file_selected.connect.assert_called_once_with(dialog.onExeSelected)
|
||||
assert explorer.button_layout.count() == int(not edit_mode)
|
||||
if not edit_mode:
|
||||
@@ -2683,3 +2706,59 @@ def test_sgdb_cloud_urls_use_proxy(
|
||||
api.fetch_sgdb_cover_async("Portal 2", completed.append, image_type)
|
||||
|
||||
assert completed == [f"https://deckyloader.ru/sgdb-cdn/{image_type}/asset.png"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("themed", [False, True])
|
||||
def test_native_steam_icon_uses_desktop_icon(tmp_path: Path, monkeypatch: Any, themed: bool) -> None:
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
image = tmp_path / "pixmaps/hytale-launcher.png"
|
||||
image.parent.mkdir()
|
||||
pixmap = QPixmap(32, 32)
|
||||
pixmap.fill(Qt.GlobalColor.red)
|
||||
assert pixmap.save(str(image))
|
||||
source = tmp_path / "hytale.desktop"
|
||||
icon = "hytale-launcher" if themed else str(image)
|
||||
source.write_text(f"[Desktop Entry]\nType=Application\nName=Hytale\nIcon={icon}\nExec=hytale-launcher\n")
|
||||
monkeypatch.setattr(steam_shortcuts.QIcon, "fromTheme", lambda _name: QIcon())
|
||||
monkeypatch.setattr(steam_shortcuts.QStandardPaths, "standardLocations", lambda _location: [str(tmp_path)])
|
||||
generated = steam_shortcuts._generate_icon(str(source), str(tmp_path))
|
||||
assert generated
|
||||
assert not QPixmap(generated).isNull()
|
||||
assert app is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relative", [
|
||||
"pixmaps/hytale.xpm", "icons/hytale.png", "icons/hicolor/128x128/apps/hytale.png",
|
||||
"icons/hicolor/scalable/apps/hytale.svg", "icons/other/64x64/games/hytale.png",
|
||||
])
|
||||
def test_desktop_icon_searches_xdg_formats(tmp_path: Path, monkeypatch: Any, relative: str) -> None:
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
image = tmp_path / relative
|
||||
image.parent.mkdir(parents=True)
|
||||
if image.suffix == ".svg":
|
||||
image.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="red"/></svg>')
|
||||
else:
|
||||
pixmap = QPixmap(32, 32)
|
||||
pixmap.fill(Qt.GlobalColor.red)
|
||||
assert pixmap.save(str(image))
|
||||
monkeypatch.setattr(steam_shortcuts.QIcon, "fromTheme", lambda _name: QIcon())
|
||||
monkeypatch.setattr(steam_shortcuts.QStandardPaths, "standardLocations", lambda _location: [str(tmp_path)])
|
||||
assert not steam_shortcuts._resolve_desktop_icon("hytale").pixmap(32, 32).isNull()
|
||||
assert not steam_shortcuts._resolve_desktop_icon(image.name).pixmap(32, 32).isNull()
|
||||
assert steam_shortcuts._resolve_desktop_icon("missing").isNull()
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_native_cached_metadata_does_not_search_desktop_directory(monkeypatch: Any) -> None:
|
||||
import portprotonqt.steam_api.api as api
|
||||
|
||||
monkeypatch.setattr(api, "_get_cached_data_and_index", lambda *_args: ([{"appid": 1}], {}))
|
||||
search = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(api, "search_app", search)
|
||||
assert api.get_cached_steam_game_info("Hytale Launcher", "native:///usr/share/applications/com.hypixel.HytaleLauncher.desktop") == {}
|
||||
assert search.call_count == 1
|
||||
assert search.call_args.args[0] == "Hytale Launcher"
|
||||
@@ -122,7 +122,6 @@ def test_dlc_worker_released_before_modal_and_retry(
|
||||
dialog = download_tab_module.QDialog(self)
|
||||
QTimer.singleShot(0, dialog.reject)
|
||||
dialog.exec()
|
||||
loop.quit()
|
||||
|
||||
window: Any = Window()
|
||||
window.gog_api = cast(Any, object())
|
||||
@@ -136,18 +135,29 @@ def test_dlc_worker_released_before_modal_and_retry(
|
||||
|
||||
def warning(*args: Any) -> None:
|
||||
observations.append((window.store_dlc_worker, QThread.currentThread()))
|
||||
loop.quit()
|
||||
|
||||
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
|
||||
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
|
||||
timeout = QTimer()
|
||||
timeout.setSingleShot(True)
|
||||
timeout.timeout.connect(loop.quit)
|
||||
for _attempt in range(2):
|
||||
ready = QTimer()
|
||||
ready.setInterval(10)
|
||||
|
||||
for attempt in range(2):
|
||||
def finish_when_observed(expected: int = attempt + 1) -> None:
|
||||
if len(observations) == expected and window.store_dlc_worker is None:
|
||||
loop.quit()
|
||||
|
||||
ready.timeout.connect(finish_when_observed)
|
||||
window._select_store_dlcs(source, {"app_id": "123", "title": "Game"})
|
||||
ready.start()
|
||||
timeout.start(3000)
|
||||
loop.exec()
|
||||
ready.stop()
|
||||
ready.timeout.disconnect(finish_when_observed)
|
||||
timeout.stop()
|
||||
assert len(observations) == attempt + 1
|
||||
assert window.store_dlc_worker is None
|
||||
assert observations == [(None, application.thread())] * 2
|
||||
window.deleteLater()
|
||||
|
||||
Reference in new issue
Block a user