Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5384104542
|
No files matched your search
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.5.0
|
||||
VERSION: 1.4.1
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -31,16 +31,11 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
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
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: pacman -S --noconfirm --needed nodejs npm
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@v4
|
||||
|
||||
@@ -74,11 +69,6 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
@@ -138,13 +128,8 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Select nearby mirrors and abandon slow downloads after 20 seconds.
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
install -y --disablerepo=fedora-cisco-openh264 \
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
@@ -187,22 +172,8 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
|
||||
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
|
||||
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends nala
|
||||
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
|
||||
test -s /etc/apt/sources.list.d/fetch.sources; then
|
||||
# Keep updates and security; use only ranked mirrors for the base suite.
|
||||
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
else
|
||||
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
|
||||
fi
|
||||
nala update
|
||||
nala install -y build-essential debhelper dpkg-dev gettext git \
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
|
||||
@@ -22,25 +22,21 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs npm
|
||||
pacman -Syuu --noconfirm --disable-download-timeout
|
||||
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Install project and check dependencies
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Activate .venv & run pre-commit
|
||||
shell: bash
|
||||
run: |
|
||||
source build-aux/PKGBUILD-git
|
||||
pacman -S --noconfirm --needed "${depends[@]}" python-qrcode libpulse mesa pre-commit pyright python-pytest python-pytest-mock
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- name: Run pre-commit
|
||||
run: pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -80,19 +80,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
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
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@b2d1ce4216f71162e00ae58fa11ee9aeb723d0d5 # v5
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: pacman -S --noconfirm --needed nodejs npm
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -140,13 +135,8 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Select nearby mirrors and abandon slow downloads after 20 seconds.
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
install -y --disablerepo=fedora-cisco-openh264 \
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
@@ -195,17 +185,12 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git wget gnupg nodejs npm
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
@@ -266,28 +251,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
|
||||
image: debian:trixie-20260824@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
|
||||
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
|
||||
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends nala
|
||||
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
|
||||
test -s /etc/apt/sources.list.d/fetch.sources; then
|
||||
# Keep updates and security; use only ranked mirrors for the base suite.
|
||||
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
else
|
||||
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
|
||||
fi
|
||||
nala update
|
||||
nala install -y build-essential debhelper dpkg-dev gettext git \
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
|
||||
@@ -9,14 +9,11 @@ jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
env:
|
||||
UV_PYTHON_INSTALL_MIRROR: https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/
|
||||
UV_DEFAULT_INDEX: https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: pyright
|
||||
entry: uv run pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
@@ -62,7 +62,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: pytest tests/ -x -q --tb=line
|
||||
entry: uv run pytest tests/ -x -q --tb=line
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^(tests/|portprotonqt/)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.11
|
||||
3.10
|
||||
@@ -1,7 +1,7 @@
|
||||
# PortProtonQt — AI Agent Guidelines
|
||||
|
||||
**Project:** PortProtonQt — Game library manager and launcher for Linux
|
||||
**Language:** Python 3.11+
|
||||
**Project:** PortProtonQt — GUI for PortProton, Steam, Epic Games Store
|
||||
**Language:** Python 3.10+
|
||||
**Platform:** Linux (POSIX)
|
||||
**License:** GPL-3.0
|
||||
**Build:** Meson + uv
|
||||
@@ -275,13 +275,11 @@ AI agents MAY:
|
||||
|
||||
- No blocking I/O in UI thread
|
||||
- No synchronous HTTP in UI thread
|
||||
- Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
|
||||
- Cache API responses in `~/.cache/PortProtonQt`
|
||||
- Avoid repeated disk reads in loops
|
||||
- Avoid O(n²) in game lists
|
||||
- Lazy load images
|
||||
- Limit threads to CPU cores
|
||||
- Skip worker creation for valid cached downloads and URLs already marked failed; still complete callbacks exactly once through the owner's event loop
|
||||
- Reuse cached download paths only while the file exists and matches the requested destination
|
||||
|
||||
### Clarification: "No Shared Mutable Global State" Does NOT Mean Removing Caches
|
||||
|
||||
@@ -290,10 +288,10 @@ Rule intent: avoid shared mutable globals that hold application state or cause h
|
||||
This does not prohibit:
|
||||
- Module-level cache constants (e.g., cache TTL values)
|
||||
- Dedicated cache managers or classes with explicit lifecycle
|
||||
- Disk caches under `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
|
||||
- Disk caches under `~/.cache/PortProtonQt`
|
||||
- `requests.Session()` objects if they are encapsulated and not mutated unpredictably
|
||||
|
||||
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache`" and "Avoid repeated disk reads in loops").
|
||||
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `~/.cache/PortProtonQt`" and "Avoid repeated disk reads in loops").
|
||||
|
||||
---
|
||||
|
||||
@@ -304,9 +302,6 @@ Do not delete or disable caching or downloaders to satisfy this rule. Caching is
|
||||
- No heavy operations in paintEvent
|
||||
- Use methods instead of lambda for complex logic
|
||||
- Use QThread for long tasks
|
||||
- Retain every background worker that can outlive its caller in an owner field or collection until it finishes
|
||||
- Register window-owned workers and retained worker collections in the application shutdown path; cancel when supported and wait before interpreter exit
|
||||
- Add or update shutdown regression tests when adding or changing a background worker, including executor-backed workers
|
||||
|
||||
---
|
||||
|
||||
@@ -334,7 +329,7 @@ Do not assume conventions. Never invent behavior.
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync
|
||||
source .venv/bin/activate
|
||||
pre-commit install --install-hooks
|
||||
@@ -414,29 +409,21 @@ tests/
|
||||
├── test_appimage_updater.py # AppImage auto-update worker, mirrors, changelog parsing
|
||||
├── test_dark_theme.py # Dark theme detection (XFCE/MATE/Cinnamon/GNOME)
|
||||
├── test_steam_cache.py # exiftool skip, cache eviction, delete_cached_app_files
|
||||
├── test_downloader.py # Cache/error callbacks, worker ownership, and shutdown regressions
|
||||
├── test_base_config.py # BaseConfig read/write, caching, versioning
|
||||
├── test_cli.py # normalize_launch_path, URL/resolution parsing
|
||||
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
|
||||
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
|
||||
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, and focus regressions
|
||||
├── test_input_manager.py # Gamepad input navigation and focus regressions
|
||||
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
|
||||
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
|
||||
├── test_main_window.py # Main window data processing and callback regressions
|
||||
├── test_library_resize.py # Library resize batching, cover reuse, and flow layout regressions
|
||||
├── test_library_loading.py # Incremental loading, source switching, first-paint focus, and cover callbacks
|
||||
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
|
||||
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
|
||||
├── test_portproton_scripts.py # Shell script launch, second exe, exit codes, user.conf
|
||||
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
|
||||
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
|
||||
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
|
||||
├── test_file_explorer.py # File explorer themed icon state regressions
|
||||
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
|
||||
├── test_exe_metadata.py # PE version strings, shell output, and ExifTool fallback
|
||||
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
|
||||
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
|
||||
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
|
||||
├── test_shortcuts.py # Desktop shortcut creation, paths with spaces, .desktop entry
|
||||
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
|
||||
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
|
||||
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
|
||||
@@ -470,15 +457,6 @@ pre-commit run pytest
|
||||
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
|
||||
- Test regression scenarios from git fix commits
|
||||
|
||||
### Library Refresh Regressions
|
||||
|
||||
- Late metadata/download completion must preserve scroll position and focused card in an already populated library; first-card focus belongs to initial loading
|
||||
- Cover both library layouts and refresh completion while the autoinstall tab is active
|
||||
- For reported scrolling regressions, supplement unit tests with a full application cycle: isolated empty cache, forced ProtonDB connection failure, scroll and refresh both tabs, switch back, then close the application
|
||||
- Record scroll values and visible cards around late updates; distinguish an intentional refresh reset from an unsolicited rollback after the user resumes scrolling
|
||||
- Use temporary XDG config/data directories for GUI reproduction; never clear the user's real cache or alter system-wide network settings
|
||||
- Report baseline reproduction and final-code results separately; passing callback tests alone does not prove the visible regression is fixed
|
||||
|
||||
### Key Regression Areas
|
||||
|
||||
| Module | Regression | Commit |
|
||||
@@ -514,7 +492,6 @@ pre-commit run pytest
|
||||
- [ ] Prefer dedicated functions for subprocess calls when practical within task scope
|
||||
- [ ] No shared mutable global state (except logger and explicit cache/session infrastructure)
|
||||
- [ ] No blocking calls introduced in the UI thread
|
||||
- [ ] Background workers have explicit ownership and shutdown cleanup with regression coverage
|
||||
- [ ] **No hardcoded styles or constants (use theme constants)**
|
||||
- [ ] **New constants added to theme files only**
|
||||
|
||||
@@ -1007,7 +984,7 @@ PortProtonQt/
|
||||
│ ├── tabs/ # Main window tabs and workers
|
||||
│ │ ├── autoinstall_tab.py
|
||||
│ │ ├── control_hints.py
|
||||
│ │ ├── download_tab.py
|
||||
│ │ ├── gog_tab.py
|
||||
│ │ ├── library_tab.py
|
||||
│ │ ├── settings_tab.py
|
||||
│ │ ├── system_tab.py
|
||||
@@ -1058,8 +1035,6 @@ PortProtonQt/
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use the existing `orjson` dependency instead of standard-library `json` for faster JSON parsing and serialization. For formatted output, use `orjson.OPT_INDENT_2`.
|
||||
|
||||
### Core
|
||||
|
||||
| Package | Purpose | License |
|
||||
@@ -1086,6 +1061,8 @@ PortProtonQt/
|
||||
|
||||
- [Theme documentation](documentation/theme_guide)
|
||||
- [Localization guide](documentation/localization_guide)
|
||||
- [Metadata override guide](documentation/metadata_override)
|
||||
- [TODO list](TODO.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
+1
-101
@@ -7,86 +7,15 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: ЛКМ и кнопка A геймпада переключают выделение, Ctrl+клик позволяет выделять карточки напрямую
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
|
||||
|
||||
### Changed
|
||||
|
||||
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
|
||||
- После добавления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог об успешном добавлении
|
||||
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
|
||||
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
|
||||
- Настройки exe и `user.conf` применяются автоматически при изменении
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
|
||||
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
|
||||
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
|
||||
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
|
||||
### Contributors
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
|
||||
- Добавлена навигация геймпадом к кнопкам под таблицами
|
||||
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
|
||||
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
|
||||
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
|
||||
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
|
||||
- Добавлены выбор и установка DLC при установке игр GOG
|
||||
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
|
||||
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
|
||||
- Добавлена настройка отключения проверки i586-зависимостей в ALT Linux
|
||||
- Добавлена настройка принудительного применения английского языка
|
||||
- Добавлено редактирование названия и обложки игр GOG по ID
|
||||
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлена поддержка сборок Proton CachyOS WineLand
|
||||
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
|
||||
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
|
||||
- В панель библиотеки добавлен выбор типа сетки
|
||||
- В виджет с исчезающей полосой прокрутки добавлена поддержка горизонтальной ориентации
|
||||
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
|
||||
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
|
||||
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
|
||||
- Добавлен графический редактор глобальных настроек игр из `user.conf`
|
||||
- В режиме `PW_USE_SUPPLIED_DXVK_VKD3D` добавлена поддержка D7VK из сборки Proton
|
||||
|
||||
### Changed
|
||||
|
||||
- Настройка `WINE_FULLSCREEN_FSR` по умолчанию отключена
|
||||
- Кнопки менеджера Wine/Proton приведены к общему стилю приложения и дополнены иконками
|
||||
- Списки DLL и шрифтов в Winetricks теперь сортируются по алфавиту
|
||||
- Оформление Winetricks приведено к общему стилю приложения
|
||||
- В поле запуска второго `.exe` / враппера теперь можно указать тот же исполняемый файл, что и основной
|
||||
- Описания приложения в README, метаданных и пакетах сделаны нейтральными, без перечисления источников игр и привязки к Windows
|
||||
- Ускорено изменение размера карточек библиотеки
|
||||
- Удалён GDK Proton из списка доступных сборок
|
||||
- Улучшено отображение описаний параметров в настройках игры: они размещаются под названием, а текущая строка выделяется рамкой
|
||||
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
|
||||
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
|
||||
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
|
||||
- Данные приложения организованы в `$XDG_DATA_HOME/PortProtonQt` (по умолчанию `~/.local/share/PortProtonQt`): кеш перенесён из `.cache` в `cache`, данные лаунчеров — в `launcher`, загружаемые бинарники — в `bin`, чтобы временный кеш AnyLinux не вызывал повторную загрузку при каждом запуске AppImage
|
||||
- Минимальная поддерживаемая версия Python повышена с 3.10 до 3.11
|
||||
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
|
||||
- Тема теперь применяется без перезапуска приложения
|
||||
- Добавлена сборка Fedora 45
|
||||
@@ -95,56 +24,27 @@
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Испанский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/`
|
||||
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
|
||||
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
|
||||
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
|
||||
- Порог определения быстрого вылета Windows-программы увеличен с 5 до 7 секунд
|
||||
- Открытие карточек и тихий запуск унифицированы
|
||||
- `minazuki` добавлен в список конфликтующих с GameMode процессов
|
||||
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
|
||||
- В список советов при вылете добавлено отключение Optiscaler
|
||||
- Повышен статус разработки в `pyproject.toml` с Alpha до Production
|
||||
- DXVK и VKD3D-Proton теперь загружаются только для выбранного режима Vulkan, а D7VK — только при его включении
|
||||
|
||||
### Fixed
|
||||
|
||||
- Исправлена запись `MANGOHUD_CONFIG` в `.ppdb` при выключенном MangoHud
|
||||
- Исправлены ложный статус установки и открытие чужой карточки для Rockstar Games Launcher при совпадении имени `launcher.exe`
|
||||
- Исправлен запуск второго `.exe` через `PW_RUN_AFTER_EXE` из пути с кириллицей
|
||||
- Исправлены мерцание сетки и задержка выделения карточек при открытии библиотеки
|
||||
- Исправлено обновление сетки комбинацией Guide + Select
|
||||
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
|
||||
- Исправлено появление дубля карточки при добавлении и после установки игры
|
||||
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
|
||||
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
|
||||
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
|
||||
- Исправлено отсутствие установленных демоверсий, модов, приложений и бета-версий Steam в библиотеке
|
||||
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
|
||||
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
|
||||
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
|
||||
- Исправлен запуск PortProtonQt на NixOS через `steam-run`
|
||||
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
|
||||
- Исправлено отсутствие иконки у окна встроенного терминала
|
||||
- Исправлена потеря финального вывода при ошибке встроенного терминала
|
||||
- Исправлено открытие карточек GOG через URI при старте приложения
|
||||
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
|
||||
- Исправлено отображение значка PortProtonQt в системном трее при медленной инициализации приложения
|
||||
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
|
||||
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
|
||||
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
|
||||
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
|
||||
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
|
||||
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
|
||||
- Исправлено завершение приложения при активных фоновых операциях
|
||||
- Исправлено автоматическое включение NVAPI, DLSS и трассировки лучей для видеокарт NVIDIA RTX
|
||||
- Исправлено применение принудительно выбранного языка в конфигурациях эмуляторов Steam
|
||||
|
||||
### Contributors
|
||||
- @dark_siders
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @adem4ik
|
||||
- @gallegonovato
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,3 +15,439 @@ GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
We make use of various projects, some of which require their license to be preserved. Here's their respective licenses:
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
===============
|
||||
= icoextract: =
|
||||
===============
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2015-2016 Fadhil Mandaga
|
||||
Copyright (c) 2019-2025 James Lu <james@overdrivenetworks.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
===============
|
||||
= portproton: =
|
||||
===============
|
||||
MIT License
|
||||
|
||||
|
||||
Copyright (c) 2025 Mikhail Tergoev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
===============================
|
||||
= HowLongToBeat-Python-API : =
|
||||
===============================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 JaeguKim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
===============================
|
||||
= mdf2iso : =
|
||||
===============================
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Library General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Library General
|
||||
Public License instead of this License.
|
||||
|
||||
=====================================================================
|
||||
|
||||
===========================
|
||||
= Kenney Interface Sounds =
|
||||
===========================
|
||||
|
||||
Interface Sounds (1.0)
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
|
||||
License: Creative Commons Zero, CC0
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
@@ -3,27 +3,16 @@
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
|
||||
|
||||
<p align="center">An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Release" alt="Releases"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&logo=linux" alt="Platform: Linux"></a>
|
||||
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Translations-Weblate-2eccaa?style=flat-square&logo=weblate" alt="Translate on Weblate"></a>
|
||||
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg" alt="Packaging status on Repology"></a>
|
||||
</p>
|
||||
|
||||
<p><a href="https://github.com/linux-gaming-ru/PortProtonQt/releases">Releases</a> · <a href="https://github.com/linux-gaming-ru/PortProtonQt/issues">Issue tracker</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Translations</a> · <a href="https://repology.org/project/portprotonqt/versions">Packages</a></p>
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-10488-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
</div>
|
||||
|
||||
### Installation (devel)
|
||||
|
||||
```sh
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync
|
||||
source .venv/bin/activate # For bash/zsh
|
||||
# or
|
||||
@@ -40,7 +29,7 @@ Launch using the `portprotonqt` command or via the shortcut in your application
|
||||
|
||||
### Development
|
||||
|
||||
To automatically prepare the environment (install Python 3.11, dependencies, pre-commit hooks, and generate translations), run the following script:
|
||||
To automatically prepare the environment (install Python 3.10, dependencies, pre-commit hooks, and generate translations), run the following script:
|
||||
|
||||
```sh
|
||||
./dev-scripts/prepare_env.sh
|
||||
@@ -68,7 +57,7 @@ pre-commit run --all-files
|
||||
|
||||
### Contributors
|
||||
|
||||
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://github.com/linux-gaming-ru/PortProtonQt/graphs/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
|
||||
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/activity/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
|
||||
|
||||
## Dependencies and Licenses
|
||||
|
||||
@@ -76,16 +65,13 @@ PortProtonQt uses code and dependencies from the following projects:
|
||||
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — icon extraction library, [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE) license.
|
||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — library for interacting with HowLongToBeat, [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md) license.
|
||||
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — library for converting mdf and nrg to iso, GPLv2 license.
|
||||
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — GOG downloading module, [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE) license.
|
||||
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — Epic Games launcher alternative, [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE) license.
|
||||
- [iat](https://sourceforge.net/projects/iat.berlios) — library for converting mdf and nrg to iso, GPLv3 license.
|
||||
- [pyte](https://github.com/selectel/pyte) — ANSI escape code parser, [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file) license.
|
||||
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — base for virtual keyboard layout data, GPLv3 license.
|
||||
- [omikuji](https://github.com/omikuji-launcher/omikuji) — inspiration and source material for the procedural detail page backgrounds, [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE) license.
|
||||
- [Bottles](https://github.com/bottlesdevs/Bottles) — source of the compatibility analysis rules, GPL-3.0-only license.
|
||||
|
||||
PortProtonQt is licensed under the terms in [LICENSE](LICENSE). See
|
||||
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for third-party licenses.
|
||||
See the [LICENSE](LICENSE) file for the full text of the licenses.
|
||||
|
||||
> [!WARNING]
|
||||
> **Be careful!** If you are using a theme not from the official repository or a trusted source, make sure its `styles.py` file does not contain malicious or unwanted code. Since `styles.py` is a regular Python file, it can contain any instructions. Always check the contents of third-party themes before use.
|
||||
+9
-23
@@ -1,29 +1,18 @@
|
||||
📘 This documentation is also available in [English](README.md)
|
||||
📖 This documentation is also available in [English](README.md)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
|
||||
|
||||
<p align="center">Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Релиз" alt="Релизы"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&logo=linux" alt="Платформа: Linux"></a>
|
||||
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Переводы-Weblate-2eccaa?style=flat-square&logo=weblate" alt="Перевести на Weblate"></a>
|
||||
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg?header=Репозитории" alt="Пакеты в Repology"></a>
|
||||
</p>
|
||||
|
||||
<p><a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases">Релизы</a> · <a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/issues">Сообщить о проблеме</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Переводы</a> · <a href="https://repology.org/project/portprotonqt/versions">Пакеты</a></p>
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-10488-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
</div>
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
```sh
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync
|
||||
source .venv/bin/activate # For bash/zsh
|
||||
# or
|
||||
@@ -40,7 +29,7 @@ source .venv/bin/activate.fish # For fish
|
||||
|
||||
### Разработка
|
||||
|
||||
Для автоматической подготовки окружения (установка Python 3.11, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
|
||||
Для автоматической подготовки окружения (установка Python 3.10, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
|
||||
|
||||
```sh
|
||||
./dev-scripts/prepare_env.sh
|
||||
@@ -76,16 +65,13 @@ PortProtonQt использует код и зависимости от след
|
||||
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
|
||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
|
||||
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv2
|
||||
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — модуль загрузки игр GOG, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE).
|
||||
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — альтернатива Epic Games Launcher, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE).
|
||||
- [iat](https://sourceforge.net/projects/iat.berlios) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv3
|
||||
- [pyte](https://github.com/selectel/pyte) — разбор ANSI escape-кодов, лицензия [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file)
|
||||
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — основа данных раскладок виртуальной клавиатуры, лицензия GPLv3.
|
||||
- [omikuji](https://github.com/omikuji-launcher/omikuji) — источник вдохновения и материалов для процедурных фонов детальной страницы, лицензия [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE).
|
||||
- [Bottles](https://github.com/bottlesdevs/Bottles) — источник правил анализа совместимости, лицензия GPL-3.0-only.
|
||||
|
||||
Условия лицензии PortProtonQt приведены в файле [LICENSE](LICENSE). Лицензии
|
||||
сторонних проектов приведены в файле [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
|
||||
> [!WARNING]
|
||||
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Do not publish suspected vulnerability details in public issues, discussions,
|
||||
or pull requests. Send reports to [support@linux-gaming.ru](mailto:support@linux-gaming.ru).
|
||||
|
||||
Please include enough information to reproduce and assess the issue:
|
||||
|
||||
- PortProtonQt version or commit and installation source.
|
||||
- Linux distribution and relevant launcher or runtime details.
|
||||
- Affected feature and clear reproduction steps.
|
||||
- Expected behavior, observed behavior, and potential security impact.
|
||||
- A minimal proof of concept or sanitized logs, if available.
|
||||
- A suggested mitigation or fix, if known.
|
||||
|
||||
Do not include passwords, tokens, private keys, or personal data. Redact
|
||||
usernames, home directory paths, and other sensitive details from logs where
|
||||
practical. Test only with systems, accounts, and files you own or are
|
||||
authorized to use.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers security issues in PortProtonQt, including its handling of
|
||||
local files and archives, launcher data, network requests, downloaded content,
|
||||
and application configuration. Issues in third-party launchers, libraries,
|
||||
compatibility tools, or distribution packages should also be reported to their
|
||||
maintainers. Please report cases where PortProtonQt's handling creates or
|
||||
increases the risk.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Please check whether the issue affects the latest published release and
|
||||
include the affected version in your report. Older releases may not receive
|
||||
security fixes; users should update when a fix becomes available.
|
||||
|
||||
## Coordinated disclosure
|
||||
|
||||
Keep vulnerability details private while maintainers investigate and prepare a
|
||||
fix or mitigation. Coordinate public disclosure with the maintainers, allowing
|
||||
reasonable time for a fix to reach users. Response and remediation times depend
|
||||
on the issue; no fixed response time is promised.
|
||||
-1278
File diff suppressed because it is too large.
Load diff
@@ -52,6 +52,7 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
|
||||
|
||||
if [ "$LOCAL_MODE" = true ]; then
|
||||
echo "Using local PKGBUILD-git from repository..."
|
||||
@@ -103,7 +104,9 @@ makepkg -si --noconfirm
|
||||
|
||||
echo "Installing debloated packages..."
|
||||
echo "---------------------------------------------------------------"
|
||||
get-debloated-pkgs --add-common --prefer-nano
|
||||
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
|
||||
chmod +x ./get-debloated-pkgs.sh
|
||||
./get-debloated-pkgs.sh --add-common --prefer-nano
|
||||
|
||||
if [ "$LOCAL_MODE" = true ]; then
|
||||
# For git version, we use portprotonqt-git
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
set -eu
|
||||
|
||||
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
|
||||
ARCH="$(uname -m)"
|
||||
VERSION="$(cat ~/version)"
|
||||
export ARCH VERSION
|
||||
@@ -19,12 +20,16 @@ export OPTIMIZE_LAUNCH=1
|
||||
# Adjust comp settings to bypass oom-killer
|
||||
export DWARFS_COMP="zstd:level=15 -S22 -B5"
|
||||
|
||||
# DEPLOY ALL LIBS
|
||||
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
|
||||
chmod +x ./quick-sharun
|
||||
|
||||
# Add udev rules
|
||||
mkdir -p ./AppDir/etc/udev/rules.d
|
||||
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
|
||||
|
||||
# Add PortProton scripts
|
||||
# Copy manually to avoid tracing 208 .ppdb files with quick-sharun
|
||||
# Copy manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
|
||||
mkdir -p ./AppDir/share
|
||||
cp -r /usr/share/portproton ./AppDir/share
|
||||
|
||||
@@ -38,7 +43,7 @@ GAMEPAD_LIBRARY=$(find /usr/lib -type f \
|
||||
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
|
||||
-print -quit)
|
||||
test -n "$GAMEPAD_LIBRARY"
|
||||
quick-sharun \
|
||||
./quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/bin/bash \
|
||||
/usr/bin/update-desktop-database \
|
||||
@@ -98,4 +103,4 @@ done
|
||||
rm -rf "$PYSIDE_DIR"/QtAsyncio
|
||||
|
||||
# Turn AppDir into AppImage
|
||||
quick-sharun --make-appimage
|
||||
./quick-sharun --make-appimage
|
||||
+15
-15
@@ -1,26 +1,16 @@
|
||||
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
|
||||
|
||||
pkgname=portprotonqt
|
||||
pkgver=1.5.0
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.4.1
|
||||
pkgrel=1
|
||||
pkgdesc="Game library manager and launcher for Linux"
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
arch=('x86_64')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
conflicts=("portprotonqt-steam-compat")
|
||||
provides=('portprotonqt-steam-compat')
|
||||
replaces=('portprotonqt-steam-compat')
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt#tag=v$pkgver")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -29,7 +19,17 @@ build() {
|
||||
meson compile -C build
|
||||
}
|
||||
|
||||
package() {
|
||||
package_portprotonqt() {
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
|
||||
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
|
||||
|
||||
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
|
||||
|
||||
+14
-14
@@ -1,26 +1,16 @@
|
||||
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
|
||||
|
||||
pkgname=portprotonqt-git
|
||||
pkgname=('portprotonqt-git')
|
||||
pkgver=.
|
||||
pkgrel=1
|
||||
pkgdesc="Game library manager and launcher for Linux (development build)"
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
|
||||
arch=('x86_64')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
conflicts=("portprotonqt-steam-compat")
|
||||
provides=('portprotonqt-steam-compat')
|
||||
replaces=('portprotonqt-steam-compat')
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -34,7 +24,17 @@ build() {
|
||||
meson compile -C build
|
||||
}
|
||||
|
||||
package() {
|
||||
package_portprotonqt-git() {
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
|
||||
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
|
||||
|
||||
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.5.0
|
||||
%global pypi_version 1.4.1
|
||||
%global oname PortProtonQt
|
||||
%global build_timestamp %(date +"%Y%m%d")
|
||||
%global _python_no_extras_requires 1
|
||||
@@ -9,7 +9,7 @@
|
||||
Name: %{pypi_name}-git
|
||||
Version: %{pypi_version}
|
||||
Release: %{rel_build}
|
||||
Summary: Game library manager and launcher for Linux (development build)
|
||||
Summary: Modern GUI for managing and launching games from PortProton and Steam (development build)
|
||||
|
||||
License: GPL-3.0
|
||||
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
|
||||
@@ -81,7 +81,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
|
||||
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
|
||||
|
||||
%description
|
||||
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
|
||||
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
|
||||
%{?python_disable_dependency_generator}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.5.0
|
||||
%global pypi_version 1.4.1
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
Name: %{pypi_name}
|
||||
Version: %{pypi_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: Game library manager and launcher for Linux
|
||||
Summary: Modern GUI for managing and launching games from PortProton and Steam
|
||||
|
||||
License: GPL-3.0
|
||||
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
|
||||
@@ -78,7 +78,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
|
||||
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
|
||||
|
||||
%description
|
||||
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
|
||||
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
|
||||
%{?python_disable_dependency_generator}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -2,11 +2,11 @@
|
||||
Name=PortProtonQt
|
||||
Exec=portprotonqt %u
|
||||
Type=Application
|
||||
Comment=Game library manager and launcher for Linux
|
||||
Comment[ru]=Приложение для управления игровой библиотекой и запуска игр на Linux
|
||||
Comment[es]=Gestor de biblioteca de juegos y lanzador para Linux
|
||||
Comment[pt]=Gerenciador de biblioteca de jogos e inicializador para Linux
|
||||
Comment[de]=Spielebibliotheksverwaltung und Spielestarter für Linux
|
||||
Comment=A modern GUI for PortProton project
|
||||
Comment[ru]=Современный графический интерфейс для проекта PortProton
|
||||
Comment[es]=Una interfaz gráfica moderna para el proyecto PortProton
|
||||
Comment[pt]=Uma interface gráfica moderna para o projeto PortProton
|
||||
Comment[de]=Eine moderne grafische Benutzeroberfläche für das PortProton-Projekt
|
||||
Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<summary>Game library manager and launcher for Linux</summary>
|
||||
<summary xml:lang="ru">Приложение для управления игровой библиотекой и запуска игр на Linux</summary>
|
||||
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
|
||||
A modern and intuitive interface for managing and launching games from PortProton, Steam, and GOG. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
</p>
|
||||
<p xml:lang="ru">
|
||||
Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
@@ -24,11 +24,8 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.5.0" date="2026-10-14">
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.4.1" date="2026-08-26">
|
||||
<description/>
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.4.0" date="2026-08-23">
|
||||
<description/>
|
||||
|
||||
@@ -16,10 +16,4 @@
|
||||
<sub-class-of type="text/plain"/>
|
||||
<glob pattern="*.ppdb" weight="100"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/x-portproton-user-conf">
|
||||
<comment>PortProton user configuration</comment>
|
||||
<comment xml:lang="ru">Пользовательская конфигурация PortProton</comment>
|
||||
<sub-class-of type="text/plain"/>
|
||||
<glob pattern="user.conf" weight="100"/>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
@@ -20,30 +20,7 @@ python_module () {
|
||||
local python3_module="$1"
|
||||
shift
|
||||
print_info "Running python3 module: $python3_module $@" >&2
|
||||
"${PORTPROTONQT_PYTHON:-python3}" -m portprotonqt.scripts_utils.$python3_module "$@"
|
||||
}
|
||||
|
||||
pw_alt_i586_dependencies () {
|
||||
local action="${1:-check}" installed list package
|
||||
installed="$(rpm -qa --qf '%{NAME}\n')" || return 2
|
||||
list=""
|
||||
|
||||
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libfreetype \
|
||||
$(grep '^libnss-' <<< "$installed" | grep -v '^libnss-fallback$') \
|
||||
$(grep '^xorg-dri-' <<< "$installed"); do
|
||||
if grep -Fqx "$package" <<< "$installed" \
|
||||
&& ! grep -Fqx "i586-$package" <<< "$installed" ; then
|
||||
list+=" i586-$package"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$action" == "check" ]] ; then
|
||||
[[ -z "$list" ]]
|
||||
return $?
|
||||
fi
|
||||
|
||||
[[ -z "$list" ]] && return 0
|
||||
apt-get update && apt-get install -y $list
|
||||
python3 -m portprotonqt.scripts_utils.$python3_module "$@"
|
||||
}
|
||||
|
||||
python_term () {
|
||||
@@ -177,7 +154,7 @@ pw_enable_idle_inhibit() {
|
||||
return
|
||||
fi
|
||||
|
||||
PW_INHIBIT_SLR="${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.idle_inhibit"
|
||||
PW_INHIBIT_SLR="python3 -m portprotonqt.scripts_utils.idle_inhibit"
|
||||
print_info "Screensaver will be inhibited using D-Bus"
|
||||
}
|
||||
|
||||
@@ -556,6 +533,21 @@ try_download () {
|
||||
return 0
|
||||
}
|
||||
|
||||
prepare_epic_wrapper () {
|
||||
local expected_hash="1feb21e21e19cbb34e881791c8f2557769f59231e46850915d49a6d0c1ce4583"
|
||||
export PW_EPIC_WRAPPER_EXE="${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}/epic/EpicGamesLauncher.exe"
|
||||
if [[ ! -f "$PW_EPIC_WRAPPER_EXE" ]] ; then
|
||||
create_new_dir "$(dirname "$PW_EPIC_WRAPPER_EXE")"
|
||||
try_download \
|
||||
"https://github.com/BananaWorks07/heroic-epic-integration/releases/download/v${PW_EPIC_WRAPPER_VER}/EpicGamesLauncher.exe" \
|
||||
"$PW_EPIC_WRAPPER_EXE" no_mirror || return 1
|
||||
fi
|
||||
if [[ "$(sha256sum "$PW_EPIC_WRAPPER_EXE" | awk '{print $1}')" != "$expected_hash" ]] ; then
|
||||
try_remove_file "$PW_EPIC_WRAPPER_EXE"
|
||||
fatal "Epic launcher wrapper checksum verification failed"
|
||||
fi
|
||||
}
|
||||
|
||||
var_winedlloverride_update () {
|
||||
export WINEDLLOVERRIDES="${1}${WINEDLLOVERRIDES:+;$WINEDLLOVERRIDES}"
|
||||
}
|
||||
@@ -615,20 +607,6 @@ check_wayland_session () {
|
||||
}
|
||||
export -f check_wayland_session
|
||||
|
||||
check_arm () {
|
||||
if [[ -n "$ARM_IN_USE" ]]
|
||||
then [[ "$ARM_IN_USE" == 1 ]] && return 0 || return 1
|
||||
fi
|
||||
if [[ "$(uname -m)" =~ ^(aarch64|arm64)$ ]] ; then
|
||||
export ARM_IN_USE=1
|
||||
return 0
|
||||
else
|
||||
export ARM_IN_USE=0
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
export -f check_arm
|
||||
|
||||
check_flatpak () {
|
||||
if [[ -n "$FLATPAK_IN_USE" ]]
|
||||
then [[ "$FLATPAK_IN_USE" == 1 ]] && return 0 || return 1
|
||||
@@ -1101,7 +1079,6 @@ EOF
|
||||
-e '/export PW_SOUND_DRIVER_USE=/d' \
|
||||
"$USER_CONF"
|
||||
fi
|
||||
chmod 644 "${USER_CONF}"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$USER_CONF"
|
||||
@@ -1154,12 +1131,7 @@ init_wine_ver () {
|
||||
export WINEDIR="${WINEDIR}/files"
|
||||
fi
|
||||
|
||||
if [[ -d "${WINEDIR}/bin-arm64" ]]
|
||||
then
|
||||
export WINE="${WINEDIR}/bin-arm64/wine"
|
||||
export WINELOADER="${WINEDIR}/bin-arm64/wine"
|
||||
export WINESERVER="${WINEDIR}/bin-arm64/wineserver"
|
||||
elif [[ "$PW_USE_WOW64" == "1" ]] \
|
||||
if [[ "$PW_USE_WOW64" == "1" ]] \
|
||||
&& [[ -d "${WINEDIR}/bin-wow64" ]]
|
||||
then
|
||||
print_warning "wine wow64 mode in use!"
|
||||
@@ -1205,9 +1177,7 @@ init_wine_ver () {
|
||||
|
||||
if ! check_flatpak ; then
|
||||
WINE_LIBRARY_PATH="${WINEDIR}/lib"
|
||||
if [[ -d "${WINEDIR}/lib/aarch64-linux-gnu" ]]
|
||||
then WINE_LIBRARY_PATH+=":${WINEDIR}/lib/aarch64-linux-gnu:${WINEDIR}/lib/x86_64-linux-gnu:${WINEDIR}/lib/i386-linux-gnu"
|
||||
elif [[ -d "${WINEDIR}/lib64/gstreamer-1.0" ]]
|
||||
if [[ -d "${WINEDIR}/lib64/gstreamer-1.0" ]]
|
||||
then WINE_LIBRARY_PATH+=":${WINEDIR}/lib64"
|
||||
elif [[ -d "${WINEDIR}/lib/x86_64-linux-gnu" ]]
|
||||
then WINE_LIBRARY_PATH+=":${WINEDIR}/lib/x86_64-linux-gnu:${WINEDIR}/lib/i386-linux-gnu"
|
||||
@@ -1220,19 +1190,10 @@ init_wine_ver () {
|
||||
if [[ -d "${WINEDIR}"/share/espeak-ng-data ]] ; then
|
||||
export ESPEAK_DATA_PATH="${WINEDIR}"/share/
|
||||
fi
|
||||
if [[ -f "${WINEDIR}/share/fex-emu/Config.json" ]] \
|
||||
&& [[ -f "${WINEDIR}/lib/wine/aarch64-unix/libwow64fex.so" ]]
|
||||
then
|
||||
export FEX_APP_CONFIG="${FEX_APP_CONFIG:-${WINEDIR}/share/fex-emu/Config.json}"
|
||||
export FEX_APP_CONFIG_LOCATION="${FEX_APP_CONFIG_LOCATION:-${WINEDIR}/share/fex-emu}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
pw_init_runtime () {
|
||||
if check_arm ; then
|
||||
export PW_USE_RUNTIME="0"
|
||||
fi
|
||||
if [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|
||||
|| [[ ! -f "${PW_WINELIB}/pressure-vessel/bin/pressure-vessel-wrap" ]]
|
||||
then
|
||||
@@ -1241,14 +1202,10 @@ pw_init_runtime () {
|
||||
if [[ "$PW_USE_RUNTIME" != "1" ]] \
|
||||
|| [[ "$PW_WINE_USE" == "USE_SYSTEM_WINE" ]]
|
||||
then
|
||||
if check_arm ; then
|
||||
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"
|
||||
else
|
||||
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
|
||||
fi
|
||||
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
|
||||
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
|
||||
if [[ -z "$PW_TERM" ]] ; then
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
|
||||
fi
|
||||
export pw_runtime="env"
|
||||
return 0
|
||||
@@ -1278,13 +1235,7 @@ ${nvidia_lib32_path}"
|
||||
PW_PV_OVERRIDES="/usr/lib/pressure-vessel/overrides/lib"
|
||||
|
||||
if ! check_flatpak; then
|
||||
if check_arm ; then
|
||||
export PW_LD_LIBRARY_PATH="\
|
||||
${PW_PV_OVERRIDES}/aarch64-linux-gnu/aliases:\
|
||||
/overrides/lib/aarch64-linux-gnu:\
|
||||
/lib/aarch64-linux-gnu"
|
||||
else
|
||||
export PW_LD_LIBRARY_PATH="\
|
||||
export PW_LD_LIBRARY_PATH="\
|
||||
${PW_PLUGINS_PATH}/portable/lib/lib64:\
|
||||
${PW_PLUGINS_PATH}/portable/lib/lib32:\
|
||||
${PW_PV_OVERRIDES}/x86_64-linux-gnu/aliases:\
|
||||
@@ -1293,7 +1244,6 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
|
||||
/overrides/lib/i386-linux-gnu:\
|
||||
/lib/x86_64-linux-gnu:\
|
||||
/lib/i386-linux-gnu"
|
||||
fi
|
||||
fi
|
||||
|
||||
export PW_PATH="${PW_PLUGINS_PATH}/portable/bin${PATH:+:$PATH}"
|
||||
@@ -1336,11 +1286,7 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
|
||||
export PRESSURE_VESSEL_RUNTIME_BASE="${PW_WINELIB}"
|
||||
export PRESSURE_VESSEL_VARIABLE_DIR="${PW_WINELIB}/var"
|
||||
export PRESSURE_VESSEL_PREFIX="${PW_WINELIB}/pressure-vessel"
|
||||
if check_arm ; then
|
||||
export PRESSURE_VESSEL_ARCHITECTURES="aarch64-linux-gnu"
|
||||
else
|
||||
export PRESSURE_VESSEL_ARCHITECTURES="x86_64-linux-gnu:i386-linux-gnu"
|
||||
fi
|
||||
export PRESSURE_VESSEL_ARCHITECTURES="x86_64-linux-gnu:i386-linux-gnu"
|
||||
export FONTCONFIG_PATH=''
|
||||
|
||||
unset PRESSURE_VESSEL_FILESYSTEMS_RO PRESSURE_VESSEL_FILESYSTEMS_RW
|
||||
@@ -1350,7 +1296,7 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
|
||||
for PWRTMRW in "${PW_RT_MOUNT_RW[@]}" ; do
|
||||
export PRESSURE_VESSEL_FILESYSTEMS_RW="${PRESSURE_VESSEL_FILESYSTEMS_RW:+$PRESSURE_VESSEL_FILESYSTEMS_RW:}${PWRTMRW}"
|
||||
done
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
|
||||
|
||||
print_info "RUNTIME is enabled"
|
||||
}
|
||||
@@ -1559,25 +1505,14 @@ kill_portwine () {
|
||||
bwrap_pids=$(pgrep -f wrap | grep PortProton | head -n 1)
|
||||
else
|
||||
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -ie PortProton | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
|
||||
bwrap_pids="$(pgrep -a wrap | grep PortProton | awk '{print $1}')"
|
||||
bwrap_pids="$(pgrep -a wrap | grep PortProton | head -n 1 | awk '{print $1}')"
|
||||
fi
|
||||
else
|
||||
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
|
||||
unset bwrap_pids
|
||||
fi
|
||||
|
||||
# bwrap hosts steam-run's FHS container on NixOS.
|
||||
# Killing an ancestor would terminate this launch with the container.
|
||||
local pw_ancestor_pids=" " pw_ancestor_pid=$BASHPID
|
||||
if [[ -n "${bwrap_pids:-}" ]] ; then
|
||||
while [[ -n "$pw_ancestor_pid" && "$pw_ancestor_pid" -gt 1 ]] ; do
|
||||
pw_ancestor_pids+="$pw_ancestor_pid "
|
||||
pw_ancestor_pid=$(ps -o ppid= -p "$pw_ancestor_pid" 2>/dev/null | tr -d '[:space:]')
|
||||
done
|
||||
fi
|
||||
|
||||
for pw_kill_pids in ${bwrap_pids} ${wine_pids}; do
|
||||
[[ "$pw_ancestor_pids" == *" $pw_kill_pids "* ]] && continue
|
||||
if ps cax | grep "${pw_kill_pids}" ; then
|
||||
kill -n 9 "${pw_kill_pids}" &>/dev/null
|
||||
fi
|
||||
@@ -1696,20 +1631,8 @@ pw_check_and_download_runtime () {
|
||||
}
|
||||
|
||||
pw_check_and_download_dxvk_and_vkd3d () {
|
||||
if check_arm ; then
|
||||
# ARM64 Wine runners provide compatible DXVK and VKD3D builds.
|
||||
# Build ARM64 dxvk using cross-compiler
|
||||
export PW_USE_SUPPLIED_DXVK_VKD3D="1"
|
||||
return 0
|
||||
fi
|
||||
case "${PW_VULKAN_USE}" in
|
||||
1) DXVK_VAR_VER="${DXVK_SAREK_VER}"; VKD3D_VAR_VER="${VKD3D_SAREK_VER}" ;;
|
||||
2) DXVK_VAR_VER="${DXVK_OLD_VER}"; VKD3D_VAR_VER="${VKD3D_OLD_VER}" ;;
|
||||
6) DXVK_VAR_VER="${DXVK_NEW_VER}"; VKD3D_VAR_VER="${VKD3D_NEW_VER}" ;;
|
||||
*) DXVK_VAR_VER=""; VKD3D_VAR_VER="" ;;
|
||||
esac
|
||||
# Download DXVK
|
||||
if [[ -n "${DXVK_VAR_VER}" ]] ; then
|
||||
for DXVK_VAR_VER in "${DXVK_SAREK_VER}" "${DXVK_OLD_VER}" "${DXVK_NEW_VER}" ; do
|
||||
if [[ ! -d "${PW_VULKAN_DIR}/dxvk-${DXVK_VAR_VER}" ]] ; then
|
||||
print_info "Download and install DXVK v.${DXVK_VAR_VER}"
|
||||
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/dxvk-${DXVK_VAR_VER}/dxvk-${DXVK_VAR_VER}.tar.xz" \
|
||||
@@ -1736,10 +1659,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
|
||||
fatal "Failed to download and unpack dxvk"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Download VKD3D
|
||||
if [[ -n "${VKD3D_VAR_VER}" ]] ; then
|
||||
for VKD3D_VAR_VER in "${VKD3D_SAREK_VER}" "${VKD3D_OLD_VER}" "${VKD3D_NEW_VER}" ; do
|
||||
if [[ ! -d "${PW_VULKAN_DIR}/vkd3d-proton-${VKD3D_VAR_VER}" ]] ; then
|
||||
print_info "Download and install VKD3D-PROTON v.${VKD3D_VAR_VER}"
|
||||
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/vkd3d-proton-${VKD3D_VAR_VER}/vkd3d-proton-${VKD3D_VAR_VER}.tar.xz" \
|
||||
@@ -1764,10 +1687,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
|
||||
fatal "Failed to download and unpack VKD3D"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Download D7VK
|
||||
if [[ "${PW_USE_D7VK}" == "1" && ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
|
||||
if [[ ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
|
||||
print_info "Download and install D7VK v.${D7VK_VAR_VER}"
|
||||
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/d7vk-${D7VK_VAR_VER}/d7vk-${D7VK_VAR_VER}.tar.xz" \
|
||||
"${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}.tar.xz" ; then
|
||||
@@ -1796,10 +1719,6 @@ pw_check_and_download_dxvk_and_vkd3d () {
|
||||
}
|
||||
|
||||
pw_check_and_download_plugins () {
|
||||
if check_arm ; then
|
||||
# ARM64 plugins archive needs an architecture-specific rebuild.
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "${PW_PLUGINS_PATH}/portable/lib/lib64/libvkbasalt.so" ]] ; then
|
||||
print_info "Download and install plugins${PW_PLUGINS_VER}..."
|
||||
if try_download "github.com/Castro-Fidel/wine_builds/releases/download/plugins${PW_PLUGINS_VER}/plugins${PW_PLUGINS_VER}.tar.xz" "${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}.tar.xz" ; then
|
||||
@@ -1915,6 +1834,11 @@ pw_init_db () {
|
||||
[[ -n "${PW_DEFAULT_PREFIX_NAME:-}" ]] && export PW_PREFIX_NAME="${PW_DEFAULT_PREFIX_NAME}"
|
||||
[[ -n "${PW_DEFAULT_VULKAN_USE:-}" ]] && export PW_VULKAN_USE="${PW_DEFAULT_VULKAN_USE}"
|
||||
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
|
||||
export PW_USE_NVAPI_AND_DLSS="1"
|
||||
export PW_USE_RAY_TRACING="1"
|
||||
fi
|
||||
|
||||
if [[ -f "${PW_EXE_FILE}" ]] ; then
|
||||
PORTWINE_DB="$(basename "${PW_EXE_FILE%.[Ee][Xx][Ee]}")"
|
||||
export PORTWINE_DB
|
||||
@@ -2265,8 +2189,8 @@ manage_user_conf_value () {
|
||||
local var_name="$2"
|
||||
local var_value="$3"
|
||||
|
||||
if [[ -z "${action}" ]]; then
|
||||
print_error "Action must be provided"
|
||||
if [[ -z "${action}" ]] || [[ -z "${var_name}" ]]; then
|
||||
print_error "Action and variable name must be provided"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -2277,11 +2201,6 @@ manage_user_conf_value () {
|
||||
|
||||
case "$action" in
|
||||
get)
|
||||
if [[ -z "${var_name}" ]]; then
|
||||
grep -E '^export [A-Za-z_][A-Za-z0-9_]*=' "${USER_CONF}" 2>/dev/null \
|
||||
| sed -E 's/^export //' || true
|
||||
return 0
|
||||
fi
|
||||
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
|
||||
# Extract the value, handling both quoted and unquoted values
|
||||
local value=$(grep "^export ${var_name}=" "${USER_CONF}" | sed -E 's/^export [^=]+=("?)(.*)\1$/\2/')
|
||||
@@ -2293,10 +2212,6 @@ manage_user_conf_value () {
|
||||
fi
|
||||
;;
|
||||
set)
|
||||
if [[ -z "${var_name}" ]]; then
|
||||
print_error "Variable name must be provided"
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${var_value}" ]]; then
|
||||
print_error "Variable value must be provided for set action"
|
||||
return 1
|
||||
@@ -2307,10 +2222,6 @@ manage_user_conf_value () {
|
||||
return 0
|
||||
;;
|
||||
delete)
|
||||
if [[ -z "${var_name}" ]]; then
|
||||
print_error "Variable name must be provided"
|
||||
return 1
|
||||
fi
|
||||
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
|
||||
sed -i "/^export ${var_name}=/d" "${USER_CONF}"
|
||||
print_info "Removed ${var_name} from USER.conf"
|
||||
@@ -2326,14 +2237,9 @@ manage_user_conf_value () {
|
||||
|
||||
use_exiftool () {
|
||||
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|
||||
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
printf '%s\n' "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if [[ "${1,,}" == *.exe ]] \
|
||||
&& python_module exe_metadata "$1" >> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if ! command -v exiftool &>/dev/null \
|
||||
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
@@ -2678,16 +2584,21 @@ pw_enable_gamemode_basic () {
|
||||
if check_flatpak ; then
|
||||
export GAMEMODERUN=1
|
||||
PW_GAMEMODERUN_SLR="gamemoderun"
|
||||
elif command -v gamemoderun &>/dev/null ; then
|
||||
export GAMEMODERUN=1
|
||||
PW_GAMEMODERUN_SLR="gamemoderun"
|
||||
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
|
||||
gamemoded &>/dev/null &
|
||||
sleep 0.1
|
||||
fi
|
||||
else
|
||||
export GAMEMODERUN=1
|
||||
local GAMEMODEAUTO_NAME="libgamemodeauto.so.0"
|
||||
export PW_LD_PRELOAD="${GAMEMODEAUTO_NAME}${PW_LD_PRELOAD:+:$PW_LD_PRELOAD}"
|
||||
if command -v gamemoderun &>/dev/null ; then
|
||||
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
|
||||
gamemoded &>/dev/null &
|
||||
sleep 0.1
|
||||
fi
|
||||
elif ! pw_is_process_active gamemoded ; then
|
||||
if [[ -n "${PW_LD_PRELOAD}" ]]; then
|
||||
export PW_LD_PRELOAD="${PW_LD_PRELOAD}:libgamemodeauto.so.0"
|
||||
else
|
||||
export PW_LD_PRELOAD="libgamemodeauto.so.0"
|
||||
fi
|
||||
if ! pw_is_process_active gamemoded ; then
|
||||
env LD_LIBRARY_PATH="${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32" \
|
||||
"${PW_PLUGINS_PATH}/portable/bin/gamemoded" &>/dev/null &
|
||||
sleep 0.1
|
||||
@@ -2701,10 +2612,7 @@ pw_enable_gamemode_basic () {
|
||||
start_portproton () {
|
||||
pw_check_and_download_plugins
|
||||
|
||||
if check_arm ; then
|
||||
export PW_USE_RUNTIME="0"
|
||||
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
|
||||
elif [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
|
||||
if [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
|
||||
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
|
||||
else
|
||||
pw_check_and_download_runtime
|
||||
@@ -2713,10 +2621,6 @@ start_portproton () {
|
||||
pw_check_and_download_dxvk_and_vkd3d
|
||||
|
||||
pw_skip_get_info
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
|
||||
[[ -z "${PW_USE_NVAPI_AND_DLSS:-}" ]] && export PW_USE_NVAPI_AND_DLSS="1"
|
||||
[[ -z "${PW_USE_RAY_TRACING:-}" ]] && export PW_USE_RAY_TRACING="1"
|
||||
fi
|
||||
if [[ "${PW_LOCALE_SELECT}" != "disabled" ]] && [[ -n "${PW_LOCALE_SELECT}" ]] ; then
|
||||
export LC_ALL="${PW_LOCALE_SELECT}"
|
||||
if [[ "${PW_USE_RUNTIME}" == "1" ]] && [[ "${HOST_LC_ALL}" != "${LC_ALL}" ]] ; then
|
||||
@@ -2844,19 +2748,15 @@ start_portproton () {
|
||||
var_winedlloverride_update "winebth.sys=d"
|
||||
|
||||
pw_wineboot () {
|
||||
local wine_pe64_path="${WINEDIR}/lib64/wine/x86_64-windows"
|
||||
if [[ -d "${WINEDIR}/lib/wine/aarch64-windows" ]]
|
||||
then wine_pe64_path="${WINEDIR}/lib/wine/aarch64-windows"
|
||||
fi
|
||||
LIST_NATIVE_WINE_DLL_LINKS="winevulkan ir50_32 amd_ags_x64 wined3d vulkan-1"
|
||||
for dll in $LIST_NATIVE_WINE_DLL_LINKS ; do
|
||||
try_force_link_file "${wine_pe64_path}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib64/wine/x86_64-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
|
||||
done
|
||||
|
||||
LIST_NATIVE_WINE_DLL_COPY="atl100 ntdll shell32"
|
||||
for dll in $LIST_NATIVE_WINE_DLL_COPY ; do
|
||||
try_copy_file "${wine_pe64_path}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_copy_file "${WINEDIR}/lib64/wine/x86_64-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_copy_file "${WINEDIR}/lib/wine/i386-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
|
||||
done
|
||||
|
||||
@@ -2868,7 +2768,7 @@ start_portproton () {
|
||||
try_force_link_file "${WINEDIR}/lib64/icu/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
elif [[ -f "${WINEDIR}/lib/wine/icu/i386-windows/${dll}.dll" ]] ; then
|
||||
try_force_link_file "${WINEDIR}/lib/wine/icu/i386-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/icu/${wine_pe64_path##*/}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/icu/x86_64-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
else
|
||||
try_remove_file "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
|
||||
try_remove_file "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
@@ -2890,9 +2790,7 @@ start_portproton () {
|
||||
then
|
||||
pw_clear_pfx
|
||||
check_dirs_and_files_in_pfx
|
||||
if check_arm ; then
|
||||
pw_wineboot -i
|
||||
elif [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
|
||||
if [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
@@ -2992,9 +2890,7 @@ start_portproton () {
|
||||
|
||||
# export WINE_HIDE_AMD_GPU="1"
|
||||
export DXVK_NVAPI_DRIVER_VERSION="99999"
|
||||
if [[ $(check_vendor_gpu) != "nvidia" ]] ; then
|
||||
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
|
||||
fi
|
||||
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
|
||||
export PW_USE_NVAPI_AND_DLSS="1"
|
||||
export PW_USE_RAY_TRACING="1"
|
||||
;;
|
||||
@@ -3288,18 +3184,9 @@ start_portproton () {
|
||||
export __GL_YIELD="NOTHING"
|
||||
export mesa_glthread="true"
|
||||
|
||||
PW_SUPPLIED_PE64_ARCH="x86_64-windows"
|
||||
PW_WINE_PE64_PATH="${WINEDIR}/lib64/wine/x86_64-windows"
|
||||
if [[ -d "${WINEDIR}/lib/wine/aarch64-windows" ]]
|
||||
then
|
||||
PW_SUPPLIED_PE64_ARCH="aarch64-windows"
|
||||
PW_WINE_PE64_PATH="${WINEDIR}/lib/wine/aarch64-windows"
|
||||
export DXVK_ENABLE_NVAPI="0"
|
||||
fi
|
||||
|
||||
if [[ $PW_USE_SUPPLIED_DXVK_VKD3D == "1" ]] \
|
||||
&& [[ ! $PW_WINE_USE =~ (PROTON_LG|WINE_LG) ]] \
|
||||
&& [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d9.dll" || -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d9.dll" ]] ; then
|
||||
&& [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d9.dll" || -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d9.dll" ]] ; then
|
||||
if [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d10.dll" && -f "${WINEDIR}/lib64/wine/dxvk/d3d10_1.dll" ]] ; then
|
||||
CP_DXVK_FILES="d3d11 d3d10core d3d10_1 d3d10 d3d9 dxgi"
|
||||
CP_WINE_FILES=""
|
||||
@@ -3312,13 +3199,13 @@ start_portproton () {
|
||||
try_force_link_file "${WINEDIR}/lib/wine/d8vk/d3d8.dll" "${WINEPREFIX}/drive_c/windows/syswow64/d3d8.dll"
|
||||
try_force_link_file "${WINEDIR}/lib64/wine/d8vk/d3d8.dll" "${WINEPREFIX}/drive_c/windows/system32/d3d8.dll"
|
||||
var_winedlloverride_update "d3d8=n"
|
||||
elif [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d8.dll" || -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d8.dll" ]]
|
||||
elif [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d8.dll" || -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d8.dll" ]]
|
||||
then add_to_var CP_DXVK_FILES "d3d8"
|
||||
else add_to_var CP_WINE_FILES "d3d8"
|
||||
fi
|
||||
fi
|
||||
if [[ -f "${WINEDIR}/lib64/wine/vkd3d-proton/d3d12core.dll" ]] \
|
||||
|| [[ -f "${WINEDIR}/lib/wine/vkd3d-proton/${PW_SUPPLIED_PE64_ARCH}/d3d12core.dll" ]] ; then
|
||||
|| [[ -f "${WINEDIR}/lib/wine/vkd3d-proton/x86_64-windows/d3d12core.dll" ]] ; then
|
||||
CP_VKD3D_FILES="d3d12 d3d12core"
|
||||
elif [[ -f "${WINEDIR}/lib64/wine/vkd3d-proton/d3d12.dll" ]] ; then
|
||||
CP_VKD3D_FILES="d3d12"
|
||||
@@ -3326,7 +3213,7 @@ start_portproton () {
|
||||
else
|
||||
add_to_var CP_WINE_FILES "d3d12 d3d12core"
|
||||
fi
|
||||
[[ -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d9.dll" ]] && PW_USE_SUPPLIED_DXVK_VKD3D="2"
|
||||
[[ -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d9.dll" ]] && PW_USE_SUPPLIED_DXVK_VKD3D="2"
|
||||
else
|
||||
PW_USE_SUPPLIED_DXVK_VKD3D="0"
|
||||
fi
|
||||
@@ -3334,7 +3221,7 @@ start_portproton () {
|
||||
# DGVOODOO2 enable:
|
||||
if [[ "${PW_DGVOODOO2}" == "1" ]] \
|
||||
&& [[ "${PW_USE_SPECIALK}" != "1" ]] \
|
||||
&& [[ "${PW_USE_D7VK}" != "1" ]]
|
||||
&& [[ "${PW_USE_D7VK}" != "1" ]] \
|
||||
then
|
||||
if [[ ! -f "${PW_VULKAN_DIR}/dgvoodoo2-${DGV2_VER}/dgVoodoo.conf" ]] ; then
|
||||
print_info "Download and install DGVOODOO2 v.${DGV2_VER}"
|
||||
@@ -3564,23 +3451,16 @@ start_portproton () {
|
||||
|
||||
print_info "Try create symlink WINE files..."
|
||||
for copy_wine_dll in $CP_WINE_FILES ; do
|
||||
try_force_link_file "${PW_WINE_PE64_PATH}/${copy_wine_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${copy_wine_dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib64/wine/x86_64-windows/${copy_wine_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${copy_wine_dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/${copy_wine_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${copy_wine_dll}.dll"
|
||||
done
|
||||
|
||||
if [[ "${PW_USE_D7VK}" == "1" ]] ; then
|
||||
PATH_TO_D7VK_FILES="${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}"
|
||||
D7VK_FILE="${PATH_TO_D7VK_FILES}/x32/ddraw.dll"
|
||||
if [[ "$PW_USE_SUPPLIED_DXVK_VKD3D" == "2" ]] \
|
||||
&& [[ -f "${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll" ]] ; then
|
||||
D7VK_FILE="${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll"
|
||||
print_info "Bundled D7VK in use"
|
||||
else
|
||||
print_info "D7VK ${D7VK_VAR_VER} in use"
|
||||
fi
|
||||
print_info "D7VK ${D7VK_VAR_VER} in use"
|
||||
print_info "Try create symlink D7VK files..."
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw_.dll"
|
||||
try_force_link_file "${D7VK_FILE}" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
try_force_link_file "${PATH_TO_D7VK_FILES}/x32/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
var_winedlloverride_update "ddraw=n,b"
|
||||
else
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
@@ -3593,7 +3473,7 @@ start_portproton () {
|
||||
case "$PW_USE_SUPPLIED_DXVK_VKD3D" in
|
||||
2)
|
||||
try_force_link_file "${WINEDIR}/lib/wine/dxvk/i386-windows/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${wine_dxvk_dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_dxvk_dll}.dll" ;;
|
||||
try_force_link_file "${WINEDIR}/lib/wine/dxvk/x86_64-windows/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_dxvk_dll}.dll" ;;
|
||||
1)
|
||||
try_force_link_file "${WINEDIR}/lib/wine/dxvk/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${wine_dxvk_dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib64/wine/dxvk/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_dxvk_dll}.dll" ;;
|
||||
@@ -3640,7 +3520,7 @@ start_portproton () {
|
||||
2)
|
||||
if [[ $wine_vkd3d_dll =~ d3d12 ]] ; then
|
||||
try_force_link_file "${WINEDIR}/lib/wine/vkd3d-proton/i386-windows/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${wine_vkd3d_dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/vkd3d-proton/${PW_SUPPLIED_PE64_ARCH}/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_vkd3d_dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/vkd3d-proton/x86_64-windows/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_vkd3d_dll}.dll"
|
||||
fi ;;
|
||||
1)
|
||||
if [[ $wine_vkd3d_dll =~ d3d12 ]] ; then
|
||||
@@ -3871,19 +3751,10 @@ start_portproton () {
|
||||
|
||||
add_in_start_portproton
|
||||
|
||||
local steam_emu_language=""
|
||||
case "${PW_LOCALE_SELECT}" in
|
||||
ru_*) steam_emu_language="russian" ;;
|
||||
en_*) steam_emu_language="english" ;;
|
||||
zh_*) steam_emu_language="schinese" ;;
|
||||
ja_*) steam_emu_language="japanese" ;;
|
||||
ko_*) steam_emu_language="koreana" ;;
|
||||
disabled|"") steam_emu_language="$FULL_LN" ;;
|
||||
esac
|
||||
if [[ -n "$steam_emu_language" ]] ; then
|
||||
if [[ "$FULL_LN" == russian ]] ; then
|
||||
for chk_lang_and_uname in "steam_emu.ini" "steam_api.ini" "steam_api64.ini" "SteamConfig.ini" ; do
|
||||
if [[ -f "${PATH_TO_GAME}/${chk_lang_and_uname}" ]] ; then
|
||||
sed -i "/^Language/c\Language=${steam_emu_language}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^Language/c\Language=${FULL_LN}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^UserName/c\UserName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^PlayerName/c\PlayerName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
fi
|
||||
@@ -3920,7 +3791,6 @@ start_portproton () {
|
||||
}
|
||||
|
||||
pw_run () {
|
||||
local pw_game_exit_code
|
||||
if [[ "$PW_USE_TERMINAL" == 1 ]] \
|
||||
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
|
||||
then
|
||||
@@ -3987,7 +3857,6 @@ pw_run () {
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_ADD_VAR_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
|
||||
pw_game_exit_code=$?
|
||||
else
|
||||
if [[ "$PW_USE_TERMINAL" == 1 ]] ; then
|
||||
pw_init_runtime
|
||||
@@ -4008,7 +3877,6 @@ pw_run () {
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_ADD_VAR_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
|
||||
pw_game_exit_code=$?
|
||||
print_info "Update prefix log:"
|
||||
[[ -f "${PW_TMPFS_PATH}/update_pfx_log" ]] && cat "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
echo
|
||||
@@ -4038,7 +3906,6 @@ pw_run () {
|
||||
${PW_INHIBIT_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
|
||||
pw_game_exit_code=$?
|
||||
wait_wineserver
|
||||
else
|
||||
print_info "Update prefix log:"
|
||||
@@ -4059,12 +3926,9 @@ pw_run () {
|
||||
${PW_INHIBIT_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters}
|
||||
pw_game_exit_code=$?
|
||||
wait_wineserver
|
||||
fi
|
||||
fi
|
||||
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$pw_game_exit_code"
|
||||
return "$pw_game_exit_code"
|
||||
}
|
||||
export -f pw_run
|
||||
|
||||
@@ -4230,7 +4094,6 @@ steamplay_launch () {
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
|
||||
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
|
||||
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
|
||||
debug_timer --end -s "PW_TIME_IN_GAME"
|
||||
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
|
||||
@@ -4268,11 +4131,12 @@ portwine_launch () {
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
local run_after_dir run_after_name
|
||||
local pw_exe_file_win run_after_exe_win
|
||||
local pw_exe_file_win
|
||||
local wine_drive_c
|
||||
wait_delay="${PW_RUN_AFTER_DELAY:-3}"
|
||||
wait_delay_int="${wait_delay%%.*}"
|
||||
@@ -4291,33 +4155,27 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${PW_RUN_AFTER_EXE#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${PW_RUN_AFTER_EXE//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_exe_win="${run_after_exe_win//\//\\}"
|
||||
|
||||
cat > "${run_after_bat}" <<EOF
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
start "" "%PW_RUN_AFTER_MAIN_PATH%" ${LAUNCH_PARAMETERS}
|
||||
start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}
|
||||
timeout /t ${wait_delay_int} /nobreak
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
start "" /unix "${PW_RUN_AFTER_EXE}"
|
||||
EOF
|
||||
|
||||
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
*.exe)
|
||||
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
|
||||
if [[ -n "${PW_EPIC_WRAPPER_EXE:-}" ]] ; then
|
||||
pw_run "${PW_VD_TMP[@]}" "${PW_EPIC_WRAPPER_EXE}" \
|
||||
"${PW_EXE_FILE}" "$@"
|
||||
else
|
||||
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
|
||||
fi
|
||||
;;
|
||||
*.bat|*.cmd)
|
||||
PW_USE_TERMINAL=1
|
||||
@@ -4428,8 +4286,7 @@ gui_edit_db () {
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_SUPPLIED_DXVK_VKD3D"
|
||||
fi
|
||||
|
||||
if check_arm \
|
||||
|| [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|
||||
if [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|
||||
|| [[ ! -f "${PW_WINELIB}/pressure-vessel/bin/pressure-vessel-wrap" ]]
|
||||
then
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_RUNTIME"
|
||||
@@ -4509,9 +4366,7 @@ gui_edit_db () {
|
||||
|
||||
edit_db_from_gui "${PW_EDIT_DB_FINAL_LIST[@]}"
|
||||
|
||||
if [[ "${PW_MANGOHUD}" == "1" ]] \
|
||||
&& [[ "${PW_MANGOHUD_USER_CONF}" != "1" ]] \
|
||||
&& [[ -z "$MANGOHUD_CONFIG" ]] ; then
|
||||
if [[ -z "$MANGOHUD_CONFIG" ]] ; then
|
||||
MONITOR_HEIGHT="$(echo "$PW_SCREEN_RESOLUTION" | awk -F'x' '{print $2}')"
|
||||
MH_FONT_SIZE="font_size=$(( MONITOR_HEIGHT / 45 ))"
|
||||
|
||||
@@ -5055,7 +4910,7 @@ pw_create_prefix_backup_cli () {
|
||||
PW_PREFIX_NAME="$1"
|
||||
PW_PREFIX_TO_BACKUP="$2"
|
||||
|
||||
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
backup "${PW_PREFIX_NAME}" "${PW_PREFIX_TO_BACKUP}"
|
||||
return $?
|
||||
}
|
||||
@@ -5109,7 +4964,7 @@ pw_unpack_prefix () {
|
||||
return $?
|
||||
fi
|
||||
|
||||
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
restore "${PW_BACKUP_FILE}"
|
||||
return $?
|
||||
}
|
||||
@@ -6,19 +6,6 @@ export url_git="https://git.linux-gaming.ru/CastroFidel/PortWINE"
|
||||
########################################################################
|
||||
$PW_DEBUG
|
||||
|
||||
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
|
||||
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
|
||||
else
|
||||
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "cli" ]] \
|
||||
&& [[ "${2:-}" == "--alt-i586-dependencies" ]] ; then
|
||||
source "$PORT_SCRIPTS_PATH/functions_helper"
|
||||
pw_alt_i586_dependencies "${3:-}"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if [[ $(id -u) = 0 ]] \
|
||||
&& [[ ! -e "/userdata/system/batocera.conf" ]]
|
||||
then
|
||||
@@ -26,6 +13,12 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
|
||||
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
|
||||
else
|
||||
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
|
||||
fi
|
||||
|
||||
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
|
||||
|
||||
if [[ -z "$PORT_DATA_PATH" ]] ; then
|
||||
@@ -80,6 +73,9 @@ fi
|
||||
|
||||
export PW_START_PID="$$"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${PORT_SCRIPTS_PATH}/var"
|
||||
|
||||
read -r -a pw_full_command_line <<< "$0 $*"
|
||||
export pw_full_command_line
|
||||
export orig_IFS="$IFS"
|
||||
@@ -126,6 +122,9 @@ elif [[ "$1" =~ ^--(debug|launch|edit-db)$ && "${2,,}" =~ \.(exe|bat|cmd|msi|reg
|
||||
elif [[ -f "$OLDPWD/$2" ]] ; then
|
||||
PW_EXE_FILE="$(realpath -s "$OLDPWD/$2")"
|
||||
fi
|
||||
elif [[ "$1" == "--epic-wrapper" && "${2,,}" =~ \.exe$ ]] ; then
|
||||
prepare_epic_wrapper || fatal "Failed to prepare Epic launcher wrapper"
|
||||
PW_EXE_FILE="$(realpath -s "$2")"
|
||||
fi
|
||||
export PW_EXE_FILE
|
||||
|
||||
@@ -171,9 +170,6 @@ export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
|
||||
create_new_dir "${PW_VULKAN_DIR}"
|
||||
|
||||
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${PORT_SCRIPTS_PATH}/var"
|
||||
# HACK: Avoid inheriting a system scripts cwd in pressure-vessel.
|
||||
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
|
||||
|
||||
@@ -222,8 +218,8 @@ fi
|
||||
print_info "The first mirror in used: $MIRROR"
|
||||
|
||||
if check_gamescope_session
|
||||
then PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
|
||||
else PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
|
||||
then PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
|
||||
else PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm -e"
|
||||
fi
|
||||
|
||||
pw_cleanup () {
|
||||
@@ -273,7 +269,7 @@ case "$1" in
|
||||
--launch Launches the application immediately, requires the path to the .exe file
|
||||
--edit-db After the variable, the path to the .exe file is required and then the variables.
|
||||
(List their variables and values for example PW_MANGOHUD=1 PW_VKBASALT=0, etc.)
|
||||
--get-user-conf Get one or all values from user.conf file
|
||||
--get-user-conf Get a value from user.conf file, requires variable name
|
||||
--set-user-conf Set a value in user.conf file, requires variable name and value
|
||||
--del-user-conf Delete a value from user.conf file, requires variable name
|
||||
--list-db List all available database variables
|
||||
@@ -583,6 +579,10 @@ Usage examples:
|
||||
portwine_launch
|
||||
stop_portproton
|
||||
;;
|
||||
--epic-wrapper)
|
||||
portwine_launch "${@:3}"
|
||||
stop_portproton
|
||||
;;
|
||||
--stop)
|
||||
start_path="$(realpath "$0")"
|
||||
pgrep -f -- "${start_path}.*--autoinstall" | while read -r autoinstall_pid ; do
|
||||
|
||||
@@ -16,6 +16,7 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
|
||||
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
|
||||
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
|
||||
export PW_SPECIALK_VER="SK_26_5_15"
|
||||
export PW_EPIC_WRAPPER_VER="0.4"
|
||||
export PW_GALLIUM_NINE_VER="0.10"
|
||||
export DOTPFX_VER="48v9"
|
||||
export DEFPFX_VER="v1"
|
||||
@@ -62,7 +63,7 @@ check_variables PW_USE_GALLIUM_NINE "0"
|
||||
check_variables PW_USE_GALLIUM_ZINK "0"
|
||||
check_variables PW_USE_WINED3D_VULKAN "0"
|
||||
check_variables PW_USE_SUPPLIED_DXVK_VKD3D "1"
|
||||
check_variables PW_WINE_FULLSCREEN_FSR "0"
|
||||
check_variables PW_WINE_FULLSCREEN_FSR "1"
|
||||
check_variables WINE_FULLSCREEN_FSR_MODE "0"
|
||||
|
||||
###DXVK_AND_VKD3D###
|
||||
|
||||
+36
-142
@@ -3,119 +3,28 @@
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define AXIS_ACTIVATION_THRESHOLD 16000
|
||||
|
||||
typedef struct {
|
||||
SDL_Gamepad *gamepad;
|
||||
SDL_JoystickID instance_id;
|
||||
int sdl_type;
|
||||
bool buttons[SDL_GAMEPAD_BUTTON_COUNT];
|
||||
bool axes_active[SDL_GAMEPAD_AXIS_COUNT];
|
||||
} GamepadEntry;
|
||||
|
||||
typedef struct {
|
||||
GamepadEntry *entries;
|
||||
int count;
|
||||
SDL_JoystickID active_instance_id;
|
||||
} PortProtonGamepad;
|
||||
|
||||
static bool has_gamepad(const PortProtonGamepad *gamepads,
|
||||
SDL_JoystickID instance_id)
|
||||
{
|
||||
for (int index = 0; index < gamepads->count; index++) {
|
||||
if (gamepads->entries[index].instance_id == instance_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool add_gamepad(PortProtonGamepad *gamepads,
|
||||
SDL_JoystickID instance_id)
|
||||
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
|
||||
{
|
||||
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
|
||||
if (gamepad == NULL) {
|
||||
return false;
|
||||
}
|
||||
GamepadEntry *entries = realloc(
|
||||
gamepads->entries, sizeof(*entries) * (gamepads->count + 1));
|
||||
if (entries == NULL) {
|
||||
SDL_CloseGamepad(gamepad);
|
||||
return SDL_SetError("Failed to allocate gamepad handle");
|
||||
}
|
||||
gamepads->entries = entries;
|
||||
GamepadEntry entry = {0};
|
||||
entry.gamepad = gamepad;
|
||||
entry.instance_id = instance_id;
|
||||
entry.sdl_type = SDL_GetGamepadTypeForID(instance_id);
|
||||
entries[gamepads->count] = entry;
|
||||
gamepads->count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void remove_disconnected(PortProtonGamepad *gamepads)
|
||||
{
|
||||
int active_count = 0;
|
||||
for (int index = 0; index < gamepads->count; index++) {
|
||||
GamepadEntry entry = gamepads->entries[index];
|
||||
if (SDL_GamepadConnected(entry.gamepad)) {
|
||||
gamepads->entries[active_count++] = entry;
|
||||
} else {
|
||||
if (gamepads->active_instance_id == entry.instance_id) {
|
||||
gamepads->active_instance_id = 0;
|
||||
}
|
||||
SDL_CloseGamepad(entry.gamepad);
|
||||
}
|
||||
}
|
||||
gamepads->count = active_count;
|
||||
}
|
||||
|
||||
static void update_gamepads(PortProtonGamepad *gamepads)
|
||||
{
|
||||
SDL_PumpEvents();
|
||||
SDL_UpdateGamepads();
|
||||
remove_disconnected(gamepads);
|
||||
int count = 0;
|
||||
SDL_JoystickID *instance_ids = SDL_GetGamepads(&count);
|
||||
if (instance_ids == NULL) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < count; index++) {
|
||||
if (!has_gamepad(gamepads, instance_ids[index])) {
|
||||
add_gamepad(gamepads, instance_ids[index]);
|
||||
}
|
||||
}
|
||||
SDL_free(instance_ids);
|
||||
}
|
||||
|
||||
static bool update_entry_activity(GamepadEntry *entry)
|
||||
{
|
||||
bool activated = false;
|
||||
for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; button++) {
|
||||
bool pressed = SDL_GetGamepadButton(entry->gamepad, button);
|
||||
activated |= pressed && !entry->buttons[button];
|
||||
entry->buttons[button] = pressed;
|
||||
}
|
||||
for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; axis++) {
|
||||
int value = SDL_GetGamepadAxis(entry->gamepad, axis);
|
||||
bool active = abs(value) >= AXIS_ACTIVATION_THRESHOLD;
|
||||
activated |= active && !entry->axes_active[axis];
|
||||
entry->axes_active[axis] = active;
|
||||
}
|
||||
return activated;
|
||||
}
|
||||
|
||||
static const GamepadEntry *get_active_gamepad(const PortProtonGamepad *gamepads)
|
||||
{
|
||||
if (gamepads == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (int index = 0; index < gamepads->count; index++) {
|
||||
if (gamepads->entries[index].instance_id == gamepads->active_instance_id) {
|
||||
return &gamepads->entries[index];
|
||||
}
|
||||
PortProtonGamepad *result = malloc(sizeof(*result));
|
||||
if (result == NULL) {
|
||||
SDL_CloseGamepad(gamepad);
|
||||
SDL_SetError("Failed to allocate gamepad handle");
|
||||
return NULL;
|
||||
}
|
||||
return NULL;
|
||||
result->gamepad = gamepad;
|
||||
result->instance_id = instance_id;
|
||||
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
|
||||
return result;
|
||||
}
|
||||
|
||||
PortProtonGamepad *portproton_gamepad_find(void)
|
||||
@@ -124,17 +33,23 @@ PortProtonGamepad *portproton_gamepad_find(void)
|
||||
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
|
||||
return NULL;
|
||||
}
|
||||
PortProtonGamepad *result = calloc(1, sizeof(*result));
|
||||
if (result == NULL) {
|
||||
SDL_SetError("Failed to allocate gamepad collection");
|
||||
SDL_PumpEvents();
|
||||
SDL_UpdateGamepads();
|
||||
int count = 0;
|
||||
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
|
||||
if (gamepads == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
update_gamepads(result);
|
||||
if (result->count == 0) {
|
||||
free(result);
|
||||
if (count == 0) {
|
||||
SDL_free(gamepads);
|
||||
SDL_ClearError();
|
||||
return NULL;
|
||||
}
|
||||
PortProtonGamepad *result = NULL;
|
||||
for (int index = 0; index < count && result == NULL; index++) {
|
||||
result = open_gamepad(gamepads[index]);
|
||||
}
|
||||
SDL_free(gamepads);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -149,77 +64,56 @@ void portproton_gamepad_close(PortProtonGamepad *gamepad)
|
||||
if (gamepad == NULL) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < gamepad->count; index++) {
|
||||
SDL_CloseGamepad(gamepad->entries[index].gamepad);
|
||||
if (gamepad->gamepad != NULL) {
|
||||
SDL_CloseGamepad(gamepad->gamepad);
|
||||
}
|
||||
free(gamepad->entries);
|
||||
free(gamepad);
|
||||
}
|
||||
|
||||
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL && gamepad->count > 0;
|
||||
return gamepad != NULL && gamepad->gamepad != NULL &&
|
||||
SDL_GamepadConnected(gamepad->gamepad);
|
||||
}
|
||||
|
||||
void portproton_gamepad_update(PortProtonGamepad *gamepad)
|
||||
void portproton_gamepad_update(void)
|
||||
{
|
||||
if (gamepad != NULL) {
|
||||
update_gamepads(gamepad);
|
||||
for (int index = 0; index < gamepad->count; index++) {
|
||||
if (update_entry_activity(&gamepad->entries[index])) {
|
||||
gamepad->active_instance_id = gamepad->entries[index].instance_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
SDL_UpdateGamepads();
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
|
||||
{
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active == NULL) {
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadButton(active->gamepad, button);
|
||||
return SDL_GetGamepadButton(gamepad->gamepad, button);
|
||||
}
|
||||
|
||||
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
|
||||
{
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active == NULL) {
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadAxis(active->gamepad, axis);
|
||||
}
|
||||
|
||||
uint32_t portproton_gamepad_get_active_instance_id(
|
||||
const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL ? gamepad->active_instance_id : 0;
|
||||
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
|
||||
}
|
||||
|
||||
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->count == 0) {
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
return "";
|
||||
}
|
||||
const char *name = SDL_GetGamepadName(gamepad->entries[0].gamepad);
|
||||
const char *name = SDL_GetGamepadName(gamepad->gamepad);
|
||||
return name != NULL ? name : "";
|
||||
}
|
||||
|
||||
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL && gamepad->count > 0
|
||||
? gamepad->entries[0].instance_id : 0;
|
||||
return gamepad != NULL ? gamepad->instance_id : 0;
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active != NULL) {
|
||||
return active->sdl_type;
|
||||
}
|
||||
return gamepad != NULL && gamepad->count > 0
|
||||
? gamepad->entries[0].sdl_type : 0;
|
||||
return gamepad != NULL ? gamepad->sdl_type : 0;
|
||||
}
|
||||
|
||||
void portproton_gamepad_shutdown(void)
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops cold war",
|
||||
"status": "Running",
|
||||
"status": "Broken",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
@@ -1286,7 +1286,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "nikke goddess of victory",
|
||||
"status": "Running",
|
||||
"status": "Broken",
|
||||
"slug": "nikke"
|
||||
},
|
||||
{
|
||||
@@ -1899,11 +1899,6 @@
|
||||
"status": "Broken",
|
||||
"slug": "tekken-7-for-esl"
|
||||
},
|
||||
{
|
||||
"normalized_name": "rocket league",
|
||||
"status": "Supported",
|
||||
"slug": "rocket-league"
|
||||
},
|
||||
{
|
||||
"normalized_name": "rocket league (faceit)",
|
||||
"status": "Broken",
|
||||
@@ -3346,7 +3341,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "the crew motorfest",
|
||||
"status": "Supported",
|
||||
"status": "Broken",
|
||||
"slug": "the-crew-motorfest"
|
||||
},
|
||||
{
|
||||
@@ -3781,7 +3776,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "exoborne",
|
||||
"status": "Denied",
|
||||
"status": "Broken",
|
||||
"slug": "exoborne"
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
+3226
-87474
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+22
-1207
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Vendored
-6
@@ -1,9 +1,3 @@
|
||||
portprotonqt (1.5.0) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 14 Oct 2026 21:30:22 +0500
|
||||
|
||||
portprotonqt (1.4.1) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
@@ -19,18 +19,6 @@ for file in "$@"; do
|
||||
echo "$file: bash -n syntax check failed"
|
||||
status=1
|
||||
fi
|
||||
|
||||
# Bash 5.1 requires a separator before then after a conditional command.
|
||||
if ! awk '
|
||||
/\]\][ \t]+then([ \t;]|$)/ || (continued && /^[ \t]*then([ \t;]|$)/) {
|
||||
printf "%s:%d: Bash 5.1 requires a newline or semicolon before then\n", FILENAME, NR
|
||||
failed = 1
|
||||
}
|
||||
{ continued = /\]\][ \t]*\\$/ }
|
||||
END { exit failed }
|
||||
' "$file"; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$status"
|
||||
@@ -1,12 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
GITHUB_REPOS=("linux-gaming-ru/PortProtonQt")
|
||||
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
|
||||
GITEA_REPO="Linux-Gaming/PortProtonQt"
|
||||
GITEA_URL="https://git.linux-gaming.ru"
|
||||
|
||||
jq_format='def format_downloads: if . < 1000 then tostring else ((. / 100 | round) / 10 | tostring) + "k" end;'
|
||||
|
||||
jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | .download_count] | add // 0'
|
||||
|
||||
echo "=== PortProtonQt Download Stats ==="
|
||||
@@ -14,52 +12,49 @@ echo
|
||||
|
||||
echo "--- GitHub (${GITHUB_REPOS[*]}) ---"
|
||||
GH_RAW=$(for repo in "${GITHUB_REPOS[@]}"; do
|
||||
curl -sfL "https://api.github.com/repos/${repo}/releases?per_page=100"
|
||||
curl -sf "https://api.github.com/repos/${repo}/releases?per_page=100"
|
||||
done | jq -s 'add')
|
||||
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
|
||||
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
|
||||
echo "Releases: $GH_RELEASES"
|
||||
GH_DISPLAY=$(jq -nr --argjson count "$GH_COUNT" "$jq_format"'$count | format_downloads')
|
||||
echo "Total downloads: $GH_DISPLAY"
|
||||
echo "Total downloads: $GH_COUNT"
|
||||
echo
|
||||
echo "Per release:"
|
||||
echo "$GH_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
|
||||
echo "$GH_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
|
||||
echo
|
||||
|
||||
echo "--- Gitea (${GITEA_REPO}) ---"
|
||||
GIT_RAW=$(curl -sfL "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
|
||||
GIT_RAW=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
|
||||
GIT_COUNT=$(echo "$GIT_RAW" | jq "$jq_total")
|
||||
GIT_RELEASES=$(echo "$GIT_RAW" | jq 'length')
|
||||
echo "Releases: $GIT_RELEASES"
|
||||
GIT_DISPLAY=$(jq -nr --argjson count "$GIT_COUNT" "$jq_format"'$count | format_downloads')
|
||||
echo "Total downloads: $GIT_DISPLAY"
|
||||
echo "Total downloads: $GIT_COUNT"
|
||||
echo
|
||||
echo "Per release:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
|
||||
echo "$GIT_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
|
||||
echo
|
||||
|
||||
TOTAL=$((GH_COUNT + GIT_COUNT))
|
||||
BADGE_TOTAL=$(jq -nr --argjson total "$TOTAL" "$jq_format"'$total | format_downloads')
|
||||
echo "========================"
|
||||
echo "GitHub total: $GH_DISPLAY"
|
||||
echo "Gitea total: $GIT_DISPLAY"
|
||||
echo "Grand total: $BADGE_TOTAL"
|
||||
echo "GitHub total: $GH_COUNT"
|
||||
echo "Gitea total: $GIT_COUNT"
|
||||
echo "Grand total: $TOTAL"
|
||||
echo
|
||||
echo "--- Top downloads (by file) ---"
|
||||
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total | format_downloads)\t\(.name)"'
|
||||
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total)\t\(.name)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_format $jq_filter_top"
|
||||
echo "$GH_RAW" | jq -r "$jq_filter_top"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_format $jq_filter_top"
|
||||
echo "$GIT_RAW" | jq -r "$jq_filter_top"
|
||||
echo
|
||||
echo "--- Top downloads (by type) ---"
|
||||
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total | format_downloads)"'
|
||||
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_format $jq_type"
|
||||
echo "$GH_RAW" | jq -r "$jq_type"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_format $jq_type"
|
||||
echo "$GIT_RAW" | jq -r "$jq_type"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo
|
||||
@@ -67,9 +62,9 @@ if [[ "${CI:-}" != "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sed -i -E "s|-[0-9]+(\.[0-9]+)?k?-green\?style=flat-square|-$BADGE_TOTAL-green?style=flat-square|g" README.md README.ru.md
|
||||
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
|
||||
echo
|
||||
echo "Badge updated: $BADGE_TOTAL"
|
||||
echo "Badge updated: $TOTAL"
|
||||
|
||||
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ if ! command -v uv &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Check if Python 3.11 is already installed via uv
|
||||
# We look for cpython-3.11 and ensure it doesn't say "<download available>"
|
||||
if uv python list | grep "cpython-3.11" | grep -qv "<download available>"; then
|
||||
echo "✅ Python 3.11 is already available."
|
||||
# 2. Check if Python 3.10 is already installed via uv
|
||||
# We look for cpython-3.10 and ensure it doesn't say "<download available>"
|
||||
if uv python list | grep "cpython-3.10" | grep -qv "<download available>"; then
|
||||
echo "✅ Python 3.10 is already available."
|
||||
else
|
||||
echo "🐍 Installing Python 3.11..."
|
||||
uv python install 3.11
|
||||
echo "🐍 Installing Python 3.10..."
|
||||
uv python install 3.10
|
||||
fi
|
||||
|
||||
# 3. Sync dependencies
|
||||
|
||||
@@ -44,7 +44,7 @@ Translations submitted online are periodically reviewed and merged into the main
|
||||
1. Run:
|
||||
|
||||
```bash
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --create-new <locale_code>
|
||||
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
|
||||
If you’ve added new strings to the code:
|
||||
|
||||
```bash
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --update-all
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
1. Выполните:
|
||||
|
||||
```bash
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --create-new <код_локали>
|
||||
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
|
||||
Если вы добавили новые строки в код:
|
||||
|
||||
```bash
|
||||
uv python install 3.11
|
||||
uv python install 3.10
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --update-all
|
||||
|
||||
@@ -291,7 +291,6 @@ GAME_CARD_GRID = {
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_orientation": "vertical",
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
@@ -303,9 +302,6 @@ GAME_CARD_HORIZONTAL = {
|
||||
}
|
||||
```
|
||||
|
||||
Set `card_orientation` to `"vertical"` to reuse the grid card proportions in
|
||||
the horizontal strip. Omit it to keep the horizontal block proportions.
|
||||
|
||||
Values are logical pixels. `cover_radius` applies to static and animated covers;
|
||||
`border_radius` applies to the painted focus and hover border. Grid cover width
|
||||
continues to follow the library card-size setting.
|
||||
@@ -316,8 +312,7 @@ widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
|
||||
opacity, scale values, animation durations, and easing curves. For example,
|
||||
`horizontal_top` can define its own `scale` type, scale values, duration, and
|
||||
easing while the complete grid uses separately configured `gradient` values.
|
||||
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback;
|
||||
`card_animation_type` defaults to `gradient`.
|
||||
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
@@ -958,20 +953,6 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
|
||||
|
||||
---
|
||||
|
||||
## Boot animation (optional)
|
||||
|
||||
Place `bootanimation.webm` beside the theme's `styles.py`; missing videos are
|
||||
inherited from parent themes. It plays with audio inside the main window only
|
||||
in fullscreen, including the automatic transition when a gamepad is connected.
|
||||
Minimized and windowed launches skip it, including windowed ppqtos.
|
||||
The “Disable Boot Animation” checkbox in settings disables playback.
|
||||
A key press, mouse click, or gamepad button skips playback. `bootAnimationTimeoutMs` defaults to
|
||||
30000; `color_boot_animation` sets the background and letterbox color.
|
||||
|
||||
Video specifications: WebM container, VP9 video, optional Opus audio;
|
||||
1920×1080 or 1280×800, 30 or 60 FPS. Recommended duration: 3–5 seconds;
|
||||
the default playback timeout is 30 seconds. Aspect ratio is preserved.
|
||||
|
||||
## Sound Effects (optional)
|
||||
|
||||
Place UI sound effects in the theme's `sounds/` directory. File names must match an event name; do not add numeric suffixes such as `_001`:
|
||||
|
||||
@@ -291,7 +291,6 @@ GAME_CARD_GRID = {
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_orientation": "vertical",
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
@@ -303,10 +302,6 @@ GAME_CARD_HORIZONTAL = {
|
||||
}
|
||||
```
|
||||
|
||||
Укажите `card_orientation = "vertical"`, чтобы использовать в горизонтальной
|
||||
ленте вертикальные пропорции карточек из сетки. Без параметра сохраняются
|
||||
пропорции горизонтальных блоков.
|
||||
|
||||
Значения задаются в логических пикселях. `cover_radius` применяется к
|
||||
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
|
||||
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
|
||||
@@ -318,8 +313,7 @@ GAME_CARD_HORIZONTAL = {
|
||||
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
|
||||
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
|
||||
длительность и easing, а полная сетка — отдельные параметры `gradient`.
|
||||
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`, а
|
||||
`card_animation_type` по умолчанию использует `gradient`.
|
||||
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
@@ -960,21 +954,6 @@ ICON_COLORS = {
|
||||
|
||||
---
|
||||
|
||||
## Загрузочная анимация (опционально)
|
||||
|
||||
Положите `bootanimation.webm` рядом со `styles.py` темы. При отсутствии файла
|
||||
используется видео родительской темы. Ролик со звуком показывается внутри
|
||||
основного окна только в fullscreen, включая автоматический переход при
|
||||
подключении геймпада. Оконный и свёрнутый запуск пропускают видео, в том числе
|
||||
оконный ppqtos. Клавиша, щелчок мыши или кнопка геймпада пропускают ролик.
|
||||
`bootAnimationTimeoutMs` по умолчанию равен 30000; `color_boot_animation`
|
||||
задаёт цвет фона и свободных краёв.
|
||||
Галочка «Disable Boot Animation» в настройках отключает воспроизведение.
|
||||
|
||||
Требования к видео: контейнер WebM, видеокодек VP9, звук Opus при необходимости;
|
||||
1920×1080 или 1280×800, 30 или 60 FPS. Рекомендуемая длительность — 3–5 секунд;
|
||||
по умолчанию воспроизведение ограничено 30 секундами. Пропорции сохраняются.
|
||||
|
||||
## Звуковые эффекты (опционально)
|
||||
|
||||
Поместите звуковые эффекты интерфейса в директорию `sounds/` темы. Имя файла должно совпадать с именем события; не добавляйте числовые суффиксы наподобие `_001`:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
project('portprotonqt', 'c',
|
||||
version: '1.5.0',
|
||||
version: '1.4.1',
|
||||
meson_version: '>= 0.61.2',
|
||||
license: 'GPL-3.0',
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -26,7 +25,10 @@ class GameCardAnimations:
|
||||
|
||||
def _animation_type(self) -> str:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
return layout_config.get("card_animation_type", "gradient")
|
||||
return layout_config.get(
|
||||
"card_animation_type",
|
||||
self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient"),
|
||||
)
|
||||
|
||||
def _config_value(self, key: str) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
@@ -168,8 +170,7 @@ class GameCardAnimations:
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
if not isinstance(QApplication.focusWidget(), QLineEdit):
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
+14
-59
@@ -2,15 +2,12 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote, unquote
|
||||
from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
@@ -55,7 +52,7 @@ from portprotonqt.localization import _, get_steam_language
|
||||
APP_VERSION = "@APP_VERSION@"
|
||||
APP_COMMIT = "@APP_COMMIT@"
|
||||
if APP_VERSION == "@APP" "_VERSION@":
|
||||
APP_VERSION = "1.5.0"
|
||||
APP_VERSION = "1.4.1"
|
||||
try:
|
||||
APP_COMMIT = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
@@ -105,7 +102,7 @@ def get_portproton_tray_icon() -> QIcon:
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with minimal tray game controls."""
|
||||
"""Launch a game with a minimal tray stop action."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
@@ -135,39 +132,6 @@ def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> No
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
def update_pause_action() -> list[psutil.Process]:
|
||||
try:
|
||||
children = psutil.Process(process.pid).children(recursive=True)
|
||||
paused = bool(children) and all(
|
||||
child.status() == psutil.STATUS_STOPPED for child in children
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
children = []
|
||||
paused = False
|
||||
pause_action.setEnabled(bool(children))
|
||||
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
return children
|
||||
|
||||
def toggle_pause() -> None:
|
||||
children = update_pause_action()
|
||||
if not children:
|
||||
return
|
||||
try:
|
||||
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
|
||||
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
|
||||
for child in children:
|
||||
try:
|
||||
child.send_signal(pause_signal)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
|
||||
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
|
||||
logger.warning("Failed to inspect game pause state: %s", error)
|
||||
update_pause_action()
|
||||
|
||||
pause_action = QAction(_("Pause Game"), tray_menu)
|
||||
pause_action.triggered.connect(toggle_pause)
|
||||
tray_menu.addAction(pause_action)
|
||||
tray_menu.aboutToShow.connect(update_pause_action)
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
@@ -236,7 +200,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
from portprotonqt.appimage_integration import integrate_appimage
|
||||
@@ -546,8 +509,6 @@ def main():
|
||||
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
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
@@ -656,12 +617,12 @@ def main():
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
display_config.set_fullscreen(True)
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
if msg.startswith("show"):
|
||||
logger.info("Switching to normal window via IPC")
|
||||
display_config.set_fullscreen(False)
|
||||
window.input_manager.handle_fullscreen_slot(False)
|
||||
window.showNormal()
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
@@ -760,7 +721,7 @@ def main():
|
||||
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
|
||||
)
|
||||
display_config.set_fullscreen(True)
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
window.showFullScreen()
|
||||
elif launch_auto_fullscreen:
|
||||
logger.info("Launching in fullscreen mode (gamepad)")
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
@@ -807,14 +768,13 @@ def main():
|
||||
return
|
||||
QMessageBox.warning(window, _("Error"), _("Failed to update AppImage."))
|
||||
|
||||
if not is_silent_launch:
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
|
||||
class GOGDLUpdateWorker(QThread):
|
||||
"""Update gogdl without blocking the UI thread."""
|
||||
@@ -901,15 +861,10 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
if start_sh:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
elif not start_sh:
|
||||
else:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
|
||||
@@ -183,7 +183,6 @@ def integrate_appimage() -> Path:
|
||||
resolved_icon = icon_source.resolve()
|
||||
icon = icon_dir / f"{APP_ID}.svg"
|
||||
_copy_appimage(source, destination)
|
||||
# Remove after the legacy AppImage migration period.
|
||||
legacy_appimage = Path.home() / "Applications/PortProtonQt.AppImage"
|
||||
if legacy_appimage.resolve() != destination.resolve():
|
||||
portproton_location = get_portproton_location()
|
||||
|
||||
@@ -14,8 +14,7 @@ import requests
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from portprotonqt.downloader import get_requests_session
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.config.base import XDG_DATA_HOME
|
||||
from portprotonqt.config import CACHE_DIR, ui_config
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -60,7 +59,7 @@ def _get_appimage_arch() -> str:
|
||||
|
||||
|
||||
def _cached_tool_path() -> Path:
|
||||
return XDG_DATA_HOME / "PortProtonQt" / "bin" / "appimageupdatetool"
|
||||
return CACHE_DIR / "bin" / "appimageupdatetool"
|
||||
|
||||
|
||||
def _get_appimageupdatetool_url() -> str:
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Theme boot video rendered inside the application's startup page."""
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPaintEvent
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import _find_theme_folder, _read_theme_parent_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtMultimedia import QVideoFrame
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def find_boot_video(theme_name: str) -> str | None:
|
||||
"""Find the theme's WebM, following its existing inheritance chain."""
|
||||
visited = set()
|
||||
while theme_name and theme_name not in visited:
|
||||
visited.add(theme_name)
|
||||
folder = _find_theme_folder(theme_name)
|
||||
if folder is not None:
|
||||
path = os.path.join(folder, "bootanimation.webm")
|
||||
if os.path.isfile(path):
|
||||
if os.path.dirname(os.path.realpath(path)) == os.path.realpath(folder):
|
||||
return path
|
||||
logger.warning("Boot video outside theme directory blocked: %s", path)
|
||||
return None
|
||||
theme_name = _read_theme_parent_name(theme_name) or ""
|
||||
return None
|
||||
|
||||
|
||||
class BootAnimation(QWidget):
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, theme: Any, video_path: str, parent: QWidget) -> None:
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoSink
|
||||
|
||||
super().__init__(parent)
|
||||
self._background = QColor(theme.color_boot_animation)
|
||||
self._video_path = video_path
|
||||
self._frame = QImage()
|
||||
self._stopped = False
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.sink = QVideoSink(self)
|
||||
self.sink.videoFrameChanged.connect(self._receive_frame)
|
||||
self.audio = QAudioOutput(self)
|
||||
self.player = QMediaPlayer(self)
|
||||
self.player.setAudioOutput(self.audio)
|
||||
self.player.setVideoSink(self.sink)
|
||||
self.player.mediaStatusChanged.connect(self._media_status_changed)
|
||||
self.player.errorOccurred.connect(self._playback_error)
|
||||
self.timeout = QTimer(self)
|
||||
self.timeout.setSingleShot(True)
|
||||
self.timeout.setInterval(theme.bootAnimationTimeoutMs)
|
||||
self.timeout.timeout.connect(self.finish)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
self.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self.timeout.start()
|
||||
self.player.setSource(QUrl.fromLocalFile(self._video_path))
|
||||
if not self._stopped:
|
||||
self.player.play()
|
||||
|
||||
def _receive_frame(self, frame: "QVideoFrame") -> None:
|
||||
if self._stopped or not frame.isValid():
|
||||
return
|
||||
image = frame.toImage()
|
||||
if not image.isNull():
|
||||
self._frame = image
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self._background)
|
||||
if not self._frame.isNull():
|
||||
size = self._frame.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
target = QRect(QPoint(), size)
|
||||
target.moveCenter(self.rect().center())
|
||||
painter.drawImage(target, self._frame)
|
||||
|
||||
def _media_status_changed(self, status: object) -> None:
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
|
||||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
self.finish()
|
||||
|
||||
def _playback_error(self, error: object, message: str) -> None:
|
||||
logger.warning("Boot video playback failed: %s", message)
|
||||
self.finish()
|
||||
|
||||
def finish(self) -> None:
|
||||
if not self._stopped:
|
||||
self.stop()
|
||||
self.finished.emit()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
self._stopped = True
|
||||
self.timeout.stop()
|
||||
self.player.stop()
|
||||
self.sink.videoFrameChanged.disconnect(self._receive_frame)
|
||||
self._frame = QImage()
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.removeEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self and event.type() in (QEvent.Type.KeyPress, QEvent.Type.MouseButtonPress):
|
||||
self.finish()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
@@ -14,7 +14,6 @@ from typing import Any
|
||||
|
||||
import orjson
|
||||
import pefile
|
||||
import psutil
|
||||
|
||||
from portprotonqt.debug_utils import (
|
||||
get_portproton_env,
|
||||
@@ -99,46 +98,16 @@ class CompatibilityLaunch:
|
||||
duration: float
|
||||
|
||||
|
||||
def is_suspected_crash(
|
||||
duration: float, stopped_by_user: bool, executable: str, exit_code: int | None = None
|
||||
) -> bool:
|
||||
"""Prefer Wine's command status; use short runtime only when it is unknown."""
|
||||
def is_suspected_crash(duration: float, stopped_by_user: bool, executable: str) -> bool:
|
||||
"""Return whether an executable closed too soon after launch."""
|
||||
always_report = os.getenv(COMPATIBILITY_ALWAYS_REPORT_ENV) == "1"
|
||||
return (
|
||||
(always_report or (not stopped_by_user and (
|
||||
exit_code != 0 if exit_code is not None else duration < CRASH_THRESHOLD_SECONDS
|
||||
)))
|
||||
(always_report or (not stopped_by_user and duration < CRASH_THRESHOLD_SECONDS))
|
||||
and executable.lower().endswith((".exe", ".msi"))
|
||||
and os.path.isfile(executable)
|
||||
)
|
||||
|
||||
|
||||
def get_running_game_processes(executable: str, started: float) -> dict[tuple[int, float], str]:
|
||||
"""Find new Windows processes in the game's directory, including engine children."""
|
||||
directory = os.path.dirname(os.path.abspath(executable)).lower() + "/"
|
||||
running = {}
|
||||
for process in psutil.process_iter(attrs=["pid", "name", "cmdline", "cwd", "create_time", "status"]):
|
||||
info = process.info
|
||||
created = info.get("create_time")
|
||||
if created is None or created < started or info.get("status") == psutil.STATUS_ZOMBIE:
|
||||
continue
|
||||
arguments = info.get("cmdline") or []
|
||||
command = (arguments[0] if arguments else info.get("name") or "").replace("\\", "/")
|
||||
if not command.lower().endswith((".exe", ".msi")):
|
||||
continue
|
||||
if command.lower().startswith("z:/"):
|
||||
command = command[2:]
|
||||
if not command.startswith("/"):
|
||||
cwd = info.get("cwd")
|
||||
if not cwd:
|
||||
continue
|
||||
command = os.path.join(cwd, os.path.basename(command) if ":" in command else command)
|
||||
command = os.path.normpath(command)
|
||||
if command.lower().startswith(directory):
|
||||
running[(info["pid"], created)] = command
|
||||
return running
|
||||
|
||||
|
||||
def _read_imports(pe: pefile.PE) -> set[str]:
|
||||
imports = set()
|
||||
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
|
||||
@@ -319,12 +288,9 @@ def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
|
||||
try:
|
||||
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
|
||||
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
|
||||
descriptor_index = pefile.DIRECTORY_ENTRY[
|
||||
"IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"
|
||||
clr = data_directories[
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
|
||||
]
|
||||
if not isinstance(descriptor_index, int):
|
||||
raise IndexError
|
||||
clr = data_directories[descriptor_index]
|
||||
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
|
||||
findings["Runtimes"].append(".NET Framework")
|
||||
except (AttributeError, IndexError):
|
||||
@@ -454,8 +420,6 @@ def _compatibility_suggestions(
|
||||
)
|
||||
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
|
||||
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
|
||||
if environment.get("PW_USE_OPTISCALER") == "1":
|
||||
suggestions.append("Temporarily disable OptiScaler in the game settings and retry to check for a conflict.")
|
||||
if _uses_integrated_gpu(environment):
|
||||
suggestions.append("Use the discrete GPU.")
|
||||
runtimes = findings.get("Runtimes", [])
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Base configuration class for PortProtonQt."""
|
||||
import os
|
||||
import configparser
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config.validators import validate_string, validate_int, validate_bool, ValidationError
|
||||
@@ -25,27 +24,7 @@ THEMES_DIRS = [
|
||||
]
|
||||
|
||||
# Cache paths
|
||||
CACHE_DIR = XDG_DATA_HOME / "PortProtonQt" / "cache"
|
||||
LEGACY_CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
|
||||
|
||||
|
||||
# Remove after the legacy cache migration period.
|
||||
def _migrate_legacy_cache() -> None:
|
||||
"""Move the legacy XDG cache into persistent application data."""
|
||||
if not LEGACY_CACHE_DIR.is_dir():
|
||||
return
|
||||
try:
|
||||
if not CACHE_DIR.exists():
|
||||
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(LEGACY_CACHE_DIR), str(CACHE_DIR))
|
||||
return
|
||||
shutil.copytree(LEGACY_CACHE_DIR, CACHE_DIR, dirs_exist_ok=True)
|
||||
shutil.rmtree(LEGACY_CACHE_DIR)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to migrate legacy cache directory: %s", error)
|
||||
|
||||
|
||||
_migrate_legacy_cache()
|
||||
CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
|
||||
|
||||
# Module-level cache storage
|
||||
_config_cache: dict[str, configparser.ConfigParser] = {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Cache configuration and management."""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -117,7 +118,7 @@ class CacheManager:
|
||||
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
|
||||
try:
|
||||
if pretty:
|
||||
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
|
||||
content = json.dumps(data, indent=2).encode("utf-8")
|
||||
else:
|
||||
content = orjson.dumps(data)
|
||||
with _CACHE_FILE_LOCK:
|
||||
|
||||
@@ -33,24 +33,6 @@ class DisplayConfig(BaseConfig):
|
||||
validate_bool(auto, "auto_fullscreen_gamepad")
|
||||
self._save_value("auto_fullscreen_gamepad", auto, "bool")
|
||||
|
||||
def get_hide_cursor(self) -> bool:
|
||||
"""Return whether fullscreen cursor hiding is enabled."""
|
||||
return self._get_bool("hide_cursor", True)
|
||||
|
||||
def set_hide_cursor(self, enabled: bool) -> None:
|
||||
"""Enable or disable fullscreen cursor hiding."""
|
||||
validate_bool(enabled, "hide_cursor")
|
||||
self._save_value("hide_cursor", enabled, "bool")
|
||||
|
||||
def get_boot_animation_enabled(self) -> bool:
|
||||
"""Return whether fullscreen boot videos are enabled."""
|
||||
return self._get_bool("boot_animation_enabled", True)
|
||||
|
||||
def set_boot_animation_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable fullscreen boot videos."""
|
||||
validate_bool(enabled, "boot_animation_enabled")
|
||||
self._save_value("boot_animation_enabled", enabled, "bool")
|
||||
|
||||
def get_minimize_to_tray(self) -> bool:
|
||||
"""Get minimize-to-tray setting."""
|
||||
return self._get_bool("minimize_to_tray", False)
|
||||
|
||||
@@ -164,19 +164,13 @@ def get_portproton_scripts_path() -> str | None:
|
||||
if appimage_path:
|
||||
appimage_root = Path(sys.executable).resolve().parent.parent
|
||||
prefixes.append(("AppImage executable", appimage_root))
|
||||
if os.getenv("FLATPAK_ID"):
|
||||
prefixes.append(("Flatpak package", Path("/app")))
|
||||
prefixes.append(("system package", Path("/usr")))
|
||||
xdg_scripts_dirs = [
|
||||
("XDG data", Path(data_dir) / "portproton" / "scripts")
|
||||
for data_dir in os.getenv(
|
||||
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
|
||||
).split(os.pathsep)
|
||||
if data_dir
|
||||
]
|
||||
|
||||
scripts_dirs = (
|
||||
("repository", Path.cwd() / "build-aux" / "share" / "portproton" / "scripts"),
|
||||
*[(source, prefix / "share" / "portproton" / "scripts") for source, prefix in prefixes],
|
||||
*xdg_scripts_dirs,
|
||||
)
|
||||
for source, scripts_dir in scripts_dirs:
|
||||
if (scripts_dir / "start.sh").is_file():
|
||||
@@ -289,7 +283,6 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
|
||||
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
|
||||
|
||||
|
||||
# Remove after the legacy shortcut migration period.
|
||||
def migrate_legacy_shortcut(
|
||||
portproton_path: str,
|
||||
desktop_dir: str | None = None,
|
||||
@@ -455,42 +448,7 @@ def migrate_legacy_shortcut(
|
||||
def parse_desktop_entry(file_path: str) -> configparser.SectionProxy | None:
|
||||
"""Read and parse a .desktop file using configparser."""
|
||||
cp = configparser.ConfigParser(interpolation=None)
|
||||
content = ""
|
||||
try:
|
||||
content = Path(file_path).read_text(encoding="utf-8")
|
||||
cp.read_string(content, source=file_path)
|
||||
except configparser.ParsingError:
|
||||
lines = []
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and "=" not in line and not stripped.startswith(("[", "#", ";")):
|
||||
if not lines or "=" not in lines[-1]:
|
||||
return None
|
||||
lines[-1] = f"{lines[-1]} {stripped}"
|
||||
continue
|
||||
lines.append(line)
|
||||
repaired_content = "\n".join(lines) + ("\n" if content.endswith("\n") else "")
|
||||
try:
|
||||
cp.read_string(repaired_content, source=file_path)
|
||||
except configparser.Error as error:
|
||||
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
|
||||
return None
|
||||
desktop_path = Path(file_path)
|
||||
temp_path = desktop_path.with_name(f".{desktop_path.name}.tmp")
|
||||
try:
|
||||
temp_path.write_text(repaired_content, encoding="utf-8")
|
||||
os.chmod(temp_path, desktop_path.stat().st_mode)
|
||||
os.replace(temp_path, desktop_path)
|
||||
logger.warning("Repaired malformed desktop file %s", file_path)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save repaired desktop file %s: %s", file_path, error)
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError as cleanup_error:
|
||||
logger.debug("Failed to remove temporary file %s: %s", temp_path, cleanup_error)
|
||||
except (configparser.Error, OSError, UnicodeError) as error:
|
||||
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
|
||||
return None
|
||||
cp.read(file_path, encoding="utf-8")
|
||||
if "Desktop Entry" not in cp:
|
||||
return None
|
||||
return cp["Desktop Entry"]
|
||||
@@ -598,7 +556,6 @@ def create_desktop_file(
|
||||
|
||||
if not game_name:
|
||||
game_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
game_name = " ".join(game_name.splitlines()).strip()
|
||||
base_path = os.path.join(portproton_path, "data")
|
||||
icon_name = _sanitize_icon_name(game_name)
|
||||
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
|
||||
|
||||
@@ -383,27 +383,6 @@ class UIConfig(BaseConfig):
|
||||
validate_string(mode, "badge_view_mode", min_len=1, max_len=20)
|
||||
self._save_value("badge_view_mode", mode, "str")
|
||||
|
||||
def get_library_layout_mode(self, theme_mode: str = "grid") -> str:
|
||||
"""Get the selected library layout or the theme default."""
|
||||
modes = {"grid", "list", "vertical", "horizontal", "horizontal_top"}
|
||||
mode = self._get_str("library_layout_mode", "theme")
|
||||
return mode if mode in modes else theme_mode.lower()
|
||||
|
||||
def set_library_layout_mode(self, mode: str) -> None:
|
||||
"""Set the library layout mode."""
|
||||
validate_string(mode, "library_layout_mode", min_len=1, max_len=20)
|
||||
self._save_value("library_layout_mode", mode, "str")
|
||||
|
||||
def get_horizontal_card_orientation(self, theme_mode: str) -> str:
|
||||
"""Get the horizontal card orientation or the theme default."""
|
||||
mode = self._get_str("horizontal_card_orientation", "theme")
|
||||
return mode if mode in {"horizontal", "vertical"} else theme_mode
|
||||
|
||||
def set_horizontal_card_orientation(self, mode: str) -> None:
|
||||
"""Set the horizontal card orientation."""
|
||||
validate_string(mode, "horizontal_card_orientation", min_len=1, max_len=20)
|
||||
self._save_value("horizontal_card_orientation", mode, "str")
|
||||
|
||||
def get_economy_mode(self) -> bool:
|
||||
"""Get economy mode setting."""
|
||||
return self._get_download_bool("economy_mode", False)
|
||||
@@ -453,15 +432,6 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "download_wine_to_steam")
|
||||
self._save_download_value("download_wine_to_steam", enabled)
|
||||
|
||||
def get_disable_steam_proton_scanning(self) -> bool:
|
||||
"""Get Steam Proton scanning setting."""
|
||||
return self._get_bool("disable_steam_proton_scanning", False)
|
||||
|
||||
def set_disable_steam_proton_scanning(self, enabled: bool) -> None:
|
||||
"""Set Steam Proton scanning setting."""
|
||||
validate_bool(enabled, "disable_steam_proton_scanning")
|
||||
self._save_value("disable_steam_proton_scanning", enabled, "bool")
|
||||
|
||||
def get_disable_runtime_download(self) -> bool:
|
||||
"""Get PortProton runtime download setting."""
|
||||
default = bool(os.getenv("FLATPAK_ID"))
|
||||
@@ -519,24 +489,6 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
def get_disable_cyrillic_path_check(self) -> bool:
|
||||
"""Get Cyrillic launch path check setting."""
|
||||
return self._get_bool("disable_cyrillic_path_check", False)
|
||||
|
||||
def set_disable_cyrillic_path_check(self, enabled: bool) -> None:
|
||||
"""Set Cyrillic launch path check setting."""
|
||||
validate_bool(enabled, "disable_cyrillic_path_check")
|
||||
self._save_value("disable_cyrillic_path_check", enabled, "bool")
|
||||
|
||||
def get_disable_alt_i586_dependency_check(self) -> bool:
|
||||
"""Get ALT i586 dependency check setting."""
|
||||
return self._get_bool("disable_alt_i586_dependency_check", False)
|
||||
|
||||
def set_disable_alt_i586_dependency_check(self, enabled: bool) -> None:
|
||||
"""Set ALT i586 dependency check setting."""
|
||||
validate_bool(enabled, "disable_alt_i586_dependency_check")
|
||||
self._save_value("disable_alt_i586_dependency_check", enabled, "bool")
|
||||
|
||||
|
||||
def load_theme_metainfo(theme_name: str) -> dict:
|
||||
"""Load theme metadata from metainfo.ini."""
|
||||
|
||||
@@ -2,10 +2,9 @@ import os
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
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
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
@@ -35,9 +34,6 @@ from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.game_card import GameCard
|
||||
|
||||
class ContextMenuSignals(QObject):
|
||||
"""Signals for thread-safe UI updates from worker threads."""
|
||||
show_warning_dialog = Signal(str, str)
|
||||
@@ -68,14 +64,6 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.game_library_manager = game_library_manager
|
||||
self._batch_action = False
|
||||
self.card_selection_mode = False
|
||||
self._card_actions: list[QAction] = []
|
||||
self._card_action_menus: list[QMenu] = []
|
||||
self._card_actions_stopping = False
|
||||
self._card_action_timer = QTimer(parent)
|
||||
self._card_action_timer.setSingleShot(True)
|
||||
self._card_action_timer.timeout.connect(self._run_next_card_action)
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.signals = ContextMenuSignals()
|
||||
self.signals.show_warning_dialog.connect(
|
||||
@@ -457,92 +445,7 @@ class ContextMenuManager:
|
||||
return self.theme_manager.get_colored_icon_path(icon_name, color)
|
||||
return None
|
||||
|
||||
def clear_card_selection(self) -> None:
|
||||
self.card_selection_mode = False
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if card.batch_selected:
|
||||
card.batch_selected = False
|
||||
card.update()
|
||||
|
||||
def start_card_selection(self, game_card: "GameCard") -> None:
|
||||
self.clear_card_selection()
|
||||
self.card_selection_mode = True
|
||||
game_card.batch_selected = True
|
||||
game_card.update()
|
||||
game_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _start_card_actions(self, actions: list[QAction], menus: list[QMenu]) -> None:
|
||||
if self._batch_action or self._card_actions_stopping:
|
||||
return
|
||||
self.clear_card_selection()
|
||||
self._batch_action = True
|
||||
self._card_actions = list(reversed(actions))
|
||||
self._card_action_menus = menus
|
||||
for menu in menus:
|
||||
menu.setParent(self.parent, menu.windowFlags())
|
||||
self._run_next_card_action()
|
||||
|
||||
def _run_next_card_action(self) -> None:
|
||||
if not self._batch_action:
|
||||
return
|
||||
jobs = [getattr(self.parent, name, None) for name in (
|
||||
"egs_process", "gog_process", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker",
|
||||
)]
|
||||
jobs.extend(getattr(self.parent, "gog_support_workers", []))
|
||||
for job in jobs:
|
||||
if job is None or not isinstance(job, (QProcess, QThread)):
|
||||
continue
|
||||
job.finished.connect(lambda *args: self._card_action_timer.start() if not self._card_actions_stopping else None)
|
||||
if ((isinstance(job, QProcess) and job.state() == QProcess.ProcessState.NotRunning)
|
||||
or (isinstance(job, QThread) and not job.isRunning())):
|
||||
self._card_action_timer.start()
|
||||
return
|
||||
if self._card_actions_stopping or not self._card_actions:
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
self._card_actions = []
|
||||
return
|
||||
self._card_actions.pop().trigger()
|
||||
self._card_action_timer.start()
|
||||
|
||||
def stop_card_actions(self) -> None:
|
||||
self._card_actions_stopping = True
|
||||
self._card_action_timer.stop()
|
||||
self._card_actions = []
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
|
||||
def _show_card_selection_menu(self, menu: QMenu, game_card: "GameCard", pos: QPoint) -> None:
|
||||
grouped: dict[str, list[QAction]] = {}
|
||||
menus: list[QMenu] = []
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if not card.batch_selected:
|
||||
continue
|
||||
card_menu = self.show_context_menu(card, pos, build_only=True)
|
||||
if card_menu is None:
|
||||
continue
|
||||
card_menu.setParent(menu, card_menu.windowFlags())
|
||||
menus.append(card_menu)
|
||||
for action in card_menu.actions():
|
||||
if not action.isSeparator() and action.isEnabled():
|
||||
grouped.setdefault(action.text(), []).append(action)
|
||||
for text, actions in grouped.items():
|
||||
action = menu.addAction(actions[0].icon(), text)
|
||||
action.setEnabled(not self._batch_action)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
|
||||
)
|
||||
menu.addSeparator()
|
||||
cancel_action = menu.addAction(_("Cancel"))
|
||||
cancel_action.triggered.connect(self.clear_card_selection)
|
||||
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint, build_only: bool = False):
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
@@ -556,16 +459,6 @@ class ContextMenuManager:
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
if not build_only:
|
||||
if self.card_selection_mode or game_card.batch_selected:
|
||||
self._show_card_selection_menu(menu, game_card, pos)
|
||||
return
|
||||
self.clear_card_selection()
|
||||
selection_action = menu.addAction(_("Select multiple"))
|
||||
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
|
||||
menu.setActiveAction(selection_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
if game_card.game_source == "portproton":
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -574,8 +467,6 @@ class ContextMenuManager:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
if build_only:
|
||||
return menu
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
@@ -653,14 +544,6 @@ class ContextMenuManager:
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_gog_game(game)
|
||||
)
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_id_shortcut(
|
||||
game_card.name,
|
||||
game_card.cover_path,
|
||||
("gog", str(game_card.appid)),
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "egs":
|
||||
app_id = str(game_card.appid)
|
||||
@@ -724,12 +607,6 @@ class ContextMenuManager:
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_egs_game(app_id)
|
||||
)
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_id_shortcut(
|
||||
game_card.name, game_card.cover_path, ("egs", app_id)
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
@@ -814,9 +691,6 @@ class ContextMenuManager:
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if build_only:
|
||||
return menu
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
@@ -1133,7 +1007,7 @@ class ContextMenuManager:
|
||||
if not resolved_exec_line:
|
||||
return
|
||||
shortcut_paths = self._get_installed_shortcut_paths(game_name)
|
||||
exe_path = extract_exec_target_path(resolved_exec_line)
|
||||
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
|
||||
if not self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to delete .desktop file: {error}"),
|
||||
@@ -1162,9 +1036,6 @@ class ContextMenuManager:
|
||||
self.game_library_manager.remove_game_incremental(game_name, card_exec_line or resolved_exec_line)
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
if self._batch_action:
|
||||
self._delete_game_without_confirm(game_name, exec_line)
|
||||
return
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Confirm Deletion"))
|
||||
@@ -1413,34 +1284,29 @@ class ContextMenuManager:
|
||||
):
|
||||
self._update_desktop_database()
|
||||
|
||||
def _edit_id_shortcut(self, game_name: str, cover_path: str,
|
||||
store: tuple[str, str]) -> None:
|
||||
source, app_id = store
|
||||
app_id = str(app_id).strip()
|
||||
invalid_path = app_id in {".", ".."} or os.path.basename(app_id) != app_id
|
||||
if not app_id or invalid_path or (source == "steam" and not app_id.isdigit()):
|
||||
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
|
||||
def _edit_steam_shortcut(self, game_name: str, appid: int | str, cover_path: str) -> None:
|
||||
appid_str = str(appid).strip()
|
||||
if not appid_str.isdigit():
|
||||
logger.warning("Invalid Steam appid for shortcut editing: %s", appid)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=appid_str)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
if not new_name:
|
||||
return
|
||||
selected_cover = dialog.coverEdit.text().strip()
|
||||
if (not os.path.isfile(selected_cover) and dialog.last_cover_path
|
||||
and os.path.isfile(dialog.last_cover_path)):
|
||||
if not os.path.isfile(selected_cover) and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path):
|
||||
selected_cover = dialog.last_cover_path
|
||||
default_data_home = os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME", default_data_home)
|
||||
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data",
|
||||
f"{source}-{app_id}")
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", appid_str)
|
||||
try:
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
clean_name = new_name.replace("\r", " ").replace("\n", " ").strip()
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
with open(metadata_path, "w", encoding="utf-8") as metadata_file:
|
||||
with open(os.path.join(game_dir, "metadata.txt"), "w", encoding="utf-8") as metadata_file:
|
||||
metadata_file.write(f"name={clean_name}\n")
|
||||
if selected_cover and os.path.isfile(selected_cover):
|
||||
extension = os.path.splitext(selected_cover)[1].lower()
|
||||
@@ -1452,90 +1318,22 @@ class ContextMenuManager:
|
||||
shutil.copyfile(selected_cover, target_cover)
|
||||
cover_path = target_cover
|
||||
except OSError as e:
|
||||
message = _("Failed to save custom data: {error}").format(error=str(e))
|
||||
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"), _("Failed to save custom data: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
for game in self.game_library_manager.games:
|
||||
if str(game[3]) != app_id or game[12] != source:
|
||||
if str(game[3]) != appid_str or game[12] != "steam":
|
||||
continue
|
||||
updated_game = list(game)
|
||||
updated_game[0] = new_name
|
||||
updated_game[2] = cover_path
|
||||
self.game_library_manager.replace_game_incremental(game[0], game[5], tuple(updated_game))
|
||||
self.game_library_manager.replace_game_incremental(
|
||||
game[0], game[5], tuple(updated_game)
|
||||
)
|
||||
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
|
||||
return
|
||||
|
||||
def handle_missing_executable(self, game_card: "GameCard") -> None:
|
||||
"""Offer removal or shortcut repair when the launch file is missing."""
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Executable not found"))
|
||||
msg_box.setText(_("Executable not found: {0}").format(game_card.missing_executable_path))
|
||||
delete_button = msg_box.addButton(_("Delete from PortProton"), QMessageBox.ButtonRole.DestructiveRole)
|
||||
edit_button = msg_box.addButton(_("Select another executable"), QMessageBox.ButtonRole.ActionRole)
|
||||
msg_box.addButton(QMessageBox.StandardButton.Cancel)
|
||||
for button in msg_box.buttons():
|
||||
button.ensurePolished()
|
||||
button.setMinimumWidth(button.sizeHint().width())
|
||||
msg_box.exec()
|
||||
if msg_box.clickedButton() == delete_button:
|
||||
self.delete_game(game_card.name, game_card.exec_line)
|
||||
elif msg_box.clickedButton() == edit_button:
|
||||
self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
|
||||
def _relocate_shortcut_data(self, old_exe: str, new_exe: str) -> list[tuple[str, str]]:
|
||||
"""Move path-specific data, refusing to overwrite another game's data."""
|
||||
if os.path.abspath(old_exe) == os.path.abspath(new_exe):
|
||||
return []
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
old_folder = resolve_custom_data_dir(custom_root, old_exe)
|
||||
new_folder = os.path.join(custom_root, get_custom_data_dir_name(new_exe))
|
||||
paths = [(old_folder, new_folder), (old_exe + ".ppdb", new_exe + ".ppdb")]
|
||||
moves = [(source, target) for source, target in paths if os.path.exists(source)]
|
||||
for _source, target in moves:
|
||||
if os.path.lexists(target):
|
||||
raise FileExistsError(_("File already exists: {file_name}").format(file_name=target))
|
||||
completed = []
|
||||
try:
|
||||
for source, target in moves:
|
||||
shutil.move(source, target)
|
||||
completed.append((source, target))
|
||||
except OSError:
|
||||
for source, target in reversed(completed):
|
||||
shutil.move(target, source)
|
||||
raise
|
||||
return completed
|
||||
|
||||
def _relocate_shortcut_statistics(self, old_exe: str, new_exe: str) -> None:
|
||||
"""Keep playtime and last launch associated with a repaired shortcut."""
|
||||
from portprotonqt.time_utils import get_last_launch_path, get_statistics_path
|
||||
|
||||
old_name = os.path.splitext(os.path.basename(old_exe))[0]
|
||||
new_name = os.path.splitext(os.path.basename(new_exe))[0]
|
||||
last_launch_path = get_last_launch_path()
|
||||
records = (
|
||||
(get_statistics_path(), os.path.normpath(old_exe).replace(" ", "#@_@#"), new_exe.replace(" ", "#@_@#")),
|
||||
(last_launch_path, old_name, new_name),
|
||||
)
|
||||
for path, old_key, new_key in records:
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as stream:
|
||||
lines = stream.readlines()
|
||||
updated = []
|
||||
for line in lines:
|
||||
parts = line.rsplit(maxsplit=1) if path == last_launch_path else line.split(maxsplit=1)
|
||||
if len(parts) == 2 and parts[0] == old_key:
|
||||
line = f"{new_key} {parts[1].rstrip()}\n"
|
||||
updated.append(line)
|
||||
if updated != lines:
|
||||
with open(path, "w", encoding="utf-8") as stream:
|
||||
stream.writelines(updated)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1546,7 +1344,7 @@ class ContextMenuManager:
|
||||
cover_path: The path to the game's cover image.
|
||||
"""
|
||||
if appid is not None:
|
||||
self._edit_id_shortcut(game_name, cover_path, ("steam", str(appid)))
|
||||
self._edit_steam_shortcut(game_name, appid, cover_path)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
@@ -1554,7 +1352,7 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
old_exec_line = exec_line
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
dialog = AddGameDialog(
|
||||
@@ -1585,27 +1383,6 @@ class ContextMenuManager:
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
except OSError as e:
|
||||
for source, target in reversed(moved_paths):
|
||||
shutil.move(target, source)
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to save .desktop file: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
for source, target in moved_paths:
|
||||
if new_cover_path.startswith(source + os.sep):
|
||||
new_cover_path = target + new_cover_path[len(source):]
|
||||
if dialog.last_cover_path and dialog.last_cover_path.startswith(source + os.sep):
|
||||
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
|
||||
if exe_path != new_exe_path:
|
||||
self._relocate_shortcut_statistics(exe_path, new_exe_path)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
@@ -1613,6 +1390,17 @@ class ContextMenuManager:
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
|
||||
try:
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to save .desktop file: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1673,8 +1461,8 @@ class ContextMenuManager:
|
||||
old_menu_path = self._get_menu_shortcut_path(game_name)
|
||||
new_menu_path = self._get_menu_shortcut_path(new_name)
|
||||
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
old_desktop_path = self._get_shortcut_path(game_name, desktop_dir)
|
||||
new_desktop_path_target = self._get_shortcut_path(new_name, desktop_dir)
|
||||
old_desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
||||
new_desktop_path_target = os.path.join(desktop_dir, f"{new_name}.desktop")
|
||||
|
||||
if game_name != new_name:
|
||||
if os.path.exists(old_menu_path):
|
||||
@@ -1685,22 +1473,19 @@ class ContextMenuManager:
|
||||
self.remove_from_steam(game_name, new_exe_path, "portproton")
|
||||
|
||||
if dialog.add_to_menu_checkbox.isChecked():
|
||||
if exe_path != new_exe_path or not os.path.exists(new_menu_path):
|
||||
if not os.path.exists(new_menu_path):
|
||||
self.add_to_menu(new_name, updated_exec_line)
|
||||
elif os.path.exists(new_menu_path):
|
||||
self.remove_from_menu(new_name)
|
||||
|
||||
if dialog.add_to_desktop_checkbox.isChecked():
|
||||
if exe_path != new_exe_path or not os.path.exists(new_desktop_path_target):
|
||||
if not os.path.exists(new_desktop_path_target):
|
||||
self.add_to_desktop(new_name, updated_exec_line)
|
||||
elif os.path.exists(new_desktop_path_target):
|
||||
self.remove_from_desktop(new_name)
|
||||
|
||||
is_in_steam = is_game_in_steam(new_name)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if is_in_steam and exe_path != new_exe_path:
|
||||
self.remove_from_steam(new_name, old_exec_line, "portproton")
|
||||
is_in_steam = False
|
||||
if not is_in_steam:
|
||||
self.add_to_steam(new_name, updated_exec_line, new_cover_path)
|
||||
elif is_in_steam:
|
||||
@@ -1734,13 +1519,16 @@ class ContextMenuManager:
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1762,8 +1550,6 @@ class ContextMenuManager:
|
||||
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
@@ -1783,7 +1569,7 @@ class ContextMenuManager:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import os
|
||||
from itertools import accumulate
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
|
||||
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -41,7 +40,6 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
result = [[0, 0, 0, 0] for _ in range(N)]
|
||||
min_margin = 20
|
||||
available_width = rect_width - 2 * min_margin
|
||||
width_sums = list(accumulate((size[0] for size in nat_sizes), initial=0))
|
||||
|
||||
# Fast search for max items per row
|
||||
max_items_per_row = 1
|
||||
@@ -57,7 +55,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
end_idx = min(i + mid, N)
|
||||
sum_w = width_sums[end_idx] - width_sums[i]
|
||||
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||
needed_width = sum_w + spacing * (mid - 1)
|
||||
|
||||
if needed_width <= available_width:
|
||||
@@ -67,7 +65,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
right = mid - 1
|
||||
|
||||
count = best_count
|
||||
sum_width = width_sums[i + count] - width_sums[i]
|
||||
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
|
||||
|
||||
if count > max_items_per_row:
|
||||
max_items_per_row = count
|
||||
@@ -91,7 +89,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
end_idx = min(i + mid, N)
|
||||
sum_w = width_sums[end_idx] - width_sums[i]
|
||||
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||
needed_width = sum_w + spacing * (mid - 1)
|
||||
|
||||
if needed_width <= available_width:
|
||||
@@ -153,13 +151,9 @@ class FlowLayout(QLayout):
|
||||
self._cache_width = None
|
||||
self._cache_visible_hash = None
|
||||
self._cache_result = None
|
||||
self._cache_visible_data = None
|
||||
self._cache_minimum_size = None
|
||||
|
||||
def _get_visible_data(self):
|
||||
"""Return list of visible items and their sizes"""
|
||||
if self._cache_visible_data is not None:
|
||||
return self._cache_visible_data
|
||||
visible_items = []
|
||||
visible_indices = []
|
||||
visible_sizes = []
|
||||
@@ -169,13 +163,10 @@ class FlowLayout(QLayout):
|
||||
if widget and not widget.isHidden():
|
||||
visible_items.append(item)
|
||||
visible_indices.append(i)
|
||||
s = widget.minimumSize()
|
||||
if s != widget.maximumSize():
|
||||
s = item.sizeHint()
|
||||
s = item.sizeHint()
|
||||
visible_sizes.append((s.width(), s.height()))
|
||||
|
||||
self._cache_visible_data = (visible_items, visible_indices, visible_sizes)
|
||||
return self._cache_visible_data
|
||||
return visible_items, visible_indices, visible_sizes
|
||||
|
||||
def _make_visible_hash(self, visible_sizes):
|
||||
"""Create hash for change detection"""
|
||||
@@ -185,12 +176,6 @@ class FlowLayout(QLayout):
|
||||
self.itemList.append(item)
|
||||
self._invalidate_cache()
|
||||
|
||||
def insertWidget(self, index: int, widget: QWidget) -> None:
|
||||
self.addWidget(widget)
|
||||
item = self.takeAt(self.count() - 1)
|
||||
self.itemList.insert(index, item)
|
||||
self.invalidate()
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem:
|
||||
if 0 <= index < len(self.itemList):
|
||||
self._invalidate_cache()
|
||||
@@ -201,8 +186,6 @@ class FlowLayout(QLayout):
|
||||
self._cache_width = None
|
||||
self._cache_visible_hash = None
|
||||
self._cache_result = None
|
||||
self._cache_visible_data = None
|
||||
self._cache_minimum_size = None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self._invalidate_cache()
|
||||
@@ -264,20 +247,12 @@ class FlowLayout(QLayout):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
if self._cache_minimum_size is not None:
|
||||
return QSize(self._cache_minimum_size)
|
||||
size = QSize()
|
||||
for item in self.itemList:
|
||||
widget = item.widget()
|
||||
if (widget is not None and not widget.isHidden()
|
||||
and widget.minimumSize() == widget.maximumSize()):
|
||||
size = size.expandedTo(widget.minimumSize())
|
||||
else:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(margins.left() + margins.right(),
|
||||
margins.top() + margins.bottom())
|
||||
self._cache_minimum_size = QSize(size)
|
||||
return size
|
||||
|
||||
def doLayout(self, rect, testOnly):
|
||||
@@ -316,18 +291,10 @@ class FlowLayout(QLayout):
|
||||
|
||||
if not testOnly:
|
||||
rx, ry = rect.x(), rect.y()
|
||||
row_bounds = {}
|
||||
if self.alignment() & Qt.AlignmentFlag.AlignHCenter:
|
||||
for x, y, w, _ in geom_array:
|
||||
left = row_bounds.get(y, (x, x))[0]
|
||||
row_bounds[y] = (left, x + w)
|
||||
|
||||
# Set geometry for visible items
|
||||
for idx, item in enumerate(visible_items):
|
||||
x, y, w, h = geom_array[idx]
|
||||
if y in row_bounds:
|
||||
left, right = row_bounds[y]
|
||||
x += max(0, (rect.width() - (right - left)) // 2) - left
|
||||
item.setGeometry(QRect(x + rx, y + ry, w, h))
|
||||
|
||||
# Hide invisible items
|
||||
@@ -580,8 +547,7 @@ class AutoSizeButton(QPushButton):
|
||||
self.current_theme_name = selected_theme
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
|
||||
self._uses_theme_padding = padding is None
|
||||
if self._uses_theme_padding:
|
||||
if padding is None:
|
||||
padding = getattr(self.theme, 'autoSizeButtonPadding', 20)
|
||||
|
||||
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = self._normalize_padding(padding)
|
||||
@@ -608,18 +574,6 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
self.adjustFontSize()
|
||||
|
||||
def refresh_theme(self, theme: object) -> None:
|
||||
"""Refresh sizing values supplied by the active theme."""
|
||||
self.theme = theme
|
||||
if not self._uses_theme_padding:
|
||||
return
|
||||
padding = getattr(theme, "autoSizeButtonPadding", 20)
|
||||
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = (
|
||||
self._normalize_padding(padding)
|
||||
)
|
||||
self.adjustFontSize()
|
||||
self.updateGeometry()
|
||||
|
||||
def _extract_icon_name(self, icon_path):
|
||||
if isinstance(icon_path, str):
|
||||
base = os.path.basename(icon_path)
|
||||
@@ -873,8 +827,6 @@ class NavLabel(QLabel):
|
||||
super().mousePressEvent(event)
|
||||
|
||||
class AutoHideScrollArea(QScrollArea):
|
||||
viewportResized = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
theme,
|
||||
@@ -893,30 +845,18 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
|
||||
self._v_scrollbar = self.verticalScrollBar()
|
||||
self._h_scrollbar = self.horizontalScrollBar()
|
||||
self._v_scrollbar.installEventFilter(self)
|
||||
self._h_scrollbar.installEventFilter(self)
|
||||
|
||||
self._opacity_effect = QGraphicsOpacityEffect(self._v_scrollbar)
|
||||
self._opacity_effect.setOpacity(0.0)
|
||||
self._v_scrollbar.setGraphicsEffect(self._opacity_effect)
|
||||
|
||||
self._h_opacity_effect = QGraphicsOpacityEffect(self._h_scrollbar)
|
||||
self._h_opacity_effect.setOpacity(0.0)
|
||||
self._h_scrollbar.setGraphicsEffect(self._h_opacity_effect)
|
||||
|
||||
self._fade_animation = QPropertyAnimation(self._opacity_effect, b"opacity")
|
||||
self._fade_animation.setDuration(self.fade_duration_ms)
|
||||
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
self._h_fade_animation = QPropertyAnimation(self._h_opacity_effect, b"opacity")
|
||||
self._h_fade_animation.setDuration(self.fade_duration_ms)
|
||||
self._h_fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
self._is_visible = False
|
||||
self._scroll_needed = False
|
||||
self._h_is_visible = False
|
||||
self._h_scroll_needed = False
|
||||
|
||||
self._apply_visible_style()
|
||||
|
||||
@@ -924,12 +864,7 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self._hide_timer.setSingleShot(True)
|
||||
self._hide_timer.timeout.connect(self._start_fade_out)
|
||||
|
||||
self._h_hide_timer = QTimer(self)
|
||||
self._h_hide_timer.setSingleShot(True)
|
||||
self._h_hide_timer.timeout.connect(self._start_horizontal_fade_out)
|
||||
|
||||
self._v_scrollbar.valueChanged.connect(self._on_scroll)
|
||||
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
|
||||
|
||||
self.viewport().installEventFilter(self)
|
||||
initial_widget = self.widget()
|
||||
@@ -940,25 +875,6 @@ class AutoHideScrollArea(QScrollArea):
|
||||
|
||||
def _apply_visible_style(self) -> None:
|
||||
self._v_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
|
||||
self._h_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
|
||||
|
||||
def _start_horizontal_fade_in(self) -> None:
|
||||
if not self._h_scroll_needed:
|
||||
return
|
||||
self._h_fade_animation.stop()
|
||||
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
|
||||
self._h_fade_animation.setEndValue(1.0)
|
||||
self._h_fade_animation.start()
|
||||
self._h_is_visible = True
|
||||
|
||||
def _start_horizontal_fade_out(self) -> None:
|
||||
if not self._h_scroll_needed:
|
||||
return
|
||||
self._h_fade_animation.stop()
|
||||
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
|
||||
self._h_fade_animation.setEndValue(0.0)
|
||||
self._h_fade_animation.start()
|
||||
self._h_is_visible = False
|
||||
|
||||
def _start_fade_in(self) -> None:
|
||||
if not self._scroll_needed:
|
||||
@@ -983,28 +899,17 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self._opacity_effect.setOpacity(opacity)
|
||||
self._is_visible = opacity == 1.0
|
||||
|
||||
def _set_horizontal_opacity_immediately(self, opacity: float) -> None:
|
||||
self._h_fade_animation.stop()
|
||||
self._h_opacity_effect.setOpacity(opacity)
|
||||
self._h_is_visible = opacity == 1.0
|
||||
|
||||
def _update_scroll_needed(self) -> None:
|
||||
widget = self.widget()
|
||||
if widget is None:
|
||||
self._scroll_needed = False
|
||||
self._h_scroll_needed = False
|
||||
self._set_opacity_immediately(0.0)
|
||||
self._set_horizontal_opacity_immediately(0.0)
|
||||
self._hide_timer.stop()
|
||||
self._h_hide_timer.stop()
|
||||
return
|
||||
|
||||
content_height = widget.sizeHint().height()
|
||||
content_width = widget.sizeHint().width()
|
||||
viewport_height = self.viewport().height()
|
||||
viewport_width = self.viewport().width()
|
||||
self._scroll_needed = content_height > viewport_height
|
||||
self._h_scroll_needed = content_width > viewport_width
|
||||
|
||||
if not self._scroll_needed:
|
||||
self._set_opacity_immediately(0.0)
|
||||
@@ -1013,58 +918,27 @@ class AutoHideScrollArea(QScrollArea):
|
||||
if not self._is_visible:
|
||||
self._set_opacity_immediately(0.0)
|
||||
|
||||
if not self._h_scroll_needed:
|
||||
self._set_horizontal_opacity_immediately(0.0)
|
||||
self._h_hide_timer.stop()
|
||||
elif not self._h_is_visible:
|
||||
self._set_horizontal_opacity_immediately(0.0)
|
||||
|
||||
def _on_scroll(self, value: int) -> None:
|
||||
if not self._scroll_needed:
|
||||
return
|
||||
self._start_fade_in()
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def _on_horizontal_scroll(self, value: int) -> None:
|
||||
if not self._h_scroll_needed:
|
||||
return
|
||||
self._start_horizontal_fade_in()
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def enterEvent(self, event):
|
||||
if self._scroll_needed:
|
||||
self._start_fade_in()
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
if self._h_scroll_needed:
|
||||
self._start_horizontal_fade_in()
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
if self._scroll_needed and self._is_visible:
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
if self._h_scroll_needed and self._h_is_visible:
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
widget = self.widget()
|
||||
horizontal_library = widget is not None and widget.property(
|
||||
"library_layout_mode"
|
||||
) in {"horizontal", "horizontal_top"}
|
||||
if self._h_scrollbar.maximum() > 0 and (
|
||||
horizontal_library or self._v_scrollbar.maximum() == 0
|
||||
):
|
||||
self._h_scrollbar.event(event)
|
||||
return
|
||||
super().wheelEvent(event)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if not hasattr(self, "_v_scrollbar") or not hasattr(self, "_h_scrollbar"):
|
||||
if not hasattr(self, "_v_scrollbar"):
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Resize:
|
||||
self._update_scroll_needed()
|
||||
if obj == self.viewport():
|
||||
self.viewportResized.emit()
|
||||
elif obj == self._v_scrollbar:
|
||||
if event.type() == QEvent.Type.Enter:
|
||||
if self._scroll_needed:
|
||||
@@ -1073,14 +947,6 @@ class AutoHideScrollArea(QScrollArea):
|
||||
elif event.type() == QEvent.Type.Leave:
|
||||
if self._scroll_needed and self._is_visible:
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
elif obj == self._h_scrollbar:
|
||||
if event.type() == QEvent.Type.Enter:
|
||||
if self._h_scroll_needed:
|
||||
self._start_horizontal_fade_in()
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
elif event.type() == QEvent.Type.Leave:
|
||||
if self._h_scroll_needed and self._h_is_visible:
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def setWidget(self, widget: QWidget | None) -> None:
|
||||
|
||||
@@ -613,23 +613,16 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(play_button)
|
||||
|
||||
if source in {"steam", "gog", "egs"} and appid:
|
||||
if str(game_source).lower() == "steam" and appid:
|
||||
edit_button = self._make_action_button(
|
||||
_("Edit Shortcut"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
if source == "steam":
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
|
||||
game_name, exec_line, cover_path, appid
|
||||
)
|
||||
)
|
||||
else:
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager._edit_id_shortcut(
|
||||
game_name, cover_path, (source, str(appid))
|
||||
)
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
|
||||
game_name, exec_line, cover_path, appid
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(edit_button)
|
||||
elif self._has_game_shortcut(game_name):
|
||||
edit_button = self._make_action_button(
|
||||
@@ -1023,9 +1016,6 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
content_frame_layout.addLayout(page_data["buttons_layout"])
|
||||
content_frame_layout.addSpacing(
|
||||
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
|
||||
)
|
||||
|
||||
def _get_compact_content_spacing(self) -> int:
|
||||
return getattr(
|
||||
@@ -1201,9 +1191,6 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
content_frame_layout.addLayout(page_data["buttons_layout"])
|
||||
content_frame_layout.addSpacing(
|
||||
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
|
||||
)
|
||||
|
||||
main_layout = self._get_main_layout(detail_page)
|
||||
if main_layout:
|
||||
@@ -1454,8 +1441,6 @@ class DetailPageManager:
|
||||
resolved_exe_path = 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():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
|
||||
@@ -745,14 +745,14 @@ def _extract_exe_name(line: str) -> str:
|
||||
|
||||
|
||||
def _extract_exe_path(line: str) -> str:
|
||||
paths = re.findall(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
|
||||
for path in paths:
|
||||
drive_index = path.lower().find("drive_c/")
|
||||
if drive_index >= 0:
|
||||
return path[drive_index:].lower()
|
||||
if "/" in path and not path.startswith("$"):
|
||||
return path.lower()
|
||||
return ""
|
||||
match = re.search(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
|
||||
if not match:
|
||||
return ""
|
||||
path = match.group(1)
|
||||
drive_index = path.lower().find("drive_c/")
|
||||
if drive_index < 0:
|
||||
return ""
|
||||
return path[drive_index:].lower()
|
||||
|
||||
|
||||
def _path_matches_autoinstall(exec_path: str, autoinstall_path: str) -> bool:
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
"""Base dialogs for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
import tempfile
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from typing import 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
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
import psutil
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
THEMED_LAUNCH_ICON_NAMES,
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
get_portproton_location,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -38,7 +28,6 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
@@ -136,229 +125,9 @@ class GameLaunchDialog(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class GameFolderScanWorker(QThread):
|
||||
"""Find executable files recursively and report scanned directory counts."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, folder: str, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.folder = folder
|
||||
self.games: list[tuple[str, str]] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
MAX_SCAN_DEPTH = 5
|
||||
MAX_SCAN_DIRECTORIES = 400
|
||||
GAME_SUBDIRECTORIES = frozenset((
|
||||
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
|
||||
))
|
||||
|
||||
def run(self) -> None:
|
||||
for directory, paths in self._find_executables(self.folder).items():
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
executable = max(paths, key=lambda path: self._executable_score(path, directory))
|
||||
self.games.append((self._game_name(executable, directory), executable))
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
self.games.sort(key=lambda game: game[1].casefold())
|
||||
|
||||
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
|
||||
name = Path(executable).stem.casefold()
|
||||
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
|
||||
key = re.sub(r"[\W_]", "", name)
|
||||
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
|
||||
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
|
||||
depth = len(Path(executable).relative_to(directory).parts)
|
||||
return match - int(launcher), -depth, os.path.getsize(executable)
|
||||
|
||||
def _executable_name(self, executable: str) -> str:
|
||||
try:
|
||||
with pefile.PE(executable, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", [])]
|
||||
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for table in tables for key, value in table.entries.items()}
|
||||
except pefile.PEFormatError:
|
||||
return ""
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", executable, error)
|
||||
return ""
|
||||
for field in ("ProductName", "FileDescription"):
|
||||
value = metadata.get(field, "").strip()
|
||||
if value and not re.fullmatch(
|
||||
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _game_name(self, executable: str, directory: str) -> str:
|
||||
name = ""
|
||||
for info in sorted(Path(directory).glob("goggame-*.info")):
|
||||
try:
|
||||
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
|
||||
if (isinstance(data, dict) and isinstance(data.get("name"), str)
|
||||
and data.get("gameId") == data.get("rootGameId")):
|
||||
name = data["name"]
|
||||
break
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read game metadata %s: %s", info, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid game metadata %s: %s", info, error)
|
||||
if not name:
|
||||
name = self._executable_name(executable) or Path(directory).name
|
||||
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
|
||||
name = name.replace(".", " ")
|
||||
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
|
||||
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
|
||||
|
||||
def _record_error(self, error: OSError) -> None:
|
||||
logger.warning("Failed to scan game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _is_runtime_directory(self, path: str) -> bool:
|
||||
name = os.path.basename(path).casefold()
|
||||
parent = os.path.basename(os.path.dirname(path)).casefold()
|
||||
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
|
||||
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
|
||||
return True
|
||||
if name == "windows" and parent == "drive_c":
|
||||
return True
|
||||
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
|
||||
return True
|
||||
if "proton" in name or name.startswith("wine"):
|
||||
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
|
||||
return False
|
||||
|
||||
def _find_executables(self, folder: str) -> dict[str, list[str]]:
|
||||
games: dict[str, list[str]] = {}
|
||||
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
|
||||
visited: dict[str, int] = {}
|
||||
completed, total = 0, len(pending)
|
||||
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
|
||||
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
|
||||
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
|
||||
while pending:
|
||||
if self.isInterruptionRequested():
|
||||
return {}
|
||||
root, depth, branch = pending.popleft()
|
||||
label = _("Scanning: {folder}").format(folder=root)
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
|
||||
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
|
||||
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
|
||||
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
|
||||
directory = root
|
||||
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
|
||||
directory = os.path.dirname(directory)
|
||||
if files:
|
||||
games.setdefault(directory, []).extend(files)
|
||||
for entry in entries_list:
|
||||
name = entry.name.casefold()
|
||||
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
|
||||
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
|
||||
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
|
||||
continue
|
||||
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
|
||||
continue
|
||||
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
|
||||
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
|
||||
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
|
||||
break
|
||||
visited[owner] = visited.get(owner, 0) + 1
|
||||
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
|
||||
pending.append((entry.path, next_depth, owner))
|
||||
total += 1
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
completed += 1
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
return games
|
||||
|
||||
|
||||
class GameFolderImportWorker(QThread):
|
||||
"""Create selected shortcuts without overwriting existing entries."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.games = games
|
||||
self.added = 0
|
||||
self.skipped = 0
|
||||
self.errors: list[str] = []
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
raise FileNotFoundError("PortProton directory not found")
|
||||
existing = set()
|
||||
with os.scandir(location) as entries:
|
||||
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
|
||||
for index, path in enumerate(paths):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
|
||||
entry = parse_desktop_entry(path)
|
||||
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
|
||||
if target:
|
||||
existing.add(os.path.realpath(target))
|
||||
for index, (name, executable) in enumerate(self.games):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
self._import_game(name, executable, existing)
|
||||
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
|
||||
target = os.path.realpath(executable)
|
||||
if target in existing:
|
||||
self.skipped += 1
|
||||
return
|
||||
created = False
|
||||
desktop_path = ""
|
||||
try:
|
||||
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
|
||||
result = create_desktop_file(executable, name)
|
||||
if result is None:
|
||||
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
|
||||
content, desktop_path, icon_path = result
|
||||
if os.path.exists(desktop_path):
|
||||
self.skipped += 1
|
||||
return
|
||||
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
|
||||
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
|
||||
with open(desktop_path, "x", encoding="utf-8") as desktop:
|
||||
created = True
|
||||
desktop.write(content)
|
||||
os.chmod(desktop_path, 0o755)
|
||||
existing.add(target)
|
||||
self.added += 1
|
||||
except FileExistsError:
|
||||
self.skipped += 1
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import '%s': %s", executable, error)
|
||||
self.errors.append(f"{name}: {error}")
|
||||
if created:
|
||||
try:
|
||||
os.unlink(desktop_path)
|
||||
except OSError as cleanup_error:
|
||||
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
|
||||
|
||||
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
gamesAdded = Signal()
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
|
||||
super().__init__(parent)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
@@ -366,9 +135,6 @@ class AddGameDialog(DraggableDialog):
|
||||
self.edit_mode = edit_mode
|
||||
self.last_exe_path = exe_path
|
||||
self.last_cover_path = cover_path
|
||||
refresh_games = getattr(parent, "refreshGames", None)
|
||||
if callable(refresh_games):
|
||||
self.gamesAdded.connect(refresh_games)
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
self.setWindowTitle(_("Edit Shortcut") if edit_mode else _("Add a shortcut"))
|
||||
@@ -572,12 +338,6 @@ class AddGameDialog(DraggableDialog):
|
||||
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)
|
||||
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))
|
||||
folder_button.setObjectName("gamepadAddButton")
|
||||
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
|
||||
file_explorer.button_layout.insertWidget(0, folder_button)
|
||||
|
||||
parent_widget = self.parentWidget()
|
||||
if parent_widget:
|
||||
@@ -591,195 +351,6 @@ class AddGameDialog(DraggableDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in browseExe: {e}")
|
||||
|
||||
def _select_game_folder(self, explorer: "FileExplorer") -> None:
|
||||
folder = explorer.current_path
|
||||
item = explorer.file_list.currentItem()
|
||||
if item and item.text() != "../":
|
||||
selected = os.path.normpath(os.path.join(folder, item.text()))
|
||||
if os.path.isdir(selected):
|
||||
folder = selected
|
||||
self._game_folder_root = folder
|
||||
explorer.accept()
|
||||
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
|
||||
|
||||
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
|
||||
owner = cast(Any, self.parentWidget() or self)
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
owner._gameFolderWorkers = workers
|
||||
workers.append(worker)
|
||||
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
progress.setValue(0)
|
||||
progress.canceled.connect(worker.requestInterruption)
|
||||
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
completed, total, current = state
|
||||
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
|
||||
completed=completed, total=total, current=current,
|
||||
))
|
||||
progress.setRange(0, max(total, 1))
|
||||
progress.setValue(completed)
|
||||
|
||||
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
owner = self.parentWidget() or self
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
cancelled = progress.wasCanceled()
|
||||
progress.close()
|
||||
progress.deleteLater()
|
||||
worker.deleteLater()
|
||||
if worker not in workers:
|
||||
return
|
||||
workers.remove(worker)
|
||||
if isinstance(worker, GameFolderImportWorker):
|
||||
if worker.added:
|
||||
self.gamesAdded.emit()
|
||||
if cancelled:
|
||||
return
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
self.reject()
|
||||
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
if worker.games:
|
||||
self._show_folder_games(worker.games)
|
||||
else:
|
||||
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
|
||||
|
||||
def _create_folder_game_row(
|
||||
self, layout: QVBoxLayout, game: tuple[str, str],
|
||||
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
|
||||
name, executable = game
|
||||
content = layout.parentWidget()
|
||||
row = QHBoxLayout()
|
||||
checkbox = QCheckBox(content)
|
||||
checkbox.setObjectName("gameFolderExecutable")
|
||||
checkbox.setAccessibleName(executable)
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox.setChecked(True)
|
||||
title = CustomLineEdit(name, content, theme=self.theme)
|
||||
title.setAccessibleName(_("Name:"))
|
||||
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
path = CustomLineEdit(executable, content, theme=self.theme)
|
||||
path.setObjectName("gameFolderPath")
|
||||
path.setAccessibleName(_("Path to Executable:"))
|
||||
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
browse.setAutoDefault(False)
|
||||
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
browse.clicked.connect(partial(self._browse_folder_exe, path))
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(path)
|
||||
path_row.addWidget(browse)
|
||||
details = QVBoxLayout()
|
||||
details.addWidget(title)
|
||||
details.addLayout(path_row)
|
||||
row.addWidget(checkbox)
|
||||
row.addLayout(details)
|
||||
layout.addLayout(row)
|
||||
QWidget.setTabOrder(checkbox, title)
|
||||
QWidget.setTabOrder(title, path)
|
||||
QWidget.setTabOrder(path, browse)
|
||||
return title, path, checkbox
|
||||
|
||||
def _browse_folder_exe(self, field: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(
|
||||
field.window(), theme=self.theme, file_filter=(".exe",),
|
||||
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(field.setText)
|
||||
explorer.exec()
|
||||
field.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
explorer.deleteLater()
|
||||
|
||||
def _accept_folder_games(
|
||||
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
for title, path, checkbox in rows:
|
||||
if not checkbox.isChecked():
|
||||
continue
|
||||
if not title.text().strip():
|
||||
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
|
||||
title.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
executable = os.path.expanduser(path.text().strip())
|
||||
if not os.path.isfile(executable):
|
||||
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
|
||||
path.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
path.setText(executable)
|
||||
dialog.accept()
|
||||
|
||||
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
|
||||
if focused is not None and scroll.isAncestorOf(focused):
|
||||
scroll.ensureWidgetVisible(focused)
|
||||
|
||||
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
|
||||
dialog = cast(Any, DraggableDialog(self))
|
||||
dialog.setWindowTitle(_("Select games"))
|
||||
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
|
||||
layout = QVBoxLayout(dialog)
|
||||
scroll = QScrollArea(dialog)
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget(scroll)
|
||||
rows_layout = QVBoxLayout(content)
|
||||
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
|
||||
rows_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
buttons = QHBoxLayout()
|
||||
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
|
||||
apply_button.setObjectName("gamepadAddButton")
|
||||
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
|
||||
for button in (apply_button, cancel_button):
|
||||
button.setAutoDefault(False)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttons.addWidget(button)
|
||||
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
|
||||
cancel_button.clicked.connect(dialog.reject)
|
||||
layout.addLayout(buttons)
|
||||
main_window = self.parentWidget()
|
||||
input_manager = getattr(main_window, "input_manager", None)
|
||||
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
|
||||
layout.addWidget(hints)
|
||||
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
|
||||
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
|
||||
dialog.finished.connect(dialog.keyboard.hide)
|
||||
if rows:
|
||||
QTimer.singleShot(0, rows[0][2].setFocus)
|
||||
app = cast(QApplication, QApplication.instance())
|
||||
scroll_focus = partial(self._scroll_folder_focus, scroll)
|
||||
app.focusChanged.connect(scroll_focus)
|
||||
try:
|
||||
accepted = dialog.exec() == QDialog.DialogCode.Accepted
|
||||
finally:
|
||||
app.focusChanged.disconnect(scroll_focus)
|
||||
if accepted:
|
||||
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
|
||||
if checkbox.isChecked()]
|
||||
if selected:
|
||||
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
|
||||
dialog.deleteLater()
|
||||
|
||||
def onExeSelected(self, file_path):
|
||||
"""Handle file selection in FileExplorer."""
|
||||
self.exeEdit.setText(file_path)
|
||||
|
||||
@@ -3,14 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
import orjson
|
||||
|
||||
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
|
||||
)
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -20,12 +14,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.config import CACHE_DIR, proxy_config, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CompatibilityReportDialog(DraggableDialog):
|
||||
@@ -80,11 +70,6 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
def _create_buttons(self, theme) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.addStretch()
|
||||
self.ppdb_button = AutoSizeButton(_("Download") + " PPDB")
|
||||
self.ppdb_button.setStyleSheet(theme.ACTION_BUTTON_STYLE)
|
||||
self.ppdb_button.hide()
|
||||
self.ppdb_button.clicked.connect(self._download_ppdb)
|
||||
layout.addWidget(self.ppdb_button)
|
||||
close_button = AutoSizeButton(_("Close"))
|
||||
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@@ -92,81 +77,6 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
layout.addWidget(close_button)
|
||||
return layout
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
if ui_config.get_auto_download_ppdb() or not os.path.isfile(executable):
|
||||
return
|
||||
self.ppdb_path = executable + ".ppdb"
|
||||
self.ppdb_network = QNetworkAccessManager(self)
|
||||
self.finished.connect(self.ppdb_network.deleteLater)
|
||||
self.ppdb_network.setTransferTimeout(
|
||||
QNetworkRequest.TransferTimeoutConstant.DefaultTransferTimeoutConstant.value
|
||||
)
|
||||
cache = QNetworkDiskCache(self.ppdb_network)
|
||||
cache.setCacheDirectory(str(CACHE_DIR / "ppdb_lookup"))
|
||||
self.ppdb_network.setCache(cache)
|
||||
proxies = proxy_config.get_proxy() or {}
|
||||
proxy_url = QUrl(proxies.get("https") or proxies.get("http") or "")
|
||||
if proxy_url.host():
|
||||
proxy_type = (QNetworkProxy.ProxyType.Socks5Proxy
|
||||
if proxy_url.scheme().startswith("socks")
|
||||
else QNetworkProxy.ProxyType.HttpProxy)
|
||||
self.ppdb_network.setProxy(QNetworkProxy(
|
||||
proxy_type, proxy_url.host(), max(proxy_url.port(), 0),
|
||||
proxy_url.userName(), proxy_url.password(),
|
||||
))
|
||||
url = QUrl("https://linux-gaming.ru/api/lookup/exe/")
|
||||
url.setPath(url.path() + os.path.basename(executable))
|
||||
reply = self.ppdb_network.get(QNetworkRequest(url))
|
||||
reply.finished.connect(lambda: self._ppdb_lookup_finished(reply))
|
||||
|
||||
def _ppdb_lookup_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB lookup failed: %s", reply.errorString())
|
||||
return
|
||||
try:
|
||||
metadata = orjson.loads(bytes(reply.readAll().data()))
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid PPDB metadata: %s", error)
|
||||
return
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
url = metadata.get("ppdb_url")
|
||||
if not isinstance(url, str):
|
||||
return
|
||||
self.ppdb_url = QUrl(url)
|
||||
if self.ppdb_url.scheme() in {"https", "http"} and self.ppdb_url.host():
|
||||
self.ppdb_button.show()
|
||||
|
||||
def _download_ppdb(self) -> None:
|
||||
self.ppdb_button.setEnabled(False)
|
||||
self.ppdb_button.setText(_("Downloading…"))
|
||||
request = QNetworkRequest(self.ppdb_url)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.CacheLoadControlAttribute,
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
|
||||
)
|
||||
reply = self.ppdb_network.get(request)
|
||||
reply.finished.connect(lambda: self._ppdb_download_finished(reply))
|
||||
|
||||
def _ppdb_download_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
self.ppdb_button.setText(_("Download") + " PPDB")
|
||||
self.ppdb_button.setEnabled(True)
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB download failed: %s", reply.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
destination = QSaveFile(self.ppdb_path)
|
||||
data = reply.readAll()
|
||||
if (data.isEmpty() or not destination.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
or destination.write(data) != data.size() or not destination.commit()):
|
||||
logger.warning("Failed to save PPDB: %s", destination.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
self.ppdb_button.setText(_("Downloading Complete"))
|
||||
self.ppdb_button.setEnabled(False)
|
||||
|
||||
def _show_context_menu(
|
||||
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
|
||||
) -> None:
|
||||
|
||||
@@ -25,12 +25,7 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
|
||||
dialog_actions = []
|
||||
|
||||
if context == 'folder_import':
|
||||
dialog_actions = [
|
||||
("add_game", _("Apply")),
|
||||
("back", _("Close")),
|
||||
]
|
||||
elif context == 'file_explorer':
|
||||
if context == 'file_explorer':
|
||||
dialog_actions = [
|
||||
("confirm", _("Open")),
|
||||
("add_game", _("Select Dir")),
|
||||
@@ -50,9 +45,10 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
elif context == 'settings':
|
||||
dialog_actions = [
|
||||
("confirm", _("Toggle")),
|
||||
("add_game", _("Save")),
|
||||
("prev_dir", _("Search")),
|
||||
("context_menu", _("Menu")),
|
||||
("back", _("Close")),
|
||||
("back", _("Cancel")),
|
||||
("prev_tab", _("Prev Tab")),
|
||||
("next_tab", _("Next Tab")),
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -9,7 +10,7 @@ from PySide6.QtWidgets import (
|
||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout,
|
||||
QWidget, QCheckBox, QHeaderView, QMessageBox, QLabel, QTextEdit,
|
||||
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
|
||||
QStackedWidget
|
||||
QStackedWidget, QPushButton
|
||||
)
|
||||
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
|
||||
@@ -17,7 +18,6 @@ from shiboken6 import isValid
|
||||
|
||||
from portprotonqt.config import get_portproton_start_command, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.localization import _
|
||||
@@ -37,6 +37,40 @@ WINE_TABLE_ROW_BATCH_SIZE = 8
|
||||
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
|
||||
|
||||
|
||||
def sort_wine_entries(entries: list[dict], source_name: str) -> list[dict]:
|
||||
"""Sort Wine entries, alternating CachyOS and WineLand releases."""
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
return sorted(entries, key=version_sort_key)
|
||||
|
||||
release_groups = []
|
||||
for is_wineland in (False, True):
|
||||
family_entries = sorted(
|
||||
(
|
||||
entry for entry in entries
|
||||
if ('proton-cachyos-wineland-' in entry.get('name', '').lower())
|
||||
== is_wineland
|
||||
),
|
||||
key=version_sort_key,
|
||||
)
|
||||
groups = []
|
||||
for entry in family_entries:
|
||||
release_name = re.sub(
|
||||
r'-(?:x86_64|arm64|aarch64)(?:_v\d+|-wow64)?$',
|
||||
'', entry.get('name', '').lower(),
|
||||
)
|
||||
if not groups or groups[-1][0] != release_name:
|
||||
groups.append((release_name, []))
|
||||
groups[-1][1].append(entry)
|
||||
release_groups.append(groups)
|
||||
|
||||
sorted_entries = []
|
||||
for index in range(max(map(len, release_groups), default=0)):
|
||||
for groups in release_groups:
|
||||
if index < len(groups):
|
||||
sorted_entries.extend(groups[index][1])
|
||||
return sorted_entries
|
||||
|
||||
|
||||
class ProtonManager(DraggableDialog):
|
||||
"""Dialog for managing Proton/Wine versions."""
|
||||
|
||||
@@ -59,7 +93,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.main_window = None
|
||||
parent_widget = self.parent()
|
||||
while parent_widget:
|
||||
if hasattr(parent_widget, 'get_button_icon'):
|
||||
if hasattr(parent_widget, 'input_manager'):
|
||||
self.main_window = parent_widget
|
||||
break
|
||||
parent_widget = parent_widget.parent()
|
||||
@@ -69,14 +103,6 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.enable_proton_manager_mode()
|
||||
if self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_hints()
|
||||
|
||||
@staticmethod
|
||||
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
|
||||
@@ -248,7 +274,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.download_progress = QProgressBar()
|
||||
self.download_progress.setMinimum(0)
|
||||
self.download_progress.setMaximum(100)
|
||||
self.cancel_btn = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
|
||||
self.cancel_btn = QPushButton(_('Cancel'))
|
||||
self.cancel_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_btn.clicked.connect(self.cancel_current_download)
|
||||
progress_layout.addWidget(self.download_progress, 4)
|
||||
@@ -264,18 +290,18 @@ class ProtonManager(DraggableDialog):
|
||||
layout.addWidget(self.hints_widget)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.download_btn = AutoSizeButton(_('Download Selected'), icon=theme_manager.get_icon('apply', as_path=True))
|
||||
self.download_btn = QPushButton(_('Download Selected'))
|
||||
self.download_btn.clicked.connect(self.download_selected)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.download_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clear_btn = AutoSizeButton(_('Clear All'), icon=theme_manager.get_icon('delete', as_path=True))
|
||||
self.clear_btn = QPushButton(_('Clear All'))
|
||||
self.clear_btn.clicked.connect(self.clear_selection)
|
||||
self.clear_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.open_folder_btn = AutoSizeButton(_('Open Folder'), icon=theme_manager.get_icon('folder', as_path=True))
|
||||
self.open_folder_btn = QPushButton(_('Open Folder'))
|
||||
self.open_folder_btn.clicked.connect(self._open_wine_folder)
|
||||
self.open_folder_btn.setEnabled(bool(self.portproton_location))
|
||||
self.open_folder_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_btn_dialog = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
|
||||
self.cancel_btn_dialog = QPushButton(_('Cancel'))
|
||||
self.cancel_btn_dialog.clicked.connect(self.reject)
|
||||
self.cancel_btn_dialog.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button_layout.addWidget(self.download_btn)
|
||||
@@ -286,6 +312,18 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||||
|
||||
if self.input_manager and self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
@@ -314,7 +352,7 @@ class ProtonManager(DraggableDialog):
|
||||
tabs = []
|
||||
for source_name, entries in metadata.items():
|
||||
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
|
||||
tabs.append((source_name, sorted(filtered_entries, key=version_sort_key)))
|
||||
tabs.append((source_name, sort_wine_entries(filtered_entries, source_name)))
|
||||
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
|
||||
self.selected_assets.clear()
|
||||
self.tab_widget.clear()
|
||||
@@ -380,14 +418,15 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def filter_entries_by_cpu_level(self, entries, source_name):
|
||||
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
|
||||
filtered_entries = []
|
||||
for entry in entries:
|
||||
entry_info = f"{entry.get('name', '')} {entry.get('url', '')}".lower()
|
||||
entry_is_arm = 'aarch64' in entry_info or '-arm' in entry_info
|
||||
if entry_is_arm == is_arm:
|
||||
filtered_entries.append(entry)
|
||||
entries = filtered_entries
|
||||
if source_name.lower() not in ('proton_cachyos', 'proton_wineland'):
|
||||
if not is_arm:
|
||||
entries = [
|
||||
entry for entry in entries
|
||||
if 'aarch64' not in entry.get('name', '').lower()
|
||||
and '-arm' not in entry.get('name', '').lower()
|
||||
and 'aarch64' not in entry.get('url', '').lower()
|
||||
and '-arm' not in entry.get('url', '').lower()
|
||||
]
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
return entries
|
||||
if self.cpu_level >= 4:
|
||||
return entries
|
||||
@@ -444,7 +483,7 @@ class ProtonManager(DraggableDialog):
|
||||
if url_filename:
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries.sort(key=version_sort_key)
|
||||
all_entries = sort_wine_entries(all_entries, source_name)
|
||||
table = self._create_empty_wine_tab(source_name, len(all_entries))
|
||||
for row_index, entry in enumerate(all_entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
@@ -634,16 +673,15 @@ class ProtonManager(DraggableDialog):
|
||||
installed_versions = []
|
||||
seen_paths = set()
|
||||
version_dirs = [dist_path]
|
||||
if not ui_config.get_disable_steam_proton_scanning():
|
||||
steam_dir = get_steam_compatibilitytools_dir()
|
||||
if steam_dir is not None:
|
||||
version_dirs.append(str(steam_dir))
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is not None:
|
||||
for steam_lib in get_steam_libs(steam_home):
|
||||
common_dir = steam_lib / "steamapps" / "common"
|
||||
if common_dir.is_dir():
|
||||
version_dirs.append(str(common_dir))
|
||||
steam_dir = get_steam_compatibilitytools_dir()
|
||||
if steam_dir is not None:
|
||||
version_dirs.append(str(steam_dir))
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is not None:
|
||||
for steam_lib in get_steam_libs(steam_home):
|
||||
common_dir = steam_lib / "steamapps" / "common"
|
||||
if common_dir.is_dir():
|
||||
version_dirs.append(str(common_dir))
|
||||
|
||||
for version_dir in version_dirs:
|
||||
for version_name in os.listdir(version_dir):
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -152,14 +151,8 @@ MANGOHUD_VALUE_SPECS = [
|
||||
'options': [f'{i / 10:.1f}' for i in range(11)]},
|
||||
{'key': 'round_corners', 'label': _("Round corners (px)"), 'type': 'combo',
|
||||
'options': [str(i) for i in range(16)]},
|
||||
{'key': 'font_scale', 'label': _("Font scale"), 'type': 'combo',
|
||||
'options': ['', '0.5', '0.75', '1.0', '1.25', '1.5', '2.0']},
|
||||
{'key': 'font_size', 'label': _("Font size"), 'type': 'line'},
|
||||
{'key': 'font_file', 'label': _("Font"), 'type': 'combo', 'options': []},
|
||||
]
|
||||
|
||||
MANGOHUD_FONT_KEYS = {'font_scale', 'font_size', 'font_file'}
|
||||
|
||||
MANGOHUD_VALUE_OPTION_TRANSLATIONS = {
|
||||
'position': {
|
||||
'top-left': _("Top-left"),
|
||||
@@ -398,7 +391,6 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_presets_group = None
|
||||
self.mangohud_toggle_group = None
|
||||
self.mangohud_values_group = None
|
||||
self.mangohud_fonts_group = None
|
||||
self.mangohud_fps_group = None
|
||||
self.mangohud_extra_group = None
|
||||
self.mangohud_action_buttons = []
|
||||
@@ -420,7 +412,6 @@ class MangoHudSettingsMixin:
|
||||
self._add_mangohud_presets_group(layout)
|
||||
self._add_mangohud_toggle_group(layout)
|
||||
self._add_mangohud_values_group(layout)
|
||||
self._add_mangohud_fonts_group(layout)
|
||||
self._add_mangohud_fps_group(layout)
|
||||
self._add_mangohud_extra_group(layout)
|
||||
layout.addStretch()
|
||||
@@ -442,8 +433,6 @@ class MangoHudSettingsMixin:
|
||||
gpu_count = sum(1 for _text, value in self.mangohud_gpu_options if value.isdigit())
|
||||
|
||||
for spec in MANGOHUD_VALUE_SPECS:
|
||||
if spec['key'] in MANGOHUD_FONT_KEYS:
|
||||
continue
|
||||
if spec['key'] == 'fps_limit_method':
|
||||
continue
|
||||
if spec['key'] == 'gpu_list' and gpu_count < 2:
|
||||
@@ -452,32 +441,8 @@ class MangoHudSettingsMixin:
|
||||
|
||||
parent_layout.addWidget(group)
|
||||
|
||||
def _add_mangohud_fonts_group(self, parent_layout):
|
||||
"""Add MangoHud font controls."""
|
||||
group = QGroupBox(_("Fonts"))
|
||||
group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
form = QFormLayout(group)
|
||||
form.setVerticalSpacing(self.theme.exeSettingsGroupBoxElementVerticalSpacing)
|
||||
form.setHorizontalSpacing(self.theme.exeSettingsGroupBoxElementHorizontalSpacing)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||
self.mangohud_fonts_group = group
|
||||
|
||||
for spec in MANGOHUD_VALUE_SPECS:
|
||||
if spec['key'] in MANGOHUD_FONT_KEYS:
|
||||
form.addRow(spec['label'], self._create_mangohud_value_widget(spec))
|
||||
|
||||
parent_layout.addWidget(group)
|
||||
|
||||
def _create_mangohud_value_widget(self, spec):
|
||||
"""Create a MangoHud value widget."""
|
||||
if spec['type'] == 'line':
|
||||
widget = QLineEdit()
|
||||
widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
widget.installEventFilter(cast(QWidget, self))
|
||||
widget.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.mangohud_widgets[spec['key']] = widget
|
||||
return widget
|
||||
|
||||
widget = CustomComboBox(theme=self.theme)
|
||||
widget.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
@@ -493,10 +458,6 @@ class MangoHudSettingsMixin:
|
||||
gpu_options = self.mangohud_gpu_options or self._get_mangohud_gpu_options()
|
||||
for text, value in gpu_options:
|
||||
widget.addItem(text, value)
|
||||
elif spec['key'] == 'font_file':
|
||||
widget.addItem(placeholder_text, '')
|
||||
for text, value in self._get_mangohud_font_options():
|
||||
widget.addItem(text, value)
|
||||
else:
|
||||
value_translations = MANGOHUD_VALUE_OPTION_TRANSLATIONS.get(spec['key'], {})
|
||||
for option in options:
|
||||
@@ -514,30 +475,6 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_widgets[spec['key']] = widget
|
||||
return widget
|
||||
|
||||
def _get_mangohud_font_options(self) -> list[tuple[str, str]]:
|
||||
"""Get installed font names and files from fontconfig."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['fc-list', '--format=%{family[0]} %{style[0]}\t%{file}\n'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=3,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("Failed to list system fonts: %s", exc)
|
||||
return []
|
||||
if result.returncode != 0:
|
||||
logger.warning("fc-list failed with exit code %s", result.returncode)
|
||||
return []
|
||||
|
||||
fonts = set()
|
||||
for line in result.stdout.splitlines():
|
||||
name, separator, path = line.partition('\t')
|
||||
if separator and path.lower().endswith(('.ttf', '.otf')):
|
||||
fonts.add((name.strip(), path.strip()))
|
||||
return sorted(fonts, key=lambda font: font[0].lower())
|
||||
|
||||
def _add_mangohud_presets_group(self, parent_layout):
|
||||
"""Add preset buttons for common MangoHud layouts."""
|
||||
presets_group = QGroupBox(_("Presets"))
|
||||
@@ -929,8 +866,6 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_presets_group.setVisible(config_visible)
|
||||
if self.mangohud_values_group is not None:
|
||||
self.mangohud_values_group.setVisible(config_visible)
|
||||
if self.mangohud_fonts_group is not None:
|
||||
self.mangohud_fonts_group.setVisible(config_visible)
|
||||
if self.mangohud_fps_group is not None:
|
||||
self.mangohud_fps_group.setVisible(config_visible)
|
||||
if self.mangohud_extra_group is not None:
|
||||
@@ -1130,9 +1065,6 @@ class MangoHudSettingsMixin:
|
||||
if widget is None:
|
||||
return
|
||||
text = value if isinstance(value, str) else ''
|
||||
if isinstance(widget, QLineEdit):
|
||||
widget.setText(text)
|
||||
return
|
||||
index = widget.findData(text)
|
||||
if text and index < 0:
|
||||
widget.addItem(text, text)
|
||||
@@ -1247,13 +1179,10 @@ class MangoHudSettingsMixin:
|
||||
if widget is None:
|
||||
return ''
|
||||
|
||||
if isinstance(widget, QLineEdit):
|
||||
value = widget.text().strip()
|
||||
else:
|
||||
current_data = widget.currentData()
|
||||
value = '' if current_data is None else str(current_data).strip()
|
||||
if not value and current_data is None:
|
||||
value = widget.currentText().strip()
|
||||
current_data = widget.currentData()
|
||||
value = '' if current_data is None else str(current_data).strip()
|
||||
if not value and current_data is None:
|
||||
value = widget.currentText().strip()
|
||||
if not value:
|
||||
return ''
|
||||
parsed_original, _raw_tokens = self._parse_mangohud_config(
|
||||
@@ -1291,9 +1220,6 @@ class MangoHudSettingsMixin:
|
||||
if new_user_conf != orig_user_conf:
|
||||
changes.append(f"PW_MANGOHUD_USER_CONF={new_user_conf}")
|
||||
|
||||
if not mangohud_enabled:
|
||||
return changes
|
||||
|
||||
config_value = self._build_mangohud_config()
|
||||
if config_value != self.mangohud_original_values.get('MANGOHUD_CONFIG', ''):
|
||||
changes.append(f"MANGOHUD_CONFIG={config_value}")
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtWidgets import (
|
||||
QVBoxLayout, QHBoxLayout, QTextEdit, QTabWidget,
|
||||
QTableWidget, QHeaderView, QTableWidgetItem, QAbstractItemView,
|
||||
QStackedWidget, QWidget, QMessageBox, QCheckBox
|
||||
QStackedWidget, QWidget, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
|
||||
|
||||
@@ -109,7 +109,7 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE + self.theme.TAB_STYLE)
|
||||
|
||||
table_base_style = self.theme.GETWINE_WINDOW_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
|
||||
table_base_style = self.theme.WINETRICKS_TABBLE_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
|
||||
|
||||
self.dll_table = QTableWidget()
|
||||
self.dll_table.setAlternatingRowColors(True)
|
||||
@@ -120,8 +120,7 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.dll_table.setColumnCount(3)
|
||||
self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")])
|
||||
self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.dll_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
|
||||
self.dll_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
|
||||
self.dll_table.horizontalHeader().resizeSection(0, 50)
|
||||
self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.dll_table.setStyleSheet(table_base_style)
|
||||
@@ -153,8 +152,7 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.fonts_table.setColumnCount(3)
|
||||
self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")])
|
||||
self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.fonts_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
|
||||
self.fonts_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
|
||||
self.fonts_table.horizontalHeader().resizeSection(0, 50)
|
||||
self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.fonts_table.setStyleSheet(table_base_style)
|
||||
@@ -186,8 +184,7 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.settings_table.setColumnCount(3)
|
||||
self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")])
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.settings_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
|
||||
self.settings_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
|
||||
self.settings_table.horizontalHeader().resizeSection(0, 50)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.settings_table.setStyleSheet(table_base_style)
|
||||
@@ -210,13 +207,6 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.settings_container.addWidget(self.settings_table)
|
||||
self.tab_widget.addTab(self.settings_container, _("Settings"))
|
||||
|
||||
for table in (self.dll_table, self.fonts_table, self.settings_table):
|
||||
table.currentCellChanged.connect(
|
||||
lambda row, *_previous, table=table: table.setFocusProxy(
|
||||
cast(QCheckBox, table.cellWidget(row, 0).findChild(QCheckBox))
|
||||
) if table.cellWidget(row, 0) else None
|
||||
)
|
||||
|
||||
self.containers = {
|
||||
"dlls": self.dll_container,
|
||||
"fonts": self.fonts_container,
|
||||
@@ -369,19 +359,14 @@ class WinetricksDialog(DraggableDialog):
|
||||
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
|
||||
continue
|
||||
|
||||
checked = name in installed
|
||||
checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked
|
||||
|
||||
row = table.rowCount()
|
||||
table.insertRow(row)
|
||||
|
||||
checkbox_widget = QWidget()
|
||||
checkbox_layout = QHBoxLayout(checkbox_widget)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(checked)
|
||||
checkbox_layout.addWidget(checkbox)
|
||||
table.setCellWidget(row, 0, checkbox_widget)
|
||||
checkbox = QTableWidgetItem()
|
||||
checkbox.setCheckState(checked)
|
||||
table.setItem(row, 0, checkbox)
|
||||
|
||||
name_item = QTableWidgetItem(name)
|
||||
table.setItem(row, 1, name_item)
|
||||
@@ -389,17 +374,13 @@ class WinetricksDialog(DraggableDialog):
|
||||
info_item = QTableWidgetItem(info)
|
||||
table.setItem(row, 2, info_item)
|
||||
|
||||
if table is self.dll_table or table is self.fonts_table:
|
||||
table.sortItems(1, Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def install_selected(self, force=False):
|
||||
"""Install selected components."""
|
||||
selected = []
|
||||
for table in [self.dll_table, self.fonts_table, self.settings_table]:
|
||||
for row in range(table.rowCount()):
|
||||
checkbox_widget = table.cellWidget(row, 0)
|
||||
checkbox = checkbox_widget.findChild(QCheckBox) if checkbox_widget else None
|
||||
if checkbox is not None and checkbox.isChecked():
|
||||
checkbox = table.item(row, 0)
|
||||
if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked:
|
||||
name_item = table.item(row, 1)
|
||||
if name_item is not None:
|
||||
name = name_item.text()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from PySide6.QtCore import QObject, Signal, QThread, Qt, QCoreApplication
|
||||
from PySide6.QtCore import QObject, Signal, QThread
|
||||
import threading
|
||||
import os
|
||||
import time
|
||||
@@ -70,7 +70,7 @@ class Downloader(QObject):
|
||||
self._locks = {}
|
||||
self._active_threads: list[QThread] = []
|
||||
self._global_lock = threading.Lock()
|
||||
self.callback_requested.connect(self._run_callback, Qt.ConnectionType.QueuedConnection)
|
||||
self.callback_requested.connect(self._run_callback)
|
||||
|
||||
def _run_callback(self, callback, result):
|
||||
if callback:
|
||||
@@ -240,14 +240,11 @@ class Downloader(QObject):
|
||||
on_result(url, None)
|
||||
emitted_urls.add(url)
|
||||
continue
|
||||
cached_path = self._cache.get(url)
|
||||
if (cached_path and os.path.exists(cached_path)
|
||||
and os.path.abspath(cached_path) == os.path.abspath(path)):
|
||||
if url in self._cache:
|
||||
if on_result and url not in emitted_urls:
|
||||
on_result(url, cached_path)
|
||||
on_result(url, self._cache[url])
|
||||
emitted_urls.add(url)
|
||||
continue
|
||||
self._cache.pop(url, None)
|
||||
filtered_urls.append(url)
|
||||
filtered_paths.append(path)
|
||||
|
||||
@@ -264,7 +261,7 @@ class Downloader(QObject):
|
||||
throttle_delay,
|
||||
workers,
|
||||
on_result=emit_result,
|
||||
) if filtered_urls else {}
|
||||
)
|
||||
|
||||
with self._global_lock:
|
||||
for url, path in results.items():
|
||||
@@ -273,9 +270,7 @@ class Downloader(QObject):
|
||||
final_results = {}
|
||||
with self._global_lock:
|
||||
for url in urls:
|
||||
if url in self._last_error:
|
||||
final_results[url] = None
|
||||
elif url in self._cache:
|
||||
if url in self._cache:
|
||||
final_results[url] = self._cache[url]
|
||||
else:
|
||||
final_results[url] = None
|
||||
@@ -286,18 +281,7 @@ class Downloader(QObject):
|
||||
return final_results
|
||||
|
||||
|
||||
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread | None:
|
||||
with self._global_lock:
|
||||
previous_error = url in self._last_error
|
||||
cached = not previous_error and os.path.exists(local_path)
|
||||
if cached:
|
||||
self._cache[url] = local_path
|
||||
if previous_error or cached:
|
||||
result = local_path if cached else None
|
||||
self.download_completed.emit(url, result or "", cached)
|
||||
self.callback_requested.emit(callback, result)
|
||||
return None
|
||||
|
||||
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread:
|
||||
class DownloadThread(QThread):
|
||||
def __init__(self, downloader: 'Downloader', url: str, local_path: str, timeout: int, parallel: bool):
|
||||
super().__init__()
|
||||
@@ -324,11 +308,7 @@ class Downloader(QObject):
|
||||
self.downloader.callback_requested.emit(callback, None)
|
||||
|
||||
thread = DownloadThread(self, url, local_path, timeout, parallel)
|
||||
thread.moveToThread(self.thread())
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
app = QCoreApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(thread.wait)
|
||||
|
||||
# Remove from list after completion
|
||||
def cleanup():
|
||||
|
||||
+5
-25
@@ -38,32 +38,17 @@ class EGSAPI:
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "launcher" / "egs"
|
||||
self.bin_dir = data_home / "PortProtonQt" / "bin"
|
||||
# Remove after the legacy launcher storage migration period.
|
||||
legacy_dir = data_home / "PortProtonQt" / "egs"
|
||||
if legacy_dir.exists() and not self.data_dir.exists():
|
||||
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_dir.rename(self.data_dir)
|
||||
legacy_bin = self.data_dir / "bin"
|
||||
if legacy_bin.is_dir():
|
||||
self.bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
for legacy_binary in legacy_bin.iterdir():
|
||||
target = self.bin_dir / legacy_binary.name
|
||||
if not target.exists():
|
||||
legacy_binary.rename(target)
|
||||
if not any(legacy_bin.iterdir()):
|
||||
legacy_bin.rmdir()
|
||||
self.data_dir = data_home / "PortProtonQt" / "egs"
|
||||
self.config_dir = self.data_dir / "legendary"
|
||||
self.user_path = self.config_dir / "user.json"
|
||||
self.library_path = self.data_dir / "library.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
self.version_path = self.bin_dir / "legendary.version"
|
||||
self.version_path = self.data_dir / "bin/legendary.version"
|
||||
self.games_dir = Path.home() / "Games"
|
||||
|
||||
def get_legendary_path(self) -> str | None:
|
||||
"""Return an available Legendary executable."""
|
||||
bundled = self.bin_dir / "legendary"
|
||||
bundled = self.data_dir / "bin/legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("legendary")
|
||||
@@ -78,7 +63,7 @@ class EGSAPI:
|
||||
|
||||
def update_legendary(self) -> str:
|
||||
"""Update the bundled Legendary binary when a newer release exists."""
|
||||
bundled = self.bin_dir / "legendary"
|
||||
bundled = self.data_dir / "bin/legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.version_path.stat().st_mtime
|
||||
@@ -124,7 +109,7 @@ class EGSAPI:
|
||||
return ""
|
||||
|
||||
def _download_legendary(self, asset: dict, release_tag: str) -> str:
|
||||
target = self.bin_dir / "legendary"
|
||||
target = self.data_dir / "bin/legendary"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
@@ -233,8 +218,6 @@ class EGSAPI:
|
||||
games = []
|
||||
for game in raw_games:
|
||||
metadata = game.get("metadata", {})
|
||||
if metadata.get("mainGameItem"):
|
||||
continue
|
||||
release_info = metadata.get("releaseInfo", [])
|
||||
mobile_only = bool(release_info) and all(
|
||||
info.get("platform")
|
||||
@@ -461,9 +444,6 @@ class EGSAPI:
|
||||
"description": str(metadata.get("description", "")),
|
||||
"cover": cover,
|
||||
"namespace": str(metadata.get("namespace", "")),
|
||||
"folder_name": str(metadata.get("customAttributes", {}).get(
|
||||
"FolderName", {}
|
||||
).get("value") or game.get("app_name") or game.get("appName") or ""),
|
||||
"description_locale": "",
|
||||
"description_preference": "",
|
||||
"description_cache_version": 0,
|
||||
|
||||
+24
-92
@@ -277,19 +277,7 @@ class AnimatedCard(QFrame):
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
parent = self.parentWidget()
|
||||
layout = parent.layout() if parent is not None else None
|
||||
if getattr(self, "list_layout", False) and isinstance(layout, QVBoxLayout):
|
||||
row_pitch = self.height() + layout.spacing()
|
||||
row = (self.y() - layout.contentsMargins().top()) // max(1, row_pitch)
|
||||
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
|
||||
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
|
||||
self.animations.paint_border(painter)
|
||||
if getattr(self, "batch_selected", False):
|
||||
painter.setPen(QPen(QColor(self.theme.color_accent),
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"]))
|
||||
painter.drawRect(self.rect())
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
@@ -325,7 +313,7 @@ class AnimatedCard(QFrame):
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.position().toPoint()):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
@@ -334,7 +322,6 @@ class AnimatedCard(QFrame):
|
||||
|
||||
|
||||
class GameCard(AnimatedCard):
|
||||
coverReady = Signal(object)
|
||||
editShortcutRequested = Signal(str, str, str)
|
||||
deleteGameRequested = Signal(str, str)
|
||||
addToMenuRequested = Signal(str, str)
|
||||
@@ -353,7 +340,6 @@ class GameCard(AnimatedCard):
|
||||
card_width=250, parent=None, context_menu_manager=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.coverReady.connect(self.on_cover_loaded, Qt.ConnectionType.QueuedConnection)
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.cover_path = cover_path
|
||||
@@ -379,7 +365,6 @@ class GameCard(AnimatedCard):
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.batch_selected = False
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
@@ -412,8 +397,7 @@ class GameCard(AnimatedCard):
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = self._get_layout_config(self.theme, config_name)
|
||||
self.card_geometry_cfg = self._get_card_geometry_config(self.theme)
|
||||
self.card_layout_cfg = getattr(self.theme, config_name, {})
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
card_style_name = (
|
||||
@@ -469,7 +453,6 @@ class GameCard(AnimatedCard):
|
||||
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
||||
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
||||
self.is_favorite = self.name in set(favorites_config.get_games())
|
||||
self._favorite_icon_state = self.is_favorite
|
||||
self.update_favorite_icon()
|
||||
self.favoriteLabel.raise_()
|
||||
if self.list_layout:
|
||||
@@ -568,8 +551,6 @@ class GameCard(AnimatedCard):
|
||||
self.nameLabel.setContentsMargins(0, 0, 10, 0)
|
||||
else:
|
||||
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if self.horizontal_layout:
|
||||
self.nameLabel.setWordWrap(True)
|
||||
name_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
|
||||
if self.vertical_layout
|
||||
@@ -589,7 +570,7 @@ class GameCard(AnimatedCard):
|
||||
strict=True,
|
||||
):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label.setProperty(
|
||||
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
|
||||
)
|
||||
@@ -627,8 +608,7 @@ class GameCard(AnimatedCard):
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = self._get_layout_config(theme, config_name)
|
||||
self.card_geometry_cfg = self._get_card_geometry_config(theme)
|
||||
self.card_layout_cfg = getattr(theme, config_name, {})
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
spacing = self.card_layout_cfg.get("spacing", 12 if self.list_layout else 5)
|
||||
@@ -685,43 +665,6 @@ class GameCard(AnimatedCard):
|
||||
else:
|
||||
self.update()
|
||||
|
||||
def _get_card_geometry_config(self, theme: Any) -> dict:
|
||||
theme_orientation = self.card_layout_cfg.get(
|
||||
"card_orientation", "horizontal"
|
||||
)
|
||||
if (
|
||||
self.horizontal_layout
|
||||
and ui_config.get_horizontal_card_orientation(theme_orientation)
|
||||
== "vertical"
|
||||
):
|
||||
return getattr(theme, "GAME_CARD_GRID", {})
|
||||
return self.card_layout_cfg
|
||||
|
||||
@staticmethod
|
||||
def _get_layout_config(theme: Any, config_name: str) -> dict:
|
||||
config = getattr(theme, config_name, {})
|
||||
get_override = getattr(theme, "get_theme_override", None)
|
||||
if not callable(get_override) or get_override(config_name) is not None:
|
||||
return config
|
||||
config = dict(config)
|
||||
for name in (
|
||||
"GAME_CARD_GRID", "GAME_CARD_HORIZONTAL",
|
||||
"GAME_CARD_LIST", "GAME_CARD_VERTICAL",
|
||||
):
|
||||
override = get_override(name)
|
||||
if isinstance(override, dict) and "card_animation_type" in override:
|
||||
config["card_animation_type"] = override["card_animation_type"]
|
||||
break
|
||||
else:
|
||||
animation = getattr(theme, "GAME_CARD_ANIMATION", {})
|
||||
config["card_animation_type"] = animation.get(
|
||||
"card_animation_type", "gradient"
|
||||
)
|
||||
radius = str(getattr(theme, "border_radius_card", "")).removesuffix("px")
|
||||
if radius.isdigit():
|
||||
config.update(cover_radius=int(radius), border_radius=int(radius))
|
||||
return config
|
||||
|
||||
def on_cover_loaded(self, pixmap):
|
||||
self.animated_cover_path = ""
|
||||
self.base_pixmap = pixmap
|
||||
@@ -734,7 +677,7 @@ class GameCard(AnimatedCard):
|
||||
height = cover_size
|
||||
else:
|
||||
width = self.base_card_width
|
||||
layout_config = getattr(self, "card_geometry_cfg", {})
|
||||
layout_config = getattr(self, "card_layout_cfg", {})
|
||||
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
|
||||
if self._set_animated_cover(cover_path, width, height):
|
||||
return
|
||||
@@ -743,7 +686,7 @@ class GameCard(AnimatedCard):
|
||||
cover_path,
|
||||
width,
|
||||
height,
|
||||
self.coverReady.emit,
|
||||
self.on_cover_loaded,
|
||||
app_name=str(self.appid or ""),
|
||||
fallback_exe=fallback_exe,
|
||||
fallback_icon_path=fallback_icon_path,
|
||||
@@ -942,11 +885,11 @@ class GameCard(AnimatedCard):
|
||||
small_card_mode = self.base_card_width < self.theme.COMPACT_CARD["width_threshold"]
|
||||
height_ratio = (
|
||||
self.theme.COMPACT_CARD["height_ratio"]
|
||||
if small_card_mode else self.card_geometry_cfg.get("card_height_ratio", 1.8)
|
||||
if small_card_mode else self.card_layout_cfg.get("card_height_ratio", 1.8)
|
||||
)
|
||||
scaled_height = int(self.base_card_width * height_ratio * self._scale)
|
||||
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
|
||||
cover_ratio = self.card_geometry_cfg.get("cover_aspect_ratio", 1.5)
|
||||
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
|
||||
cover_height = int(scaled_width * cover_ratio)
|
||||
self.coverWidget.setFixedSize(scaled_width, cover_height)
|
||||
self.coverLabel.setFixedSize(scaled_width, cover_height)
|
||||
@@ -1022,17 +965,23 @@ class GameCard(AnimatedCard):
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
# Ensure parent layout is updated safely
|
||||
try:
|
||||
parent = self.parentWidget()
|
||||
if parent:
|
||||
layout = parent.layout()
|
||||
if layout:
|
||||
layout.invalidate()
|
||||
layout.activate()
|
||||
layout.update()
|
||||
parent.updateGeometry()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def update_card_size(self, new_width: int):
|
||||
if self.base_card_width == new_width:
|
||||
return
|
||||
self.base_card_width = new_width
|
||||
cover_height = int(new_width * self.card_geometry_cfg.get("cover_aspect_ratio", 1.5))
|
||||
if not self.animated_cover_path and (
|
||||
self.base_pixmap is None or self.base_pixmap.isNull()
|
||||
or self.base_pixmap.width() < new_width
|
||||
or self.base_pixmap.height() < cover_height
|
||||
):
|
||||
self._load_cover_image(self.cover_path or "")
|
||||
self._load_cover_image(self.cover_path or "")
|
||||
self.update_scale()
|
||||
|
||||
def update_badge_visibility(self, display_filter: str):
|
||||
@@ -1221,9 +1170,6 @@ class GameCard(AnimatedCard):
|
||||
# Handle the case where the Qt object was deleted
|
||||
return
|
||||
|
||||
if self._favorite_icon_state == self.is_favorite:
|
||||
return
|
||||
self._favorite_icon_state = self.is_favorite
|
||||
try:
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
@@ -1261,15 +1207,6 @@ class GameCard(AnimatedCard):
|
||||
super().showEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
if self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode is True:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
return
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
if self._get_missing_executable_path() and self.context_menu_manager:
|
||||
self.context_menu_manager.handle_missing_executable(self)
|
||||
return
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
@@ -1293,11 +1230,6 @@ class GameCard(AnimatedCard):
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
event.accept()
|
||||
return
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from portprotonqt.search_utils import (
|
||||
)
|
||||
from PySide6.QtWidgets import QAbstractButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import QElapsedTimer, QRect, QRectF, Qt, QTimer
|
||||
from PySide6.QtCore import QRect, QRectF, Qt, QTimer
|
||||
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
|
||||
from portprotonqt.custom_widgets import FlowLayout, AutoHideScrollArea
|
||||
from portprotonqt.config import favorites_config, game_config, ui_config
|
||||
@@ -26,9 +26,7 @@ class FullLibraryTile(AnimatedCard):
|
||||
self.name = ""
|
||||
self.animation_base_size = theme.fullLibraryTileSize
|
||||
self.tile_pixmap = QPixmap()
|
||||
self.setup_card_animations(
|
||||
theme, GameCard._get_layout_config(theme, "GAME_CARD_HORIZONTAL")
|
||||
)
|
||||
self.setup_card_animations(theme, theme.GAME_CARD_HORIZONTAL)
|
||||
self.update_scale()
|
||||
|
||||
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
|
||||
@@ -74,8 +72,7 @@ class GameLibraryManager:
|
||||
self.game_card_cache = {}
|
||||
self.pending_images = {}
|
||||
self.card_width = ui_config.get_card_width()
|
||||
theme_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
self.layout_mode = ui_config.get_library_layout_mode(theme_mode)
|
||||
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.gamesListWidget: QWidget | None = None
|
||||
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
|
||||
self.gamesScrollArea: QScrollArea | None = None
|
||||
@@ -88,17 +85,11 @@ class GameLibraryManager:
|
||||
self.full_library_open = False
|
||||
self.sizeSlider: QSlider | None = None
|
||||
self._update_timer: QTimer | None = None
|
||||
self._card_resize_timer: QTimer | None = None
|
||||
self._card_resize_queue: deque[tuple[str, str]] = deque()
|
||||
self._card_resize_batch_size: int = 8
|
||||
self._incremental_add_timer: QTimer | None = None
|
||||
self._incremental_add_queue: deque[tuple[str, str]] = deque()
|
||||
self._incremental_new_games_map: dict[tuple[str, str], tuple] = {}
|
||||
self._incremental_search_text: str = ""
|
||||
self._incremental_card_positions: dict[tuple[str, str], int] = {}
|
||||
self._incremental_batch_size: int = 8
|
||||
self._incremental_batch_budget_ms: int = 8
|
||||
self._incremental_scroll_delay_ms: int = 250
|
||||
self._incremental_batch_size: int = 16
|
||||
self._focus_first_card_after_update = False
|
||||
self._pending_update = False
|
||||
self.pending_deletions = deque()
|
||||
@@ -114,17 +105,17 @@ class GameLibraryManager:
|
||||
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
|
||||
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
stack_layout = QGridLayout(self.gamesLibraryWidget)
|
||||
if isinstance(library_background, dict):
|
||||
stack_layout = QGridLayout(self.gamesLibraryWidget)
|
||||
stack_layout.setContentsMargins(*library_background["margins"])
|
||||
self.libraryBackgroundLabel = QLabel()
|
||||
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
|
||||
content_widget = QWidget()
|
||||
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
layout = QVBoxLayout(content_widget)
|
||||
stack_layout.addWidget(content_widget, 0, 0)
|
||||
else:
|
||||
stack_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.libraryBackgroundLabel = QLabel()
|
||||
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
|
||||
content_widget = QWidget()
|
||||
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
layout = QVBoxLayout(content_widget)
|
||||
stack_layout.addWidget(content_widget, 0, 0)
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
self.libraryContentLayout = layout
|
||||
layout.setSpacing(15)
|
||||
|
||||
@@ -132,6 +123,10 @@ class GameLibraryManager:
|
||||
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
|
||||
layout.addWidget(searchWidget)
|
||||
|
||||
if self.layout_mode == "vertical":
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
layout.addWidget(self.libraryHeaderWidget)
|
||||
|
||||
# Scroll area for game grid
|
||||
scrollArea = AutoHideScrollArea(theme=self.theme)
|
||||
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
|
||||
@@ -144,19 +139,15 @@ class GameLibraryManager:
|
||||
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
|
||||
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
layout_config = self.theme.GAME_CARD_HORIZONTAL
|
||||
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
|
||||
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
|
||||
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
elif self.layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
@@ -166,9 +157,6 @@ class GameLibraryManager:
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
|
||||
scrollArea.setWidget(self.gamesListWidget)
|
||||
if self.layout_mode == "vertical":
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
layout.addWidget(self.libraryHeaderWidget)
|
||||
layout.addWidget(scrollArea)
|
||||
|
||||
# Slider for card size
|
||||
@@ -197,17 +185,13 @@ class GameLibraryManager:
|
||||
self._update_timer.setSingleShot(True)
|
||||
self._update_timer.setInterval(100) # 100ms debounce
|
||||
self._update_timer.timeout.connect(self._perform_update)
|
||||
self._card_resize_timer = QTimer(self.gamesListWidget)
|
||||
self._card_resize_timer.setSingleShot(True)
|
||||
self._card_resize_timer.timeout.connect(self._resize_next_card_batch)
|
||||
self._incremental_add_timer = QTimer(self.gamesListWidget)
|
||||
self._incremental_add_timer = QTimer()
|
||||
self._incremental_add_timer.setSingleShot(True)
|
||||
self._incremental_add_timer.timeout.connect(self._process_incremental_add_batch)
|
||||
|
||||
# Connect scroll event for lazy loading
|
||||
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
scrollArea.viewportResized.connect(self.force_update_cards_library)
|
||||
|
||||
return self.gamesLibraryWidget
|
||||
|
||||
@@ -221,19 +205,14 @@ class GameLibraryManager:
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(*config["header_margins"])
|
||||
header_layout.setSpacing(config["header_spacing"])
|
||||
header_layout.addSpacing(config["header_cover_width"] + config["header_spacing"])
|
||||
header_layout.addSpacing(config["header_cover_width"])
|
||||
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
|
||||
for text, stretch in zip(labels, config["column_stretches"], strict=True):
|
||||
label = QLabel(text.upper())
|
||||
label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
|
||||
if text == labels[0] else Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
|
||||
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
|
||||
header_layout.addWidget(label, stretch)
|
||||
if self.gamesScrollArea is not None:
|
||||
header_layout.addSpacing(self.gamesScrollArea.verticalScrollBar().sizeHint().width())
|
||||
return header
|
||||
|
||||
def rebuild_library_layout(self, layout_mode: str) -> None:
|
||||
@@ -244,17 +223,13 @@ class GameLibraryManager:
|
||||
self.fullLibraryTile = None
|
||||
old_layout = self.gamesListLayout
|
||||
if layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
layout_config = self.theme.GAME_CARD_HORIZONTAL
|
||||
self.gamesListLayout = QHBoxLayout()
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
|
||||
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
|
||||
elif layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout()
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
@@ -268,18 +243,13 @@ class GameLibraryManager:
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal
|
||||
else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if layout_mode == "vertical"
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
self.gamesScrollArea.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
theme_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
|
||||
if self.full_library_open and self.libraryBackgroundLabel is not None:
|
||||
remove_cover_background(self.libraryBackgroundLabel)
|
||||
@@ -329,28 +299,9 @@ class GameLibraryManager:
|
||||
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
|
||||
ui_config.set_card_width(self.card_width)
|
||||
self.main_window.card_width = self.card_width
|
||||
self._card_resize_queue = deque(self.game_card_cache)
|
||||
self._resize_next_card_batch()
|
||||
|
||||
def _resize_next_card_batch(self) -> None:
|
||||
if self.gamesListWidget is None or self.gamesListLayout is None:
|
||||
self._card_resize_queue.clear()
|
||||
return
|
||||
self.gamesListLayout.setEnabled(False)
|
||||
try:
|
||||
for _ in range(min(self._card_resize_batch_size, len(self._card_resize_queue))):
|
||||
card = self.game_card_cache.get(self._card_resize_queue.popleft())
|
||||
if card is not None:
|
||||
card.update_card_size(self.card_width)
|
||||
finally:
|
||||
self.gamesListLayout.invalidate()
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListLayout.activate()
|
||||
if self._card_resize_queue and self._card_resize_timer is not None:
|
||||
self._card_resize_timer.start()
|
||||
else:
|
||||
self.force_update_cards_library()
|
||||
self.load_visible_images()
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
|
||||
def _set_card_width_from_slider(self):
|
||||
"""Use max card width for fixed-size layouts."""
|
||||
@@ -368,8 +319,6 @@ class GameLibraryManager:
|
||||
"""Loads images for visible game cards."""
|
||||
if self.gamesListWidget is None:
|
||||
return
|
||||
if self._incremental_add_queue and self._incremental_add_timer is not None:
|
||||
self._incremental_add_timer.start(self._incremental_scroll_delay_ms)
|
||||
visible_region = self.gamesListWidget.visibleRegion()
|
||||
max_concurrent_loads = 5
|
||||
loaded_count = 0
|
||||
@@ -392,7 +341,6 @@ class GameLibraryManager:
|
||||
card.set_animated_cover_paused(True)
|
||||
|
||||
def stop_background_activity(self) -> None:
|
||||
self._cancel_incremental_add()
|
||||
for card in self.game_card_cache.values():
|
||||
card.stop_background_activity()
|
||||
|
||||
@@ -434,17 +382,10 @@ class GameLibraryManager:
|
||||
"""Render the configured library background from the active cover."""
|
||||
if self.full_library_open or self.libraryBackgroundLabel is None:
|
||||
return
|
||||
self.render_library_background(self.libraryBackgroundLabel, card)
|
||||
|
||||
def render_library_background(self, label: QLabel, card: GameCard) -> None:
|
||||
"""Render the configured library background on a target label."""
|
||||
pixmap = card.coverLabel.pixmap()
|
||||
if pixmap is None or pixmap.isNull():
|
||||
return
|
||||
library_config = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
if not isinstance(library_config, dict):
|
||||
remove_cover_background(label)
|
||||
return
|
||||
library_config = self.theme.LIBRARY_BACKGROUND
|
||||
backgrounds = dict(getattr(self.theme, "DETAIL_PAGE_BACKGROUNDS", {}))
|
||||
backgrounds.update(library_config.get("backgrounds", {}))
|
||||
background_theme = SimpleNamespace(
|
||||
@@ -452,7 +393,7 @@ class GameLibraryManager:
|
||||
DETAIL_PAGE_BACKGROUNDS=backgrounds,
|
||||
)
|
||||
setup_cover_background(
|
||||
label, pixmap, self.main_window, background_theme
|
||||
self.libraryBackgroundLabel, pixmap, self.main_window, background_theme
|
||||
)
|
||||
|
||||
def _collapse_library_filter_controls(self) -> None:
|
||||
@@ -517,9 +458,7 @@ class GameLibraryManager:
|
||||
"""Schedules a game grid update with debouncing."""
|
||||
if focus_first_card is not None:
|
||||
self._focus_first_card_after_update = focus_first_card
|
||||
theme_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
if not self.full_library_open:
|
||||
self.layout_mode = theme_mode
|
||||
if self.sizeSlider is not None:
|
||||
@@ -560,42 +499,18 @@ class GameLibraryManager:
|
||||
|
||||
def _perform_force_update(self):
|
||||
"""Perform the actual force update on the layout."""
|
||||
self._fit_horizontal_cards()
|
||||
if self.gamesListLayout:
|
||||
self.gamesListLayout.invalidate()
|
||||
if self.gamesListWidget:
|
||||
self.gamesListWidget.adjustSize()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
def _fit_horizontal_cards(self, card: GameCard | None = None) -> None:
|
||||
if self.layout_mode not in {"horizontal", "horizontal_top"}:
|
||||
return
|
||||
if self.gamesScrollArea is None or self.gamesListLayout is None:
|
||||
return
|
||||
margins = self.gamesListLayout.contentsMargins()
|
||||
height = self.gamesScrollArea.viewport().height() - margins.top() - margins.bottom()
|
||||
if height <= 0:
|
||||
return
|
||||
cards = (card,) if card is not None else self.game_card_cache.values()
|
||||
for card in cards:
|
||||
config = card.card_layout_cfg
|
||||
scale = max(
|
||||
config.get(name, self.theme.GAME_CARD_ANIMATION[name])
|
||||
for name in ("default_scale", "hover_scale", "focus_scale")
|
||||
)
|
||||
available = height / scale - card.base_extra_margin
|
||||
ratio = card.card_geometry_cfg.get("card_height_ratio", 1.8)
|
||||
width = min(self.card_width, max(1, int(available / ratio)))
|
||||
if width < self.theme.COMPACT_CARD["width_threshold"]:
|
||||
width = min(width, max(1, int(available / self.theme.COMPACT_CARD["height_ratio"])))
|
||||
card.update_card_size(width)
|
||||
|
||||
def _cancel_incremental_add(self) -> None:
|
||||
if self._incremental_add_timer is not None and self._incremental_add_timer.isActive():
|
||||
self._incremental_add_timer.stop()
|
||||
self._incremental_add_queue.clear()
|
||||
self._incremental_new_games_map = {}
|
||||
self._incremental_search_text = ""
|
||||
self._incremental_card_positions = {}
|
||||
|
||||
def _start_incremental_add(
|
||||
self,
|
||||
@@ -606,43 +521,41 @@ class GameLibraryManager:
|
||||
if self.gamesListLayout is None:
|
||||
return
|
||||
self._cancel_incremental_add()
|
||||
self._incremental_card_positions = {key: index for index, key in enumerate(card_order)}
|
||||
desired_keys = set(card_order)
|
||||
self.gamesListLayout.setEnabled(False)
|
||||
try:
|
||||
while self.gamesListLayout.count():
|
||||
self.gamesListLayout.takeAt(0)
|
||||
for key, card in self.game_card_cache.items():
|
||||
visible = key in desired_keys and (not search_text or search_text in key[0].lower())
|
||||
card.setVisible(visible)
|
||||
card.set_animated_cover_paused(not visible)
|
||||
for key in card_order:
|
||||
card = self.game_card_cache.get(key)
|
||||
if card is not None:
|
||||
self.gamesListLayout.addWidget(card)
|
||||
finally:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self._incremental_add_queue = deque(key for key in card_order if key not in self.game_card_cache)
|
||||
while self.gamesListLayout.count():
|
||||
self.gamesListLayout.takeAt(0)
|
||||
for card in self.game_card_cache.values():
|
||||
if card.isVisible():
|
||||
card.setVisible(False)
|
||||
self._incremental_add_queue = deque(card_order)
|
||||
self._incremental_new_games_map = new_games_map
|
||||
self._incremental_search_text = search_text
|
||||
self._process_incremental_add_batch()
|
||||
if self._incremental_add_timer is not None:
|
||||
self._incremental_add_timer.start(0)
|
||||
|
||||
def _process_incremental_add_batch(self) -> None:
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
self._cancel_incremental_add()
|
||||
return
|
||||
|
||||
batch_timer = QElapsedTimer()
|
||||
batch_timer.start()
|
||||
self.gamesListLayout.setEnabled(False)
|
||||
added_new_card = False
|
||||
processed = 0
|
||||
max_batch = min(self._incremental_batch_size, len(self._incremental_add_queue))
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
try:
|
||||
for _ in range(min(self._incremental_batch_size, len(self._incremental_add_queue))):
|
||||
while processed < max_batch:
|
||||
game_key = self._incremental_add_queue.popleft()
|
||||
game_data = self._incremental_new_games_map.get(game_key)
|
||||
if game_data is None or self.context_menu_manager is None:
|
||||
continue
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
card = self.game_card_cache.get(game_key)
|
||||
if card is None:
|
||||
if self.context_menu_manager is None:
|
||||
processed += 1
|
||||
continue
|
||||
game_data = self._incremental_new_games_map.get(game_key)
|
||||
if game_data is None:
|
||||
processed += 1
|
||||
continue
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
added_new_card = True
|
||||
should_be_visible = (
|
||||
not self._incremental_search_text or
|
||||
self._incremental_search_text in str(game_key[0]).lower()
|
||||
@@ -650,29 +563,24 @@ class GameLibraryManager:
|
||||
if card.isVisible() != should_be_visible:
|
||||
card.setVisible(should_be_visible)
|
||||
card.set_animated_cover_paused(not should_be_visible)
|
||||
self.gamesListLayout.insertWidget(self._incremental_card_positions[game_key], card)
|
||||
if batch_timer.elapsed() >= self._incremental_batch_budget_ms:
|
||||
break
|
||||
self.gamesListLayout.addWidget(card)
|
||||
processed += 1
|
||||
finally:
|
||||
self.gamesListLayout.invalidate()
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
self.gamesListWidget.resize(
|
||||
self.gamesListWidget.size().expandedTo(self.gamesListLayout.minimumSize())
|
||||
)
|
||||
self.gamesListLayout.activate()
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
self._focus_first_visible_card()
|
||||
if self._incremental_add_queue:
|
||||
if self._incremental_add_timer is not None:
|
||||
self._incremental_add_timer.start(self._incremental_batch_budget_ms)
|
||||
self._incremental_add_timer.start(0)
|
||||
return
|
||||
|
||||
self.load_visible_images()
|
||||
if added_new_card:
|
||||
self.load_visible_images()
|
||||
self._sync_full_library_tile()
|
||||
self.force_update_cards_library()
|
||||
self._cancel_incremental_add()
|
||||
self._schedule_focus_first_card()
|
||||
|
||||
def _update_game_grid_immediate(self):
|
||||
"""Updates the game grid with the provided or current game list."""
|
||||
@@ -695,6 +603,7 @@ class GameLibraryManager:
|
||||
sort_method = game_config.get_sort_method()
|
||||
|
||||
# Batch layout updates (extended scope)
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(False) # Disable layout during batch
|
||||
|
||||
@@ -730,19 +639,10 @@ class GameLibraryManager:
|
||||
current_game_keys = {(game[0], game[5]) for game in sorted_games}
|
||||
|
||||
# Remove cards that no longer exist (batch)
|
||||
retained_keys = {
|
||||
(game[0], game[5])
|
||||
for games in getattr(self.main_window, "_loaded_library_cache", {}).values()
|
||||
for game in games
|
||||
}
|
||||
cards_to_remove = []
|
||||
for card_key in list(self.game_card_cache.keys()):
|
||||
if card_key not in current_game_keys:
|
||||
card = self.game_card_cache[card_key]
|
||||
card.setVisible(False)
|
||||
card.set_animated_cover_paused(True)
|
||||
if card_key not in retained_keys:
|
||||
cards_to_remove.append(card_key)
|
||||
cards_to_remove.append(card_key)
|
||||
|
||||
for card_key in cards_to_remove:
|
||||
card = self.game_card_cache.pop(card_key)
|
||||
@@ -755,15 +655,15 @@ class GameLibraryManager:
|
||||
# Track current layout order (only if dirty/full update needed)
|
||||
if self.dirty and self.gamesListLayout is not None:
|
||||
current_layout_order = []
|
||||
card_keys = {card: key for key, card in self.game_card_cache.items()}
|
||||
for i in range(self.gamesListLayout.count()):
|
||||
item = self.gamesListLayout.itemAt(i)
|
||||
if item is not None:
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
key = card_keys.get(widget)
|
||||
if key is not None:
|
||||
current_layout_order.append(key)
|
||||
for key, card in self.game_card_cache.items():
|
||||
if card == widget:
|
||||
current_layout_order.append(key)
|
||||
break
|
||||
else:
|
||||
current_layout_order = None # Skip reorg if not dirty
|
||||
|
||||
@@ -819,6 +719,7 @@ class GameLibraryManager:
|
||||
finally:
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
@@ -893,7 +794,7 @@ class GameLibraryManager:
|
||||
QTimer.singleShot(0, self._focus_first_visible_card)
|
||||
|
||||
def _focus_first_visible_card(self) -> None:
|
||||
if not self._focus_first_card_after_update or self.gamesListWidget is None:
|
||||
if self.gamesListWidget is None:
|
||||
return
|
||||
if getattr(self.main_window.stackedWidget, "currentIndex", lambda: -1)() != 0:
|
||||
return
|
||||
@@ -910,14 +811,74 @@ class GameLibraryManager:
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
return
|
||||
|
||||
visible_keys = {(game[0], game[5]) for game in self.filtered_games}
|
||||
card_order = [key for key in self.game_card_cache if key in visible_keys]
|
||||
new_games_map = {
|
||||
(game[0], game[5]): game for game in self.filtered_games
|
||||
if (game[0], game[5]) not in self.game_card_cache
|
||||
}
|
||||
card_order.extend(new_games_map)
|
||||
self._start_incremental_add(card_order, new_games_map, search_text)
|
||||
# Batch layout updates
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(False) # Disable layout during batch
|
||||
|
||||
try:
|
||||
# Create set of keys for current filtered games for fast lookup
|
||||
filtered_keys = {(game[0], game[5]) for game in self.filtered_games} # (name, exec_line)
|
||||
|
||||
# Process existing cards: show cards that are in filtered results, hide others
|
||||
cards_to_hide = []
|
||||
for card_key, card in self.game_card_cache.items():
|
||||
if card_key in filtered_keys:
|
||||
# Card should be visible
|
||||
if not card.isVisible():
|
||||
card.setVisible(True)
|
||||
card.set_animated_cover_paused(False)
|
||||
else:
|
||||
# Card should be hidden
|
||||
if card.isVisible():
|
||||
card.setVisible(False)
|
||||
cards_to_hide.append(card_key)
|
||||
card.set_animated_cover_paused(True)
|
||||
|
||||
# Now add any missing cards that are in filtered results but not in cache
|
||||
cards_to_add = []
|
||||
for game_data in self.filtered_games:
|
||||
game_name = game_data[0]
|
||||
exec_line = game_data[5]
|
||||
game_key = (game_name, exec_line)
|
||||
|
||||
if game_key not in self.game_card_cache:
|
||||
if self.context_menu_manager is None:
|
||||
continue
|
||||
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
card.setVisible(True) # New cards should be visible
|
||||
card.set_animated_cover_paused(False)
|
||||
cards_to_add.append((game_key, card))
|
||||
|
||||
# Add new cards to layout
|
||||
for _game_key, card in cards_to_add:
|
||||
self.gamesListLayout.addWidget(card)
|
||||
|
||||
# Remove cards that are no longer needed (if any)
|
||||
# Note: we're not removing them completely as they might be needed later
|
||||
# Instead, we just hide them and they'll be reused if needed
|
||||
|
||||
finally:
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
self.force_update_cards_library()
|
||||
|
||||
self.gamesListLayout.update()
|
||||
if self.gamesListWidget is not None:
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
# If search is empty, load images for visible ones
|
||||
if not search_text:
|
||||
self.load_visible_images()
|
||||
else:
|
||||
QTimer.singleShot(0, self.load_visible_images)
|
||||
|
||||
def _create_game_card(self, game_data: tuple) -> GameCard:
|
||||
"""Creates a new game card with all necessary connections."""
|
||||
@@ -933,8 +894,6 @@ class GameLibraryManager:
|
||||
card.hoverChanged.connect(self._on_card_hovered)
|
||||
card.focusChanged.connect(self._on_card_focused)
|
||||
|
||||
self._fit_horizontal_cards(card)
|
||||
|
||||
if self.context_menu_manager:
|
||||
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
||||
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
||||
@@ -980,8 +939,6 @@ class GameLibraryManager:
|
||||
# Always schedule widget for deletion regardless of cache state
|
||||
if isinstance(widget, GameCard):
|
||||
widget.cleanup()
|
||||
if widget is self.fullLibraryTile:
|
||||
self.fullLibraryTile = None
|
||||
widget.deleteLater()
|
||||
|
||||
# Also clear the cache completely if needed (in case layout wasn't in sync)
|
||||
@@ -1025,9 +982,7 @@ class GameLibraryManager:
|
||||
def add_game_incremental(self, game_data: tuple):
|
||||
"""Add a single game without full reload."""
|
||||
self.games.append(game_data)
|
||||
if self.filtered_games is not self.games:
|
||||
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
|
||||
self._build_search_indices(self.games)
|
||||
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
|
||||
self.dirty = True
|
||||
self.update_game_grid()
|
||||
self._update_missing_exe_button()
|
||||
@@ -1054,17 +1009,6 @@ class GameLibraryManager:
|
||||
self.update_game_grid()
|
||||
self._update_missing_exe_button()
|
||||
|
||||
detail_manager = getattr(self.main_window, "detail_page_manager", None)
|
||||
source = getattr(detail_manager, "_current_detail_source", None)
|
||||
if detail_manager and source and source[0] == "game" and detail_manager._detail_page_active:
|
||||
detail_data = source[1]
|
||||
if (detail_data.get("name"), detail_data.get("exec_line")) == old_key:
|
||||
detail_data.update(
|
||||
name=game_data[0], description=game_data[1],
|
||||
cover_path=game_data[2], exec_line=game_data[5],
|
||||
)
|
||||
detail_manager._reopen_current_detail_page()
|
||||
|
||||
def remove_game_incremental(self, game_name: str, exec_line: str):
|
||||
"""Remove a single game without full reload."""
|
||||
key = (game_name, exec_line)
|
||||
|
||||
+6
-72
@@ -7,7 +7,6 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import zlib
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
@@ -32,9 +31,6 @@ GOG_USER_TIMEOUT = 15
|
||||
GOG_METADATA_WORKERS = 4
|
||||
GOG_SETUP_MARKER_VERSION = 1
|
||||
GOG_SETUP_TIMEOUT = 600
|
||||
GOG_REPAIR_METADATA_TIMEOUT = 30
|
||||
GOG_COMPRESSED_MANIFEST_VERSION = 2
|
||||
GOG_BUILDS_API_VERSION = 2
|
||||
GOG_PRODUCT_LOCALES = {
|
||||
"bg": "bg-BG", "cs": "cs-CZ", "da": "da-DK", "de": "de-DE",
|
||||
"el": "el-GR", "en": "en-US", "es": "es-ES", "fi": "fi-FI",
|
||||
@@ -51,26 +47,11 @@ class GOGAPI:
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "launcher" / "gog"
|
||||
self.bin_dir = data_home / "PortProtonQt" / "bin"
|
||||
# Remove after the legacy launcher storage migration period.
|
||||
legacy_dir = data_home / "PortProtonQt" / "gog"
|
||||
if legacy_dir.exists() and not self.data_dir.exists():
|
||||
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_dir.rename(self.data_dir)
|
||||
legacy_bin = self.data_dir / "bin"
|
||||
if legacy_bin.is_dir():
|
||||
self.bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
for legacy_binary in legacy_bin.iterdir():
|
||||
target = self.bin_dir / legacy_binary.name
|
||||
if not target.exists():
|
||||
legacy_binary.rename(target)
|
||||
if not any(legacy_bin.iterdir()):
|
||||
legacy_bin.rmdir()
|
||||
self.data_dir = data_home / "PortProtonQt" / "gog"
|
||||
self.auth_path = self.data_dir / "auth.json"
|
||||
self.account_path = self.data_dir / "account.json"
|
||||
self.config_dir = self.data_dir / "gogdl"
|
||||
self.gogdl_version_path = self.bin_dir / "gogdl.version"
|
||||
self.gogdl_version_path = self.data_dir / "bin/gogdl.version"
|
||||
self.library_path = self.data_dir / "library.json"
|
||||
self.installed_path = self.data_dir / "installed.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
@@ -78,7 +59,7 @@ class GOGAPI:
|
||||
|
||||
def get_gogdl_path(self) -> str | None:
|
||||
"""Return an available gogdl executable."""
|
||||
bundled = self.bin_dir / "gogdl"
|
||||
bundled = self.data_dir / "bin/gogdl"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("gogdl")
|
||||
@@ -103,7 +84,7 @@ class GOGAPI:
|
||||
|
||||
def update_gogdl(self) -> str:
|
||||
"""Update the bundled gogdl binary to the latest official release."""
|
||||
bundled = self.bin_dir / "gogdl"
|
||||
bundled = self.data_dir / "bin/gogdl"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.gogdl_version_path.stat().st_mtime
|
||||
@@ -153,7 +134,7 @@ class GOGAPI:
|
||||
return path
|
||||
|
||||
def _download_gogdl_asset(self, asset: dict) -> str:
|
||||
target = self.bin_dir / "gogdl"
|
||||
target = self.data_dir / "bin/gogdl"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
@@ -394,53 +375,6 @@ class GOGAPI:
|
||||
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
|
||||
return None
|
||||
|
||||
def prepare_repair_manifest(self, app_id: str, install_path: Path) -> None:
|
||||
"""Restore gogdl metadata before verifying an imported Windows game."""
|
||||
manifest_path = self.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
if manifest_path.is_file():
|
||||
return
|
||||
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
|
||||
build_info = self._load_json(install_path / f"goggame-{app_id}.id", {})
|
||||
build_id = build_info.get("buildId") or info.get("buildId")
|
||||
credentials = self.get_credentials()
|
||||
response = requests.get(
|
||||
f"https://content-system.gog.com/products/{app_id}/os/windows/builds",
|
||||
params={"generation": GOG_COMPRESSED_MANIFEST_VERSION, "_version": GOG_BUILDS_API_VERSION},
|
||||
headers={"Authorization": f"Bearer {credentials.get('access_token', '')}"},
|
||||
timeout=GOG_REPAIR_METADATA_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
builds = response.json().get("items", [])
|
||||
build = next((item for item in builds if (
|
||||
str(item.get("build_id")) == str(build_id) if build_id
|
||||
else not item.get("branch")
|
||||
)), None)
|
||||
if build is None and build_id:
|
||||
build = next((item for item in builds if not item.get("branch") and item.get("urls")), None)
|
||||
if build:
|
||||
logger.warning(
|
||||
"GOG build %s for %s is unavailable; verifying against build %s",
|
||||
build_id, app_id, build.get("build_id"),
|
||||
)
|
||||
if not build or not build.get("urls"):
|
||||
raise OSError(f"GOG build not found for installed game {app_id}")
|
||||
response = requests.get(build["urls"][0]["url"], timeout=GOG_REPAIR_METADATA_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
data = response.content
|
||||
if build.get("generation") == GOG_COMPRESSED_MANIFEST_VERSION:
|
||||
data = zlib.decompress(data)
|
||||
manifest = orjson.loads(data)
|
||||
manifest["HGLPlatform"] = "windows"
|
||||
manifest["HGLInstallLanguage"] = (
|
||||
info.get("language") or next(iter(info.get("languages", [])), "en-US")
|
||||
)
|
||||
manifest["HGLdlcs"] = [
|
||||
{"id": path.stem.removeprefix("goggame-")}
|
||||
for path in install_path.glob("goggame-*.info")
|
||||
if path.stem != f"goggame-{app_id}"
|
||||
]
|
||||
self._save_json(manifest_path, manifest)
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
"""Return the primary executable from gogdl installation metadata."""
|
||||
primary, install_path = self._get_primary_task(app_id)
|
||||
@@ -681,7 +615,7 @@ class GOGAPI:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
game = data.get("game", {})
|
||||
if data.get("type") not in {"game", "mod"} or not game.get("visible_in_library", True):
|
||||
if not game.get("visible_in_library", True):
|
||||
return {}
|
||||
steam_release = next(
|
||||
(
|
||||
|
||||
@@ -7,7 +7,6 @@ import struct
|
||||
import io
|
||||
import pefile
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -49,9 +48,14 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
cache_home,
|
||||
"PortProtonQt",
|
||||
"images",
|
||||
"exe_icons",
|
||||
f"{icon_name}.png",
|
||||
|
||||
@@ -8,7 +8,7 @@ from PySide6.QtCore import (
|
||||
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
|
||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
|
||||
from PIL import Image, ImageQt, ImageSequence
|
||||
from portprotonqt.config import CACHE_DIR, ui_config
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -367,7 +367,8 @@ def _get_ppdb_autoinstall_image_name(cover: str) -> str:
|
||||
|
||||
|
||||
def clear_ppdb_autoinstall_image_cache(cover_urls: list[str]) -> None:
|
||||
image_folder = str(CACHE_DIR / "images")
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
for cover in cover_urls:
|
||||
filename = _get_ppdb_autoinstall_image_name(cover)
|
||||
if not filename:
|
||||
@@ -456,7 +457,8 @@ def load_pixmap_async(
|
||||
pixmap.load(icon_path)
|
||||
return pixmap
|
||||
|
||||
image_folder = str(CACHE_DIR / "images")
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
os.makedirs(image_folder, exist_ok=True)
|
||||
|
||||
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
|
||||
|
||||
@@ -60,7 +60,6 @@ class MainWindowProtocol(Protocol):
|
||||
current_exec_line: str | None
|
||||
current_add_game_dialog: AddGameDialog | None
|
||||
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
|
||||
theme: Any
|
||||
auto_size_slider: QSlider | None
|
||||
|
||||
class MouseEmulationThread(QThread):
|
||||
@@ -290,7 +289,7 @@ class InputManager(
|
||||
card for card in container.findChildren(AnimatedCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
if self._navigate_horizontal_library(game_cards, tab_index, code, value):
|
||||
if self._navigate_horizontal_library(game_cards, code, value):
|
||||
return
|
||||
focused = QApplication.focusWidget()
|
||||
if game_cards and focused not in game_cards:
|
||||
@@ -303,21 +302,14 @@ class InputManager(
|
||||
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _navigate_horizontal_library(
|
||||
self, cards: list[QWidget], tab_index: int, code: int, value: int
|
||||
self, cards: list[QWidget], code: int, value: int
|
||||
) -> bool:
|
||||
if tab_index == 1:
|
||||
mode = str(
|
||||
getattr(self._parent.theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
).lower()
|
||||
layout = getattr(self._parent, "autoInstallContainerLayout", None)
|
||||
else:
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
mode = getattr(manager, "layout_mode", "grid")
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
if mode not in {
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
if getattr(manager, "layout_mode", "grid") not in {
|
||||
"horizontal", "horizontal_top"
|
||||
}:
|
||||
return False
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
if code != PAD_DPAD_X or value == 0 or layout is None:
|
||||
return False
|
||||
ordered_cards = []
|
||||
@@ -529,18 +521,12 @@ class InputManager(
|
||||
window = self._parent
|
||||
if not isinstance(window, QWidget):
|
||||
return
|
||||
if enable and not window.isFullScreen():
|
||||
if enable and not self._is_fullscreen:
|
||||
if not window.isFullScreen():
|
||||
window_config.set_geometry(window.width(), window.height())
|
||||
window.showFullScreen()
|
||||
self._is_fullscreen = True
|
||||
start_animation = getattr(window, "start_boot_animation", None)
|
||||
if callable(start_animation):
|
||||
start_animation()
|
||||
elif not enable and window.isFullScreen():
|
||||
animation = getattr(window, "boot_animation", None)
|
||||
if animation is not None:
|
||||
animation.finish()
|
||||
elif not enable and self._is_fullscreen:
|
||||
window.showNormal()
|
||||
width, height = window_config.get_geometry()
|
||||
if width > 0 and height > 0:
|
||||
|
||||
@@ -8,7 +8,6 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
@@ -35,11 +34,6 @@ logger = get_logger(__name__)
|
||||
class ButtonInputMixin(InputMixin):
|
||||
@Slot(int, int)
|
||||
def handle_button_slot(self, button_code: int, value: int) -> None:
|
||||
animation = getattr(self._parent, "boot_animation", None)
|
||||
if animation is not None:
|
||||
if value:
|
||||
animation.finish()
|
||||
return
|
||||
if self._route_surface_button(button_code, value):
|
||||
return
|
||||
self._handle_default_button(button_code, value)
|
||||
@@ -88,9 +82,6 @@ class ButtonInputMixin(InputMixin):
|
||||
if self._handle_system_table_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_table_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_system_quick_button(button_code):
|
||||
return
|
||||
|
||||
@@ -98,14 +89,6 @@ class ButtonInputMixin(InputMixin):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||
|
||||
def _handle_table_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if not isinstance(focused, QTableWidget) or button_code not in BUTTONS['confirm']:
|
||||
return False
|
||||
if not self.handle_table_confirm(focused):
|
||||
return False
|
||||
SoundManager().play("click")
|
||||
return True
|
||||
|
||||
def _open_focused_context_menu(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if button_code not in BUTTONS['context_menu'] or focused is None:
|
||||
return False
|
||||
@@ -161,7 +144,11 @@ class ButtonInputMixin(InputMixin):
|
||||
def _route_button_to_text_input(
|
||||
self, active: QWidget, focused: QWidget | None, button_code: int
|
||||
) -> bool:
|
||||
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
keyboard = (
|
||||
getattr(active, 'keyboard', None)
|
||||
if isinstance(active, AddGameDialog)
|
||||
else getattr(self._parent, 'keyboard', None)
|
||||
)
|
||||
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
@@ -243,11 +230,6 @@ class ButtonInputMixin(InputMixin):
|
||||
return False
|
||||
|
||||
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
|
||||
if button_code in BUTTONS['add_game'] and isinstance(active, QDialog):
|
||||
action = active.findChild(QPushButton, "gamepadAddButton")
|
||||
if action is not None and action.isVisible() and action.isEnabled():
|
||||
action.click()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
||||
active.reject()
|
||||
return True
|
||||
|
||||
@@ -11,7 +11,6 @@ from PySide6.QtWidgets import (
|
||||
QListView,
|
||||
QListWidget,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
@@ -36,48 +35,6 @@ class InputSurface:
|
||||
|
||||
|
||||
class DialogInputModesMixin(InputMixin):
|
||||
def _get_dialog_action_buttons(self, dialog: QWidget) -> list[QPushButton]:
|
||||
"""Return available dialog buttons outside tab contents."""
|
||||
buttons = [
|
||||
button for button in dialog.findChildren(
|
||||
QPushButton, options=Qt.FindChildOption.FindDirectChildrenOnly,
|
||||
)
|
||||
if button.isVisible() and button.isEnabled()
|
||||
]
|
||||
return sorted(buttons, key=lambda button: (button.y(), button.x()))
|
||||
|
||||
def _handle_table_action_navigation(self, dialog: QWidget, code: int, value: int) -> bool:
|
||||
"""Move between the current table and dialog action buttons."""
|
||||
direction = self._normalize_dpad_direction(code, value)
|
||||
if direction is None or QApplication.activePopupWidget() is not None:
|
||||
return False
|
||||
code, value = direction
|
||||
buttons = self._get_dialog_action_buttons(dialog)
|
||||
focused = QApplication.focusWidget()
|
||||
table = next((table for table in dialog.findChildren(QTableWidget) if table.isVisible()), None)
|
||||
if isinstance(focused, QPushButton) and focused in buttons:
|
||||
self._reset_surface_navigation()
|
||||
if code == PAD_DPAD_X:
|
||||
index = buttons.index(focused) + value
|
||||
if 0 <= index < len(buttons):
|
||||
buttons[index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif value < 0:
|
||||
if table is not None:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif dialog is getattr(self, 'settings_dialog', None):
|
||||
self._focus_first_row_in_current_settings_table()
|
||||
return True
|
||||
if table is None or code != PAD_DPAD_Y or value < 0 or not buttons:
|
||||
return False
|
||||
next_row = table.currentRow() + 1
|
||||
while next_row < table.rowCount() and table.isRowHidden(next_row):
|
||||
next_row += 1
|
||||
if next_row < table.rowCount():
|
||||
return False
|
||||
self._reset_surface_navigation()
|
||||
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
# WINETRICKS MODE
|
||||
def enable_winetricks_mode(self, winetricks_dialog):
|
||||
"""Setup gamepad handling for WinetricksDialog"""
|
||||
@@ -113,13 +70,9 @@ class DialogInputModesMixin(InputMixin):
|
||||
# Winetricks-specific button handling
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']: # A: Confirm
|
||||
if isinstance(focused, QCheckBox) and focused.isEnabled():
|
||||
focused.toggle()
|
||||
elif isinstance(focused, QTableWidget):
|
||||
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
|
||||
if isinstance(focused, QTableWidget):
|
||||
self.handle_table_confirm(focused)
|
||||
elif isinstance(focused, QPushButton) and focused.isEnabled():
|
||||
focused.click()
|
||||
return
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X: Install
|
||||
@@ -157,8 +110,6 @@ class DialogInputModesMixin(InputMixin):
|
||||
if self.winetricks_dialog is None:
|
||||
return
|
||||
try:
|
||||
if self._handle_table_action_navigation(self.winetricks_dialog, code, value):
|
||||
return
|
||||
if value == 0: # Release
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
@@ -306,10 +257,6 @@ class DialogInputModesMixin(InputMixin):
|
||||
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
|
||||
cell_widget.setChecked(not cell_widget.isChecked())
|
||||
return True
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
|
||||
cell_widget.showPopup()
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
@@ -318,8 +265,7 @@ class DialogInputModesMixin(InputMixin):
|
||||
|
||||
# Check if the cell contains a checkbox
|
||||
item = table.item(current_row, current_col)
|
||||
if (item and item.data(Qt.ItemDataRole.CheckStateRole) is not None
|
||||
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
|
||||
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
|
||||
# Toggle the checkbox state
|
||||
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
|
||||
item.setCheckState(new_state)
|
||||
@@ -547,10 +493,7 @@ class DialogInputModesMixin(InputMixin):
|
||||
# ProtonManager-specific button handling
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']: # A: Confirm
|
||||
if isinstance(focused, QPushButton) and focused.isEnabled():
|
||||
focused.click()
|
||||
return
|
||||
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
|
||||
if isinstance(focused, QTableWidget):
|
||||
current_row = focused.currentRow()
|
||||
if current_row >= 0:
|
||||
@@ -607,8 +550,6 @@ class DialogInputModesMixin(InputMixin):
|
||||
return
|
||||
|
||||
try:
|
||||
if self._handle_table_action_navigation(self.proton_manager_dialog, code, value):
|
||||
return
|
||||
if value == 0: # Release
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
|
||||
@@ -317,7 +317,10 @@ class DpadInputMixin(InputMixin):
|
||||
|
||||
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
keyboard = getattr(active_window, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
else:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if value == 0:
|
||||
self._reset_dpad_repeat()
|
||||
|
||||
@@ -141,10 +141,6 @@ class FileExplorerInputMixin(InputMixin):
|
||||
self.file_explorer.show_folder_context_menu(item_rect.center())
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self._handle_active_window_button(self.file_explorer, button_code):
|
||||
return
|
||||
if not self.file_explorer.directory_only:
|
||||
return
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ class KeyboardInputMixin(InputMixin):
|
||||
keyboard.stop_backspace_repeat()
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Show:
|
||||
if isinstance(obj, QMenu):
|
||||
SoundManager().play("open")
|
||||
@@ -158,7 +162,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
return False
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
|
||||
native_key = self._native_scan_to_input_key(event)
|
||||
@@ -444,9 +448,8 @@ class KeyboardInputMixin(InputMixin):
|
||||
for attr_name in (
|
||||
"gamesSortCombo",
|
||||
"gamesDisplayCombo",
|
||||
"gamesBadgeViewCombo",
|
||||
"gamesLayoutCombo",
|
||||
"onlyInstalledCheckBox",
|
||||
"gamesBadgeViewCombo",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
|
||||
@@ -512,14 +515,13 @@ class KeyboardInputMixin(InputMixin):
|
||||
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
|
||||
if focused in filter_widgets:
|
||||
if code == PAD_DPAD_X:
|
||||
widget_index = filter_widgets.index(cast(QWidget, focused))
|
||||
next_index = widget_index + (1 if value > 0 else -1)
|
||||
if 0 <= next_index < len(filter_widgets):
|
||||
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if code != PAD_DPAD_Y:
|
||||
return False
|
||||
widget_index = filter_widgets.index(cast(QWidget, focused))
|
||||
next_index = widget_index + (1 if value > 0 else -1)
|
||||
if 0 <= next_index < len(filter_widgets):
|
||||
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0:
|
||||
controls_button = getattr(self._parent, "libraryControlsButton", None)
|
||||
if isinstance(controls_button, QWidget):
|
||||
|
||||
@@ -100,7 +100,7 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
if not self._initial_gamepad_check:
|
||||
SoundManager().play("gamepad_connect")
|
||||
|
||||
if not self._initial_gamepad_check and display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
self.toggle_fullscreen.emit(True)
|
||||
|
||||
elif self.gamepad:
|
||||
@@ -175,7 +175,7 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
keyboard.update_keyboard()
|
||||
|
||||
def find_gamepad(self) -> SDLGamepad | None:
|
||||
"""Open the SDL3 handle that combines all connected gamepads."""
|
||||
"""Find the first SDL3 gamepad with a standardized mapping."""
|
||||
try:
|
||||
gamepad = find_gamepad()
|
||||
self._last_gamepad_error = None
|
||||
@@ -217,12 +217,7 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
|
||||
def _poll_button_events(self, gamepad: SDLGamepad, current_time: float) -> None:
|
||||
"""Emit button changes using SDL's standardized controller mapping."""
|
||||
# Deliver Guide before Select when both change in the same SDL snapshot.
|
||||
buttons = sorted(
|
||||
SDL_CONTROLLER_BUTTON_TO_PAD.items(),
|
||||
key=lambda item: item[1] not in BUTTONS['guide'],
|
||||
)
|
||||
for button_index, button_code in buttons:
|
||||
for button_index, button_code in SDL_CONTROLLER_BUTTON_TO_PAD.items():
|
||||
value = gamepad.get_button(button_index)
|
||||
self._handle_button_value(button_index, button_code, value, current_time)
|
||||
|
||||
@@ -230,13 +225,8 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
if self._button_states.get(button_index) == value:
|
||||
return
|
||||
self._button_states[button_index] = value
|
||||
if getattr(self._parent, "boot_animation", None) is not None:
|
||||
self.button_event.emit(button_code, value)
|
||||
return
|
||||
if button_code in BUTTONS['guide']:
|
||||
self.guide_held = value == 1
|
||||
if self.guide_held:
|
||||
self.pending_menu_fullscreen_time = 0.0
|
||||
if button_code in BUTTONS['menu']:
|
||||
self.select_held = value == 1
|
||||
if button_code == PAD_BUTTON_START:
|
||||
@@ -269,8 +259,7 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
return (
|
||||
value == 1 and button_code in BUTTONS['menu'] and
|
||||
not emulation_combo and
|
||||
not self._is_gamescope_session and not self.guide_held and
|
||||
not self.in_guide_combination_attempt and
|
||||
not self._is_gamescope_session and not self.in_guide_combination_attempt and
|
||||
self._parent.isActiveWindow()
|
||||
)
|
||||
|
||||
@@ -364,18 +353,10 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
self._last_gamepad_check_time = current_time
|
||||
return
|
||||
try:
|
||||
active_changed = active_gamepad.update()
|
||||
active_gamepad.update()
|
||||
if not active_gamepad.connected():
|
||||
self.gamepad_hotplug.emit('remove')
|
||||
return
|
||||
if active_changed:
|
||||
self._reset_input_state()
|
||||
self.gamepad_type = self._get_effective_gamepad_type(active_gamepad)
|
||||
self._refresh_gamepad_ui()
|
||||
logger.info(
|
||||
"Active gamepad changed to SDL instance %s",
|
||||
active_gamepad.active_instance_id,
|
||||
)
|
||||
self._poll_button_events(active_gamepad, current_time)
|
||||
self._handle_pending_menu_fullscreen(current_time)
|
||||
self._poll_hat_events(active_gamepad, current_time)
|
||||
|
||||
@@ -128,7 +128,6 @@ class SettingsInputMixin(InputMixin):
|
||||
open_combo.lineEdit().setFocus()
|
||||
else:
|
||||
open_combo.setFocus()
|
||||
open_combo.activated.emit(open_combo.currentIndex())
|
||||
return
|
||||
|
||||
# Standard interaction
|
||||
@@ -184,7 +183,10 @@ class SettingsInputMixin(InputMixin):
|
||||
return
|
||||
|
||||
# 4. Global Shortcuts
|
||||
if button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
if button_code in BUTTONS['add_game']: # X: Apply
|
||||
self.settings_dialog.apply_changes()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
focused = QApplication.focusWidget()
|
||||
focused_combo = None
|
||||
if isinstance(focused, QTableWidget) and self.settings_dialog:
|
||||
@@ -267,10 +269,6 @@ class SettingsInputMixin(InputMixin):
|
||||
kb.move_focus_up()
|
||||
return
|
||||
|
||||
if self._handle_table_action_navigation(self.settings_dialog, code, value):
|
||||
return
|
||||
action_buttons = self._get_dialog_action_buttons(self.settings_dialog)
|
||||
|
||||
if self._is_current_settings_tool_tab():
|
||||
focused = QApplication.focusWidget()
|
||||
open_combo = self._get_open_settings_combo()
|
||||
@@ -316,8 +314,6 @@ class SettingsInputMixin(InputMixin):
|
||||
self.current_dpad_value = normalized_value
|
||||
|
||||
sections = self._get_mangohud_nav_sections()
|
||||
if action_buttons:
|
||||
sections.append(action_buttons)
|
||||
if not sections:
|
||||
return
|
||||
|
||||
@@ -331,31 +327,19 @@ class SettingsInputMixin(InputMixin):
|
||||
self._move_mangohud_vertical(focused, normalized_value, sections)
|
||||
return
|
||||
|
||||
# 2. Combo Box Navigation (within Advanced and Main Tables)
|
||||
# 2. Combo Box Navigation (within Advanced Table)
|
||||
table = self._get_current_settings_table()
|
||||
if not table or table.rowCount() == 0:
|
||||
return
|
||||
|
||||
combo_tables = (
|
||||
self.settings_dialog.advanced_table,
|
||||
self.settings_dialog.settings_table,
|
||||
getattr(self.settings_dialog, "favorites_table", None),
|
||||
)
|
||||
if self.settings_dialog and table in combo_tables and table.currentRow() >= 0:
|
||||
if self.settings_dialog and table == self.settings_dialog.advanced_table and table.currentRow() >= 0:
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
view = cell_widget.view()
|
||||
idx = view.currentIndex().row()
|
||||
step = -1 if value < 0 else 1
|
||||
model = cell_widget.model()
|
||||
end = -1 if step < 0 else cell_widget.count()
|
||||
new_idx = next((
|
||||
row for row in range(idx + step, end, step)
|
||||
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
), idx)
|
||||
view.setCurrentIndex(model.index(new_idx, 0))
|
||||
view.scrollTo(model.index(new_idx, 0), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
idx = cell_widget.currentIndex()
|
||||
new_idx = max(0, idx - 1) if value < 0 else min(cell_widget.count() - 1, idx + 1)
|
||||
if new_idx != idx:
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
@@ -449,9 +433,6 @@ class SettingsInputMixin(InputMixin):
|
||||
|
||||
if table == self.settings_dialog.settings_table:
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
|
||||
@@ -22,12 +22,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.5\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -148,9 +142,6 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -178,9 +169,6 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
@@ -193,6 +181,9 @@ msgstr ""
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -258,26 +249,12 @@ msgstr ""
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable not found"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
@@ -286,6 +263,10 @@ msgstr ""
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -303,6 +284,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -358,24 +343,15 @@ msgstr ""
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
@@ -439,6 +415,10 @@ msgstr ""
|
||||
msgid "Unknown Application"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
@@ -463,10 +443,6 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -476,16 +452,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -793,9 +759,6 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
@@ -818,6 +781,12 @@ msgstr ""
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
@@ -842,15 +811,15 @@ msgstr ""
|
||||
msgid "Create Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr ""
|
||||
|
||||
@@ -895,22 +864,6 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -935,30 +888,6 @@ msgstr ""
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
@@ -977,13 +906,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1007,6 +930,9 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1148,6 +1074,9 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1187,15 +1116,9 @@ msgstr ""
|
||||
msgid "System WINE"
|
||||
msgstr ""
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr ""
|
||||
|
||||
@@ -1220,9 +1143,6 @@ msgstr ""
|
||||
msgid "Clear PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr ""
|
||||
|
||||
@@ -1232,7 +1152,7 @@ msgstr ""
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
@@ -1244,24 +1164,18 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr ""
|
||||
|
||||
@@ -1455,6 +1369,9 @@ msgstr ""
|
||||
msgid "Enable {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr ""
|
||||
|
||||
@@ -1531,15 +1448,6 @@ msgstr ""
|
||||
msgid "Round corners (px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr ""
|
||||
|
||||
@@ -1849,9 +1757,6 @@ msgstr ""
|
||||
msgid "Layout and limiter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Presets"
|
||||
msgstr ""
|
||||
|
||||
@@ -1937,6 +1842,9 @@ msgstr ""
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -2052,6 +1960,9 @@ msgstr ""
|
||||
msgid "Disk: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Started at"
|
||||
msgstr ""
|
||||
|
||||
@@ -2142,6 +2053,10 @@ msgstr ""
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting"
|
||||
msgstr ""
|
||||
|
||||
@@ -2163,6 +2078,9 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2190,7 +2108,7 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgid "Only Installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
@@ -2202,42 +2120,12 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2268,15 +2156,9 @@ msgstr ""
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
@@ -2301,21 +2183,12 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -2524,10 +2397,16 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
@@ -2559,6 +2438,9 @@ msgstr ""
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -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-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-10-12 19:09+0000\n"
|
||||
"PO-Revision-Date: 2026-09-02 20:25+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -18,15 +18,9 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Spiel fortsetzen"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Spiel anhalten"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
@@ -153,10 +147,6 @@ msgstr "Die Datei existiert bereits: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Datei konnte nicht umbenannt werden: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Alle auswählen"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Von PortProton löschen"
|
||||
|
||||
@@ -184,9 +174,6 @@ msgstr "Löschen"
|
||||
msgid "Import"
|
||||
msgstr "Import"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Verknüpfung bearbeiten"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Aktualisierung"
|
||||
|
||||
@@ -199,6 +186,9 @@ msgstr "EOS-Overlay aktivieren"
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Spielordner öffnen"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Verknüpfung bearbeiten"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Von Steam entfernen"
|
||||
|
||||
@@ -268,27 +258,12 @@ msgstr "Desktop"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Fehler beim Speichern der Benutzerdaten: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Ausführbare Datei nicht gefunden"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Ausführbare Datei nicht gefunden: {0}"
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr "Wähle eine andere ausführbare Datei aus"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Spielname und Ausführungspfad werden benötigt"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "Fehler beim Generieren der .desktop-Datei"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
@@ -297,6 +272,10 @@ msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
|
||||
@@ -316,6 +295,10 @@ msgstr ""
|
||||
"'{game_name}' wurde zu Steam hinzugefügt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' wurde zu Steam hinzugefügt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Fehler beim Hinzufügen von '{game_name}' zu Steam: {error}"
|
||||
@@ -373,24 +356,15 @@ msgstr "Kaputt"
|
||||
msgid "Denied"
|
||||
msgstr "Abgelehnt"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Spieltitel"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "LETZTER START"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "SPIELZEIT"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Sammlung"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Unbekanntes Spiel"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Wine wird heruntergeladen…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Sammlung"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Automatische Installation"
|
||||
|
||||
@@ -451,6 +425,10 @@ msgstr "Niemals"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Unbekannte Anwendung"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Ausführbare Datei nicht gefunden: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Datei nicht gefunden: {0}"
|
||||
@@ -476,10 +454,6 @@ msgstr "Abhängigkeiten installieren"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Epic-Spieleordner auswählen"
|
||||
|
||||
@@ -489,16 +463,6 @@ msgstr "Eine weitere Epic-Operation läuft"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Wine-Prefix nicht gefunden"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
@@ -865,9 +829,6 @@ msgstr "Favoriten"
|
||||
msgid "Recent Games"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Spiel konnte nicht pausiert werden"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Keine aktuellen Spiele"
|
||||
|
||||
@@ -891,6 +852,12 @@ msgstr "Herunterladen"
|
||||
msgid "Size"
|
||||
msgstr "Größe"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "LETZTER START"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "SPIELZEIT"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "HAUPTSPIEL"
|
||||
|
||||
@@ -915,15 +882,15 @@ msgstr "Einstellungen"
|
||||
msgid "Create Log"
|
||||
msgstr "Protokoll erstellen"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Protokollierung beenden"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Ansicht öffnen"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Neuinstallation"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Protokollierung beenden"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "Protokollierung konnte nicht gestartet werden"
|
||||
|
||||
@@ -968,22 +935,6 @@ msgstr "AppImage aktualisieren..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Start {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Ordner konnte nicht erstellt werden: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Titel:"
|
||||
|
||||
@@ -1008,33 +959,6 @@ msgstr "Titelbild-Vorschau:"
|
||||
msgid "Apply"
|
||||
msgstr "Anwenden"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Ordner löschen"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Ungültiges Bild"
|
||||
|
||||
@@ -1055,14 +979,8 @@ msgstr ""
|
||||
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
|
||||
"erfasst."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
@@ -1085,6 +1003,9 @@ msgstr "Vorheriger Tab"
|
||||
msgid "Next Tab"
|
||||
msgstr "Nächster Tab"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Suchen"
|
||||
|
||||
@@ -1232,6 +1153,9 @@ msgstr "WINE/Proton konnte nicht entfernt werden in {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton erfolgreich entfernt."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Alle ausgewählten WINE/Proton-Versionen wurden heruntergeladen!"
|
||||
|
||||
@@ -1271,15 +1195,9 @@ msgstr "Der Download oder das Entpacken wurde abgebrochen."
|
||||
msgid "System WINE"
|
||||
msgstr "System WINE"
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Globale Spieleinstellungen"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Einstellungen für die exe-Datei"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "user.conf bearbeiten"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Einstellungen suchen…"
|
||||
|
||||
@@ -1304,9 +1222,6 @@ msgstr "PPDB bearbeiten"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "PPDB zurücksetzen"
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Das Laden der globalen Spieleinstellungen ist fehlgeschlagen."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "Der Pfad zur ausführbaren Datei ist nicht verfügbar."
|
||||
|
||||
@@ -1316,8 +1231,8 @@ msgstr "PPDB-Datei ist nicht vorhanden unter: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Datei konnte nicht geöffnet werden:\n"
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "PPDB-Zurücksetzung bestätigen"
|
||||
@@ -1330,24 +1245,18 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "Fehler beim Entfernen der PPDB-Datei:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Standard"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "deaktiviert"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Lade Wine/Proton herunter..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Prefix-Name eingeben"
|
||||
|
||||
msgid "Default value"
|
||||
msgstr "Standardwert"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "Rahmenloses Fenster"
|
||||
|
||||
@@ -1551,6 +1460,9 @@ msgstr "Voreingestellte Einstellungen"
|
||||
msgid "Enable {0}"
|
||||
msgstr "Einschalten {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Standard"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR-Upscaling"
|
||||
|
||||
@@ -1629,15 +1541,6 @@ msgstr "Hintergrund-Deckkraft"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Abgerundete Ecken (px)"
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr "Schriftgröße"
|
||||
|
||||
msgid "Font size"
|
||||
msgstr "Schriftgröße"
|
||||
|
||||
msgid "Font"
|
||||
msgstr "Schrift"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Oben links"
|
||||
|
||||
@@ -1947,9 +1850,6 @@ msgstr "VRAM-Nutzung"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "Layout und Limiter"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "Voreinstellungen"
|
||||
|
||||
@@ -2039,6 +1939,9 @@ msgstr "Auswahl"
|
||||
msgid "Libraries"
|
||||
msgstr "Bibliotheken"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks wurde nicht gefunden. Bitte versuche es erneut."
|
||||
|
||||
@@ -2154,6 +2057,9 @@ msgstr "Warten…"
|
||||
msgid "Disk: "
|
||||
msgstr "Datenträger: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Spieltitel"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Begonnen"
|
||||
|
||||
@@ -2244,6 +2150,10 @@ msgstr "GOG-Spielordner auswählen"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "Der ausgewählte Ordner ist nicht dieses GOG-Spiel"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Starten"
|
||||
|
||||
@@ -2265,6 +2175,9 @@ msgstr "Fehlgeschlagen"
|
||||
msgid "Repaired"
|
||||
msgstr "Repariert"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Schnellstart"
|
||||
|
||||
@@ -2292,8 +2205,8 @@ msgstr "Alle"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Anzeigefilter:"
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgstr "Nur installierte Spiele anzeigen"
|
||||
msgid "Only Installed"
|
||||
msgstr "Nur installierte"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detailliert"
|
||||
@@ -2304,33 +2217,6 @@ msgstr "Versteckt"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Anzeigetyp von Titelbildern:"
|
||||
|
||||
msgid "Theme"
|
||||
msgstr "Thema"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Gitter"
|
||||
|
||||
msgid "List"
|
||||
msgstr "Liste"
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr "Vertikal"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Horizontale Blöcke"
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Horizontale Karten"
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "Endlose horizontale Blöcke"
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "Endlose horizontale Karten"
|
||||
|
||||
msgid "Layout"
|
||||
msgstr "Layout"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2339,9 +2225,6 @@ msgstr ""
|
||||
"Sollen '{count}' Titelbidler ohne ausführbaren Dateien wirklich gelöscht "
|
||||
"werden? Dabei werden Benutzerdaten und .desktop-Dateien gelöscht."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton-Einstellungen"
|
||||
|
||||
@@ -2372,15 +2255,9 @@ msgstr "Gamepad-Typ:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Anwendung im Vollbildmodus starten"
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Cursor im Vollbildmodus ausblenden"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Automatischer Vollbildmodus bei angeschlossenem Gamepad"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Startanimation deaktivieren"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Beim Schließen in die Taskleiste minimieren"
|
||||
|
||||
@@ -2405,21 +2282,12 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Runtime-Download deaktivieren"
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "Zu den Steam-Kompatibilitätswerkzeugen hinzufügen"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Scan nach Steam-Proton deaktivieren"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Lade WINE/Proton zu Steam herunter"
|
||||
|
||||
@@ -2632,9 +2500,15 @@ msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Herunterladen des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Herunterladen… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Theme auswählen:"
|
||||
|
||||
@@ -2666,6 +2540,9 @@ msgstr "Das Theme konnte nicht gelöscht werden."
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefix:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Standardmäßig verwenden"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Wine-Konfiguration"
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-09-10 15:44+0000\n"
|
||||
"PO-Revision-Date: 2026-08-11 19:26+0000\n"
|
||||
"Last-Translator: gallegonovato <gallegonovato@noreply.codeberg.org>\n"
|
||||
"Language-Team: Spanish <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/es/>\n"
|
||||
@@ -21,12 +21,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Reanudar el juego"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar el juego"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
@@ -155,10 +149,6 @@ msgstr "El archivo ya existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "No se pudo cambiar el nombre del archivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Eliminar de PortProton"
|
||||
|
||||
@@ -186,9 +176,6 @@ msgstr "Borrar"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Editar acceso directo"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Actualizar"
|
||||
|
||||
@@ -201,6 +188,9 @@ msgstr ""
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir la carpeta del juego"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Editar acceso directo"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Eliminar de Steam"
|
||||
|
||||
@@ -268,18 +258,6 @@ msgstr "Escritorio"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "No se han podido guardar los datos personalizados: {error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "No se ha encontrado el archivo ejecutable: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "No se ha encontrado el archivo ejecutable: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select another executable"
|
||||
msgstr "Ruta al ejecutable:"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
"Es necesario indicar el nombre del juego y la ruta del archivo ejecutable"
|
||||
@@ -287,10 +265,6 @@ msgstr ""
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "No se han podido generar los datos del archivo .desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
@@ -299,6 +273,10 @@ msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "No se ha podido copiar la imagen de portada: {error}"
|
||||
@@ -318,6 +296,10 @@ msgstr ""
|
||||
"Se ha añadido «{game_name}» a Steam. Reinicia Steam para que los cambios "
|
||||
"surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "«{game_name}» se ha añadido a Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "No se ha podido añadir «{game_name}» a Steam: {error}"
|
||||
@@ -375,24 +357,15 @@ msgstr "Falló"
|
||||
msgid "Denied"
|
||||
msgstr "Denegado"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título del juego"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANZAMIENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TIEMPO EMPLEADO"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Juego desconocido"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Descargando Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Instalación automática"
|
||||
|
||||
@@ -453,6 +426,10 @@ msgstr "Nunca"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Aplicación desconocida"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "No se ha encontrado el archivo ejecutable: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Archivo no encontrado: {0}"
|
||||
@@ -479,10 +456,6 @@ msgstr "Instalar las dependencias"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
@@ -494,16 +467,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefijo no encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
@@ -885,9 +848,6 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Error al pausar el juego"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Sin juegos recientes"
|
||||
|
||||
@@ -910,6 +870,12 @@ msgstr "Descargar"
|
||||
msgid "Size"
|
||||
msgstr "Tamaño"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANZAMIENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TIEMPO EMPLEADO"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "HISTORIA PRINCIPAL"
|
||||
|
||||
@@ -934,15 +900,15 @@ msgstr "Ajustes"
|
||||
msgid "Create Log"
|
||||
msgstr "Crear registro"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Detener el registro"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Carta abierta"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstalar"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Detener el registro"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "No se pudo iniciar la sesión de depuración"
|
||||
|
||||
@@ -987,22 +953,6 @@ msgstr "Actualizando AppImage…"
|
||||
msgid "Launching {0}"
|
||||
msgstr "Comenzando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "No se ha podido crear la carpeta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nombre:"
|
||||
|
||||
@@ -1027,33 +977,6 @@ msgstr "Vista previa de la portada:"
|
||||
msgid "Apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagen no válida"
|
||||
|
||||
@@ -1072,14 +995,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1103,6 +1019,9 @@ msgstr "Pestaña anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Pestaña siguiente"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
@@ -1248,6 +1167,9 @@ msgstr "No se pudo eliminar WINE/Proton en {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1287,16 +1209,9 @@ msgstr ""
|
||||
msgid "System WINE"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Ajustes de Wine"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Buscar ajustes…"
|
||||
|
||||
@@ -1321,10 +1236,6 @@ msgstr ""
|
||||
msgid "Clear PPDB"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Error al cargar los temas"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr ""
|
||||
|
||||
@@ -1334,9 +1245,8 @@ msgstr ""
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "No se ha podido abrir la carpeta: {error}"
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar que PPDB está vacío"
|
||||
@@ -1352,25 +1262,18 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "No se ha podido eliminar el archivo PPDB:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Versión de WINE/Proton"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr ""
|
||||
|
||||
@@ -1563,6 +1466,9 @@ msgstr ""
|
||||
msgid "Enable {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr ""
|
||||
|
||||
@@ -1639,17 +1545,6 @@ msgstr ""
|
||||
msgid "Round corners (px)"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "Local"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "Tamaño"
|
||||
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr ""
|
||||
|
||||
@@ -1959,9 +1854,6 @@ msgstr ""
|
||||
msgid "Layout and limiter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Presets"
|
||||
msgstr ""
|
||||
|
||||
@@ -2047,6 +1939,9 @@ msgstr ""
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -2162,6 +2057,9 @@ msgstr "Esperando…"
|
||||
msgid "Disk: "
|
||||
msgstr "Disco: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título del juego"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Comenzó en"
|
||||
|
||||
@@ -2255,6 +2153,10 @@ msgstr "Borrar la carpeta"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "Iniciar"
|
||||
@@ -2277,6 +2179,10 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2305,8 +2211,8 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgstr "Mostrar solo los juegos instalados"
|
||||
msgid "Only Installed"
|
||||
msgstr "Solo instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
@@ -2317,35 +2223,6 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Temas"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Vertical"
|
||||
msgstr "parcial"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2354,9 +2231,6 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar «{game_name}»? Esto eliminará el "
|
||||
"archivo .desktop y los datos personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2387,15 +2261,9 @@ msgstr ""
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
@@ -2420,21 +2288,12 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -2645,9 +2504,15 @@ msgstr "Error al cargar los temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Error al descargar los temas"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Descargando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
@@ -2681,6 +2546,9 @@ msgstr "Error al cargar los temas"
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefijo:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Usar por defecto"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Configuración de Wine"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-10-21 00:34+0500\n"
|
||||
"POT-Creation-Date: 2026-09-01 17:22+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -18,12 +18,6 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -144,9 +138,6 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -174,9 +165,6 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
@@ -189,6 +177,9 @@ msgstr ""
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -254,26 +245,12 @@ msgstr ""
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable not found"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
@@ -282,6 +259,10 @@ msgstr ""
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -299,6 +280,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -354,24 +339,15 @@ msgstr ""
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
@@ -435,6 +411,10 @@ msgstr ""
|
||||
msgid "Unknown Application"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
@@ -459,10 +439,6 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -472,16 +448,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -789,9 +755,6 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
@@ -814,6 +777,12 @@ msgstr ""
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
@@ -838,15 +807,15 @@ msgstr ""
|
||||
msgid "Create Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr ""
|
||||
|
||||
@@ -891,22 +860,6 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -931,30 +884,6 @@ msgstr ""
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
@@ -973,13 +902,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1003,6 +926,9 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1144,6 +1070,9 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1183,15 +1112,9 @@ msgstr ""
|
||||
msgid "System WINE"
|
||||
msgstr ""
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr ""
|
||||
|
||||
@@ -1216,9 +1139,6 @@ msgstr ""
|
||||
msgid "Clear PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr ""
|
||||
|
||||
@@ -1228,7 +1148,7 @@ msgstr ""
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
@@ -1240,24 +1160,18 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr ""
|
||||
|
||||
@@ -1451,6 +1365,9 @@ msgstr ""
|
||||
msgid "Enable {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr ""
|
||||
|
||||
@@ -1527,15 +1444,6 @@ msgstr ""
|
||||
msgid "Round corners (px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr ""
|
||||
|
||||
@@ -1845,9 +1753,6 @@ msgstr ""
|
||||
msgid "Layout and limiter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Presets"
|
||||
msgstr ""
|
||||
|
||||
@@ -1933,6 +1838,9 @@ msgstr ""
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -2048,6 +1956,9 @@ msgstr ""
|
||||
msgid "Disk: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Started at"
|
||||
msgstr ""
|
||||
|
||||
@@ -2138,6 +2049,10 @@ msgstr ""
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting"
|
||||
msgstr ""
|
||||
|
||||
@@ -2159,6 +2074,9 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2186,7 +2104,7 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgid "Only Installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
@@ -2198,42 +2116,12 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2264,15 +2152,9 @@ msgstr ""
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
@@ -2297,21 +2179,12 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -2521,10 +2394,16 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
@@ -2556,6 +2435,9 @@ msgstr ""
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -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-10-17 23:27+0000\n"
|
||||
"PO-Revision-Date: 2026-08-28 22:26+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"
|
||||
@@ -18,15 +18,9 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Retomar jogo"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar jogo"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
@@ -153,10 +147,6 @@ 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 Tudo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
|
||||
@@ -184,21 +174,23 @@ msgstr "Excluir"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Editar Atalho"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Atualizar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Desativar sobreposição do EOS"
|
||||
msgstr "Ativar overlay mangoapp"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Ativar sobreposição do EOS"
|
||||
msgstr "Ativar overlay mangoapp"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Editar Atalho"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Remover da Steam"
|
||||
|
||||
@@ -268,27 +260,12 @@ msgstr "Área de Trabalho"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Falha ao salvar os dados personalizados: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Executável não encontrado"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Executável não encontrado: {0}"
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr "Selecione outro executável"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "O nome do jogo e o caminho do executável são obrigatórios"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "Falha ao gerar os dados do arquivo .desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
@@ -297,6 +274,10 @@ msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Falha ao copiar a imagem de capa: {error}"
|
||||
@@ -316,6 +297,10 @@ msgstr ""
|
||||
"'{game_name}' foi adicionado à Steam. Reinicie a Steam para aplicar as "
|
||||
"alterações."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' foi adicionado à Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Falha ao adicionar '{game_name}' à Steam: {error}"
|
||||
@@ -373,24 +358,15 @@ msgstr "Quebrado"
|
||||
msgid "Denied"
|
||||
msgstr "Negado"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título do jogo"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANÇAMENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TEMPO JOGADO"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Jogo Desconhecido"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Baixando Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Instalação Automática"
|
||||
|
||||
@@ -451,6 +427,10 @@ msgstr "Nunca"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Aplicativo Desconhecido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Executável não encontrado: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Arquivo não encontrado: {0}"
|
||||
@@ -475,28 +455,17 @@ msgstr "Instalar dependências"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "As dependências de 32 bits estão faltando. Instalá-las agora?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "elecionar pasta do jogo da Epic"
|
||||
msgstr "Selecionar pasta do jogo da GOG"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Outra operação da Epic está em andamento"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo do Wine não encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
msgstr "Prefixo não encontrado"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
@@ -867,9 +836,6 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Falha ao pausar o jogo"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Nenhum jogo recente"
|
||||
|
||||
@@ -892,6 +858,12 @@ msgstr "Baixar"
|
||||
msgid "Size"
|
||||
msgstr "Tamanho"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANÇAMENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TEMPO JOGADO"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "HISTÓRIA PRINCIPAL"
|
||||
|
||||
@@ -916,15 +888,15 @@ msgstr "Configurações"
|
||||
msgid "Create Log"
|
||||
msgstr "Criar log"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Encerrar log"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Abrir cartão"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstale"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Encerrar log"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "Falha ao iniciar a sessão de depuração"
|
||||
|
||||
@@ -969,22 +941,6 @@ msgstr "Atualizando AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
@@ -1009,33 +965,6 @@ msgstr "Pré-visualização da Capa:"
|
||||
msgid "Apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
@@ -1056,14 +985,8 @@ msgstr ""
|
||||
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
|
||||
"automaticamente."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -1086,6 +1009,9 @@ msgstr "Aba Anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Próxima Aba"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
@@ -1233,6 +1159,9 @@ msgstr "Falha ao remover WINE/Proton em {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton removido(s) com sucesso."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Todos os WINE/Proton selecionados foram baixados!"
|
||||
|
||||
@@ -1271,15 +1200,9 @@ msgstr "O download ou a extração foi cancelado."
|
||||
msgid "System WINE"
|
||||
msgstr "WINE do Sistema"
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Configurações globais dos jogos"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Configurações do Exe"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "Editar o arquivo user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Pesquisar configurações…"
|
||||
|
||||
@@ -1304,9 +1227,6 @@ msgstr "Editar PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Falha ao carregar as configurações globais dos jogos."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "O caminho do executável não está disponível."
|
||||
|
||||
@@ -1316,8 +1236,8 @@ msgstr "O arquivo PPDB não existe em: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Falha ao abrir o arquivo:\n"
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar Limpeza do PPDB"
|
||||
@@ -1330,24 +1250,18 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "Falha ao remover o arquivo PPDB:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Falha ao aplicar as alterações. Verifique os logs."
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Padrão"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Baixar Wine/Proton..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Digite o nome do prefixo"
|
||||
|
||||
msgid "Default value"
|
||||
msgstr "Valor padrão"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Falha ao aplicar as alterações. Verifique os logs."
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "Janela sem bordas"
|
||||
|
||||
@@ -1551,6 +1465,9 @@ msgstr "Predefinições rápidas"
|
||||
msgid "Enable {0}"
|
||||
msgstr "Ativar {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Padrão"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR upscaling"
|
||||
|
||||
@@ -1627,15 +1544,6 @@ msgstr "Opacidade do fundo"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Cantos arredondados (px)"
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr "Escala da fonte"
|
||||
|
||||
msgid "Font size"
|
||||
msgstr "Tamanho da fonte"
|
||||
|
||||
msgid "Font"
|
||||
msgstr "Fonte"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Superior esquerdo"
|
||||
|
||||
@@ -1945,9 +1853,6 @@ msgstr "Uso de VRAM"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "Layout e limitador"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Fontes"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "Predefinições"
|
||||
|
||||
@@ -2037,6 +1942,9 @@ msgstr "Definir"
|
||||
msgid "Libraries"
|
||||
msgstr "Bibliotecas"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Fontes"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks não encontrado. Tente novamente."
|
||||
|
||||
@@ -2152,6 +2060,9 @@ msgstr "Aguardando…"
|
||||
msgid "Disk: "
|
||||
msgstr "Disco: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título do jogo"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Iniciado em"
|
||||
|
||||
@@ -2181,7 +2092,7 @@ msgid "GOG account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
msgid "Log out"
|
||||
msgstr "Sair da sessão"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de login"
|
||||
@@ -2197,38 +2108,43 @@ msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Falha ao sair da conta da Epic"
|
||||
msgstr "Falha ao sair da conta GOG"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Copiar o código de autorização para a área de transferência"
|
||||
msgstr "Copiar o link para a área de transferência"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Autorização da Epic…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Falha no login da Epic: {0}"
|
||||
msgstr "Falha no login da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account connected"
|
||||
msgstr "Conta da Epic conectada"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Conta Epic não conectada"
|
||||
msgstr "Conta GOG não conectada"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Atualizando biblioteca da Epic… {0}/{1}"
|
||||
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da Epic: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Selecionar pasta de instalação"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "Outro download está em andamento"
|
||||
msgstr "Baixando agora"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
@@ -2242,6 +2158,10 @@ msgstr "Selecionar pasta do jogo da GOG"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "A pasta selecionada não corresponde a este jogo da GOG"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Iniciando"
|
||||
|
||||
@@ -2263,6 +2183,9 @@ msgstr "Falhou"
|
||||
msgid "Repaired"
|
||||
msgstr "Reparado"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
|
||||
@@ -2290,8 +2213,8 @@ msgstr "Todos"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Filtro de Exibição:"
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgstr "Mostrar apenas jogos instalados"
|
||||
msgid "Only Installed"
|
||||
msgstr "Apenas instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detalhado"
|
||||
@@ -2302,33 +2225,6 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Grade"
|
||||
|
||||
msgid "List"
|
||||
msgstr "Lista"
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr "Vertical"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Blocos horizontais"
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Cartões horizontais"
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "Blocos horizontais infinitos"
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "Cartões horizontais infinitos"
|
||||
|
||||
msgid "Layout"
|
||||
msgstr "Layout"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2337,9 +2233,6 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir {count} cartões sem executável? Isso "
|
||||
"removerá os arquivos .desktop e os dados personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
@@ -2370,15 +2263,9 @@ msgstr "Tipo de Controle:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Iniciar Aplicativo em Tela Cheia"
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Ocultar o cursor em tela cheia"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Tela Cheia Automática ao Conectar o Controle"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Desativar animação de inicialização"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Minimizar para a bandeja ao fechar"
|
||||
|
||||
@@ -2395,7 +2282,7 @@ msgid "Hide Control Hints"
|
||||
msgstr "Ocultar dicas de controle"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Forçar idioma inglês"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Ativar sons da interface"
|
||||
@@ -2403,21 +2290,12 @@ msgstr "Ativar sons da interface"
|
||||
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 ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Desativar a busca por instalações do Steam Proton"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
|
||||
@@ -2573,8 +2451,9 @@ msgstr "Dispositivos de reprodução"
|
||||
msgid "Output"
|
||||
msgstr "Saída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "Voltar para a área de trabalho"
|
||||
msgstr "Adicionar à Área de Trabalho"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Encerrar sessão"
|
||||
@@ -2628,9 +2507,15 @@ msgstr "Falha ao carregar temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Falha ao baixar o tema"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Baixando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Selecionar Tema:"
|
||||
|
||||
@@ -2662,6 +2547,9 @@ msgstr "Não foi possível excluir o tema."
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefixo:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Usar por padrão"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Configuração do Wine"
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ 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-15 19:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-09-05 17:10+0000\n"
|
||||
"Last-Translator: adem4ik <adem4ik@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -19,15 +19,9 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Возобновить игру"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Приостановить игру"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
@@ -118,8 +112,8 @@ msgid ""
|
||||
"Are you sure you want to delete folder '{folder_name}'? This action "
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
|
||||
"отменить."
|
||||
"Вы уверены, что хотите удалить папку '{folder_name}'? Это действие нельзя"
|
||||
" отменить."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
@@ -136,7 +130,8 @@ msgid ""
|
||||
"Are you sure you want to delete file '{file_name}'? This action cannot be"
|
||||
" undone."
|
||||
msgstr ""
|
||||
"Уверены, что хотите удалить файл «{file_name}»? Это действие нельзя отменить."
|
||||
"Вы уверены, что хотите удалить файл '{file_name}'? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete file: {error}"
|
||||
@@ -153,10 +148,6 @@ msgstr "Файл уже существует: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Не удалось переименовать файл: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Выбрать всё"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
@@ -184,9 +175,6 @@ msgstr "Удалить"
|
||||
msgid "Import"
|
||||
msgstr "Импорт"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Редактировать ярлык"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
@@ -199,6 +187,9 @@ msgstr "Включить наложение EOS"
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Редактировать ярлык"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Удалить из Steam"
|
||||
|
||||
@@ -241,8 +232,8 @@ msgid ""
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the "
|
||||
".desktop file and custom data."
|
||||
msgstr ""
|
||||
"Уверены, что хотите удалить «{game_name}»? Это приведёт к удалению файла "
|
||||
".desktop и пользовательских данных."
|
||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
|
||||
"файла .desktop и пользовательских данных."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to {location}: {error}"
|
||||
@@ -266,27 +257,12 @@ msgstr "Рабочий стол"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Не удалось сохранить пользовательские данные: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Исполняемый файл не найден"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Исполняемый файл не найден: {0}"
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr "Выбрать другой исполняемый файл"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Требуются название игры и путь к исполняемому файлу"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "Не удалось сгенерировать данные файла .desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Не удалось удалить старый файл .desktop: {error}"
|
||||
@@ -295,6 +271,10 @@ msgstr "Не удалось удалить старый файл .desktop: {erro
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Не удалось скопировать обложку: {error}"
|
||||
@@ -314,6 +294,10 @@ msgstr ""
|
||||
"'{game_name}' добавлен(а) в Steam. Пожалуйста, перезапустите Steam, чтобы"
|
||||
" изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' добавлен(а) в Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
@@ -371,24 +355,15 @@ msgstr "Сломано"
|
||||
msgid "Denied"
|
||||
msgstr "Отказано"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Название игры"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "ПРОВЕДЁННОЕ ВРЕМЯ"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Неизвестная игра"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Скачивание Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Автоустановка"
|
||||
|
||||
@@ -449,6 +424,10 @@ msgstr "Никогда"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Неизвестное приложение"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Исполняемый файл не найден: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Файл не найден: {0}"
|
||||
@@ -473,10 +452,6 @@ msgstr "Установить зависимости"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Отсутствуют 32-битные зависимости. Установить их сейчас?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Удалить «{0}» и все файлы в папке установки?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Выберите папку игры Epic"
|
||||
|
||||
@@ -486,16 +461,6 @@ msgstr "Другая операция Epic уже выполняется"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Префикс Wine не найден"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
@@ -763,14 +728,14 @@ msgstr ""
|
||||
"run --user --scope --slice=app.slice"
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr "Ограничение ядер ЦП"
|
||||
msgstr "Ограничение ядер CPU"
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
"Ограничение количества ядер процессора полезно для игр на Unity "
|
||||
"(рекомендуется установить значение равным 8)"
|
||||
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
|
||||
"установить значение равным 8)"
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr "Версия OpenGL"
|
||||
@@ -866,9 +831,6 @@ msgstr "Избранное"
|
||||
msgid "Recent Games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Не удалось приостановить игру"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Нет недавних игр"
|
||||
|
||||
@@ -877,7 +839,7 @@ msgstr "Нет избранных"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch \"{name}\" with PortProton"
|
||||
msgstr "Запустить «{name}» с помощью PortProton"
|
||||
msgstr "Запустить \"{name}\" с помощью PortProton"
|
||||
|
||||
msgid "Debug log mode was launched"
|
||||
msgstr "Запущен режим логирования"
|
||||
@@ -891,6 +853,12 @@ msgstr "Скачать"
|
||||
msgid "Size"
|
||||
msgstr "Размер"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "ПРОВЕДЁННОЕ ВРЕМЯ"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "СЮЖЕТ"
|
||||
|
||||
@@ -915,15 +883,15 @@ msgstr "Настройки"
|
||||
msgid "Create Log"
|
||||
msgstr "Создать лог"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Остановить лог"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Открыть карточку"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Переустановить"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Остановить лог"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "Не удалось запустить процесс отладки"
|
||||
|
||||
@@ -968,22 +936,6 @@ msgstr "Обновление AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Запуск {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Не удалось создать папку: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
|
||||
@@ -1008,33 +960,6 @@ msgstr "Предпросмотр обложки:"
|
||||
msgid "Apply"
|
||||
msgstr "Применить"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Удалить Папку"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Недавние"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Недопустимое изображение"
|
||||
|
||||
@@ -1055,14 +980,8 @@ msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1085,6 +1004,9 @@ msgstr "Предыдущая вкладка"
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
@@ -1221,7 +1143,7 @@ msgid ""
|
||||
"\n"
|
||||
"This action cannot be undone."
|
||||
msgstr ""
|
||||
"Уверены, что хотите удалить {} выбранных WINE/Proton?\n"
|
||||
"Вы уверены, что хотите удалить {} выбранные WINE/Proton?\n"
|
||||
"\n"
|
||||
"Это действие нельзя отменить."
|
||||
|
||||
@@ -1232,6 +1154,9 @@ msgstr "Не удалось удалить WINE/Proton в {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "Успешно удалено {} WINE/Proton."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Все выбранные WINE/Proton успешно загружены!"
|
||||
|
||||
@@ -1270,15 +1195,9 @@ msgstr "Скачивание или распаковка отменены."
|
||||
msgid "System WINE"
|
||||
msgstr "Системный WINE"
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Глобальные настройки игры"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Настройки EXE"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "Изменить user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Поиск настроек…"
|
||||
|
||||
@@ -1303,9 +1222,6 @@ msgstr "Редактировать PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Очистить PPDB"
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Не удалось загрузить глобальные настройки игры."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "Путь к исполняемому файлу недоступен."
|
||||
|
||||
@@ -1315,36 +1231,30 @@ msgstr "Файл PPDB не существует по адресу: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
|
||||
msgid "Are you sure you want to clear settings? This action cannot be undone."
|
||||
msgstr "Уверены, что хотите сбросить настройки? Это действие нельзя отменить."
|
||||
msgstr "Вы уверены, что хотите сбросить настройки? Это действие нельзя отменить."
|
||||
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "Не удалось удалить файл PPDB:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Не удалось применить изменения. Проверьте логи."
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Стандартный"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "отключено"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Скачать Wine/Proton..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Введите имя префикса"
|
||||
|
||||
msgid "Default value"
|
||||
msgstr "По умолчанию"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Не удалось применить изменения. Проверьте логи."
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "Без рамок"
|
||||
|
||||
@@ -1533,7 +1443,7 @@ msgid "Display heatmap-style debug view of HDR luminance in nits"
|
||||
msgstr "Отображение отладочной тепловой карты вида яркости HDR в нитах"
|
||||
|
||||
msgid "Launch with the mangoapp (mangohud) performance overlay enabled"
|
||||
msgstr "Запуск с оверлеем производительности mangoapp (mangohud)"
|
||||
msgstr "Запуск с включенным mangoapp (mangohud)"
|
||||
|
||||
msgid "Enable adaptive sync if available (variable rate refresh)"
|
||||
msgstr ""
|
||||
@@ -1550,6 +1460,9 @@ msgstr "Быстрые профили"
|
||||
msgid "Enable {0}"
|
||||
msgstr "Включить {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Стандартный"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR масштабирование"
|
||||
|
||||
@@ -1597,7 +1510,7 @@ msgid "Failed to save custom preset."
|
||||
msgstr "Ошибка сохранения пользовательских настроек."
|
||||
|
||||
msgid "Overlay position"
|
||||
msgstr "Положение MangoHud"
|
||||
msgstr "Позиция MangoHud"
|
||||
|
||||
msgid "Device battery"
|
||||
msgstr "Батарея устройства"
|
||||
@@ -1612,7 +1525,7 @@ msgid "FCAT screen edge"
|
||||
msgstr "Край области FCAT"
|
||||
|
||||
msgid "Table columns"
|
||||
msgstr "Столбцов таблицы"
|
||||
msgstr "Кол-во колонок"
|
||||
|
||||
msgid "Network (tx/rx kb/s)"
|
||||
msgstr "Сеть (tx/rx kb/s)"
|
||||
@@ -1626,15 +1539,6 @@ msgstr "Прозрачность фона"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Скругление углов (пикс)"
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr "Масштаб шрифта"
|
||||
|
||||
msgid "Font size"
|
||||
msgstr "Размер шрифта"
|
||||
|
||||
msgid "Font"
|
||||
msgstr "Шрифт"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Слева сверху"
|
||||
|
||||
@@ -1915,7 +1819,7 @@ msgid "Local time"
|
||||
msgstr "Местное время"
|
||||
|
||||
msgid "Time without label"
|
||||
msgstr "Скрывать слово «Time» перед временем"
|
||||
msgstr "Скрывать слово \"Time\" перед временем"
|
||||
|
||||
msgid "Force trilinear filtering"
|
||||
msgstr "Включить трилинейную фильтрацию"
|
||||
@@ -1944,9 +1848,6 @@ msgstr "Использование видеопамяти"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "Компоновка и ограничения"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Шрифты"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "Профили"
|
||||
|
||||
@@ -1981,8 +1882,9 @@ msgid ""
|
||||
"Select one or more FPS presets. The values are saved into FPS_LIMIT and "
|
||||
"MangoHud will also show the active FPS limit."
|
||||
msgstr ""
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения будут "
|
||||
"сохранены в FPS_LIMIT, а также MangoHud отобразит активное ограничение FPS."
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения "
|
||||
"будут сохранены в FPS_LIMIT и MangoHud также отобразит активное "
|
||||
"ограничение FPS."
|
||||
|
||||
msgid "Additional comma-separated MangoHud options not covered by the GUI."
|
||||
msgstr "Дополнительные параметры MangoHud (через запятую), отсутствующие в GUI."
|
||||
@@ -2034,6 +1936,9 @@ msgstr "Выбор"
|
||||
msgid "Libraries"
|
||||
msgstr "Библиотеки"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Шрифты"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks не найден. Повторите попытку."
|
||||
|
||||
@@ -2149,6 +2054,9 @@ msgstr "Ожидание…"
|
||||
msgid "Disk: "
|
||||
msgstr "Диск: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Название игры"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Начато"
|
||||
|
||||
@@ -2237,6 +2145,10 @@ msgstr "Выберите папку игры GOG"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "Выбранная папка не является этой игрой GOG"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Удалить «{0}» и все файлы в папке установки?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Запуск"
|
||||
|
||||
@@ -2256,6 +2168,9 @@ msgstr "Ошибка"
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2283,11 +2198,11 @@ msgstr "Все"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Фильтр:"
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgstr "Показывать только установленные игры"
|
||||
msgid "Only Installed"
|
||||
msgstr "Только Установленные"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Подробный"
|
||||
msgstr "Детальный"
|
||||
|
||||
msgid "Hidden"
|
||||
msgstr "Скрытый"
|
||||
@@ -2295,43 +2210,13 @@ msgstr "Скрытый"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Тип отображения бейджей:"
|
||||
|
||||
msgid "Theme"
|
||||
msgstr "Тема"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Сетка"
|
||||
|
||||
msgid "List"
|
||||
msgstr "Список"
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr "Вертикальная"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Горизонтальные блоки"
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Горизонтальные карточки"
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "Бесконечные горизонтальные блоки"
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "Бесконечные горизонтальные карточки"
|
||||
|
||||
msgid "Layout"
|
||||
msgstr "Режим отображения"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
"Вы уверены, что хотите удалить {count} карточек без исполняемого файла? "
|
||||
"Это приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
@@ -2363,15 +2248,9 @@ msgstr "Тип геймпада:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Запускать приложение в полноэкранном режиме"
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Скрывать курсор в полноэкранном режиме"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Отключить анимацию загрузки"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Сворачивать в область уведомления при закрытии"
|
||||
|
||||
@@ -2382,7 +2261,7 @@ msgid "Start in tray"
|
||||
msgstr "Запускать свёрнутым"
|
||||
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Скрывать вкладку «Автоустановка»"
|
||||
msgstr "Скрывать вкладку \"Автоустановка\""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Скрывать подсказки управления"
|
||||
@@ -2396,21 +2275,12 @@ msgstr "Включить звуки интерфейса"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Отключить проверку зависимостей ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "Добавить в инструменты совместимости Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Отключить сканирование Steam Proton"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Скачивать Proton в Steam"
|
||||
|
||||
@@ -2485,7 +2355,8 @@ msgstr "Подтверждение сброса"
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
"Уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Migrate old PortProton shortcuts to PortProtonQt format?"
|
||||
msgstr "Обновить старые ярлыки PortProton до формата PortProtonQt?"
|
||||
@@ -2494,7 +2365,7 @@ msgid "Confirm Clear Cache"
|
||||
msgstr "Подтверждение очистки кэша"
|
||||
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr "Уверены, что хотите очистить кэш? Это действие нельзя отменить."
|
||||
msgstr "Вы уверены, что хотите очистить кэш? Это действие нельзя отменить."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
@@ -2619,9 +2490,15 @@ msgstr "Не удалось загрузить темы"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Не удалось скачать тему"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Скачивание… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
@@ -2653,6 +2530,9 @@ msgstr "Не удалось удалить тему."
|
||||
msgid "Prefix:"
|
||||
msgstr "Префикс:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Использовать по умолчанию"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Конфигурация Wine"
|
||||
|
||||
@@ -2682,7 +2562,7 @@ msgstr "Не удалось запустить процесс."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to clear prefix '{}'?"
|
||||
msgstr "Уверены, что хотите очистить префикс «{}»?"
|
||||
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
|
||||
|
||||
msgid "Failed to start prefix clear process."
|
||||
msgstr "Не удалось запустить процесс очистки префикса."
|
||||
@@ -2715,7 +2595,7 @@ msgstr "Восстановление префикса не удалось."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete prefix '{}'?"
|
||||
msgstr "Уверены, что хотите удалить префикс «{}»?"
|
||||
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix '{}' deleted."
|
||||
|
||||
@@ -21,14 +21,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
@@ -149,10 +141,6 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "全选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
|
||||
@@ -181,9 +169,6 @@ msgstr "删除"
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
@@ -198,6 +183,9 @@ msgstr "启用 mangoapp 叠加层"
|
||||
msgid "Open Game Folder"
|
||||
msgstr "打开游戏文件夹"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "从 Steam 移除"
|
||||
|
||||
@@ -263,28 +251,12 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select another executable"
|
||||
msgstr "删除没有可执行文件的卡片"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "游戏名称和可执行文件路径为必填项"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "生成 .desktop 文件数据失败"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
@@ -293,6 +265,10 @@ msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "复制封面图像失败:{error}"
|
||||
@@ -310,6 +286,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "无法将\"{game_name}\"添加到 Steam:{error}"
|
||||
@@ -365,25 +345,15 @@ msgstr "损坏"
|
||||
msgid "Denied"
|
||||
msgstr "已拒绝"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "游戏宽度"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "上次启动"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "游戏时长"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "库"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "未知游戏"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "正在下载 Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "库"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "自动安装"
|
||||
|
||||
@@ -445,6 +415,10 @@ msgstr "从不"
|
||||
msgid "Unknown Application"
|
||||
msgstr "未知应用程序"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "找不到文件:{0}"
|
||||
@@ -469,10 +443,6 @@ msgstr "安装依赖"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
@@ -484,16 +454,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "未找到前缀"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -832,10 +792,6 @@ msgstr "收藏夹"
|
||||
msgid "Recent Games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to pause game"
|
||||
msgstr "停止游戏失败"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "无最近游戏"
|
||||
|
||||
@@ -858,6 +814,12 @@ msgstr "下载"
|
||||
msgid "Size"
|
||||
msgstr "大小"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "上次启动"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "游戏时长"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "主线剧情"
|
||||
|
||||
@@ -882,15 +844,15 @@ msgstr "设置"
|
||||
msgid "Create Log"
|
||||
msgstr "创建日志"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "停止日志"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "打开卡片"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "重新安装"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "停止日志"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "启动调试会话失败"
|
||||
|
||||
@@ -935,22 +897,6 @@ msgstr "正在更新 AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
|
||||
@@ -975,34 +921,6 @@ msgstr "封面预览:"
|
||||
msgid "Apply"
|
||||
msgstr "应用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
@@ -1021,15 +939,9 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
@@ -1052,6 +964,9 @@ msgstr "上一标签页"
|
||||
msgid "Next Tab"
|
||||
msgstr "下一标签页"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
@@ -1199,6 +1114,9 @@ msgstr "移除位于 {path} 的 WINE/Proton 失败:{error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "已成功移除 {} 个 WINE/Proton。"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "所有选定的 WINE/Proton 已下载完成!"
|
||||
|
||||
@@ -1237,16 +1155,9 @@ msgstr "下载或提取已被取消。"
|
||||
msgid "System WINE"
|
||||
msgstr "系统 WINE"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "保存设置"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
|
||||
@@ -1271,10 +1182,6 @@ msgstr "编辑 PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
|
||||
@@ -1284,9 +1191,8 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
@@ -1297,25 +1203,18 @@ msgstr "确定要清除设置吗?此操作无法撤销。"
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "移除 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "应用更改失败。请检查日志。"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "默认"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
|
||||
msgid "Default value"
|
||||
msgstr "默认值"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "应用更改失败。请检查日志。"
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "无边框窗口"
|
||||
|
||||
@@ -1509,6 +1408,9 @@ msgstr "快速预设"
|
||||
msgid "Enable {0}"
|
||||
msgstr "启用 {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "默认"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR 升频"
|
||||
|
||||
@@ -1585,18 +1487,6 @@ msgstr "背景不透明度"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "圆角(像素)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "字体"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "字体"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font"
|
||||
msgstr "字体"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "左上角"
|
||||
|
||||
@@ -1906,9 +1796,6 @@ msgstr "VRAM 使用量"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "布局和限制器"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "字体"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "预设"
|
||||
|
||||
@@ -1994,6 +1881,9 @@ msgstr "设置"
|
||||
msgid "Libraries"
|
||||
msgstr "库"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "字体"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "未找到 Winetricks。请重试。"
|
||||
|
||||
@@ -2115,6 +2005,10 @@ msgstr "警告"
|
||||
msgid "Disk: "
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "游戏宽度"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
msgstr "状态"
|
||||
@@ -2214,6 +2108,10 @@ msgstr "删除文件夹"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "设置项"
|
||||
@@ -2238,6 +2136,10 @@ msgstr "详细"
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
@@ -2265,8 +2167,9 @@ msgstr "全部"
|
||||
msgid "Display Filter:"
|
||||
msgstr "显示筛选:"
|
||||
|
||||
msgid "Show only installed games"
|
||||
msgstr "仅显示已安装的游戏"
|
||||
#, fuzzy
|
||||
msgid "Only Installed"
|
||||
msgstr "已安装"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "详细"
|
||||
@@ -2277,41 +2180,6 @@ msgstr "隐藏"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "徽章视图类型:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "浅色"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Layout"
|
||||
msgstr "注销"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2320,9 +2188,6 @@ msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
@@ -2354,16 +2219,9 @@ msgstr "游戏手柄类型:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "全屏启动应用程序"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "强制窗口全屏"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "连接手柄时自动全屏"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "关闭时最小化到系统托盘"
|
||||
|
||||
@@ -2388,21 +2246,12 @@ msgstr "启用界面音效"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
@@ -2613,9 +2462,15 @@ msgstr "加载主题失败"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "下载主题失败"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "正在下载… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "选择主题:"
|
||||
|
||||
@@ -2649,6 +2504,9 @@ msgstr "加载主题失败"
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "默认使用"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Wine 配置"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from babel import Locale
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -42,24 +42,20 @@ STORE_CONTENT_LOCALES = {'es': 'es-ES', 'pt': 'pt-BR', 'zh': 'zh-CN'}
|
||||
|
||||
# Try local locale directory first, fallback to system for development
|
||||
_local_localedir = Path(__file__).parent / "locales"
|
||||
_locale_dirs = [_local_localedir]
|
||||
if os.getenv("SHARUN_DIR"):
|
||||
_locale_dirs.append(Path(os.environ["SHARUN_DIR"]) / "share" / "locale")
|
||||
_locale_dirs.extend(
|
||||
Path(data_dir) / "locale"
|
||||
for data_dir in os.getenv(
|
||||
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
|
||||
).split(os.pathsep)
|
||||
if data_dir
|
||||
)
|
||||
_system_prefix = "/app" if os.getenv("FLATPAK_ID") else os.getenv("SHARUN_DIR", "/usr")
|
||||
_system_localedir = Path(_system_prefix) / "share" / "locale"
|
||||
|
||||
translate = gettext.NullTranslations()
|
||||
for _locale_dir in _locale_dirs:
|
||||
try:
|
||||
translate = gettext.translation("portprotonqt", localedir=_locale_dir)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
try:
|
||||
translate = gettext.translation(
|
||||
domain="portprotonqt",
|
||||
localedir=_local_localedir,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
translate = gettext.translation(
|
||||
domain="portprotonqt",
|
||||
localedir=_system_localedir,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
@@ -85,11 +81,8 @@ def get_system_locale():
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
try:
|
||||
loc = locale.getlocale()[0]
|
||||
return str(Locale.parse(loc)) if loc else 'en'
|
||||
except (UnknownLocaleError, ValueError):
|
||||
return 'en'
|
||||
loc = locale.getdefaultlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
def get_steam_language():
|
||||
try:
|
||||
|
||||
+166
-350
@@ -5,9 +5,9 @@ import sys
|
||||
import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
@@ -67,7 +67,6 @@ from portprotonqt.disc_image_utils import DiscImageManager
|
||||
from portprotonqt.compatibility_report import (
|
||||
CompatibilityLaunch,
|
||||
analyze_launch,
|
||||
get_running_game_processes,
|
||||
has_dxvk_vulkan_incompatibility,
|
||||
is_suspected_crash,
|
||||
)
|
||||
@@ -94,14 +93,61 @@ STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
from portprotonqt.boot_animation import BootAnimation
|
||||
|
||||
logger = get_logger(__name__)
|
||||
DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
|
||||
ALT_BIARCH_REPO = "x86_64-i586"
|
||||
ALT_BIARCH_URL = "https://www.altlinux.org/Biarch"
|
||||
GAME_LAUNCH_MARKER = "PORTPROTONQT_GAME_LAUNCH_STARTED"
|
||||
GAME_EXIT_MARKER = "PORTPROTONQT_GAME_EXIT_CODE"
|
||||
ALT_I586_BASE_PACKAGES = (
|
||||
"glibc-nss",
|
||||
"glibc-gconv-modules",
|
||||
"libnm",
|
||||
"libvulkan1",
|
||||
"libd3d",
|
||||
"libfreetype",
|
||||
)
|
||||
ALT_I586_PACKAGE_PREFIXES = ("libnss-", "xorg-dri-")
|
||||
ALT_I586_EXCLUDED_PACKAGES = ("libnss-fallback",)
|
||||
ALT_INSTALL_SCRIPT_MODE = 0o700
|
||||
ALT_I586_INSTALL_SCRIPT = """#!/bin/sh
|
||||
set -u
|
||||
trap 'rm -f "$0"' EXIT
|
||||
|
||||
LIST=""
|
||||
|
||||
add_if_needed() {
|
||||
package="$1"
|
||||
i586_package="i586-$package"
|
||||
|
||||
if rpm -q "$package" >/dev/null 2>&1 && ! rpm -q "$i586_package" >/dev/null 2>&1; then
|
||||
LIST="$LIST $i586_package"
|
||||
fi
|
||||
}
|
||||
|
||||
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libd3d libfreetype; do
|
||||
add_if_needed "$package"
|
||||
done
|
||||
|
||||
for package in $(rpm -qa --qf '%{NAME}\\n' \
|
||||
| grep '^libnss-' \
|
||||
| grep -v '^libnss-fallback$' || true); do
|
||||
add_if_needed "$package"
|
||||
done
|
||||
|
||||
for package in $(rpm -qa --qf '%{NAME}\\n' | grep '^xorg-dri-' || true); do
|
||||
add_if_needed "$package"
|
||||
done
|
||||
|
||||
if ! apt-get update; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$LIST" ]; then
|
||||
apt-get install -y $LIST
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
class MainWindow(
|
||||
MainWindowControlHintsMixin,
|
||||
@@ -116,7 +162,7 @@ class MainWindow(
|
||||
QMainWindow,
|
||||
):
|
||||
games_loaded = Signal(list)
|
||||
compatibility_report_ready = Signal(str, str)
|
||||
compatibility_report_ready = Signal(str)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -136,49 +182,19 @@ class MainWindow(
|
||||
def changeEvent(self, event: QEvent) -> None:
|
||||
super().changeEvent(event)
|
||||
if event.type() in (QEvent.Type.ActivationChange, QEvent.Type.WindowStateChange):
|
||||
self._update_cursor_visibility()
|
||||
self._update_animated_covers_activity()
|
||||
|
||||
def hideEvent(self, event: QHideEvent) -> None:
|
||||
self._update_cursor_visibility()
|
||||
self._set_animated_covers_suspended(True)
|
||||
super().hideEvent(event)
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
super().showEvent(event)
|
||||
self._update_cursor_visibility()
|
||||
self._update_animated_covers_activity()
|
||||
|
||||
def _on_application_state_changed(self, _state: Qt.ApplicationState) -> None:
|
||||
self._update_cursor_visibility()
|
||||
self._update_animated_covers_activity()
|
||||
|
||||
def _update_cursor_visibility(self) -> None:
|
||||
if not hasattr(self, "_cursor_timer"):
|
||||
return
|
||||
hide = (
|
||||
self.isFullScreen() and self.isVisible() and not self.isMinimized()
|
||||
and display_config.get_hide_cursor()
|
||||
and QApplication.applicationState() == Qt.ApplicationState.ApplicationActive
|
||||
)
|
||||
if hide and not self._cursor_hidden and not self._cursor_timer.isActive():
|
||||
self._cursor_timer.start(self.theme.cursorHideTimeoutMs)
|
||||
elif not hide:
|
||||
self._cursor_timer.stop()
|
||||
if self._cursor_hidden:
|
||||
QApplication.restoreOverrideCursor()
|
||||
self._cursor_hidden = False
|
||||
|
||||
def _hide_cursor(self) -> None:
|
||||
if (
|
||||
self.isFullScreen() and self.isVisible() and not self.isMinimized()
|
||||
and display_config.get_hide_cursor()
|
||||
and QApplication.applicationState() == Qt.ApplicationState.ApplicationActive
|
||||
and not self._cursor_hidden
|
||||
):
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.BlankCursor)
|
||||
self._cursor_hidden = True
|
||||
|
||||
def _update_animated_covers_activity(self) -> None:
|
||||
should_suspend = (
|
||||
not self.isVisible() or
|
||||
@@ -250,15 +266,6 @@ class MainWindow(
|
||||
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if isinstance(obj, QWidget) and hasattr(self, "_cursor_timer"):
|
||||
if event.type() == QEvent.Type.Polish:
|
||||
obj.setMouseTracking(True)
|
||||
if event.type() in (QEvent.Type.MouseMove, QEvent.Type.MouseButtonPress):
|
||||
if self.isFullScreen() and self.isVisible() and not self.isMinimized() and display_config.get_hide_cursor():
|
||||
if self._cursor_hidden:
|
||||
QApplication.restoreOverrideCursor()
|
||||
self._cursor_hidden = False
|
||||
self._cursor_timer.start(self.theme.cursorHideTimeoutMs)
|
||||
if isinstance(obj, QWidget) and obj in getattr(self, "_gamepad_tooltip_map", {}):
|
||||
if event.type() in (QEvent.Type.Enter, QEvent.Type.FocusIn):
|
||||
self._show_gamepad_tooltip(True, self._gamepad_tooltip_map[obj], obj)
|
||||
@@ -301,8 +308,7 @@ class MainWindow(
|
||||
self.current_theme_name = selected_theme
|
||||
# Apply theme but defer heavy font loading
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
self.tray_manager = None
|
||||
QTimer.singleShot(0, lambda: self._initialize_tray(app_name))
|
||||
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
|
||||
self.card_width = ui_config.get_card_width()
|
||||
self.auto_card_width = ui_config.get_auto_card_width()
|
||||
self.setWindowTitle(f"{app_name} {version}")
|
||||
@@ -327,7 +333,6 @@ class MainWindow(
|
||||
self._pending_log_exe: str | None = None
|
||||
self.appimageUpdateWorker: AppImageUpdateWorker | None = None
|
||||
self.gogdlUpdateWorker: QThread | None = None
|
||||
self.initialCommandWorker: QThread | None = None
|
||||
self.compatibility_report_ready.connect(self._show_compatibility_report)
|
||||
|
||||
self.game_library_manager = GameLibraryManager(self, self.theme, None)
|
||||
@@ -348,15 +353,6 @@ class MainWindow(
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
self.setAcceptDrops(True)
|
||||
self._init_gamepad_tooltip()
|
||||
self._cursor_hidden = False
|
||||
self._cursor_timer = QTimer(self)
|
||||
self._cursor_timer.setSingleShot(True)
|
||||
self._cursor_timer.timeout.connect(self._hide_cursor)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
for widget in self.findChildren(QWidget):
|
||||
widget.setMouseTracking(True)
|
||||
self.current_exec_line = None
|
||||
self.currentDetailPage = None
|
||||
self.current_play_button = None
|
||||
@@ -417,11 +413,7 @@ class MainWindow(
|
||||
|
||||
# Central widget and main layout
|
||||
centralWidget = QWidget()
|
||||
self.startup_stack = QStackedWidget(self)
|
||||
self.startup_stack.addWidget(centralWidget)
|
||||
self.setCentralWidget(self.startup_stack)
|
||||
self.boot_animation: BootAnimation | None = None
|
||||
self._boot_pending_load = False
|
||||
self.setCentralWidget(centralWidget)
|
||||
mainLayout = QVBoxLayout(centralWidget)
|
||||
mainLayout.setSpacing(0)
|
||||
mainLayout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -558,59 +550,31 @@ class MainWindow(
|
||||
)
|
||||
self.system_theme_watcher.start()
|
||||
|
||||
if self._pending_resolution:
|
||||
auto_fullscreen_gamepad = (
|
||||
display_config.get_auto_fullscreen_gamepad()
|
||||
and self.input_manager.gamepad is not None
|
||||
)
|
||||
if display_config.get_fullscreen() or auto_fullscreen_gamepad:
|
||||
self.showFullScreen()
|
||||
elif self._pending_resolution:
|
||||
# Apply resolution from command line
|
||||
self.resize(self._pending_resolution[0], self._pending_resolution[1])
|
||||
self.showNormal()
|
||||
else:
|
||||
width, height = window_config.get_geometry()
|
||||
if width > 0 and height > 0:
|
||||
self.resize(width, height)
|
||||
else:
|
||||
self.showNormal()
|
||||
|
||||
# Process events to ensure UI is responsive before starting heavy operations
|
||||
QApplication.processEvents()
|
||||
self.updateControlHints("force")
|
||||
|
||||
self.startup_load_timer = QTimer(self)
|
||||
self.startup_load_timer.setSingleShot(True)
|
||||
self.startup_load_timer.setInterval(self.theme.startupLibraryLoadDelayMs)
|
||||
self.startup_load_timer.timeout.connect(self.loadGames)
|
||||
self.startup_load_timer.start()
|
||||
|
||||
def start_boot_animation(self) -> None:
|
||||
"""Show the boot page when entering fullscreen."""
|
||||
if not self.isFullScreen() or self.boot_animation is not None:
|
||||
return
|
||||
if not display_config.get_boot_animation_enabled():
|
||||
return
|
||||
from portprotonqt.boot_animation import BootAnimation, find_boot_video
|
||||
|
||||
path = find_boot_video(self.current_theme_name)
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
animation = BootAnimation(self.theme, path, self.startup_stack)
|
||||
except ImportError as error:
|
||||
logger.warning("Qt Multimedia unavailable for boot video: %s", error)
|
||||
return
|
||||
self.boot_animation = animation
|
||||
self._boot_pending_load = self.startup_load_timer.isActive()
|
||||
self.startup_load_timer.stop()
|
||||
self.startup_stack.addWidget(animation)
|
||||
self.startup_stack.setCurrentWidget(animation)
|
||||
animation.finished.connect(self._finish_boot_animation)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(animation.stop)
|
||||
QTimer.singleShot(0, animation.start)
|
||||
|
||||
def _finish_boot_animation(self) -> None:
|
||||
animation = self.boot_animation
|
||||
if animation is None:
|
||||
return
|
||||
self.boot_animation = None
|
||||
self.startup_stack.setCurrentIndex(0)
|
||||
self.startup_stack.removeWidget(animation)
|
||||
animation.deleteLater()
|
||||
if self._boot_pending_load:
|
||||
self._boot_pending_load = False
|
||||
self.startup_load_timer.start()
|
||||
# Delay game loading until after the UI is fully displayed to prevent blocking
|
||||
# Use a longer delay to ensure window is fully rendered and responsive
|
||||
# Use a custom event processing approach to make sure UI stays responsive
|
||||
QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing
|
||||
|
||||
def on_slider_released(self) -> None:
|
||||
"""Delegate to game library manager."""
|
||||
@@ -807,7 +771,7 @@ class MainWindow(
|
||||
if state is None:
|
||||
continue
|
||||
status, percent, launch_started = state
|
||||
if launch_started or status == GAME_EXIT_MARKER:
|
||||
if launch_started:
|
||||
continue
|
||||
self._update_install_progress(status, percent)
|
||||
|
||||
@@ -909,9 +873,6 @@ class MainWindow(
|
||||
line_lower = line.lower()
|
||||
status = percent = None
|
||||
launch_started = line_text == GAME_LAUNCH_MARKER
|
||||
exit_match = re.fullmatch(rf"{GAME_EXIT_MARKER}=([0-9]{{1,3}})", line_text)
|
||||
if exit_match:
|
||||
return GAME_EXIT_MARKER, int(exit_match.group(1)), False
|
||||
progress_match = re.fullmatch(r'([0-9]*\.?[0-9]+)%', line_text)
|
||||
if progress_match:
|
||||
percent = float(progress_match.group(1))
|
||||
@@ -975,13 +936,17 @@ class MainWindow(
|
||||
@Slot(list)
|
||||
def on_games_loaded(self, games: list[tuple]):
|
||||
self._loading_games = False
|
||||
current_filter = (game_config.get_display_filter(), game_config.get_only_installed())
|
||||
loaded_filter = getattr(self, "_loading_library_filter", current_filter)
|
||||
if not loaded_filter[1]:
|
||||
display_filter = loaded_filter[0]
|
||||
self.games = games
|
||||
if not game_config.get_only_installed():
|
||||
display_filter = game_config.get_display_filter()
|
||||
library_cache = getattr(self, "_loaded_library_cache", {})
|
||||
library_cache[display_filter] = games
|
||||
self._loaded_library_cache = library_cache
|
||||
focus_first_card = not getattr(self, "_preserve_library_focus_after_load", False)
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
|
||||
self._known_portproton_desktops = self._get_portproton_desktop_files()
|
||||
|
||||
# Clear the refresh in progress flag
|
||||
if hasattr(self, '_refresh_in_progress'):
|
||||
self._refresh_in_progress = False
|
||||
@@ -991,17 +956,6 @@ class MainWindow(
|
||||
self.refreshButton.setEnabled(True)
|
||||
self._gamepad_tooltip_map[self.refreshButton] = _("Refresh Grid")
|
||||
|
||||
if loaded_filter != current_filter:
|
||||
self._on_only_installed_changed(current_filter[1])
|
||||
return
|
||||
self.games = games
|
||||
focus_first_card = not self.game_library_manager.games and not getattr(
|
||||
self, "_preserve_library_focus_after_load", False
|
||||
)
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
|
||||
self._known_portproton_desktops = self._get_portproton_desktop_files()
|
||||
|
||||
def loadGames(self, force_load: bool = False):
|
||||
if self._loading_games:
|
||||
return
|
||||
@@ -1015,7 +969,6 @@ class MainWindow(
|
||||
|
||||
self._loading_games = True
|
||||
display_filter = game_config.get_display_filter()
|
||||
self._loading_library_filter = (display_filter, game_config.get_only_installed())
|
||||
favorites = favorites_config.get_games()
|
||||
self.pending_games = []
|
||||
self.games = []
|
||||
@@ -1141,9 +1094,6 @@ class MainWindow(
|
||||
def on_game_info(game: dict, steam_info: dict) -> None:
|
||||
nonlocal processed_count
|
||||
app_id = str(game.get("app_id", ""))
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"gog", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
if not only_installed or is_installed:
|
||||
action = "launch" if is_installed else "install"
|
||||
@@ -1153,8 +1103,8 @@ class MainWindow(
|
||||
) if target else 0
|
||||
playtime_seconds = playtime_seconds or 0
|
||||
games.append((
|
||||
name, str(game.get("description", "")),
|
||||
custom_cover or str(game.get("cover", "")),
|
||||
str(game.get("title", app_id)),
|
||||
str(game.get("description", "")), str(game.get("cover", "")),
|
||||
app_id,
|
||||
steam_info.get("controller_support", ""),
|
||||
f"gog://{action}/{app_id}",
|
||||
@@ -1203,9 +1153,6 @@ class MainWindow(
|
||||
def on_game_info(game: dict, steam_info: dict) -> None:
|
||||
nonlocal processed_count
|
||||
app_id = str(game["app_id"])
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"egs", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
if not only_installed or installed:
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
@@ -1217,8 +1164,8 @@ class MainWindow(
|
||||
) if target else 0
|
||||
playtime = playtime or 0
|
||||
games.append((
|
||||
name, description,
|
||||
custom_cover or str(game.get("cover", "")), app_id,
|
||||
str(game.get("title", app_id)), description,
|
||||
str(game.get("cover", "")), app_id,
|
||||
steam_info.get("controller_support", ""),
|
||||
f"egs://{'launch' if installed else 'install'}/{app_id}",
|
||||
get_last_launch(f"egs-{app_id}") if target else _("Never"),
|
||||
@@ -1316,30 +1263,12 @@ class MainWindow(
|
||||
appid: int | str, fallback_name: str
|
||||
) -> tuple[str, str]:
|
||||
"""Return custom Steam name and cover stored by AppID."""
|
||||
return MainWindow._get_custom_store_data("steam", appid, fallback_name)
|
||||
|
||||
@staticmethod
|
||||
def _get_custom_store_data(
|
||||
source: str, appid: int | str, fallback_name: str
|
||||
) -> tuple[str, str]:
|
||||
"""Return custom store name and cover stored by game ID."""
|
||||
appid_str = str(appid)
|
||||
if appid_str in {"", ".", ".."} or os.path.basename(appid_str) != appid_str:
|
||||
return fallback_name, ""
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
storage_id = f"{source}-{appid_str}"
|
||||
game_dir = os.path.join(
|
||||
xdg_data_home, "PortProtonQt", "custom_data", storage_id
|
||||
xdg_data_home, "PortProtonQt", "custom_data", str(appid)
|
||||
)
|
||||
if source == "steam" and not os.path.isdir(game_dir):
|
||||
# Remove the legacy AppID-only fallback after the migration period.
|
||||
legacy_dir = os.path.join(
|
||||
xdg_data_home, "PortProtonQt", "custom_data", appid_str
|
||||
)
|
||||
if os.path.isdir(legacy_dir):
|
||||
game_dir = legacy_dir
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
custom_name = fallback_name
|
||||
if os.path.isfile(metadata_path):
|
||||
@@ -2085,8 +2014,8 @@ class MainWindow(
|
||||
|
||||
def checkTargetExe(self):
|
||||
"""Update launch state from the marker and PortProton process."""
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
child_running = self._has_running_game_process()
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
|
||||
if dependency_active:
|
||||
# Dependencies are downloading/extracting - update button with progress
|
||||
@@ -2109,8 +2038,7 @@ class MainWindow(
|
||||
def _finish_silent_launch(self) -> None:
|
||||
if not getattr(self, "silent_launch_mode", False):
|
||||
return
|
||||
if self.tray_manager is not None:
|
||||
self.tray_manager.tray_icon.hide()
|
||||
self.tray_manager.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
|
||||
def _set_running_button_stop(self) -> None:
|
||||
@@ -2145,9 +2073,6 @@ class MainWindow(
|
||||
status, percent, launch_started = self.launch_output_queue.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
if status == GAME_EXIT_MARKER:
|
||||
self.game_command_exit_code = int(percent) if percent is not None else None
|
||||
continue
|
||||
if launch_started:
|
||||
if not self.game_launch_started:
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
@@ -2201,9 +2126,6 @@ class MainWindow(
|
||||
self.wine_download_percent = 0.0
|
||||
self.wine_download_status = _("Downloading Wine…")
|
||||
self.game_launch_started = False
|
||||
self.game_observed_processes = {}
|
||||
self.game_observed_started = None
|
||||
self.game_command_exit_code = None
|
||||
launcher_only = getattr(self, "launcher_process_only", False)
|
||||
self.launcher_process_only = False
|
||||
self.game_processes = [] if launcher_only else [
|
||||
@@ -2312,33 +2234,22 @@ class MainWindow(
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
if self._observe_game_processes():
|
||||
return True
|
||||
process_alive = any(proc.poll() is None for proc in self.game_processes)
|
||||
if process_alive and not (
|
||||
getattr(self, "launcher_process_only", False)
|
||||
and (
|
||||
self.game_launch_started
|
||||
or getattr(self, "game_stopped_by_user", False)
|
||||
)
|
||||
and self.game_launch_started
|
||||
):
|
||||
if getattr(self, "game_observed_started", None) is None:
|
||||
self.game_process_exit_monotonic = None
|
||||
self.game_process_exit_monotonic = None
|
||||
return True
|
||||
target = str(self.target_exe or "").lower()
|
||||
if target and not getattr(self, "game_start_exe", None):
|
||||
for process in psutil.process_iter(attrs=["name", "status"]):
|
||||
if target:
|
||||
for process in psutil.process_iter(attrs=["name"]):
|
||||
try:
|
||||
if (
|
||||
str(process.info.get("name") or "").lower() == target
|
||||
and process.info.get("status") != psutil.STATUS_ZOMBIE
|
||||
):
|
||||
if str(process.info.get("name") or "").lower() == target:
|
||||
self.game_process_exit_monotonic = None
|
||||
return True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
if getattr(self, "game_stopped_by_user", False):
|
||||
return False
|
||||
launch_started = getattr(self, "game_launch_monotonic", None)
|
||||
if launch_started is not None:
|
||||
now = time.monotonic()
|
||||
@@ -2350,26 +2261,6 @@ class MainWindow(
|
||||
return False
|
||||
return (datetime.now() - started).total_seconds() < STORE_LAUNCH_GRACE_SECONDS
|
||||
|
||||
def _observe_game_processes(self) -> bool:
|
||||
executable = getattr(self, "game_start_exe", None)
|
||||
started = getattr(self, "game_start_time", None)
|
||||
if not executable or started is None:
|
||||
return False
|
||||
running = get_running_game_processes(executable, started.timestamp())
|
||||
previous = getattr(self, "game_observed_processes", {})
|
||||
for identity in running.keys() - previous.keys():
|
||||
logger.info("Game process appeared: pid=%s executable=%s", identity[0], running[identity])
|
||||
for identity in previous.keys() - running.keys():
|
||||
logger.info("Game process disappeared: pid=%s executable=%s", identity[0], previous[identity])
|
||||
self.game_observed_processes = running
|
||||
if running:
|
||||
if getattr(self, "game_observed_started", None) is None:
|
||||
self.game_observed_started = time.monotonic()
|
||||
self.game_process_exit_monotonic = None
|
||||
elif previous:
|
||||
self.game_process_exit_monotonic = time.monotonic()
|
||||
return bool(running)
|
||||
|
||||
def _start_launch_output_reader(self, process: subprocess.Popen[str]) -> None:
|
||||
"""Start background reader for PortProton launch output."""
|
||||
self.launch_output_queue = Queue()
|
||||
@@ -2434,43 +2325,28 @@ class MainWindow(
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
logger.info("Compatibility report skipped: crash reports disabled")
|
||||
return
|
||||
executable = getattr(self, "game_start_exe", None) or ""
|
||||
started = getattr(self, "game_observed_started", None)
|
||||
started = getattr(self, "game_launch_monotonic", None)
|
||||
if started is None:
|
||||
started = getattr(self, "game_launch_monotonic", None)
|
||||
if started is None:
|
||||
logger.info("Compatibility report skipped: no launch marker for %s", executable)
|
||||
return
|
||||
ended = getattr(self, "game_process_exit_monotonic", None)
|
||||
duration = (ended or time.monotonic()) - started
|
||||
stopped_by_user = getattr(self, "game_stopped_by_user", False)
|
||||
launcher_exit_code = next(
|
||||
exit_code = next(
|
||||
(process.poll() for process in self.game_processes if process.poll() is not None),
|
||||
None,
|
||||
)
|
||||
exit_code = getattr(self, "game_command_exit_code", None)
|
||||
launch = CompatibilityLaunch(
|
||||
executable=executable,
|
||||
exit_code=exit_code,
|
||||
duration=duration,
|
||||
)
|
||||
logger.info(
|
||||
"Compatibility launch: executable=%s duration=%.3fs wine_exit_code=%s "
|
||||
"launcher_exit_code=%s stopped_by_user=%s process_observed=%s",
|
||||
executable, duration, exit_code, launcher_exit_code, stopped_by_user,
|
||||
getattr(self, "game_observed_started", None) is not None,
|
||||
)
|
||||
worker = Thread(
|
||||
target=self._build_compatibility_report,
|
||||
args=(launch, stopped_by_user),
|
||||
daemon=True,
|
||||
)
|
||||
self.compatibility_workers = [
|
||||
active for active in getattr(self, "compatibility_workers", []) if active.is_alive()
|
||||
]
|
||||
self.compatibility_workers.append(worker)
|
||||
worker.start()
|
||||
|
||||
def _build_compatibility_report(
|
||||
@@ -2479,27 +2355,17 @@ class MainWindow(
|
||||
try:
|
||||
incompatible_dxvk = has_dxvk_vulkan_incompatibility(launch.executable)
|
||||
if not incompatible_dxvk and not is_suspected_crash(
|
||||
launch.duration, stopped_by_user, launch.executable, launch.exit_code
|
||||
launch.duration, stopped_by_user, launch.executable
|
||||
):
|
||||
logger.info(
|
||||
"Compatibility report skipped: crash criteria not met "
|
||||
"(duration=%.3fs stopped_by_user=%s windows_executable=%s exists=%s)",
|
||||
launch.duration, stopped_by_user,
|
||||
launch.executable.lower().endswith((".exe", ".msi")),
|
||||
os.path.isfile(launch.executable),
|
||||
)
|
||||
return
|
||||
logger.info("Compatibility analysis started: %s", launch.executable)
|
||||
report = analyze_launch(launch, self.portproton_location or "")
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
|
||||
return
|
||||
self.compatibility_report_ready.emit(report, launch.executable)
|
||||
self.compatibility_report_ready.emit(report)
|
||||
|
||||
def _show_compatibility_report(self, report: str, executable: str) -> None:
|
||||
logger.info("Compatibility report dialog opened")
|
||||
def _show_compatibility_report(self, report: str) -> None:
|
||||
dialog = CompatibilityReportDialog(self, self.theme, report)
|
||||
dialog.check_ppdb(executable)
|
||||
dialog.exec()
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
@@ -2532,10 +2398,6 @@ class MainWindow(
|
||||
self.current_running_button = button
|
||||
self.game_stopped_by_user = True
|
||||
self.egs_launch_cancelled = True
|
||||
if getattr(self, "launcher_process_only", False):
|
||||
if self._run_portproton_stop_command():
|
||||
return True
|
||||
|
||||
self._analyze_short_launch()
|
||||
|
||||
self._terminate_game_processes()
|
||||
@@ -2605,14 +2467,23 @@ class MainWindow(
|
||||
|
||||
return ALT_BIARCH_REPO in result.stdout
|
||||
|
||||
def _has_missing_alt_i586_packages(self) -> bool:
|
||||
if not self.start_sh:
|
||||
return True
|
||||
def _get_missing_alt_i586_packages(self) -> list[str]:
|
||||
try:
|
||||
installed = set(self._get_installed_alt_package_names())
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to check ALT i586 packages: %s", e)
|
||||
return [f"i586-{package}" for package in ALT_I586_BASE_PACKAGES]
|
||||
|
||||
return [
|
||||
f"i586-{package}" for package in self._get_alt_i586_package_names(installed)
|
||||
if f"i586-{package}" not in installed
|
||||
]
|
||||
|
||||
def _get_installed_alt_package_names(self) -> list[str]:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(
|
||||
subprocess.run,
|
||||
self.start_sh + ["cli", "--alt-i586-dependencies"],
|
||||
["rpm", "-qa", "--qf", "%{NAME}\n"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
@@ -2623,44 +2494,46 @@ class MainWindow(
|
||||
while not future.done():
|
||||
QApplication.processEvents(event_flags)
|
||||
time.sleep(0.01)
|
||||
try:
|
||||
result = future.result()
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to check ALT i586 packages: %s", e)
|
||||
return True
|
||||
result = future.result()
|
||||
if result.returncode != 0:
|
||||
raise subprocess.SubprocessError(result.stderr.strip())
|
||||
|
||||
return result.returncode != 0
|
||||
return result.stdout.splitlines()
|
||||
|
||||
def _install_alt_i586_dependencies(self) -> bool:
|
||||
if not self.start_sh:
|
||||
return False
|
||||
|
||||
command = [
|
||||
"pkexec",
|
||||
*self.start_sh,
|
||||
"cli",
|
||||
"--alt-i586-dependencies",
|
||||
"install",
|
||||
def _get_alt_i586_package_names(self, installed: set[str]) -> list[str]:
|
||||
packages = [
|
||||
package for package in ALT_I586_BASE_PACKAGES
|
||||
if package in installed
|
||||
]
|
||||
process = QProcess(self)
|
||||
event_loop = QEventLoop()
|
||||
process.finished.connect(event_loop.quit)
|
||||
process.start(
|
||||
sys.executable,
|
||||
["-m", "portprotonqt.scripts_utils.easyterm", "-e", shlex.join(command)],
|
||||
packages.extend(
|
||||
package for package in sorted(installed)
|
||||
if self._is_alt_i586_prefixed_package(package)
|
||||
)
|
||||
if not process.waitForStarted(5000):
|
||||
logger.warning("Failed to start ALT i586 dependency installer")
|
||||
return False
|
||||
return packages
|
||||
|
||||
event_loop.exec()
|
||||
return not self._has_missing_alt_i586_packages()
|
||||
def _is_alt_i586_prefixed_package(self, package: str) -> bool:
|
||||
if package in ALT_I586_EXCLUDED_PACKAGES:
|
||||
return False
|
||||
return any(package.startswith(prefix) for prefix in ALT_I586_PACKAGE_PREFIXES)
|
||||
|
||||
def _create_alt_i586_install_script(self) -> str | None:
|
||||
try:
|
||||
fd, script_path = tempfile.mkstemp(
|
||||
prefix="portprotonqt-alt-i586-",
|
||||
suffix=".sh",
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as script:
|
||||
script.write(ALT_I586_INSTALL_SCRIPT)
|
||||
os.chmod(script_path, ALT_INSTALL_SCRIPT_MODE)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to create ALT i586 install script: %s", e)
|
||||
return None
|
||||
|
||||
return script_path
|
||||
|
||||
def _check_alt_i586_dependencies_before_launch(self) -> bool:
|
||||
if not self._is_alt_x86_64():
|
||||
return True
|
||||
if ui_config.get_disable_alt_i586_dependency_check():
|
||||
return True
|
||||
|
||||
if not self._has_alt_biarch_repo():
|
||||
QMessageBox.warning(
|
||||
@@ -2670,7 +2543,7 @@ class MainWindow(
|
||||
)
|
||||
return False
|
||||
|
||||
if not self._has_missing_alt_i586_packages():
|
||||
if not self._get_missing_alt_i586_packages():
|
||||
return True
|
||||
|
||||
msg_box = QMessageBox(self)
|
||||
@@ -2685,7 +2558,19 @@ class MainWindow(
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
return self._install_alt_i586_dependencies()
|
||||
script_path = self._create_alt_i586_install_script()
|
||||
if not script_path:
|
||||
return False
|
||||
|
||||
QProcess.startDetached(
|
||||
sys.executable,
|
||||
[
|
||||
"-m",
|
||||
"portprotonqt.scripts_utils.easyterm",
|
||||
"-e",
|
||||
f"pkexec /bin/sh {shlex.quote(script_path)}",
|
||||
],
|
||||
)
|
||||
return False
|
||||
|
||||
def _resolve_iso_launch_parts(self, iso_path: str) -> list[str] | None:
|
||||
@@ -2734,7 +2619,7 @@ class MainWindow(
|
||||
None,
|
||||
)
|
||||
if game:
|
||||
self._select_store_dlcs("gog", game)
|
||||
self._install_gog_game(game)
|
||||
|
||||
def _handle_egs_game(self, exec_line: str, button=None) -> None:
|
||||
action, app_id = exec_line.removeprefix("egs://").split("/", 1)
|
||||
@@ -2746,12 +2631,7 @@ class MainWindow(
|
||||
self._install_egs_game(app_id)
|
||||
|
||||
def _install_egs_game(self, app_id: str) -> None:
|
||||
game = next(
|
||||
(item for item in self.egs_api.load_library()
|
||||
if str(item.get("app_id", "")) == app_id),
|
||||
{"app_id": app_id, "title": app_id, "cover": ""},
|
||||
)
|
||||
self._select_store_dlcs("egs", game)
|
||||
self._install_egs_download(app_id)
|
||||
|
||||
def _repair_egs_game(self, app_id: str) -> None:
|
||||
self._start_egs_operation(
|
||||
@@ -2765,24 +2645,6 @@ class MainWindow(
|
||||
)
|
||||
|
||||
def _delete_egs_game(self, app_id: str) -> None:
|
||||
game = self.egs_api.load_installed().get(app_id, {})
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game.get("title") or app_id
|
||||
)
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._start_egs_operation(
|
||||
app_id, ["uninstall", app_id, "-y"], _("Delete")
|
||||
)
|
||||
@@ -2873,37 +2735,6 @@ class MainWindow(
|
||||
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
|
||||
return self.egs_api.is_eos_overlay_enabled(prefix_path)
|
||||
|
||||
def _warn_cyrillic_launch_paths(self, game_path: str) -> bool:
|
||||
if ui_config.get_disable_cyrillic_path_check():
|
||||
return False
|
||||
paths = [game_path, *(self.start_sh or [])[:1]]
|
||||
affected_paths = []
|
||||
for path in paths:
|
||||
for candidate in (os.path.abspath(path), os.path.realpath(path)):
|
||||
if candidate in affected_paths:
|
||||
continue
|
||||
if any("CYRILLIC" in unicodedata.name(char, "") for char in candidate):
|
||||
affected_paths.append(candidate)
|
||||
if not affected_paths:
|
||||
return False
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Warning)
|
||||
message_box.setWindowTitle(_("Warning"))
|
||||
message_box.setText(
|
||||
_("Cyrillic characters were found in the following paths. "
|
||||
"Wine may work unreliably. Move the game or PortProton "
|
||||
"to a path without Cyrillic characters:\n\n")
|
||||
+ "\n".join(affected_paths)
|
||||
)
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Close)
|
||||
disable_button = message_box.addButton(
|
||||
_("Don't remind again"), QMessageBox.ButtonRole.ActionRole
|
||||
)
|
||||
message_box.exec()
|
||||
if message_box.clickedButton() is disable_button:
|
||||
ui_config.set_disable_cyrillic_path_check(True)
|
||||
return True
|
||||
|
||||
def _launch_egs_game(self, app_id: str, button=None) -> None:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
if not target:
|
||||
@@ -2923,15 +2754,12 @@ class MainWindow(
|
||||
self, _("Error"), _("Cannot launch game while another game is running")
|
||||
)
|
||||
return
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
self._finish_silent_launch()
|
||||
return
|
||||
try:
|
||||
wrapper = shlex.join([self.start_sh[0], "--epic-wrapper"])
|
||||
command = self.egs_api.build_command([
|
||||
"launch", app_id, "--json", "--wrapper", self.start_sh[0],
|
||||
"launch", app_id, "--json", "--wrapper", wrapper,
|
||||
"--no-wine", "--skip-version-check",
|
||||
])
|
||||
self._warn_cyrillic_launch_paths(target)
|
||||
process = subprocess.Popen(
|
||||
command, env=self.egs_api.get_environment(), shell=False,
|
||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
||||
@@ -2990,12 +2818,6 @@ class MainWindow(
|
||||
if self.game_processes:
|
||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||
return
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
self._finish_silent_launch()
|
||||
return
|
||||
self._warn_cyrillic_launch_paths(
|
||||
self.gog_api.get_launch_target(app_id) or install_path
|
||||
)
|
||||
needs_setup = getattr(self.gog_api, "needs_support_setup", lambda _app_id: False)
|
||||
if needs_setup(app_id):
|
||||
self._start_gog_support_setup(app_id, button)
|
||||
@@ -3099,11 +2921,6 @@ class MainWindow(
|
||||
if not self.stop_running_game(update_button):
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to stop game"))
|
||||
else:
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
if file_to_check.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS):
|
||||
self._warn_cyrillic_launch_paths(file_to_check)
|
||||
|
||||
if update_button:
|
||||
try:
|
||||
update_button.setText(_("Stop"))
|
||||
@@ -3112,6 +2929,12 @@ class MainWindow(
|
||||
except RuntimeError:
|
||||
update_button = None
|
||||
SoundManager().play("game_launch")
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
if update_button:
|
||||
update_button.setText(_("Start"))
|
||||
icon = self.theme_manager.get_icon("play", as_path=True)
|
||||
update_button.setIcon(icon)
|
||||
return
|
||||
|
||||
# Save button reference for reset after game completion
|
||||
self.current_running_button = update_button
|
||||
@@ -3160,11 +2983,6 @@ class MainWindow(
|
||||
logger.error(f"Failed to launch game {exe_name}: {e}")
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||
|
||||
def _initialize_tray(self, app_name: str) -> None:
|
||||
"""Create the tray item after the Qt event loop starts."""
|
||||
if self.tray_manager is None:
|
||||
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close: check minimize_to_tray setting.
|
||||
If True - minimize to tray. Otherwise - fully close.
|
||||
@@ -3186,13 +3004,11 @@ class MainWindow(
|
||||
watcher.wait()
|
||||
|
||||
# Hide and remove tray icon
|
||||
if self.tray_manager is not None:
|
||||
if hasattr(self, "tray_manager"):
|
||||
self.tray_manager.shutdown()
|
||||
|
||||
# Save card sizes only for grid layouts.
|
||||
layout_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
size_slider = getattr(self.game_library_manager, 'sizeSlider', None)
|
||||
if size_slider is None or layout_mode not in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
|
||||
@@ -4,7 +4,6 @@ install_data(
|
||||
'appimage_integration.py',
|
||||
'appimage_updater.py',
|
||||
'cli.py',
|
||||
'boot_animation.py',
|
||||
'context_menu_manager.py',
|
||||
'custom_widgets.py',
|
||||
'dialogs.py',
|
||||
@@ -186,7 +185,6 @@ install_data(
|
||||
['scripts_utils/__init__.py',
|
||||
'scripts_utils/dbus_tools.py',
|
||||
'scripts_utils/easyterm.py',
|
||||
'scripts_utils/exe_metadata.py',
|
||||
'scripts_utils/graphics_detector.py',
|
||||
'scripts_utils/idle_inhibit.py',
|
||||
'scripts_utils/json_tools.py',
|
||||
|
||||
@@ -28,14 +28,12 @@ _library.portproton_gamepad_close.argtypes = [_handle]
|
||||
_library.portproton_gamepad_close.restype = None
|
||||
_library.portproton_gamepad_connected.argtypes = [_handle]
|
||||
_library.portproton_gamepad_connected.restype = ctypes.c_bool
|
||||
_library.portproton_gamepad_update.argtypes = [_handle]
|
||||
_library.portproton_gamepad_update.argtypes = []
|
||||
_library.portproton_gamepad_update.restype = None
|
||||
_library.portproton_gamepad_get_button.argtypes = [_handle, ctypes.c_int]
|
||||
_library.portproton_gamepad_get_button.restype = ctypes.c_int
|
||||
_library.portproton_gamepad_get_axis.argtypes = [_handle, ctypes.c_int]
|
||||
_library.portproton_gamepad_get_axis.restype = ctypes.c_int16
|
||||
_library.portproton_gamepad_get_active_instance_id.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_active_instance_id.restype = ctypes.c_uint32
|
||||
_library.portproton_gamepad_get_name.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_name.restype = ctypes.c_char_p
|
||||
_library.portproton_gamepad_get_instance_id.argtypes = [_handle]
|
||||
@@ -47,7 +45,7 @@ _library.portproton_gamepad_shutdown.restype = None
|
||||
|
||||
|
||||
class GamepadBackendError(RuntimeError):
|
||||
"""SDL failed while discovering connected gamepads."""
|
||||
"""SDL failed while discovering the active gamepad."""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,7 +55,6 @@ class SDLGamepad:
|
||||
path: str
|
||||
instance_id: int
|
||||
sdl_type: int
|
||||
active_instance_id: int = 0
|
||||
|
||||
def close(self) -> None:
|
||||
if self.controller:
|
||||
@@ -70,16 +67,8 @@ class SDLGamepad:
|
||||
def connected(self) -> bool:
|
||||
return bool(_library.portproton_gamepad_connected(self.controller))
|
||||
|
||||
def update(self) -> bool:
|
||||
_library.portproton_gamepad_update(self.controller)
|
||||
self.sdl_type = int(_library.portproton_gamepad_get_type(self.controller))
|
||||
active_instance_id = int(
|
||||
_library.portproton_gamepad_get_active_instance_id(self.controller)
|
||||
)
|
||||
if active_instance_id == self.active_instance_id:
|
||||
return False
|
||||
self.active_instance_id = active_instance_id
|
||||
return True
|
||||
def update(self) -> None:
|
||||
_library.portproton_gamepad_update()
|
||||
|
||||
def get_button(self, button: int) -> int:
|
||||
return int(_library.portproton_gamepad_get_button(self.controller, button))
|
||||
|
||||
@@ -340,31 +340,17 @@ class PortProtonAPI:
|
||||
|
||||
def _get_autoinstall_exe_name(self, script_path: str) -> str:
|
||||
install_exe = ""
|
||||
shortcut_name = ""
|
||||
try:
|
||||
with open(script_path, encoding="utf-8") as script_file:
|
||||
for line in script_file:
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
if "PORTWINE_CREATE_SHORTCUT_NAME=" in line:
|
||||
match = re.search(
|
||||
r'PORTWINE_CREATE_SHORTCUT_NAME=["\']([^"\']+)', line
|
||||
)
|
||||
if match:
|
||||
shortcut_name = match.group(1)
|
||||
if "PW_EXE_FILE" in line:
|
||||
exe_name = self._extract_exe_name_from_script_line(line)
|
||||
if exe_name:
|
||||
install_exe = exe_name
|
||||
if line.strip().startswith("pw_create_unique_exe"):
|
||||
match = re.search(r'pw_create_unique_exe\s+["\']?([^"\' ]+)', line)
|
||||
unique_name = match.group(1) if match else shortcut_name
|
||||
if unique_name:
|
||||
return f"{os.path.splitext(unique_name)[0]}.exe"
|
||||
return exe_name
|
||||
if "PW_AUTOINSTALL_EXE" not in line:
|
||||
continue
|
||||
exe_name = self._extract_exe_name_from_script_line(line)
|
||||
if exe_name and not install_exe:
|
||||
if exe_name:
|
||||
install_exe = exe_name
|
||||
except OSError as e:
|
||||
logger.warning("Failed to read autoinstall script %s: %s", script_path, e)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
@@ -169,20 +168,17 @@ async def request_deepin_wm_switch() -> bool:
|
||||
|
||||
|
||||
async def request_idle_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
"""Request idle inhibit through screensaver, portal or logind D-Bus API."""
|
||||
bus = None
|
||||
"""Request idle inhibit through screensaver or portal D-Bus API."""
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
kind, payload = await _request_screensaver_inhibit(bus, application, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Screensaver inhibit failed: %s", error)
|
||||
kind, payload = await _request_portal_inhibit(bus, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Session idle inhibit failed: %s", error)
|
||||
if bus is not None:
|
||||
bus.disconnect()
|
||||
return await _request_logind_inhibit(application, reason)
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
return bus, kind, payload
|
||||
|
||||
@@ -215,33 +211,8 @@ async def _request_portal_inhibit(bus: MessageBus, reason: str) -> tuple[str, An
|
||||
return "portal", body[0]
|
||||
|
||||
|
||||
async def _request_logind_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SYSTEM, negotiate_unix_fd=True).connect())
|
||||
try:
|
||||
reply = await dbus_call(bus.call(Message(
|
||||
destination="org.freedesktop.login1",
|
||||
path="/org/freedesktop/login1",
|
||||
interface="org.freedesktop.login1.Manager",
|
||||
member="Inhibit",
|
||||
signature="ssss",
|
||||
body=["sleep:idle", application, reason, "block"],
|
||||
)))
|
||||
if reply.message_type.name == "ERROR":
|
||||
raise DBusError(reply.error_name, reply.body[0])
|
||||
return bus, "logind", reply.unix_fds[reply.body[0]]
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
|
||||
async def release_idle_inhibit(bus: MessageBus, kind: str, payload: Any) -> None:
|
||||
"""Release idle inhibit requested through request_idle_inhibit."""
|
||||
if kind == "logind":
|
||||
try:
|
||||
os.close(payload)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
return
|
||||
with contextlib.suppress(DBusError, asyncio.TimeoutError):
|
||||
if kind == "screensaver":
|
||||
iface, cookie = payload
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import codecs
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
@@ -15,7 +14,6 @@ from typing import Any
|
||||
|
||||
from PySide6.QtCore import QMimeData, QSocketNotifier, Qt, QTimer
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
@@ -30,13 +28,10 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QPlainTextEdit
|
||||
|
||||
from portprotonqt.input_manager.constants import SDL_GAMEPAD_BUTTON_SOUTH
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_gamepad import GamepadBackendError, SDLGamepad, find_gamepad
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
APPLICATION_ID = "ru.linux_gaming.PortProtonQt"
|
||||
|
||||
CONF_NAME = "PortProtonQt Terminal"
|
||||
CONF_FALLBACK_SHELL = "/bin/bash"
|
||||
@@ -44,7 +39,6 @@ CONF_DEF_SIZE = (800, 260)
|
||||
CONF_FALLBACK_FONT_EXTRA = 6
|
||||
CONF_SCROLLBACK_LINES = 2000
|
||||
CONF_RENDER_INTERVAL_MS = 16
|
||||
CONF_GAMEPAD_POLL_INTERVAL_MS = 50
|
||||
|
||||
XDG_DATA_HOME = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
@@ -884,9 +878,6 @@ class TerminalWidget(QPlainTextEdit):
|
||||
self.app_theme = app_theme or load_current_theme()
|
||||
self.cmd_mode = cmd_mode
|
||||
self.child_pid: int | None = None
|
||||
self.exit_code: int | None = None
|
||||
self.gamepad: SDLGamepad | None = None
|
||||
self.gamepad_a_pressed = False
|
||||
self.pty_fd: int | None = None
|
||||
self.notifier: QSocketNotifier | None = None
|
||||
self.ansi_colors = _theme_ansi_colors(theme)
|
||||
@@ -915,8 +906,6 @@ class TerminalWidget(QPlainTextEdit):
|
||||
self.theme_manager = ThemeManager()
|
||||
self.child_timer = QTimer(self)
|
||||
self.child_timer.timeout.connect(self._check_child)
|
||||
self.gamepad_timer = QTimer(self)
|
||||
self.gamepad_timer.timeout.connect(self._poll_close_gamepad)
|
||||
self.render_timer = QTimer(self)
|
||||
self.render_timer.setSingleShot(True)
|
||||
self.render_timer.timeout.connect(self._render_screen)
|
||||
@@ -1026,7 +1015,11 @@ class TerminalWidget(QPlainTextEdit):
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
except ValueError:
|
||||
max_fd = 256
|
||||
os.closerange(3, max_fd)
|
||||
for fd in range(3, max_fd):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.cwd:
|
||||
os.chdir(self.cwd)
|
||||
@@ -1050,15 +1043,11 @@ class TerminalWidget(QPlainTextEdit):
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO:
|
||||
if self.notifier is not None:
|
||||
self.notifier.setEnabled(False)
|
||||
return
|
||||
logger.warning("Terminal read failed: %s", e)
|
||||
QApplication.quit()
|
||||
return
|
||||
if not data:
|
||||
if self.notifier is not None:
|
||||
self.notifier.setEnabled(False)
|
||||
QApplication.quit()
|
||||
return
|
||||
text = self.output_decoder.decode(data)
|
||||
if self.cmd_mode:
|
||||
@@ -1072,65 +1061,15 @@ class TerminalWidget(QPlainTextEdit):
|
||||
if self.child_pid is None:
|
||||
return
|
||||
try:
|
||||
pid, status = os.waitpid(self.child_pid, os.WNOHANG)
|
||||
pid, _status = os.waitpid(self.child_pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
pid = self.child_pid
|
||||
status = 0
|
||||
if pid == 0:
|
||||
return
|
||||
self.child_pid = None
|
||||
self.child_timer.stop()
|
||||
self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self._read_remaining_output()
|
||||
self.exit_code = os.waitstatus_to_exitcode(status)
|
||||
if self.exit_code == 0:
|
||||
QApplication.exit(0)
|
||||
return
|
||||
self._start_close_gamepad()
|
||||
|
||||
def _start_close_gamepad(self) -> None:
|
||||
try:
|
||||
self.gamepad = find_gamepad()
|
||||
except GamepadBackendError as e:
|
||||
logger.debug("Failed to find terminal gamepad: %s", e)
|
||||
return
|
||||
if self.gamepad is None:
|
||||
return
|
||||
self.gamepad.update()
|
||||
self.gamepad_a_pressed = bool(
|
||||
self.gamepad.get_button(SDL_GAMEPAD_BUTTON_SOUTH)
|
||||
)
|
||||
self.gamepad_timer.start(CONF_GAMEPAD_POLL_INTERVAL_MS)
|
||||
|
||||
def _poll_close_gamepad(self) -> None:
|
||||
if self.gamepad is None or not self.gamepad.connected():
|
||||
self.gamepad_timer.stop()
|
||||
return
|
||||
self.gamepad.update()
|
||||
pressed = bool(self.gamepad.get_button(SDL_GAMEPAD_BUTTON_SOUTH))
|
||||
if pressed and not self.gamepad_a_pressed:
|
||||
self.window().close()
|
||||
self.gamepad_a_pressed = pressed
|
||||
|
||||
def _close_gamepad(self) -> None:
|
||||
self.gamepad_timer.stop()
|
||||
if self.gamepad is not None:
|
||||
self.gamepad.close()
|
||||
self.gamepad = None
|
||||
|
||||
def _read_remaining_output(self) -> None:
|
||||
if self.pty_fd is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
data = os.read(self.pty_fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
self._append_output(self.output_decoder.decode(data))
|
||||
if self.terminal_screen is not None:
|
||||
self._render_screen()
|
||||
QApplication.quit()
|
||||
|
||||
def _append_output(self, text: str) -> None:
|
||||
if self.terminal_screen is not None:
|
||||
@@ -1257,12 +1196,6 @@ class TerminalWidget(QPlainTextEdit):
|
||||
self._write_pty(source.text())
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
if self.child_pid is None and event.key() in (
|
||||
Qt.Key.Key_Return,
|
||||
Qt.Key.Key_Enter,
|
||||
):
|
||||
self.window().close()
|
||||
return
|
||||
if self.cmd_mode and self._handle_cmd_key(event):
|
||||
return
|
||||
if self._handle_shortcut(event):
|
||||
@@ -1850,12 +1783,6 @@ class MainWindow(QMainWindow):
|
||||
if fullscreen:
|
||||
self.showFullScreen()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
exit_code = self.terminal.exit_code or 0
|
||||
self.terminal._close_gamepad()
|
||||
event.accept()
|
||||
QApplication.exit(exit_code)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse xterm-compatible arguments used by PortProton scripts."""
|
||||
@@ -1935,8 +1862,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
command = build_command(args)
|
||||
cmd_mode = _is_cmd_mode(args)
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setWindowIcon(QIcon.fromTheme(APPLICATION_ID))
|
||||
app.setDesktopFileName(APPLICATION_ID)
|
||||
app_theme = load_current_theme()
|
||||
theme = load_current_terminal_scheme() or load_terminal_scheme("default")
|
||||
window = MainWindow(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Read PE version metadata for PortProton shell scripts."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pefile
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def read_metadata(path: str) -> dict[str, str] | None:
|
||||
"""Read version strings without starting an external metadata tool."""
|
||||
try:
|
||||
with pefile.PE(path, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
return {
|
||||
key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", []) for key, value in table.entries.items()
|
||||
}
|
||||
except pefile.PEFormatError as error:
|
||||
logger.debug("Unsupported PE file %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
sys.stderr.write("Usage: python -m portprotonqt.scripts_utils.exe_metadata FILE\n")
|
||||
return 2
|
||||
metadata = read_metadata(sys.argv[1])
|
||||
if metadata is None:
|
||||
return 1
|
||||
sys.stdout.write(f"File Name: {Path(sys.argv[1]).name}\n")
|
||||
for key, value in metadata.items():
|
||||
label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
|
||||
sys.stdout.write(f"{label}: {' '.join(value.splitlines())}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,11 +1,8 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
UNREAL_BOOTSTRAPPER_MAX_SIZE = 1_048_576
|
||||
LAUNCHER_NAME_MARKERS = ("launcher", "setup", "updater", "unins")
|
||||
|
||||
DIRECTX_IMPORTS = {
|
||||
"ddraw.dll": "DirectDraw",
|
||||
@@ -52,8 +49,6 @@ OPENGL_SIGNATURES = {
|
||||
"OpenGL Utility Library": (b"glu32.dll", b"gluPerspective", b"gluLookAt"),
|
||||
}
|
||||
|
||||
VULKAN_SIGNATURES = (b"vulkan-1.dll", b"vkCreateInstance", b"vkGetInstanceProcAddr")
|
||||
|
||||
|
||||
def analyze_executable(file_path: str) -> dict[str, Any]:
|
||||
"""Detect graphics API usage in a Windows executable."""
|
||||
@@ -64,58 +59,23 @@ def analyze_executable(file_path: str) -> dict[str, Any]:
|
||||
|
||||
directx = set()
|
||||
opengl = set()
|
||||
vulkan = set()
|
||||
|
||||
if analysis_path.suffix.lower() in {".exe", ".dll"}:
|
||||
_add_pe_imports(analysis_path, directx, opengl)
|
||||
_add_file_signatures(analysis_path, directx, opengl, vulkan)
|
||||
engine_family = _read_engine_render_family(analysis_path)
|
||||
if engine_family == "vulkan":
|
||||
vulkan.add("Vulkan")
|
||||
elif engine_family == "opengl":
|
||||
opengl.add("OpenGL")
|
||||
_add_file_signatures(analysis_path, directx, opengl)
|
||||
|
||||
return {
|
||||
"highest_directx": _highest_directx(list(directx)),
|
||||
"uses_opengl": bool(opengl),
|
||||
"uses_vulkan": bool(vulkan),
|
||||
"source": str(analysis_path),
|
||||
}
|
||||
|
||||
|
||||
def _read_engine_render_family(executable: Path) -> str | None:
|
||||
"""Read a renderer choice from the active Wine prefix engine config."""
|
||||
wineprefix = os.getenv("WINEPREFIX")
|
||||
if not wineprefix:
|
||||
return None
|
||||
config_root = Path(wineprefix) / "drive_c" / "users"
|
||||
try:
|
||||
config_paths = config_root.glob("*/Saved Games/**/kexengine.cfg")
|
||||
for config_path in config_paths:
|
||||
game_name = re.sub(r"[^a-z0-9]", "", config_path.parent.name.lower())
|
||||
executable_name = re.sub(
|
||||
r"[^a-z0-9]", "", executable.parent.name.lower()
|
||||
)
|
||||
executable_stem = re.sub(r"[^a-z0-9]", "", executable.stem.lower())
|
||||
if game_name not in executable_name and game_name not in executable_stem:
|
||||
continue
|
||||
for line in config_path.read_text(errors="ignore").splitlines():
|
||||
match = re.match(r'\s*seta\s+r_rhirenderfamily\s+"?([^"\s]+)', line)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_graphics_executable(file_path: str) -> Path:
|
||||
"""Resolve a launcher to the binary that implements its graphics API."""
|
||||
path = Path(file_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
unity_player = path.parent / "UnityPlayer.dll"
|
||||
if unity_player.is_file():
|
||||
return unity_player
|
||||
if path.stat().st_size < UNREAL_BOOTSTRAPPER_MAX_SIZE:
|
||||
patterns = (
|
||||
"*/Binaries/Win64/*-Win64-Shipping.exe",
|
||||
@@ -129,28 +89,9 @@ def resolve_graphics_executable(file_path: str) -> Path:
|
||||
renpy_libraries = sorted(path.parent.glob("lib/*/librenpython.dll"))
|
||||
if renpy_libraries:
|
||||
return renpy_libraries[0]
|
||||
is_launcher = any(marker in path.stem.lower() for marker in LAUNCHER_NAME_MARKERS)
|
||||
if is_launcher or not _is_pe_file(path):
|
||||
pe_candidates = [
|
||||
candidate
|
||||
for candidate in path.parent.glob("*.exe")
|
||||
if candidate != path
|
||||
and _is_pe_file(candidate)
|
||||
and not any(marker in candidate.stem.lower() for marker in LAUNCHER_NAME_MARKERS)
|
||||
]
|
||||
if pe_candidates:
|
||||
return max(pe_candidates, key=lambda candidate: candidate.stat().st_size)
|
||||
return path
|
||||
|
||||
|
||||
def _is_pe_file(path: Path) -> bool:
|
||||
try:
|
||||
with path.open("rb") as executable:
|
||||
return executable.read(2) == b"MZ"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
try:
|
||||
import pefile
|
||||
@@ -176,9 +117,7 @@ def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _add_file_signatures(
|
||||
path: Path, directx: set[str], opengl: set[str], vulkan: set[str]
|
||||
) -> None:
|
||||
def _add_file_signatures(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
data = f.read(67108864).lower()
|
||||
@@ -192,8 +131,6 @@ def _add_file_signatures(
|
||||
for name, patterns in OPENGL_SIGNATURES.items():
|
||||
if any(p.lower() in data for p in patterns):
|
||||
opengl.add(name)
|
||||
if any(signature.lower() in data for signature in VULKAN_SIGNATURES):
|
||||
vulkan.add("Vulkan")
|
||||
|
||||
|
||||
def _highest_directx(directx: list[str]) -> str:
|
||||
@@ -211,8 +148,6 @@ def _main(argv: list[str]) -> int:
|
||||
result = analyze_executable(argv[1])
|
||||
print(f"HIGHEST_DX={result['highest_directx']}")
|
||||
print(f"USES_OGL={'true' if result['uses_opengl'] else 'false'}")
|
||||
print(f"USES_VULKAN={'true' if result['uses_vulkan'] else 'false'}")
|
||||
print(f"SOURCE={result['source']}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
@@ -121,6 +121,7 @@ def _archive_entry(archive, source_path: str, archive_path: str) -> bool:
|
||||
flags=READDISK_NO_XATTR,
|
||||
pathname=archive_path,
|
||||
recursive=False,
|
||||
symlink_mode="physical",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QUrl, qInstallMessageHandler
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -95,7 +94,13 @@ class SoundManager:
|
||||
self._initialized = True
|
||||
self._enabled = ui_config.get_sounds_enabled()
|
||||
self._sounds_dirs: list[str] = []
|
||||
self._slots: list[_SoundSlot] | None = None
|
||||
try:
|
||||
if QCoreApplication.instance() is None or not _audio_service_available():
|
||||
raise RuntimeError("audio service is unavailable")
|
||||
self._slots = [_SoundSlot() for _ in range(4)]
|
||||
except Exception as e:
|
||||
logger.warning("UI sounds are unavailable: %s", e)
|
||||
self._slots = []
|
||||
self._slot_index = 0
|
||||
self._url_cache: dict[str, QUrl] = {}
|
||||
|
||||
@@ -103,7 +108,7 @@ class SoundManager:
|
||||
if dirs != self._sounds_dirs:
|
||||
self._sounds_dirs = dirs
|
||||
self._url_cache.clear()
|
||||
for slot in self._slots or []:
|
||||
for slot in self._slots:
|
||||
slot.reset()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
@@ -131,23 +136,8 @@ class SoundManager:
|
||||
def play(self, event: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
if any(
|
||||
getattr(window, "silent_launch_mode", False) and not window.isVisible()
|
||||
for window in QApplication.topLevelWidgets()
|
||||
):
|
||||
return
|
||||
url = self._get_url(event)
|
||||
if url is None:
|
||||
return
|
||||
if self._slots is None:
|
||||
try:
|
||||
if QCoreApplication.instance() is None or not _audio_service_available():
|
||||
raise RuntimeError("audio service is unavailable")
|
||||
self._slots = [_SoundSlot() for _ in range(4)]
|
||||
except Exception as e:
|
||||
logger.warning("UI sounds are unavailable: %s", e)
|
||||
self._slots = []
|
||||
if not self._slots:
|
||||
if url is None or not self._slots:
|
||||
return
|
||||
try:
|
||||
for slot in self._slots:
|
||||
|
||||
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.config import THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
|
||||
from portprotonqt.steam_api.cache import (
|
||||
@@ -501,7 +501,8 @@ def _get_cached_data_and_index(
|
||||
|
||||
|
||||
def _get_cached_steam_cover_path(appid: int) -> str:
|
||||
image_folder = str(CACHE_DIR / "images")
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
for ext in COVER_IMAGE_EXTENSIONS:
|
||||
cover_path = os.path.join(image_folder, f"{appid}{ext}")
|
||||
if os.path.exists(cover_path):
|
||||
@@ -752,18 +753,15 @@ def get_steam_game_info_async(
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if not app_info:
|
||||
cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if not cover and not has_custom_data:
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
def on_anticheat_info_no_info(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
appid=appid,
|
||||
appid="",
|
||||
name=decode_text(game_name),
|
||||
description="",
|
||||
cover=cover,
|
||||
cover="",
|
||||
controller_support="",
|
||||
protondb_tier="",
|
||||
steam_game="true",
|
||||
steam_game="false",
|
||||
anticheat_status=anticheat_info.get("status", ""),
|
||||
anticheat_slug=anticheat_info.get("slug", ""),
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ def build_index(steam_apps: list) -> dict:
|
||||
for app in steam_apps:
|
||||
normalized = app.get("normalized_name", "")
|
||||
if normalized:
|
||||
steam_apps_index.setdefault(normalized, app)
|
||||
steam_apps_index[normalized] = app
|
||||
return steam_apps_index
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user