Compare commits
20 Commits
v0.1.6
...
download-t
Author | SHA1 | Date | |
---|---|---|---|
44bb095a03
|
|||
|
39712f0591 | ||
|
60b508af18 | ||
|
b6637b4163 | ||
|
6d9eed42f8 | ||
7372e3b7f5
|
|||
e0d5bd7993 | |||
|
12f8067af1 | ||
|
716a813ca9 | ||
c62cc6853f
|
|||
2e018b4690
|
|||
ad5b25f713
|
|||
3fb8201305
|
|||
04d8302d6c
|
|||
|
f868b21178 | ||
|
ebe25b41d8 | ||
|
fae6cad52d | ||
|
42bce11ada | ||
f088c01768
|
|||
e7eee85ed4
|
@@ -12,17 +12,27 @@ jobs:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
|
||||
|
||||
- name: Install tools
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
python3 -m pip install --upgrade \
|
||||
pip setuptools setuptools-scm wheel packaging build
|
||||
|
||||
- name: Install appimage-builder
|
||||
run: |
|
||||
git clone https://github.com/Boria138/appimage-builder
|
||||
cd appimage-builder
|
||||
pip install .
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
@@ -63,7 +73,7 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
@@ -84,7 +94,7 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
|
||||
image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
@@ -124,7 +134,7 @@ jobs:
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
@@ -14,33 +14,6 @@ env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
jobs:
|
||||
build-appimage:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
cd build-aux
|
||||
appimage-builder
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-AppImage
|
||||
path: build-aux/PortProtonQt*.AppImage*
|
||||
|
||||
build-arch:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -85,7 +58,7 @@ jobs:
|
||||
su user -c "yes '' | makepkg --noconfirm -s"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@v5
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
@@ -93,53 +66,9 @@ jobs:
|
||||
name: PortProtonQt-Arch
|
||||
path: ${{ env.PKGDEST }}/*
|
||||
|
||||
build-fedora:
|
||||
name: Build Fedora RPM
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, 43, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
||||
python3-build pyproject-rpm-macros python3-setuptools \
|
||||
redhat-rpm-config nodejs npm
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
useradd rpmbuild -u 5002 -g users || true
|
||||
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
cp build-aux/fedora.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
|
||||
|
||||
- name: Upload RPM package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
|
||||
path: /home/rpmbuild/RPMS/**/*.rpm
|
||||
|
||||
release:
|
||||
name: Create and Publish Release
|
||||
needs: [build-appimage, build-arch, build-fedora]
|
||||
needs: [ build-arch ]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
@@ -150,30 +79,6 @@ jobs:
|
||||
sudo apt install -y original-awk unzip
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: https://gitea.com/actions/download-artifact@v3
|
||||
uses: https://gitea.com/actions/download-artifact@v5
|
||||
with:
|
||||
path: release/
|
||||
|
||||
- name: Extract downloaded artifacts
|
||||
run: |
|
||||
mkdir -p extracted
|
||||
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
||||
find extracted/ -type f -exec mv {} release/ \;
|
||||
find release/ -name '*.zip' -delete
|
||||
rm -rf extracted/
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ env.VERSION }}"
|
||||
awk "/^## \\[$VERSION\\]/ {flag=1; next} /^## \\[/ || /^---/ {flag=0} flag" CHANGELOG.md > changelog.txt
|
||||
|
||||
- name: Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
body_path: changelog.txt
|
||||
token: ${{ env.GITEA_TOKEN }}
|
||||
tag_name: v${{ env.VERSION }}
|
||||
prerelease: true
|
||||
files: release/**/*
|
||||
sha256sum: true
|
||||
|
@@ -1,4 +1,4 @@
|
||||
name: Check Translations
|
||||
name: Check Translations (disabled until yaspeller is fixed)
|
||||
run-name: Check spelling in translation files
|
||||
on:
|
||||
push:
|
||||
@@ -12,10 +12,11 @@ on:
|
||||
|
||||
jobs:
|
||||
check-translations:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
|
@@ -18,7 +18,7 @@ jobs:
|
||||
fedora: ${{ steps.check.outputs.fedora }}
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Copy fedora-git.spec
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
|
||||
image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
@@ -20,10 +20,10 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
|
@@ -8,12 +8,12 @@ on:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
@@ -11,12 +11,12 @@ repos:
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.8.9
|
||||
rev: 0.8.22
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.8
|
||||
rev: v0.13.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
|
||||
|
13
CHANGELOG.md
13
CHANGELOG.md
@@ -3,6 +3,19 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
||||
|
||||
### Contributors
|
||||
|
||||
---
|
||||
|
||||
## [0.1.6] - 2025-09-23
|
||||
|
||||
### Added
|
||||
|
@@ -1,16 +1,11 @@
|
||||
version: 1
|
||||
script:
|
||||
# 1) чистим старый AppDir
|
||||
- rm -rf AppDir || true
|
||||
# 2) создаём структуру каталога
|
||||
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
|
||||
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
|
||||
- uv venv
|
||||
- uv pip install --no-cache-dir ../
|
||||
# 4) копируем всё из .venv в AppDir
|
||||
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
|
||||
- cp -r share AppDir/usr
|
||||
# 5) чистим от ненужных модулей и бинарников
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
|
||||
@@ -19,7 +14,6 @@ script:
|
||||
AppDir:
|
||||
path: ./AppDir
|
||||
after_bundle:
|
||||
# Документация, справка, примеры
|
||||
- rm -rf $TARGET_APPDIR/usr/share/man || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/doc || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
|
||||
@@ -35,11 +29,8 @@ AppDir:
|
||||
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
|
||||
- rm -rf $TARGET_APPDIR/usr/include || true
|
||||
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
|
||||
# Статика и отладка
|
||||
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
|
||||
# Strip ELF бинарников (исключая Python extensions)
|
||||
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
|
||||
# Удаление пустых папок
|
||||
- find $TARGET_APPDIR -type d -empty -delete || true
|
||||
app_info:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
@@ -64,15 +55,12 @@ AppDir:
|
||||
- libimage-exiftool-perl
|
||||
- xdg-utils
|
||||
exclude:
|
||||
# Документация и man-страницы
|
||||
- "*-doc"
|
||||
- "*-man"
|
||||
- manpages
|
||||
- mandb
|
||||
# Статические библиотеки
|
||||
- "*-dev"
|
||||
- "*-static"
|
||||
# Дебаг-символы
|
||||
- "*-dbg"
|
||||
- "*-dbgsym"
|
||||
runtime:
|
||||
@@ -83,3 +71,4 @@ AppDir:
|
||||
AppImage:
|
||||
sign-key: None
|
||||
arch: x86_64
|
||||
comp: zstd
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
|
||||
@@ -134,6 +135,12 @@ def main():
|
||||
print(f"Updated version from {old} to {new} in {len(updated)} files:")
|
||||
for p in sorted(updated):
|
||||
print(f" - {p}")
|
||||
|
||||
try:
|
||||
subprocess.run(["uv", "lock"], check=True)
|
||||
print("Regenerated uv.lock")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to regenerate uv.lock: {e}")
|
||||
else:
|
||||
print(f"No occurrences of version {old} found in specified files.")
|
||||
|
||||
|
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 204 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 204 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 of 204 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 204 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 204 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 из 204 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -1,17 +1,15 @@
|
||||
import argparse
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def parse_args():
|
||||
"""
|
||||
Парсит аргументы командной строки.
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||
help="Launch the application in fullscreen mode and save this setting"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug-level",
|
||||
|
@@ -62,7 +62,7 @@ class ContextMenuManager:
|
||||
self.parent.statusBar().showMessage,
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
logger.debug("Connected show_status_message signal to statusBar")
|
||||
logger.debug("Connected show_status_message signal to status bar")
|
||||
self.signals.show_warning_dialog.connect(
|
||||
self._show_warning_dialog,
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
@@ -74,28 +74,28 @@ class ContextMenuManager:
|
||||
|
||||
def _show_warning_dialog(self, title: str, message: str):
|
||||
"""Show a warning dialog in the main thread."""
|
||||
logger.debug("Showing warning dialog: %s - %s", title, message)
|
||||
logger.debug("Displaying warning dialog: %s - %s", title, message)
|
||||
QMessageBox.warning(self.parent, title, message)
|
||||
|
||||
def _show_info_dialog(self, title: str, message: str):
|
||||
"""Show an info dialog in the main thread."""
|
||||
logger.debug("Showing info dialog: %s - %s", title, message)
|
||||
logger.debug("Displaying info dialog: %s - %s", title, message)
|
||||
QMessageBox.information(self.parent, title, message)
|
||||
|
||||
def _show_status_message(self, message: str, timeout: int = 3000):
|
||||
"""Show a status message on the status bar if available."""
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(message, timeout)
|
||||
logger.debug("Direct status message: %s", message)
|
||||
logger.debug("Displayed status message: %s", message)
|
||||
else:
|
||||
logger.warning("Status bar not available for message: %s", message)
|
||||
logger.warning("Status bar unavailable for message: %s", message)
|
||||
|
||||
def _check_portproton(self):
|
||||
"""Check if PortProton is available."""
|
||||
if self.portproton_location is None:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("PortProton is not found")
|
||||
_("PortProton directory not found")
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -119,7 +119,7 @@ class ContextMenuManager:
|
||||
installed_games = orjson.loads(f.read())
|
||||
return app_name in installed_games
|
||||
except (OSError, orjson.JSONDecodeError) as e:
|
||||
logger.error("Failed to read installed.json: %s", e)
|
||||
logger.error("Error reading installed.json: %s", e)
|
||||
return False
|
||||
|
||||
def _is_game_running(self, game_card) -> bool:
|
||||
@@ -155,7 +155,7 @@ class ContextMenuManager:
|
||||
try:
|
||||
item = file_explorer.file_list.itemAt(pos)
|
||||
if not item:
|
||||
logger.debug("No item selected at position %s", pos)
|
||||
logger.debug("No folder selected at position %s", pos)
|
||||
return
|
||||
selected = item.text()
|
||||
if not selected.endswith("/"):
|
||||
@@ -202,7 +202,7 @@ class ContextMenuManager:
|
||||
global_pos = file_explorer.file_list.mapToGlobal(pos)
|
||||
menu.exec(global_pos)
|
||||
except Exception as e:
|
||||
logger.error("Error showing folder context menu: %s", e)
|
||||
logger.error("Error displaying folder context menu: %s", e)
|
||||
|
||||
def toggle_favorite_folder(self, file_explorer, folder_path, add):
|
||||
"""Adds or removes a folder from favorites."""
|
||||
@@ -211,12 +211,12 @@ class ContextMenuManager:
|
||||
if folder_path not in favorite_folders:
|
||||
favorite_folders.append(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
logger.info(f"Folder added to favorites: {folder_path}")
|
||||
logger.info("Added folder to favorites: %s", folder_path)
|
||||
else:
|
||||
if folder_path in favorite_folders:
|
||||
favorite_folders.remove(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
logger.info(f"Folder removed from favorites: {folder_path}")
|
||||
logger.info("Removed folder from favorites: %s", folder_path)
|
||||
file_explorer.update_drives_list()
|
||||
|
||||
def _get_safe_icon(self, icon_name: str) -> QIcon:
|
||||
@@ -607,10 +607,10 @@ class ContextMenuManager:
|
||||
exe_path = get_egs_executable(app_name, self.legendary_config_path)
|
||||
if exe_path and os.path.exists(exe_path):
|
||||
if not generate_thumbnail(exe_path, icon_path, size=128):
|
||||
logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
|
||||
logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
|
||||
icon_path = ""
|
||||
else:
|
||||
logger.error(f"No executable found for EGS game: {app_name}")
|
||||
logger.error("No executable found for EGS game: %s", app_name)
|
||||
icon_path = ""
|
||||
|
||||
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
|
||||
@@ -750,7 +750,7 @@ Icon={icon_path}
|
||||
if not exec_line:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
|
||||
_("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return None
|
||||
else:
|
||||
@@ -762,7 +762,7 @@ Icon={icon_path}
|
||||
except Exception as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to read .desktop file: {error}").format(error=str(e))
|
||||
_("Error reading .desktop file: {error}").format(error=str(e))
|
||||
)
|
||||
return None
|
||||
else:
|
||||
@@ -784,7 +784,7 @@ Icon={icon_path}
|
||||
try:
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
|
||||
logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
|
||||
return None
|
||||
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
||||
exe_path = entry_exec_split[2]
|
||||
@@ -793,11 +793,11 @@ Icon={icon_path}
|
||||
else:
|
||||
exe_path = entry_exec_split[-1]
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
|
||||
logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
|
||||
return None
|
||||
return exe_path
|
||||
except Exception as e:
|
||||
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
|
||||
logger.debug("Error parsing executable for game '%s': %s", game_name, e)
|
||||
return None
|
||||
|
||||
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
||||
@@ -936,7 +936,7 @@ Icon={icon_path}
|
||||
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
|
||||
if not os.path.exists(icon_path):
|
||||
if not generate_thumbnail(exe_path, icon_path, size=128):
|
||||
logger.error(f"Failed to generate thumbnail for {exe_path}")
|
||||
logger.error("Failed to generate thumbnail for game: %s", exe_path)
|
||||
|
||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
@@ -1072,7 +1072,7 @@ Icon={icon_path}
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding '%s' to Steam", game_name)
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
self.signals.show_info_dialog.emit(
|
||||
@@ -1115,7 +1115,7 @@ Icon={icon_path}
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Removing non-EGS game '%s' from Steam", game_name)
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
success, message = remove_from_steam(game_name, exec_line)
|
||||
self.signals.show_info_dialog.emit(
|
||||
|
@@ -5,29 +5,29 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
||||
|
||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
"""
|
||||
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
|
||||
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
|
||||
rect_width: доступная ширина контейнера.
|
||||
spacing: отступ между элементами (горизонтальный и вертикальный).
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.0).
|
||||
Computes the layout of elements considering spacing and potential scaling of cards.
|
||||
nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
|
||||
rect_width: Available container width.
|
||||
spacing: Spacing between elements (horizontal and vertical).
|
||||
max_scale: Maximum scaling factor (e.g., 1.0).
|
||||
|
||||
Возвращает:
|
||||
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
|
||||
total_height: итоговая высота всех рядов.
|
||||
Returns:
|
||||
result: Array (N, 4), where each row contains [x, y, new_width, new_height].
|
||||
total_height: Total height of all rows.
|
||||
"""
|
||||
N = nat_sizes.shape[0]
|
||||
result = np.zeros((N, 4), dtype=np.int32)
|
||||
y = 0
|
||||
i = 0
|
||||
min_margin = 20 # Минимальный отступ по краям
|
||||
min_margin = 20 # Minimum margin on edges
|
||||
|
||||
# Определяем максимальное количество элементов в ряду и общий масштаб
|
||||
# Determine the maximum number of items per row and overall scale
|
||||
max_items_per_row = 0
|
||||
global_scale = 1.0
|
||||
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда
|
||||
max_row_x_start = min_margin # Starting x position of the widest row
|
||||
temp_i = 0
|
||||
|
||||
# Первый проход: находим максимальное количество элементов в ряду
|
||||
# First pass: Find the maximum number of items in a row
|
||||
while temp_i < N:
|
||||
sum_width = 0
|
||||
count = 0
|
||||
@@ -42,23 +42,23 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
|
||||
if count > max_items_per_row:
|
||||
max_items_per_row = count
|
||||
# Вычисляем масштаб для самого заполненного ряда
|
||||
# Calculate scale for the most populated row
|
||||
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
|
||||
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
|
||||
global_scale = desired_scale if desired_scale < max_scale else max_scale
|
||||
# Сохраняем начальную позицию x для самого длинного ряда
|
||||
# Store starting x position for the widest row
|
||||
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
|
||||
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||
temp_i = temp_j
|
||||
|
||||
# Второй проход: размещаем элементы
|
||||
# Second pass: Place elements
|
||||
while i < N:
|
||||
sum_width = 0
|
||||
row_max_height = 0
|
||||
count = 0
|
||||
j = i
|
||||
|
||||
# Подбираем количество элементов для текущего ряда
|
||||
# Determine the number of items for the current row
|
||||
while j < N:
|
||||
w = nat_sizes[j, 0]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
@@ -70,16 +70,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
row_max_height = h
|
||||
j += 1
|
||||
|
||||
# Используем глобальный масштаб для всех рядов
|
||||
# Use global scale for all rows
|
||||
scale = global_scale
|
||||
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
|
||||
|
||||
# Определяем начальную координату x
|
||||
# Determine starting x coordinate
|
||||
if count == max_items_per_row:
|
||||
# Центрируем полный ряд
|
||||
# Center the full row
|
||||
x = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||
else:
|
||||
# Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
|
||||
# Align incomplete row to the left, matching the widest row's start
|
||||
x = max_row_x_start
|
||||
|
||||
for k in range(i, j):
|
||||
@@ -99,9 +99,9 @@ class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.itemList = []
|
||||
self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
|
||||
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
|
||||
self._max_scale = 1.0 # Отключено масштабирование в layout
|
||||
self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
|
||||
self._spacing = 20 # Spacing for animation and overlap prevention
|
||||
self._max_scale = 1.0 # Scaling disabled in layout
|
||||
|
||||
def addItem(self, item: QLayoutItem) -> None:
|
||||
self.itemList.append(item)
|
||||
|
@@ -6,7 +6,7 @@ from PySide6.QtGui import QPixmap, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
from PIL import Image
|
||||
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
|
||||
@@ -179,9 +179,11 @@ class FileExplorer(QDialog):
|
||||
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
|
||||
self.path_history = {} # Dictionary to store last selected item per directory
|
||||
self.initial_path = initial_path # Store initial path if provided
|
||||
self.thumbnail_cache = {} # Cache for loaded thumbnails
|
||||
self.pending_thumbnails = set() # Track files pending thumbnail loading
|
||||
self.setup_ui()
|
||||
|
||||
# Настройки окна
|
||||
# Window settings
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
||||
|
||||
@@ -208,8 +210,115 @@ class FileExplorer(QDialog):
|
||||
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
|
||||
self.update_file_list()
|
||||
|
||||
class ThumbnailLoader(QRunnable):
|
||||
"""Class for asynchronous thumbnail loading in a separate thread."""
|
||||
class Signals(QObject):
|
||||
thumbnail_ready = Signal(str, QIcon) # Signal for ready thumbnail: file path and icon
|
||||
|
||||
def __init__(self, file_path, mime_type, size=64):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.mime_type = mime_type
|
||||
self.size = size
|
||||
self.signals = self.Signals()
|
||||
|
||||
@Slot()
|
||||
def run(self):
|
||||
"""Performs thumbnail loading in a background thread."""
|
||||
try:
|
||||
if self.mime_type.startswith("image/"):
|
||||
pixmap = QPixmap(self.file_path)
|
||||
if not pixmap.isNull():
|
||||
scaled_pixmap = pixmap.scaled(self.size, self.size, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QIcon(scaled_pixmap))
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith(".exe"):
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
if generate_thumbnail(self.file_path, tmp.name, size=self.size):
|
||||
pixmap = QPixmap(tmp.name)
|
||||
if not pixmap.isNull():
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QIcon(pixmap))
|
||||
os.unlink(tmp.name)
|
||||
else:
|
||||
logger.warning("Failed to generate thumbnail for .exe: %s", self.file_path)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
|
||||
|
||||
def async_load_thumbnails(self, files, mime_db):
|
||||
"""
|
||||
Asynchronously loads thumbnails for a list of files.
|
||||
|
||||
Args:
|
||||
files (list): List of file names to process.
|
||||
mime_db (QMimeDatabase): QMimeDatabase instance for file type detection.
|
||||
"""
|
||||
thread_pool = QThreadPool.globalInstance()
|
||||
thread_pool.setMaxThreadCount(4) # Limit the number of threads
|
||||
|
||||
for f in files:
|
||||
file_path = os.path.join(self.current_path, f)
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue # Skip if already cached or pending
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(".exe"):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
loader = self.ThumbnailLoader(file_path, mime_type, size=64)
|
||||
loader.signals.thumbnail_ready.connect(self.update_thumbnail)
|
||||
thread_pool.start(loader)
|
||||
|
||||
|
||||
@Slot(str, QIcon)
|
||||
def update_thumbnail(self, file_path, icon):
|
||||
"""
|
||||
Updates the icon for a file list item after thumbnail loading.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file for which the thumbnail was loaded.
|
||||
icon (QIcon): Loaded icon.
|
||||
"""
|
||||
try:
|
||||
# Cache the thumbnail
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
self.pending_thumbnails.discard(file_path)
|
||||
# Update the item in the file list
|
||||
file_name = os.path.basename(file_path)
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.text() == file_name:
|
||||
item.setIcon(icon)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error updating thumbnail for %s: %s", file_path, str(e))
|
||||
|
||||
|
||||
def load_visible_thumbnails(self):
|
||||
"""Load thumbnails only for visible items in the file list."""
|
||||
try:
|
||||
visible_range = self.file_list.count()
|
||||
first_visible = max(0, self.file_list.indexAt(self.file_list.viewport().rect().topLeft()).row())
|
||||
last_visible = min(visible_range - 1, self.file_list.indexAt(self.file_list.viewport().rect().bottomRight()).row() + 5)
|
||||
|
||||
files_to_load = []
|
||||
for i in range(first_visible, last_visible + 1):
|
||||
item = self.file_list.item(i)
|
||||
if not item:
|
||||
continue
|
||||
file_name = item.text()
|
||||
if file_name.endswith("/"):
|
||||
continue # Skip directories
|
||||
file_path = os.path.join(self.current_path, file_name)
|
||||
if file_path not in self.thumbnail_cache and file_path not in self.pending_thumbnails:
|
||||
files_to_load.append(file_name)
|
||||
|
||||
if files_to_load:
|
||||
self.async_load_thumbnails(files_to_load, self.mime_db)
|
||||
except Exception as e:
|
||||
logger.error("Error loading visible thumbnails: %s", str(e))
|
||||
|
||||
def get_mounted_drives(self):
|
||||
"""Получение списка смонтированных дисков из /proc/mounts, исключая системные пути"""
|
||||
"""Retrieve a list of mounted drives from /proc/mounts, excluding system paths."""
|
||||
mounted_drives = []
|
||||
try:
|
||||
with open('/proc/mounts') as f:
|
||||
@@ -218,20 +327,20 @@ class FileExplorer(QDialog):
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
mount_point = parts[1]
|
||||
# Исключаем системные и временные пути, но сохраняем /run/media
|
||||
# Exclude system and temporary paths, but keep /run/media
|
||||
if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
|
||||
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
|
||||
continue
|
||||
# Проверяем, является ли точка монтирования директорией и доступна ли она
|
||||
# Check if the mount point is a directory and accessible
|
||||
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
|
||||
mounted_drives.append(mount_point)
|
||||
return sorted(mounted_drives)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении смонтированных дисков: {e}")
|
||||
logger.error(f"Error retrieving mounted drives: {e}")
|
||||
return []
|
||||
|
||||
def setup_ui(self):
|
||||
"""Настройка интерфейса"""
|
||||
"""Set up the user interface."""
|
||||
self.setWindowTitle(_("File Explorer"))
|
||||
self.setGeometry(100, 100, 600, 600)
|
||||
|
||||
@@ -240,7 +349,7 @@ class FileExplorer(QDialog):
|
||||
self.main_layout.setSpacing(10)
|
||||
self.setLayout(self.main_layout)
|
||||
|
||||
# Панель для смонтированных дисков и избранных папок
|
||||
# Panel for mounted drives and favorite folders
|
||||
self.drives_layout = QHBoxLayout()
|
||||
self.drives_scroll = QScrollArea()
|
||||
self.drives_scroll.setWidgetResizable(True)
|
||||
@@ -253,12 +362,12 @@ class FileExplorer(QDialog):
|
||||
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
# Путь
|
||||
# Path label
|
||||
self.path_label = QLabel()
|
||||
self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE)
|
||||
self.main_layout.addWidget(self.path_label)
|
||||
|
||||
# Список файлов
|
||||
# File list
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
|
||||
self.file_list.itemClicked.connect(self.handle_item_click)
|
||||
@@ -267,7 +376,10 @@ class FileExplorer(QDialog):
|
||||
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
|
||||
self.main_layout.addWidget(self.file_list)
|
||||
|
||||
# Кнопки
|
||||
# Connect scroll signal for lazy loading
|
||||
self.file_list.verticalScrollBar().valueChanged.connect(self.load_visible_thumbnails)
|
||||
|
||||
# Buttons
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.button_layout.setSpacing(10)
|
||||
self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
|
||||
@@ -289,40 +401,40 @@ class FileExplorer(QDialog):
|
||||
logger.warning("ContextMenuManager not found in parent")
|
||||
|
||||
def move_selection(self, direction):
|
||||
"""Перемещение выбора по списку"""
|
||||
"""Move selection in the list."""
|
||||
current_row = self.file_list.currentRow()
|
||||
if direction < 0 and current_row > 0: # Вверх
|
||||
if direction < 0 and current_row > 0: # Up
|
||||
self.file_list.setCurrentRow(current_row - 1)
|
||||
elif direction > 0 and current_row < self.file_list.count() - 1: # Вниз
|
||||
elif direction > 0 and current_row < self.file_list.count() - 1: # Down
|
||||
self.file_list.setCurrentRow(current_row + 1)
|
||||
self.file_list.scrollToItem(self.file_list.currentItem())
|
||||
|
||||
def handle_item_click(self, item):
|
||||
"""Обработка одинарного клика мышью"""
|
||||
"""Handle single mouse click."""
|
||||
try:
|
||||
self.file_list.setCurrentItem(item)
|
||||
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент
|
||||
self.path_history[self.current_path] = item.text() # Save selected item
|
||||
logger.debug("Selected item: %s", item.text())
|
||||
except Exception as e:
|
||||
logger.error("Error in handle_item_click: %s", e)
|
||||
|
||||
def handle_item_double_click(self, item):
|
||||
"""Обработка двойного клика мышью по элементу списка"""
|
||||
"""Handle double mouse click on a list item."""
|
||||
try:
|
||||
self.file_list.setCurrentItem(item)
|
||||
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент
|
||||
self.path_history[self.current_path] = item.text() # Save selected item
|
||||
selected = item.text()
|
||||
full_path = os.path.join(self.current_path, selected)
|
||||
if os.path.isdir(full_path):
|
||||
if selected == "../":
|
||||
# Переходим в родительскую директорию
|
||||
# Navigate to parent directory
|
||||
self.previous_dir()
|
||||
else:
|
||||
# Открываем директорию
|
||||
# Open directory
|
||||
self.current_path = os.path.normpath(full_path)
|
||||
self.update_file_list()
|
||||
elif not self.directory_only:
|
||||
# Выбираем файл, если directory_only=False
|
||||
# Select file if directory_only=False
|
||||
self.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.accept()
|
||||
else:
|
||||
@@ -331,7 +443,7 @@ class FileExplorer(QDialog):
|
||||
logger.error("Error in handle_item_double_click: %s", e)
|
||||
|
||||
def select_item(self):
|
||||
"""Обработка выбора файла/папки"""
|
||||
"""Handle file/folder selection."""
|
||||
if self.file_list.count() == 0:
|
||||
return
|
||||
|
||||
@@ -340,30 +452,30 @@ class FileExplorer(QDialog):
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
if self.directory_only:
|
||||
# Подтверждаем выбор директории
|
||||
# Confirm directory selection
|
||||
self.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.accept()
|
||||
else:
|
||||
# Открываем директорию
|
||||
# Open directory
|
||||
self.current_path = os.path.normpath(full_path)
|
||||
self.update_file_list()
|
||||
else:
|
||||
if not self.directory_only:
|
||||
# Для файла отправляем нормализованный путь
|
||||
# Emit normalized path for file
|
||||
self.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.accept()
|
||||
else:
|
||||
logger.debug("Selected item is not a directory, ignoring: %s", full_path)
|
||||
|
||||
def previous_dir(self):
|
||||
"""Возврат к родительской директории"""
|
||||
"""Navigate to parent directory."""
|
||||
try:
|
||||
if self.current_path == "/":
|
||||
return # Уже в корне
|
||||
return # Already at root
|
||||
|
||||
# Нормализуем путь (убираем конечный слеш, если есть)
|
||||
# Normalize path (remove trailing slash if present)
|
||||
normalized_path = os.path.normpath(self.current_path)
|
||||
# Получаем родительскую директорию
|
||||
# Get parent directory
|
||||
parent_dir = os.path.dirname(normalized_path)
|
||||
|
||||
if not parent_dir:
|
||||
@@ -389,7 +501,7 @@ class FileExplorer(QDialog):
|
||||
logger.error(f"Error ensuring button visible: {e}")
|
||||
|
||||
def update_drives_list(self):
|
||||
"""Обновление списка смонтированных дисков и избранных папок."""
|
||||
"""Update the list of mounted drives and favorite folders."""
|
||||
for i in reversed(range(self.drives_layout.count())):
|
||||
item = self.drives_layout.itemAt(i)
|
||||
if item and item.widget():
|
||||
@@ -401,7 +513,7 @@ class FileExplorer(QDialog):
|
||||
drives = self.get_mounted_drives()
|
||||
favorite_folders = read_favorite_folders()
|
||||
|
||||
# Добавляем смонтированные диски
|
||||
# Add mounted drives
|
||||
for drive in drives:
|
||||
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
||||
button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
|
||||
@@ -411,7 +523,7 @@ class FileExplorer(QDialog):
|
||||
self.drives_layout.addWidget(button)
|
||||
self.drive_buttons.append(button)
|
||||
|
||||
# Добавляем избранные папки
|
||||
# Add favorite folders
|
||||
for folder in favorite_folders:
|
||||
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
|
||||
button = AutoSizeButton(folder_name, icon=theme_manager.get_icon("folder"))
|
||||
@@ -421,92 +533,92 @@ class FileExplorer(QDialog):
|
||||
self.drives_layout.addWidget(button)
|
||||
self.drive_buttons.append(button)
|
||||
|
||||
# Добавляем растяжку, чтобы выровнять элементы
|
||||
# Add spacer to align elements
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.drives_layout.addWidget(spacer)
|
||||
|
||||
def select_drive(self):
|
||||
"""Обрабатывает выбор диска или избранной папки через геймпад."""
|
||||
"""Handle drive or favorite folder selection via gamepad."""
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
|
||||
drive_name = focused_widget.text().strip() # Удаляем пробелы
|
||||
logger.debug(f"Выбрано имя: {drive_name}")
|
||||
drive_name = focused_widget.text().strip() # Remove whitespace
|
||||
logger.debug(f"Selected name: {drive_name}")
|
||||
|
||||
# Специальная обработка корневого каталога
|
||||
# Special handling for root directory
|
||||
if drive_name == "/":
|
||||
if os.path.isdir("/") and os.access("/", os.R_OK):
|
||||
self.current_path = "/"
|
||||
self.update_file_list()
|
||||
logger.info("Выбран корневой каталог: /")
|
||||
logger.info("Selected root directory")
|
||||
return
|
||||
else:
|
||||
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
|
||||
logger.warning("Root directory is inaccessible: insufficient permissions or path error")
|
||||
return
|
||||
|
||||
# Проверяем избранные папки
|
||||
# Check favorite folders
|
||||
favorite_folders = read_favorite_folders()
|
||||
logger.debug(f"Избранные папки: {favorite_folders}")
|
||||
logger.debug(f"Favorite folders: {favorite_folders}")
|
||||
for folder in favorite_folders:
|
||||
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей
|
||||
folder_name = os.path.basename(os.path.normpath(folder)) or folder # For root paths
|
||||
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
|
||||
self.current_path = os.path.normpath(folder)
|
||||
self.update_file_list()
|
||||
logger.info(f"Выбрана избранная папка: {self.current_path}")
|
||||
logger.info(f"Selected favorite folder: {self.current_path}")
|
||||
return
|
||||
|
||||
# Проверяем смонтированные диски
|
||||
# Check mounted drives
|
||||
mounted_drives = self.get_mounted_drives()
|
||||
logger.debug(f"Смонтированные диски: {mounted_drives}")
|
||||
logger.debug(f"Mounted drives: {mounted_drives}")
|
||||
for drive in mounted_drives:
|
||||
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей
|
||||
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # For root paths
|
||||
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
|
||||
self.current_path = os.path.normpath(drive)
|
||||
self.update_file_list()
|
||||
logger.info(f"Выбран смонтированный диск: {self.current_path}")
|
||||
logger.info(f"Selected mounted drive: {self.current_path}")
|
||||
return
|
||||
|
||||
logger.warning(f"Путь недоступен: {drive_name}.")
|
||||
logger.warning(f"Path is inaccessible: {drive_name}.")
|
||||
|
||||
def change_drive(self, drive_path):
|
||||
"""Переход к выбранному диску"""
|
||||
"""Navigate to the selected drive."""
|
||||
if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
|
||||
self.current_path = os.path.normpath(drive_path)
|
||||
self.update_file_list()
|
||||
else:
|
||||
logger.warning(f"Путь диска недоступен: {drive_path}")
|
||||
logger.warning(f"Drive path is inaccessible: {drive_path}")
|
||||
|
||||
def update_file_list(self):
|
||||
"""Обновление списка файлов с превью в виде иконок"""
|
||||
"""Update the file list with asynchronous thumbnail loading."""
|
||||
self.file_list.clear()
|
||||
self.thumbnail_cache.clear() # Clear cache when changing directories
|
||||
self.pending_thumbnails.clear() # Clear pending thumbnails
|
||||
try:
|
||||
if self.current_path != "/":
|
||||
item = QListWidgetItem("../")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
# Ensure the icon is a QIcon
|
||||
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
|
||||
folder_icon = QIcon(folder_icon)
|
||||
elif not isinstance(folder_icon, QIcon):
|
||||
folder_icon = QIcon() # Fallback to empty icon
|
||||
folder_icon = QIcon()
|
||||
item.setIcon(folder_icon)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
items = os.listdir(self.current_path)
|
||||
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
|
||||
|
||||
# Добавляем директории
|
||||
# Add directories
|
||||
for d in sorted(dirs):
|
||||
item = QListWidgetItem(f"{d}/")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
# Ensure the icon is a QIcon
|
||||
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
|
||||
folder_icon = QIcon(folder_icon)
|
||||
elif not isinstance(folder_icon, QIcon):
|
||||
folder_icon = QIcon() # Fallback to empty icon
|
||||
folder_icon = QIcon()
|
||||
item.setIcon(folder_icon)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
# Добавляем файлы только если directory_only=False
|
||||
# Add files only if directory_only=False
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
@@ -515,26 +627,14 @@ class FileExplorer(QDialog):
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
# Add files to the list without immediate thumbnail loading
|
||||
for f in sorted(files):
|
||||
item = QListWidgetItem(f)
|
||||
file_path = os.path.join(self.current_path, f)
|
||||
mime_type = self.mime_db.mimeTypeForFile(file_path).name()
|
||||
|
||||
if mime_type.startswith("image/"):
|
||||
pixmap = QPixmap(file_path)
|
||||
if not pixmap.isNull():
|
||||
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
|
||||
elif file_path.lower().endswith(".exe"):
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
|
||||
tmp.close()
|
||||
if generate_thumbnail(file_path, tmp.name, size=64):
|
||||
pixmap = QPixmap(tmp.name)
|
||||
if not pixmap.isNull():
|
||||
item.setIcon(QIcon(pixmap))
|
||||
os.unlink(tmp.name)
|
||||
|
||||
self.file_list.addItem(item)
|
||||
|
||||
# Load thumbnails for visible items only
|
||||
self.load_visible_thumbnails()
|
||||
|
||||
self.path_label.setText(_("Path: ") + self.current_path)
|
||||
|
||||
# Restore last selected item for this directory
|
||||
@@ -556,10 +656,10 @@ class FileExplorer(QDialog):
|
||||
self.file_list.setAlternatingRowColors(True)
|
||||
|
||||
except PermissionError:
|
||||
self.path_label.setText(f"Access denied: {self.current_path}")
|
||||
self.path_label.setText(_("Access denied: %s") % self.current_path)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Закрытие окна"""
|
||||
"""Handle window closing."""
|
||||
try:
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_file_explorer_mode()
|
||||
@@ -573,13 +673,13 @@ class FileExplorer(QDialog):
|
||||
super().closeEvent(event)
|
||||
|
||||
def reject(self):
|
||||
"""Закрытие диалога"""
|
||||
"""Close the dialog."""
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_file_explorer_mode()
|
||||
super().reject()
|
||||
|
||||
def accept(self):
|
||||
"""Принятие диалога"""
|
||||
"""Accept the dialog."""
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_file_explorer_mode()
|
||||
super().accept()
|
||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -23,7 +23,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -155,7 +155,7 @@ msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -264,6 +264,10 @@ msgstr ""
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -456,21 +460,6 @@ msgstr ""
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -480,28 +469,6 @@ msgstr ""
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -23,7 +23,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -155,7 +155,7 @@ msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -264,6 +264,10 @@ msgstr ""
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -456,21 +460,6 @@ msgstr ""
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -480,28 +469,6 @@ msgstr ""
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+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"
|
||||
@@ -21,7 +21,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -153,7 +153,7 @@ msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -262,6 +262,10 @@ msgstr ""
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -454,21 +458,6 @@ msgstr ""
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -478,28 +467,6 @@ msgstr ""
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
|
Binary file not shown.
@@ -9,23 +9,24 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"PO-Revision-Date: 2025-09-13 11:47+0500\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
||||
"PO-Revision-Date: 2025-09-23 22:23+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\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"
|
||||
"Language: ru_RU\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"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"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
"X-Generator: Poedit 3.6\n"
|
||||
|
||||
msgid "Error"
|
||||
msgstr "Ошибка"
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgstr "PortProton не найден"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "Не найден каталог PortProton"
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr "Удалить из Избранного"
|
||||
@@ -86,11 +87,11 @@ msgstr "Успешно"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"'{game_name}' was added to Steam. Please restart Steam for changes to "
|
||||
"take effect."
|
||||
"'{game_name}' was added to Steam. Please restart Steam for changes to take "
|
||||
"effect."
|
||||
msgstr ""
|
||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите "
|
||||
"Steam, чтобы изменения вступили в силу."
|
||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
|
||||
"чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found for game: {game_name}"
|
||||
@@ -158,16 +159,16 @@ msgid "Menu"
|
||||
msgstr "Меню"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgstr "Не удалось прочитать файл .desktop: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr "Ошибка при чтении файла .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No .desktop file found for '{game_name}'"
|
||||
@@ -178,11 +179,11 @@ msgstr "Подтвердите удаление"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the "
|
||||
".desktop file and custom data."
|
||||
"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 delete .desktop file: {error}"
|
||||
@@ -224,11 +225,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to"
|
||||
" take effect."
|
||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to take "
|
||||
"effect."
|
||||
msgstr ""
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
|
||||
" чтобы изменения вступили в силу."
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
|
||||
"изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
@@ -271,6 +272,10 @@ msgstr "Выбрать"
|
||||
msgid "Path: "
|
||||
msgstr "Путь: "
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr "Доступ запрещен: %s"
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr "Редактировать игру"
|
||||
|
||||
@@ -370,7 +375,6 @@ msgstr "Темы"
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Fullscreen"
|
||||
msgstr "Полный экран"
|
||||
|
||||
@@ -464,21 +468,6 @@ msgstr "Тактильная отдача на геймпаде"
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr "Тактильная отдача на геймпаде:"
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr "Открыть браузер для входа в Legendary"
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr "Авторизация в Legendary:"
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr "Введите код авторизации Legendary"
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr "Код авторизации:"
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr "Отправить код"
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr "Сохранить настройки"
|
||||
|
||||
@@ -488,35 +477,12 @@ msgstr "Сбросить настройки"
|
||||
msgid "Clear Cache"
|
||||
msgstr "Очистить кэш"
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr "Открытие страницы входа в Legendary в браузере"
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr "Не удалось открыть страницу входа в Legendary"
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr "Пожалуйста, введите код авторизации"
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr "Успешная аутентификация в Legendary"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr "Не найден исполняемый файл Legendary"
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr "Неожиданная ошибка при аутентификации"
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr "Подтвердите удаление"
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
|
||||
"отменить."
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr "Настройки сброшены. Перезапуск..."
|
||||
@@ -688,4 +654,3 @@ msgstr "Нет избранных"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Нет недавних игр"
|
||||
|
||||
|
@@ -45,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict:
|
||||
|
||||
def decode_text(text: str) -> str:
|
||||
"""
|
||||
Декодирует HTML-сущности в строке.
|
||||
Например, "&quot;" преобразуется в '"'.
|
||||
Остальные символы и HTML-теги остаются без изменений.
|
||||
Decodes HTML entities in a string.
|
||||
For example, "&quot;" is converted to '"'.
|
||||
Other characters and HTML tags remain unchanged.
|
||||
"""
|
||||
return html.unescape(text)
|
||||
|
||||
def get_cache_dir():
|
||||
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
|
||||
"""Returns the path to the cache directory, creating it if necessary."""
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
@@ -65,7 +65,7 @@ STEAM_DATA_DIRS = (
|
||||
)
|
||||
|
||||
def get_steam_home():
|
||||
"""Возвращает путь к директории Steam, используя список возможных директорий."""
|
||||
"""Returns the path to the Steam directory using a list of possible directories."""
|
||||
for dir_path in STEAM_DATA_DIRS:
|
||||
expanded_path = Path(os.path.expanduser(dir_path))
|
||||
if expanded_path.exists():
|
||||
@@ -73,7 +73,7 @@ def get_steam_home():
|
||||
return None
|
||||
|
||||
def get_last_steam_user(steam_home: Path) -> dict | None:
|
||||
"""Возвращает данные последнего пользователя Steam из loginusers.vdf."""
|
||||
"""Returns data for the last Steam user from loginusers.vdf."""
|
||||
loginusers_path = steam_home / "config/loginusers.vdf"
|
||||
data = safe_vdf_load(loginusers_path)
|
||||
if not data:
|
||||
@@ -84,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
|
||||
try:
|
||||
return {'SteamID': int(user_id)}
|
||||
except ValueError:
|
||||
logger.error(f"Неверный формат SteamID: {user_id}")
|
||||
logger.error(f"Invalid SteamID format: {user_id}")
|
||||
return None
|
||||
logger.info("Не найден пользователь с MostRecent=1")
|
||||
logger.info("No user found with MostRecent=1")
|
||||
return None
|
||||
|
||||
def convert_steam_id(steam_id: int) -> int:
|
||||
"""
|
||||
Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число.
|
||||
Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения.
|
||||
Converts a signed 32-bit integer to an unsigned 32-bit integer.
|
||||
Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
|
||||
"""
|
||||
return steam_id & 0xFFFFFFFF
|
||||
|
||||
def get_steam_libs(steam_dir: Path) -> set[Path]:
|
||||
"""Возвращает набор директорий Steam libraryfolders."""
|
||||
"""Returns a set of Steam library folders."""
|
||||
libs = set()
|
||||
libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
|
||||
data = safe_vdf_load(libs_vdf)
|
||||
@@ -113,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
|
||||
return libs
|
||||
|
||||
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
|
||||
"""Возвращает данные о времени игры для последнего пользователя."""
|
||||
"""Returns playtime data for the last user."""
|
||||
play_data: dict[int, tuple[int, int]] = {}
|
||||
if steam_home is None:
|
||||
steam_home = get_steam_home()
|
||||
@@ -133,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
|
||||
return play_data
|
||||
|
||||
if not last_user:
|
||||
logger.info("Не удалось определить последнего пользователя Steam")
|
||||
logger.info("Could not identify the last Steam user")
|
||||
return play_data
|
||||
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = userdata_dir / str(unsigned_id)
|
||||
if not user_dir.exists():
|
||||
logger.info(f"Директория пользователя {unsigned_id} не найдена")
|
||||
logger.info(f"User directory {unsigned_id} not found")
|
||||
return play_data
|
||||
|
||||
localconfig = user_dir / "config/localconfig.vdf"
|
||||
@@ -154,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
|
||||
playtime = int(info.get('Playtime', 0))
|
||||
play_data[appid] = (last_played, playtime)
|
||||
except ValueError:
|
||||
logger.warning(f"Некорректные данные playtime для app {appid_str}")
|
||||
logger.warning(f"Invalid playtime data for app {appid_str}")
|
||||
return play_data
|
||||
|
||||
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
"""Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec)."""
|
||||
"""Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
|
||||
games: list[tuple[str, int, int, int]] = []
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is None or not steam_home.exists():
|
||||
@@ -187,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
|
||||
def normalize_name(s):
|
||||
"""
|
||||
Приведение строки к нормальному виду:
|
||||
- перевод в нижний регистр,
|
||||
- удаление символов ™ и ®,
|
||||
- замена разделителей (-, :, ,) на пробел,
|
||||
- удаление лишних пробелов,
|
||||
- удаление суффиксов 'bin' или 'app' в конце строки,
|
||||
- удаление ключевых слов типа 'ultimate', 'edition' и т.п.
|
||||
Normalizes a string by:
|
||||
- converting to lowercase,
|
||||
- removing ™ and ® symbols,
|
||||
- replacing separators (-, :, ,) with spaces,
|
||||
- removing extra spaces,
|
||||
- removing 'bin' or 'app' suffixes,
|
||||
- removing keywords like 'ultimate', 'edition', etc.
|
||||
"""
|
||||
s = s.lower()
|
||||
for ch in ["™", "®"]:
|
||||
@@ -211,12 +211,12 @@ def normalize_name(s):
|
||||
|
||||
def is_valid_candidate(candidate):
|
||||
"""
|
||||
Проверяет, содержит ли кандидат запрещённые подстроки:
|
||||
Checks if a candidate contains forbidden substrings:
|
||||
- win32
|
||||
- win64
|
||||
- gamelauncher
|
||||
Для проверки дополнительно используется строка без пробелов.
|
||||
Возвращает True, если кандидат допустим, иначе False.
|
||||
Additionally checks the string without spaces.
|
||||
Returns True if the candidate is valid, otherwise False.
|
||||
"""
|
||||
normalized_candidate = normalize_name(candidate)
|
||||
normalized_no_space = normalized_candidate.replace(" ", "")
|
||||
@@ -228,7 +228,7 @@ def is_valid_candidate(candidate):
|
||||
|
||||
def filter_candidates(candidates):
|
||||
"""
|
||||
Фильтрует список кандидатов, отбрасывая недопустимые.
|
||||
Filters a list of candidates, discarding invalid ones.
|
||||
"""
|
||||
valid = []
|
||||
dropped = []
|
||||
@@ -238,18 +238,18 @@ def filter_candidates(candidates):
|
||||
else:
|
||||
dropped.append(cand)
|
||||
if dropped:
|
||||
logger.info("Отбрасываю кандидатов: %s", dropped)
|
||||
logger.info("Discarding candidates: %s", dropped)
|
||||
return valid
|
||||
|
||||
def remove_duplicates(candidates):
|
||||
"""
|
||||
Удаляет дубликаты из списка, сохраняя порядок.
|
||||
Removes duplicates from a list while preserving order.
|
||||
"""
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def get_exiftool_data(game_exe):
|
||||
"""Получает метаданные через exiftool"""
|
||||
"""Retrieves metadata using exiftool."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["exiftool", "-j", game_exe],
|
||||
@@ -258,12 +258,12 @@ def get_exiftool_data(game_exe):
|
||||
check=False
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}")
|
||||
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
|
||||
return {}
|
||||
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
|
||||
return meta_data_list[0] if meta_data_list else {}
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
|
||||
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
|
||||
return {}
|
||||
|
||||
def delete_cached_app_files(cache_dir: str, pattern: str):
|
||||
@@ -305,14 +305,14 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
f.write(orjson.dumps(data))
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Archive %s deleted after extraction", cache_tar)
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
# Delete all cached app detail files (steam_app_*.json)
|
||||
delete_cached_app_files(cache_dir, "steam_app_*.json")
|
||||
steam_apps = data if isinstance(data, list) else []
|
||||
logger.info("Loaded %d apps from archive", len(steam_apps))
|
||||
callback(steam_apps)
|
||||
except Exception as e:
|
||||
logger.error("Error extracting Steam apps archive: %s", e)
|
||||
logger.error("Failed to extract Steam apps archive: %s", e)
|
||||
callback([])
|
||||
|
||||
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
|
||||
@@ -322,18 +322,18 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
data = orjson.loads(f.read())
|
||||
# Validate JSON structure
|
||||
if not isinstance(data, list):
|
||||
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
|
||||
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
|
||||
raise ValueError("Invalid JSON structure")
|
||||
# Validate each app entry
|
||||
for app in data:
|
||||
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
|
||||
logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
|
||||
logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
|
||||
raise ValueError("Invalid app entry structure")
|
||||
steam_apps = data
|
||||
logger.info("Loaded %d apps from cache", len(steam_apps))
|
||||
callback(steam_apps)
|
||||
except Exception as e:
|
||||
logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
|
||||
logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
|
||||
# Attempt to re-download if cache is invalid or corrupted
|
||||
app_list_url = (
|
||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
|
||||
@@ -351,12 +351,12 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
|
||||
def build_index(steam_apps):
|
||||
"""
|
||||
Строит индекс приложений по полю normalized_name.
|
||||
Builds an index of applications by normalized_name field.
|
||||
"""
|
||||
steam_apps_index = {}
|
||||
if not steam_apps:
|
||||
return steam_apps_index
|
||||
logger.info("Построение индекса Steam приложений:")
|
||||
logger.info("Building Steam apps index")
|
||||
for app in steam_apps:
|
||||
normalized = app["normalized_name"]
|
||||
steam_apps_index[normalized] = app
|
||||
@@ -364,25 +364,24 @@ def build_index(steam_apps):
|
||||
|
||||
def search_app(candidate, steam_apps_index):
|
||||
"""
|
||||
Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку.
|
||||
Searches for an application by candidate: tries exact match first, then substring match.
|
||||
"""
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm)
|
||||
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
if candidate_norm in steam_apps_index:
|
||||
logger.info(" Найдено точное совпадение: '%s'", candidate_norm)
|
||||
logger.info("Found exact match: '%s'", candidate_norm)
|
||||
return steam_apps_index[candidate_norm]
|
||||
for name_norm, app in steam_apps_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
if ratio > 0.8:
|
||||
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)",
|
||||
candidate_norm, name_norm, ratio)
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
|
||||
return app
|
||||
logger.info(" Приложение для кандидата '%s' не найдено", candidate_norm)
|
||||
logger.info("No app found for candidate '%s'", candidate_norm)
|
||||
return None
|
||||
|
||||
def load_app_details(app_id):
|
||||
"""Загружает кэшированные данные для игры по appid, если они не устарели."""
|
||||
"""Loads cached game data by appid if not outdated."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
|
||||
if os.path.exists(cache_file):
|
||||
@@ -392,7 +391,7 @@ def load_app_details(app_id):
|
||||
return None
|
||||
|
||||
def save_app_details(app_id, data):
|
||||
"""Сохраняет данные по appid в файл кэша."""
|
||||
"""Saves appid data to a cache file."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
|
||||
with open(cache_file, "wb") as f:
|
||||
@@ -435,7 +434,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
|
||||
save_app_details(app_id, app_data)
|
||||
callback(app_data)
|
||||
except Exception as e:
|
||||
logger.error("Error processing Steam app info for appid %s: %s", app_id, e)
|
||||
logger.error("Failed to process Steam app info for appid %s: %s", app_id, e)
|
||||
callback(None)
|
||||
|
||||
downloader.download_async(url, cache_file, timeout=5, callback=process_response)
|
||||
@@ -470,12 +469,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
f.write(orjson.dumps(data))
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Archive %s deleted after extraction", cache_tar)
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
anti_cheat_data = data or []
|
||||
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
|
||||
callback(anti_cheat_data)
|
||||
except Exception as e:
|
||||
logger.error("Error extracting WeAntiCheatYet archive: %s", e)
|
||||
logger.error("Failed to extract WeAntiCheatYet archive: %s", e)
|
||||
callback([])
|
||||
|
||||
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
|
||||
@@ -485,41 +484,37 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
data = orjson.loads(f.read())
|
||||
# Validate JSON structure
|
||||
if not isinstance(data, list):
|
||||
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
|
||||
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
|
||||
raise ValueError("Invalid JSON structure")
|
||||
# Validate each anti-cheat entry
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
|
||||
logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
|
||||
logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
|
||||
raise ValueError("Invalid anti-cheat entry structure")
|
||||
anti_cheat_data = data
|
||||
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
|
||||
callback(anti_cheat_data)
|
||||
except Exception as e:
|
||||
logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
|
||||
logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
|
||||
# Attempt to re-download if cache is invalid or corrupted
|
||||
app_list_url = (
|
||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
|
||||
)
|
||||
# Delete cached anti-cheat files before re-downloading
|
||||
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
|
||||
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
|
||||
else:
|
||||
app_list_url = (
|
||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
|
||||
)
|
||||
# Delete cached anti-cheat files before downloading
|
||||
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
|
||||
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
|
||||
|
||||
def build_weanticheatyet_index(anti_cheat_data):
|
||||
"""
|
||||
Строит индекс античит-данных по полю normalized_name.
|
||||
Builds an index of anti-cheat data by normalized_name field.
|
||||
"""
|
||||
anti_cheat_index = {}
|
||||
if not anti_cheat_data:
|
||||
return anti_cheat_index
|
||||
logger.info("Построение индекса WeAntiCheatYet данных:")
|
||||
logger.info("Building WeAntiCheatYet data index")
|
||||
for entry in anti_cheat_data:
|
||||
normalized = entry["normalized_name"]
|
||||
anti_cheat_index[normalized] = entry
|
||||
@@ -527,20 +522,19 @@ def build_weanticheatyet_index(anti_cheat_data):
|
||||
|
||||
def search_anticheat_status(candidate, anti_cheat_index):
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm)
|
||||
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
if candidate_norm in anti_cheat_index:
|
||||
status = anti_cheat_index[candidate_norm]["status"]
|
||||
logger.info(" Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status)
|
||||
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
|
||||
return status
|
||||
for name_norm, entry in anti_cheat_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
if ratio > 0.8:
|
||||
status = entry["status"]
|
||||
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'",
|
||||
candidate_norm, name_norm, ratio, status)
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
|
||||
return status
|
||||
logger.info(" Античит-статус для кандидата '%s' не найден", candidate_norm)
|
||||
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
|
||||
return ""
|
||||
|
||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
|
||||
@@ -556,7 +550,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
|
||||
load_weanticheatyet_data_async(on_anticheat_data)
|
||||
|
||||
def load_protondb_status(appid):
|
||||
"""Загружает закешированные данные ProtonDB для игры по appid, если они не устарели."""
|
||||
"""Loads cached ProtonDB data for a game by appid if not outdated."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
|
||||
if os.path.exists(cache_file):
|
||||
@@ -565,18 +559,18 @@ def load_protondb_status(appid):
|
||||
with open(cache_file, "rb") as f:
|
||||
return orjson.loads(f.read())
|
||||
except Exception as e:
|
||||
logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e)
|
||||
logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
|
||||
return None
|
||||
|
||||
def save_protondb_status(appid, data):
|
||||
"""Сохраняет данные ProtonDB для игры по appid в файл кэша."""
|
||||
"""Saves ProtonDB data for a game by appid to a cache file."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
|
||||
try:
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
except Exception as e:
|
||||
logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e)
|
||||
logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e)
|
||||
|
||||
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
|
||||
"""
|
||||
@@ -664,7 +658,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
if game_exe.lower().endswith('.exe'):
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error processing bat file %s: %s", game_exe, e)
|
||||
logger.error("Failed to process bat file %s: %s", game_exe, e)
|
||||
else:
|
||||
logger.error("Bat file not found: %s", game_exe)
|
||||
|
||||
@@ -799,55 +793,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
|
||||
|
||||
def enable_steam_cef() -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
|
||||
Checks and enables Steam CEF remote debugging if necessary.
|
||||
|
||||
Создает файл .cef-enable-remote-debugging в директории Steam.
|
||||
Steam необходимо перезапустить после первого создания этого файла.
|
||||
Creates a .cef-enable-remote-debugging file in the Steam directory.
|
||||
Steam must be restarted after the file is first created.
|
||||
|
||||
Возвращает кортеж:
|
||||
- (True, "already_enabled") если уже было активно.
|
||||
- (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
|
||||
- (False, "steam_not_found") если директория Steam не найдена.
|
||||
Returns a tuple:
|
||||
- (True, "already_enabled") if already enabled.
|
||||
- (True, "restart_needed") if just enabled and Steam restart is needed.
|
||||
- (False, "steam_not_found") if Steam directory is not found.
|
||||
"""
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
return (False, "steam_not_found")
|
||||
|
||||
cef_flag_file = steam_home / ".cef-enable-remote-debugging"
|
||||
logger.info(f"Проверка CEF флага: {cef_flag_file}")
|
||||
logger.info(f"Checking CEF flag: {cef_flag_file}")
|
||||
|
||||
if cef_flag_file.exists():
|
||||
logger.info("CEF Remote Debugging уже активирован.")
|
||||
logger.info("CEF Remote Debugging is already enabled")
|
||||
return (True, "already_enabled")
|
||||
else:
|
||||
try:
|
||||
os.makedirs(cef_flag_file.parent, exist_ok=True)
|
||||
cef_flag_file.touch()
|
||||
logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.")
|
||||
logger.info("Enabled CEF Remote Debugging. Steam restart required")
|
||||
return (True, "restart_needed")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}")
|
||||
logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
|
||||
return (False, str(e))
|
||||
|
||||
def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
"""
|
||||
Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
|
||||
Executes a JavaScript function in the Steam context via CEF Remote Debugging.
|
||||
|
||||
Args:
|
||||
js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
|
||||
*args: Аргументы для передачи в JS функцию.
|
||||
js_cmd: Name of the JS function to call (e.g., 'createShortcut').
|
||||
*args: Arguments to pass to the JS function.
|
||||
|
||||
Returns:
|
||||
Словарь с результатом выполнения или None в случае ошибки.
|
||||
Dictionary with the result or None if an error occurs.
|
||||
"""
|
||||
status, message = enable_steam_cef()
|
||||
if not (status is True and message == "already_enabled"):
|
||||
if message == "restart_needed":
|
||||
logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
|
||||
logger.warning("Steam CEF API is available but requires Steam restart for full activation")
|
||||
elif message == "steam_not_found":
|
||||
logger.error("Не удалось найти директорию Steam для проверки CEF API.")
|
||||
logger.error("Could not find Steam directory to check CEF API")
|
||||
else:
|
||||
logger.error(f"Steam CEF API недоступен или не готов: {message}")
|
||||
logger.error(f"Steam CEF API is unavailable or not ready: {message}")
|
||||
return None
|
||||
|
||||
steam_debug_url = "http://localhost:8080/json"
|
||||
@@ -858,10 +852,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
contexts = response.json()
|
||||
ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
|
||||
if not ws_url:
|
||||
logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?")
|
||||
logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
|
||||
logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}")
|
||||
return None
|
||||
|
||||
js_code = """
|
||||
@@ -906,15 +900,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
|
||||
response_data = orjson.loads(response_str)
|
||||
if "error" in response_data:
|
||||
logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}")
|
||||
logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
|
||||
return None
|
||||
result = response_data.get('result', {}).get('result', {})
|
||||
if result.get('type') == 'object' and result.get('subtype') == 'error':
|
||||
logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}")
|
||||
logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
|
||||
return None
|
||||
return result.get('value')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
|
||||
logger.error(f"WebSocket interaction error with Steam: {e}")
|
||||
return None
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
@@ -991,24 +985,24 @@ export START_FROM_STEAM=1
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"generate_thumbnail failed to create icon for {exe_path}")
|
||||
logger.warning(f"Failed to generate thumbnail for {exe_path}")
|
||||
icon_path = ""
|
||||
else:
|
||||
logger.info(f"Generated thumbnail: {generated_icon_path}")
|
||||
icon_path = generated_icon_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail for {exe_path}: {e}")
|
||||
logger.error(f"Failed to generate thumbnail for {exe_path}: {e}")
|
||||
icon_path = ""
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
logger.error("Steam home directory not found")
|
||||
return (False, "Steam directory not found.")
|
||||
return (False, "Steam directory not found")
|
||||
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
return (False, "Failed to get Steam user ID.")
|
||||
return (False, "Failed to get Steam user ID")
|
||||
|
||||
userdata_dir = steam_home / "userdata"
|
||||
user_id = last_user['SteamID']
|
||||
@@ -1021,7 +1015,7 @@ export START_FROM_STEAM=1
|
||||
appid = None
|
||||
was_api_used = False
|
||||
|
||||
logger.info("Попытка добавления ярлыка через Steam CEF API...")
|
||||
logger.info("Attempting to add shortcut via Steam CEF API")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_name,
|
||||
@@ -1034,9 +1028,9 @@ export START_FROM_STEAM=1
|
||||
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||
appid = api_response['id']
|
||||
was_api_used = True
|
||||
logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}")
|
||||
logger.info(f"Shortcut successfully added via API. AppID: {appid}")
|
||||
else:
|
||||
logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
|
||||
logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
@@ -1110,7 +1104,7 @@ export START_FROM_STEAM=1
|
||||
appid = None
|
||||
|
||||
if not appid:
|
||||
return (False, "Не удалось создать ярлык ни одним из способов.")
|
||||
return (False, "Failed to create shortcut using any method")
|
||||
|
||||
steam_appid = None
|
||||
|
||||
@@ -1120,7 +1114,7 @@ export START_FROM_STEAM=1
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
return
|
||||
logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
|
||||
cover_types = [
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
@@ -1137,15 +1131,15 @@ export START_FROM_STEAM=1
|
||||
try:
|
||||
with open(result_path, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}")
|
||||
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
|
||||
ext = Path(steam_name).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}")
|
||||
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}")
|
||||
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
@@ -1186,13 +1180,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
logger.error("Steam home directory not found")
|
||||
return (False, "Steam directory not found.")
|
||||
return (False, "Steam directory not found")
|
||||
|
||||
# Get current Steam user ID
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
return (False, "Failed to get Steam user ID.")
|
||||
return (False, "Failed to get Steam user ID")
|
||||
userdata_dir = steam_home / "userdata"
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
@@ -1238,10 +1232,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
|
||||
return (False, f"Game '{game_name}' not found in Steam")
|
||||
|
||||
api_response = call_steam_api("removeShortcut", appid)
|
||||
if api_response is not None: # API ответил, даже если ответ пустой
|
||||
logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
|
||||
if api_response is not None: # API responded, even if response is empty
|
||||
logger.info(f"Shortcut for AppID {appid} successfully removed via API")
|
||||
else:
|
||||
logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
|
||||
logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
|
||||
|
||||
# Create backup of shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
@@ -1320,5 +1314,5 @@ def is_game_in_steam(game_name: str) -> bool:
|
||||
if entry.get("AppName") == game_name:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if game {game_name} is in Steam: {e}")
|
||||
logger.error(f"Failed to check if game {game_name} is in Steam: {e}")
|
||||
return False
|
||||
|
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"matchFileNames": [".python-version"],
|
||||
"enabled": false,
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["poetry", "pyenv"],
|
||||
@@ -41,6 +41,6 @@
|
||||
"matchDatasources": ["github-runners", "python-version"],
|
||||
"enabled": false,
|
||||
"description": "Prevent Renovate from updating runs-on to unsupported ubuntu-24.04"
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user