Compare commits
No commits in common. "0.2" and "main" have entirely different histories.
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
rare/resources/resources.py binary
|
||||
rare/resources/static_css/__init__.py binary
|
||||
rare/resources/stylesheets/ChildOfMetropolis/__init__.py binary
|
||||
rare/resources/stylesheets/RareStyle/__init__.py binary
|
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System information**
|
||||
|
||||
Please complete the following information
|
||||
- Operating system: [e.g. Manjaro/Windows 10]
|
||||
- Version [e.g. 1.6.2]
|
||||
- Installation method [e.g. pip/msi/AppImage]
|
||||
- Python version (if installed through `pip` or your package manager)
|
||||
|
||||
**Additional context**
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Error message**
|
||||
|
||||
You can find logs in these locations
|
||||
|
||||
| OS | Path |
|
||||
|---------|----------------------------------------------------------|
|
||||
| Windows | `C:\Users\<username>\AppData\Local\Rare\Rare\cache\logs` |
|
||||
| Linux | `/home/<username>/.cache/Rare/Rare/logs` |
|
||||
| masOS | `/Users/<username>/Library/Caches/Rare/Rare/logs` |
|
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature Request]"
|
||||
labels: feature-request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
51
.github/workflows/checks.yml
vendored
Normal file
51
.github/workflows/checks.yml
vendored
Normal file
|
@ -0,0 +1,51 @@
|
|||
|
||||
name: "Checks"
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'rare/**'
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'rare/**'
|
||||
|
||||
|
||||
jobs:
|
||||
pylint:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['macos-latest', 'windows-latest', 'ubuntu-latest']
|
||||
version: ['3.9', '3.10', '3.11', '3.12']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip3 install astroid
|
||||
pip3 install pylint
|
||||
- name: Install Target Dependencies
|
||||
run: |
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install -r requirements-presence.txt
|
||||
- name: Install Development Dependencies
|
||||
run: |
|
||||
pip3 install qstylizer
|
||||
- name: Analysis with pylint
|
||||
run: |
|
||||
python3 -m pylint rare
|
93
.github/workflows/codeql.yml
vendored
Normal file
93
.github/workflows/codeql.yml
vendored
Normal file
|
@ -0,0 +1,93 @@
|
|||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '37 11 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: python
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
40
.github/workflows/job_appimage.yml
vendored
Normal file
40
.github/workflows/job_appimage.yml
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
name: job_appimage
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install python3 python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse
|
||||
- name: Install appimage-builder
|
||||
run: |
|
||||
sudo wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
|
||||
sudo chmod +x /usr/local/bin/appimagetool
|
||||
sudo pip3 install appimage-builder
|
||||
- name: Build
|
||||
run: |
|
||||
appimage-builder --skip-test
|
||||
mv Rare-*.AppImage Rare.AppImage
|
||||
mv Rare-*.AppImage.zsync Rare.AppImage.zsync
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-${{ inputs.version }}.AppImage
|
||||
path: Rare.AppImage
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-${{ inputs.version }}.AppImage.zsync
|
||||
path: Rare.AppImage.zsync
|
37
.github/workflows/job_cx-freeze-msi.yml
vendored
Normal file
37
.github/workflows/job_cx-freeze-msi.yml
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
name: job_cx-freeze-msi
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: "windows-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
cache: pip
|
||||
python-version: '3.12'
|
||||
check-latest: true
|
||||
architecture: x64
|
||||
- name: Install Build Dependencies
|
||||
run: pip3 install --upgrade cx_freeze wheel
|
||||
- name: Install Target Dependencies
|
||||
run: |
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install -r requirements-presence.txt
|
||||
- name: Build
|
||||
run: |
|
||||
python freeze.py bdist_msi
|
||||
mv dist/*.msi Rare.msi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-${{ inputs.version }}.msi
|
||||
path: Rare.msi
|
39
.github/workflows/job_cx-freeze-zip.yml
vendored
Normal file
39
.github/workflows/job_cx-freeze-zip.yml
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
name: job_cx-freeze-zip
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: "windows-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
cache: pip
|
||||
python-version: '3.12'
|
||||
check-latest: true
|
||||
architecture: x64
|
||||
- name: Install build dependencies
|
||||
run: pip3 install cx_freeze
|
||||
- name: Install target dependencies
|
||||
run: |
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install -r requirements-presence.txt
|
||||
pip3 install .
|
||||
- name: Build
|
||||
run: cxfreeze -c rare/main.py --target-dir dist --target-name rare --icon rare/resources/images/Rare.ico -OO --base-name Win32GUI
|
||||
- name: Compress
|
||||
run: |
|
||||
python -c "import shutil; shutil.make_archive('Rare-Windows', 'zip', 'dist')"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-Windows-${{ inputs.version }}.zip
|
||||
path: Rare-Windows.zip
|
46
.github/workflows/job_macos.yml
vendored
Normal file
46
.github/workflows/job_macos.yml
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
name: job_macos
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
cache: pip
|
||||
python-version: '3.12'
|
||||
check-latest: true
|
||||
- name: Install Build Dependencies
|
||||
run: pip install pyinstaller
|
||||
- name: Install Target Dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
- name: Move files
|
||||
run: mv rare/__main__.py __main__.py
|
||||
- name: Build
|
||||
run: >-
|
||||
pyinstaller -F --name Rare
|
||||
--add-data "rare/resources/languages/*:rare/resources/languages"
|
||||
--add-data "rare/resources/images/*:rare/resources/images/"
|
||||
--windowed
|
||||
--icon rare/resources/images/Rare.icns
|
||||
--hidden-import=legendary
|
||||
__main__.py
|
||||
- name: Create dmg
|
||||
run: |
|
||||
git clone https://github.com/create-dmg/create-dmg
|
||||
create-dmg/create-dmg Rare.dmg dist/Rare.App --volname Rare --volicon rare/resources/images/Rare.icns
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-${{ inputs.version }}.dmg
|
||||
path: Rare.dmg
|
70
.github/workflows/job_nuitka-win.yml
vendored
Normal file
70
.github/workflows/job_nuitka-win.yml
vendored
Normal file
|
@ -0,0 +1,70 @@
|
|||
name: job_nuitka-win
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: "windows-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
cache: pip
|
||||
python-version: '3.9'
|
||||
check-latest: true
|
||||
architecture: x64
|
||||
- name: Install build dependencies
|
||||
run: pip3 install nuitka ordered-set
|
||||
- name: Install target dependencies
|
||||
run: |
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install -r requirements-presence.txt
|
||||
- name: Build
|
||||
run: >-
|
||||
python -m nuitka
|
||||
--assume-yes-for-downloads
|
||||
--msvc=latest
|
||||
--lto=yes
|
||||
--jobs=2
|
||||
--static-libpython=no
|
||||
--standalone
|
||||
--enable-plugin=anti-bloat
|
||||
--enable-plugin=pyqt5
|
||||
--show-modules
|
||||
--show-anti-bloat-changes
|
||||
--follow-stdlib
|
||||
--follow-imports
|
||||
--nofollow-import-to="*.tests"
|
||||
--nofollow-import-to="*.distutils"
|
||||
--prefer-source-code
|
||||
--include-package=pypresence
|
||||
--include-package-data=qtawesome
|
||||
--include-data-dir=rare\resources\images=rare\resources\images
|
||||
--include-data-files=rare\resources\languages=rare\resources\languages="*.qm"
|
||||
--windows-icon-from-ico=rare\resources\images\Rare.ico
|
||||
--windows-company-name=Rare
|
||||
--windows-product-name=Rare
|
||||
--windows-file-description=rare.exe
|
||||
--windows-file-version=${{ inputs.version }}
|
||||
--windows-product-version=${{ inputs.version }}
|
||||
--disable-console
|
||||
rare
|
||||
- name: Fix QtNetwork SSL
|
||||
run: |
|
||||
Copy-Item -Path "rare.dist\libcrypto-1_1.dll" -Destination "rare.dist\libcrypto-1_1-x64.dll"
|
||||
Copy-Item -Path "rare.dist\libssl-1_1.dll" -Destination "rare.dist\libssl-1_1-x64.dll"
|
||||
- name: Compress
|
||||
run: |
|
||||
python -c "import shutil; shutil.make_archive('Rare-Windows', 'zip', 'rare.dist')"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-Windows-${{ inputs.version }}.zip
|
||||
path: Rare-Windows.zip
|
30
.github/workflows/job_pypi.yml
vendored
Normal file
30
.github/workflows/job_pypi.yml
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
name: job_pypi
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip3 install setuptools wheel twine
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
run: |
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
47
.github/workflows/job_release.yml
vendored
Normal file
47
.github/workflows/job_release.yml
vendored
Normal file
|
@ -0,0 +1,47 @@
|
|||
name: job_release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
file1:
|
||||
required: true
|
||||
type: string
|
||||
name1:
|
||||
required: true
|
||||
type: string
|
||||
name2:
|
||||
type: string
|
||||
default: ""
|
||||
file2:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Upload
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: ${{ inputs.name1 }}
|
||||
file: ${{ inputs.file1 }}
|
||||
- name: ${{ inputs.name2 }}
|
||||
file: ${{ inputs.file2 }}
|
||||
steps:
|
||||
- name: Download ${{ matrix.name }} from artifact
|
||||
uses: actions/download-artifact@v3
|
||||
if: ${{ matrix.name != '' }}
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
- name: Upload ${{ matrix.name }} to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: ${{ matrix.name != '' }}
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ matrix.file }}
|
||||
asset_name: ${{ matrix.name }}
|
||||
tag: ${{ inputs.version }}
|
||||
overwrite: true
|
39
.github/workflows/job_ubuntu.yml
vendored
Normal file
39
.github/workflows/job_ubuntu.yml
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
name: job_ubuntu
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install makedeb
|
||||
run: |
|
||||
wget -qO - 'https://proget.makedeb.org/debian-feeds/makedeb.pub' | gpg --dearmor | sudo tee /usr/share/keyrings/makedeb-archive-keyring.gpg 1> /dev/null
|
||||
echo 'deb [signed-by=/usr/share/keyrings/makedeb-archive-keyring.gpg arch=all] https://proget.makedeb.org/ makedeb main' | sudo tee /etc/apt/sources.list.d/makedeb.list
|
||||
sudo apt update
|
||||
sudo apt install makedeb
|
||||
- name: Prepare source directory
|
||||
run: |
|
||||
git clone https://github.com/RareDevs/package-mpr.git build
|
||||
sed '/^pkgver=/d' -i build/PKGBUILD
|
||||
sed '/^source=/d' -i build/PKGBUILD
|
||||
echo "pkgver=${{ inputs.version }}" >> build/PKGBUILD
|
||||
echo "source=(\"git+https://github.com/${{ github.repository }}.git#branch=${{ github.ref_name }}\")" >> build/PKGBUILD
|
||||
|
||||
- name: Run makedeb
|
||||
run: |
|
||||
cd build
|
||||
makedeb -d
|
||||
mv *.deb ../Rare.deb
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Rare-${{ inputs.version }}.deb
|
||||
path: Rare.deb
|
35
.github/workflows/job_version.yml
vendored
Normal file
35
.github/workflows/job_version.yml
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
name: job_version
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
version:
|
||||
value: "${{ jobs.version.outputs.tag_abbrev }}.${{ jobs.version.outputs.tag_offset }}"
|
||||
branch:
|
||||
value: "${{ jobs.version.outputs.branch }}"
|
||||
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_abbrev: ${{ steps.describe.outputs.tag_abbrev }}
|
||||
tag_offset: ${{ steps.describe.outputs.tag_offset }}
|
||||
sha_short: ${{ steps.describe.outputs.sha_short }}
|
||||
full_desc: ${{ steps.describe.outputs.full_desc }}
|
||||
branch: ${{ steps.describe.outputs.branch }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Describe
|
||||
id: describe
|
||||
shell: bash
|
||||
run: |
|
||||
tag_abbrev=$(git tag --sort=v:refname | grep -oE "(^[0-9]+\.[0-9]+(.[0-9]+)?)$" | tail -1)
|
||||
echo "tag_abbrev=$tag_abbrev" >> $GITHUB_OUTPUT
|
||||
echo "tag_offset=$(git rev-list $tag_abbrev..HEAD --count)" >> $GITHUB_OUTPUT
|
||||
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "full_desc=$(git describe --long --tags)" >> $GITHUB_OUTPUT
|
||||
echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
|
116
.github/workflows/release.yml
vendored
Normal file
116
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,116 @@
|
|||
|
||||
name: "Release"
|
||||
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
title:
|
||||
name: Version ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: "true"
|
||||
|
||||
pypi:
|
||||
if: "!github.event.release.prerelease"
|
||||
name: PyPI
|
||||
uses: ./.github/workflows/job_pypi.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
|
||||
ubuntu:
|
||||
name: Ubuntu
|
||||
uses: ./.github/workflows/job_ubuntu.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
ubuntu-release:
|
||||
needs: ubuntu
|
||||
name: Ubuntu
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
file1: Rare.deb
|
||||
name1: Rare-${{ github.ref_name }}.deb
|
||||
|
||||
appimage:
|
||||
name: AppImage
|
||||
uses: ./.github/workflows/job_appimage.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
appimage-release:
|
||||
needs: appimage
|
||||
name: AppImage
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
file1: Rare.AppImage
|
||||
name1: Rare-${{ github.ref_name }}.AppImage
|
||||
file2: Rare.AppImage.zsync
|
||||
name2: Rare-${{ github.ref_name }}.AppImage.zsync
|
||||
|
||||
nuitka-win:
|
||||
if: ${{ false }}
|
||||
name: Nuitka Windows
|
||||
uses: ./.github/workflows/job_nuitka-win.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
nuitka-win-release:
|
||||
needs: nuitka-win
|
||||
name: Nuitka Windows
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
file1: Rare-Windows.zip
|
||||
name1: Rare-Windows-${{ github.ref_name }}.zip
|
||||
|
||||
cx-freeze-msi:
|
||||
name: cx-Freeze msi
|
||||
uses: ./.github/workflows/job_cx-freeze-msi.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
cx-freeze-msi-release:
|
||||
needs: cx-freeze-msi
|
||||
name: cx-Freeze msi
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
file1: Rare.msi
|
||||
name1: Rare-${{ github.ref_name }}.msi
|
||||
|
||||
cx-freeze-zip:
|
||||
name: cx-Freeze zip
|
||||
uses: ./.github/workflows/job_cx-freeze-zip.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
cx-freeze-zip-release:
|
||||
needs: cx-freeze-zip
|
||||
name: cx-Freeze zip
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
file1: Rare-Windows.zip
|
||||
name1: Rare-Windows-${{ github.ref_name }}.zip
|
||||
|
||||
macos:
|
||||
name: macOS
|
||||
uses: ./.github/workflows/job_macos.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
macos-release:
|
||||
needs: macos
|
||||
name: macOS
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
file1: Rare.dmg
|
||||
name1: Rare-${{ github.ref_name }}.dmg
|
||||
|
152
.github/workflows/snapshot.yml
vendored
Normal file
152
.github/workflows/snapshot.yml
vendored
Normal file
|
@ -0,0 +1,152 @@
|
|||
|
||||
name: "Snapshot"
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prerelease:
|
||||
description: "Create a pre-release"
|
||||
default: false
|
||||
required: true
|
||||
type: boolean
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [closed]
|
||||
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
discussions: write
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
version:
|
||||
name: Describe
|
||||
uses: ./.github/workflows/job_version.yml
|
||||
|
||||
title:
|
||||
needs: version
|
||||
name: Version ${{ needs.version.outputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: "true"
|
||||
|
||||
prerelease:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: version
|
||||
name: Create pre-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ needs.version.outputs.version }}
|
||||
commit: "main"
|
||||
name: Pre-release ${{ needs.version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
generateReleaseNotes: true
|
||||
discussionCategory: "Releases"
|
||||
makeLatest: false
|
||||
|
||||
ubuntu:
|
||||
needs: version
|
||||
name: Ubuntu
|
||||
uses: ./.github/workflows/job_ubuntu.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
ubuntu-release:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: [version, prerelease, ubuntu]
|
||||
name: Ubuntu
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
file1: Rare.deb
|
||||
name1: Rare-${{ needs.version.outputs.version }}.deb
|
||||
|
||||
appimage:
|
||||
needs: version
|
||||
name: AppImage
|
||||
uses: ./.github/workflows/job_appimage.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
appimage-release:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: [version, prerelease, appimage]
|
||||
name: AppImage
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
file1: Rare.AppImage
|
||||
name1: Rare-${{ needs.version.outputs.version }}.AppImage
|
||||
file2: Rare.AppImage.zsync
|
||||
name2: Rare-${{ needs.version.outputs.version }}.AppImage.zsync
|
||||
|
||||
nuitka-win:
|
||||
if: ${{ false }}
|
||||
needs: version
|
||||
name: Nuitka Windows
|
||||
uses: ./.github/workflows/job_nuitka-win.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
nuitka-win-release:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: [version, prerelease, nuitka-win]
|
||||
name: Nuitka Windows
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
file1: Rare-Windows.zip
|
||||
name1: Rare-Windows-${{ needs.version.outputs.version }}.zip
|
||||
|
||||
cx-freeze-msi:
|
||||
needs: version
|
||||
name: cx-Freeze msi
|
||||
uses: ./.github/workflows/job_cx-freeze-msi.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
cx-freeze-msi-release:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: [version, prerelease, cx-freeze-msi]
|
||||
name: cx-Freeze msi
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
file1: Rare.msi
|
||||
name1: Rare-${{ needs.version.outputs.version }}.msi
|
||||
|
||||
cx-freeze-zip:
|
||||
needs: version
|
||||
name: cx-Freeze zip
|
||||
uses: ./.github/workflows/job_cx-freeze-zip.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
cx-freeze-zip-release:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: [version, prerelease, cx-freeze-zip]
|
||||
name: cx-Freeze zip
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
file1: Rare-Windows.zip
|
||||
name1: Rare-Windows-${{ needs.version.outputs.version }}.zip
|
||||
|
||||
macos:
|
||||
needs: version
|
||||
name: macOS
|
||||
uses: ./.github/workflows/job_macos.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
macos-release:
|
||||
if: ${{ inputs.prerelease }}
|
||||
needs: [version, prerelease, macos]
|
||||
name: macOS
|
||||
uses: ./.github/workflows/job_release.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
file1: Rare.dmg
|
||||
name1: Rare-${{ needs.version.outputs.version }}.dmg
|
||||
|
31
.gitignore
vendored
31
.gitignore
vendored
|
@ -1,7 +1,26 @@
|
|||
/images/
|
||||
/.idea/
|
||||
/Rare/__pycache__/
|
||||
/CountLines.sh
|
||||
/build/
|
||||
/dist/
|
||||
*.pyc
|
||||
*.pyo
|
||||
__pycache__
|
||||
|
||||
/.idea
|
||||
/.vscode
|
||||
*~
|
||||
/build
|
||||
/dist
|
||||
/deb_dist
|
||||
*.tar.gz
|
||||
/Rare.egg-info/
|
||||
/venv*
|
||||
/test.py
|
||||
/.eggs
|
||||
/appimage-builder-cache/
|
||||
/AppDir/
|
||||
/System Volume Information/
|
||||
/test_files/
|
||||
# Nuitka build artifacts
|
||||
/rare.build
|
||||
/rare.dist
|
||||
/rare.bin
|
||||
/rare.cmd
|
||||
/rare.exe
|
||||
/poetry.lock
|
0
Rare/utils/__init__.py → .gitmodules
vendored
0
Rare/utils/__init__.py → .gitmodules
vendored
10
.tx/config
Normal file
10
.tx/config
Normal file
|
@ -0,0 +1,10 @@
|
|||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:rare-1:p:rare:r:placeholder-ts]
|
||||
file_filter = rare/resources/languages/rare_<lang>.ts
|
||||
source_file = rare/resources/languages/source.ts
|
||||
source_lang = en
|
||||
type = QT
|
||||
minimum_perc = 50
|
||||
|
74
AppImageBuilder.yml
Normal file
74
AppImageBuilder.yml
Normal file
|
@ -0,0 +1,74 @@
|
|||
# appimage-builder recipe see https://appimage-builder.readthedocs.io for details
|
||||
version: 1
|
||||
|
||||
script:
|
||||
# Remove any previous build
|
||||
- rm -rf AppDir | true
|
||||
# Make usr and icons dirs
|
||||
- mkdir -p AppDir/usr/src
|
||||
- mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps/
|
||||
# Copy source files
|
||||
- cp -r rare AppDir/usr/src/rare
|
||||
# copy Logo
|
||||
- cp AppDir/usr/src/rare/resources/images/Rare.png AppDir/usr/share/icons/hicolor/256x256/apps/
|
||||
# Install application dependencies
|
||||
- python3 -m pip install --ignore-installed --prefix=/usr --root=AppDir pypresence qtawesome legendary-gl orjson
|
||||
|
||||
AppDir:
|
||||
path: AppDir
|
||||
app_info:
|
||||
id: io.github.dummerle.rare
|
||||
name: Rare
|
||||
icon: Rare
|
||||
version: 1.10.11
|
||||
exec: usr/bin/python3
|
||||
exec_args: $APPDIR/usr/src/rare/main.py $@
|
||||
apt:
|
||||
arch: amd64
|
||||
allow_unauthenticated: true
|
||||
sources:
|
||||
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
|
||||
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x3b4fe6acc0b21f32'
|
||||
|
||||
include:
|
||||
- python3
|
||||
- python3-distutils
|
||||
- python3-pyqt5
|
||||
- python3-pyqt5.qtsvg
|
||||
- python3-requests
|
||||
|
||||
runtime:
|
||||
env:
|
||||
# Set python home
|
||||
# See https://docs.python.org/3/using/cmdline.html#envvar-PYTHONHOME
|
||||
PYTHONHOME: '${APPDIR}/usr'
|
||||
# Path to the site-packages dir or other modules dirs
|
||||
# See https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH
|
||||
PYTHONPATH: '${APPDIR}/usr/lib/python3.8/site-packages:${APPDIR}/usr/lib/python3.9/site-packages'
|
||||
PYTHONNOUSERSITE: 1
|
||||
|
||||
test:
|
||||
fedora:
|
||||
image: appimagecrafters/tests-env:fedora-30
|
||||
command: ./AppRun --test-start
|
||||
use_host_x: true
|
||||
debian-stable:
|
||||
image: appimagecrafters/tests-env:debian-stable
|
||||
command: ./AppRun --test-start
|
||||
use_host_x: true
|
||||
archlinux-latest:
|
||||
image: appimagecrafters/tests-env:archlinux-latest
|
||||
command: ./AppRun --test-start
|
||||
use_host_x: true
|
||||
centos-7:
|
||||
image: appimagecrafters/tests-env:centos-7
|
||||
command: ./AppRun --test-start
|
||||
use_host_x: true
|
||||
ubuntu-xenial:
|
||||
image: appimagecrafters/tests-env:ubuntu-xenial
|
||||
command: ./AppRun --test-start
|
||||
use_host_x: true
|
||||
|
||||
AppImage:
|
||||
arch: x86_64
|
||||
update-information: gh-releases-zsync|Dummerle|Rare|latest|Rare*.AppImage.zsync
|
35
CONTRIBUTING.md
Normal file
35
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Contributing
|
||||
|
||||
## What you can do
|
||||
|
||||
### Add translations
|
||||
|
||||
To help with translations check [transifex](https://www.transifex.com/rare-1/rare)
|
||||
|
||||
### Add Stylesheets
|
||||
|
||||
For this you can create a .qss file in rare/resources/stylesheets directory or modify the existing RareStyle.qss file. Here are some
|
||||
examples:
|
||||
[Qt Docs](https://doc.qt.io/qt-5/stylesheet-examples.html)
|
||||
|
||||
### Add features
|
||||
|
||||
Select one Card of the project and implement it, or if you want to add another feature ask me on Discord, or create an
|
||||
issue on GitHub
|
||||
|
||||
## Git crash-course
|
||||
|
||||
To contribute fork the repository and clone **your** repo: `git clone https://github.com/YourName/Rare` Then make your changes, add changed files to git with `git add File.xy rare/other_file.py`
|
||||
and upload it to GitHub with `git commit -m "message"` and `git push`. Some IDEs like PyCharm can do this automatically.
|
||||
|
||||
If you uploaded your changes, create a pull request
|
||||
|
||||
# Code Style Guidelines
|
||||
|
||||
## Signals and threads
|
||||
|
||||
## Function naming
|
||||
|
||||
## UI Classes
|
||||
|
||||
### Widget and Layout naming
|
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
|
@ -0,0 +1,3 @@
|
|||
include README.md
|
||||
include rare/resources/images/*
|
||||
include rare/resources/languages/*
|
200
README.md
200
README.md
|
@ -1,26 +1,192 @@
|
|||
# Rare
|
||||
[![Discord Shield](https://discordapp.com/api/guilds/826881530310819914/widget.png?style=shield)](https://discord.gg/YvmABK9YSk)
|
||||
|
||||
|
||||
## A frontend for legendary, the open source Epic Games Launcher alternative
|
||||
|
||||
Rare is currently considered beta software and in no way feature-complete. You **will** run into issues, so make backups first!
|
||||
Rare is a graphical interface for Legendary, a command line alternative to Epic Games launcher, based on PyQt5
|
||||
|
||||
### Requirements
|
||||
- requests, pillow and pyqt5 installed via PyPI
|
||||
- Legendary installed via PyPI
|
||||
<div align="center">
|
||||
<img src="https://github.com/RareDevs/Rare/blob/main/rare/resources/images/Rare_nonsquared.png?raw=true" alt="Logo" width="200"/>
|
||||
<p><i>Logo by <a href="https://github.com/MultisampledNight">@MultisampledNight</a> available
|
||||
<a href="https://github.com/RareDevs/Rare/blob/main/rare/resources/images/">here</a>,
|
||||
licensed under CC BY-SA 4.0</i></p>
|
||||
</div>
|
||||
|
||||
|
||||
## Why Rare?
|
||||
|
||||
- Runs natively, and supports most of the major platforms
|
||||
- Gets out of your way when you don't need it, allowing you to enjoy your games
|
||||
- Tries to be as lightweight as we can make it while still offering a feature-full experience
|
||||
- Integrates seamlessly with legendary as both projects are developed in Python
|
||||
- Packages, packages everywhere
|
||||
|
||||
|
||||
## Reporting issues
|
||||
|
||||
If you run into any issues, you can report them by creating an issue on GitHub: https://github.com/RareDevs/Rare/issues/new/choose
|
||||
|
||||
When reporting issues, it is helpful to also include the logs with your issue.
|
||||
You can find the longs in the following locations depending on your operating system
|
||||
|
||||
| OS | Path |
|
||||
|---------|----------------------------------------------------------|
|
||||
| Windows | `C:\Users\<username>\AppData\Local\Rare\Rare\cache\logs` |
|
||||
| Linux | `/home/<username>/.cache/Rare/Rare/logs` |
|
||||
| masOS | `/Users/<username>/Library/Caches/Rare/Rare/logs` |
|
||||
|
||||
In these folders you will find files named like below
|
||||
|
||||
- `Rare_23-12-19--11-14.log`
|
||||
|
||||
These are the logs for the main Rare application. As such are importand when Rare itself is crashing.
|
||||
|
||||
### Usage
|
||||
When you run Rare, it'll check if you're currently logged in and walk you through logging in if you aren't.
|
||||
Once you're logged in, it will pull game covers from the "metadata" folder in Legedary's config folder, join logo and background together and then display a list of games you have in your account. Installed games will appear in full color while not installed ones will appear in black and white.
|
||||
- `RareLauncher_f4e0c1dff48749fa9145c1585699e276_23-12-17--19-53.log`
|
||||
|
||||
### Implemented
|
||||
- Launch, install and uninstall games
|
||||
- Authentication(Import from existing installation and via Browser)**(Please test it!)**
|
||||
These are the logs for each of the games you run through Rare. Rare uses a separate instance of itself
|
||||
to launch games, and these are the logs of that instance.
|
||||
|
||||
### Todos
|
||||
- Sync saves
|
||||
- Settings
|
||||
- Search Games
|
||||
- In-app Browser to buy games
|
||||
- ...
|
||||
If you don't have a GitHub account or you just want to chat, you also can contact us on Discord: https://discord.gg/YvmABK9YSk
|
||||
|
||||
If you have features you want to have in this app, create an issue on github or build it yourself. Please report bugs(Especially Windows)
|
||||
|
||||
## Installation
|
||||
|
||||
### Linux
|
||||
|
||||
#### Flatpak
|
||||
|
||||
Rare is available as a flatpak. See [rare](https://flathub.org/apps/details/io.github.dummerle.rare).
|
||||
|
||||
Install it via:
|
||||
|
||||
`flatpak install flathub io.github.dummerle.rare`
|
||||
|
||||
Run it via:
|
||||
|
||||
`flatpak run io.github.dummerle.rare`
|
||||
|
||||
#### Arch based
|
||||
|
||||
There are some AUR packages available:
|
||||
|
||||
- [rare](https://aur.archlinux.org/packages/rare) - for stable releases
|
||||
- [rare-git](https://aur.archlinux.org/packages/rare-git) - for the latest development version
|
||||
|
||||
#### Debian based
|
||||
|
||||
- DUR package: [rare](https://mpr.hunterwittenborn.com/packages/rare)
|
||||
- `.deb` file in [releases page](https://github.com/RareDevs/Rare/releases)
|
||||
|
||||
**Note**:
|
||||
- pypresence is an optional package. You can install it from [DUR](https://mpr.hunterwittenborn.com/packages/python3-pypresence) or with pip.
|
||||
- Some icons might look strange on Debian based distributions. The official python3-qtawesome package is too old.
|
||||
|
||||
|
||||
### macOS
|
||||
|
||||
There is a `.dmg` file available in [releases page](https://github.com/RareDevs/Rare/releases).
|
||||
|
||||
**Note**: When you launch it, you will see an error, that the package is from an unknown source. You have to enable it manually in `Settings -> Security and Privacy`. Otherwise, Gatekeeper will block Rare from running.
|
||||
|
||||
You can also use `pip`.
|
||||
|
||||
|
||||
### Windows
|
||||
|
||||
There is an `.msi` installer available in [releases page](https://github.com/RareDevs/Rare/releases).
|
||||
|
||||
There is also a semi-portable `.zip` archive in [releases page](https://github.com/RareDevs/Rare/releases) that lets you run Rare without installing it.
|
||||
|
||||
**Important**: On recent version of Windows you should have MSVC 2015 installed, you can get it from [here](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist?view=msvc-170#visual-studio-2015-2017-2019-and-2022)
|
||||
|
||||
#### Packages
|
||||
|
||||
- Rare is available as a [Winget package](https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/Dummerle/Rare)
|
||||
You can install Rare with the following one-liner:
|
||||
|
||||
`winget install rare`
|
||||
|
||||
- Rare is available as a [Chocolatey package](https://community.chocolatey.org/packages/rare).
|
||||
You can install Rare with the following one-liner:
|
||||
|
||||
`choco install rare`
|
||||
|
||||
- We also have a beta tool for Windows: [Rare Updater](https://github.com/Dummerle/RareUpdater), which installs and updates rare with a single click
|
||||
|
||||
### Packages
|
||||
|
||||
In [releases page](https://github.com/RareDevs/Rare/releases), AppImages are available for Linux, a .msi file for windows and a .dmg
|
||||
file for macOS.
|
||||
|
||||
### Latest development version
|
||||
|
||||
In the [actions](https://github.com/RareDevs/Rare/actions) tab you can find packages for the latest commits.
|
||||
|
||||
**Note**: They might be unstable and likely broken.
|
||||
|
||||
|
||||
### Installation via pip (platform independent)
|
||||
|
||||
Execute `pip install Rare` for all users, or `pip install Rare --user` for the current user only.
|
||||
|
||||
- Linux, macOS and FreeBSD: execute `rare` in your terminal.
|
||||
- Windows: execute `pythonw -m rare` in cmd
|
||||
|
||||
It is possible to create a desktop link, or a start menu link. Execute the command above with `--desktop-shortcut` or `--startmenu-shortcut` option, alternatively you can create them in the settings.
|
||||
|
||||
**Note about $PATH**:
|
||||
Depending on your operating system and the `python` distribution, the following paths might need to be in your environment's `PATH`
|
||||
|
||||
| OS | Path |
|
||||
|---------|--------------------------------------------|
|
||||
| Windows | `<python_installation_folder>\Scripts` |
|
||||
| Linux | `/home/<username>/.local/bin` |
|
||||
| masOS | `/Users/<username>/Library/Python/3.x/bin` |
|
||||
|
||||
|
||||
### Run from source
|
||||
|
||||
1. Clone the repo: `git clone https://github.com/RareDevs/Rare`
|
||||
2. Change your working directory to the project folder: `cd Rare`
|
||||
3. Run `pip install -r requirements.txt` to install all required dependencies.
|
||||
* If you want to be able to use the automatic login and Discord pypresence, run
|
||||
```shell
|
||||
pip install -r requirements-full.txt
|
||||
```
|
||||
* If you are on Arch you can run
|
||||
```shell
|
||||
sudo pacman --needed -S python-wheel python-setuptools python-pyqt5 python-qtawesome python-requests python-orjson
|
||||
```
|
||||
```
|
||||
yay -S legendary
|
||||
```
|
||||
* If you are on FreeBSD you have to install py39-qt5 from the packages
|
||||
```shell
|
||||
sudo pkg install py39-qt5
|
||||
```
|
||||
4. Run `python3 -m rare`
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
There are several options to contribute.
|
||||
|
||||
- If you know Python and PyQt, you can implement new features (Some ideas are in the projects tab).
|
||||
- You can translate the application in your language: Check our [transifex](https://www.transifex.com/rare-1/rare) page for that.
|
||||
|
||||
More information is available in CONTRIBUTING.md.
|
||||
|
||||
## Screenshots
|
||||
| Game covers | Vertical list |
|
||||
|----------------------------------------------|----------------------------------------------|
|
||||
| ![alt text](Screenshots/RareLibraryIcon.png) | ![alt text](Screenshots/RareLibraryList.png) |
|
||||
|
||||
| Game details | Game settings |
|
||||
|-------------------------------------------|-----------------------------------------------|
|
||||
| ![alt text](Screenshots/RareGameInfo.png) | ![alt text](Screenshots/RareGameSettings.png) |
|
||||
|
||||
| Downloads | Application settings |
|
||||
|--------------------------------------------|-------------------------------------------|
|
||||
| ![alt text](Screenshots/RareDownloads.png) | ![alt text](Screenshots/RareSettings.png) |
|
||||
|
||||
|
|
122
Rare/Dialogs.py
122
Rare/Dialogs.py
|
@ -1,122 +0,0 @@
|
|||
import os
|
||||
|
||||
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QVBoxLayout, QPushButton, QLineEdit, QLabel
|
||||
|
||||
|
||||
class InstallDialog(QDialog):
|
||||
def __init__(self, game):
|
||||
super(InstallDialog, self).__init__()
|
||||
self.setWindowTitle("Install Game")
|
||||
self.layout = QVBoxLayout()
|
||||
self.yes = False
|
||||
self.install_path = QLineEdit(f"{os.path.expanduser('~')}/legendary")
|
||||
self.options = QLabel("Verschiedene Optionene")
|
||||
self.layout.addWidget(self.options)
|
||||
|
||||
self.layout.addStretch(1)
|
||||
self.yes_button = QPushButton("Install")
|
||||
self.yes_button.clicked.connect(self.close)
|
||||
self.cancel_button = QPushButton("cancel")
|
||||
|
||||
self.layout.addWidget(self.options)
|
||||
self.layout.addWidget(self.install_path)
|
||||
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.button_layout.addWidget(self.yes_button)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
self.yes_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.cancel)
|
||||
self.layout.addLayout(self.button_layout)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_data(self) -> dict:
|
||||
self.exec_()
|
||||
return {
|
||||
"install_path": self.install_path.text()
|
||||
} if self.yes else 0
|
||||
|
||||
def accept(self):
|
||||
self.yes = True
|
||||
self.close()
|
||||
|
||||
def cancel(self):
|
||||
self.yes = False
|
||||
self.close()
|
||||
|
||||
|
||||
class GameSettingsDialog(QDialog):
|
||||
action: str = None
|
||||
|
||||
def __init__(self, game, parent):
|
||||
super(GameSettingsDialog, self).__init__(parent=parent)
|
||||
self.game = game
|
||||
self.layout = QVBoxLayout()
|
||||
self.layout.addWidget(QLabel("Einstellungen"))
|
||||
|
||||
self.wine_prefix_text = QLabel("Wine prefix")
|
||||
self.wine_prefix = QLineEdit(f"{os.path.expanduser('~')}/.wine")
|
||||
self.wine_prefix.setPlaceholderText("Wineprefix")
|
||||
self.uninstall_button = QPushButton("Uninstall Game")
|
||||
self.uninstall_button.clicked.connect(self.uninstall)
|
||||
self.save_button = QPushButton("Save settings")
|
||||
self.exit_button = QPushButton("Exit withot save")
|
||||
self.exit_button.clicked.connect(self.close)
|
||||
self.save_button.clicked.connect(self.exit_settings)
|
||||
|
||||
self.layout.addWidget(self.wine_prefix_text)
|
||||
self.layout.addWidget(self.wine_prefix)
|
||||
self.layout.addWidget(self.uninstall_button)
|
||||
self.layout.addWidget(self.save_button)
|
||||
self.layout.addWidget(self.exit_button)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_settings(self):
|
||||
self.exec_()
|
||||
return self.action
|
||||
|
||||
def uninstall(self):
|
||||
dia = AcceptDialog(f"Do you really want to delete {self.game.title}")
|
||||
if dia.get_accept():
|
||||
self.action = "uninstall"
|
||||
else:
|
||||
self.action = "nothing"
|
||||
self.close()
|
||||
|
||||
def exit_settings(self):
|
||||
self.action = self.wine_prefix.text()
|
||||
self.close()
|
||||
|
||||
|
||||
class AcceptDialog(QDialog):
|
||||
def __init__(self, text: str):
|
||||
super(AcceptDialog, self).__init__()
|
||||
self.accept_status = False
|
||||
self.text = QLabel(text)
|
||||
self.accept_button = QPushButton("Yes")
|
||||
self.accept_button.clicked.connect(self.accept)
|
||||
self.exit_button = QPushButton("Cancel")
|
||||
self.exit_button.clicked.connect(self.cancel)
|
||||
self.layout = QVBoxLayout()
|
||||
self.child_layout = QHBoxLayout()
|
||||
|
||||
self.layout.addWidget(self.text)
|
||||
self.child_layout.addStretch(1)
|
||||
self.child_layout.addWidget(self.accept_button)
|
||||
self.child_layout.addWidget(self.exit_button)
|
||||
|
||||
self.layout.addStretch(1)
|
||||
self.layout.addLayout(self.child_layout)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_accept(self):
|
||||
self.exec_()
|
||||
return self.accept_status
|
||||
|
||||
def accept(self):
|
||||
self.accept_status = True
|
||||
self.close()
|
||||
|
||||
def cancel(self):
|
||||
self.accept_status = False
|
||||
self.close()
|
|
@ -1,184 +0,0 @@
|
|||
import os
|
||||
import subprocess
|
||||
from logging import getLogger
|
||||
from legendary.models.game import InstalledGame
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, QProcess
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import QWidget, QLabel, QHBoxLayout, QVBoxLayout, QPushButton, QStyle
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
from Rare.Dialogs import InstallDialog, GameSettingsDialog
|
||||
from Rare.utils import legendaryUtils
|
||||
|
||||
logger = getLogger("Game")
|
||||
|
||||
|
||||
class Thread(QThread):
|
||||
signal = pyqtSignal()
|
||||
|
||||
def __init__(self, proc):
|
||||
super(Thread, self).__init__()
|
||||
self.proc: subprocess.Popen = proc
|
||||
|
||||
def run(self):
|
||||
self.sleep(3)
|
||||
logger.info("Running ")
|
||||
while True:
|
||||
if not self.proc.poll():
|
||||
self.sleep(3)
|
||||
else:
|
||||
self.signal.emit()
|
||||
self.quit()
|
||||
logger.info("Kill")
|
||||
break
|
||||
|
||||
|
||||
class GameWidget(QWidget):
|
||||
proc: QProcess
|
||||
signal = pyqtSignal(str)
|
||||
# TODO Repair
|
||||
def __init__(self, game: InstalledGame, core: LegendaryCore):
|
||||
super(GameWidget, self).__init__()
|
||||
self.core = core
|
||||
self.game = game
|
||||
self.dev = core.get_game(self.game.app_name).metadata["developer"]
|
||||
self.title = game.title
|
||||
self.app_name = game.app_name
|
||||
self.version = game.version
|
||||
self.size = game.install_size
|
||||
self.launch_params = game.launch_parameters
|
||||
# self.dev =
|
||||
self.game_running = False
|
||||
self.layout = QHBoxLayout()
|
||||
if os.path.exists(f"../images/{game.app_name}/FinalArt.png"):
|
||||
pixmap = QPixmap(f"../images/{game.app_name}/FinalArt.png")
|
||||
elif os.path.exists(f"../images/{game.app_name}/DieselGameBoxTall.png"):
|
||||
pixmap = QPixmap(f"../images/{game.app_name}/DieselGameBoxTall.png")
|
||||
elif os.path.exists(f"../images/{game.app_name}/DieselGameBoxLogo.png"):
|
||||
pixmap = QPixmap(f"../images/{game.app_name}/DieselGameBoxLogo.png")
|
||||
else:
|
||||
logger.warning("No Image found")
|
||||
pixmap = pixmap.scaled(180, 240)
|
||||
self.image = QLabel()
|
||||
self.image.setPixmap(pixmap)
|
||||
self.layout.addWidget(self.image)
|
||||
|
||||
##Layout on the right
|
||||
self.childLayout = QVBoxLayout()
|
||||
|
||||
play_icon = self.style().standardIcon(getattr(QStyle, 'SP_MediaPlay'))
|
||||
settings_icon = self.style().standardIcon(getattr(QStyle, 'SP_DirIcon'))
|
||||
self.title_widget = QLabel(f"<h1>{self.title}</h1>")
|
||||
self.launch_button = QPushButton(play_icon, "Launch")
|
||||
self.launch_button.clicked.connect(self.launch)
|
||||
self.wine_rating = QLabel("Wine Rating: " + self.get_rating())
|
||||
self.developer_label = QLabel("Dev: "+ self.dev)
|
||||
self.version_label = QLabel("Version: " + str(self.version))
|
||||
self.size_label = QLabel(f"Installed size: {round(self.size / (1024 ** 3), 2)} GB")
|
||||
self.settings_button = QPushButton(settings_icon, " Settings (Icon TODO)")
|
||||
self.settings_button.clicked.connect(self.settings)
|
||||
|
||||
self.childLayout.addWidget(self.title_widget)
|
||||
self.childLayout.addWidget(self.launch_button)
|
||||
self.childLayout.addWidget(self.developer_label)
|
||||
self.childLayout.addWidget(self.wine_rating)
|
||||
self.childLayout.addWidget(self.version_label)
|
||||
self.childLayout.addWidget(self.size_label)
|
||||
self.childLayout.addWidget(self.settings_button)
|
||||
|
||||
self.childLayout.addStretch(1)
|
||||
self.layout.addLayout(self.childLayout)
|
||||
self.layout.addStretch(1)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def launch(self):
|
||||
print("Launch")
|
||||
if not self.game_running:
|
||||
logger.info(f"launching {self.title}")
|
||||
self.proc = legendaryUtils.launch_game(self.app_name, self.core)
|
||||
if not self.proc:
|
||||
print("Fail")
|
||||
return
|
||||
|
||||
self.proc.finished.connect(self.finished)
|
||||
self.launch_button.setText("Kill")
|
||||
self.game_running = True
|
||||
|
||||
else:
|
||||
self.kill()
|
||||
|
||||
def finished(self, exit_code):
|
||||
self.launch_button.setText("Launch")
|
||||
logger.info(f"Game {self.title} finished with exit code {exit_code}")
|
||||
self.game_running = False
|
||||
|
||||
def kill(self):
|
||||
self.proc.terminate()
|
||||
self.launch_button.setText("Launch")
|
||||
self.game_running = False
|
||||
logger.info("Killing Game")
|
||||
|
||||
def get_rating(self) -> str:
|
||||
return "gold" # TODO
|
||||
|
||||
def settings(self):
|
||||
settings_dialog = GameSettingsDialog(self.game, self)
|
||||
action = settings_dialog.get_settings()
|
||||
if action == "uninstall":
|
||||
legendaryUtils.uninstall(self.app_name, self.core)
|
||||
self.signal.emit(self.app_name)
|
||||
|
||||
|
||||
class UninstalledGameWidget(QWidget):
|
||||
def __init__(self, game):
|
||||
super(UninstalledGameWidget, self).__init__()
|
||||
self.title = game.app_title
|
||||
self.app_name = game.app_name
|
||||
self.version = game.app_version
|
||||
self.layout = QHBoxLayout()
|
||||
self.game = game
|
||||
|
||||
pixmap = QPixmap(f"../images/{game.app_name}/UninstalledArt.png")
|
||||
pixmap = pixmap.scaled(120, 160)
|
||||
self.image = QLabel()
|
||||
self.image.setPixmap(pixmap)
|
||||
|
||||
self.child_layout = QVBoxLayout()
|
||||
|
||||
self.title_label = QLabel(f"<h2>{self.title}</h2>")
|
||||
self.app_name_label = QLabel(f"App Name: {self.app_name}")
|
||||
self.version_label = QLabel(f"Version: {self.version}")
|
||||
self.install_button = QPushButton("Install")
|
||||
self.install_button.clicked.connect(self.install)
|
||||
|
||||
self.child_layout.addWidget(self.title_label)
|
||||
self.child_layout.addWidget(self.app_name_label)
|
||||
self.child_layout.addWidget(self.version_label)
|
||||
self.child_layout.addWidget(self.install_button)
|
||||
self.child_layout.addStretch(1)
|
||||
self.layout.addWidget(self.image)
|
||||
self.layout.addLayout(self.child_layout)
|
||||
|
||||
self.layout.addStretch(1)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def install(self):
|
||||
logger.info("install " + self.title)
|
||||
dia = InstallDialog(self.game)
|
||||
data = dia.get_data()
|
||||
print(data)
|
||||
if data != 0:
|
||||
path = data.get("install_path")
|
||||
logger.info(f"install {self.app_name} in path {path}")
|
||||
# TODO
|
||||
self.proc = QProcess()
|
||||
self.proc.finished.connect(self.download_finished)
|
||||
self.proc.start("legendary", ["-y", f"--base-path {path}", self.app_name])
|
||||
|
||||
# legendaryUtils.install(self.app_name, path=path)
|
||||
else:
|
||||
logger.info("Download canceled")
|
||||
|
||||
def download_finished(self):
|
||||
self.setVisible(False)
|
||||
logger.info("Download finished")
|
|
@ -1,49 +0,0 @@
|
|||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
from PyQt5.QtWidgets import QDialog, QLabel, QProgressBar, QVBoxLayout
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
from Rare.utils.RareUtils import download_images
|
||||
|
||||
|
||||
class LaunchThread(QThread):
|
||||
download_progess = pyqtSignal(int)
|
||||
action = pyqtSignal(str)
|
||||
|
||||
def __init__(self, core: LegendaryCore, parent=None):
|
||||
super(LaunchThread, self).__init__(parent)
|
||||
self.core = core
|
||||
|
||||
def run(self):
|
||||
self.action.emit("Login")
|
||||
self.action.emit("Downloading Images")
|
||||
download_images(self.download_progess, self.core)
|
||||
self.action.emit("finish")
|
||||
|
||||
|
||||
class LaunchDialog(QDialog):
|
||||
def __init__(self, core: LegendaryCore):
|
||||
super(LaunchDialog, self).__init__()
|
||||
|
||||
self.title = QLabel("<h3>Launching Rare</h3>")
|
||||
self.thread = LaunchThread(core, self)
|
||||
self.thread.download_progess.connect(self.update_pb)
|
||||
self.thread.action.connect(self.info)
|
||||
self.info_pb = QProgressBar()
|
||||
self.info_pb.setMaximum(len(core.get_game_list()))
|
||||
self.info_text = QLabel("Logging in")
|
||||
self.layout = QVBoxLayout()
|
||||
|
||||
self.layout.addWidget(self.title)
|
||||
self.layout.addWidget(self.info_pb)
|
||||
self.layout.addWidget(self.info_text)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
self.thread.start()
|
||||
|
||||
def update_pb(self, i: int):
|
||||
self.info_pb.setValue(i)
|
||||
|
||||
def info(self, text: str):
|
||||
if text == "finish":
|
||||
self.close()
|
||||
self.info_text.setText(text)
|
195
Rare/Login.py
195
Rare/Login.py
|
@ -1,195 +0,0 @@
|
|||
import os
|
||||
from getpass import getuser
|
||||
from json import loads
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import QUrl, pyqtSignal
|
||||
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage
|
||||
from PyQt5.QtWidgets import QDialog, QWidget, QVBoxLayout, QLabel, QPushButton, QStackedLayout, QLineEdit, QButtonGroup, \
|
||||
QRadioButton
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
logger = getLogger("LoginWindow")
|
||||
|
||||
|
||||
class LoginBrowser(QWebEngineView):
|
||||
def __init__(self):
|
||||
super(LoginBrowser, self).__init__()
|
||||
self.browser_profile = QWebEngineProfile("storage", self)
|
||||
self.webpage = QWebEnginePage(self.browser_profile, self)
|
||||
self.setPage(self.webpage)
|
||||
|
||||
def createWindow(self, webengine_window_type):
|
||||
return self
|
||||
|
||||
|
||||
class ImportWidget(QWidget):
|
||||
signal = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, core: LegendaryCore):
|
||||
|
||||
super(ImportWidget, self).__init__()
|
||||
self.wine_paths = []
|
||||
self.layout = QVBoxLayout()
|
||||
self.core = core
|
||||
|
||||
self.import_text = QLabel("<h2>Import from existing Epic Games Launcher installation</h2>\nYou will get "
|
||||
"logged out there")
|
||||
self.import_accept_button = QPushButton("Import")
|
||||
self.import_accept_button.clicked.connect(self.auth)
|
||||
appdata_paths = self.get_appdata_path()
|
||||
|
||||
self.layout.addWidget(self.import_text)
|
||||
|
||||
if len(appdata_paths) == 0:
|
||||
self.path = QLineEdit()
|
||||
self.path.setPlaceholderText("Path to Wineprefix (Not implemented)")
|
||||
self.layout.addWidget(self.path)
|
||||
else:
|
||||
self.btn_group = QButtonGroup()
|
||||
for i in appdata_paths:
|
||||
radio_button = QRadioButton(i)
|
||||
self.btn_group.addButton(radio_button)
|
||||
self.layout.addWidget(radio_button)
|
||||
|
||||
# for i in appdata_paths:
|
||||
|
||||
self.appdata_path_text = QLabel(f"Appdata path: {self.core.egl.appdata_path}")
|
||||
|
||||
self.login_text = QLabel("")
|
||||
# self.layout.addWidget(self.btn_group)
|
||||
self.layout.addWidget(self.login_text)
|
||||
self.layout.addStretch(1)
|
||||
self.layout.addWidget(self.import_accept_button)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_appdata_path(self) -> list:
|
||||
if self.core.egl.appdata_path:
|
||||
return [self.core.egl.appdata_path]
|
||||
|
||||
else: # Linux
|
||||
wine_paths = []
|
||||
possible_wine_paths = [os.path.expanduser('~/Games/epic-games-store/'),
|
||||
os.path.expanduser('~/.wine/')]
|
||||
|
||||
for i in possible_wine_paths:
|
||||
if os.path.exists(i):
|
||||
if os.path.exists(os.path.join(i, "drive_c/users", getuser(),
|
||||
'Local Settings/Application Data/EpicGamesLauncher',
|
||||
'Saved/Config/Windows')):
|
||||
wine_paths.append(i)
|
||||
|
||||
if len(wine_paths) > 0:
|
||||
appdata_dirs = [
|
||||
os.path.join(i, "drive_c/users", getuser(), 'Local Settings/Application Data/EpicGamesLauncher',
|
||||
'Saved/Config/Windows') for i in wine_paths]
|
||||
return appdata_dirs
|
||||
return []
|
||||
|
||||
def auth(self):
|
||||
self.import_accept_button.setDisabled(True)
|
||||
if not self.btn_group:
|
||||
self.core.egl.appdata_path = self.path.text()
|
||||
|
||||
for i in self.btn_group.buttons():
|
||||
|
||||
if i.isChecked():
|
||||
self.core.egl.appdata_path = i.text()
|
||||
try:
|
||||
if self.core.auth_import():
|
||||
logger.info(f"Logged in as {self.core.lgd.userdata['displayName']}")
|
||||
self.signal.emit(True)
|
||||
return
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
self.import_accept_button.setDisabled(False)
|
||||
logger.warning("Error: No valid session found")
|
||||
self.login_text.setText("Error: No valid session found")
|
||||
|
||||
|
||||
class LoginWindow(QDialog):
|
||||
def __init__(self, core: LegendaryCore):
|
||||
super(LoginWindow, self).__init__()
|
||||
self.core = core
|
||||
self.success_code = False
|
||||
self.widget = QWidget()
|
||||
self.setGeometry(0, 0, 200, 300)
|
||||
self.welcome_layout = QVBoxLayout()
|
||||
self.title = QLabel(
|
||||
"<h2>Welcome to Rare the graphical interface for Legendary, an open source Epic Games alternative.</h2>\n<h3>Select one Option to Login</h3>")
|
||||
self.browser_btn = QPushButton("Use browser to login")
|
||||
self.browser_btn.clicked.connect(self.browser_login)
|
||||
self.import_btn = QPushButton("Import from existing Epic Games installation")
|
||||
|
||||
self.import_btn.clicked.connect(self.import_login)
|
||||
self.text = QLabel("")
|
||||
self.exit_btn = QPushButton("Exit App")
|
||||
self.exit_btn.clicked.connect(self.exit_login)
|
||||
|
||||
self.welcome_layout.addWidget(self.title)
|
||||
self.welcome_layout.addWidget(self.browser_btn)
|
||||
self.welcome_layout.addWidget(self.import_btn)
|
||||
self.welcome_layout.addWidget(self.text)
|
||||
self.welcome_layout.addWidget(self.exit_btn)
|
||||
self.widget.setLayout(self.welcome_layout)
|
||||
|
||||
self.browser = LoginBrowser()
|
||||
self.browser.loadFinished.connect(self.check_for_sid_page)
|
||||
|
||||
self.import_widget = ImportWidget(self.core)
|
||||
self.import_widget.signal.connect(self.import_resp)
|
||||
self.layout = QStackedLayout()
|
||||
self.layout.addWidget(self.widget)
|
||||
self.layout.addWidget(self.browser)
|
||||
self.layout.addWidget(self.import_widget)
|
||||
self.setLayout(self.layout)
|
||||
self.show()
|
||||
|
||||
def import_resp(self, b: bool):
|
||||
if b:
|
||||
self.success()
|
||||
else:
|
||||
self.layout.setCurrentIndex(0)
|
||||
self.text.setText("<h4 style='color: red'>No valid session found</h4>")
|
||||
|
||||
def login(self):
|
||||
self.exec_()
|
||||
return self.success_code
|
||||
|
||||
def success(self):
|
||||
self.success_code = True
|
||||
self.close()
|
||||
|
||||
def retry(self):
|
||||
self.__init__(self.core)
|
||||
|
||||
def exit_login(self):
|
||||
self.code = 1
|
||||
self.close()
|
||||
|
||||
def browser_login(self):
|
||||
self.setGeometry(0, 0, 800, 600)
|
||||
self.browser.load(QUrl(
|
||||
'https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect'))
|
||||
self.layout.setCurrentIndex(1)
|
||||
|
||||
def import_login(self):
|
||||
self.layout.setCurrentIndex(2)
|
||||
|
||||
def check_for_sid_page(self):
|
||||
if self.browser.url() == QUrl("https://www.epicgames.com/id/api/redirect"):
|
||||
self.browser.page().toPlainText(self.browser_auth)
|
||||
|
||||
def browser_auth(self, json):
|
||||
token = self.core.auth_sid(loads(json)["sid"])
|
||||
if self.core.auth_code(token):
|
||||
logger.info(f"Successfully logged in as {self.core.lgd.userdata['displayName']}")
|
||||
self.success()
|
||||
else:
|
||||
self.layout.setCurrentIndex(0)
|
||||
logger.warning("Login failed")
|
||||
self.browser.close()
|
||||
self.text.setText("Login Failed")
|
43
Rare/Main.py
43
Rare/Main.py
|
@ -1,43 +0,0 @@
|
|||
import logging
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
from Rare.Launch import LaunchDialog
|
||||
from Rare.Login import LoginWindow
|
||||
from Rare.MainWindow import MainWindow
|
||||
|
||||
logging.basicConfig(
|
||||
format='[%(name)s] %(levelname)s: %(message)s',
|
||||
level=logging.WARNING
|
||||
)
|
||||
logger = logging.getLogger("Rare")
|
||||
core = LegendaryCore()
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
logger.info("Try if you are logged in")
|
||||
try:
|
||||
if core.login():
|
||||
|
||||
logger.info("You are logged in")
|
||||
else:
|
||||
logger.error("Login Failed")
|
||||
main()
|
||||
|
||||
except ValueError:
|
||||
logger.info("You ar not logged in. Open Login Window")
|
||||
login_window = LoginWindow(core)
|
||||
if not login_window.login():
|
||||
return
|
||||
launch_dialog = LaunchDialog(core)
|
||||
launch_dialog.exec_()
|
||||
mainwindow = MainWindow(core)
|
||||
app.exec_()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,34 +0,0 @@
|
|||
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QWidget
|
||||
|
||||
from Rare.TabWidgets import GameListInstalled, GameListUninstalled, UpdateList, BrowserTab, Settings
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self, core):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Rare - GUI for legendary-gl")
|
||||
self.setGeometry(0, 0, 1200, 900)
|
||||
self.setCentralWidget(TabWidget(self, core))
|
||||
self.show()
|
||||
|
||||
|
||||
class TabWidget(QTabWidget):
|
||||
|
||||
def __init__(self, parent, core):
|
||||
super(QWidget, self).__init__(parent)
|
||||
|
||||
self.game_list = GameListInstalled(self, core)
|
||||
self.addTab(self.game_list, "Games")
|
||||
|
||||
self.uninstalled_games = GameListUninstalled(self, core)
|
||||
self.addTab(self.uninstalled_games, "Install Games")
|
||||
|
||||
self.update_tab = UpdateList(self, core)
|
||||
self.addTab(self.update_tab, "Updates")
|
||||
|
||||
self.browser = BrowserTab(self)
|
||||
self.addTab(self.browser, "Store")
|
||||
|
||||
self.settings = Settings(self)
|
||||
self.addTab(self.settings, "Settings")
|
|
@ -1,301 +0,0 @@
|
|||
import os
|
||||
import signal
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5 import QtCore
|
||||
from PyQt5.QtCore import QUrl, Qt, QProcess
|
||||
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea, QLineEdit, QPushButton, QFormLayout, QGroupBox, \
|
||||
QComboBox, QHBoxLayout, QTableWidget, QTableWidgetItem
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
from Rare.GameWidget import GameWidget, UninstalledGameWidget
|
||||
from Rare.utils import legendaryConfig, RareConfig
|
||||
from Rare.utils.legendaryUtils import logout, get_updates, get_name, update
|
||||
|
||||
logger = getLogger("TabWidgets")
|
||||
|
||||
|
||||
class BrowserTab(QWebEngineView):
|
||||
def __init__(self, parent):
|
||||
super(BrowserTab, self).__init__(parent=parent)
|
||||
self.profile = QWebEngineProfile("storage", self)
|
||||
self.webpage = QWebEnginePage(self.profile, self)
|
||||
self.setPage(self.webpage)
|
||||
self.load(QUrl("https://www.epicgames.com/store/"))
|
||||
self.show()
|
||||
|
||||
def createWindow(self, QWebEnginePage_WebWindowType):
|
||||
return self
|
||||
|
||||
|
||||
class Settings(QScrollArea):
|
||||
def __init__(self, parent):
|
||||
super(Settings, self).__init__(parent=parent)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
# Settings
|
||||
self.layout = QVBoxLayout()
|
||||
self.layout.addWidget(QLabel("<h1>Rare Settings</h1>"))
|
||||
self.logged_in_as = QLabel(f"Logged in as {get_name()}")
|
||||
self.get_legendary_config()
|
||||
self.gen_form_legendary()
|
||||
self.gen_form_rare()
|
||||
|
||||
if RareConfig.get_config()["Rare"].get("theme") == "dark":
|
||||
self.parent().parent().setStyleSheet(open("../Styles/style.qss").read())
|
||||
self.style_combo_box.setCurrentIndex(1)
|
||||
|
||||
self.logout_button = QPushButton("Logout")
|
||||
self.logout_button.clicked.connect(self.logout)
|
||||
self.layout.addWidget(self.logged_in_as)
|
||||
self.layout.addWidget(self.rare_form_group_box)
|
||||
self.update_rare_button = QPushButton("Update Rare Settings")
|
||||
self.update_rare_button.clicked.connect(self.update_rare_settings)
|
||||
self.layout.addWidget(self.update_rare_button)
|
||||
|
||||
self.layout.addWidget(self.form_group_box)
|
||||
self.update_legendary_button = QPushButton("Update Legendary Settings")
|
||||
self.update_legendary_button.clicked.connect(self.update_legendary_settings)
|
||||
self.layout.addWidget(self.update_legendary_button)
|
||||
self.layout.addStretch(1)
|
||||
self.layout.addWidget(self.logout_button)
|
||||
|
||||
self.info_label = QLabel("<h2>Credits</h2>")
|
||||
self.infotext = QLabel("Developers : Dummerle, CommandMC\nLegendary Dev: Derrod\nLicence: GPL v.3")
|
||||
|
||||
self.layout.addWidget(self.info_label)
|
||||
self.layout.addWidget(self.infotext)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def update_rare_settings(self):
|
||||
logger.info("Update Rare settings")
|
||||
config = {"Rare": {}}
|
||||
if self.style_combo_box.currentIndex() == 1:
|
||||
self.parent().parent().parent().setStyleSheet(open("../Styles/style.qss").read())
|
||||
config["Rare"]["theme"] = "dark"
|
||||
else:
|
||||
self.parent().parent().parent().setStyleSheet("")
|
||||
config["Rare"]["theme"] = "light"
|
||||
|
||||
RareConfig.set_config(config)
|
||||
|
||||
def update_legendary_settings(self):
|
||||
print("Legendary update")
|
||||
self.config["Legendary"]["wine_executable"] = self.lgd_conf_wine_exec.text()
|
||||
self.config["Legendary"]["wine_prefix"] = self.lgd_conf_wine_prefix.text()
|
||||
self.config["Legendary"]["locale"] = self.lgd_conf_locale.text()
|
||||
# self.config["default.env"] = self.lgd_conf_env_vars.toPlainText()
|
||||
self.config["default.env"] = {}
|
||||
for row in range(self.table.rowCount()):
|
||||
self.config["default.env"][self.table.item(row, 0).text()] = self.table.item(row, 1).text()
|
||||
print(self.config["default.env"])
|
||||
legendaryConfig.set_config(self.config)
|
||||
|
||||
def logout(self):
|
||||
logout()
|
||||
exit(0)
|
||||
|
||||
def gen_form_legendary(self):
|
||||
# Default Config
|
||||
if not self.config.get("Legendary"):
|
||||
self.config["Legendary"] = {}
|
||||
if not self.config["Legendary"].get("wine_executable"):
|
||||
self.config["Legendary"]["wine_executable"] = "wine"
|
||||
if not self.config["Legendary"].get("wine_prefix"):
|
||||
self.config["Legendary"]["wine_prefix"] = f"{os.path.expanduser('~')}/.wine"
|
||||
if not self.config["Legendary"].get("locale"):
|
||||
self.config["Legendary"]["locale"] = "en-US"
|
||||
|
||||
env_vars = self.config.get("default.env")
|
||||
if env_vars:
|
||||
self.table = QTableWidget(len(env_vars), 2)
|
||||
for i, label in enumerate(env_vars):
|
||||
self.table.setItem(i, 0, QTableWidgetItem(label))
|
||||
self.table.setItem(i, 1, QTableWidgetItem(env_vars[label]))
|
||||
|
||||
else:
|
||||
self.table = QTableWidget(0, 2)
|
||||
|
||||
self.table.setHorizontalHeaderLabels(["Variable", "Value"])
|
||||
|
||||
self.form_group_box = QGroupBox("Legendary Defaults")
|
||||
self.form = QFormLayout()
|
||||
|
||||
self.lgd_conf_wine_prefix = QLineEdit(self.config["Legendary"]["wine_prefix"])
|
||||
self.lgd_conf_wine_exec = QLineEdit(self.config["Legendary"]["wine_executable"])
|
||||
self.lgd_conf_locale = QLineEdit(self.config["Legendary"]["locale"])
|
||||
|
||||
self.add_button = QPushButton("Add Environment Variable")
|
||||
self.delete_env_var = QPushButton("Delete selected Variable")
|
||||
self.delete_env_var.clicked.connect(self.delete_var)
|
||||
self.add_button.clicked.connect(self.add_variable)
|
||||
self.form.addRow(QLabel("Default Wineprefix"), self.lgd_conf_wine_prefix)
|
||||
self.form.addRow(QLabel("Wine executable"), self.lgd_conf_wine_exec)
|
||||
self.form.addRow(QLabel("Environment Variables"), self.table)
|
||||
self.form.addRow(QLabel("Add Variable"), self.add_button)
|
||||
self.form.addRow(QLabel("Delete Variable"), self.delete_env_var)
|
||||
self.form.addRow(QLabel("Locale"), self.lgd_conf_locale)
|
||||
|
||||
self.form_group_box.setLayout(self.form)
|
||||
|
||||
def add_variable(self):
|
||||
print("add row")
|
||||
self.table.insertRow(self.table.rowCount())
|
||||
self.table.setItem(self.table.rowCount(), 0, QTableWidgetItem(""))
|
||||
self.table.setItem(self.table.rowCount(), 1, QTableWidgetItem(""))
|
||||
|
||||
def delete_var(self):
|
||||
self.table.removeRow(self.table.currentRow())
|
||||
|
||||
def gen_form_rare(self):
|
||||
self.rare_form_group_box = QGroupBox("Rare Settings")
|
||||
self.rare_form = QFormLayout()
|
||||
self.style_combo_box = QComboBox()
|
||||
self.style_combo_box.addItems(["Light", "Dark"])
|
||||
self.rare_form.addRow(QLabel("Style"), self.style_combo_box)
|
||||
|
||||
self.rare_form_group_box.setLayout(self.rare_form)
|
||||
|
||||
def get_legendary_config(self):
|
||||
self.config = legendaryConfig.get_config()
|
||||
|
||||
|
||||
class GameListInstalled(QScrollArea):
|
||||
def __init__(self, parent, core: LegendaryCore):
|
||||
super(GameListInstalled, self).__init__(parent=parent)
|
||||
self.widget = QWidget()
|
||||
self.core = core
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
self.layout = QVBoxLayout()
|
||||
self.widgets = {}
|
||||
for i in sorted(core.get_installed_list(), key=lambda game: game.title):
|
||||
widget = GameWidget(i, core)
|
||||
widget.signal.connect(self.remove_game)
|
||||
self.widgets[i.app_name] = widget
|
||||
self.layout.addWidget(widget)
|
||||
self.widget.setLayout(self.layout)
|
||||
self.setWidget(self.widget)
|
||||
|
||||
# self.setLayout(self.layout)
|
||||
|
||||
def remove_game(self, app_name: str):
|
||||
logger.info(f"Uninstall {app_name}")
|
||||
self.widgets[app_name].setVisible(False)
|
||||
self.layout.removeWidget(self.widgets[app_name])
|
||||
self.widgets[app_name].deleteLater()
|
||||
self.widgets.pop(app_name)
|
||||
|
||||
|
||||
class GameListUninstalled(QScrollArea):
|
||||
def __init__(self, parent, core: LegendaryCore):
|
||||
super(GameListUninstalled, self).__init__(parent=parent)
|
||||
self.widget = QWidget()
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
|
||||
self.layout = QVBoxLayout()
|
||||
|
||||
self.filter = QLineEdit()
|
||||
self.filter.textChanged.connect(self.filter_games)
|
||||
self.filter.setPlaceholderText("Filter Games")
|
||||
self.layout.addWidget(self.filter)
|
||||
|
||||
self.widgets_uninstalled = []
|
||||
games = []
|
||||
installed = [i.app_name for i in core.get_installed_list()]
|
||||
for game in core.get_game_list():
|
||||
if not game.app_name in installed:
|
||||
games.append(game)
|
||||
games = sorted(games, key=lambda x: x.app_title)
|
||||
for game in games:
|
||||
game_widget = UninstalledGameWidget(game)
|
||||
self.layout.addWidget(game_widget)
|
||||
self.widgets_uninstalled.append(game_widget)
|
||||
|
||||
self.layout.addStretch(1)
|
||||
self.widget.setLayout(self.layout)
|
||||
self.setWidget(self.widget)
|
||||
|
||||
def filter_games(self):
|
||||
for i in self.widgets_uninstalled:
|
||||
if self.filter.text().lower() in i.game.app_title.lower() + i.game.app_name.lower():
|
||||
i.setVisible(True)
|
||||
else:
|
||||
i.setVisible(False)
|
||||
|
||||
|
||||
class UpdateList(QWidget):
|
||||
class UpdateWidget(QWidget):
|
||||
def __init__(self, game):
|
||||
super().__init__()
|
||||
self.updating = False
|
||||
self.game = game
|
||||
self.layout = QHBoxLayout()
|
||||
self.label = QLabel("Update available for " + self.game.title)
|
||||
self.button = QPushButton("Update")
|
||||
self.button.clicked.connect(self.update_game)
|
||||
|
||||
self.layout.addWidget(self.label)
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def update_game(self):
|
||||
if not self.updating:
|
||||
logger.info("Update " + self.game.title)
|
||||
self.proc = update(self.game.app_name)
|
||||
self.proc = QProcess()
|
||||
self.proc.setProcessChannelMode(QProcess.MergedChannels)
|
||||
self.proc.start("legendary", ["-y", "update", self.game.app_name])
|
||||
self.proc.started.connect(self.start)
|
||||
self.proc.finished.connect(self.finished)
|
||||
self.proc.readyRead.connect(self.dataReady)
|
||||
|
||||
|
||||
else:
|
||||
logger.info("Terminate process")
|
||||
self.proc.kill()
|
||||
self.button.setText("Update")
|
||||
self.updating = False
|
||||
|
||||
def start(self):
|
||||
self.button.setText("Cancel")
|
||||
self.updating = True
|
||||
|
||||
def finished(self, code):
|
||||
if code == 0:
|
||||
logger.info("Update finished")
|
||||
self.setVisible(False)
|
||||
else:
|
||||
logger.info("Update finished with exit code " + str(code))
|
||||
|
||||
def dataReady(self):
|
||||
bytes = self.process.readAllStandardOutput()
|
||||
byte_list = bytes.split('\n')
|
||||
data=[]
|
||||
for i in byte_list:
|
||||
data.append(byte_list)
|
||||
# TODO Daten verarbeiten
|
||||
print(data)
|
||||
|
||||
def __init__(self, parent, core: LegendaryCore):
|
||||
super(UpdateList, self).__init__(parent=parent)
|
||||
self.core = core
|
||||
self.layout = QVBoxLayout()
|
||||
|
||||
update_games = []
|
||||
for game in core.get_installed_list():
|
||||
update_games.append(game) if core.get_game(game.app_name).app_version != game.version else None
|
||||
if update_games:
|
||||
for game in get_updates():
|
||||
self.layout.addWidget(self.UpdateWidget(game))
|
||||
else:
|
||||
self.layout.addWidget(QLabel("No updates available"))
|
||||
self.layout.addStretch(1)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
# TODO Remove when finished
|
|
@ -1 +0,0 @@
|
|||
__version__ = "0.1"
|
|
@ -1,26 +0,0 @@
|
|||
import configparser
|
||||
import logging
|
||||
import os
|
||||
|
||||
config_path = os.path.expanduser("~") + "/.config/Rare"
|
||||
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(os.path.expanduser("~") + "/.config/Rare")
|
||||
|
||||
logger = logging.getLogger("Config")
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
os.mkdir(config_path)
|
||||
logger.info("Create Config dir")
|
||||
|
||||
if not cfg.sections():
|
||||
cfg["Rare"] = {
|
||||
"IMAGE_DIR": os.path.expanduser("~/.rare/images"),
|
||||
"LOGLEVEL": logging.INFO
|
||||
}
|
||||
|
||||
with open(config_path + '/Rare.ini', 'w') as configfile:
|
||||
cfg.write(configfile)
|
||||
|
||||
IMAGE_DIR = cfg["Rare"]["IMAGE_DIR"]
|
||||
LOGLEVEL = cfg["Rare"]["LOGLEVEL"]
|
|
@ -1,24 +0,0 @@
|
|||
import configparser
|
||||
import os
|
||||
|
||||
config_path = os.path.join(os.path.expanduser("~"), ".config/Rare/")
|
||||
rare_config = configparser.ConfigParser()
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
os.mkdir(config_path)
|
||||
rare_config["Rare"] = {
|
||||
"image_dir": "../",
|
||||
"theme": "light"
|
||||
}
|
||||
rare_config.write(open(config_path + "config.ini", "w"))
|
||||
else:
|
||||
rare_config.read(config_path + "config.ini")
|
||||
|
||||
|
||||
def get_config() -> {}:
|
||||
return rare_config.__dict__["_sections"]
|
||||
|
||||
|
||||
def set_config(new_config: {}):
|
||||
rare_config.__dict__["_sections"] = new_config
|
||||
rare_config.write(open(config_path + "config.ini", "w"))
|
|
@ -1,82 +0,0 @@
|
|||
import json
|
||||
import os
|
||||
from logging import getLogger
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
from Rare.config import IMAGE_DIR
|
||||
|
||||
logger = getLogger("Utils")
|
||||
|
||||
|
||||
def download_images(signal: pyqtSignal, core: LegendaryCore):
|
||||
if not os.path.isdir(IMAGE_DIR):
|
||||
os.makedirs(IMAGE_DIR)
|
||||
logger.info("Create Image dir")
|
||||
|
||||
# Download Images
|
||||
for i, game in enumerate(sorted(core.get_game_list(), key=lambda x: x.app_title)):
|
||||
# if game.app_name == "CrabEA":
|
||||
# print(game.metadata)
|
||||
|
||||
if not os.path.isdir(f"{IMAGE_DIR}/" + game.app_name):
|
||||
os.mkdir(f"{IMAGE_DIR}/" + game.app_name)
|
||||
|
||||
if not os.path.isfile(f"{IMAGE_DIR}/{game.app_name}/image.json"):
|
||||
json_data = {"DieselGameBoxTall": None, "DieselGameBoxLogo": None}
|
||||
else:
|
||||
json_data = json.load(open(f"{IMAGE_DIR}/{game.app_name}/image.json", "r"))
|
||||
|
||||
for image in game.metadata["keyImages"]:
|
||||
if image["type"] == "DieselGameBoxTall" or image["type"] == "DieselGameBoxLogo":
|
||||
|
||||
if json_data[image["type"]] != image["md5"] or not os.path.isfile(
|
||||
f"{IMAGE_DIR}/{game.app_name}/{image['type']}.png"):
|
||||
# Download
|
||||
json_data[image["type"]] = image["md5"]
|
||||
# os.remove(f"{IMAGE_DIR}/{game.app_name}/{image['type']}.png")
|
||||
json.dump(json_data, open(f"{IMAGE_DIR}/{game.app_name}/image.json", "w"))
|
||||
logger.info(f"Download Image for Game: {game.app_title}")
|
||||
url = image["url"]
|
||||
with open(f"{IMAGE_DIR}/{game.app_name}/{image['type']}.png", "wb") as f:
|
||||
f.write(requests.get(url).content)
|
||||
f.close()
|
||||
|
||||
if not os.path.isfile(f'{IMAGE_DIR}/' + game.app_name + '/UninstalledArt.png'):
|
||||
|
||||
if os.path.isfile(f'{IMAGE_DIR}/' + game.app_name + '/DieselGameBoxTall.png'):
|
||||
# finalArt = Image.open(f'{IMAGE_DIR}/' + game.app_name + '/DieselGameBoxTall.png')
|
||||
# finalArt.save(f'{IMAGE_DIR}/{game.app_name}/FinalArt.png')
|
||||
# And same with the grayscale one
|
||||
|
||||
bg = Image.open(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png")
|
||||
uninstalledArt = bg.convert('L')
|
||||
uninstalledArt.save(f'{IMAGE_DIR}/{game.app_name}/UninstalledArt.png')
|
||||
elif os.path.isfile(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png"):
|
||||
bg: Image.Image = Image.open(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png")
|
||||
bg = bg.resize((int(bg.size[1] * 3 / 4), bg.size[1]))
|
||||
logo = Image.open(f'{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png').convert('RGBA')
|
||||
wpercent = ((bg.size[0] * (3 / 4)) / float(logo.size[0]))
|
||||
hsize = int((float(logo.size[1]) * float(wpercent)))
|
||||
logo = logo.resize((int(bg.size[0] * (3 / 4)), hsize), Image.ANTIALIAS)
|
||||
# Calculate where the image has to be placed
|
||||
pasteX = int((bg.size[0] - logo.size[0]) / 2)
|
||||
pasteY = int((bg.size[1] - logo.size[1]) / 2)
|
||||
# And finally copy the background and paste in the image
|
||||
# finalArt = bg.copy()
|
||||
# finalArt.paste(logo, (pasteX, pasteY), logo)
|
||||
# Write out the file
|
||||
# finalArt.save(f'{IMAGE_DIR}/' + game.app_name + '/FinalArt.png')
|
||||
logoCopy = logo.copy()
|
||||
logoCopy.putalpha(int(256 * 3 / 4))
|
||||
logo.paste(logoCopy, logo)
|
||||
uninstalledArt = bg.copy()
|
||||
uninstalledArt.paste(logo, (pasteX, pasteY), logo)
|
||||
uninstalledArt = uninstalledArt.convert('L')
|
||||
uninstalledArt.save(f'{IMAGE_DIR}/' + game.app_name + '/UninstalledArt.png')
|
||||
else:
|
||||
logger.warning(f"File {IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png dowsn't exist")
|
||||
signal.emit(i)
|
|
@ -1,15 +0,0 @@
|
|||
import configparser
|
||||
import os
|
||||
|
||||
config_path = os.path.expanduser("~") + "/.config/legendary/"
|
||||
lgd_config = configparser.ConfigParser()
|
||||
lgd_config.read(config_path + "config.ini")
|
||||
|
||||
|
||||
def get_config() -> {}:
|
||||
return lgd_config.__dict__["_sections"]
|
||||
|
||||
|
||||
def set_config(new_config: {}):
|
||||
lgd_config.__dict__["_sections"] = new_config
|
||||
lgd_config.write(open(config_path + "config.ini", "w"))
|
|
@ -1,134 +0,0 @@
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
logger = logging.getLogger("LGD")
|
||||
core = LegendaryCore()
|
||||
|
||||
|
||||
def get_installed():
|
||||
return sorted(core.get_installed_list(), key=lambda name: name.title)
|
||||
|
||||
|
||||
def get_installed_names():
|
||||
return [i.app_name for i in core.get_installed_list()]
|
||||
|
||||
|
||||
def get_not_installed():
|
||||
games = []
|
||||
installed = get_installed_names()
|
||||
for game in get_games():
|
||||
if not game.app_name in installed:
|
||||
games.append(game)
|
||||
return games
|
||||
|
||||
|
||||
# return (games, dlcs)
|
||||
def get_games_and_dlcs():
|
||||
if not core.login():
|
||||
print("Login Failed")
|
||||
exit(1)
|
||||
return core.get_game_and_dlc_list()
|
||||
|
||||
|
||||
def get_game_by_name(name: str):
|
||||
return core.get_game(name)
|
||||
|
||||
|
||||
def get_games():
|
||||
if not core.login():
|
||||
print("Login Failed")
|
||||
return None
|
||||
|
||||
return sorted(core.get_game_list(), key=lambda x: x.app_title)
|
||||
|
||||
|
||||
def get_games_sorted():
|
||||
if not core.login():
|
||||
logging.error("No login")
|
||||
|
||||
|
||||
def launch_game(app_name: str, lgd_core: LegendaryCore, offline: bool = False, skip_version_check: bool = False,
|
||||
username_override=None,
|
||||
wine_bin: str = None, wine_prfix: str = None, language: str = None, wrapper=None,
|
||||
no_wine: bool = os.name == "nt", extra: [] = None):
|
||||
game = lgd_core.get_installed_game(app_name)
|
||||
if not game:
|
||||
print("Game not found")
|
||||
return None
|
||||
if game.is_dlc:
|
||||
print("Game is dlc")
|
||||
return None
|
||||
if not os.path.exists(game.install_path):
|
||||
print("Game doesn't exist")
|
||||
return None
|
||||
|
||||
if not offline:
|
||||
print("logging in")
|
||||
if not lgd_core.login():
|
||||
return None
|
||||
if not skip_version_check and not core.is_noupdate_game(app_name):
|
||||
# check updates
|
||||
try:
|
||||
latest = lgd_core.get_asset(app_name, update=True)
|
||||
except ValueError:
|
||||
print("Metadata doesn't exist")
|
||||
return None
|
||||
if latest.build_version != game.version:
|
||||
print("Please update game")
|
||||
return None
|
||||
params, cwd, env = lgd_core.get_launch_parameters(app_name=app_name, offline=offline,
|
||||
extra_args=extra, user=username_override,
|
||||
wine_bin=wine_bin, wine_pfx=wine_prfix,
|
||||
language=language, wrapper=wrapper,
|
||||
disable_wine=no_wine)
|
||||
process = QProcess()
|
||||
process.setWorkingDirectory(cwd)
|
||||
environment = QProcessEnvironment()
|
||||
for e in env:
|
||||
environment.insert(e, env[e])
|
||||
process.setProcessEnvironment(environment)
|
||||
|
||||
process.start(params[0], params[1:])
|
||||
return process
|
||||
|
||||
|
||||
def get_updates():
|
||||
update_games = []
|
||||
for game in core.get_installed_list():
|
||||
update_games.append(game) if get_game_by_name(game.app_name).app_version != game.version else None
|
||||
return update_games
|
||||
|
||||
|
||||
def logout():
|
||||
core.lgd.invalidate_userdata()
|
||||
|
||||
|
||||
def install(app_name: str, path: str = None):
|
||||
subprocess.Popen(f"legendary -y install {app_name}".split(" "))
|
||||
# TODO
|
||||
|
||||
|
||||
def login(sid):
|
||||
code = core.auth_sid(sid)
|
||||
if code != '':
|
||||
return core.auth_code(code)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def get_name():
|
||||
return core.lgd.userdata["displayName"]
|
||||
|
||||
|
||||
def uninstall(app_name: str, lgd_core):
|
||||
lgd_core.uninstall_game(core.get_installed_game(app_name))
|
||||
# logger.info("Uninstalling " + app_name)
|
||||
|
||||
|
||||
def update(app_name) -> subprocess.Popen:
|
||||
logger.info(f"Updating {app_name}")
|
||||
return subprocess.Popen(f"legendary -y update {app_name}".split())
|
BIN
Screenshots/RareDownloads.png
Normal file
BIN
Screenshots/RareDownloads.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 93 KiB |
BIN
Screenshots/RareGameInfo.png
Normal file
BIN
Screenshots/RareGameInfo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 91 KiB |
BIN
Screenshots/RareGameSettings.png
Normal file
BIN
Screenshots/RareGameSettings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
BIN
Screenshots/RareLibraryIcon.png
Normal file
BIN
Screenshots/RareLibraryIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
Screenshots/RareLibraryList.png
Normal file
BIN
Screenshots/RareLibraryList.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 358 KiB |
BIN
Screenshots/RareSettings.png
Normal file
BIN
Screenshots/RareSettings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
2175
Styles/style.qss
2175
Styles/style.qss
File diff suppressed because it is too large
Load diff
49
freeze.py
Normal file
49
freeze.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
from cx_Freeze import setup, Executable
|
||||
|
||||
from rare import __version__
|
||||
|
||||
name = 'Rare'
|
||||
author = 'Dummerle'
|
||||
description = 'A GUI for Legendary'
|
||||
|
||||
shortcut_table = [
|
||||
("DesktopShortcut", # Shortcut
|
||||
"DesktopFolder", # Directory_
|
||||
"Rare", # Name
|
||||
"TARGETDIR", # Component_
|
||||
"[TARGETDIR]Rare.exe", # Target
|
||||
None, # Arguments
|
||||
None, # Description
|
||||
None, # Hotkey
|
||||
None, # Icon
|
||||
None, # IconIndex
|
||||
None, # ShowCmd
|
||||
'TARGETDIR' # WkDir
|
||||
)
|
||||
]
|
||||
|
||||
msi_data = {"Shortcut": shortcut_table}
|
||||
bdist_msi_options = {
|
||||
'data': msi_data,
|
||||
# generated with str(uuid.uuid3(uuid.NAMESPACE_DNS, 'io.github.dummerle.rare')).upper()
|
||||
'upgrade_code': '{85D9FCC2-733E-3D74-8DD4-8FE33A07ADF8}'
|
||||
}
|
||||
base = "Win32GUI"
|
||||
|
||||
exe = Executable(
|
||||
"rare/main.py",
|
||||
base=base,
|
||||
icon="rare/resources/images/Rare.ico",
|
||||
target_name=name
|
||||
)
|
||||
|
||||
setup(
|
||||
name=name,
|
||||
version=__version__,
|
||||
author=author,
|
||||
description=description,
|
||||
options={
|
||||
"bdist_msi": bdist_msi_options,
|
||||
},
|
||||
executables=[exe]
|
||||
)
|
9
misc/generate_version_name.py
Normal file
9
misc/generate_version_name.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
import random
|
||||
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
core = LegendaryCore()
|
||||
core.login()
|
||||
|
||||
print(" ".join(map(lambda game: game.app_name,
|
||||
random.choices(list(filter(lambda x: len(x.app_name) != 32, core.get_game_list(False))), k=2))))
|
35
misc/nuitka_build.bat
Normal file
35
misc/nuitka_build.bat
Normal file
|
@ -0,0 +1,35 @@
|
|||
python -m nuitka ^
|
||||
--assume-yes-for-downloads ^
|
||||
--mingw64 ^
|
||||
--lto=no ^
|
||||
--jobs=2 ^
|
||||
--static-libpython=no ^
|
||||
--standalone ^
|
||||
--enable-plugin=anti-bloat ^
|
||||
--enable-plugin=pyqt5 ^
|
||||
--show-modules ^
|
||||
--show-anti-bloat-changes ^
|
||||
--follow-stdlib ^
|
||||
--follow-imports ^
|
||||
--nofollow-import-to="*.tests" ^
|
||||
--nofollow-import-to="*.distutils" ^
|
||||
--nofollow-import-to="distutils" ^
|
||||
--nofollow-import-to="unittest" ^
|
||||
--nofollow-import-to="pydoc" ^
|
||||
--nofollow-import-to="tkinter" ^
|
||||
--nofollow-import-to="test" ^
|
||||
--prefer-source-code ^
|
||||
--include-package=pypresence ^
|
||||
--include-package-data=qtawesome ^
|
||||
--include-data-dir=rare\resources\images=rare\resources\images ^
|
||||
--include-data-files=rare\resources\languages=rare\resources\languages="*.qm" ^
|
||||
--windows-icon-from-ico=rare\resources\images\Rare.ico ^
|
||||
--windows-company-name=Rare ^
|
||||
--windows-product-name=Rare ^
|
||||
--windows-file-description=rare.exe ^
|
||||
--windows-file-version=0.0.0.0 ^
|
||||
--windows-product-version=0.0.0.0 ^
|
||||
--enable-console ^
|
||||
rare
|
||||
copy rare.dist\libcrypto-1_1.dll rare.dist\libcrypto-1_1-x64.dll
|
||||
copy rare.dist\libssl-1_1.dll rare.dist\libssl-1_1-x64.dll
|
33
misc/nuitka_build.sh
Executable file
33
misc/nuitka_build.sh
Executable file
|
@ -0,0 +1,33 @@
|
|||
python -m nuitka \
|
||||
--assume-yes-for-downloads \
|
||||
--mingw64 \
|
||||
--lto=no \
|
||||
--jobs=2 \
|
||||
--static-libpython=no \
|
||||
--standalone \
|
||||
--enable-plugin=anti-bloat \
|
||||
--enable-plugin=pyqt5 \
|
||||
--show-modules \
|
||||
--show-anti-bloat-changes \
|
||||
--follow-stdlib \
|
||||
--follow-imports \
|
||||
--nofollow-import-to="*.tests" \
|
||||
--nofollow-import-to="*.distutils" \
|
||||
--nofollow-import-to="distutils" \
|
||||
--nofollow-import-to="unittest" \
|
||||
--nofollow-import-to="pydoc" \
|
||||
--nofollow-import-to="tkinter" \
|
||||
--nofollow-import-to="test" \
|
||||
--prefer-source-code \
|
||||
--include-package=pypresence \
|
||||
--include-package-data=qtawesome \
|
||||
--include-data-dir=rare/resources/images=rare/resources/images \
|
||||
--include-data-files=rare/resources/languages=rare/resources/languages="*.qm" \
|
||||
--windows-icon-from-ico=rare/resources/images/Rare.ico \
|
||||
--windows-company-name=Rare \
|
||||
--windows-product-name=Rare \
|
||||
--windows-file-description=rare.exe \
|
||||
--windows-file-version=0.0.0.0 \
|
||||
--windows-product-version=0.0.0.0 \
|
||||
--enable-console \
|
||||
rare
|
5
misc/pip_upgrade_venv.py
Normal file
5
misc/pip_upgrade_venv.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
import pkg_resources
|
||||
from subprocess import call
|
||||
|
||||
for dist in pkg_resources.working_set:
|
||||
call(f"python -m pip install --upgrade {dist.project_name}", shell=True)
|
11
misc/py2ts.sh
Executable file
11
misc/py2ts.sh
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
|
||||
cwd="$(pwd)"
|
||||
cd "$(dirname "$0")"/.. || exit
|
||||
|
||||
#py_files=$(find rare -iname "*.py" -not -path rare/ui)
|
||||
#ui_files=$(find rare/ui -iname "*.ui")
|
||||
|
||||
pylupdate5 -noobsolete $(find rare/ -iname "*.py") -ts rare/resources/languages/source.ts
|
||||
|
||||
cd "$cwd" || exit
|
8
misc/pylint.sh
Executable file
8
misc/pylint.sh
Executable file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
|
||||
cwd="$(pwd)"
|
||||
cd "$(dirname "$0")"/.. || exit
|
||||
|
||||
python -m pylint -E rare --jobs=1 --disable=E0611,E1123,E1120 --ignore=ui,singleton.py --extension-pkg-whitelist=PyQt5 --generated-members=PyQt5.*
|
||||
|
||||
cd "$cwd" || exit
|
48
misc/qrc2py.sh
Executable file
48
misc/qrc2py.sh
Executable file
|
@ -0,0 +1,48 @@
|
|||
#!/bin/bash
|
||||
|
||||
cwd="$(pwd)"
|
||||
cd "$(dirname "$0")"/../ || exit
|
||||
|
||||
resources=(
|
||||
"rare/resources/images/"
|
||||
"rare/resources/colors/"
|
||||
"rare/resources/resources.qrc"
|
||||
)
|
||||
|
||||
resources_changed=0
|
||||
|
||||
for r in "${resources[@]}"
|
||||
do
|
||||
if [[ $(git diff --name-only HEAD "$r") ]]
|
||||
then
|
||||
resources_changed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $resources_changed -eq 1 ]]
|
||||
then
|
||||
echo "Re-compiling main resources"
|
||||
pyrcc5 -compress 6 \
|
||||
rare/resources/resources.qrc \
|
||||
-o rare/resources/resources.py
|
||||
fi
|
||||
|
||||
if [[ $(git diff --name-only HEAD "rare/resources/stylesheets/RareStyle/") ]]
|
||||
then
|
||||
echo "Re-compiling RareStyle stylesheet resources"
|
||||
pyrcc5 -compress 6 \
|
||||
rare/resources/stylesheets/RareStyle/stylesheet.qrc \
|
||||
-o rare/resources/stylesheets/RareStyle/__init__.py
|
||||
fi
|
||||
|
||||
|
||||
if [[ $(git diff --name-only HEAD "rare/resources/stylesheets/ChildOfMetropolis/") ]]
|
||||
then
|
||||
echo "Re-compiling ChildOfMetropolis stylesheet resources"
|
||||
pyrcc5 -compress 6 \
|
||||
rare/resources/stylesheets/ChildOfMetropolis/stylesheet.qrc \
|
||||
-o rare/resources/stylesheets/ChildOfMetropolis/__init__.py
|
||||
fi
|
||||
|
||||
|
||||
cd "$cwd" || exit
|
10
misc/rare.desktop
Normal file
10
misc/rare.desktop
Normal file
|
@ -0,0 +1,10 @@
|
|||
[Desktop Entry]
|
||||
Name=Rare
|
||||
Type=Application
|
||||
Categories=Game;
|
||||
Icon=rare
|
||||
Exec=rare
|
||||
Comment=A GUI for legendary, an open source replacement for Epic Games Launcher
|
||||
Terminal=false
|
||||
StartupWMClass=rare
|
||||
Keywords=epic;games;launcher;legendary;
|
5
misc/ts2qm.py
Normal file
5
misc/ts2qm.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
import os
|
||||
|
||||
for f in os.listdir(os.path.join(os.path.dirname(__file__), "../rare/resources/languages/")):
|
||||
if f.endswith(".ts") and f != "translation_source.ts":
|
||||
os.system(f"lrelease {os.path.join(os.path.dirname(__file__), '../rare/resources/languages/', f)}")
|
29
misc/ui2py.sh
Executable file
29
misc/ui2py.sh
Executable file
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ -n "${1}" ]; then
|
||||
if [ ! -f "${1}" ]; then
|
||||
echo "${1} does not exist"
|
||||
exit 0
|
||||
fi
|
||||
echo "Generating python file for ${1}"
|
||||
pyuic5 "${1}" -x -o "${1%.ui}.py"
|
||||
sed '/QtCore.QMetaObject.connectSlotsByName/d' -i "${1%.ui}.py"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cwd="$(pwd)"
|
||||
cd "$(dirname "$0")"/.. || exit
|
||||
|
||||
changed="$(git diff --name-only HEAD | grep '\.ui')"
|
||||
|
||||
for ui in $changed; do
|
||||
if [ ! -f "$ui" ]; then
|
||||
echo "$ui does not exist. Skipping"
|
||||
continue
|
||||
fi
|
||||
echo "Generating python file for ${ui}"
|
||||
pyuic5 "${ui}" -x -o "${ui%.ui}.py"
|
||||
sed '/QtCore.QMetaObject.connectSlotsByName/d' -i "${ui%.ui}.py"
|
||||
done
|
||||
|
||||
cd "$cwd" || exit
|
643
pylintrc
Normal file
643
pylintrc
Normal file
|
@ -0,0 +1,643 @@
|
|||
[MAIN]
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
|
||||
# in a server-like mode.
|
||||
clear-cache-post-run=no
|
||||
|
||||
# Load and enable all available extensions. Use --list-extensions to see a list
|
||||
# all available extensions.
|
||||
#enable-all-extensions=
|
||||
|
||||
# In error mode, messages with a category besides ERROR or FATAL are
|
||||
# suppressed, and no reports are done by default. Error mode is compatible with
|
||||
# disabling specific errors.
|
||||
errors-only=yes
|
||||
|
||||
# Always return a 0 (non-error) status code, even if lint errors are found.
|
||||
# This is primarily useful in continuous integration scripts.
|
||||
#exit-zero=
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=PyQt5
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
|
||||
# Specify a score threshold under which the program will exit with error.
|
||||
fail-under=10
|
||||
|
||||
# Interpret the stdin as a python script, whose filename needs to be passed as
|
||||
# the module_or_package argument.
|
||||
#from-stdin=
|
||||
|
||||
# Files or directories to be skipped. They should be base names, not paths.
|
||||
ignore=ui,singleton.py
|
||||
|
||||
# Add files or directories matching the regular expressions patterns to the
|
||||
# ignore-list. The regex matches against paths and can be in Posix or Windows
|
||||
# format. Because '\\' represents the directory delimiter on Windows systems,
|
||||
# it can't be used as an escape character.
|
||||
ignore-paths=
|
||||
|
||||
# Files or directories matching the regular expression patterns are skipped.
|
||||
# The regex matches against base names, not paths. The default value ignores
|
||||
# Emacs file locks
|
||||
ignore-patterns=^\.#
|
||||
|
||||
# List of module names for which member attributes should not be checked and
|
||||
# will not be imported (useful for modules/projects where namespaces are
|
||||
# manipulated during runtime and thus existing member attributes cannot be
|
||||
# deduced by static analysis). It supports qualified module names, as well as
|
||||
# Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use, and will cap the count on Windows to
|
||||
# avoid hangs.
|
||||
jobs=3
|
||||
|
||||
# Control the amount of potential inferred values when inferring a single
|
||||
# object. This can help the performance when dealing with large functions or
|
||||
# complex, nested conditions.
|
||||
limit-inference-results=100
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.9
|
||||
|
||||
# Discover python modules and packages in the file system subtree.
|
||||
recursive=yes
|
||||
|
||||
# Add paths to the list of the source roots. Supports globbing patterns. The
|
||||
# source root is an absolute path or a path relative to the current working
|
||||
# directory used to determine a package namespace for modules located under the
|
||||
# source root.
|
||||
source-roots=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
# In verbose mode, extra non-checker-related info will be displayed.
|
||||
#verbose=
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names.
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style. If left empty, argument names will be checked with the set
|
||||
# naming style.
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names.
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style. If left empty, attribute names will be checked with the set naming
|
||||
# style.
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma.
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be refused
|
||||
bad-names-rgxs=
|
||||
|
||||
# Naming style matching correct class attribute names.
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style. If left empty, class attribute names will be checked
|
||||
# with the set naming style.
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class constant names.
|
||||
class-const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct class constant names. Overrides class-
|
||||
# const-naming-style. If left empty, class constant names will be checked with
|
||||
# the set naming style.
|
||||
#class-const-rgx=
|
||||
|
||||
# Naming style matching correct class names.
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-
|
||||
# style. If left empty, class names will be checked with the set naming style.
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names.
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style. If left empty, constant names will be checked with the set naming
|
||||
# style.
|
||||
#const-rgx=
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names.
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style. If left empty, function names will be checked with the set
|
||||
# naming style.
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be accepted
|
||||
good-names-rgxs=
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name.
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names.
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style. If left empty, inline iteration names will be checked
|
||||
# with the set naming style.
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names.
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style. If left empty, method names will be checked with the set naming style.
|
||||
#method-rgx=
|
||||
|
||||
# Naming style matching correct module names.
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style. If left empty, module names will be checked with the set naming style.
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
# These decorators are taken in consideration only for invalid-name.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Regular expression matching correct type alias names. If left empty, type
|
||||
# alias names will be checked with the set naming style.
|
||||
#typealias-rgx=
|
||||
|
||||
# Regular expression matching correct type variable names. If left empty, type
|
||||
# variable names will be checked with the set naming style.
|
||||
#typevar-rgx=
|
||||
|
||||
# Naming style matching correct variable names.
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style. If left empty, variable names will be checked with the set
|
||||
# naming style.
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# Warn about protected attribute access inside special methods
|
||||
check-protected-access-in-special-methods=no
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp,
|
||||
asyncSetUp,
|
||||
__post_init__
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# List of regular expressions of class ancestor names to ignore when counting
|
||||
# public methods (see R0903)
|
||||
exclude-too-few-public-methods=
|
||||
|
||||
# List of qualified class names to ignore when counting class parents (see
|
||||
# R0901)
|
||||
ignored-parents=
|
||||
|
||||
# Maximum number of arguments for function / method.
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body.
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body.
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body.
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body.
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when caught.
|
||||
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module.
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# List of modules that can be imported at any level, not just the top level
|
||||
# one.
|
||||
allow-any-import-level=
|
||||
|
||||
# Allow explicit reexports by alias from a package __init__.
|
||||
allow-reexport-from-package=no
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma.
|
||||
deprecated-modules=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of external dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
ext-import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of all (i.e. internal and
|
||||
# external) dependencies to the given file (report RP0402 must not be
|
||||
# disabled).
|
||||
import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of internal dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
# Couples of modules and preferred modules, separated by a comma.
|
||||
preferred-modules=
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# The type of string formatting that logging methods do. `old` means using %
|
||||
# formatting, `new` is for `{}` formatting.
|
||||
logging-format-style=old
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format.
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
|
||||
# UNDEFINED.
|
||||
confidence=HIGH,
|
||||
CONTROL_FLOW,
|
||||
INFERENCE,
|
||||
INFERENCE_FAILURE,
|
||||
UNDEFINED
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once). You can also use "--disable=all" to
|
||||
# disable everything first and then re-enable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
||||
# --disable=W".
|
||||
disable=raw-checker-failed,
|
||||
bad-inline-option,
|
||||
locally-disabled,
|
||||
file-ignored,
|
||||
suppressed-message,
|
||||
useless-suppression,
|
||||
deprecated-pragma,
|
||||
use-symbolic-message-instead,
|
||||
use-implicit-booleaness-not-comparison-to-string,
|
||||
use-implicit-booleaness-not-comparison-to-zero,
|
||||
no-name-in-module,
|
||||
unexpected-keyword-arg,
|
||||
no-value-for-parameter
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=
|
||||
|
||||
|
||||
[METHOD_ARGS]
|
||||
|
||||
# List of qualified names (i.e., library.method) which require a timeout
|
||||
# parameter e.g. 'requests.api.get,requests.api.post'
|
||||
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
# Regular expression of note tags to take in consideration.
|
||||
notes-rgx=
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
# Let 'consider-using-join' be raised when the separator to join on would be
|
||||
# non-empty (resulting in expected fixes of the type: ``"- " + " -
|
||||
# ".join(items)``)
|
||||
suggest-join-with-non-empty-separator=yes
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a score less than or equal to 10. You
|
||||
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
|
||||
# 'convention', and 'info' which contain the number of messages in each
|
||||
# category, as well as 'statement' which is the total number of statements
|
||||
# analyzed. This score is used by the global evaluation report (RP0004).
|
||||
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details.
|
||||
msg-template=
|
||||
|
||||
# Set the output format. Available formats are: text, parseable, colorized,
|
||||
# json2 (improved json format), json (old json format) and msvs (visual
|
||||
# studio). You can also give a reporter class, e.g.
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
#output-format=
|
||||
|
||||
# Tells whether to display a full report or only the messages.
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Comments are removed from the similarity computation
|
||||
ignore-comments=yes
|
||||
|
||||
# Docstrings are removed from the similarity computation
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Imports are removed from the similarity computation
|
||||
ignore-imports=yes
|
||||
|
||||
# Signatures are removed from the similarity computation
|
||||
ignore-signatures=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=4
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes.
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. No available dictionaries : You need to install
|
||||
# both the python package and the system dependency for enchant to work.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should be considered directives if they
|
||||
# appear at the beginning of a comment and should not be checked.
|
||||
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains the private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to the private dictionary (see the
|
||||
# --spelling-private-dict-file option) instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[STRING]
|
||||
|
||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
||||
# character used as a quote delimiter is used inconsistently within a module.
|
||||
check-quote-consistency=no
|
||||
|
||||
# This flag controls whether the implicit-str-concat should generate a warning
|
||||
# on implicit string concatenation in sequences defined over several lines.
|
||||
check-str-concat-over-line-jumps=no
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=PyQt5.*,orjson.*
|
||||
|
||||
# Tells whether to warn about missing members when the owner of the attribute
|
||||
# is inferred to be None.
|
||||
ignore-none=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of symbolic message names to ignore for Mixin members.
|
||||
ignored-checks-for-mixins=no-member,
|
||||
not-async-context-manager,
|
||||
not-context-manager,
|
||||
attribute-defined-outside-init
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
# Regex pattern to define which classes are considered mixins.
|
||||
mixin-class-rgx=.*[Mm]ixin
|
||||
|
||||
# List of decorators that change the signature of a decorated function.
|
||||
signature-mutators=
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid defining new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of names allowed to shadow builtins
|
||||
allowed-redefined-builtins=
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
||||
# not be used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored.
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
71
pyproject.toml
Normal file
71
pyproject.toml
Normal file
|
@ -0,0 +1,71 @@
|
|||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ['py39', 'py310', 'py311']
|
||||
include = '\.py$'
|
||||
force-exclude = '''
|
||||
/(
|
||||
| rare/ui
|
||||
| rare/legendary
|
||||
| rare/resources
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.poetry]
|
||||
name = "rare"
|
||||
version = "1.10.11"
|
||||
description = "A GUI for Legendary"
|
||||
authors = ["Dummerle"]
|
||||
license = "GPL3"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/RareDevs/Rare"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
PyQt5 = "^5.15.7"
|
||||
requests = "^2.28.1"
|
||||
QtAwesome = "^1.1.1"
|
||||
pypresence = { version = "^4.2.1", optional = true }
|
||||
pywin32 = { version = "^304", markers = "platform_system == 'Windows'" }
|
||||
pywebview = [
|
||||
{ version = "^3.6.3", extras = ["cef"], platform = "windows", optional = true },
|
||||
{ version = "^3.6.3", extras = ["gtk"], platform = "linux", optional = true },
|
||||
{ version = "^3.6.3", extras = ["gtk"], platform = "freebsd", optional = true },
|
||||
]
|
||||
legendary-gl = "^0.20.34"
|
||||
orjson = "^3.8.0"
|
||||
typing-extensions = "^4.3.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
start = "rare.main:main"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Nuitka = "^1.0.6"
|
||||
pylint = "^2.15.0"
|
||||
black = "^22.6.0"
|
||||
PyQt5-stubs = "^5.15.6.0"
|
||||
|
||||
#[build-system]
|
||||
#requires = ["setuptools>=42", "wheel", "nuitka", "toml"]
|
||||
#build-backend = "nuitka.distutils.Build"
|
||||
|
||||
[nuitka]
|
||||
assume-yes-for-downloads = true
|
||||
follow-imports = true
|
||||
prefer-source-code = true
|
||||
mingw64 = true
|
||||
lto = true
|
||||
static-libpython = false
|
||||
standalone = true
|
||||
show-scons = false
|
||||
enable-plugin = ["anti-bloat", "pyqt5"]
|
||||
show-anti-bloat-changes = true
|
||||
nofollow-import-to = ["*.tests", "*.distutils"]
|
||||
include-package-data = "qtawesome"
|
||||
include-data-dir = "rare/resources/images=rare/resources/images"
|
||||
include-data-files = "rare/resources/languages=rare/resources/laguanges=*.qm"
|
||||
windows-icon-from-ico = "rare/resources/images/Rare.ico"
|
||||
windows-company-name = "Rare"
|
||||
windows-product-name = "Rare"
|
||||
windows-file-version = "1.9.0"
|
||||
windows-product-version = "1.9.0"
|
||||
windows-disable-console = true
|
8
rare/__init__.py
Normal file
8
rare/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
__version__ = "1.10.11"
|
||||
__codename__ = "Garlic Crab"
|
||||
|
||||
# For PyCharm profiler
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from rare.main import main
|
||||
sys.exit(main())
|
4
rare/__main__.py
Normal file
4
rare/__main__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
if __name__ == "__main__":
|
||||
import sys
|
||||
from rare.main import main
|
||||
sys.exit(main())
|
0
rare/commands/__init__.py
Normal file
0
rare/commands/__init__.py
Normal file
459
rare/commands/launcher/__init__.py
Normal file
459
rare/commands/launcher/__init__.py
Normal file
|
@ -0,0 +1,459 @@
|
|||
import json
|
||||
import platform
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
import traceback
|
||||
from argparse import Namespace
|
||||
from logging import getLogger
|
||||
from signal import signal, SIGINT, SIGTERM, strsignal
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5 import sip
|
||||
from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QUrl, QRunnable, QThreadPool, QSettings, Qt, pyqtSlot, QTimer
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from legendary.models.game import SaveGameStatus
|
||||
|
||||
from rare.lgndr.core import LegendaryCore
|
||||
from rare.models.base_game import RareGameSlim
|
||||
from rare.models.launcher import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel
|
||||
from rare.models.options import options
|
||||
from rare.widgets.rare_app import RareApp, RareAppException
|
||||
from .cloud_sync_dialog import CloudSyncDialog, CloudSyncDialogResult
|
||||
from .console_dialog import ConsoleDialog
|
||||
from .lgd_helper import get_launch_args, InitArgs, get_configured_process, LaunchArgs, GameArgsError
|
||||
|
||||
DETACHED_APP_NAMES = {
|
||||
"0a2d9f6403244d12969e11da6713137b", # Fall Guys
|
||||
"Fortnite",
|
||||
"afdb5a85efcc45d8ae8e406e2121d81c", # Fortnite Battle Royale
|
||||
"09e442f830a341f698b4da42abd98c9b", # Fortnite Festival
|
||||
"d8f7763e07d74c209d760a679f9ed6ac", # Lego Fortnite
|
||||
"Fortnite_Studio", # Unreal Editor for Fortnite
|
||||
"dcfccf8d965a4f2281dddf9fead042de", # Homeworld Remastered Collection (issue#376)
|
||||
}
|
||||
|
||||
|
||||
class PreLaunch(QRunnable):
|
||||
class Signals(QObject):
|
||||
ready_to_launch = pyqtSignal(LaunchArgs)
|
||||
pre_launch_command_started = pyqtSignal()
|
||||
pre_launch_command_finished = pyqtSignal(int) # exit_code
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self, args: InitArgs, rgame: RareGameSlim, sync_action=None):
|
||||
super(PreLaunch, self).__init__()
|
||||
self.signals = self.Signals()
|
||||
self.logger = getLogger(type(self).__name__)
|
||||
self.args = args
|
||||
self.rgame = rgame
|
||||
self.sync_action = sync_action
|
||||
|
||||
def run(self) -> None:
|
||||
self.logger.info(f"Sync action: {self.sync_action}")
|
||||
if self.sync_action == CloudSyncDialogResult.UPLOAD:
|
||||
self.rgame.upload_saves(False)
|
||||
elif self.sync_action == CloudSyncDialogResult.DOWNLOAD:
|
||||
self.rgame.download_saves(False)
|
||||
else:
|
||||
self.logger.info("No sync action")
|
||||
|
||||
if args := self.prepare_launch(self.args):
|
||||
self.signals.ready_to_launch.emit(args)
|
||||
else:
|
||||
return
|
||||
|
||||
def prepare_launch(self, args: InitArgs) -> Optional[LaunchArgs]:
|
||||
try:
|
||||
launch_args = get_launch_args(self.rgame, args)
|
||||
except Exception as e:
|
||||
self.signals.error_occurred.emit(str(e))
|
||||
return None
|
||||
if not launch_args:
|
||||
return None
|
||||
|
||||
if launch_args.pre_launch_command:
|
||||
proc = get_configured_process()
|
||||
proc.setProcessEnvironment(launch_args.environment)
|
||||
self.signals.pre_launch_command_started.emit()
|
||||
pre_launch_command = shlex.split(launch_args.pre_launch_command)
|
||||
self.logger.debug("Running pre-launch command %s, %s", pre_launch_command[0], pre_launch_command[1:])
|
||||
if launch_args.pre_launch_wait:
|
||||
proc.start(pre_launch_command[0], pre_launch_command[1:])
|
||||
self.logger.debug("Waiting for pre-launch command to finish")
|
||||
proc.waitForFinished(-1)
|
||||
else:
|
||||
proc.startDetached(pre_launch_command[0], pre_launch_command[1:])
|
||||
return launch_args
|
||||
|
||||
|
||||
class SyncCheckWorker(QRunnable):
|
||||
class Signals(QObject):
|
||||
sync_state_ready = pyqtSignal()
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self, core: LegendaryCore, rgame: RareGameSlim):
|
||||
super().__init__()
|
||||
self.signals = self.Signals()
|
||||
self.core = core
|
||||
self.rgame = rgame
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.rgame.update_saves()
|
||||
except Exception as e:
|
||||
self.signals.error_occurred.emit(str(e))
|
||||
return
|
||||
self.signals.sync_state_ready.emit()
|
||||
|
||||
|
||||
class RareLauncherException(RareAppException):
|
||||
def __init__(self, app: 'RareLauncher', args: Namespace, parent=None):
|
||||
super(RareLauncherException, self).__init__(parent=parent)
|
||||
self.__app = app
|
||||
self.__args = args
|
||||
|
||||
def _handler(self, exc_type, exc_value, exc_tb) -> bool:
|
||||
try:
|
||||
self.__app.send_message(ErrorModel(
|
||||
app_name=self.__args.app_name,
|
||||
action=Actions.error,
|
||||
error_string="".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
))
|
||||
except RuntimeError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class RareLauncher(RareApp):
|
||||
exit_app = pyqtSignal()
|
||||
|
||||
def __init__(self, args: InitArgs):
|
||||
super(RareLauncher, self).__init__(args, f"{type(self).__name__}_{args.app_name}_{{0}}.log")
|
||||
self.socket: Optional[QLocalSocket] = None
|
||||
self.console: Optional[ConsoleDialog] = None
|
||||
self.game_process: QProcess = QProcess(self)
|
||||
self.server: QLocalServer = QLocalServer(self)
|
||||
|
||||
self._hook.deleteLater()
|
||||
self._hook = RareLauncherException(self, args, self)
|
||||
|
||||
self.success: bool = False
|
||||
self.no_sync_on_exit = False
|
||||
self.args = args
|
||||
self.core = LegendaryCore()
|
||||
game = self.core.get_game(args.app_name)
|
||||
if not game:
|
||||
self.logger.error(f"Game {args.app_name} not found. Exiting")
|
||||
return
|
||||
self.rgame = RareGameSlim(self.core, game)
|
||||
|
||||
language = self.settings.value(*options.language)
|
||||
self.load_translator(language)
|
||||
|
||||
if (
|
||||
QSettings(self).value(*options.log_games)
|
||||
or (game.app_name in DETACHED_APP_NAMES and platform.system() == "Windows")
|
||||
):
|
||||
self.console = ConsoleDialog(game.app_title)
|
||||
self.console.show()
|
||||
|
||||
self.game_process.finished.connect(self.__process_finished)
|
||||
self.game_process.errorOccurred.connect(self.__process_errored)
|
||||
if self.console:
|
||||
self.game_process.readyReadStandardOutput.connect(self.__proc_log_stdout)
|
||||
self.game_process.readyReadStandardError.connect(self.__proc_log_stderr)
|
||||
self.console.term.connect(self.__proc_term)
|
||||
self.console.kill.connect(self.__proc_kill)
|
||||
|
||||
ret = self.server.listen(f"rare_{args.app_name}")
|
||||
if not ret:
|
||||
self.logger.error(self.server.errorString())
|
||||
self.logger.info("Server is running")
|
||||
self.server.close()
|
||||
return
|
||||
self.server.newConnection.connect(self.new_server_connection)
|
||||
|
||||
self.success = True
|
||||
self.start_time = time.time()
|
||||
|
||||
# This launches the application after it has been instantiated.
|
||||
# The timer's signal will be serviced once we call `exec()` on the application
|
||||
QTimer.singleShot(0, self.start)
|
||||
|
||||
@pyqtSlot()
|
||||
def __proc_log_stdout(self):
|
||||
self.console.log_stdout(
|
||||
self.game_process.readAllStandardOutput().data().decode("utf-8", "ignore")
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def __proc_log_stderr(self):
|
||||
self.console.log_stderr(
|
||||
self.game_process.readAllStandardError().data().decode("utf-8", "ignore")
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def __proc_term(self):
|
||||
self.game_process.terminate()
|
||||
|
||||
@pyqtSlot()
|
||||
def __proc_kill(self):
|
||||
self.game_process.kill()
|
||||
|
||||
def new_server_connection(self):
|
||||
if self.socket is not None:
|
||||
try:
|
||||
self.socket.disconnectFromServer()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.logger.info("New connection")
|
||||
self.socket = self.server.nextPendingConnection()
|
||||
self.socket.disconnected.connect(self.socket_disconnected)
|
||||
self.socket.flush()
|
||||
|
||||
def socket_disconnected(self):
|
||||
self.logger.info("Server disconnected")
|
||||
self.socket.deleteLater()
|
||||
self.socket = None
|
||||
|
||||
def send_message(self, message: BaseModel):
|
||||
if self.socket:
|
||||
self.socket.write(json.dumps(vars(message)).encode("utf-8"))
|
||||
self.socket.flush()
|
||||
else:
|
||||
self.logger.error("Can't send message")
|
||||
|
||||
def check_saves_finished(self, exit_code: int):
|
||||
self.rgame.signals.widget.update.connect(lambda: self.on_exit(exit_code))
|
||||
|
||||
state, (dt_local, dt_remote) = self.rgame.save_game_state
|
||||
|
||||
if state == SaveGameStatus.LOCAL_NEWER and not self.no_sync_on_exit:
|
||||
action = CloudSyncDialogResult.UPLOAD
|
||||
self.__check_saved_finished(exit_code, action)
|
||||
else:
|
||||
sync_dialog = CloudSyncDialog(self.rgame.igame, dt_local, dt_remote)
|
||||
sync_dialog.result_ready.connect(lambda a: self.__check_saved_finished(exit_code, a))
|
||||
sync_dialog.open()
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
@pyqtSlot(int, CloudSyncDialogResult)
|
||||
def __check_saved_finished(self, exit_code, action):
|
||||
action = CloudSyncDialogResult(action)
|
||||
if action == CloudSyncDialogResult.UPLOAD:
|
||||
if self.console:
|
||||
self.console.log("Uploading saves...")
|
||||
self.rgame.upload_saves()
|
||||
elif action == CloudSyncDialogResult.DOWNLOAD:
|
||||
if self.console:
|
||||
self.console.log("Downloading saves...")
|
||||
self.rgame.download_saves()
|
||||
else:
|
||||
self.on_exit(exit_code)
|
||||
|
||||
@pyqtSlot(int, QProcess.ExitStatus)
|
||||
def __process_finished(self, exit_code: int, exit_status: QProcess.ExitStatus):
|
||||
self.logger.info("Game finished")
|
||||
|
||||
if self.rgame.auto_sync_saves:
|
||||
self.check_saves_finished(exit_code)
|
||||
else:
|
||||
self.on_exit(exit_code)
|
||||
|
||||
@pyqtSlot(QProcess.ProcessError)
|
||||
def __process_errored(self, error: QProcess.ProcessError):
|
||||
self.error_occurred(self.game_process.errorString())
|
||||
|
||||
def on_exit(self, exit_code: int):
|
||||
if self.console:
|
||||
self.console.on_process_exit(self.core.get_game(self.rgame.app_name).app_title, exit_code)
|
||||
|
||||
self.send_message(
|
||||
FinishedModel(
|
||||
action=Actions.finished,
|
||||
app_name=self.rgame.app_name,
|
||||
exit_code=exit_code,
|
||||
playtime=int(time.time() - self.start_time)
|
||||
)
|
||||
|
||||
)
|
||||
self.stop()
|
||||
|
||||
@pyqtSlot(object)
|
||||
def launch_game(self, args: LaunchArgs):
|
||||
# should never happen
|
||||
if not args:
|
||||
self.stop()
|
||||
return
|
||||
if self.console:
|
||||
self.console.set_env(args.environment)
|
||||
self.start_time = time.time()
|
||||
|
||||
if self.args.dry_run:
|
||||
self.logger.info("Dry run %s (%s)", self.rgame.app_title, self.rgame.app_name)
|
||||
self.logger.info("%s %s", args.executable, " ".join(args.arguments))
|
||||
if self.console:
|
||||
self.console.log(f"Dry run {self.rgame.app_title} ({self.rgame.app_name})")
|
||||
self.console.log(f"{shlex.join([args.executable] + args.arguments)}")
|
||||
self.console.accept_close = True
|
||||
print(shlex.join([args.executable] + args.arguments))
|
||||
self.stop()
|
||||
return
|
||||
|
||||
if args.is_origin_game:
|
||||
QDesktopServices.openUrl(QUrl(args.executable))
|
||||
self.stop() # stop because it is not a subprocess
|
||||
return
|
||||
|
||||
self.logger.debug("Launch command %s, %s", args.executable, " ".join(args.arguments))
|
||||
self.logger.debug("Working directory %s", args.working_directory)
|
||||
|
||||
if self.rgame.app_name in DETACHED_APP_NAMES and platform.system() == "Windows":
|
||||
if self.console:
|
||||
self.console.log(f"Launching as a detached process")
|
||||
subprocess.Popen([args.executable] + args.arguments, cwd=args.working_directory,
|
||||
env={i: args.environment.value(i) for i in args.environment.keys()})
|
||||
self.stop() # stop because we do not attach to the output
|
||||
return
|
||||
|
||||
if args.working_directory:
|
||||
self.game_process.setWorkingDirectory(args.working_directory)
|
||||
self.game_process.setProcessEnvironment(args.environment)
|
||||
# send start message after process started
|
||||
self.game_process.started.connect(lambda: self.send_message(
|
||||
StateChangedModel(
|
||||
action=Actions.state_update, app_name=self.rgame.app_name,
|
||||
new_state=StateChangedModel.States.started
|
||||
)
|
||||
))
|
||||
# self.logger.debug("Executing prelaunch command %s, %s", args.executable, args.arguments)
|
||||
self.game_process.start(args.executable, args.arguments)
|
||||
|
||||
def error_occurred(self, error_str: str):
|
||||
self.logger.warning(error_str)
|
||||
if self.console:
|
||||
self.console.on_process_exit(self.core.get_game(self.rgame.app_name).app_title, error_str)
|
||||
self.send_message(ErrorModel(
|
||||
error_string=error_str, app_name=self.rgame.app_name,
|
||||
action=Actions.error)
|
||||
)
|
||||
self.stop()
|
||||
|
||||
def start_prepare(self, sync_action=None):
|
||||
worker = PreLaunch(self.args, self.rgame, sync_action)
|
||||
worker.signals.ready_to_launch.connect(self.launch_game)
|
||||
worker.signals.error_occurred.connect(self.error_occurred)
|
||||
# worker.signals.started_pre_launch_command(None)
|
||||
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
|
||||
def sync_ready(self):
|
||||
if self.rgame.is_save_up_to_date:
|
||||
if self.console:
|
||||
self.console.log("Sync worker ready. Sync not required")
|
||||
self.start_prepare()
|
||||
return
|
||||
|
||||
_, (dt_local, dt_remote) = self.rgame.save_game_state
|
||||
sync_dialog = CloudSyncDialog(self.rgame.igame, dt_local, dt_remote)
|
||||
sync_dialog.result_ready.connect(self.__sync_ready)
|
||||
sync_dialog.open()
|
||||
|
||||
@pyqtSlot(int)
|
||||
@pyqtSlot(CloudSyncDialogResult)
|
||||
def __sync_ready(self, action: CloudSyncDialogResult):
|
||||
action = CloudSyncDialogResult(action)
|
||||
if action == CloudSyncDialogResult.CANCEL:
|
||||
self.no_sync_on_exit = True
|
||||
if self.console:
|
||||
if action == CloudSyncDialogResult.DOWNLOAD:
|
||||
self.console.log("Downloading saves...")
|
||||
elif action == CloudSyncDialogResult.UPLOAD:
|
||||
self.console.log("Uploading saves...")
|
||||
self.start_prepare(action)
|
||||
|
||||
def start(self):
|
||||
if not self.args.offline:
|
||||
try:
|
||||
if not self.core.login():
|
||||
raise ValueError("You are not logged in")
|
||||
except ValueError:
|
||||
# automatically launch offline if available
|
||||
self.logger.error("Not logged in. Trying to launch the game in offline mode")
|
||||
self.args.offline = True
|
||||
|
||||
if not self.args.offline and self.rgame.auto_sync_saves:
|
||||
self.logger.info("Start sync worker")
|
||||
worker = SyncCheckWorker(self.core, self.rgame)
|
||||
worker.signals.error_occurred.connect(self.error_occurred)
|
||||
worker.signals.sync_state_ready.connect(self.sync_ready)
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
else:
|
||||
self.start_prepare()
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
if self.console:
|
||||
self.game_process.readyReadStandardOutput.disconnect()
|
||||
self.game_process.readyReadStandardError.disconnect()
|
||||
if self.game_process.receivers(self.game_process.finished):
|
||||
self.game_process.finished.disconnect()
|
||||
if self.game_process.receivers(self.game_process.errorOccurred):
|
||||
self.game_process.errorOccurred.disconnect()
|
||||
except (TypeError, RuntimeError) as e:
|
||||
self.logger.error("Failed to disconnect process signals: %s", e)
|
||||
|
||||
if self.game_process.state() != QProcess.NotRunning:
|
||||
self.game_process.kill()
|
||||
exit_code = self.game_process.exitCode()
|
||||
self.game_process.deleteLater()
|
||||
|
||||
self.logger.info("Stopping server %s", self.server.socketDescriptor())
|
||||
try:
|
||||
self.server.close()
|
||||
self.server.deleteLater()
|
||||
except RuntimeError as e:
|
||||
self.logger.error("Error occured while stopping server: %s", e)
|
||||
|
||||
self.processEvents()
|
||||
if not self.console:
|
||||
self.exit(exit_code)
|
||||
else:
|
||||
self.console.on_process_exit(self.rgame.app_title, exit_code)
|
||||
|
||||
|
||||
def launch(args: Namespace) -> int:
|
||||
args = InitArgs.from_argparse(args)
|
||||
|
||||
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
|
||||
app = RareLauncher(args)
|
||||
app.setQuitOnLastWindowClosed(True)
|
||||
|
||||
# This prevents ghost QLocalSockets, which block the name, which makes it unable to start
|
||||
# No handling for SIGKILL
|
||||
def sighandler(s, frame):
|
||||
app.logger.info("%s received. Stopping", strsignal(s))
|
||||
app.stop()
|
||||
app.exit(1)
|
||||
signal(SIGINT, sighandler)
|
||||
signal(SIGTERM, sighandler)
|
||||
|
||||
if not app.success:
|
||||
app.stop()
|
||||
app.exit(1)
|
||||
return 1
|
||||
|
||||
try:
|
||||
exit_code = app.exec()
|
||||
except Exception as e:
|
||||
app.logger.error("Unhandled error %s", e)
|
||||
exit_code = 1
|
||||
finally:
|
||||
if not sip.isdeleted(app.server):
|
||||
app.server.close()
|
||||
return exit_code
|
103
rare/commands/launcher/cloud_sync_dialog.py
Normal file
103
rare/commands/launcher/cloud_sync_dialog.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
import sys
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QDialog
|
||||
from legendary.core import LegendaryCore
|
||||
from legendary.models.game import InstalledGame
|
||||
|
||||
from rare.ui.components.tabs.games.game_info.cloud_sync_widget import Ui_CloudSyncWidget
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.dialogs import ButtonDialog, game_title
|
||||
|
||||
logger = getLogger("CloudSyncDialog")
|
||||
|
||||
|
||||
class CloudSyncDialogResult(IntEnum):
|
||||
DOWNLOAD = 2
|
||||
UPLOAD = 1
|
||||
CANCEL = 0
|
||||
SKIP = 3
|
||||
|
||||
|
||||
class CloudSyncDialog(ButtonDialog):
|
||||
result_ready: pyqtSignal = pyqtSignal(CloudSyncDialogResult)
|
||||
|
||||
def __init__(self, igame: InstalledGame, dt_local: datetime, dt_remote: datetime, parent=None):
|
||||
super(CloudSyncDialog, self).__init__(parent=parent)
|
||||
header = self.tr("Cloud saves for")
|
||||
self.setWindowTitle(game_title(header, igame.title))
|
||||
|
||||
title_label = QLabel(f"<h4>{game_title(header, igame.title)}</h4>", self)
|
||||
|
||||
sync_widget = QWidget(self)
|
||||
self.sync_ui = Ui_CloudSyncWidget()
|
||||
self.sync_ui.setupUi(sync_widget)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(title_label)
|
||||
layout.addWidget(sync_widget)
|
||||
|
||||
self.accept_button.setText(self.tr("Skip"))
|
||||
self.accept_button.setIcon(qta_icon("fa.chevron-right"))
|
||||
|
||||
self.setCentralLayout(layout)
|
||||
|
||||
self.status = CloudSyncDialogResult.CANCEL
|
||||
|
||||
newer = self.tr("Newer")
|
||||
if dt_remote and dt_local:
|
||||
self.sync_ui.age_label_local.setText(f"<b>{newer}</b>" if dt_remote < dt_local else " ")
|
||||
self.sync_ui.age_label_remote.setText(f"<b>{newer}</b>" if dt_remote > dt_local else " ")
|
||||
# Set status, if one of them is None
|
||||
elif dt_remote and not dt_local:
|
||||
self.status = CloudSyncDialogResult.DOWNLOAD
|
||||
elif not dt_remote and dt_local:
|
||||
self.status = CloudSyncDialogResult.UPLOAD
|
||||
else:
|
||||
self.status = CloudSyncDialogResult.SKIP
|
||||
|
||||
self.sync_ui.date_info_local.setText(dt_local.strftime("%A, %d. %B %Y %X") if dt_local else "None")
|
||||
self.sync_ui.date_info_remote.setText(dt_remote.strftime("%A, %d. %B %Y %X") if dt_remote else "None")
|
||||
|
||||
self.sync_ui.icon_local.setPixmap(qta_icon("mdi.harddisk", "fa.desktop").pixmap(128, 128))
|
||||
self.sync_ui.icon_remote.setPixmap(qta_icon("mdi.cloud-outline", "ei.cloud").pixmap(128, 128))
|
||||
|
||||
self.sync_ui.upload_button.clicked.connect(self.__on_upload)
|
||||
self.sync_ui.download_button.clicked.connect(self.__on_download)
|
||||
|
||||
if self.status == CloudSyncDialogResult.SKIP:
|
||||
self.accept()
|
||||
|
||||
def __on_upload(self):
|
||||
self.status = CloudSyncDialogResult.UPLOAD
|
||||
self.done(QDialog.Accepted)
|
||||
|
||||
def __on_download(self):
|
||||
self.status = CloudSyncDialogResult.DOWNLOAD
|
||||
self.done(QDialog.Accepted)
|
||||
|
||||
def done_handler(self):
|
||||
self.result_ready.emit(self.status)
|
||||
|
||||
def accept_handler(self):
|
||||
self.status = CloudSyncDialogResult.SKIP
|
||||
|
||||
def reject_handler(self):
|
||||
self.status = CloudSyncDialogResult.CANCEL
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
core = LegendaryCore()
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __callback(status: int):
|
||||
print(repr(CloudSyncDialogResult(status)))
|
||||
|
||||
dlg = CloudSyncDialog(core.get_installed_list()[0], datetime.now(), datetime.strptime("2021,1", "%Y,%M"))
|
||||
dlg.result_ready.connect(__callback)
|
||||
dlg.open()
|
||||
app.exec()
|
198
rare/commands/launcher/console_dialog.py
Normal file
198
rare/commands/launcher/console_dialog.py
Normal file
|
@ -0,0 +1,198 @@
|
|||
import os
|
||||
from typing import Union
|
||||
|
||||
from PyQt5.QtCore import QProcessEnvironment, pyqtSignal, QSize, Qt
|
||||
from PyQt5.QtGui import QTextCursor, QFont, QCursor, QCloseEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QPlainTextEdit,
|
||||
QDialog,
|
||||
QPushButton,
|
||||
QFileDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QSpacerItem,
|
||||
QSizePolicy, QTableWidgetItem, QHeaderView, QApplication,
|
||||
)
|
||||
|
||||
from rare.ui.commands.launcher.console_env import Ui_ConsoleEnv
|
||||
from rare.widgets.dialogs import dialog_title, game_title
|
||||
|
||||
|
||||
class ConsoleDialog(QDialog):
|
||||
term = pyqtSignal()
|
||||
kill = pyqtSignal()
|
||||
env: QProcessEnvironment
|
||||
|
||||
def __init__(self, app_title: str, parent=None):
|
||||
super(ConsoleDialog, self).__init__(parent=parent)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self.setWindowTitle(
|
||||
dialog_title(game_title(self.tr("Console"), app_title))
|
||||
)
|
||||
self.setGeometry(0, 0, 640, 480)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
self.console_edit = ConsoleEdit(self)
|
||||
layout.addWidget(self.console_edit)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.env_button = QPushButton(self.tr("Show environment"))
|
||||
button_layout.addWidget(self.env_button)
|
||||
self.env_button.clicked.connect(self.show_env)
|
||||
|
||||
self.save_button = QPushButton(self.tr("Save output to file"))
|
||||
button_layout.addWidget(self.save_button)
|
||||
self.save_button.clicked.connect(self.save)
|
||||
|
||||
self.clear_button = QPushButton(self.tr("Clear console"))
|
||||
button_layout.addWidget(self.clear_button)
|
||||
self.clear_button.clicked.connect(self.console_edit.clear)
|
||||
|
||||
button_layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed))
|
||||
|
||||
self.terminate_button = QPushButton(self.tr("Terminate"))
|
||||
# self.terminate_button.setVisible(platform.system() == "Windows")
|
||||
button_layout.addWidget(self.terminate_button)
|
||||
self.terminate_button.clicked.connect(lambda: self.term.emit())
|
||||
|
||||
self.kill_button = QPushButton(self.tr("Kill"))
|
||||
# self.kill_button.setVisible(platform.system() == "Windows")
|
||||
button_layout.addWidget(self.kill_button)
|
||||
self.kill_button.clicked.connect(lambda: self.kill.emit())
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.env_variables = ConsoleEnv(app_title, self)
|
||||
self.env_variables.hide()
|
||||
|
||||
self.accept_close = False
|
||||
|
||||
def show(self) -> None:
|
||||
super(ConsoleDialog, self).show()
|
||||
self.center_window()
|
||||
|
||||
def center_window(self):
|
||||
# get the margins of the decorated window
|
||||
margins = self.windowHandle().frameMargins()
|
||||
# get the screen the cursor is on
|
||||
current_screen = QApplication.screenAt(QCursor.pos())
|
||||
if not current_screen:
|
||||
current_screen = QApplication.primaryScreen()
|
||||
# get the available screen geometry (excludes panels/docks)
|
||||
screen_rect = current_screen.availableGeometry()
|
||||
decor_width = margins.left() + margins.right()
|
||||
decor_height = margins.top() + margins.bottom()
|
||||
window_size = QSize(self.width(), self.height()).boundedTo(
|
||||
screen_rect.size() - QSize(decor_width, decor_height)
|
||||
)
|
||||
|
||||
self.resize(window_size)
|
||||
self.move(
|
||||
screen_rect.center()
|
||||
- self.rect().adjusted(0, 0, decor_width, decor_height).center()
|
||||
)
|
||||
|
||||
def save(self):
|
||||
file, ok = QFileDialog.getSaveFileName(
|
||||
self, "Save output", "", "Log Files (*.log);;All Files (*)"
|
||||
)
|
||||
if ok:
|
||||
if "." not in file:
|
||||
file += ".log"
|
||||
with open(file, "w") as f:
|
||||
f.write(self.console_edit.toPlainText())
|
||||
f.close()
|
||||
self.save_button.setText(self.tr("Saved"))
|
||||
|
||||
def set_env(self, env: QProcessEnvironment):
|
||||
self.env = env
|
||||
|
||||
def show_env(self):
|
||||
self.env_variables.setTable(self.env)
|
||||
self.env_variables.show()
|
||||
|
||||
def log(self, text: str):
|
||||
self.console_edit.log(f"Rare: {text}")
|
||||
|
||||
def log_stdout(self, text: str):
|
||||
self.console_edit.log(text)
|
||||
|
||||
def error(self, text):
|
||||
self.console_edit.error(f"Rare: {text}")
|
||||
|
||||
def log_stderr(self, text):
|
||||
self.console_edit.error(text)
|
||||
|
||||
def on_process_exit(self, app_title: str, status: Union[int, str]):
|
||||
self.error(
|
||||
self.tr("Application finished with exit code \"{}\"").format(status)
|
||||
)
|
||||
self.accept_close = True
|
||||
|
||||
def reject(self) -> None:
|
||||
self.close()
|
||||
|
||||
def accept(self) -> None:
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, a0: QCloseEvent) -> None:
|
||||
if self.accept_close:
|
||||
super(ConsoleDialog, self).closeEvent(a0)
|
||||
a0.accept()
|
||||
else:
|
||||
self.showMinimized()
|
||||
a0.ignore()
|
||||
|
||||
|
||||
class ConsoleEnv(QDialog):
|
||||
|
||||
def __init__(self, app_title: str, parent=None):
|
||||
super(ConsoleEnv, self).__init__(parent=parent)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, False)
|
||||
self.ui = Ui_ConsoleEnv()
|
||||
self.ui.setupUi(self)
|
||||
self.setWindowTitle(
|
||||
dialog_title(game_title(self.tr("Environment"), app_title))
|
||||
)
|
||||
|
||||
def setTable(self, env: QProcessEnvironment):
|
||||
self.ui.table.clearContents()
|
||||
self.ui.table.setRowCount(len(env.keys()))
|
||||
|
||||
for idx, key in enumerate(env.keys()):
|
||||
self.ui.table.setItem(idx, 0, QTableWidgetItem(env.keys()[idx]))
|
||||
self.ui.table.setItem(idx, 1, QTableWidgetItem(env.value(env.keys()[idx])))
|
||||
|
||||
self.ui.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||
|
||||
|
||||
class ConsoleEdit(QPlainTextEdit):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(ConsoleEdit, self).__init__(parent=parent)
|
||||
self.setReadOnly(True)
|
||||
font = QFont("Monospace")
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
self.setFont(font)
|
||||
|
||||
def scroll_to_last_line(self):
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(
|
||||
QTextCursor.Up if cursor.atBlockStart() else QTextCursor.StartOfLine
|
||||
)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
def print_to_console(self, text: str, color: str):
|
||||
html = f"<p style=\"color:{color};white-space:pre\">{text}</p>"
|
||||
self.appendHtml(html)
|
||||
self.scroll_to_last_line()
|
||||
|
||||
def log(self, text):
|
||||
self.print_to_console(text, "#aaa")
|
||||
|
||||
def error(self, text):
|
||||
self.print_to_console(text, "#a33")
|
186
rare/commands/launcher/lgd_helper.py
Normal file
186
rare/commands/launcher/lgd_helper.py
Normal file
|
@ -0,0 +1,186 @@
|
|||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from argparse import Namespace
|
||||
from dataclasses import dataclass, field
|
||||
from logging import getLogger
|
||||
from typing import List
|
||||
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
from legendary.models.game import LaunchParameters
|
||||
|
||||
from rare.models.base_game import RareGameSlim
|
||||
|
||||
logger = getLogger("RareLauncherHelper")
|
||||
|
||||
|
||||
class GameArgsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InitArgs(Namespace):
|
||||
app_name: str
|
||||
dry_run: bool = False
|
||||
debug: bool = False
|
||||
offline: bool = False
|
||||
skip_update_check: bool = False
|
||||
wine_prefix: str = ""
|
||||
wine_bin: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_argparse(cls, args):
|
||||
return cls(
|
||||
app_name=args.app_name,
|
||||
debug=args.debug,
|
||||
offline=args.offline,
|
||||
skip_update_check=args.skip_update_check,
|
||||
wine_bin=args.wine_bin,
|
||||
wine_prefix=args.wine_pfx,
|
||||
dry_run=args.dry_run
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaunchArgs:
|
||||
executable: str = ""
|
||||
arguments: List[str] = field(default_factory=list)
|
||||
working_directory: str = ""
|
||||
environment: QProcessEnvironment = None
|
||||
pre_launch_command: str = ""
|
||||
pre_launch_wait: bool = False
|
||||
is_origin_game: bool = False # only for windows to launch as url
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.executable)
|
||||
|
||||
|
||||
def get_origin_params(rgame: RareGameSlim, init_args: InitArgs, launch_args: LaunchArgs) -> LaunchArgs:
|
||||
core = rgame.core
|
||||
app_name = rgame.app_name
|
||||
|
||||
origin_uri = core.get_origin_uri(app_name, init_args.offline)
|
||||
if platform.system() == "Windows":
|
||||
launch_args.executable = origin_uri
|
||||
launch_args.arguments = []
|
||||
# only set it here true, because on linux it is a launch command like every other game
|
||||
launch_args.is_origin_game = True
|
||||
return launch_args
|
||||
|
||||
command = core.get_app_launch_command(app_name)
|
||||
if not os.path.exists(command[0]) and shutil.which(command[0]) is None:
|
||||
return launch_args
|
||||
command.append(origin_uri)
|
||||
|
||||
env = core.get_app_environment(app_name)
|
||||
launch_args.environment = QProcessEnvironment.systemEnvironment()
|
||||
|
||||
if os.environ.get("container") == "flatpak":
|
||||
flatpak_command = ["flatpak-spawn", "--host"]
|
||||
flatpak_command.extend(f"--env={name}={value}" for name, value in env.items())
|
||||
command = flatpak_command + command
|
||||
else:
|
||||
for name, value in env.items():
|
||||
launch_args.environment.insert(name, value)
|
||||
|
||||
launch_args.executable = command[0]
|
||||
launch_args.arguments = command[1:]
|
||||
|
||||
return launch_args
|
||||
|
||||
|
||||
def get_game_params(rgame: RareGameSlim, args: InitArgs, launch_args: LaunchArgs) -> LaunchArgs:
|
||||
if not args.offline: # skip for update
|
||||
if not args.skip_update_check and not rgame.core.is_noupdate_game(rgame.app_name):
|
||||
try:
|
||||
latest = rgame.core.get_asset(rgame.app_name, rgame.igame.platform, update=False)
|
||||
except ValueError:
|
||||
raise GameArgsError("Metadata doesn't exist")
|
||||
else:
|
||||
if latest.build_version != rgame.igame.version:
|
||||
raise GameArgsError("Game is not up to date. Please update first")
|
||||
|
||||
if (not rgame.igame or not rgame.igame.executable) and rgame.game is not None:
|
||||
# override installed game with base title
|
||||
if rgame.is_launchable_addon:
|
||||
app_name = rgame.game.metadata['mainGameItem']['releaseInfo'][0]['appId']
|
||||
rgame.igame = rgame.core.get_installed_game(app_name)
|
||||
|
||||
try:
|
||||
params: LaunchParameters = rgame.core.get_launch_parameters(
|
||||
app_name=rgame.game.app_name, offline=args.offline, addon_app_name=rgame.igame.app_name
|
||||
)
|
||||
except TypeError:
|
||||
logger.warning("Using older get_launch_parameters due to legendary version")
|
||||
params: LaunchParameters = rgame.core.get_launch_parameters(
|
||||
app_name=rgame.game.app_name, offline=args.offline
|
||||
)
|
||||
|
||||
full_params = []
|
||||
launch_args.environment = QProcessEnvironment.systemEnvironment()
|
||||
|
||||
if os.environ.get("container") == "flatpak":
|
||||
full_params.extend(["flatpak-spawn", "--host"])
|
||||
full_params.extend(
|
||||
f"--env={name}={value}"
|
||||
for name, value in params.environment.items()
|
||||
)
|
||||
else:
|
||||
for name, value in params.environment.items():
|
||||
launch_args.environment.insert(name, value)
|
||||
|
||||
full_params.extend(params.launch_command)
|
||||
full_params.append(os.path.join(params.game_directory, params.game_executable))
|
||||
full_params.extend(params.game_parameters)
|
||||
full_params.extend(params.egl_parameters)
|
||||
full_params.extend(params.user_parameters)
|
||||
|
||||
launch_args.executable = full_params[0]
|
||||
launch_args.arguments = full_params[1:]
|
||||
launch_args.working_directory = params.working_directory
|
||||
|
||||
return launch_args
|
||||
|
||||
|
||||
def get_launch_args(rgame: RareGameSlim, init_args: InitArgs = None) -> LaunchArgs:
|
||||
|
||||
resp = LaunchArgs()
|
||||
|
||||
if not rgame.game:
|
||||
raise GameArgsError(f"Could not find metadata for {rgame.app_title}")
|
||||
|
||||
if rgame.is_origin:
|
||||
init_args.offline = False
|
||||
else:
|
||||
if not rgame.is_installed:
|
||||
raise GameArgsError("Game is not installed or has unsupported format")
|
||||
|
||||
if rgame.is_dlc and not rgame.is_launchable_addon:
|
||||
raise GameArgsError("Game is a DLC")
|
||||
if not os.path.exists(rgame.install_path):
|
||||
raise GameArgsError("Game path does not exist")
|
||||
|
||||
if rgame.is_origin:
|
||||
resp = get_origin_params(rgame, init_args, resp)
|
||||
else:
|
||||
resp = get_game_params(rgame, init_args, resp)
|
||||
|
||||
pre_cmd, wait = rgame.core.get_pre_launch_command(init_args.app_name)
|
||||
resp.pre_launch_command, resp.pre_launch_wait = pre_cmd, wait
|
||||
return resp
|
||||
|
||||
|
||||
def get_configured_process(env: dict = None):
|
||||
proc = QProcess()
|
||||
proc.setProcessChannelMode(QProcess.MergedChannels)
|
||||
proc.readyReadStandardOutput.connect(
|
||||
lambda: logger.info(
|
||||
str(proc.readAllStandardOutput().data(), "utf-8", "ignore")
|
||||
)
|
||||
)
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
if env:
|
||||
for e in env:
|
||||
environment.insert(e, env[e])
|
||||
proc.setProcessEnvironment(environment)
|
||||
|
||||
return proc
|
0
rare/commands/reaper.py
Normal file
0
rare/commands/reaper.py
Normal file
13
rare/commands/webview.py
Normal file
13
rare/commands/webview.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
import sys
|
||||
from argparse import Namespace
|
||||
|
||||
from legendary.utils import webview_login
|
||||
|
||||
|
||||
def launch(args: Namespace) -> int:
|
||||
if webview_login.do_webview_login(
|
||||
callback_code=sys.stdout.write, user_agent=f'EpicGamesLauncher/{args.egl_version}'
|
||||
):
|
||||
return 0
|
||||
else:
|
||||
return 1
|
132
rare/components/__init__.py
Normal file
132
rare/components/__init__.py
Normal file
|
@ -0,0 +1,132 @@
|
|||
import os
|
||||
import shutil
|
||||
from argparse import Namespace
|
||||
from datetime import datetime, timezone, UTC
|
||||
from typing import Optional
|
||||
|
||||
import requests.exceptions
|
||||
from PyQt5.QtCore import QThreadPool, QTimer, pyqtSlot, Qt
|
||||
from PyQt5.QtWidgets import QApplication, QMessageBox
|
||||
from requests import HTTPError
|
||||
|
||||
from rare.models.options import options
|
||||
from rare.components.dialogs.launch_dialog import LaunchDialog
|
||||
from rare.components.main_window import MainWindow
|
||||
from rare.shared import RareCore
|
||||
from rare.utils import paths
|
||||
from rare.utils.misc import ExitCodes
|
||||
from rare.widgets.rare_app import RareApp, RareAppException
|
||||
|
||||
|
||||
class RareException(RareAppException):
|
||||
def __init__(self, parent=None):
|
||||
super(RareException, self).__init__(parent=parent)
|
||||
|
||||
def _handler(self, exc_type, exc_value, exc_tb) -> bool:
|
||||
if exc_type == HTTPError:
|
||||
try:
|
||||
if RareCore.instance() is not None:
|
||||
if RareCore.instance().core().login():
|
||||
return True
|
||||
raise ValueError
|
||||
except Exception as e:
|
||||
self.logger.fatal(str(e))
|
||||
QMessageBox.warning(None, "Error", self.tr("Failed to login"))
|
||||
QApplication.quit()
|
||||
return False
|
||||
|
||||
|
||||
class Rare(RareApp):
|
||||
def __init__(self, args: Namespace):
|
||||
super(Rare, self).__init__(args, f"{type(self).__name__}_{{0}}.log")
|
||||
self._hook.deleteLater()
|
||||
self._hook = RareException(self)
|
||||
self.rcore = RareCore(args=args)
|
||||
self.args = RareCore.instance().args()
|
||||
self.signals = RareCore.instance().signals()
|
||||
self.core = RareCore.instance().core()
|
||||
|
||||
language = self.settings.value(*options.language)
|
||||
self.load_translator(language)
|
||||
|
||||
# set Application name for settings
|
||||
self.main_window: Optional[MainWindow] = None
|
||||
self.launch_dialog: Optional[LaunchDialog] = None
|
||||
self.relogin_timer: Optional[QTimer] = None
|
||||
|
||||
# This launches the application after it has been instantiated.
|
||||
# The timer's signal will be serviced once we call `exec()` on the application
|
||||
QTimer.singleShot(0, self.launch_app)
|
||||
|
||||
def poke_timer(self):
|
||||
dt_exp = datetime.fromisoformat(self.core.lgd.userdata['expires_at'][:-1]).replace(tzinfo=timezone.utc)
|
||||
dt_now = datetime.now(UTC)
|
||||
td = abs(dt_exp - dt_now)
|
||||
self.relogin_timer.start(int(td.total_seconds() - 60) * 1000)
|
||||
self.logger.info(f"Renewed session expires at {self.core.lgd.userdata['expires_at']}")
|
||||
|
||||
def relogin(self):
|
||||
self.logger.info("Session expires shortly. Renew session")
|
||||
try:
|
||||
self.core.login(force_refresh=True)
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.relogin_timer.start(3000) # try again if no connection
|
||||
return
|
||||
self.poke_timer()
|
||||
|
||||
@pyqtSlot()
|
||||
def launch_app(self):
|
||||
self.launch_dialog = LaunchDialog(parent=None)
|
||||
self.launch_dialog.rejected.connect(self.__on_exit_app)
|
||||
# lk: the reason we use the `start_app` signal here instead of accepted, is to keep the dialog
|
||||
# until the main window has been created, then we call `accept()` to close the dialog
|
||||
self.launch_dialog.start_app.connect(self.__on_start_app)
|
||||
self.launch_dialog.start_app.connect(self.launch_dialog.accept)
|
||||
self.launch_dialog.login()
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_start_app(self):
|
||||
self.relogin_timer = QTimer(self)
|
||||
self.relogin_timer.setTimerType(Qt.VeryCoarseTimer)
|
||||
self.relogin_timer.timeout.connect(self.relogin)
|
||||
self.poke_timer()
|
||||
|
||||
self.main_window = MainWindow()
|
||||
self.main_window.exit_app.connect(self.__on_exit_app)
|
||||
|
||||
if not self.args.silent:
|
||||
self.main_window.show()
|
||||
|
||||
if self.args.test_start:
|
||||
self.main_window.close()
|
||||
self.main_window = None
|
||||
self.__on_exit_app(0)
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(int)
|
||||
def __on_exit_app(self, exit_code=0):
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
threadpool.waitForDone()
|
||||
if self.relogin_timer is not None:
|
||||
self.relogin_timer.stop()
|
||||
self.relogin_timer.deleteLater()
|
||||
self.relogin_timer = None
|
||||
self.rcore.deleteLater()
|
||||
del self.rcore
|
||||
self.processEvents()
|
||||
shutil.rmtree(paths.tmp_dir())
|
||||
os.makedirs(paths.tmp_dir())
|
||||
|
||||
self.exit(exit_code)
|
||||
|
||||
|
||||
def start(args) -> int:
|
||||
while True:
|
||||
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
app = Rare(args)
|
||||
exit_code = app.exec()
|
||||
del app
|
||||
if exit_code != ExitCodes.LOGOUT:
|
||||
break
|
||||
return exit_code
|
0
rare/components/dialogs/__init__.py
Normal file
0
rare/components/dialogs/__init__.py
Normal file
381
rare/components/dialogs/install_dialog.py
Normal file
381
rare/components/dialogs/install_dialog.py
Normal file
|
@ -0,0 +1,381 @@
|
|||
import os
|
||||
import platform as pf
|
||||
import shutil
|
||||
from typing import Tuple, List, Union, Optional
|
||||
|
||||
from PyQt5.QtCore import QThreadPool, QSettings
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QShowEvent
|
||||
from PyQt5.QtWidgets import QFileDialog, QCheckBox, QWidget, QFormLayout
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.install import InstallDownloadModel, InstallQueueItemModel, InstallOptionsModel
|
||||
from rare.shared.workers.install_info import InstallInfoWorker
|
||||
from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog
|
||||
from rare.ui.components.dialogs.install_dialog_advanced import Ui_InstallDialogAdvanced
|
||||
from rare.utils.misc import format_size, qta_icon
|
||||
from rare.widgets.collapsible_widget import CollapsibleFrame
|
||||
from rare.widgets.dialogs import ActionDialog, game_title
|
||||
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
|
||||
from rare.widgets.selective_widget import SelectiveWidget
|
||||
|
||||
|
||||
class InstallDialogAdvanced(CollapsibleFrame):
|
||||
def __init__(self, parent=None):
|
||||
super(InstallDialogAdvanced, self).__init__(parent=parent)
|
||||
|
||||
title = self.tr("Advanced options")
|
||||
self.setTitle(title)
|
||||
|
||||
self.widget = QWidget(parent=self)
|
||||
self.ui = Ui_InstallDialogAdvanced()
|
||||
self.ui.setupUi(self.widget)
|
||||
self.setWidget(self.widget)
|
||||
|
||||
|
||||
class InstallDialogSelective(CollapsibleFrame):
|
||||
stateChanged: pyqtSignal = pyqtSignal()
|
||||
|
||||
def __init__(self, rgame: RareGame, parent=None):
|
||||
super(InstallDialogSelective, self).__init__(parent=parent)
|
||||
title = self.tr("Optional downloads")
|
||||
self.setTitle(title)
|
||||
self.setEnabled(bool(rgame.sdl_name))
|
||||
|
||||
self.widget: SelectiveWidget = None
|
||||
self.rgame = rgame
|
||||
|
||||
def update_list(self, platform: str):
|
||||
if self.widget is not None:
|
||||
self.widget.deleteLater()
|
||||
self.widget = SelectiveWidget(self.rgame, platform, parent=self)
|
||||
self.widget.stateChanged.connect(self.stateChanged)
|
||||
self.setWidget(self.widget)
|
||||
|
||||
def install_tags(self):
|
||||
return self.widget.install_tags()
|
||||
|
||||
|
||||
class InstallDialog(ActionDialog):
|
||||
result_ready = pyqtSignal(InstallQueueItemModel)
|
||||
|
||||
def __init__(self, rgame: RareGame, options: InstallOptionsModel, parent=None):
|
||||
super(InstallDialog, self).__init__(parent=parent)
|
||||
|
||||
header = self.tr("Install")
|
||||
bicon = qta_icon("ri.install-line")
|
||||
if options.repair_mode:
|
||||
header = self.tr("Repair")
|
||||
bicon = qta_icon("fa.wrench")
|
||||
if options.repair_and_update:
|
||||
header = self.tr("Repair and update")
|
||||
elif options.update:
|
||||
header = self.tr("Update")
|
||||
elif options.reset_sdl:
|
||||
header = self.tr("Modify")
|
||||
bicon = qta_icon("fa.gear")
|
||||
self.setWindowTitle(game_title(header, rgame.app_title))
|
||||
self.setSubtitle(game_title(header, rgame.app_title))
|
||||
|
||||
install_widget = QWidget(self)
|
||||
self.ui = Ui_InstallDialog()
|
||||
self.ui.setupUi(install_widget)
|
||||
|
||||
self.core = rgame.core
|
||||
self.rgame = rgame
|
||||
self.__options: InstallOptionsModel = options
|
||||
self.__download: Optional[InstallDownloadModel] = None
|
||||
self.__queue_item: Optional[InstallQueueItemModel] = None
|
||||
|
||||
self.advanced = InstallDialogAdvanced(parent=self)
|
||||
self.ui.advanced_layout.addWidget(self.advanced)
|
||||
|
||||
self.selectable = InstallDialogSelective(rgame, parent=self)
|
||||
self.selectable.stateChanged.connect(self.option_changed)
|
||||
self.ui.selectable_layout.addWidget(self.selectable)
|
||||
|
||||
self.options_changed = False
|
||||
|
||||
self.threadpool = QThreadPool(self)
|
||||
self.threadpool.setMaxThreadCount(1)
|
||||
|
||||
if options.base_path:
|
||||
base_path = options.base_path
|
||||
elif rgame.is_installed:
|
||||
base_path = rgame.install_path
|
||||
else:
|
||||
base_path = self.core.get_default_install_dir(rgame.default_platform)
|
||||
|
||||
self.install_dir_edit = PathEdit(
|
||||
path=base_path,
|
||||
file_mode=QFileDialog.DirectoryOnly,
|
||||
edit_func=self.install_dir_edit_callback,
|
||||
save_func=self.install_dir_save_callback,
|
||||
parent=self,
|
||||
)
|
||||
self.ui.main_layout.setWidget(
|
||||
self.ui.main_layout.getWidgetPosition(self.ui.install_dir_label)[0],
|
||||
QFormLayout.FieldRole,
|
||||
self.install_dir_edit,
|
||||
)
|
||||
|
||||
self.install_dir_edit.setDisabled(rgame.is_installed)
|
||||
self.ui.install_dir_label.setDisabled(rgame.is_installed)
|
||||
self.ui.shortcut_label.setDisabled(rgame.is_installed)
|
||||
self.ui.shortcut_check.setDisabled(rgame.is_installed)
|
||||
self.ui.shortcut_check.setChecked(not rgame.is_installed and QSettings().value("create_shortcut", True, bool))
|
||||
|
||||
self.error_box()
|
||||
|
||||
self.ui.platform_combo.addItems(reversed(rgame.platforms))
|
||||
combo_text = rgame.igame.platform if rgame.is_installed else rgame.default_platform
|
||||
self.ui.platform_combo.setCurrentIndex(self.ui.platform_combo.findText(combo_text))
|
||||
self.ui.platform_combo.currentIndexChanged.connect(self.option_changed)
|
||||
self.ui.platform_combo.currentIndexChanged.connect(self.check_incompatible_platform)
|
||||
self.ui.platform_combo.currentIndexChanged.connect(self.reset_install_dir)
|
||||
self.ui.platform_combo.currentTextChanged.connect(self.selectable.update_list)
|
||||
|
||||
self.ui.platform_label.setDisabled(rgame.is_installed)
|
||||
self.ui.platform_combo.setDisabled(rgame.is_installed)
|
||||
|
||||
# if we are repairing, disable the SDL selection and open the dialog frame to be visible
|
||||
self.selectable.setDisabled(options.repair_mode and not options.repair_and_update)
|
||||
if options.repair_mode and not options.repair_and_update:
|
||||
self.selectable.click()
|
||||
|
||||
self.advanced.ui.max_workers_spin.setValue(self.core.lgd.config.getint("Legendary", "max_workers", fallback=0))
|
||||
self.advanced.ui.max_workers_spin.valueChanged.connect(self.option_changed)
|
||||
|
||||
self.advanced.ui.max_memory_spin.setValue(self.core.lgd.config.getint("Legendary", "max_memory", fallback=0))
|
||||
self.advanced.ui.max_memory_spin.valueChanged.connect(self.option_changed)
|
||||
|
||||
self.advanced.ui.dl_optimizations_check.stateChanged.connect(self.option_changed)
|
||||
self.advanced.ui.force_download_check.stateChanged.connect(self.option_changed)
|
||||
self.advanced.ui.ignore_space_check.stateChanged.connect(self.option_changed)
|
||||
self.advanced.ui.download_only_check.stateChanged.connect(
|
||||
lambda: self.non_reload_option_changed("download_only")
|
||||
)
|
||||
self.ui.shortcut_check.stateChanged.connect(
|
||||
lambda: self.non_reload_option_changed("shortcut")
|
||||
)
|
||||
|
||||
self.reset_install_dir(self.ui.platform_combo.currentIndex())
|
||||
self.selectable.update_list(self.ui.platform_combo.currentText())
|
||||
self.check_incompatible_platform(self.ui.platform_combo.currentIndex())
|
||||
|
||||
self.accept_button.setEnabled(False)
|
||||
|
||||
if self.__options.overlay:
|
||||
self.ui.platform_label.setEnabled(False)
|
||||
self.ui.platform_combo.setEnabled(False)
|
||||
self.advanced.ui.ignore_space_label.setEnabled(False)
|
||||
self.advanced.ui.ignore_space_check.setEnabled(False)
|
||||
self.advanced.ui.download_only_label.setEnabled(False)
|
||||
self.advanced.ui.download_only_check.setEnabled(False)
|
||||
self.ui.shortcut_label.setEnabled(False)
|
||||
self.ui.shortcut_check.setEnabled(False)
|
||||
self.selectable.setEnabled(False)
|
||||
|
||||
if pf.system() == "Darwin":
|
||||
self.ui.shortcut_label.setDisabled(True)
|
||||
self.ui.shortcut_check.setDisabled(True)
|
||||
self.ui.shortcut_check.setChecked(False)
|
||||
self.ui.shortcut_check.setToolTip(self.tr("Creating a shortcut is not supported on macOS"))
|
||||
|
||||
self.advanced.ui.install_prereqs_label.setEnabled(False)
|
||||
self.advanced.ui.install_prereqs_check.setEnabled(False)
|
||||
self.advanced.ui.install_prereqs_check.stateChanged.connect(
|
||||
lambda: self.non_reload_option_changed("install_prereqs")
|
||||
)
|
||||
|
||||
self.non_reload_option_changed("shortcut")
|
||||
|
||||
self.advanced.ui.install_prereqs_check.setChecked(self.__options.install_prereqs)
|
||||
|
||||
# lk: set object names for CSS properties
|
||||
self.accept_button.setText(header)
|
||||
self.accept_button.setIcon(bicon)
|
||||
self.accept_button.setObjectName("InstallButton")
|
||||
|
||||
self.action_button.setText(self.tr("Verify"))
|
||||
self.action_button.setIcon(qta_icon("fa.check"))
|
||||
|
||||
self.setCentralWidget(install_widget)
|
||||
|
||||
def showEvent(self, a0: QShowEvent) -> None:
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
self.install_dir_save_callback(self.install_dir_edit.text())
|
||||
super().showEvent(a0)
|
||||
|
||||
def execute(self):
|
||||
if self.__options.silent:
|
||||
self.get_download_info()
|
||||
else:
|
||||
self.action_handler()
|
||||
self.open()
|
||||
|
||||
@pyqtSlot(int)
|
||||
def reset_install_dir(self, index: int):
|
||||
if not self.rgame.is_installed:
|
||||
platform = self.ui.platform_combo.itemText(index)
|
||||
default_dir = self.core.get_default_install_dir(platform)
|
||||
self.install_dir_edit.setText(default_dir)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def check_incompatible_platform(self, index: int):
|
||||
platform = self.ui.platform_combo.itemText(index)
|
||||
if platform == "Mac" and pf.system() != "Darwin":
|
||||
self.error_box(
|
||||
self.tr("Warning"),
|
||||
self.tr("You will not be able to run the game if you select <b>{}</b> as platform").format(platform),
|
||||
)
|
||||
else:
|
||||
self.error_box()
|
||||
|
||||
def get_options(self):
|
||||
base_path = os.path.join(self.install_dir_edit.text(), ".overlay" if self.__options.overlay else "")
|
||||
self.__options.base_path = "" if self.rgame.is_installed else base_path
|
||||
self.__options.platform = self.ui.platform_combo.currentText()
|
||||
self.__options.create_shortcut = self.ui.shortcut_check.isChecked()
|
||||
self.__options.max_workers = self.advanced.ui.max_workers_spin.value()
|
||||
self.__options.shared_memory = self.advanced.ui.max_memory_spin.value()
|
||||
self.__options.order_opt = self.advanced.ui.dl_optimizations_check.isChecked()
|
||||
self.__options.force = self.advanced.ui.force_download_check.isChecked()
|
||||
self.__options.ignore_space = self.advanced.ui.ignore_space_check.isChecked()
|
||||
self.__options.no_install = self.advanced.ui.download_only_check.isChecked()
|
||||
self.__options.install_prereqs = self.advanced.ui.install_prereqs_check.isChecked()
|
||||
self.__options.install_tag = self.selectable.install_tags()
|
||||
self.__options.reset_sdl = True
|
||||
|
||||
def get_download_info(self):
|
||||
self.__download = None
|
||||
info_worker = InstallInfoWorker(self.core, self.__options)
|
||||
info_worker.signals.result.connect(self.on_worker_result)
|
||||
info_worker.signals.failed.connect(self.on_worker_failed)
|
||||
self.threadpool.start(info_worker)
|
||||
|
||||
def action_handler(self):
|
||||
self.error_box()
|
||||
message = self.tr("Updating...")
|
||||
font = self.font()
|
||||
font.setItalic(True)
|
||||
self.ui.download_size_text.setText(message)
|
||||
self.ui.download_size_text.setFont(font)
|
||||
self.ui.install_size_text.setText(message)
|
||||
self.ui.install_size_text.setFont(font)
|
||||
self.setActive(True)
|
||||
self.options_changed = False
|
||||
self.get_options()
|
||||
self.get_download_info()
|
||||
|
||||
@pyqtSlot()
|
||||
def option_changed(self):
|
||||
self.options_changed = True
|
||||
self.accept_button.setEnabled(False)
|
||||
self.action_button.setEnabled(not self.active())
|
||||
|
||||
def install_dir_edit_callback(self, path: str) -> Tuple[bool, str, int]:
|
||||
self.option_changed()
|
||||
return True, path, IndicatorReasonsCommon.VALID
|
||||
|
||||
def install_dir_save_callback(self, path: str):
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
_, _, free_space = shutil.disk_usage(path)
|
||||
self.ui.avail_space.setText(format_size(free_space))
|
||||
|
||||
def non_reload_option_changed(self, option: str):
|
||||
if option == "download_only":
|
||||
self.__options.no_install = self.advanced.ui.download_only_check.isChecked()
|
||||
elif option == "shortcut":
|
||||
QSettings().setValue("create_shortcut", self.ui.shortcut_check.isChecked())
|
||||
self.__options.create_shortcut = self.ui.shortcut_check.isChecked()
|
||||
elif option == "install_prereqs":
|
||||
self.__options.install_prereqs = self.advanced.ui.install_prereqs_check.isChecked()
|
||||
|
||||
@staticmethod
|
||||
def same_platform(download: InstallDownloadModel) -> bool:
|
||||
platform = download.igame.platform
|
||||
if pf.system() == "Windows":
|
||||
return platform in {"Windows", "Win32"}
|
||||
elif pf.system() == "Darwin":
|
||||
return platform == "Mac"
|
||||
else:
|
||||
return False
|
||||
|
||||
@pyqtSlot(InstallDownloadModel)
|
||||
def on_worker_result(self, download: InstallDownloadModel):
|
||||
self.setActive(False)
|
||||
self.__download = download
|
||||
download_size = download.analysis.dl_size
|
||||
install_size = download.analysis.install_size
|
||||
# install_size = self.dl_item.download.analysis.disk_space_delta
|
||||
bold_font = self.font()
|
||||
bold_font.setBold(True)
|
||||
italic_font = self.font()
|
||||
italic_font.setItalic(True)
|
||||
if download_size or (not download_size and (download.game.is_dlc or download.repair)):
|
||||
self.ui.download_size_text.setText(format_size(download_size))
|
||||
self.ui.download_size_text.setFont(bold_font)
|
||||
self.accept_button.setEnabled(not self.options_changed)
|
||||
else:
|
||||
self.ui.download_size_text.setText(self.tr("Game already installed"))
|
||||
self.ui.download_size_text.setFont(italic_font)
|
||||
self.ui.install_size_text.setText(format_size(install_size))
|
||||
self.ui.install_size_text.setFont(bold_font)
|
||||
self.action_button.setEnabled(self.options_changed)
|
||||
has_prereqs = bool(download.igame.prereq_info) and not download.igame.prereq_info.get("installed", False)
|
||||
if has_prereqs:
|
||||
prereq_name = download.igame.prereq_info.get("name", "")
|
||||
prereq_path = os.path.split(download.igame.prereq_info.get("path", ""))[-1]
|
||||
prereq_desc = prereq_name if prereq_name else prereq_path
|
||||
self.advanced.ui.install_prereqs_check.setText(self.tr("Also install: {}").format(prereq_desc))
|
||||
else:
|
||||
self.advanced.ui.install_prereqs_check.setText("")
|
||||
# Offer to install prerequisites only on same platforms
|
||||
self.advanced.ui.install_prereqs_label.setEnabled(has_prereqs)
|
||||
self.advanced.ui.install_prereqs_check.setEnabled(has_prereqs)
|
||||
self.advanced.ui.install_prereqs_check.setChecked(has_prereqs and self.same_platform(download))
|
||||
if self.__options.silent:
|
||||
self.accept()
|
||||
|
||||
def on_worker_failed(self, message: str):
|
||||
self.setActive(False)
|
||||
error_text = self.tr("Error")
|
||||
self.ui.download_size_text.setText(error_text)
|
||||
self.ui.install_size_text.setText(error_text)
|
||||
self.error_box(error_text, message)
|
||||
self.action_button.setEnabled(self.options_changed)
|
||||
self.accept_button.setEnabled(False)
|
||||
if self.__options.silent:
|
||||
self.open()
|
||||
|
||||
def error_box(self, label: str = "", message: str = ""):
|
||||
self.ui.warning_label.setVisible(bool(label))
|
||||
self.ui.warning_label.setText(label)
|
||||
self.ui.warning_text.setVisible(bool(message))
|
||||
self.ui.warning_text.setText(message)
|
||||
|
||||
def done_handler(self):
|
||||
self.threadpool.clear()
|
||||
self.threadpool.waitForDone()
|
||||
self.result_ready.emit(self.__queue_item)
|
||||
|
||||
# lk: __download is already set at this point so just do nothing.
|
||||
def accept_handler(self):
|
||||
self.__queue_item = InstallQueueItemModel(options=self.__options, download=self.__download)
|
||||
|
||||
def reject_handler(self):
|
||||
self.__queue_item = InstallQueueItemModel(options=self.__options, download=None)
|
||||
|
||||
|
||||
class TagCheckBox(QCheckBox):
|
||||
def __init__(self, text, desc, tags: List[str], parent=None):
|
||||
super(TagCheckBox, self).__init__(parent)
|
||||
self.setText(text)
|
||||
self.setToolTip(desc)
|
||||
self.tags = tags
|
||||
|
||||
def isChecked(self) -> Union[bool, List[str]]:
|
||||
return self.tags if super(TagCheckBox, self).isChecked() else False
|
90
rare/components/dialogs/launch_dialog.py
Normal file
90
rare/components/dialogs/launch_dialog.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
|
||||
from requests.exceptions import ConnectionError, HTTPError
|
||||
|
||||
from rare.components.dialogs.login import LoginDialog
|
||||
from rare.shared import RareCore
|
||||
from rare.ui.components.dialogs.launch_dialog import Ui_LaunchDialog
|
||||
from rare.widgets.dialogs import BaseDialog
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
|
||||
logger = getLogger("LaunchDialog")
|
||||
|
||||
|
||||
class LaunchDialog(BaseDialog):
|
||||
# lk: the reason we use the `start_app` signal here instead of accepted, is to keep the dialog
|
||||
# until the main window has been created, then we call `accept()` to close the dialog
|
||||
start_app = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(LaunchDialog, self).__init__(parent=parent)
|
||||
self.setWindowFlags(
|
||||
Qt.Window
|
||||
| Qt.Dialog
|
||||
| Qt.CustomizeWindowHint
|
||||
| Qt.WindowSystemMenuHint
|
||||
| Qt.WindowTitleHint
|
||||
| Qt.WindowMinimizeButtonHint
|
||||
| Qt.MSWindowsFixedSizeDialogHint
|
||||
)
|
||||
|
||||
self.ui = Ui_LaunchDialog()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.progress_info = ElideLabel(parent=self)
|
||||
self.progress_info.setFixedHeight(False)
|
||||
self.ui.launch_layout.addWidget(self.progress_info)
|
||||
|
||||
self.rcore = RareCore.instance()
|
||||
self.rcore.progress.connect(self.__on_progress)
|
||||
self.rcore.completed.connect(self.__on_completed)
|
||||
self.core = self.rcore.core()
|
||||
self.args = self.rcore.args()
|
||||
|
||||
self.login_dialog = LoginDialog(core=self.core, parent=parent)
|
||||
self.login_dialog.rejected.connect(self.reject)
|
||||
self.login_dialog.accepted.connect(self.do_launch)
|
||||
|
||||
def login(self):
|
||||
can_launch = True
|
||||
try:
|
||||
if not self.args.offline:
|
||||
# Force an update check and notice in case there are API changes
|
||||
# self.core.check_for_updates(force=True)
|
||||
# self.core.force_show_update = True
|
||||
if not self.core.login(force_refresh=True):
|
||||
raise ValueError("You are not logged in. Opening login window.")
|
||||
logger.info("You are logged in")
|
||||
self.login_dialog.close()
|
||||
except ValueError as e:
|
||||
logger.info(str(e))
|
||||
# Do not set parent, because it won't show a task bar icon
|
||||
# Update: Inherit the same parent as LaunchDialog
|
||||
can_launch = False
|
||||
self.login_dialog.open()
|
||||
except (HTTPError, ConnectionError) as e:
|
||||
logger.warning(e)
|
||||
self.args.offline = True
|
||||
finally:
|
||||
if can_launch:
|
||||
self.do_launch()
|
||||
|
||||
@pyqtSlot()
|
||||
def do_launch(self):
|
||||
if not self.args.silent:
|
||||
self.open()
|
||||
self.launch()
|
||||
|
||||
def launch(self):
|
||||
self.progress_info.setText(self.tr("Preparing Rare"))
|
||||
self.rcore.fetch()
|
||||
|
||||
@pyqtSlot(int, str)
|
||||
def __on_progress(self, i: int, m: str):
|
||||
self.ui.progress_bar.setValue(i)
|
||||
self.progress_info.setText(m)
|
||||
|
||||
def __on_completed(self):
|
||||
logger.info("App starting")
|
||||
self.start_app.emit()
|
156
rare/components/dialogs/login/__init__.py
Normal file
156
rare/components/dialogs/login/__init__.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QLayout, QMessageBox, QFrame
|
||||
from legendary.core import LegendaryCore
|
||||
|
||||
from rare.shared import ArgumentsSingleton
|
||||
from rare.ui.components.dialogs.login.landing_page import Ui_LandingPage
|
||||
from rare.ui.components.dialogs.login.login_dialog import Ui_LoginDialog
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.dialogs import BaseDialog
|
||||
from rare.widgets.sliding_stack import SlidingStackedWidget
|
||||
from .browser_login import BrowserLogin
|
||||
from .import_login import ImportLogin
|
||||
|
||||
logger = getLogger("LoginDialog")
|
||||
|
||||
|
||||
class LandingPage(QFrame):
|
||||
def __init__(self, parent=None):
|
||||
super(LandingPage, self).__init__(parent=parent)
|
||||
self.setFrameStyle(self.StyledPanel)
|
||||
self.ui = Ui_LandingPage()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
|
||||
class LoginDialog(BaseDialog):
|
||||
|
||||
def __init__(self, core: LegendaryCore, parent=None):
|
||||
super(LoginDialog, self).__init__(parent=parent)
|
||||
self.setWindowFlags(
|
||||
Qt.Window
|
||||
| Qt.Dialog
|
||||
| Qt.CustomizeWindowHint
|
||||
| Qt.WindowSystemMenuHint
|
||||
| Qt.WindowTitleHint
|
||||
| Qt.WindowMinimizeButtonHint
|
||||
| Qt.WindowCloseButtonHint
|
||||
| Qt.MSWindowsFixedSizeDialogHint
|
||||
)
|
||||
|
||||
self.ui = Ui_LoginDialog()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.logged_in: bool = False
|
||||
|
||||
self.core = core
|
||||
self.args = ArgumentsSingleton()
|
||||
|
||||
self.login_stack = SlidingStackedWidget(parent=self)
|
||||
self.login_stack.setMinimumWidth(480)
|
||||
self.ui.login_stack_layout.addWidget(self.login_stack)
|
||||
|
||||
self.landing_page = LandingPage(self.login_stack)
|
||||
self.login_stack.insertWidget(0, self.landing_page)
|
||||
|
||||
self.browser_page = BrowserLogin(self.core, self.login_stack)
|
||||
self.login_stack.insertWidget(1, self.browser_page)
|
||||
self.browser_page.success.connect(self.login_successful)
|
||||
self.browser_page.changed.connect(
|
||||
lambda: self.ui.next_button.setEnabled(self.browser_page.is_valid())
|
||||
)
|
||||
self.import_page = ImportLogin(self.core, self.login_stack)
|
||||
self.login_stack.insertWidget(2, self.import_page)
|
||||
self.import_page.success.connect(self.login_successful)
|
||||
self.import_page.changed.connect(lambda: self.ui.next_button.setEnabled(self.import_page.is_valid()))
|
||||
|
||||
# # NOTE: The real problem is that the BrowserLogin page has a huge QLabel with word-wrapping enabled.
|
||||
# # That forces the whole form to vertically expand instead of horizontally. Since the form is not shown
|
||||
# # on the first page, the internal Qt calculation for the size of that form calculates it by expanding it
|
||||
# # vertically. Once the form becomes visible, the correct calculation takes place and that is why the
|
||||
# # dialog reduces in height. To avoid that, calculate the bounding size of all forms and set it as the
|
||||
# # minumum size
|
||||
# self.login_stack.setMinimumSize(
|
||||
# self.landing_page.sizeHint().expandedTo(
|
||||
# self.browser_page.sizeHint().expandedTo(self.import_page.sizeHint())
|
||||
# )
|
||||
# )
|
||||
|
||||
self.login_stack.setFixedHeight(
|
||||
max(
|
||||
self.landing_page.heightForWidth(self.login_stack.minimumWidth()),
|
||||
self.browser_page.heightForWidth(self.login_stack.minimumWidth()),
|
||||
self.import_page.heightForWidth(self.login_stack.minimumWidth()),
|
||||
)
|
||||
)
|
||||
|
||||
self.ui.next_button.setEnabled(False)
|
||||
self.ui.back_button.setEnabled(False)
|
||||
|
||||
self.landing_page.ui.login_browser_radio.clicked.connect(lambda: self.ui.next_button.setEnabled(True))
|
||||
self.landing_page.ui.login_browser_radio.clicked.connect(self.browser_radio_clicked)
|
||||
self.landing_page.ui.login_import_radio.clicked.connect(lambda: self.ui.next_button.setEnabled(True))
|
||||
self.landing_page.ui.login_import_radio.clicked.connect(self.import_radio_clicked)
|
||||
|
||||
self.ui.exit_button.clicked.connect(self.reject)
|
||||
self.ui.back_button.clicked.connect(self.back_clicked)
|
||||
self.ui.next_button.clicked.connect(self.next_clicked)
|
||||
|
||||
self.login_stack.setCurrentWidget(self.landing_page)
|
||||
|
||||
self.ui.exit_button.setIcon(qta_icon("fa.remove"))
|
||||
self.ui.back_button.setIcon(qta_icon("fa.chevron-left"))
|
||||
self.ui.next_button.setIcon(qta_icon("fa.chevron-right"))
|
||||
|
||||
# lk: Set next as the default button only to stop closing the dialog when pressing enter
|
||||
self.ui.exit_button.setAutoDefault(False)
|
||||
self.ui.back_button.setAutoDefault(False)
|
||||
self.ui.next_button.setAutoDefault(True)
|
||||
|
||||
self.ui.main_layout.setSizeConstraint(QLayout.SetFixedSize)
|
||||
|
||||
def back_clicked(self):
|
||||
self.ui.back_button.setEnabled(False)
|
||||
self.ui.next_button.setEnabled(True)
|
||||
self.login_stack.slideInWidget(self.landing_page)
|
||||
|
||||
def browser_radio_clicked(self):
|
||||
self.login_stack.slideInWidget(self.browser_page)
|
||||
self.ui.back_button.setEnabled(True)
|
||||
self.ui.next_button.setEnabled(False)
|
||||
|
||||
def import_radio_clicked(self):
|
||||
self.login_stack.slideInWidget(self.import_page)
|
||||
self.ui.back_button.setEnabled(True)
|
||||
self.ui.next_button.setEnabled(self.import_page.is_valid())
|
||||
|
||||
def next_clicked(self):
|
||||
if self.login_stack.currentWidget() is self.landing_page:
|
||||
if self.landing_page.ui.login_browser_radio.isChecked():
|
||||
self.browser_radio_clicked()
|
||||
if self.landing_page.ui.login_import_radio.isChecked():
|
||||
self.import_radio_clicked()
|
||||
elif self.login_stack.currentWidget() is self.browser_page:
|
||||
self.browser_page.do_login()
|
||||
elif self.login_stack.currentWidget() is self.import_page:
|
||||
self.import_page.do_login()
|
||||
|
||||
def login(self):
|
||||
if self.args.test_start:
|
||||
self.reject()
|
||||
self.open()
|
||||
|
||||
def login_successful(self):
|
||||
try:
|
||||
if not self.core.login():
|
||||
raise ValueError("Login failed.")
|
||||
self.logged_in = True
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
self.core.lgd.invalidate_userdata()
|
||||
self.ui.next_button.setEnabled(False)
|
||||
self.logged_in = False
|
||||
QMessageBox.warning(None, self.tr("Login error"), str(e))
|
||||
|
105
rare/components/dialogs/login/browser_login.py
Normal file
105
rare/components/dialogs/login/browser_login.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
import json
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, QUrl, QProcess, pyqtSlot
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtWidgets import QFrame, qApp, QFormLayout, QLineEdit
|
||||
from legendary.utils import webview_login
|
||||
|
||||
from rare.lgndr.core import LegendaryCore
|
||||
from rare.ui.components.dialogs.login.browser_login import Ui_BrowserLogin
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.utils.paths import get_rare_executable
|
||||
from rare.widgets.indicator_edit import IndicatorLineEdit, IndicatorReasonsCommon
|
||||
|
||||
logger = getLogger("BrowserLogin")
|
||||
|
||||
|
||||
class BrowserLogin(QFrame):
|
||||
success = pyqtSignal()
|
||||
changed = pyqtSignal()
|
||||
|
||||
def __init__(self, core: LegendaryCore, parent=None):
|
||||
super(BrowserLogin, self).__init__(parent=parent)
|
||||
self.setFrameStyle(self.StyledPanel)
|
||||
self.ui = Ui_BrowserLogin()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.core = core
|
||||
self.login_url = self.core.egs.get_auth_url()
|
||||
|
||||
self.sid_edit = IndicatorLineEdit(
|
||||
placeholder=self.tr("Insert authorizationCode here"), edit_func=self.text_changed, parent=self
|
||||
)
|
||||
self.sid_edit.line_edit.setEchoMode(QLineEdit.Password)
|
||||
self.ui.link_text.setText(self.login_url)
|
||||
self.ui.copy_button.setIcon(qta_icon("mdi.content-copy", "fa.copy"))
|
||||
self.ui.copy_button.clicked.connect(self.copy_link)
|
||||
self.ui.form_layout.setWidget(
|
||||
self.ui.form_layout.getWidgetPosition(self.ui.sid_label)[0],
|
||||
QFormLayout.FieldRole, self.sid_edit
|
||||
)
|
||||
|
||||
self.ui.open_button.clicked.connect(self.open_browser)
|
||||
self.sid_edit.textChanged.connect(self.changed.emit)
|
||||
|
||||
@pyqtSlot()
|
||||
def copy_link(self):
|
||||
clipboard = qApp.clipboard()
|
||||
clipboard.setText(self.login_url)
|
||||
self.ui.status_label.setText(self.tr("Copied to clipboard"))
|
||||
|
||||
def is_valid(self):
|
||||
return self.sid_edit.is_valid
|
||||
|
||||
@staticmethod
|
||||
def text_changed(text) -> Tuple[bool, str, int]:
|
||||
if text:
|
||||
text = text.strip()
|
||||
if text.startswith("{") and text.endswith("}"):
|
||||
try:
|
||||
text = json.loads(text).get("authorizationCode")
|
||||
except json.JSONDecodeError:
|
||||
return False, text, IndicatorReasonsCommon.WRONG_FORMAT
|
||||
elif '"' in text:
|
||||
text = text.strip('"')
|
||||
return len(text) == 32, text, IndicatorReasonsCommon.WRONG_FORMAT
|
||||
else:
|
||||
return False, text, IndicatorReasonsCommon.VALID
|
||||
|
||||
def do_login(self):
|
||||
self.ui.status_label.setText(self.tr("Logging in..."))
|
||||
auth_code = self.sid_edit.text()
|
||||
try:
|
||||
if self.core.auth_code(auth_code):
|
||||
logger.info("Successfully logged in as %s", self.core.lgd.userdata['displayName'])
|
||||
self.success.emit()
|
||||
else:
|
||||
self.ui.status_label.setText(self.tr("Login failed."))
|
||||
logger.warning("Failed to login through browser")
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
@pyqtSlot()
|
||||
def open_browser(self):
|
||||
if not webview_login.webview_available:
|
||||
logger.warning("You don't have webengine installed, you will need to manually copy the authorizationCode.")
|
||||
QDesktopServices.openUrl(QUrl(self.login_url))
|
||||
else:
|
||||
cmd = get_rare_executable() + ["login", self.core.get_egl_version()]
|
||||
proc = QProcess(self)
|
||||
proc.start(cmd[0], cmd[1:])
|
||||
proc.waitForFinished(-1)
|
||||
out, err = (
|
||||
proc.readAllStandardOutput().data().decode("utf-8", "ignore"),
|
||||
proc.readAllStandardError().data().decode("utf-8", "ignore")
|
||||
)
|
||||
proc.deleteLater()
|
||||
|
||||
if out:
|
||||
self.core.auth_ex_token(out)
|
||||
logger.info("Successfully logged in as %s", {self.core.lgd.userdata['displayName']})
|
||||
self.success.emit()
|
||||
else:
|
||||
logger.warning("Failed to login through browser.")
|
107
rare/components/dialogs/login/import_login.py
Normal file
107
rare/components/dialogs/login/import_login.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
import os
|
||||
from getpass import getuser
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QFrame, QFileDialog
|
||||
from legendary.core import LegendaryCore
|
||||
from legendary.lfs.wine_helpers import get_shell_folders, read_registry
|
||||
|
||||
from rare.ui.components.dialogs.login.import_login import Ui_ImportLogin
|
||||
|
||||
logger = getLogger("ImportLogin")
|
||||
|
||||
|
||||
class ImportLogin(QFrame):
|
||||
success = pyqtSignal()
|
||||
changed = pyqtSignal()
|
||||
|
||||
# FIXME: Use pathspec instead of duplicated code
|
||||
if os.name == "nt":
|
||||
localappdata = os.path.expandvars("%LOCALAPPDATA%")
|
||||
else:
|
||||
localappdata = os.path.join("drive_c/users", getuser(), "Local Settings/Application Data")
|
||||
egl_appdata = os.path.join(localappdata, "EpicGamesLauncher", "Saved", "Config", "Windows")
|
||||
found = False
|
||||
|
||||
def __init__(self, core: LegendaryCore, parent=None):
|
||||
super(ImportLogin, self).__init__(parent=parent)
|
||||
self.setFrameStyle(self.StyledPanel)
|
||||
self.ui = Ui_ImportLogin()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.core = core
|
||||
|
||||
self.text_egl_found = self.tr("Found EGL Program Data. Click 'Next' to import them.")
|
||||
self.text_egl_notfound = self.tr("Could not find EGL Program Data. ")
|
||||
|
||||
if os.name == "nt":
|
||||
if not self.core.egl.appdata_path and os.path.exists(self.egl_appdata):
|
||||
self.core.egl.appdata_path = self.egl_appdata
|
||||
if not self.core.egl.appdata_path:
|
||||
self.ui.status_label.setText(self.text_egl_notfound)
|
||||
else:
|
||||
self.ui.status_label.setText(self.text_egl_found)
|
||||
self.found = True
|
||||
else:
|
||||
if programdata_path := self.core.egl.programdata_path:
|
||||
if wine_pfx := programdata_path.split("drive_c")[0]:
|
||||
self.ui.prefix_combo.addItem(wine_pfx)
|
||||
prefixes = self.get_wine_prefixes()
|
||||
if len(prefixes):
|
||||
self.ui.prefix_combo.addItems(prefixes)
|
||||
self.ui.status_label.setText(self.tr("Select the Wine prefix you want to import."))
|
||||
else:
|
||||
self.ui.status_label.setText(self.text_egl_notfound)
|
||||
|
||||
self.ui.prefix_button.clicked.connect(self.prefix_path)
|
||||
self.ui.prefix_combo.editTextChanged.connect(self.changed.emit)
|
||||
|
||||
def get_wine_prefixes(self):
|
||||
possible_prefixes = [
|
||||
os.path.expanduser("~/.wine"),
|
||||
os.path.expanduser("~/Games/epic-games-store"),
|
||||
]
|
||||
prefixes = []
|
||||
for prefix in possible_prefixes:
|
||||
if os.path.exists(os.path.join(prefix, self.egl_appdata)):
|
||||
prefixes.append(prefix)
|
||||
return prefixes
|
||||
|
||||
def prefix_path(self):
|
||||
prefix_dialog = QFileDialog(self, self.tr("Choose path"), os.path.expanduser("~/"))
|
||||
prefix_dialog.setFileMode(QFileDialog.DirectoryOnly)
|
||||
if prefix_dialog.exec_():
|
||||
names = prefix_dialog.selectedFiles()
|
||||
self.ui.prefix_combo.setCurrentText(names[0])
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
if os.name == "nt":
|
||||
return self.found
|
||||
else:
|
||||
egl_wine_pfx = self.ui.prefix_combo.currentText()
|
||||
try:
|
||||
wine_folders = get_shell_folders(read_registry(egl_wine_pfx), egl_wine_pfx)
|
||||
self.egl_appdata = os.path.realpath(
|
||||
os.path.join(wine_folders['Local AppData'], 'EpicGamesLauncher', 'Saved', 'Config', 'Windows'))
|
||||
if path_exists := os.path.exists(self.egl_appdata):
|
||||
self.ui.status_label.setText(self.text_egl_found)
|
||||
return path_exists
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def do_login(self):
|
||||
self.ui.status_label.setText(self.tr("Loading..."))
|
||||
if os.name != "nt":
|
||||
logger.info("Using EGL appdata path at %s", {self.egl_appdata})
|
||||
self.core.egl.appdata_path = self.egl_appdata
|
||||
try:
|
||||
if self.core.auth_import():
|
||||
logger.info("Logged in as %s", {self.core.lgd.userdata['displayName']})
|
||||
self.success.emit()
|
||||
else:
|
||||
self.ui.status_label.setText(self.tr("Login failed."))
|
||||
logger.warning("Failed to import existing session.")
|
||||
except Exception as e:
|
||||
self.ui.status_label.setText(self.tr("Login failed. {}").format(str(e)))
|
||||
logger.warning("Failed to import existing session: %s", e)
|
199
rare/components/dialogs/move_dialog.py
Normal file
199
rare/components/dialogs/move_dialog.py
Normal file
|
@ -0,0 +1,199 @@
|
|||
import os
|
||||
import shutil
|
||||
from enum import auto
|
||||
from logging import getLogger
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QLayout
|
||||
|
||||
from rare.models.install import MoveGameModel
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import RareCore
|
||||
from rare.utils.misc import path_size, format_size, qta_icon
|
||||
from rare.widgets.dialogs import ActionDialog, game_title
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
from rare.widgets.indicator_edit import PathEdit, IndicatorReasons, IndicatorReasonsCommon
|
||||
|
||||
logger = getLogger("MoveGame")
|
||||
|
||||
|
||||
class MovePathEditReasons(IndicatorReasons):
|
||||
DST_MISSING = auto()
|
||||
NO_WRITE_PERM = auto()
|
||||
SAME_DIR = auto()
|
||||
DST_IN_SRC = auto()
|
||||
NESTED_DIR = auto()
|
||||
NO_SPACE = auto()
|
||||
|
||||
|
||||
class MoveDialog(ActionDialog):
|
||||
result_ready = pyqtSignal(RareGame, MoveGameModel)
|
||||
|
||||
def __init__(self, rgame: RareGame, parent=None):
|
||||
super(MoveDialog, self).__init__(parent=parent)
|
||||
header = self.tr("Move")
|
||||
self.setWindowTitle(game_title(header, rgame.app_title))
|
||||
self.setSubtitle(game_title(header, rgame.app_title))
|
||||
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = RareCore.instance().core()
|
||||
self.rgame: Optional[RareGame] = None
|
||||
|
||||
self.path_edit = PathEdit("", QFileDialog.Directory, edit_func=self.path_edit_callback)
|
||||
self.path_edit.extend_reasons({
|
||||
MovePathEditReasons.DST_MISSING: self.tr("You need to provide the destination directory."),
|
||||
MovePathEditReasons.NO_WRITE_PERM: self.tr("No write permission on destination."),
|
||||
MovePathEditReasons.SAME_DIR: self.tr("Same directory or subdirectory selected."),
|
||||
MovePathEditReasons.DST_IN_SRC: self.tr("Destination is inside source directory"),
|
||||
MovePathEditReasons.NESTED_DIR: self.tr("Game install directories cannot be nested."),
|
||||
MovePathEditReasons.NO_SPACE: self.tr("Not enough space available on drive."),
|
||||
})
|
||||
|
||||
self.warn_label = ElideLabel("", parent=self)
|
||||
|
||||
font = self.font()
|
||||
font.setBold(True)
|
||||
self.req_space_label = QLabel(self.tr("Required:"), self)
|
||||
self.req_space = QLabel(self)
|
||||
self.req_space.setFont(font)
|
||||
self.avail_space_label = QLabel(self.tr("Available:"), self)
|
||||
self.avail_space = QLabel(self)
|
||||
self.avail_space.setFont(font)
|
||||
|
||||
bottom_layout = QHBoxLayout()
|
||||
bottom_layout.addWidget(self.req_space_label)
|
||||
bottom_layout.addWidget(self.req_space, stretch=1)
|
||||
bottom_layout.addWidget(self.avail_space_label)
|
||||
bottom_layout.addWidget(self.avail_space, stretch=1)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSizeConstraint(QLayout.SetFixedSize)
|
||||
layout.addWidget(self.path_edit)
|
||||
layout.addWidget(self.warn_label)
|
||||
layout.addLayout(bottom_layout)
|
||||
|
||||
self.setCentralLayout(layout)
|
||||
|
||||
self.accept_button.setText(self.tr("Move"))
|
||||
self.accept_button.setIcon(qta_icon("mdi.folder-move-outline"))
|
||||
|
||||
self.action_button.setHidden(True)
|
||||
|
||||
self.update_game(rgame)
|
||||
|
||||
self.options: MoveGameModel = MoveGameModel(rgame.app_name)
|
||||
|
||||
def action_handler(self):
|
||||
pass
|
||||
|
||||
def done_handler(self):
|
||||
self.result_ready.emit(self.rgame, self.options)
|
||||
|
||||
def accept_handler(self):
|
||||
self.options.accepted = True
|
||||
self.options.target_path = self.path_edit.text()
|
||||
|
||||
def reject_handler(self):
|
||||
self.options.accepted = False
|
||||
self.options.target_path = ""
|
||||
|
||||
def refresh_indicator(self):
|
||||
# needed so the edit_func gets run again
|
||||
text = self.path_edit.text()
|
||||
self.path_edit.setText(str())
|
||||
self.path_edit.setText(text)
|
||||
|
||||
def path_edit_callback(self, path: str) -> Tuple[bool, str, int]:
|
||||
self.accept_button.setEnabled(True)
|
||||
self.warn_label.setHidden(False)
|
||||
self.req_space.setText("...")
|
||||
self.avail_space.setText("...")
|
||||
|
||||
def helper_func(reason: int) -> Tuple[bool, str, int]:
|
||||
self.accept_button.setEnabled(False)
|
||||
return False, path, reason
|
||||
|
||||
if not self.rgame.install_path or not path:
|
||||
return helper_func(MovePathEditReasons.DST_MISSING)
|
||||
|
||||
src_path = os.path.realpath(self.rgame.install_path)
|
||||
dst_path = os.path.realpath(path)
|
||||
dst_install_path = os.path.realpath(os.path.join(dst_path, os.path.basename(src_path)))
|
||||
|
||||
if not os.path.isdir(dst_path):
|
||||
return helper_func(IndicatorReasonsCommon.DIR_NOT_EXISTS)
|
||||
|
||||
# Get free space on drive and size of game folder
|
||||
_, _, free_space = shutil.disk_usage(dst_path)
|
||||
source_size = path_size(src_path)
|
||||
|
||||
# Calculate from bytes to gigabytes
|
||||
self.req_space.setText(format_size(source_size))
|
||||
self.avail_space.setText(format_size(free_space))
|
||||
|
||||
if not os.access(path, os.W_OK) or not os.access(self.rgame.install_path, os.W_OK):
|
||||
return helper_func(MovePathEditReasons.NO_WRITE_PERM)
|
||||
|
||||
if src_path in {dst_path, dst_install_path}:
|
||||
return helper_func(MovePathEditReasons.SAME_DIR)
|
||||
|
||||
if str(src_path) in str(dst_path):
|
||||
return helper_func(MovePathEditReasons.DST_IN_SRC)
|
||||
|
||||
if str(dst_install_path) in str(src_path):
|
||||
return helper_func(MovePathEditReasons.DST_IN_SRC)
|
||||
|
||||
for rgame in self.rcore.installed_games:
|
||||
if not rgame.is_non_asset and rgame.install_path in path:
|
||||
return helper_func(MovePathEditReasons.NESTED_DIR)
|
||||
|
||||
is_existing_dir = is_game_dir(src_path, dst_install_path)
|
||||
|
||||
for item in os.listdir(dst_path):
|
||||
if os.path.basename(src_path) in os.path.basename(item):
|
||||
if os.path.isdir(dst_install_path):
|
||||
if not is_existing_dir:
|
||||
self.warn_label.setHidden(False)
|
||||
elif os.path.isfile(dst_install_path):
|
||||
self.warn_label.setHidden(False)
|
||||
|
||||
if free_space <= source_size and not is_existing_dir:
|
||||
return helper_func(MovePathEditReasons.NO_SPACE)
|
||||
|
||||
# Fallback
|
||||
self.accept_button.setEnabled(True)
|
||||
return True, path, IndicatorReasonsCommon.VALID
|
||||
|
||||
@pyqtSlot()
|
||||
def __update_widget(self):
|
||||
""" React to state updates from RareGame """
|
||||
if not self.rgame.is_installed or self.rgame.is_non_asset:
|
||||
self.setDisabled(True)
|
||||
return
|
||||
# FIXME: Make edit_func lighter instead of blocking signals
|
||||
# self.path_edit.line_edit.blockSignals(True)
|
||||
self.setActive(True)
|
||||
self.path_edit.setText(self.rgame.install_path)
|
||||
# FIXME: Make edit_func lighter instead of blocking signals
|
||||
# self.path_edit.line_edit.blockSignals(False)
|
||||
self.setActive(False)
|
||||
self.warn_label.setText(
|
||||
self.tr("Moving here will overwrite <b>{}</b>").format(os.path.basename(self.rgame.install_path))
|
||||
)
|
||||
self.refresh_indicator()
|
||||
|
||||
def update_game(self, rgame: RareGame):
|
||||
self.rgame = rgame
|
||||
self.__update_widget()
|
||||
|
||||
|
||||
def is_game_dir(src_path: str, dst_path: str):
|
||||
# This iterates over the destination dir, then iterates over the current install dir and if the file names
|
||||
# matches, we have an exisiting dir
|
||||
if os.path.isdir(dst_path):
|
||||
for dst_file in os.listdir(dst_path):
|
||||
for src_file in os.listdir(src_path):
|
||||
if dst_file == src_file:
|
||||
return True
|
||||
return False
|
47
rare/components/dialogs/selective_dialog.py
Normal file
47
rare/components/dialogs/selective_dialog.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QVBoxLayout, QGroupBox
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.install import SelectiveDownloadsModel
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.dialogs import ButtonDialog, game_title
|
||||
from rare.widgets.selective_widget import SelectiveWidget
|
||||
|
||||
|
||||
class SelectiveDialog(ButtonDialog):
|
||||
result_ready = pyqtSignal(RareGame, SelectiveDownloadsModel)
|
||||
|
||||
def __init__(self, rgame: RareGame, parent=None):
|
||||
super(SelectiveDialog, self).__init__(parent=parent)
|
||||
header = self.tr("Optional downloads for")
|
||||
self.setWindowTitle(game_title(header, rgame.app_title))
|
||||
self.setSubtitle(game_title(header, rgame.app_title))
|
||||
|
||||
self.rgame = rgame
|
||||
self.selective_widget = SelectiveWidget(rgame, rgame.igame.platform, self)
|
||||
|
||||
container = QGroupBox(self.tr("Optional downloads"), self)
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.addWidget(self.selective_widget)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(container)
|
||||
|
||||
self.setCentralLayout(layout)
|
||||
|
||||
self.accept_button.setText(self.tr("Verify"))
|
||||
self.accept_button.setIcon(qta_icon("fa.check"))
|
||||
|
||||
self.options: SelectiveDownloadsModel = SelectiveDownloadsModel(rgame.app_name)
|
||||
|
||||
def done_handler(self):
|
||||
self.result_ready.emit(self.rgame, self.options)
|
||||
|
||||
def accept_handler(self):
|
||||
self.options.accepted = True
|
||||
self.options.install_tag = self.selective_widget.install_tags()
|
||||
|
||||
def reject_handler(self):
|
||||
self.options.accepted = False
|
||||
self.options.install_tag = None
|
62
rare/components/dialogs/uninstall_dialog.py
Normal file
62
rare/components/dialogs/uninstall_dialog.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QCheckBox,
|
||||
)
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.install import UninstallOptionsModel
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.dialogs import ButtonDialog, game_title
|
||||
|
||||
|
||||
class UninstallDialog(ButtonDialog):
|
||||
result_ready = pyqtSignal(UninstallOptionsModel)
|
||||
|
||||
def __init__(self, rgame: RareGame, options: UninstallOptionsModel, parent=None):
|
||||
super(UninstallDialog, self).__init__(parent=parent)
|
||||
header = self.tr("Uninstall")
|
||||
self.setWindowTitle(game_title(header, rgame.app_title))
|
||||
self.setSubtitle(game_title(header, rgame.app_title))
|
||||
|
||||
self.keep_files = QCheckBox(self.tr("Keep files"))
|
||||
self.keep_files.setChecked(bool(options.keep_files))
|
||||
self.keep_files.setEnabled(not rgame.is_overlay)
|
||||
|
||||
self.keep_config = QCheckBox(self.tr("Keep configuation"))
|
||||
self.keep_config.setChecked(bool(options.keep_config))
|
||||
self.keep_config.setEnabled(not rgame.is_overlay)
|
||||
|
||||
self.keep_overlay_keys = QCheckBox(self.tr("Keep EOS Overlay registry keys"))
|
||||
self.keep_overlay_keys.setChecked(bool(options.keep_overlay_keys))
|
||||
self.keep_overlay_keys.setEnabled(rgame.is_overlay)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.keep_files)
|
||||
layout.addWidget(self.keep_config)
|
||||
layout.addWidget(self.keep_overlay_keys)
|
||||
|
||||
self.setCentralLayout(layout)
|
||||
|
||||
self.accept_button.setText(self.tr("Uninstall"))
|
||||
self.accept_button.setIcon(qta_icon("ri.uninstall-line"))
|
||||
self.accept_button.setObjectName("UninstallButton")
|
||||
|
||||
if rgame.sdl_name is not None:
|
||||
self.keep_config.setChecked(True)
|
||||
|
||||
self.options: UninstallOptionsModel = options
|
||||
|
||||
def done_handler(self) -> None:
|
||||
self.result_ready.emit(self.options)
|
||||
|
||||
def accept_handler(self):
|
||||
self.options.values = (
|
||||
True,
|
||||
self.keep_files.isChecked(),
|
||||
self.keep_config.isChecked(),
|
||||
self.keep_overlay_keys.isChecked(),
|
||||
)
|
||||
|
||||
def reject_handler(self):
|
||||
self.options.values = (None, None, None, None)
|
266
rare/components/main_window.py
Normal file
266
rare/components/main_window.py
Normal file
|
@ -0,0 +1,266 @@
|
|||
import os
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import Qt, QSettings, QTimer, QSize, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QCloseEvent, QCursor
|
||||
from PyQt5.QtWidgets import (
|
||||
QMainWindow,
|
||||
QApplication,
|
||||
QStatusBar,
|
||||
QScrollArea,
|
||||
QScroller,
|
||||
QComboBox,
|
||||
QMessageBox,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
)
|
||||
|
||||
from rare.models.options import options
|
||||
from rare.components.tabs import MainTabWidget
|
||||
from rare.components.tray_icon import TrayIcon
|
||||
from rare.shared import RareCore
|
||||
from rare.shared.workers.worker import QueueWorkerState
|
||||
from rare.utils.paths import lock_file
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
|
||||
logger = getLogger("MainWindow")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
# int: exit code
|
||||
exit_app: pyqtSignal = pyqtSignal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
self.__exit_code = 0
|
||||
self.__accept_close = False
|
||||
self._window_launched = False
|
||||
super(MainWindow, self).__init__(parent=parent)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = RareCore.instance().core()
|
||||
self.signals = RareCore.instance().signals()
|
||||
self.args = RareCore.instance().args()
|
||||
self.settings = QSettings()
|
||||
|
||||
self.setWindowTitle("Rare - GUI for legendary")
|
||||
self.tab_widget = MainTabWidget(self)
|
||||
self.tab_widget.exit_app.connect(self.__on_exit_app)
|
||||
self.setCentralWidget(self.tab_widget)
|
||||
|
||||
# Set up status bar stuff (jumping through a lot of hoops)
|
||||
self.status_bar = QStatusBar(self)
|
||||
self.setStatusBar(self.status_bar)
|
||||
|
||||
self.active_label = QLabel(self.tr("Active:"), self.status_bar)
|
||||
# lk: set top and botton margins to accommodate border for scroll area labels
|
||||
self.active_label.setContentsMargins(5, 1, 0, 1)
|
||||
self.status_bar.addWidget(self.active_label)
|
||||
|
||||
self.active_container = QWidget(self.status_bar)
|
||||
active_layout = QHBoxLayout(self.active_container)
|
||||
active_layout.setContentsMargins(0, 0, 0, 0)
|
||||
active_layout.setSizeConstraint(QHBoxLayout.SetFixedSize)
|
||||
self.status_bar.addWidget(self.active_container, stretch=0)
|
||||
|
||||
self.queued_label = QLabel(self.tr("Queued:"), self.status_bar)
|
||||
# lk: set top and botton margins to accommodate border for scroll area labels
|
||||
self.queued_label.setContentsMargins(5, 1, 0, 1)
|
||||
self.status_bar.addPermanentWidget(self.queued_label)
|
||||
|
||||
self.queued_scroll = QScrollArea(self.status_bar)
|
||||
self.queued_scroll.setFrameStyle(QScrollArea.NoFrame)
|
||||
self.queued_scroll.setWidgetResizable(True)
|
||||
self.queued_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.queued_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.queued_scroll.setFixedHeight(self.queued_label.sizeHint().height())
|
||||
self.status_bar.addPermanentWidget(self.queued_scroll, stretch=1)
|
||||
|
||||
self.queued_container = QWidget(self.queued_scroll)
|
||||
queued_layout = QHBoxLayout(self.queued_container)
|
||||
queued_layout.setContentsMargins(0, 0, 0, 0)
|
||||
queued_layout.setSizeConstraint(QHBoxLayout.SetFixedSize)
|
||||
|
||||
self.active_label.setVisible(False)
|
||||
self.active_container.setVisible(False)
|
||||
self.queued_label.setVisible(False)
|
||||
self.queued_scroll.setVisible(False)
|
||||
|
||||
self.signals.application.update_statusbar.connect(self.update_statusbar)
|
||||
|
||||
# self.status_timer = QTimer(self)
|
||||
# self.status_timer.timeout.connect(self.update_statusbar)
|
||||
# self.status_timer.setInterval(5000)
|
||||
# self.status_timer.start()
|
||||
|
||||
width, height = 1280, 720
|
||||
if self.settings.value(*options.save_size):
|
||||
width, height = self.settings.value(*options.window_size)
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
if not self.args.offline:
|
||||
try:
|
||||
from rare.utils.discord_rpc import DiscordRPC
|
||||
|
||||
self.discord_rpc = DiscordRPC()
|
||||
except ModuleNotFoundError:
|
||||
logger.warning("Discord RPC module not found")
|
||||
|
||||
self.singleton_timer = QTimer(self)
|
||||
self.singleton_timer.setInterval(1000)
|
||||
self.singleton_timer.timeout.connect(self.timer_finished)
|
||||
self.singleton_timer.start()
|
||||
|
||||
self.tray_icon: TrayIcon = TrayIcon(self)
|
||||
self.tray_icon.exit_app.connect(self.__on_exit_app)
|
||||
self.tray_icon.show_app.connect(self.show)
|
||||
self.tray_icon.activated.connect(lambda r: self.toggle() if r == self.tray_icon.DoubleClick else None)
|
||||
|
||||
# enable kinetic scrolling
|
||||
for scroll_area in self.findChildren(QScrollArea):
|
||||
if not scroll_area.property("no_kinetic_scroll"):
|
||||
QScroller.grabGesture(scroll_area.viewport(), QScroller.LeftMouseButtonGesture)
|
||||
|
||||
# fix scrolling
|
||||
for combo_box in scroll_area.findChildren(QComboBox):
|
||||
combo_box.wheelEvent = lambda e: e.ignore()
|
||||
|
||||
def center_window(self):
|
||||
# get the margins of the decorated window
|
||||
margins = self.windowHandle().frameMargins()
|
||||
# get the screen the cursor is on
|
||||
current_screen = QApplication.screenAt(QCursor.pos())
|
||||
if not current_screen:
|
||||
current_screen = QApplication.primaryScreen()
|
||||
# get the available screen geometry (excludes panels/docks)
|
||||
screen_rect = current_screen.availableGeometry()
|
||||
decor_width = margins.left() + margins.right()
|
||||
decor_height = margins.top() + margins.bottom()
|
||||
window_size = QSize(self.width(), self.height()).boundedTo(
|
||||
screen_rect.size() - QSize(decor_width, decor_height)
|
||||
)
|
||||
|
||||
self.resize(window_size)
|
||||
self.move(screen_rect.center() - self.rect().adjusted(0, 0, decor_width, decor_height).center())
|
||||
|
||||
@pyqtSlot()
|
||||
def show(self) -> None:
|
||||
super(MainWindow, self).show()
|
||||
if not self._window_launched:
|
||||
self.center_window()
|
||||
self._window_launched = True
|
||||
|
||||
def hide(self) -> None:
|
||||
if self.settings.value(*options.save_size):
|
||||
size = self.size().width(), self.size().height()
|
||||
self.settings.setValue(options.window_size.key, size)
|
||||
super(MainWindow, self).hide()
|
||||
|
||||
def toggle(self):
|
||||
if self.isHidden():
|
||||
self.show()
|
||||
else:
|
||||
self.hide()
|
||||
|
||||
@pyqtSlot()
|
||||
def update_statusbar(self):
|
||||
self.active_label.setVisible(False)
|
||||
self.active_container.setVisible(False)
|
||||
self.queued_label.setVisible(False)
|
||||
self.queued_scroll.setVisible(False)
|
||||
for label in self.active_container.findChildren(QLabel, options=Qt.FindDirectChildrenOnly):
|
||||
self.active_container.layout().removeWidget(label)
|
||||
label.deleteLater()
|
||||
for label in self.queued_container.findChildren(QLabel, options=Qt.FindDirectChildrenOnly):
|
||||
self.queued_container.layout().removeWidget(label)
|
||||
label.deleteLater()
|
||||
for info in self.rcore.queue_info():
|
||||
label = ElideLabel(info.app_title)
|
||||
label.setObjectName("QueueWorkerLabel")
|
||||
label.setToolTip(f"<b>{info.worker_type}</b>: {info.app_title}")
|
||||
label.setProperty("workerType", info.worker_type)
|
||||
label.setFixedWidth(150)
|
||||
label.setContentsMargins(3, 0, 3, 0)
|
||||
if info.state == QueueWorkerState.ACTIVE:
|
||||
self.active_container.layout().addWidget(label)
|
||||
self.active_label.setVisible(True)
|
||||
self.active_container.setVisible(True)
|
||||
elif info.state == QueueWorkerState.QUEUED:
|
||||
self.queued_container.layout().addWidget(label)
|
||||
self.queued_label.setVisible(True)
|
||||
self.queued_scroll.setVisible(True)
|
||||
|
||||
def timer_finished(self):
|
||||
file_path = lock_file()
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, "r") as file:
|
||||
action = file.read()
|
||||
if action.startswith("show"):
|
||||
self.show()
|
||||
os.remove(file_path)
|
||||
self.singleton_timer.start()
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(int)
|
||||
def __on_exit_app(self, exit_code=0) -> None:
|
||||
self.__exit_code = exit_code
|
||||
self.close()
|
||||
|
||||
def close(self) -> bool:
|
||||
self.__accept_close = True
|
||||
return super(MainWindow, self).close()
|
||||
|
||||
def closeEvent(self, e: QCloseEvent) -> None:
|
||||
# lk: `accept_close` is set to `True` by the `close()` method, overrides exiting to tray in `closeEvent()`
|
||||
# lk: ensures exiting instead of hiding when `close()` is called programmatically
|
||||
if not self.__accept_close:
|
||||
if self.settings.value(*options.sys_tray):
|
||||
self.hide()
|
||||
e.ignore()
|
||||
return
|
||||
|
||||
# FIXME: Move this to RareCore once the download manager is implemented
|
||||
if self.rcore.queue_threadpool.activeThreadCount():
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
self.tr("Quit {}?").format(QApplication.applicationName()),
|
||||
self.tr(
|
||||
"There are currently running operations. "
|
||||
"Rare cannot exit until they are completed.\n\n"
|
||||
"Do you want to clear the queue?"
|
||||
),
|
||||
buttons=(QMessageBox.Yes | QMessageBox.No),
|
||||
defaultButton=QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.rcore.queue_threadpool.clear()
|
||||
for qw in self.rcore.queued_workers():
|
||||
self.rcore.dequeue_worker(qw)
|
||||
self.update_statusbar()
|
||||
e.ignore()
|
||||
return
|
||||
elif self.tab_widget.downloads_tab.is_download_active:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
self.tr("Quit {}?").format(QApplication.applicationName()),
|
||||
self.tr(
|
||||
"There is an active download. "
|
||||
"Quitting Rare now will stop the download.\n\n"
|
||||
"Are you sure you want to quit?"
|
||||
),
|
||||
buttons=(QMessageBox.Yes | QMessageBox.No),
|
||||
defaultButton=QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.tab_widget.downloads_tab.stop_download(omit_queue=True)
|
||||
else:
|
||||
e.ignore()
|
||||
return
|
||||
# FIXME: End of FIXME
|
||||
self.singleton_timer.stop()
|
||||
self.tray_icon.deleteLater()
|
||||
self.hide()
|
||||
self.exit_app.emit(self.__exit_code)
|
||||
super(MainWindow, self).closeEvent(e)
|
||||
|
118
rare/components/tabs/__init__.py
Normal file
118
rare/components/tabs/__init__.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from PyQt5.QtCore import QSize, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QMenu, QTabWidget, QWidget, QWidgetAction, QShortcut, QMessageBox
|
||||
|
||||
from rare.shared import RareCore, LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
|
||||
from rare.utils.misc import qta_icon, ExitCodes
|
||||
from .account import AccountWidget
|
||||
from .downloads import DownloadsTab
|
||||
from .games import GamesLibrary
|
||||
from .settings import SettingsTab
|
||||
from .settings.debug import DebugSettings
|
||||
from .store import StoreTab
|
||||
from .tab_widgets import MainTabBar, TabButtonWidget
|
||||
|
||||
|
||||
class MainTabWidget(QTabWidget):
|
||||
# int: exit code
|
||||
exit_app: pyqtSignal = pyqtSignal(int)
|
||||
|
||||
def __init__(self, parent):
|
||||
super(MainTabWidget, self).__init__(parent=parent)
|
||||
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.signals = GlobalSignalsSingleton()
|
||||
self.args = ArgumentsSingleton()
|
||||
|
||||
self.tab_bar = MainTabBar(parent=self)
|
||||
self.setTabBar(self.tab_bar)
|
||||
|
||||
# Generate Tabs
|
||||
self.games_tab = GamesLibrary(self)
|
||||
self.games_index = self.addTab(self.games_tab, self.tr("Games"))
|
||||
|
||||
# Downloads Tab after Games Tab to use populated RareCore games list
|
||||
self.downloads_tab = DownloadsTab(self)
|
||||
self.downloads_index = self.addTab(self.downloads_tab, "")
|
||||
self.downloads_tab.update_title.connect(self.__on_downloads_update_title)
|
||||
self.downloads_tab.update_queues_count()
|
||||
self.setTabEnabled(self.downloads_index, not self.args.offline)
|
||||
|
||||
if not self.args.offline:
|
||||
self.store_tab = StoreTab(self.core, parent=self)
|
||||
self.store_index = self.addTab(self.store_tab, self.tr("Store (Beta)"))
|
||||
self.setTabEnabled(self.store_index, not self.args.offline)
|
||||
|
||||
# Space Tab
|
||||
space_index = self.addTab(QWidget(self), "")
|
||||
self.setTabEnabled(space_index, False)
|
||||
self.tab_bar.expanded = space_index
|
||||
# Button
|
||||
button_index = self.addTab(QWidget(self), "")
|
||||
self.setTabEnabled(button_index, False)
|
||||
|
||||
self.account_widget = AccountWidget(self)
|
||||
self.account_widget.exit_app.connect(self.__on_exit_app)
|
||||
account_action = QWidgetAction(self)
|
||||
account_action.setDefaultWidget(self.account_widget)
|
||||
account_button = TabButtonWidget(qta_icon("mdi.account-circle", fallback="fa.user"), tooltip="Menu")
|
||||
account_menu = QMenu(account_button)
|
||||
account_menu.addAction(account_action)
|
||||
account_button.setMenu(account_menu)
|
||||
self.tab_bar.setTabButton(
|
||||
button_index, MainTabBar.RightSide, account_button
|
||||
)
|
||||
|
||||
self.settings_tab = SettingsTab(self)
|
||||
self.settings_index = self.addTab(self.settings_tab, qta_icon("fa.gear"), "")
|
||||
self.settings_tab.about.update_available_ready.connect(
|
||||
lambda: self.tab_bar.setTabText(self.settings_index, "(!)")
|
||||
)
|
||||
|
||||
# Open game list on click on Games tab button
|
||||
self.tabBarClicked.connect(self.mouse_clicked)
|
||||
|
||||
# shortcuts
|
||||
QShortcut("Alt+1", self).activated.connect(lambda: self.setCurrentIndex(self.games_index))
|
||||
if not self.args.offline:
|
||||
QShortcut("Alt+2", self).activated.connect(lambda: self.setCurrentIndex(self.downloads_index))
|
||||
QShortcut("Alt+3", self).activated.connect(lambda: self.setCurrentIndex(self.store_index))
|
||||
QShortcut("Alt+4", self).activated.connect(lambda: self.setCurrentIndex(self.settings_index))
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __on_downloads_update_title(self, num_downloads: int):
|
||||
self.setTabText(self.indexOf(self.downloads_tab), self.tr("Downloads ({})").format(num_downloads))
|
||||
|
||||
def mouse_clicked(self, index):
|
||||
if index == self.games_index:
|
||||
self.games_tab.setCurrentWidget(self.games_tab.games_page)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self.tab_bar.setMinimumWidth(self.width())
|
||||
super(MainTabWidget, self).resizeEvent(event)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __on_exit_app(self, exit_code: int):
|
||||
# FIXME: Don't allow logging out if there are active downloads
|
||||
if self.downloads_tab.is_download_active:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Quit") if exit_code == ExitCodes.EXIT else self.tr("Logout"),
|
||||
self.tr("There are active downloads. Stop them before trying to quit."),
|
||||
)
|
||||
return
|
||||
# FIXME: End of FIXME
|
||||
if exit_code == ExitCodes.LOGOUT:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
self.tr("Logout"),
|
||||
self.tr("Do you really want to logout <b>{}</b>?").format(self.core.lgd.userdata.get("display_name")),
|
||||
buttons=(QMessageBox.Yes | QMessageBox.No),
|
||||
defaultButton=QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self.core.lgd.invalidate_userdata()
|
||||
else:
|
||||
return
|
||||
self.exit_app.emit(exit_code) # restart exit code
|
47
rare/components/tabs/account/__init__.py
Normal file
47
rare/components/tabs/account/__init__.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
import webbrowser
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
||||
|
||||
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
|
||||
from rare.utils.misc import qta_icon, ExitCodes
|
||||
|
||||
|
||||
class AccountWidget(QWidget):
|
||||
exit_app: pyqtSignal = pyqtSignal(int)
|
||||
logout: pyqtSignal = pyqtSignal()
|
||||
|
||||
def __init__(self, parent):
|
||||
super(AccountWidget, self).__init__(parent=parent)
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.signals = GlobalSignalsSingleton()
|
||||
|
||||
username = self.core.lgd.userdata.get("displayName")
|
||||
if not username:
|
||||
username = "Offline"
|
||||
|
||||
self.open_browser = QPushButton(qta_icon("fa.external-link"), self.tr("Account settings"))
|
||||
self.open_browser.clicked.connect(
|
||||
lambda: webbrowser.open(
|
||||
"https://www.epicgames.com/account/personal?productName=epicgames"
|
||||
)
|
||||
)
|
||||
self.logout_button = QPushButton(self.tr("Logout"), parent=self)
|
||||
self.logout_button.clicked.connect(self.__on_logout)
|
||||
self.quit_button = QPushButton(self.tr("Quit"), parent=self)
|
||||
self.quit_button.clicked.connect(self.__on_quit)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(self.tr("Account")))
|
||||
layout.addWidget(QLabel(self.tr("Logged in as <b>{}</b>").format(username)))
|
||||
layout.addWidget(self.open_browser)
|
||||
layout.addWidget(self.logout_button)
|
||||
layout.addWidget(self.quit_button)
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_quit(self):
|
||||
self.exit_app.emit(ExitCodes.EXIT)
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_logout(self):
|
||||
self.exit_app.emit(ExitCodes.LOGOUT)
|
366
rare/components/tabs/downloads/__init__.py
Normal file
366
rare/components/tabs/downloads/__init__.py
Normal file
|
@ -0,0 +1,366 @@
|
|||
import datetime
|
||||
import platform
|
||||
from logging import getLogger
|
||||
from typing import Union, Optional
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, QSettings, pyqtSlot, QThreadPool, Qt
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget,
|
||||
QMessageBox, QScrollArea, QVBoxLayout, QSizePolicy,
|
||||
)
|
||||
|
||||
from rare.components.dialogs.install_dialog import InstallDialog
|
||||
from rare.components.dialogs.uninstall_dialog import UninstallDialog
|
||||
from rare.lgndr.models.downloading import UIUpdate
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.image import ImageSize
|
||||
from rare.models.install import InstallOptionsModel, InstallQueueItemModel, UninstallOptionsModel
|
||||
from rare.models.options import options
|
||||
from rare.shared import RareCore
|
||||
from rare.shared.workers.install_info import InstallInfoWorker
|
||||
from rare.shared.workers.uninstall import UninstallWorker
|
||||
from rare.utils.misc import format_size
|
||||
from rare.utils.paths import create_desktop_link, desktop_links_supported
|
||||
from .download import DownloadWidget
|
||||
from .groups import UpdateGroup, QueueGroup
|
||||
from .thread import DlThread, DlResultModel, DlResultCode
|
||||
from .widgets import UpdateWidget, QueueWidget
|
||||
|
||||
logger = getLogger("Download")
|
||||
|
||||
|
||||
def get_time(seconds: Union[int, float]) -> str:
|
||||
return str(datetime.timedelta(seconds=seconds))
|
||||
|
||||
|
||||
class DownloadsTab(QWidget):
|
||||
# int: number of updates
|
||||
update_title = pyqtSignal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(DownloadsTab, self).__init__(parent=parent)
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = RareCore.instance().core()
|
||||
self.signals = RareCore.instance().signals()
|
||||
self.args = RareCore.instance().args()
|
||||
|
||||
self.__thread: Optional[DlThread] = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.download_widget = DownloadWidget(self)
|
||||
self.download_widget.ui.kill_button.clicked.connect(self.stop_download)
|
||||
layout.addWidget(self.download_widget)
|
||||
|
||||
self.queue_scrollarea = QScrollArea(self)
|
||||
self.queue_scrollarea.setWidgetResizable(True)
|
||||
self.queue_scrollarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.queue_scrollarea.setFrameStyle(QScrollArea.Plain | QScrollArea.NoFrame)
|
||||
layout.addWidget(self.queue_scrollarea)
|
||||
|
||||
queue_contents = QWidget(self.queue_scrollarea)
|
||||
queue_contents.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.queue_scrollarea.setWidget(queue_contents)
|
||||
self.queue_scrollarea.widget().setAutoFillBackground(False)
|
||||
self.queue_scrollarea.viewport().setAutoFillBackground(False)
|
||||
|
||||
queue_contents_layout = QVBoxLayout(queue_contents)
|
||||
queue_contents_layout.setContentsMargins(0, 0, 3, 0)
|
||||
|
||||
self.queue_group = QueueGroup(self)
|
||||
self.queue_group.update_count.connect(self.update_queues_count)
|
||||
self.queue_group.removed.connect(self.__on_queue_removed)
|
||||
self.queue_group.force.connect(self.__on_queue_force)
|
||||
queue_contents_layout.addWidget(self.queue_group)
|
||||
|
||||
self.updates_group = UpdateGroup(self)
|
||||
self.updates_group.update_count.connect(self.update_queues_count)
|
||||
self.updates_group.enqueue.connect(self.__get_install_options)
|
||||
queue_contents_layout.addWidget(self.updates_group)
|
||||
|
||||
self.__check_updates()
|
||||
self.__reset_download()
|
||||
|
||||
self.signals.game.install.connect(self.__get_install_options)
|
||||
self.signals.game.uninstall.connect(self.__get_uninstall_options)
|
||||
self.signals.download.enqueue.connect(self.__add_update)
|
||||
self.signals.download.dequeue.connect(self.__remove_update)
|
||||
|
||||
self.__forced_item: Optional[InstallQueueItemModel] = None
|
||||
self.__omit_requeue = False
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(int)
|
||||
def update_queues_count(self):
|
||||
count = self.updates_group.count() + self.queue_group.count() + (1 if self.is_download_active else 0)
|
||||
self.update_title.emit(count)
|
||||
|
||||
@property
|
||||
def is_download_active(self):
|
||||
return self.__thread is not None
|
||||
|
||||
def __check_updates(self):
|
||||
for rgame in self.rcore.updates:
|
||||
self.__add_update(rgame)
|
||||
|
||||
@pyqtSlot(str)
|
||||
@pyqtSlot(RareGame)
|
||||
def __add_update(self, update: Union[str, RareGame]):
|
||||
if isinstance(update, str):
|
||||
update = self.rcore.get_game(update)
|
||||
|
||||
auto_update = QSettings(self).value(
|
||||
f"{update.app_name}/{options.auto_update.key}",
|
||||
QSettings(self).value(*options.auto_update),
|
||||
options.auto_update.dtype
|
||||
)
|
||||
if auto_update:
|
||||
self.__get_install_options(
|
||||
InstallOptionsModel(app_name=update.app_name, update=True, silent=True)
|
||||
)
|
||||
else:
|
||||
self.updates_group.append(update.game, update.igame)
|
||||
self.update_queues_count()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def __remove_update(self, app_name):
|
||||
if self.__thread and self.__thread.item.options.app_name == app_name:
|
||||
self.stop_download(omit_queue=True)
|
||||
if self.queue_group.contains(app_name):
|
||||
self.queue_group.remove(app_name)
|
||||
if self.updates_group.contains(app_name):
|
||||
self.updates_group.remove(app_name)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def __on_queue_removed(self, app_name: str):
|
||||
"""
|
||||
Handle removing a queued item.
|
||||
If the item exists in the updates (it means a repair was removed), re-enable the buttons.
|
||||
If it doesn't exist in the updates, recreate the widget.
|
||||
:param app_name:
|
||||
:return:
|
||||
"""
|
||||
rgame = self.rcore.get_game(app_name)
|
||||
rgame.state = RareGame.State.IDLE
|
||||
if self.updates_group.contains(app_name):
|
||||
self.updates_group.set_widget_enabled(app_name, True)
|
||||
else:
|
||||
if rgame.is_installed and rgame.has_update:
|
||||
self.__add_update(app_name)
|
||||
|
||||
@pyqtSlot(InstallQueueItemModel)
|
||||
def __on_queue_force(self, item: InstallQueueItemModel):
|
||||
if self.__thread:
|
||||
self.stop_download()
|
||||
self.__forced_item = item
|
||||
else:
|
||||
self.__start_download(item)
|
||||
|
||||
def stop_download(self, omit_queue=False):
|
||||
"""
|
||||
Stops the active download, by optionally skipping the queue
|
||||
|
||||
:param omit_queue: bool
|
||||
If `True`, the stopped download won't be added back to the queue.
|
||||
Defaults to `False`
|
||||
:return:
|
||||
"""
|
||||
self.__thread.kill()
|
||||
self.download_widget.ui.kill_button.setEnabled(False)
|
||||
# lk: if we are exiting Rare, wait for thread to finish
|
||||
# `self.on_exit` control whether we try to add the download
|
||||
# back in the queue. If we are on exit we wait for the thread
|
||||
# to finish, we do not care about handling the result really
|
||||
self.__omit_requeue = omit_queue
|
||||
if omit_queue:
|
||||
self.__thread.wait()
|
||||
|
||||
def __refresh_download(self, item: InstallQueueItemModel):
|
||||
worker = InstallInfoWorker(self.core, item.options)
|
||||
worker.signals.result.connect(
|
||||
lambda d: self.__start_download(InstallQueueItemModel(options=item.options, download=d))
|
||||
)
|
||||
worker.signals.failed.connect(
|
||||
lambda m: logger.error(f"Failed to refresh download for {item.options.app_name} with error: {m}")
|
||||
)
|
||||
worker.signals.finished.connect(
|
||||
lambda: logger.info(f"Download refresh worker finished for {item.options.app_name}")
|
||||
)
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
|
||||
def __start_download(self, item: InstallQueueItemModel):
|
||||
rgame = self.rcore.get_game(item.options.app_name)
|
||||
if not rgame.state == RareGame.State.DOWNLOADING:
|
||||
logger.error(
|
||||
f"Can't start download {item.options.app_name}"
|
||||
f"due to incompatible state {RareGame.State(rgame.state).name}"
|
||||
)
|
||||
# lk: invalidate the queue item in case the game was uninstalled
|
||||
self.__requeue_download(InstallQueueItemModel(options=item.options))
|
||||
return
|
||||
if item.expired:
|
||||
self.__refresh_download(item)
|
||||
return
|
||||
dl_thread = DlThread(item, rgame, self.core, self.args.debug)
|
||||
dl_thread.result.connect(self.__on_download_result)
|
||||
dl_thread.progress.connect(self.__on_download_progress)
|
||||
dl_thread.finished.connect(dl_thread.deleteLater)
|
||||
dl_thread.start()
|
||||
self.__thread = dl_thread
|
||||
self.download_widget.ui.kill_button.setDisabled(False)
|
||||
self.download_widget.ui.dl_name.setText(item.download.game.app_title)
|
||||
self.download_widget.setPixmap(self.rcore.image_manager().get_pixmap(
|
||||
rgame.app_name, ImageSize.Wide, True
|
||||
))
|
||||
|
||||
self.signals.application.notify.emit(
|
||||
self.tr("Downloads"),
|
||||
self.tr("Starting: \"{}\" is now downloading.").format(rgame.app_title)
|
||||
)
|
||||
|
||||
@pyqtSlot(UIUpdate, object)
|
||||
def __on_download_progress(self, ui_update: UIUpdate, dl_size: int):
|
||||
self.download_widget.ui.progress_bar.setValue(int(ui_update.progress))
|
||||
self.download_widget.ui.dl_speed.setText(f"{format_size(ui_update.download_compressed_speed)}/s")
|
||||
self.download_widget.ui.cache_used.setText(
|
||||
f"{format_size(ui_update.cache_usage) if ui_update.cache_usage > 1023 else '0KB'}"
|
||||
)
|
||||
self.download_widget.ui.downloaded.setText(
|
||||
f"{format_size(ui_update.total_downloaded)} / {format_size(dl_size)}"
|
||||
)
|
||||
self.download_widget.ui.time_left.setText(get_time(ui_update.estimated_time_left))
|
||||
|
||||
def __requeue_download(self, item: InstallQueueItemModel):
|
||||
rgame = self.rcore.get_game(item.options.app_name)
|
||||
rgame.state = RareGame.State.DOWNLOADING
|
||||
self.queue_group.push_front(item, rgame.igame)
|
||||
logger.info(f"Re-queued download for {rgame.app_name} ({rgame.app_title})")
|
||||
|
||||
@pyqtSlot(DlResultModel)
|
||||
def __on_download_result(self, result: DlResultModel):
|
||||
if result.code == DlResultCode.FINISHED:
|
||||
logger.info(f"Download finished: {result.options.app_name}")
|
||||
if result.shortcut and desktop_links_supported():
|
||||
if not create_desktop_link(
|
||||
app_name=result.options.app_name,
|
||||
app_title=result.app_title,
|
||||
link_name=result.folder_name,
|
||||
link_type="desktop",
|
||||
):
|
||||
# maybe add it to download summary, to show in finished downloads
|
||||
logger.error(f"Failed to create desktop link on {platform.system()}")
|
||||
else:
|
||||
logger.info(f"Created desktop link {result.folder_name} for {result.app_title}")
|
||||
|
||||
self.signals.application.notify.emit(
|
||||
self.tr("Downloads"),
|
||||
self.tr("Finished: \"{}\" is now playable.").format(result.app_title),
|
||||
)
|
||||
|
||||
if self.updates_group.contains(result.options.app_name):
|
||||
self.updates_group.set_widget_enabled(result.options.app_name, True)
|
||||
|
||||
elif result.code == DlResultCode.ERROR:
|
||||
logger.error(f"Download error: {result.options.app_name} ({result.message})")
|
||||
QMessageBox.warning(self, self.tr("Error"), self.tr("Download error: {}").format(result.message))
|
||||
|
||||
elif result.code == DlResultCode.STOPPED:
|
||||
logger.info(f"Download stopped: {result.options.app_name}")
|
||||
if not self.__omit_requeue:
|
||||
self.__requeue_download(InstallQueueItemModel(options=result.options))
|
||||
else:
|
||||
return
|
||||
|
||||
# lk: if we finished a repair, and we have a disabled update, re-enable it
|
||||
if self.updates_group.contains(result.options.app_name):
|
||||
self.updates_group.set_widget_enabled(result.options.app_name, True)
|
||||
|
||||
if result.code == DlResultCode.FINISHED and self.queue_group.count():
|
||||
self.__start_download(self.queue_group.pop_front())
|
||||
elif result.code == DlResultCode.STOPPED and self.__forced_item:
|
||||
self.__start_download(self.__forced_item)
|
||||
self.__forced_item = None
|
||||
else:
|
||||
self.__reset_download()
|
||||
|
||||
def __reset_download(self):
|
||||
self.download_widget.setPixmap(QPixmap())
|
||||
self.download_widget.ui.kill_button.setDisabled(True)
|
||||
self.download_widget.ui.dl_name.setText(self.tr("No active download"))
|
||||
self.download_widget.ui.progress_bar.setValue(0)
|
||||
self.download_widget.ui.dl_speed.setText("...")
|
||||
self.download_widget.ui.time_left.setText("...")
|
||||
self.download_widget.ui.cache_used.setText("...")
|
||||
self.download_widget.ui.downloaded.setText("...")
|
||||
self.__thread = None
|
||||
|
||||
@pyqtSlot(InstallOptionsModel)
|
||||
def __get_install_options(self, options: InstallOptionsModel):
|
||||
rgame = self.rcore.get_game(options.app_name)
|
||||
rgame.state = RareGame.State.DOWNLOADING
|
||||
install_dialog = InstallDialog(
|
||||
rgame,
|
||||
options=options,
|
||||
parent=self,
|
||||
)
|
||||
install_dialog.result_ready.connect(self.__on_install_dialog_closed)
|
||||
install_dialog.execute()
|
||||
|
||||
@pyqtSlot(InstallQueueItemModel)
|
||||
def __on_install_dialog_closed(self, item: InstallQueueItemModel):
|
||||
rgame = self.rcore.get_game(item.options.app_name)
|
||||
if item and not item.download.game.is_dlc and not item.download.analysis.dl_size:
|
||||
rgame.set_installed(True)
|
||||
rgame.state = RareGame.State.IDLE
|
||||
return
|
||||
if item:
|
||||
# lk: start update only if there is no other active thread and there is no queue
|
||||
if self.__thread is None and not self.queue_group.count():
|
||||
self.__start_download(item)
|
||||
else:
|
||||
rgame = self.rcore.get_game(item.options.app_name)
|
||||
self.queue_group.push_back(item, rgame.igame)
|
||||
# lk: Handle repairing into the current version
|
||||
# When we add something to the queue from repair, we might select to update or not
|
||||
# if we do select to update with repair, we can remove the widget from the updates groups
|
||||
# otherwise we disable it and keep it in the updates
|
||||
if self.updates_group.contains(item.options.app_name):
|
||||
if item.download.igame.version == self.updates_group.get_widget_version(item.options.app_name):
|
||||
self.updates_group.remove(item.options.app_name)
|
||||
else:
|
||||
self.updates_group.set_widget_enabled(item.options.app_name, False)
|
||||
else:
|
||||
if self.updates_group.contains(item.options.app_name):
|
||||
self.updates_group.set_widget_enabled(item.options.app_name, True)
|
||||
rgame.state = RareGame.State.IDLE
|
||||
self.update_queues_count()
|
||||
|
||||
@pyqtSlot(UninstallOptionsModel)
|
||||
def __get_uninstall_options(self, options: UninstallOptionsModel):
|
||||
rgame = self.rcore.get_game(options.app_name)
|
||||
rgame.state = RareGame.State.UNINSTALLING
|
||||
uninstall_dialog = UninstallDialog(
|
||||
rgame,
|
||||
options=options,
|
||||
parent=self,
|
||||
)
|
||||
uninstall_dialog.result_ready.connect(self.__on_uninstall_dialog_closed)
|
||||
uninstall_dialog.open()
|
||||
|
||||
@pyqtSlot(UninstallOptionsModel)
|
||||
def __on_uninstall_dialog_closed(self, options: UninstallOptionsModel):
|
||||
rgame = self.rcore.get_game(options.app_name)
|
||||
if options and options.accepted:
|
||||
rgame.set_installed(False)
|
||||
worker = UninstallWorker(self.core, rgame, options)
|
||||
worker.signals.result.connect(self.__on_uninstall_worker_result)
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
else:
|
||||
rgame.state = RareGame.State.IDLE
|
||||
|
||||
@pyqtSlot(RareGame, bool, str)
|
||||
def __on_uninstall_worker_result(self, rgame: RareGame, success: bool, message: str):
|
||||
if not success:
|
||||
QMessageBox.warning(None, self.tr("Uninstall - {}").format(rgame.app_title), message, QMessageBox.Close)
|
||||
rgame.state = RareGame.State.IDLE
|
74
rare/components/tabs/downloads/download.py
Normal file
74
rare/components/tabs/downloads/download.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from PyQt5.QtCore import QRect, Qt
|
||||
from PyQt5.QtGui import (
|
||||
QPixmap,
|
||||
QImage,
|
||||
QPainter,
|
||||
QBrush,
|
||||
QLinearGradient,
|
||||
QPaintEvent,
|
||||
QPalette,
|
||||
)
|
||||
|
||||
from rare.ui.components.tabs.downloads.download_widget import Ui_DownloadWidget
|
||||
from rare.widgets.image_widget import ImageWidget
|
||||
|
||||
|
||||
class DownloadWidget(ImageWidget):
|
||||
def __init__(self, parent=None):
|
||||
super(DownloadWidget, self).__init__(parent=parent)
|
||||
self.ui = Ui_DownloadWidget()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
"""
|
||||
Painting overrides
|
||||
Let them live here until a better alternative is divised.
|
||||
|
||||
This is also part of list_game_widget and maybe a
|
||||
common base can bring them together.
|
||||
"""
|
||||
|
||||
def prepare_pixmap(self, pixmap: QPixmap) -> QPixmap:
|
||||
device: QImage = QImage(
|
||||
pixmap.size().width() * 1,
|
||||
int(self.sizeHint().height() * pixmap.devicePixelRatioF()) + 1,
|
||||
QImage.Format_ARGB32_Premultiplied,
|
||||
)
|
||||
painter = QPainter(device)
|
||||
brush = QBrush(pixmap)
|
||||
painter.fillRect(device.rect(), brush)
|
||||
# the gradient could be cached and reused as it is expensive
|
||||
gradient = QLinearGradient(0, 0, device.width(), 0)
|
||||
gradient.setColorAt(0.02, Qt.transparent)
|
||||
gradient.setColorAt(0.5, Qt.black)
|
||||
gradient.setColorAt(0.98, Qt.transparent)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_DestinationIn)
|
||||
painter.fillRect(device.rect(), gradient)
|
||||
painter.end()
|
||||
ret = QPixmap.fromImage(device)
|
||||
ret.setDevicePixelRatio(pixmap.devicePixelRatioF())
|
||||
return ret
|
||||
|
||||
def setPixmap(self, pixmap: QPixmap) -> None:
|
||||
# lk: trade some possible delay and start-up time
|
||||
# lk: for faster rendering. Gradients are expensive
|
||||
# lk: so pre-generate the image
|
||||
if not pixmap.isNull():
|
||||
pixmap = self.prepare_pixmap(pixmap)
|
||||
super(DownloadWidget, self).setPixmap(pixmap)
|
||||
|
||||
def paint_image_empty(self, painter: QPainter, a0: QPaintEvent) -> None:
|
||||
# when pixmap object is not available yet, show a gray rectangle
|
||||
painter.setOpacity(0.5 * self._opacity)
|
||||
painter.fillRect(a0.rect(), self.palette().color(QPalette.Window))
|
||||
|
||||
def paint_image_cover(self, painter: QPainter, a0: QPaintEvent) -> None:
|
||||
painter.setOpacity(self._opacity)
|
||||
color = self.palette().color(QPalette.Window).darker(75)
|
||||
painter.fillRect(self.rect(), color)
|
||||
brush = QBrush(self._pixmap)
|
||||
brush.setTransform(self._transform)
|
||||
width = int(self._pixmap.width() / self._pixmap.devicePixelRatioF())
|
||||
origin = self.width() - width
|
||||
painter.setBrushOrigin(origin, 0)
|
||||
fill_rect = QRect(origin, 0, width, self.height())
|
||||
painter.fillRect(fill_rect, brush)
|
224
rare/components/tabs/downloads/groups.py
Normal file
224
rare/components/tabs/downloads/groups.py
Normal file
|
@ -0,0 +1,224 @@
|
|||
from collections import deque
|
||||
from enum import IntEnum
|
||||
from logging import getLogger
|
||||
from typing import Optional, Deque
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget,
|
||||
QGroupBox,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
)
|
||||
from legendary.models.game import Game, InstalledGame
|
||||
|
||||
from rare.components.tabs.downloads.widgets import QueueWidget, UpdateWidget
|
||||
from rare.models.install import InstallOptionsModel, InstallQueueItemModel
|
||||
from rare.utils.misc import widget_object_name
|
||||
|
||||
logger = getLogger("QueueGroup")
|
||||
|
||||
|
||||
class UpdateGroup(QGroupBox):
|
||||
update_count = pyqtSignal(int)
|
||||
enqueue = pyqtSignal(InstallOptionsModel)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(UpdateGroup, self).__init__(parent=parent)
|
||||
self.setObjectName(type(self).__name__)
|
||||
self.setTitle(self.tr("Updates"))
|
||||
self.__text = QLabel(self.tr("No updates available"))
|
||||
self.__text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
# lk: For findChildren to work, the update's layout has to be in a widget
|
||||
self.__container = QWidget(self)
|
||||
self.__container.setVisible(False)
|
||||
container_layout = QVBoxLayout(self.__container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.__text)
|
||||
layout.addWidget(self.__container)
|
||||
|
||||
def __find_widget(self, app_name: str) -> Optional[UpdateWidget]:
|
||||
return self.__container.findChild(UpdateWidget, name=widget_object_name(UpdateWidget, app_name))
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.__container.findChildren(UpdateWidget, options=Qt.FindDirectChildrenOnly))
|
||||
|
||||
def contains(self, app_name: str) -> bool:
|
||||
return self.__find_widget(app_name) is not None
|
||||
|
||||
def __update_group(self):
|
||||
count = self.count()
|
||||
self.__text.setVisible(not count)
|
||||
self.__container.setVisible(bool(count))
|
||||
self.update_count.emit(count)
|
||||
|
||||
def append(self, game: Game, igame: InstalledGame):
|
||||
self.__text.setVisible(False)
|
||||
self.__container.setVisible(True)
|
||||
widget: UpdateWidget = self.__find_widget(game.app_name)
|
||||
if widget is not None:
|
||||
self.__container.layout().removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
widget = UpdateWidget(game, igame, parent=self.__container)
|
||||
widget.destroyed.connect(self.__update_group)
|
||||
widget.enqueue.connect(self.enqueue)
|
||||
self.__container.layout().addWidget(widget)
|
||||
|
||||
def remove(self, app_name: str):
|
||||
widget: UpdateWidget = self.__find_widget(app_name)
|
||||
self.__container.layout().removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
|
||||
def set_widget_enabled(self, app_name: str, enabled: bool):
|
||||
widget: UpdateWidget = self.__find_widget(app_name)
|
||||
widget.set_enabled(enabled)
|
||||
|
||||
def get_widget_version(self, app_name: str) -> str:
|
||||
widget: UpdateWidget = self.__find_widget(app_name)
|
||||
return widget.version()
|
||||
|
||||
|
||||
class QueueGroup(QGroupBox):
|
||||
update_count = pyqtSignal(int)
|
||||
removed = pyqtSignal(str)
|
||||
force = pyqtSignal(InstallQueueItemModel)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(QueueGroup, self).__init__(parent=parent)
|
||||
self.setObjectName(type(self).__name__)
|
||||
self.setTitle(self.tr("Queue"))
|
||||
self.__text = QLabel(self.tr("No downloads in queue"), self)
|
||||
self.__text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
# lk: For findChildren to work, the queue's layout has to be in a widget
|
||||
self.__container = QWidget(self)
|
||||
self.__container.setVisible(False)
|
||||
container_layout = QVBoxLayout(self.__container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.__text)
|
||||
layout.addWidget(self.__container)
|
||||
|
||||
self.__queue: Deque[str] = deque()
|
||||
|
||||
def __find_widget(self, app_name: str) -> Optional[QueueWidget]:
|
||||
return self.__container.findChild(QueueWidget, name=widget_object_name(QueueWidget, app_name))
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.__queue)
|
||||
|
||||
def contains(self, app_name: str) -> bool:
|
||||
if app_name in self.__queue:
|
||||
return self.__find_widget(app_name) is not None
|
||||
else:
|
||||
return False
|
||||
|
||||
def __update_group(self):
|
||||
count = self.count()
|
||||
self.__text.setVisible(not count)
|
||||
self.__container.setVisible(bool(count))
|
||||
self.update_count.emit(count)
|
||||
|
||||
def __create_widget(self, item: InstallQueueItemModel, old_igame: InstalledGame) -> QueueWidget:
|
||||
widget: QueueWidget = QueueWidget(item, old_igame, parent=self.__container)
|
||||
widget.toggle_arrows(self.__queue.index(item.options.app_name), len(self.__queue))
|
||||
widget.destroyed.connect(self.__update_group)
|
||||
widget.destroyed.connect(self.__update_arrows)
|
||||
widget.remove.connect(self.remove)
|
||||
widget.force.connect(self.__on_force)
|
||||
widget.move_up.connect(self.__on_move_up)
|
||||
widget.move_down.connect(self.__on_move_down)
|
||||
return widget
|
||||
|
||||
def push_front(self, item: InstallQueueItemModel, old_igame: InstalledGame):
|
||||
self.__text.setVisible(False)
|
||||
self.__container.setVisible(True)
|
||||
self.__queue.appendleft(item.options.app_name)
|
||||
widget = self.__create_widget(item, old_igame)
|
||||
self.__container.layout().insertWidget(0, widget)
|
||||
if self.count() > 1:
|
||||
app_name = self.__queue[1]
|
||||
other: QueueWidget = self.__find_widget(app_name)
|
||||
other.toggle_arrows(1, len(self.__queue))
|
||||
|
||||
def push_back(self, item: InstallQueueItemModel, old_igame: InstalledGame):
|
||||
self.__text.setVisible(False)
|
||||
self.__container.setVisible(True)
|
||||
self.__queue.append(item.download.game.app_name)
|
||||
widget = self.__create_widget(item, old_igame)
|
||||
self.__container.layout().addWidget(widget)
|
||||
if self.count() > 1:
|
||||
app_name = self.__queue[-2]
|
||||
other: QueueWidget = self.__find_widget(app_name)
|
||||
other.toggle_arrows(len(self.__queue) - 2, len(self.__queue))
|
||||
|
||||
def pop_front(self) -> InstallQueueItemModel:
|
||||
app_name = self.__queue.popleft()
|
||||
widget: QueueWidget = self.__find_widget(app_name)
|
||||
item = widget.item
|
||||
widget.deleteLater()
|
||||
return item
|
||||
|
||||
def __update_arrows(self):
|
||||
"""
|
||||
Check the first, second, last and second to last widgets in the list
|
||||
and update their arrows
|
||||
:return: None
|
||||
"""
|
||||
for idx in [0, 1]:
|
||||
if self.count() > idx:
|
||||
app_name = self.__queue[idx]
|
||||
widget: QueueWidget = self.__find_widget(app_name)
|
||||
widget.toggle_arrows(idx, len(self.__queue))
|
||||
for idx in [1, 2]:
|
||||
if self.count() > idx:
|
||||
app_name = self.__queue[-idx]
|
||||
widget: QueueWidget = self.__find_widget(app_name)
|
||||
widget.toggle_arrows(len(self.__queue) - idx, len(self.__queue))
|
||||
|
||||
def __remove(self, app_name: str):
|
||||
self.__queue.remove(app_name)
|
||||
widget: QueueWidget = self.__find_widget(app_name)
|
||||
self.__container.layout().removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def remove(self, app_name: str):
|
||||
self.__remove(app_name)
|
||||
self.removed.emit(app_name)
|
||||
|
||||
@pyqtSlot(InstallQueueItemModel)
|
||||
def __on_force(self, item: InstallQueueItemModel):
|
||||
self.__remove(item.options.app_name)
|
||||
self.force.emit(item)
|
||||
|
||||
class MoveDirection(IntEnum):
|
||||
UP = -1
|
||||
DOWN = 1
|
||||
|
||||
def __move(self, app_name: str, direction: MoveDirection):
|
||||
"""
|
||||
Moved the widget for `app_name` up or down in the queue and the container
|
||||
:param app_name: The app_name associated with the widget
|
||||
:param direction: -1 to move up, +1 to move down
|
||||
:return: None
|
||||
"""
|
||||
index = self.__queue.index(app_name)
|
||||
self.__queue.remove(app_name)
|
||||
self.__queue.insert(index + int(direction), app_name)
|
||||
widget: QueueWidget = self.__find_widget(app_name)
|
||||
self.__container.layout().insertWidget(index + int(direction), widget)
|
||||
self.__update_arrows()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def __on_move_up(self, app_name: str):
|
||||
self.__move(app_name, QueueGroup.MoveDirection.UP)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def __on_move_down(self, app_name: str):
|
||||
self.__move(app_name, QueueGroup.MoveDirection.DOWN)
|
183
rare/components/tabs/downloads/thread.py
Normal file
183
rare/components/tabs/downloads/thread.py
Normal file
|
@ -0,0 +1,183 @@
|
|||
import os
|
||||
import platform
|
||||
import queue
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from logging import getLogger
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, QProcess
|
||||
|
||||
from rare.lgndr.cli import LegendaryCLI
|
||||
from rare.lgndr.core import LegendaryCore
|
||||
from rare.lgndr.glue.monkeys import DLManagerSignals
|
||||
from rare.lgndr.models.downloading import UIUpdate
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.install import InstallQueueItemModel, InstallOptionsModel
|
||||
|
||||
logger = getLogger("DownloadThread")
|
||||
|
||||
|
||||
class DlResultCode(IntEnum):
|
||||
ERROR = 1
|
||||
STOPPED = 2
|
||||
FINISHED = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class DlResultModel:
|
||||
options: InstallOptionsModel
|
||||
code: DlResultCode = DlResultCode.ERROR
|
||||
message: str = ""
|
||||
dlcs: Optional[List[Dict]] = None
|
||||
sync_saves: bool = False
|
||||
tip_url: str = ""
|
||||
shortcut: bool = False
|
||||
folder_name: str = ""
|
||||
app_title: str = ""
|
||||
|
||||
|
||||
class DlThread(QThread):
|
||||
result = pyqtSignal(DlResultModel)
|
||||
progress = pyqtSignal(UIUpdate, object)
|
||||
|
||||
def __init__(self, item: InstallQueueItemModel, rgame: RareGame, core: LegendaryCore, debug: bool = False):
|
||||
super(DlThread, self).__init__()
|
||||
self.dlm_signals: DLManagerSignals = DLManagerSignals()
|
||||
self.core: LegendaryCore = core
|
||||
self.item: InstallQueueItemModel = item
|
||||
self.dl_size = item.download.analysis.dl_size
|
||||
self.rgame = rgame
|
||||
self.debug = debug
|
||||
|
||||
def __finish(self, result):
|
||||
if result.code == DlResultCode.FINISHED:
|
||||
self.rgame.set_installed(True)
|
||||
self.rgame.state = RareGame.State.IDLE
|
||||
self.rgame.signals.progress.finish.emit(result.code != DlResultCode.FINISHED)
|
||||
self.result.emit(result)
|
||||
|
||||
def __status_callback(self, status: UIUpdate):
|
||||
self.progress.emit(status, self.dl_size)
|
||||
self.rgame.signals.progress.update.emit(int(status.progress))
|
||||
|
||||
def run(self):
|
||||
cli = LegendaryCLI(self.core)
|
||||
self.item.download.dlm.logging_queue = cli.logging_queue
|
||||
self.item.download.dlm.proc_debug = self.debug
|
||||
result = DlResultModel(self.item.options)
|
||||
start_t = time.time()
|
||||
try:
|
||||
self.item.download.dlm.start()
|
||||
self.rgame.state = RareGame.State.DOWNLOADING
|
||||
self.rgame.signals.progress.start.emit()
|
||||
time.sleep(1)
|
||||
while self.item.download.dlm.is_alive():
|
||||
try:
|
||||
self.__status_callback(self.item.download.dlm.status_queue.get(timeout=1.0))
|
||||
except queue.Empty:
|
||||
pass
|
||||
if self.dlm_signals.update:
|
||||
try:
|
||||
self.item.download.dlm.signals_queue.put(self.dlm_signals, block=False, timeout=1.0)
|
||||
except queue.Full:
|
||||
pass
|
||||
time.sleep(self.item.download.dlm.update_interval / 10)
|
||||
self.item.download.dlm.join()
|
||||
except Exception as e:
|
||||
self.kill()
|
||||
self.item.download.dlm.join()
|
||||
end_t = time.time()
|
||||
logger.error(f"Installation failed after {end_t - start_t:.02f} seconds.")
|
||||
logger.warning(f"The following exception occurred while waiting for the downloader to finish: {e!r}.")
|
||||
result.code = DlResultCode.ERROR
|
||||
result.message = f"{e!r}"
|
||||
self.__finish(result)
|
||||
return
|
||||
else:
|
||||
end_t = time.time()
|
||||
if self.dlm_signals.kill is True:
|
||||
logger.info(f"Download stopped after {end_t - start_t:.02f} seconds.")
|
||||
result.code = DlResultCode.STOPPED
|
||||
self.__finish(result)
|
||||
return
|
||||
logger.info(f"Download finished in {end_t - start_t:.02f} seconds.")
|
||||
|
||||
result.code = DlResultCode.FINISHED
|
||||
|
||||
if self.item.options.overlay:
|
||||
self.core.finish_overlay_install(self.item.download.igame)
|
||||
self.__finish(result)
|
||||
return
|
||||
|
||||
if not self.item.options.no_install:
|
||||
postinstall = self.core.install_game(self.item.download.igame)
|
||||
if postinstall:
|
||||
# LegendaryCLI(self.core)._handle_postinstall(
|
||||
# postinstall,
|
||||
# self.item.download.igame,
|
||||
# False,
|
||||
# self.item.options.install_prereqs,
|
||||
# )
|
||||
self._handle_postinstall(postinstall, self.item.download.igame)
|
||||
|
||||
dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name)
|
||||
if dlcs and not self.item.options.skip_dlcs:
|
||||
result.dlcs = []
|
||||
result.dlcs.extend(
|
||||
{
|
||||
"app_name": dlc.app_name,
|
||||
"app_title": dlc.app_title,
|
||||
"app_version": dlc.app_version(self.item.options.platform),
|
||||
}
|
||||
for dlc in dlcs
|
||||
)
|
||||
if (
|
||||
self.item.download.game.supports_cloud_saves
|
||||
or self.item.download.game.supports_mac_cloud_saves
|
||||
) and not self.item.download.game.is_dlc:
|
||||
result.sync_saves = True
|
||||
|
||||
# show tip again after installation finishes so users hopefully actually see it
|
||||
if tip_url := self.core.get_game_tip(self.item.download.igame.app_name):
|
||||
result.tip_url = tip_url
|
||||
|
||||
LegendaryCLI(self.core).install_game_cleanup(
|
||||
self.item.download.game,
|
||||
self.item.download.igame,
|
||||
self.item.download.repair,
|
||||
self.item.download.repair_file,
|
||||
)
|
||||
|
||||
result.shortcut = not self.item.options.update and self.item.options.create_shortcut
|
||||
result.folder_name = self.rgame.folder_name
|
||||
result.app_title = self.rgame.app_title
|
||||
|
||||
self.__finish(result)
|
||||
|
||||
def _handle_postinstall(self, postinstall, igame):
|
||||
logger.info("This game lists the following prerequisites to be installed:")
|
||||
logger.info(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}')
|
||||
if platform.system() == "Windows":
|
||||
if self.item.options.install_prereqs:
|
||||
logger.info("Launching prerequisite executable..")
|
||||
self.core.prereq_installed(igame.app_name)
|
||||
req_path, req_exec = os.path.split(postinstall["path"])
|
||||
work_dir = os.path.join(igame.install_path, req_path)
|
||||
fullpath = os.path.join(work_dir, req_exec)
|
||||
proc = QProcess()
|
||||
proc.setProcessChannelMode(QProcess.MergedChannels)
|
||||
proc.readyReadStandardOutput.connect(
|
||||
lambda: logger.debug(str(proc.readAllStandardOutput().data(), "utf-8", "ignore"))
|
||||
)
|
||||
proc.setProgram(fullpath)
|
||||
proc.setArguments(postinstall.get("args", "").split(" "))
|
||||
proc.setWorkingDirectory(work_dir)
|
||||
proc.start()
|
||||
proc.waitForFinished() # wait, because it is inside the thread
|
||||
else:
|
||||
logger.info("Automatic installation not available on Linux.")
|
||||
|
||||
def kill(self):
|
||||
self.dlm_signals.kill = True
|
163
rare/components/tabs/downloads/widgets.py
Normal file
163
rare/components/tabs/downloads/widgets.py
Normal file
|
@ -0,0 +1,163 @@
|
|||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, Qt, QThreadPool, pyqtSlot
|
||||
from PyQt5.QtWidgets import QWidget, QFrame
|
||||
from legendary.models.downloading import AnalysisResult
|
||||
from legendary.models.game import Game, InstalledGame
|
||||
from qtawesome import icon
|
||||
|
||||
from rare.models.install import InstallQueueItemModel, InstallOptionsModel, InstallDownloadModel
|
||||
from rare.shared import RareCore, ImageManagerSingleton
|
||||
from rare.shared.workers.install_info import InstallInfoWorker
|
||||
from rare.ui.components.tabs.downloads.queue_base_widget import Ui_QueueBaseWidget
|
||||
from rare.ui.components.tabs.downloads.queue_info_widget import Ui_QueueInfoWidget
|
||||
from rare.utils.misc import format_size, widget_object_name, elide_text
|
||||
from rare.widgets.image_widget import ImageWidget, ImageSize
|
||||
|
||||
logger = getLogger("DownloadWidgets")
|
||||
|
||||
|
||||
class QueueInfoWidget(QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
game: Optional[Game],
|
||||
igame: Optional[InstalledGame],
|
||||
analysis: Optional[AnalysisResult] = None,
|
||||
old_igame: Optional[InstalledGame] = None,
|
||||
parent=None,
|
||||
):
|
||||
super(QueueInfoWidget, self).__init__(parent=parent)
|
||||
self.ui = Ui_QueueInfoWidget()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.image_manager = ImageManagerSingleton()
|
||||
|
||||
self.image = ImageWidget(self)
|
||||
self.image.setFixedSize(ImageSize.LibraryIcon)
|
||||
self.ui.image_layout.addWidget(self.image)
|
||||
|
||||
self.ui.queue_info_layout.setAlignment(Qt.AlignTop)
|
||||
|
||||
if game and igame:
|
||||
self.update_information(game, igame, analysis, old_igame)
|
||||
else:
|
||||
self.ui.title.setText("...")
|
||||
self.ui.remote_version.setText("...")
|
||||
self.ui.local_version.setText("...")
|
||||
self.ui.dl_size.setText("...")
|
||||
self.ui.install_size.setText("...")
|
||||
|
||||
if old_igame:
|
||||
self.ui.title.setText(old_igame.title)
|
||||
self.image.setPixmap(self.image_manager.get_pixmap(old_igame.app_name, ImageSize.LibraryIcon))
|
||||
|
||||
def update_information(self, game, igame, analysis, old_igame):
|
||||
self.ui.title.setText(game.app_title)
|
||||
self.ui.remote_version.setText(
|
||||
elide_text(self.ui.remote_version, old_igame.version if old_igame else game.app_version(igame.platform))
|
||||
)
|
||||
self.ui.local_version.setText(elide_text(self.ui.local_version, igame.version))
|
||||
self.ui.dl_size.setText(format_size(analysis.dl_size) if analysis else "")
|
||||
self.ui.install_size.setText(format_size(analysis.install_size) if analysis else "")
|
||||
self.image.setPixmap(self.image_manager.get_pixmap(game.app_name, ImageSize.LibraryIcon))
|
||||
|
||||
|
||||
class UpdateWidget(QFrame):
|
||||
enqueue = pyqtSignal(InstallOptionsModel)
|
||||
|
||||
def __init__(self, game: Game, igame: InstalledGame, parent=None):
|
||||
super(UpdateWidget, self).__init__(parent=parent)
|
||||
self.ui = Ui_QueueBaseWidget()
|
||||
self.ui.setupUi(self)
|
||||
# lk: setObjectName has to be after `setupUi` because it is also set in that function
|
||||
self.setObjectName(widget_object_name(self, game.app_name))
|
||||
|
||||
self.game = game
|
||||
self.igame = igame
|
||||
|
||||
self.ui.queue_buttons.setVisible(False)
|
||||
self.ui.move_buttons.setVisible(False)
|
||||
|
||||
self.info_widget = QueueInfoWidget(game, igame, parent=self)
|
||||
self.ui.info_layout.addWidget(self.info_widget)
|
||||
|
||||
self.ui.update_button.clicked.connect(lambda: self.update_game(True))
|
||||
self.ui.settings_button.clicked.connect(lambda: self.update_game(False))
|
||||
|
||||
def update_game(self, auto: bool):
|
||||
self.ui.update_button.setDisabled(True)
|
||||
self.ui.settings_button.setDisabled(True)
|
||||
self.enqueue.emit(InstallOptionsModel(app_name=self.game.app_name, update=True, silent=auto)) # True if settings
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
self.ui.update_button.setEnabled(enabled)
|
||||
self.ui.settings_button.setEnabled(enabled)
|
||||
|
||||
def version(self) -> str:
|
||||
return self.game.app_version(self.igame.platform)
|
||||
|
||||
|
||||
class QueueWidget(QFrame):
|
||||
# str: app_name
|
||||
move_up = pyqtSignal(str)
|
||||
# str: app_name
|
||||
move_down = pyqtSignal(str)
|
||||
# str: app_name
|
||||
remove = pyqtSignal(str)
|
||||
# InstallQueueItemModel
|
||||
force = pyqtSignal(InstallQueueItemModel)
|
||||
|
||||
def __init__(self, item: InstallQueueItemModel, old_igame: InstalledGame, parent=None):
|
||||
super(QueueWidget, self).__init__(parent=parent)
|
||||
self.ui = Ui_QueueBaseWidget()
|
||||
self.ui.setupUi(self)
|
||||
# lk: setObjectName has to be after `setupUi` because it is also set in that function
|
||||
self.setObjectName(widget_object_name(self, item.options.app_name))
|
||||
|
||||
if not item:
|
||||
self.ui.queue_buttons.setEnabled(False)
|
||||
worker = InstallInfoWorker(RareCore.instance().core(), item.options)
|
||||
worker.signals.result.connect(self.__update_info)
|
||||
worker.signals.failed.connect(
|
||||
lambda m: logger.error(f"Failed to requeue download for {item.options.app_name} with error: {m}")
|
||||
)
|
||||
worker.signals.failed.connect(lambda m: self.remove.emit(item.options.app_name))
|
||||
worker.signals.finished.connect(
|
||||
lambda: logger.info(f"Download requeue worker finished for {item.options.app_name}")
|
||||
)
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
self.info_widget = QueueInfoWidget(None, None, None, old_igame, parent=self)
|
||||
else:
|
||||
self.info_widget = QueueInfoWidget(
|
||||
item.download.game, item.download.igame, item.download.analysis, old_igame, parent=self
|
||||
)
|
||||
self.ui.info_layout.addWidget(self.info_widget)
|
||||
self.ui.update_buttons.setVisible(False)
|
||||
|
||||
self.old_igame = old_igame
|
||||
self.item = item
|
||||
|
||||
self.ui.move_up_button.setIcon(icon("fa.arrow-up"))
|
||||
self.ui.move_up_button.clicked.connect(
|
||||
lambda: self.move_up.emit(self.item.options.app_name)
|
||||
)
|
||||
|
||||
self.ui.move_down_button.setIcon(icon("fa.arrow-down"))
|
||||
self.ui.move_down_button.clicked.connect(
|
||||
lambda: self.move_down.emit(self.item.options.app_name)
|
||||
)
|
||||
|
||||
self.ui.remove_button.clicked.connect(lambda: self.remove.emit(self.item.options.app_name))
|
||||
self.ui.force_button.clicked.connect(lambda: self.force.emit(self.item))
|
||||
|
||||
@pyqtSlot(InstallDownloadModel)
|
||||
def __update_info(self, download: InstallDownloadModel):
|
||||
self.item.download = download
|
||||
if self.item:
|
||||
self.info_widget.update_information(download.game, download.igame, download.analysis, self.old_igame)
|
||||
self.ui.queue_buttons.setEnabled(bool(self.item))
|
||||
|
||||
def toggle_arrows(self, index: int, length: int):
|
||||
self.ui.move_up_button.setEnabled(bool(index))
|
||||
self.ui.move_down_button.setEnabled(bool(length - (index + 1)))
|
156
rare/components/tabs/games/__init__.py
Normal file
156
rare/components/tabs/games/__init__.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import QSettings, Qt, pyqtSlot
|
||||
from PyQt5.QtGui import QShowEvent
|
||||
from PyQt5.QtWidgets import QStackedWidget, QVBoxLayout, QWidget, QScrollArea, QFrame
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import (
|
||||
LegendaryCoreSingleton,
|
||||
GlobalSignalsSingleton,
|
||||
ArgumentsSingleton,
|
||||
ImageManagerSingleton,
|
||||
)
|
||||
from rare.shared import RareCore
|
||||
from rare.models.options import options
|
||||
from .game_info import GameInfoTabs
|
||||
from .game_widgets import LibraryWidgetController, LibraryFilter, LibraryOrder, LibraryView
|
||||
from .game_widgets.icon_game_widget import IconGameWidget
|
||||
from .game_widgets.list_game_widget import ListGameWidget
|
||||
from .head_bar import LibraryHeadBar
|
||||
from .integrations import IntegrationsTabs
|
||||
|
||||
logger = getLogger("GamesLibrary")
|
||||
|
||||
|
||||
class GamesLibrary(QStackedWidget):
|
||||
def __init__(self, parent=None):
|
||||
super(GamesLibrary, self).__init__(parent=parent)
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.signals = GlobalSignalsSingleton()
|
||||
self.args = ArgumentsSingleton()
|
||||
self.image_manager = ImageManagerSingleton()
|
||||
self.settings = QSettings()
|
||||
|
||||
self.games_page = QWidget(parent=self)
|
||||
games_page_layout = QVBoxLayout(self.games_page)
|
||||
self.addWidget(self.games_page)
|
||||
|
||||
self.head_bar = LibraryHeadBar(parent=self.games_page)
|
||||
self.head_bar.goto_import.connect(self.show_import)
|
||||
self.head_bar.goto_egl_sync.connect(self.show_egl_sync)
|
||||
self.head_bar.goto_eos_ubisoft.connect(self.show_eos_ubisoft)
|
||||
games_page_layout.addWidget(self.head_bar)
|
||||
|
||||
self.game_info_page = GameInfoTabs(self)
|
||||
self.game_info_page.back_clicked.connect(lambda: self.setCurrentWidget(self.games_page))
|
||||
self.game_info_page.import_clicked.connect(self.show_import)
|
||||
self.addWidget(self.game_info_page)
|
||||
|
||||
self.integrations_page = IntegrationsTabs(self)
|
||||
self.integrations_page.back_clicked.connect(lambda: self.setCurrentWidget(self.games_page))
|
||||
self.addWidget(self.integrations_page)
|
||||
|
||||
self.view_scroll = QScrollArea(self.games_page)
|
||||
self.view_scroll.setWidgetResizable(True)
|
||||
self.view_scroll.setFrameShape(QFrame.StyledPanel)
|
||||
self.view_scroll.horizontalScrollBar().setDisabled(True)
|
||||
|
||||
library_view = LibraryView(self.settings.value(*options.library_view))
|
||||
self.library_controller = LibraryWidgetController(library_view, self.view_scroll)
|
||||
games_page_layout.addWidget(self.view_scroll)
|
||||
|
||||
self.head_bar.search_bar.textChanged.connect(self.search_games)
|
||||
self.head_bar.search_bar.textChanged.connect(self.scroll_to_top)
|
||||
self.head_bar.filterChanged.connect(self.filter_games)
|
||||
self.head_bar.filterChanged.connect(self.scroll_to_top)
|
||||
self.head_bar.orderChanged.connect(self.order_games)
|
||||
self.head_bar.orderChanged.connect(self.scroll_to_top)
|
||||
self.head_bar.refresh_list.clicked.connect(self.library_controller.update_game_views)
|
||||
|
||||
# signals
|
||||
self.signals.game.installed.connect(self.update_count_games_label)
|
||||
self.signals.game.uninstalled.connect(self.update_count_games_label)
|
||||
|
||||
self.init = True
|
||||
|
||||
def showEvent(self, a0: QShowEvent):
|
||||
if a0.spontaneous() or not self.init:
|
||||
return super().showEvent(a0)
|
||||
self.setup_game_list()
|
||||
self.init = False
|
||||
return super().showEvent(a0)
|
||||
|
||||
@pyqtSlot()
|
||||
def scroll_to_top(self):
|
||||
self.view_scroll.verticalScrollBar().setSliderPosition(
|
||||
self.view_scroll.verticalScrollBar().minimum()
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(str)
|
||||
def show_import(self, app_name: str = None):
|
||||
self.setCurrentWidget(self.integrations_page)
|
||||
self.integrations_page.show_import(app_name)
|
||||
|
||||
@pyqtSlot()
|
||||
def show_egl_sync(self):
|
||||
self.setCurrentWidget(self.integrations_page)
|
||||
self.integrations_page.show_egl_sync()
|
||||
|
||||
@pyqtSlot()
|
||||
def show_eos_ubisoft(self):
|
||||
self.setCurrentWidget(self.integrations_page)
|
||||
self.integrations_page.show_eos_ubisoft()
|
||||
|
||||
@pyqtSlot(RareGame)
|
||||
def show_game_info(self, rgame):
|
||||
self.game_info_page.update_game(rgame)
|
||||
self.setCurrentWidget(self.game_info_page)
|
||||
|
||||
@pyqtSlot()
|
||||
def update_count_games_label(self):
|
||||
self.head_bar.set_games_count(
|
||||
len([game for game in self.rcore.games if game.is_installed]),
|
||||
len(list(self.rcore.games)),
|
||||
)
|
||||
|
||||
def setup_game_list(self):
|
||||
for rgame in self.rcore.games:
|
||||
widget = self.add_library_widget(rgame)
|
||||
if not widget:
|
||||
logger.warning("Excluding %s from the game list", rgame.app_title)
|
||||
continue
|
||||
self.filter_games(self.head_bar.current_filter())
|
||||
self.order_games(self.head_bar.current_order())
|
||||
self.update_count_games_label()
|
||||
|
||||
def add_library_widget(self, rgame: RareGame):
|
||||
try:
|
||||
widget = self.library_controller.add_game(rgame)
|
||||
except Exception as e:
|
||||
logger.error("Could not add widget for %s to library: %s", rgame.app_name, e)
|
||||
return None
|
||||
widget.show_info.connect(self.show_game_info)
|
||||
return widget
|
||||
|
||||
@pyqtSlot(str)
|
||||
def search_games(self, search_text: str = ""):
|
||||
self.filter_games(self.head_bar.current_filter(), search_text)
|
||||
|
||||
@pyqtSlot(object)
|
||||
@pyqtSlot(object, str)
|
||||
def filter_games(self, library_filter: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
|
||||
if not search_text and (t := self.head_bar.search_bar.text()):
|
||||
search_text = t
|
||||
|
||||
self.library_controller.filter_game_views(library_filter, search_text.lower())
|
||||
|
||||
@pyqtSlot(object)
|
||||
@pyqtSlot(object, str)
|
||||
def order_games(self, library_order: LibraryOrder = LibraryFilter.ALL, search_text: str = ""):
|
||||
if not search_text and (t := self.head_bar.search_bar.text()):
|
||||
search_text = t
|
||||
|
||||
self.library_controller.order_game_views(library_order, search_text.lower())
|
94
rare/components/tabs/games/game_info/__init__.py
Normal file
94
rare/components/tabs/games/game_info/__init__.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QKeyEvent
|
||||
from PyQt5.QtWidgets import QTreeView
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
|
||||
from rare.utils.json_formatter import QJsonModel
|
||||
from rare.widgets.side_tab import SideTabWidget, SideTabContents
|
||||
from .dlcs import GameDlcs
|
||||
from .details import GameDetails
|
||||
from .settings import GameSettings
|
||||
from .cloud_saves import CloudSaves
|
||||
|
||||
|
||||
class GameInfoTabs(SideTabWidget):
|
||||
# str: app_name
|
||||
import_clicked = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(GameInfoTabs, self).__init__(show_back=True, parent=parent)
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.signals = GlobalSignalsSingleton()
|
||||
self.args = ArgumentsSingleton()
|
||||
|
||||
self.details_tab = GameDetails(self)
|
||||
self.details_tab.import_clicked.connect(self.import_clicked)
|
||||
self.details_index = self.addTab(self.details_tab, self.tr("Information"))
|
||||
|
||||
self.settings_tab = GameSettings(self)
|
||||
self.settings_index = self.addTab(self.settings_tab, self.tr("Settings"))
|
||||
|
||||
self.cloud_saves_tab = CloudSaves(self)
|
||||
self.cloud_saves_index = self.addTab(self.cloud_saves_tab, self.tr("Cloud Saves"))
|
||||
|
||||
self.dlcs_tab = GameDlcs(self)
|
||||
self.dlcs_index = self.addTab(self.dlcs_tab, self.tr("Downloadable Content"))
|
||||
|
||||
# FIXME: Hiding didn't work, so don't add these tabs in normal mode. Fix this properly later
|
||||
if self.args.debug:
|
||||
self.game_meta_view = GameMetadataView(self)
|
||||
self.game_meta_index = self.addTab(self.game_meta_view, self.tr("Game Metadata"))
|
||||
self.igame_meta_view = GameMetadataView(self)
|
||||
self.igame_meta_index = self.addTab(self.igame_meta_view, self.tr("InstalledGame Metadata"))
|
||||
self.rgame_meta_view = GameMetadataView(self)
|
||||
self.rgame_meta_index = self.addTab(self.rgame_meta_view, self.tr("RareGame Metadata"))
|
||||
|
||||
self.setCurrentIndex(self.details_index)
|
||||
|
||||
def update_game(self, rgame: RareGame):
|
||||
self.details_tab.update_game(rgame)
|
||||
|
||||
self.settings_tab.load_settings(rgame)
|
||||
self.settings_tab.setEnabled(rgame.is_installed or rgame.is_origin)
|
||||
|
||||
self.dlcs_tab.update_dlcs(rgame)
|
||||
self.dlcs_tab.setEnabled(rgame.is_installed and bool(rgame.owned_dlcs))
|
||||
|
||||
self.cloud_saves_tab.update_game(rgame)
|
||||
# self.cloud_saves_tab.setEnabled(rgame.game.supports_cloud_saves or rgame.game.supports_mac_cloud_saves)
|
||||
|
||||
if self.args.debug:
|
||||
self.game_meta_view.update_game(rgame, rgame.game)
|
||||
self.igame_meta_view.update_game(rgame, rgame.igame)
|
||||
self.rgame_meta_view.update_game(rgame, rgame.metadata)
|
||||
|
||||
self.setCurrentIndex(self.details_index)
|
||||
|
||||
def keyPressEvent(self, a0: QKeyEvent):
|
||||
if a0.key() == Qt.Key_Escape:
|
||||
self.back_clicked.emit()
|
||||
|
||||
|
||||
class GameMetadataView(QTreeView, SideTabContents):
|
||||
def __init__(self, parent=None):
|
||||
super(GameMetadataView, self).__init__(parent=parent)
|
||||
self.implements_scrollarea = True
|
||||
self.setColumnWidth(0, 300)
|
||||
self.setWordWrap(True)
|
||||
self.setEditTriggers(QTreeView.NoEditTriggers)
|
||||
self.model = QJsonModel()
|
||||
self.setModel(self.model)
|
||||
self.rgame: Optional[RareGame] = None
|
||||
|
||||
def update_game(self, rgame: RareGame, view):
|
||||
self.rgame = rgame
|
||||
self.set_title.emit(self.rgame.app_title)
|
||||
self.model.clear()
|
||||
try:
|
||||
self.model.load(vars(view))
|
||||
except Exception as e:
|
||||
pass
|
||||
self.resizeColumnToContents(0)
|
228
rare/components/tabs/games/game_info/cloud_saves.py
Normal file
228
rare/components/tabs/games/game_info/cloud_saves.py
Normal file
|
@ -0,0 +1,228 @@
|
|||
import os
|
||||
import platform
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
|
||||
from PyQt5.QtCore import QThreadPool, QSettings, pyqtSlot
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget,
|
||||
QFileDialog,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QMessageBox,
|
||||
QGroupBox,
|
||||
QVBoxLayout,
|
||||
QSpacerItem, QFormLayout,
|
||||
)
|
||||
from legendary.models.game import SaveGameStatus
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import RareCore
|
||||
from rare.shared.workers.wine_resolver import WineSavePathResolver
|
||||
from rare.ui.components.tabs.games.game_info.cloud_settings_widget import Ui_CloudSettingsWidget
|
||||
from rare.ui.components.tabs.games.game_info.cloud_sync_widget import Ui_CloudSyncWidget
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.utils.metrics import timelogger
|
||||
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
|
||||
from rare.widgets.loading_widget import LoadingWidget
|
||||
from rare.widgets.side_tab import SideTabContents
|
||||
|
||||
logger = getLogger("CloudSaves")
|
||||
|
||||
|
||||
class CloudSaves(QWidget, SideTabContents):
|
||||
def __init__(self, parent=None):
|
||||
super(CloudSaves, self).__init__(parent=parent)
|
||||
|
||||
self.sync_widget = QWidget(self)
|
||||
self.sync_ui = Ui_CloudSyncWidget()
|
||||
self.sync_ui.setupUi(self.sync_widget)
|
||||
|
||||
self.info_label = QLabel(self.tr("<b>This game doesn't support cloud saves</b>"))
|
||||
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = RareCore.instance().core()
|
||||
self.settings = QSettings()
|
||||
|
||||
self.sync_ui.icon_local.setPixmap(qta_icon("mdi.harddisk", "fa.desktop").pixmap(128, 128))
|
||||
self.sync_ui.icon_remote.setPixmap(qta_icon("mdi.cloud-outline", "ei.cloud").pixmap(128, 128))
|
||||
|
||||
self.sync_ui.upload_button.clicked.connect(self.upload)
|
||||
self.sync_ui.download_button.clicked.connect(self.download)
|
||||
|
||||
self.loading_widget = LoadingWidget(parent=self.sync_widget)
|
||||
self.loading_widget.setVisible(False)
|
||||
|
||||
self.rgame: RareGame = None
|
||||
|
||||
self.cloud_widget = QGroupBox(self)
|
||||
self.cloud_ui = Ui_CloudSettingsWidget()
|
||||
self.cloud_ui.setupUi(self.cloud_widget)
|
||||
|
||||
self.cloud_save_path_edit = PathEdit(
|
||||
"",
|
||||
file_mode=QFileDialog.DirectoryOnly,
|
||||
placeholder=self.tr('Use "Calculate path" or "Browse" ...'),
|
||||
edit_func=self.edit_save_path,
|
||||
save_func=self.save_save_path,
|
||||
)
|
||||
self.cloud_ui.main_layout.setWidget(
|
||||
self.cloud_ui.main_layout.getWidgetPosition(self.cloud_ui.path_label)[0],
|
||||
QFormLayout.FieldRole,
|
||||
self.cloud_save_path_edit
|
||||
)
|
||||
|
||||
self.compute_save_path_button = QPushButton(qta_icon("fa.magic"), self.tr("Calculate path"))
|
||||
self.compute_save_path_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
||||
self.compute_save_path_button.clicked.connect(self.compute_save_path)
|
||||
self.cloud_ui.main_layout.addRow(None, self.compute_save_path_button)
|
||||
|
||||
self.cloud_ui.sync_check.stateChanged.connect(
|
||||
lambda: self.settings.setValue(
|
||||
f"{self.rgame.app_name}/auto_sync_cloud", self.cloud_ui.sync_check.isChecked()
|
||||
)
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.sync_widget)
|
||||
layout.addWidget(self.cloud_widget)
|
||||
layout.addWidget(self.info_label)
|
||||
layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding))
|
||||
|
||||
def edit_save_path(self, text: str) -> Tuple[bool, str, int]:
|
||||
if platform.system() != "Windows":
|
||||
if os.path.exists(text):
|
||||
return True, text, IndicatorReasonsCommon.VALID
|
||||
else:
|
||||
return False, text, IndicatorReasonsCommon.DIR_NOT_EXISTS
|
||||
return True, text, IndicatorReasonsCommon.VALID
|
||||
|
||||
def save_save_path(self, text: str):
|
||||
if text != self.rgame.save_path:
|
||||
self.rgame.save_path = text
|
||||
|
||||
def upload(self):
|
||||
self.sync_widget.setEnabled(False)
|
||||
self.loading_widget.setVisible(True)
|
||||
self.rgame.upload_saves()
|
||||
|
||||
def download(self):
|
||||
self.sync_widget.setEnabled(False)
|
||||
self.loading_widget.setVisible(True)
|
||||
self.rgame.download_saves()
|
||||
|
||||
def compute_save_path(self):
|
||||
if self.rgame.is_installed and self.rgame.game.supports_cloud_saves:
|
||||
try:
|
||||
with timelogger(logger, "Detecting save path"):
|
||||
new_path = self.core.get_save_path(self.rgame.app_name)
|
||||
if platform.system() != "Windows" and not os.path.exists(new_path):
|
||||
raise ValueError(f'Path "{new_path}" does not exist.')
|
||||
except Exception as e:
|
||||
logger.warning(str(e))
|
||||
resolver = WineSavePathResolver(self.core, self.rgame)
|
||||
# if not resolver.environ.get("WINEPREFIX"):
|
||||
# del resolver
|
||||
# self.cloud_save_path_edit.setText("")
|
||||
# QMessageBox.warning(self, "Warning", "No wine prefix selected. Please set it in settings")
|
||||
# return
|
||||
self.cloud_save_path_edit.setText(self.tr("Loading..."))
|
||||
self.cloud_save_path_edit.setDisabled(True)
|
||||
self.compute_save_path_button.setDisabled(True)
|
||||
|
||||
resolver.signals.result_ready.connect(self.__on_wine_resolver_result)
|
||||
QThreadPool.globalInstance().start(resolver)
|
||||
return
|
||||
else:
|
||||
self.cloud_save_path_edit.setText(new_path)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def __on_wine_resolver_result(self, path, app_name):
|
||||
logger.info("Wine resolver finished for %s", app_name)
|
||||
logger.info("Computed save path: %s", path)
|
||||
if app_name == self.rgame.app_name:
|
||||
self.cloud_save_path_edit.setDisabled(False)
|
||||
self.compute_save_path_button.setDisabled(False)
|
||||
if path and not os.path.exists(path):
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except PermissionError:
|
||||
self.cloud_save_path_edit.setText("")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Error - {}").format(self.rgame.app_title),
|
||||
self.tr(
|
||||
"Error while calculating path for <b>{}</b>. Insufficient permissions to create <b>{}</b>"
|
||||
).format(self.rgame.app_title, path),
|
||||
)
|
||||
return
|
||||
if not path:
|
||||
self.cloud_save_path_edit.setText("")
|
||||
return
|
||||
self.cloud_save_path_edit.setText(path)
|
||||
|
||||
def __update_widget(self):
|
||||
supports_saves = self.rgame.igame is not None and (
|
||||
self.rgame.game.supports_cloud_saves or self.rgame.game.supports_mac_cloud_saves
|
||||
)
|
||||
|
||||
self.sync_widget.setEnabled(
|
||||
bool(supports_saves and self.rgame.save_path)) # and not self.rgame.is_save_up_to_date))
|
||||
|
||||
self.cloud_widget.setEnabled(supports_saves)
|
||||
self.info_label.setVisible(not supports_saves)
|
||||
if not supports_saves:
|
||||
self.sync_ui.date_info_local.setText("None")
|
||||
self.sync_ui.age_label_local.setText("None")
|
||||
self.sync_ui.date_info_remote.setText("None")
|
||||
self.sync_ui.age_label_remote.setText("None")
|
||||
self.cloud_ui.sync_check.setChecked(False)
|
||||
self.cloud_save_path_edit.setText("")
|
||||
return
|
||||
|
||||
status, (dt_local, dt_remote) = self.rgame.save_game_state
|
||||
|
||||
self.sync_ui.date_info_local.setText(
|
||||
dt_local.strftime("%A, %d. %B %Y %X") if dt_local and self.rgame.save_path else "None"
|
||||
)
|
||||
self.sync_ui.date_info_remote.setText(
|
||||
dt_remote.strftime("%A, %d. %B %Y %X") if dt_remote and self.rgame.save_path else "None"
|
||||
)
|
||||
|
||||
newer = self.tr("Newer")
|
||||
self.sync_ui.age_label_local.setText(
|
||||
f"<b>{newer}</b>" if status == SaveGameStatus.LOCAL_NEWER else " "
|
||||
)
|
||||
self.sync_ui.age_label_remote.setText(
|
||||
f"<b>{newer}</b>" if status == SaveGameStatus.REMOTE_NEWER else " "
|
||||
)
|
||||
|
||||
button_disabled = self.rgame.state in [RareGame.State.RUNNING, RareGame.State.SYNCING]
|
||||
self.sync_widget.setDisabled(button_disabled)
|
||||
if self.rgame.state == RareGame.State.SYNCING:
|
||||
self.loading_widget.start()
|
||||
else:
|
||||
self.loading_widget.stop()
|
||||
|
||||
self.sync_ui.upload_button.setDisabled(not dt_local)
|
||||
self.sync_ui.download_button.setDisabled(not dt_remote)
|
||||
|
||||
self.cloud_ui.sync_check.blockSignals(True)
|
||||
self.cloud_ui.sync_check.setChecked(self.rgame.auto_sync_saves)
|
||||
self.cloud_ui.sync_check.blockSignals(False)
|
||||
|
||||
self.cloud_save_path_edit.setText(self.rgame.save_path if self.rgame.save_path else "")
|
||||
if platform.system() == "Windows" and not self.rgame.save_path:
|
||||
self.compute_save_path()
|
||||
|
||||
def update_game(self, rgame: RareGame):
|
||||
if self.rgame:
|
||||
self.rgame.signals.widget.update.disconnect(self.__update_widget)
|
||||
|
||||
self.rgame = rgame
|
||||
|
||||
self.set_title.emit(rgame.app_title)
|
||||
rgame.signals.widget.update.connect(self.__update_widget)
|
||||
|
||||
self.__update_widget()
|
396
rare/components/tabs/games/game_info/details.py
Normal file
396
rare/components/tabs/games/game_info/details.py
Normal file
|
@ -0,0 +1,396 @@
|
|||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
Qt,
|
||||
pyqtSlot,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from rare.models.install import SelectiveDownloadsModel, MoveGameModel
|
||||
from rare.components.dialogs.selective_dialog import SelectiveDialog
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import RareCore
|
||||
from rare.shared.workers import VerifyWorker, MoveWorker
|
||||
from rare.ui.components.tabs.games.game_info.details import Ui_GameDetails
|
||||
from rare.utils.misc import format_size, qta_icon, style_hyperlink
|
||||
from rare.widgets.image_widget import ImageWidget, ImageSize
|
||||
from rare.widgets.side_tab import SideTabContents
|
||||
from rare.components.dialogs.move_dialog import MoveDialog, is_game_dir
|
||||
|
||||
logger = getLogger("GameInfo")
|
||||
|
||||
|
||||
class GameDetails(QWidget, SideTabContents):
|
||||
# str: app_name
|
||||
import_clicked = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(GameDetails, self).__init__(parent=parent)
|
||||
self.ui = Ui_GameDetails()
|
||||
self.ui.setupUi(self)
|
||||
# lk: set object names for CSS properties
|
||||
self.ui.install_button.setObjectName("InstallButton")
|
||||
self.ui.modify_button.setObjectName("InstallButton")
|
||||
self.ui.uninstall_button.setObjectName("UninstallButton")
|
||||
|
||||
self.ui.install_button.setIcon(qta_icon("ri.install-line"))
|
||||
self.ui.import_button.setIcon(qta_icon("mdi.application-import"))
|
||||
|
||||
self.ui.modify_button.setIcon(qta_icon("fa.gear"))
|
||||
self.ui.verify_button.setIcon(qta_icon("fa.check"))
|
||||
self.ui.repair_button.setIcon(qta_icon("fa.wrench"))
|
||||
self.ui.move_button.setIcon(qta_icon("mdi.folder-move-outline"))
|
||||
self.ui.uninstall_button.setIcon(qta_icon("ri.uninstall-line"))
|
||||
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = RareCore.instance().core()
|
||||
self.args = RareCore.instance().args()
|
||||
# self.image_manager = RareCore.instance().image_manager()
|
||||
|
||||
self.rgame: Optional[RareGame] = None
|
||||
|
||||
self.image = ImageWidget(self)
|
||||
self.image.setFixedSize(ImageSize.DisplayTall)
|
||||
self.ui.left_layout.insertWidget(0, self.image, alignment=Qt.AlignTop)
|
||||
|
||||
self.ui.install_button.clicked.connect(self.__on_install)
|
||||
self.ui.import_button.clicked.connect(self.__on_import)
|
||||
self.ui.modify_button.clicked.connect(self.__on_modify)
|
||||
self.ui.verify_button.clicked.connect(self.__on_verify)
|
||||
self.ui.repair_button.clicked.connect(self.__on_repair)
|
||||
self.ui.move_button.clicked.connect(self.__on_move)
|
||||
self.ui.uninstall_button.clicked.connect(self.__on_uninstall)
|
||||
|
||||
self.steam_grade_ratings = {
|
||||
"platinum": self.tr("Platinum"),
|
||||
"gold": self.tr("Gold"),
|
||||
"silver": self.tr("Silver"),
|
||||
"bronze": self.tr("Bronze"),
|
||||
"borked": self.tr("Borked"),
|
||||
"fail": self.tr("Failed to get rating"),
|
||||
"pending": self.tr("Loading..."),
|
||||
"na": self.tr("Not applicable"),
|
||||
}
|
||||
|
||||
# lk: hide unfinished things
|
||||
self.ui.tags_group.setVisible(False)
|
||||
self.ui.requirements_group.setVisible(False)
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_install(self):
|
||||
if self.rgame.is_non_asset:
|
||||
self.rgame.launch()
|
||||
else:
|
||||
self.rgame.install()
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_import(self):
|
||||
self.import_clicked.emit(self.rgame.app_name)
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_uninstall(self):
|
||||
""" This method is to be called from the button only """
|
||||
self.rgame.uninstall()
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_modify(self):
|
||||
""" This method is to be called from the button only """
|
||||
self.rgame.modify()
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_repair(self):
|
||||
""" This method is to be called from the button only """
|
||||
repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.rgame.app_name}.repair")
|
||||
if not os.path.exists(repair_file):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Error - {}").format(self.rgame.app_title),
|
||||
self.tr(
|
||||
"Repair file does not exist or game does not need a repair. Please verify game first"
|
||||
),
|
||||
)
|
||||
return
|
||||
self.repair_game(self.rgame)
|
||||
|
||||
def repair_game(self, rgame: RareGame):
|
||||
rgame.update_game()
|
||||
ans = False
|
||||
if rgame.has_update:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
self.tr("Repair and update? - {}").format(self.rgame.app_title),
|
||||
self.tr(
|
||||
"There is an update for <b>{}</b> from <b>{}</b> to <b>{}</b>. "
|
||||
"Do you want to update the game while repairing it?"
|
||||
).format(rgame.app_title, rgame.version, rgame.remote_version),
|
||||
) == QMessageBox.Yes
|
||||
rgame.repair(repair_and_update=ans)
|
||||
|
||||
@pyqtSlot(RareGame, str)
|
||||
def __on_worker_error(self, rgame: RareGame, message: str):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Error - {}").format(rgame.app_title),
|
||||
message
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_verify(self):
|
||||
""" This method is to be called from the button only """
|
||||
if not os.path.exists(self.rgame.igame.install_path):
|
||||
logger.error(f"Installation path {self.rgame.igame.install_path} for {self.rgame.app_title} does not exist")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Error - {}").format(self.rgame.app_title),
|
||||
self.tr("Installation path for <b>{}</b> does not exist. Cannot continue.").format(self.rgame.app_title),
|
||||
)
|
||||
return
|
||||
if self.rgame.sdl_name is not None:
|
||||
selective_dialog = SelectiveDialog(
|
||||
self.rgame, parent=self
|
||||
)
|
||||
selective_dialog.result_ready.connect(self.verify_game)
|
||||
selective_dialog.open()
|
||||
else:
|
||||
self.verify_game(self.rgame)
|
||||
|
||||
@pyqtSlot(RareGame, SelectiveDownloadsModel)
|
||||
def verify_game(self, rgame: RareGame, sdl_model: SelectiveDownloadsModel = None):
|
||||
if sdl_model is not None:
|
||||
if not sdl_model.accepted or sdl_model.install_tag is None:
|
||||
return
|
||||
self.core.lgd.config.set(rgame.app_name, "install_tags", ','.join(sdl_model.install_tag))
|
||||
self.core.lgd.save_config()
|
||||
worker = VerifyWorker(self.core, self.args, rgame)
|
||||
worker.signals.progress.connect(self.__on_verify_progress)
|
||||
worker.signals.result.connect(self.__on_verify_result)
|
||||
worker.signals.error.connect(self.__on_worker_error)
|
||||
self.rcore.enqueue_worker(rgame, worker)
|
||||
|
||||
@pyqtSlot(RareGame, int, int, float, float)
|
||||
def __on_verify_progress(self, rgame: RareGame, num, total, percentage, speed):
|
||||
# lk: the check is NOT REQUIRED because signals are disconnected but protect against it anyway
|
||||
if rgame is not self.rgame:
|
||||
return
|
||||
self.ui.verify_progress.setValue(num * 100 // total)
|
||||
|
||||
@pyqtSlot(RareGame, bool, int, int)
|
||||
def __on_verify_result(self, rgame: RareGame, success, failed, missing):
|
||||
self.ui.repair_button.setDisabled(success)
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
self.tr("Summary - {}").format(rgame.app_title),
|
||||
self.tr("<b>{}</b> has been verified successfully. "
|
||||
"No missing or corrupt files found").format(rgame.app_title),
|
||||
)
|
||||
else:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
self.tr("Summary - {}").format(rgame.app_title),
|
||||
self.tr(
|
||||
"<b>{}</b> failed verification, <b>{}</b> file(s) corrupted, <b>{}</b> file(s) are missing. "
|
||||
"Do you want to repair them?"
|
||||
).format(rgame.app_title, failed, missing),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.Yes,
|
||||
)
|
||||
if ans == QMessageBox.Yes:
|
||||
self.repair_game(rgame)
|
||||
|
||||
@pyqtSlot()
|
||||
def __on_move(self):
|
||||
""" This method is to be called from the button only """
|
||||
move_dialog = MoveDialog(self.rgame, parent=self)
|
||||
move_dialog.result_ready.connect(self.move_game)
|
||||
move_dialog.open()
|
||||
|
||||
def move_game(self, rgame: RareGame, model: MoveGameModel):
|
||||
if not model.accepted:
|
||||
return
|
||||
|
||||
new_install_path = os.path.join(model.target_path, os.path.basename(self.rgame.install_path))
|
||||
dir_exists = False
|
||||
if os.path.isdir(new_install_path):
|
||||
dir_exists = is_game_dir(self.rgame.install_path, new_install_path)
|
||||
|
||||
if not dir_exists:
|
||||
for item in os.listdir(model.target_path):
|
||||
if os.path.basename(self.rgame.install_path) in os.path.basename(item):
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
self.tr("Move game? - {}").format(self.rgame.app_title),
|
||||
self.tr(
|
||||
"Destination <b>{}</b> already exists. "
|
||||
"Are you sure you want to overwrite it?"
|
||||
).format(new_install_path),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.Yes,
|
||||
)
|
||||
|
||||
if ans == QMessageBox.Yes:
|
||||
if os.path.isdir(new_install_path):
|
||||
shutil.rmtree(new_install_path)
|
||||
else:
|
||||
os.remove(new_install_path)
|
||||
else:
|
||||
return
|
||||
|
||||
worker = MoveWorker(
|
||||
self.core, rgame=rgame, dst_path=model.target_path, dst_exists=dir_exists
|
||||
)
|
||||
worker.signals.progress.connect(self.__on_move_progress)
|
||||
worker.signals.result.connect(self.__on_move_result)
|
||||
worker.signals.error.connect(self.__on_worker_error)
|
||||
self.rcore.enqueue_worker(self.rgame, worker)
|
||||
|
||||
@pyqtSlot(RareGame, int, object, object)
|
||||
def __on_move_progress(self, rgame: RareGame, progress: int, total_size: int, copied_size: int):
|
||||
# lk: the check is NOT REQUIRED because signals are disconnected but protect against it anyway
|
||||
if rgame is not self.rgame:
|
||||
return
|
||||
self.ui.move_progress.setValue(progress)
|
||||
|
||||
@pyqtSlot(RareGame, str)
|
||||
def __on_move_result(self, rgame: RareGame, dst_path: str):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
self.tr("Summary - {}").format(rgame.app_title),
|
||||
self.tr("<b>{}</b> successfully moved to <b>{}<b>.").format(rgame.app_title, dst_path),
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def __update_widget(self):
|
||||
""" React to state updates from RareGame """
|
||||
self.image.setPixmap(self.rgame.get_pixmap(ImageSize.DisplayTall, True))
|
||||
|
||||
self.ui.lbl_version.setDisabled(self.rgame.is_non_asset)
|
||||
self.ui.version.setDisabled(self.rgame.is_non_asset)
|
||||
self.ui.version.setText(
|
||||
self.rgame.version if not self.rgame.is_non_asset else "N/A"
|
||||
)
|
||||
|
||||
self.ui.lbl_install_size.setEnabled(bool(self.rgame.install_size))
|
||||
self.ui.install_size.setEnabled(bool(self.rgame.install_size))
|
||||
self.ui.install_size.setText(
|
||||
format_size(self.rgame.install_size) if self.rgame.install_size else "N/A"
|
||||
)
|
||||
|
||||
self.ui.lbl_install_path.setEnabled(bool(self.rgame.install_path))
|
||||
self.ui.install_path.setEnabled(bool(self.rgame.install_path))
|
||||
self.ui.install_path.setText(
|
||||
self.rgame.install_path if self.rgame.install_path else "N/A"
|
||||
)
|
||||
|
||||
self.ui.platform.setText(
|
||||
self.rgame.igame.platform
|
||||
if self.rgame.is_installed and not self.rgame.is_non_asset
|
||||
else self.rgame.default_platform
|
||||
)
|
||||
|
||||
self.ui.lbl_grade.setDisabled(
|
||||
self.rgame.is_unreal or platform.system() == "Windows"
|
||||
)
|
||||
self.ui.grade.setDisabled(
|
||||
self.rgame.is_unreal or platform.system() == "Windows"
|
||||
)
|
||||
self.ui.grade.setText(
|
||||
style_hyperlink(
|
||||
f"https://www.protondb.com/app/{self.rgame.steam_appid}",
|
||||
self.steam_grade_ratings[self.rgame.steam_grade()]
|
||||
)
|
||||
)
|
||||
|
||||
self.ui.install_button.setEnabled(
|
||||
(not self.rgame.is_installed or self.rgame.is_non_asset) and self.rgame.is_idle
|
||||
)
|
||||
|
||||
self.ui.import_button.setEnabled(
|
||||
(not self.rgame.is_installed or self.rgame.is_non_asset) and self.rgame.is_idle
|
||||
)
|
||||
|
||||
self.ui.modify_button.setEnabled(
|
||||
self.rgame.is_installed
|
||||
and (not self.rgame.is_non_asset)
|
||||
and self.rgame.is_idle
|
||||
and self.rgame.sdl_name is not None
|
||||
)
|
||||
|
||||
self.ui.verify_button.setEnabled(
|
||||
self.rgame.is_installed and (not self.rgame.is_non_asset) and self.rgame.is_idle
|
||||
)
|
||||
self.ui.verify_progress.setValue(self.rgame.progress if self.rgame.state == RareGame.State.VERIFYING else 0)
|
||||
if self.rgame.state == RareGame.State.VERIFYING:
|
||||
self.ui.verify_stack.setCurrentWidget(self.ui.verify_progress_page)
|
||||
else:
|
||||
self.ui.verify_stack.setCurrentWidget(self.ui.verify_button_page)
|
||||
|
||||
self.ui.repair_button.setEnabled(
|
||||
self.rgame.is_installed and (not self.rgame.is_non_asset) and self.rgame.is_idle
|
||||
and self.rgame.needs_repair
|
||||
and not self.args.offline
|
||||
)
|
||||
|
||||
self.ui.move_button.setEnabled(
|
||||
self.rgame.is_installed and (not self.rgame.is_non_asset) and self.rgame.is_idle
|
||||
)
|
||||
self.ui.move_progress.setValue(self.rgame.progress if self.rgame.state == RareGame.State.MOVING else 0)
|
||||
if self.rgame.state == RareGame.State.MOVING:
|
||||
self.ui.move_stack.setCurrentWidget(self.ui.move_progress_page)
|
||||
else:
|
||||
self.ui.move_stack.setCurrentWidget(self.ui.move_button_page)
|
||||
|
||||
self.ui.uninstall_button.setEnabled(
|
||||
self.rgame.is_installed and (not self.rgame.is_non_asset) and self.rgame.is_idle
|
||||
)
|
||||
|
||||
if self.rgame.is_installed and not self.rgame.is_non_asset:
|
||||
self.ui.game_actions_stack.setCurrentWidget(self.ui.installed_page)
|
||||
else:
|
||||
self.ui.game_actions_stack.setCurrentWidget(self.ui.uninstalled_page)
|
||||
|
||||
@pyqtSlot(RareGame)
|
||||
def update_game(self, rgame: RareGame):
|
||||
if self.rgame is not None:
|
||||
if (worker := self.rgame.worker()) is not None:
|
||||
if isinstance(worker, VerifyWorker):
|
||||
try:
|
||||
worker.signals.progress.disconnect(self.__on_verify_progress)
|
||||
except TypeError as e:
|
||||
logger.warning(f"{self.rgame.app_name} verify worker: {e}")
|
||||
if isinstance(worker, MoveWorker):
|
||||
try:
|
||||
worker.signals.progress.disconnect(self.__on_move_progress)
|
||||
except TypeError as e:
|
||||
logger.warning(f"{self.rgame.app_name} move worker: {e}")
|
||||
self.rgame.signals.widget.update.disconnect(self.__update_widget)
|
||||
|
||||
self.rgame = None
|
||||
|
||||
rgame.signals.widget.update.connect(self.__update_widget)
|
||||
if (worker := rgame.worker()) is not None:
|
||||
if isinstance(worker, VerifyWorker):
|
||||
worker.signals.progress.connect(self.__on_verify_progress)
|
||||
if isinstance(worker, MoveWorker):
|
||||
worker.signals.progress.connect(self.__on_move_progress)
|
||||
|
||||
self.set_title.emit(rgame.app_title)
|
||||
self.ui.app_name.setText(rgame.app_name)
|
||||
self.ui.dev.setText(rgame.developer)
|
||||
|
||||
if rgame.is_non_asset:
|
||||
self.ui.install_button.setText(self.tr("Link/Launch"))
|
||||
self.ui.game_actions_stack.setCurrentWidget(self.ui.uninstalled_page)
|
||||
else:
|
||||
self.ui.install_button.setText(self.tr("Install"))
|
||||
|
||||
self.rgame = rgame
|
||||
self.__update_widget()
|
196
rare/components/tabs/games/game_info/dlcs.py
Normal file
196
rare/components/tabs/games/game_info/dlcs.py
Normal file
|
@ -0,0 +1,196 @@
|
|||
from typing import Optional, List
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal
|
||||
from PyQt5.QtGui import QShowEvent
|
||||
from PyQt5.QtWidgets import QFrame, QMessageBox, QToolBox
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
|
||||
from rare.ui.components.tabs.games.game_info.dlcs import Ui_GameDlcs
|
||||
from rare.ui.components.tabs.games.game_info.dlc_widget import Ui_GameDlcWidget
|
||||
from rare.widgets.image_widget import ImageWidget, ImageSize
|
||||
from rare.widgets.side_tab import SideTabContents
|
||||
from rare.utils.misc import widget_object_name, qta_icon
|
||||
|
||||
|
||||
class GameDlcWidget(QFrame):
|
||||
def __init__(self, rgame: RareGame, rdlc: RareGame, parent=None):
|
||||
super(GameDlcWidget, self).__init__(parent=parent)
|
||||
self.ui = Ui_GameDlcWidget()
|
||||
self.ui.setupUi(self)
|
||||
self.setObjectName(widget_object_name(self, rdlc.app_name))
|
||||
self.rgame = rgame
|
||||
self.rdlc = rdlc
|
||||
|
||||
self.image = ImageWidget(self)
|
||||
self.image.setFixedSize(ImageSize.LibraryIcon)
|
||||
self.ui.dlc_layout.insertWidget(0, self.image)
|
||||
|
||||
self.ui.dlc_name.setText(rdlc.app_title)
|
||||
self.ui.version.setText(rdlc.version)
|
||||
self.ui.app_name.setText(rdlc.app_name)
|
||||
|
||||
# self.image.setPixmap(rdlc.get_pixmap_icon(rdlc.is_installed))
|
||||
self.__update()
|
||||
|
||||
rdlc.signals.widget.update.connect(self.__update)
|
||||
|
||||
@pyqtSlot()
|
||||
def __update(self):
|
||||
self.ui.action_button.setEnabled(self.rdlc.is_idle)
|
||||
self.image.setPixmap(self.rdlc.get_pixmap(ImageSize.LibraryIcon, self.rdlc.is_installed))
|
||||
|
||||
def showEvent(self, a0: QShowEvent) -> None:
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
if not self.rdlc.has_pixmap:
|
||||
self.rdlc.load_pixmaps()
|
||||
super().showEvent(a0)
|
||||
|
||||
|
||||
class InstalledGameDlcWidget(GameDlcWidget):
|
||||
uninstalled = pyqtSignal(RareGame)
|
||||
|
||||
def __init__(self, rgame: RareGame, rdlc: RareGame, parent=None):
|
||||
super(InstalledGameDlcWidget, self).__init__(rgame=rgame, rdlc=rdlc, parent=parent)
|
||||
# lk: set object names for CSS properties
|
||||
self.ui.action_button.setObjectName("UninstallButton")
|
||||
self.ui.action_button.clicked.connect(self.uninstall_dlc)
|
||||
self.ui.action_button.setText(self.tr("Uninstall DLC"))
|
||||
self.ui.action_button.setIcon(qta_icon("ri.uninstall-line"))
|
||||
# lk: don't reference `self.rdlc` here because the object has been deleted
|
||||
rdlc.signals.game.uninstalled.connect(self.__uninstalled)
|
||||
|
||||
@pyqtSlot()
|
||||
def __uninstalled(self):
|
||||
self.uninstalled.emit(self.rdlc)
|
||||
|
||||
def uninstall_dlc(self):
|
||||
self.rdlc.uninstall()
|
||||
|
||||
|
||||
class AvailableGameDlcWidget(GameDlcWidget):
|
||||
installed = pyqtSignal(RareGame)
|
||||
|
||||
def __init__(self, rgame: RareGame, rdlc: RareGame, parent=None):
|
||||
super(AvailableGameDlcWidget, self).__init__(rgame=rgame, rdlc=rdlc, parent=parent)
|
||||
# lk: set object names for CSS properties
|
||||
self.ui.action_button.setObjectName("InstallButton")
|
||||
self.ui.action_button.clicked.connect(self.install_dlc)
|
||||
self.ui.action_button.setText(self.tr("Install DLC"))
|
||||
self.ui.action_button.setIcon(qta_icon("ri.install-line"))
|
||||
|
||||
# lk: don't reference `self.rdlc` here because the object has been deleted
|
||||
rdlc.signals.game.installed.connect(self.__installed)
|
||||
|
||||
@pyqtSlot()
|
||||
def __installed(self):
|
||||
self.installed.emit(self.rdlc)
|
||||
|
||||
def install_dlc(self):
|
||||
if not self.rgame.is_installed:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Error"),
|
||||
self.tr("Base Game is not installed. Please install {} first").format(self.rgame.app_title),
|
||||
)
|
||||
return
|
||||
self.rdlc.install()
|
||||
|
||||
|
||||
class GameDlcs(QToolBox, SideTabContents):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(GameDlcs, self).__init__(parent=parent)
|
||||
self.implements_scrollarea = True
|
||||
self.ui = Ui_GameDlcs()
|
||||
self.ui.setupUi(self)
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.signals = GlobalSignalsSingleton()
|
||||
|
||||
self.rgame: Optional[RareGame] = None
|
||||
|
||||
def list_installed(self) -> List[InstalledGameDlcWidget]:
|
||||
return self.ui.installed_dlc_container.findChildren(InstalledGameDlcWidget, options=Qt.FindDirectChildrenOnly)
|
||||
|
||||
def list_available(self) -> List[AvailableGameDlcWidget]:
|
||||
return self.ui.available_dlc_container.findChildren(AvailableGameDlcWidget, options=Qt.FindDirectChildrenOnly)
|
||||
|
||||
def get_installed(self, app_name: str) -> Optional[InstalledGameDlcWidget]:
|
||||
return self.ui.installed_dlc_container.findChild(
|
||||
InstalledGameDlcWidget,
|
||||
name=widget_object_name(InstalledGameDlcWidget, app_name),
|
||||
options=Qt.FindDirectChildrenOnly
|
||||
)
|
||||
|
||||
def get_available(self, app_name: str) -> Optional[AvailableGameDlcWidget]:
|
||||
return self.ui.available_dlc_container.findChild(
|
||||
AvailableGameDlcWidget,
|
||||
name=widget_object_name(AvailableGameDlcWidget, app_name),
|
||||
options=Qt.FindDirectChildrenOnly
|
||||
)
|
||||
|
||||
def update_installed_page(self):
|
||||
have_installed = bool(self.list_installed())
|
||||
self.ui.installed_dlc_label.setVisible(not have_installed)
|
||||
self.ui.installed_dlc_container.setVisible(have_installed)
|
||||
if not have_installed:
|
||||
self.setCurrentWidget(self.ui.available_dlc_page)
|
||||
|
||||
def update_available_page(self):
|
||||
have_available = bool(self.list_available())
|
||||
self.ui.available_dlc_label.setVisible(not have_available)
|
||||
self.ui.available_dlc_container.setVisible(have_available)
|
||||
if not have_available:
|
||||
self.setCurrentWidget(self.ui.installed_dlc_page)
|
||||
|
||||
def append_installed(self, rdlc: RareGame):
|
||||
self.ui.installed_dlc_label.setVisible(False)
|
||||
self.ui.installed_dlc_container.setVisible(True)
|
||||
a_widget: AvailableGameDlcWidget = self.get_available(rdlc.app_name)
|
||||
if a_widget is not None:
|
||||
self.ui.available_dlc_container.layout().removeWidget(a_widget)
|
||||
a_widget.deleteLater()
|
||||
i_widget: InstalledGameDlcWidget = InstalledGameDlcWidget(
|
||||
self.rgame, rdlc, self.ui.installed_dlc_container
|
||||
)
|
||||
i_widget.destroyed.connect(self.update_installed_page)
|
||||
i_widget.uninstalled.connect(self.append_available)
|
||||
self.ui.installed_dlc_container.layout().addWidget(i_widget)
|
||||
|
||||
def append_available(self, rdlc: RareGame):
|
||||
self.ui.available_dlc_label.setVisible(False)
|
||||
self.ui.available_dlc_container.setVisible(True)
|
||||
i_widget: InstalledGameDlcWidget = self.get_installed(rdlc.app_name)
|
||||
if i_widget is not None:
|
||||
self.ui.available_dlc_container.layout().removeWidget(i_widget)
|
||||
i_widget.deleteLater()
|
||||
a_widget: AvailableGameDlcWidget = AvailableGameDlcWidget(
|
||||
self.rgame, rdlc, self.ui.available_dlc_container
|
||||
)
|
||||
a_widget.destroyed.connect(self.update_available_page)
|
||||
a_widget.installed.connect(self.append_installed)
|
||||
self.ui.available_dlc_container.layout().addWidget(a_widget)
|
||||
|
||||
def update_dlcs(self, rgame: RareGame):
|
||||
self.rgame = rgame
|
||||
self.set_title.emit(self.rgame.app_title)
|
||||
|
||||
for i_widget in self.list_installed():
|
||||
self.ui.installed_dlc_container.layout().removeWidget(i_widget)
|
||||
i_widget.deleteLater()
|
||||
|
||||
for a_widget in self.list_available():
|
||||
self.ui.available_dlc_container.layout().removeWidget(a_widget)
|
||||
a_widget.deleteLater()
|
||||
|
||||
for dlc in sorted(self.rgame.owned_dlcs, key=lambda x: x.app_title):
|
||||
if dlc.is_installed:
|
||||
self.append_installed(rdlc=dlc)
|
||||
else:
|
||||
self.append_available(rdlc=dlc)
|
||||
|
||||
if not self.list_available():
|
||||
self.setCurrentWidget(self.ui.installed_dlc_page)
|
||||
if not self.list_installed():
|
||||
self.setCurrentWidget(self.ui.available_dlc_page)
|
197
rare/components/tabs/games/game_info/settings.py
Normal file
197
rare/components/tabs/games/game_info/settings.py
Normal file
|
@ -0,0 +1,197 @@
|
|||
import os.path
|
||||
import platform as pf
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSlot
|
||||
from PyQt5.QtGui import QShowEvent
|
||||
from PyQt5.QtWidgets import QFileDialog, QComboBox, QLineEdit
|
||||
from legendary.models.game import Game, InstalledGame
|
||||
|
||||
from rare.components.tabs.settings.widgets.env_vars import EnvVars
|
||||
from rare.components.tabs.settings.widgets.game import GameSettingsBase
|
||||
from rare.components.tabs.settings.widgets.launch import LaunchSettingsBase
|
||||
from rare.components.tabs.settings.widgets.overlay import DxvkSettings
|
||||
from rare.components.tabs.settings.widgets.wrappers import WrapperSettings
|
||||
from rare.models.game import RareGame
|
||||
from rare.utils import config_helper as config
|
||||
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
|
||||
|
||||
if pf.system() != "Windows":
|
||||
from rare.components.tabs.settings.widgets.wine import WineSettings
|
||||
if pf.system() in {"Linux", "FreeBSD"}:
|
||||
from rare.components.tabs.settings.widgets.proton import ProtonSettings
|
||||
from rare.components.tabs.settings.widgets.overlay import MangoHudSettings
|
||||
|
||||
logger = getLogger("GameSettings")
|
||||
|
||||
|
||||
class GameWrapperSettings(WrapperSettings):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
def load_settings(self, app_name: str):
|
||||
self.app_name = app_name
|
||||
|
||||
|
||||
class GameLaunchSettings(LaunchSettingsBase):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(GameLaunchSettings, self).__init__(GameWrapperSettings, parent=parent)
|
||||
|
||||
self.game: Game = None
|
||||
self.igame: InstalledGame = None
|
||||
|
||||
self.skip_update_combo = QComboBox(self)
|
||||
self.skip_update_combo.addItem(self.tr("Default"), None)
|
||||
self.skip_update_combo.addItem(self.tr("No"), "false")
|
||||
self.skip_update_combo.addItem(self.tr("Yes"), "true")
|
||||
self.skip_update_combo.currentIndexChanged.connect(self.__skip_update_changed)
|
||||
|
||||
self.offline_combo = QComboBox(self)
|
||||
self.offline_combo.addItem(self.tr("Default"), None)
|
||||
self.offline_combo.addItem(self.tr("No"), "false")
|
||||
self.offline_combo.addItem(self.tr("Yes"), "true")
|
||||
self.offline_combo.currentIndexChanged.connect(self.__offline_changed)
|
||||
|
||||
self.override_exe_edit = PathEdit(
|
||||
file_mode=QFileDialog.ExistingFile,
|
||||
name_filters=["*.exe", "*.app"],
|
||||
placeholder=self.tr("Relative path to the replacement executable"),
|
||||
edit_func=self.__override_exe_edit_callback,
|
||||
save_func=self.__override_exe_save_callback,
|
||||
parent=self
|
||||
)
|
||||
|
||||
self.launch_params_edit = QLineEdit(self)
|
||||
self.launch_params_edit.setPlaceholderText(self.tr("Game specific command line arguments"))
|
||||
self.launch_params_edit.textChanged.connect(self.__launch_params_changed)
|
||||
|
||||
self.main_layout.insertRow(0, self.tr("Skip update check"), self.skip_update_combo)
|
||||
self.main_layout.insertRow(1, self.tr("Offline mode"), self.offline_combo)
|
||||
self.main_layout.insertRow(2, self.tr("Launch parameters"), self.launch_params_edit)
|
||||
self.main_layout.insertRow(3, self.tr("Override executable"), self.override_exe_edit)
|
||||
|
||||
def showEvent(self, a0: QShowEvent):
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
|
||||
skip_update = config.get_option(self.app_name, "skip_update_check", fallback=None)
|
||||
self.skip_update_combo.setCurrentIndex(self.offline_combo.findData(skip_update, Qt.UserRole))
|
||||
|
||||
offline = config.get_option(self.app_name, "offline", fallback=None)
|
||||
self.offline_combo.setCurrentIndex(self.offline_combo.findData(offline, Qt.UserRole))
|
||||
|
||||
if self.igame:
|
||||
self.offline_combo.setEnabled(self.igame.can_run_offline)
|
||||
self.override_exe_edit.setRootPath(self.igame.install_path)
|
||||
else:
|
||||
self.offline_combo.setEnabled(False)
|
||||
self.override_exe_edit.setRootPath(os.path.expanduser("~/"))
|
||||
|
||||
launch_params = config.get_option(self.app_name, "start_params", "")
|
||||
self.launch_params_edit.setText(launch_params)
|
||||
|
||||
override_exe = config.get_option(self.app_name, "override_exe", fallback="")
|
||||
self.override_exe_edit.setText(override_exe)
|
||||
|
||||
return super().showEvent(a0)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __skip_update_changed(self, index):
|
||||
data = self.skip_update_combo.itemData(index, Qt.UserRole)
|
||||
config.save_option(self.app_name, "skip_update_check", data)
|
||||
|
||||
def __override_exe_edit_callback(self, path: str) -> Tuple[bool, str, int]:
|
||||
if not path or self.igame is None:
|
||||
return True, path, IndicatorReasonsCommon.VALID
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.join(self.igame.install_path, path)
|
||||
# lk: Compare paths through python's commonpath because in windows we
|
||||
# cannot compare as strings
|
||||
# antonia disapproves of this
|
||||
if os.path.commonpath([self.igame.install_path, path]) != self.igame.install_path:
|
||||
return False, self.igame.install_path, IndicatorReasonsCommon.WRONG_PATH
|
||||
if not os.path.exists(path):
|
||||
return False, path, IndicatorReasonsCommon.WRONG_PATH
|
||||
|
||||
if not path.endswith(".exe") and not path.endswith(".app"):
|
||||
return False, path, IndicatorReasonsCommon.WRONG_PATH
|
||||
path = os.path.relpath(path, self.igame.install_path)
|
||||
return True, path, IndicatorReasonsCommon.VALID
|
||||
|
||||
def __override_exe_save_callback(self, path: str):
|
||||
config.save_option(self.app_name, "override_exe", path)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __offline_changed(self, index):
|
||||
data = self.skip_update_combo.itemData(index, Qt.UserRole)
|
||||
config.save_option(self.app_name, "offline", data)
|
||||
|
||||
def __launch_params_changed(self, value) -> None:
|
||||
config.save_option(self.app_name, "start_params", value)
|
||||
|
||||
def load_settings(self, rgame: RareGame):
|
||||
self.game = rgame.game
|
||||
self.igame = rgame.igame
|
||||
self.app_name = rgame.app_name
|
||||
self.wrappers_widget.load_settings(rgame.app_name)
|
||||
|
||||
|
||||
if pf.system() != "Windows":
|
||||
class GameWineSettings(WineSettings):
|
||||
def load_settings(self, app_name):
|
||||
self.app_name = app_name
|
||||
|
||||
if pf.system() in {"Linux", "FreeBSD"}:
|
||||
class GameProtonSettings(ProtonSettings):
|
||||
def load_settings(self, app_name: str):
|
||||
self.app_name = app_name
|
||||
|
||||
class GameMangoHudSettings(MangoHudSettings):
|
||||
def load_settings(self, app_name: str):
|
||||
self.app_name = app_name
|
||||
|
||||
|
||||
class GameDxvkSettings(DxvkSettings):
|
||||
def load_settings(self, app_name: str):
|
||||
self.app_name = app_name
|
||||
|
||||
|
||||
class GameEnvVars(EnvVars):
|
||||
def load_settings(self, app_name):
|
||||
self.app_name = app_name
|
||||
|
||||
|
||||
class GameSettings(GameSettingsBase):
|
||||
def __init__(self, parent=None):
|
||||
if pf.system() != "Windows":
|
||||
if pf.system() in {"Linux", "FreeBSD"}:
|
||||
super(GameSettings, self).__init__(
|
||||
GameLaunchSettings, GameDxvkSettings, GameEnvVars,
|
||||
GameWineSettings, GameProtonSettings, GameMangoHudSettings,
|
||||
parent=parent
|
||||
)
|
||||
else:
|
||||
super(GameSettings, self).__init__(
|
||||
GameLaunchSettings, GameDxvkSettings, GameEnvVars,
|
||||
GameWineSettings,
|
||||
parent=parent
|
||||
)
|
||||
else:
|
||||
super(GameSettings, self).__init__(
|
||||
GameLaunchSettings, GameDxvkSettings, GameEnvVars,
|
||||
parent=parent
|
||||
)
|
||||
|
||||
def load_settings(self, rgame: RareGame):
|
||||
self.set_title.emit(rgame.app_title)
|
||||
self.app_name = rgame.app_name
|
||||
self.launch.load_settings(rgame)
|
||||
if pf.system() != "Windows":
|
||||
self.wine.load_settings(rgame.app_name)
|
||||
if pf.system() in {"Linux", "FreeBSD"}:
|
||||
self.proton_tool.load_settings(rgame.app_name)
|
||||
self.mangohud.load_settings(rgame.app_name)
|
||||
self.dxvk.load_settings(rgame.app_name)
|
||||
self.env_vars.load_settings(rgame.app_name)
|
217
rare/components/tabs/games/game_widgets/__init__.py
Normal file
217
rare/components/tabs/games/game_widgets/__init__.py
Normal file
|
@ -0,0 +1,217 @@
|
|||
from abc import abstractmethod
|
||||
from typing import Tuple, List, Union, Type, TypeVar
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot, Qt
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QScrollArea
|
||||
|
||||
from rare.lgndr.core import LegendaryCore
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.signals import GlobalSignals
|
||||
from rare.models.library import LibraryFilter, LibraryOrder, LibraryView
|
||||
from rare.shared import RareCore
|
||||
from rare.widgets.library_layout import LibraryLayout
|
||||
from .icon_game_widget import IconGameWidget
|
||||
from .list_game_widget import ListGameWidget
|
||||
|
||||
ViewWidget = TypeVar("ViewWidget", IconGameWidget, ListGameWidget)
|
||||
|
||||
|
||||
class ViewContainer(QWidget):
|
||||
def __init__(self, rcore: RareCore, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.rcore: RareCore = rcore
|
||||
|
||||
def _add_widget(self, widget_type: Type[ViewWidget], rgame: RareGame) -> ViewWidget:
|
||||
widget = widget_type(rgame, self)
|
||||
self.layout().addWidget(widget)
|
||||
return widget
|
||||
|
||||
@staticmethod
|
||||
def __visibility(widget: ViewWidget, library_filter, search_text) -> Tuple[bool, float]:
|
||||
if library_filter == LibraryFilter.HIDDEN:
|
||||
visible = "hidden" in widget.rgame.metadata.tags
|
||||
elif "hidden" in widget.rgame.metadata.tags:
|
||||
visible = False
|
||||
elif library_filter == LibraryFilter.INSTALLED:
|
||||
visible = widget.rgame.is_installed and not widget.rgame.is_unreal
|
||||
elif library_filter == LibraryFilter.OFFLINE:
|
||||
visible = widget.rgame.can_run_offline and not widget.rgame.is_unreal
|
||||
elif library_filter == LibraryFilter.WIN32:
|
||||
visible = widget.rgame.is_win32 and not widget.rgame.is_unreal
|
||||
elif library_filter == LibraryFilter.MAC:
|
||||
visible = widget.rgame.is_mac and not widget.rgame.is_unreal
|
||||
elif library_filter == LibraryFilter.INSTALLABLE:
|
||||
visible = not widget.rgame.is_non_asset and not widget.rgame.is_unreal
|
||||
elif library_filter == LibraryFilter.INCLUDE_UE:
|
||||
visible = True
|
||||
elif library_filter == LibraryFilter.ALL:
|
||||
visible = not widget.rgame.is_unreal
|
||||
else:
|
||||
visible = True
|
||||
|
||||
if (
|
||||
search_text not in widget.rgame.app_name.lower()
|
||||
and search_text not in widget.rgame.app_title.lower()
|
||||
):
|
||||
opacity = 0.25
|
||||
else:
|
||||
opacity = 1.0
|
||||
|
||||
return visible, opacity
|
||||
|
||||
def _filter_view(self, widget_type: Type[ViewWidget], filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
|
||||
widgets = self.findChildren(widget_type)
|
||||
for iw in widgets:
|
||||
visibility, opacity = self.__visibility(iw, filter_by, search_text)
|
||||
iw.setOpacity(opacity)
|
||||
iw.setVisible(visibility)
|
||||
|
||||
def _update_view(self, widget_type: Type[ViewWidget]):
|
||||
widgets = self.findChildren(widget_type)
|
||||
app_names = {iw.rgame.app_name for iw in widgets}
|
||||
games = list(self.rcore.games)
|
||||
game_app_names = {g.app_name for g in games}
|
||||
new_app_names = game_app_names.difference(app_names)
|
||||
for app_name in new_app_names:
|
||||
game = self.rcore.get_game(app_name)
|
||||
w = widget_type(game, self)
|
||||
self.layout().addWidget(w)
|
||||
|
||||
def _find_widget(self, widget_type: Type[ViewWidget], app_name: str) -> ViewWidget:
|
||||
w = self.findChild(widget_type, app_name)
|
||||
return w
|
||||
|
||||
@abstractmethod
|
||||
def order_view(self):
|
||||
pass
|
||||
|
||||
|
||||
class IconViewContainer(ViewContainer):
|
||||
def __init__(self, rcore: RareCore, parent=None):
|
||||
super().__init__(rcore, parent=parent)
|
||||
view_layout = LibraryLayout(self)
|
||||
view_layout.setSpacing(9)
|
||||
view_layout.setContentsMargins(0, 13, 0, 13)
|
||||
view_layout.setAlignment(Qt.AlignTop)
|
||||
self.setLayout(view_layout)
|
||||
|
||||
def add_widget(self, rgame: RareGame) -> IconGameWidget:
|
||||
return self._add_widget(IconGameWidget, rgame)
|
||||
|
||||
def filter_view(self, filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
|
||||
self._filter_view(IconGameWidget, filter_by, search_text)
|
||||
|
||||
def update_view(self):
|
||||
self._update_view(IconGameWidget)
|
||||
|
||||
def find_widget(self, app_name: str) -> ViewWidget:
|
||||
return self._find_widget(IconGameWidget, app_name)
|
||||
|
||||
def order_view(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""):
|
||||
if search_text:
|
||||
self.layout().sort(
|
||||
lambda x: (search_text not in x.widget().rgame.app_title.lower(),)
|
||||
)
|
||||
else:
|
||||
if (newest := order_by == LibraryOrder.NEWEST) or order_by == LibraryOrder.OLDEST:
|
||||
# Sort by grant date
|
||||
self.layout().sort(
|
||||
key=lambda x: (x.widget().rgame.is_installed, not x.widget().rgame.is_non_asset, x.widget().rgame.grant_date()),
|
||||
reverse=newest,
|
||||
)
|
||||
elif order_by == LibraryOrder.RECENT:
|
||||
# Sort by recently played
|
||||
self.layout().sort(
|
||||
key=lambda x: (x.widget().rgame.is_installed, not x.widget().rgame.is_non_asset, x.widget().rgame.metadata.last_played),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
# Sort by title
|
||||
self.layout().sort(
|
||||
key=lambda x: (not x.widget().rgame.is_installed, x.widget().rgame.is_non_asset, x.widget().rgame.app_title)
|
||||
)
|
||||
|
||||
|
||||
class ListViewContainer(ViewContainer):
|
||||
def __init__(self, rcore, parent=None):
|
||||
super().__init__(rcore, parent=parent)
|
||||
view_layout = QVBoxLayout(self)
|
||||
view_layout.setContentsMargins(3, 3, 9, 3)
|
||||
view_layout.setAlignment(Qt.AlignTop)
|
||||
self.setLayout(view_layout)
|
||||
|
||||
def add_widget(self, rgame: RareGame) -> ListGameWidget:
|
||||
return self._add_widget(ListGameWidget, rgame)
|
||||
|
||||
def filter_view(self, filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
|
||||
self._filter_view(ListGameWidget, filter_by, search_text)
|
||||
|
||||
def update_view(self):
|
||||
self._update_view(ListGameWidget)
|
||||
|
||||
def find_widget(self, app_name: str) -> ViewWidget:
|
||||
return self._find_widget(ListGameWidget, app_name)
|
||||
|
||||
def order_view(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""):
|
||||
list_widgets = self.findChildren(ListGameWidget)
|
||||
if search_text:
|
||||
list_widgets.sort(key=lambda x: (search_text not in x.rgame.app_title.lower(),))
|
||||
else:
|
||||
if (newest := order_by == LibraryOrder.NEWEST) or order_by == LibraryOrder.OLDEST:
|
||||
list_widgets.sort(
|
||||
key=lambda x: (x.rgame.is_installed, not x.rgame.is_non_asset, x.rgame.grant_date()),
|
||||
reverse=newest,
|
||||
)
|
||||
elif order_by == LibraryOrder.RECENT:
|
||||
list_widgets.sort(
|
||||
key=lambda x: (x.rgame.is_installed, not x.rgame.is_non_asset, x.rgame.metadata.last_played),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
list_widgets.sort(
|
||||
key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.app_title)
|
||||
)
|
||||
for idx, wl in enumerate(list_widgets):
|
||||
self.layout().insertWidget(idx, wl)
|
||||
|
||||
|
||||
class LibraryWidgetController(QObject):
|
||||
def __init__(self, view: LibraryView, parent: QScrollArea = None):
|
||||
super(LibraryWidgetController, self).__init__(parent=parent)
|
||||
self.rcore = RareCore.instance()
|
||||
self.core: LegendaryCore = self.rcore.core()
|
||||
self.signals: GlobalSignals = self.rcore.signals()
|
||||
|
||||
if view == LibraryView.COVER:
|
||||
self._container: IconViewContainer = IconViewContainer(self.rcore, parent)
|
||||
else:
|
||||
self._container: ListViewContainer = ListViewContainer(self.rcore, parent)
|
||||
parent.setWidget(self._container)
|
||||
|
||||
self.signals.game.installed.connect(self.order_game_views)
|
||||
self.signals.game.uninstalled.connect(self.order_game_views)
|
||||
|
||||
def add_game(self, rgame: RareGame):
|
||||
return self.add_widgets(rgame)
|
||||
|
||||
def add_widgets(self, rgame: RareGame) -> ViewWidget:
|
||||
return self._container.add_widget(rgame)
|
||||
|
||||
def filter_game_views(self, filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
|
||||
self._container.filter_view(filter_by, search_text)
|
||||
self.order_game_views(search_text=search_text)
|
||||
|
||||
@pyqtSlot()
|
||||
def order_game_views(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""):
|
||||
self._container.order_view(order_by, search_text)
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(list)
|
||||
def update_game_views(self, app_names: List[str] = None):
|
||||
if app_names:
|
||||
return
|
||||
self._container.update_view()
|
||||
self.order_game_views()
|
||||
|
||||
def __find_widget(self, app_name: str) -> Union[ViewWidget, None]:
|
||||
return self._container.find_widget(app_name)
|
280
rare/components/tabs/games/game_widgets/game_widget.py
Normal file
280
rare/components/tabs/games/game_widgets/game_widget.py
Normal file
|
@ -0,0 +1,280 @@
|
|||
import platform
|
||||
import random
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, Qt, pyqtSlot, QObject, QEvent
|
||||
from PyQt5.QtGui import QMouseEvent, QShowEvent, QPaintEvent
|
||||
from PyQt5.QtWidgets import QMessageBox, QAction
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton, ImageManagerSingleton
|
||||
from rare.utils.paths import desktop_links_supported, desktop_link_path, create_desktop_link
|
||||
from rare.utils.steam_shortcuts import (
|
||||
steam_shortcuts_supported,
|
||||
steam_shortcut_exists,
|
||||
remove_steam_shortcut,
|
||||
remove_steam_coverart,
|
||||
add_steam_shortcut,
|
||||
add_steam_coverart,
|
||||
save_steam_shortcuts,
|
||||
)
|
||||
from .library_widget import LibraryWidget
|
||||
|
||||
logger = getLogger("GameWidget")
|
||||
|
||||
|
||||
class GameWidget(LibraryWidget):
|
||||
show_info = pyqtSignal(RareGame)
|
||||
|
||||
def __init__(self, rgame: RareGame, parent=None):
|
||||
super(GameWidget, self).__init__(parent=parent)
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.signals = GlobalSignalsSingleton()
|
||||
self.args = ArgumentsSingleton()
|
||||
self.image_manager = ImageManagerSingleton()
|
||||
|
||||
self.rgame: RareGame = rgame
|
||||
|
||||
self.setContextMenuPolicy(Qt.ActionsContextMenu)
|
||||
|
||||
self.launch_action = QAction(self.tr("Launch"), self)
|
||||
self.launch_action.triggered.connect(self._launch)
|
||||
|
||||
self.install_action = QAction(self.tr("Install"), self)
|
||||
self.install_action.triggered.connect(self._install)
|
||||
|
||||
self.desktop_link_action = QAction(self)
|
||||
self.desktop_link_action.triggered.connect(lambda: self._create_link(self.rgame.folder_name, "desktop"))
|
||||
|
||||
self.menu_link_action = QAction(self)
|
||||
self.menu_link_action.triggered.connect(lambda: self._create_link(self.rgame.folder_name, "start_menu"))
|
||||
|
||||
self.steam_shortcut_action = QAction(self)
|
||||
self.steam_shortcut_action.triggered.connect(
|
||||
lambda: self._create_steam_shortcut(self.rgame.app_name, self.rgame.app_title)
|
||||
)
|
||||
|
||||
self.reload_action = QAction(self.tr("Reload Image"), self)
|
||||
self.reload_action.triggered.connect(self._on_reload_image)
|
||||
|
||||
self.uninstall_action = QAction(self.tr("Uninstall"), self)
|
||||
self.uninstall_action.triggered.connect(self._uninstall)
|
||||
|
||||
self.update_actions()
|
||||
|
||||
# signals
|
||||
self.rgame.signals.widget.update.connect(self.update_pixmap)
|
||||
self.rgame.signals.widget.update.connect(self.update_buttons)
|
||||
self.rgame.signals.widget.update.connect(self.update_state)
|
||||
self.rgame.signals.game.installed.connect(self.update_actions)
|
||||
self.rgame.signals.game.uninstalled.connect(self.update_actions)
|
||||
|
||||
self.rgame.signals.progress.start.connect(self.start_progress)
|
||||
self.rgame.signals.progress.update.connect(lambda p: self.updateProgress(p))
|
||||
self.rgame.signals.progress.finish.connect(lambda e: self.hideProgress(e))
|
||||
|
||||
self.state_strings = {
|
||||
RareGame.State.IDLE: "",
|
||||
RareGame.State.RUNNING: self.tr("Running..."),
|
||||
RareGame.State.DOWNLOADING: self.tr("Downloading..."),
|
||||
RareGame.State.VERIFYING: self.tr("Verifying..."),
|
||||
RareGame.State.MOVING: self.tr("Moving..."),
|
||||
RareGame.State.UNINSTALLING: self.tr("Uninstalling..."),
|
||||
RareGame.State.SYNCING: self.tr("Syncing saves..."),
|
||||
"has_update": self.tr("Update available"),
|
||||
"needs_verification": self.tr("Needs verification"),
|
||||
"not_can_launch": self.tr("Can't launch"),
|
||||
"save_not_up_to_date": self.tr("Save is not up-to-date"),
|
||||
}
|
||||
|
||||
self.hover_strings = {
|
||||
"info": self.tr("Show information"),
|
||||
"install": self.tr("Install game"),
|
||||
"can_launch": self.tr("Launch game"),
|
||||
"is_foreign": self.tr("Launch offline"),
|
||||
"has_update": self.tr("Launch without version check"),
|
||||
"is_origin": self.tr("Launch/Link"),
|
||||
"not_can_launch": self.tr("Can't launch"),
|
||||
}
|
||||
|
||||
# lk: abstract class for typing, the `self.ui` attribute should be used
|
||||
# lk: by the Ui class in the children. It must contain at least the same
|
||||
# lk: attributes as `GameWidgetUi` class
|
||||
__slots__ = "ui", "update_pixmap", "start_progress"
|
||||
|
||||
def paintEvent(self, a0: QPaintEvent) -> None:
|
||||
if not self.visibleRegion().isNull() and not self.rgame.has_pixmap:
|
||||
self.startTimer(random.randrange(42, 2361, 129), Qt.CoarseTimer)
|
||||
# self.startTimer(random.randrange(42, 2361, 363), Qt.VeryCoarseTimer)
|
||||
# self.rgame.load_pixmap()
|
||||
super().paintEvent(a0)
|
||||
|
||||
def timerEvent(self, a0):
|
||||
self.killTimer(a0.timerId())
|
||||
self.rgame.load_pixmaps()
|
||||
|
||||
def showEvent(self, a0: QShowEvent) -> None:
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
super().showEvent(a0)
|
||||
|
||||
@pyqtSlot()
|
||||
def update_state(self):
|
||||
if self.rgame.is_idle:
|
||||
if self.rgame.has_update:
|
||||
self.ui.status_label.setText(self.state_strings["has_update"])
|
||||
elif self.rgame.needs_verification:
|
||||
self.ui.status_label.setText(self.state_strings["needs_verification"])
|
||||
elif not self.rgame.can_launch and self.rgame.is_installed:
|
||||
self.ui.status_label.setText(self.state_strings["not_can_launch"])
|
||||
elif (
|
||||
self.rgame.igame
|
||||
and (self.rgame.game.supports_cloud_saves or self.rgame.game.supports_mac_cloud_saves)
|
||||
and not self.rgame.is_save_up_to_date
|
||||
):
|
||||
self.ui.status_label.setText(self.state_strings["save_not_up_to_date"])
|
||||
else:
|
||||
self.ui.status_label.setText(self.state_strings[self.rgame.state])
|
||||
else:
|
||||
self.ui.status_label.setText(self.state_strings[self.rgame.state])
|
||||
self.ui.status_label.setVisible(bool(self.ui.status_label.text()))
|
||||
|
||||
@pyqtSlot()
|
||||
def update_buttons(self):
|
||||
self.ui.install_btn.setVisible(not self.rgame.is_installed)
|
||||
self.ui.install_btn.setEnabled(not self.rgame.is_installed)
|
||||
self.ui.launch_btn.setVisible(self.rgame.is_installed)
|
||||
self.ui.launch_btn.setEnabled(self.rgame.can_launch)
|
||||
|
||||
self.steam_shortcut_action.setEnabled(self.rgame.has_pixmap)
|
||||
|
||||
@pyqtSlot()
|
||||
def update_actions(self):
|
||||
for action in self.actions():
|
||||
self.removeAction(action)
|
||||
|
||||
if self.rgame.is_installed or self.rgame.is_origin:
|
||||
self.addAction(self.launch_action)
|
||||
else:
|
||||
self.addAction(self.install_action)
|
||||
|
||||
if desktop_links_supported() and self.rgame.is_installed:
|
||||
if desktop_link_path(self.rgame.folder_name, "desktop").exists():
|
||||
self.desktop_link_action.setText(self.tr("Remove Desktop link"))
|
||||
else:
|
||||
self.desktop_link_action.setText(self.tr("Create Desktop link"))
|
||||
self.addAction(self.desktop_link_action)
|
||||
if desktop_link_path(self.rgame.folder_name, "start_menu").exists():
|
||||
self.menu_link_action.setText(self.tr("Remove Start Menu link"))
|
||||
else:
|
||||
self.menu_link_action.setText(self.tr("Create Start Menu link"))
|
||||
self.addAction(self.menu_link_action)
|
||||
|
||||
if steam_shortcuts_supported() and self.rgame.is_installed:
|
||||
if steam_shortcut_exists(self.rgame.app_name):
|
||||
self.steam_shortcut_action.setText(self.tr("Remove from Steam"))
|
||||
else:
|
||||
self.steam_shortcut_action.setText(self.tr("Add to Steam"))
|
||||
self.addAction(self.steam_shortcut_action)
|
||||
|
||||
self.addAction(self.reload_action)
|
||||
if self.rgame.is_installed and not self.rgame.is_origin:
|
||||
self.addAction(self.uninstall_action)
|
||||
|
||||
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
|
||||
if a0 is self.ui.launch_btn:
|
||||
if a1.type() == QEvent.Enter:
|
||||
if not self.rgame.can_launch:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["not_can_launch"])
|
||||
elif self.rgame.is_origin:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["is_origin"])
|
||||
elif self.rgame.has_update:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["has_update"])
|
||||
elif self.rgame.is_foreign and self.rgame.can_run_offline:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["is_foreign"])
|
||||
elif self.rgame.can_launch:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["can_launch"])
|
||||
return True
|
||||
if a1.type() == QEvent.Leave:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["info"])
|
||||
# return True
|
||||
if a0 is self.ui.install_btn:
|
||||
if a1.type() == QEvent.Enter:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["install"])
|
||||
return True
|
||||
if a1.type() == QEvent.Leave:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["info"])
|
||||
# return True
|
||||
if a0 is self:
|
||||
if a1.type() == QEvent.Enter:
|
||||
self.ui.tooltip_label.setText(self.hover_strings["info"])
|
||||
return super(GameWidget, self).eventFilter(a0, a1)
|
||||
|
||||
def mousePressEvent(self, e: QMouseEvent) -> None:
|
||||
# left button
|
||||
if e.button() == 1:
|
||||
self.show_info.emit(self.rgame)
|
||||
# right
|
||||
elif e.button() == 2:
|
||||
super(GameWidget, self).mousePressEvent(e)
|
||||
|
||||
@pyqtSlot()
|
||||
def _on_reload_image(self) -> None:
|
||||
self.rgame.refresh_pixmap()
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(bool, bool)
|
||||
def _launch(self, offline=False, skip_version_check=False):
|
||||
if offline or (self.rgame.is_foreign and self.rgame.can_run_offline):
|
||||
offline = True
|
||||
if self.rgame.has_update:
|
||||
skip_version_check = True
|
||||
self.rgame.launch(offline=offline, skip_update_check=skip_version_check)
|
||||
|
||||
@pyqtSlot()
|
||||
def _install(self):
|
||||
self.show_info.emit(self.rgame)
|
||||
|
||||
@pyqtSlot()
|
||||
def _uninstall(self):
|
||||
self.show_info.emit(self.rgame)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _create_link(self, name: str, link_type: str):
|
||||
if not desktop_links_supported():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.tr("Warning"),
|
||||
self.tr("Creating shortcuts is currently unsupported on {}").format(platform.system()),
|
||||
)
|
||||
return
|
||||
|
||||
shortcut_path = desktop_link_path(name, link_type)
|
||||
|
||||
if not shortcut_path.exists():
|
||||
try:
|
||||
if not create_desktop_link(
|
||||
app_name=self.rgame.app_name,
|
||||
app_title=self.rgame.app_title,
|
||||
link_name=self.rgame.folder_name,
|
||||
link_type=link_type,
|
||||
):
|
||||
raise PermissionError
|
||||
except PermissionError:
|
||||
QMessageBox.warning(self, "Error", "Could not create shortcut.")
|
||||
return
|
||||
else:
|
||||
if shortcut_path.exists():
|
||||
shortcut_path.unlink(missing_ok=True)
|
||||
self.update_actions()
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _create_steam_shortcut(self, app_name: str, app_title: str):
|
||||
if steam_shortcut_exists(app_name):
|
||||
if shortcut := remove_steam_shortcut(app_name):
|
||||
remove_steam_coverart(shortcut)
|
||||
else:
|
||||
if shortcut := add_steam_shortcut(app_name, app_title):
|
||||
add_steam_coverart(app_name, shortcut)
|
||||
save_steam_shortcuts()
|
||||
self.update_actions()
|
56
rare/components/tabs/games/game_widgets/icon_game_widget.py
Normal file
56
rare/components/tabs/games/game_widgets/icon_game_widget.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QEvent, pyqtSlot
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.image import ImageSize
|
||||
from .game_widget import GameWidget
|
||||
from .icon_widget import IconWidget
|
||||
|
||||
logger = getLogger("IconGameWidget")
|
||||
|
||||
|
||||
class IconGameWidget(GameWidget):
|
||||
def __init__(self, rgame: RareGame, parent=None):
|
||||
super().__init__(rgame, parent)
|
||||
self.setObjectName(f"{rgame.app_name}")
|
||||
self.setFixedSize(ImageSize.LibraryTall)
|
||||
self.ui = IconWidget()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.ui.title_label.setText(self.rgame.app_title)
|
||||
self.ui.launch_btn.clicked.connect(self._launch)
|
||||
self.ui.launch_btn.setVisible(self.rgame.is_installed)
|
||||
self.ui.install_btn.clicked.connect(self._install)
|
||||
self.ui.install_btn.setVisible(not self.rgame.is_installed)
|
||||
|
||||
self.ui.launch_btn.setEnabled(self.rgame.can_launch)
|
||||
|
||||
self.update_state()
|
||||
|
||||
# lk: "connect" the buttons' enter/leave events to this widget
|
||||
self.installEventFilter(self)
|
||||
self.ui.launch_btn.installEventFilter(self)
|
||||
self.ui.install_btn.installEventFilter(self)
|
||||
|
||||
@pyqtSlot()
|
||||
def update_pixmap(self):
|
||||
self.setPixmap(self.rgame.get_pixmap(ImageSize.LibraryTall, self.rgame.is_installed))
|
||||
|
||||
@pyqtSlot()
|
||||
def start_progress(self):
|
||||
self.showProgress(
|
||||
self.rgame.get_pixmap(ImageSize.LibraryTall, True),
|
||||
self.rgame.get_pixmap(ImageSize.LibraryTall, False)
|
||||
)
|
||||
|
||||
def enterEvent(self, a0: Optional[QEvent] = None) -> None:
|
||||
if a0 is not None:
|
||||
a0.accept()
|
||||
self.ui.enterAnimation(self)
|
||||
|
||||
def leaveEvent(self, a0: Optional[QEvent] = None) -> None:
|
||||
if a0 is not None:
|
||||
a0.accept()
|
||||
self.ui.leaveAnimation(self)
|
129
rare/components/tabs/games/game_widgets/icon_widget.py
Normal file
129
rare/components/tabs/games/game_widgets/icon_widget.py
Normal file
|
@ -0,0 +1,129 @@
|
|||
from PyQt5.QtCore import Qt, QPropertyAnimation, QEasingCurve, QSize
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QGraphicsOpacityEffect,
|
||||
QSpacerItem,
|
||||
QSizePolicy,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
)
|
||||
|
||||
from rare.utils.misc import qta_icon, widget_object_name
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
|
||||
|
||||
class IconWidget(object):
|
||||
def __init__(self):
|
||||
self._effect = None
|
||||
self._animation: QPropertyAnimation = None
|
||||
|
||||
self.status_label: ElideLabel = None
|
||||
self.mini_widget: QWidget = None
|
||||
self.mini_effect: QGraphicsOpacityEffect = None
|
||||
self.title_label: QLabel = None
|
||||
self.tooltip_label: ElideLabel = None
|
||||
self.launch_btn: QPushButton = None
|
||||
self.install_btn: QPushButton = None
|
||||
|
||||
def setupUi(self, widget: QWidget):
|
||||
# information at top
|
||||
self.status_label = ElideLabel(parent=widget)
|
||||
self.status_label.setObjectName(f"{type(self).__name__}StatusLabel")
|
||||
self.status_label.setFixedHeight(False)
|
||||
self.status_label.setContentsMargins(6, 6, 6, 6)
|
||||
self.status_label.setAutoFillBackground(False)
|
||||
|
||||
# on-hover popup
|
||||
self.mini_widget = QWidget(parent=widget)
|
||||
self.mini_widget.setObjectName(f"{type(self).__name__}MiniWidget")
|
||||
self.mini_widget.setFixedHeight(widget.height() // 3)
|
||||
|
||||
self.mini_effect = QGraphicsOpacityEffect(self.mini_widget)
|
||||
self.mini_widget.setGraphicsEffect(self.mini_effect)
|
||||
|
||||
# game title
|
||||
self.title_label = QLabel(parent=self.mini_widget)
|
||||
self.title_label.setObjectName(f"{type(self).__name__}TitleLabel")
|
||||
self.title_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.title_label.setAlignment(Qt.AlignVCenter)
|
||||
self.title_label.setAutoFillBackground(False)
|
||||
self.title_label.setWordWrap(True)
|
||||
|
||||
# information below title
|
||||
self.tooltip_label = ElideLabel(parent=self.mini_widget)
|
||||
self.tooltip_label.setObjectName(f"{type(self).__name__}TooltipLabel")
|
||||
self.tooltip_label.setAutoFillBackground(False)
|
||||
|
||||
# play button
|
||||
self.launch_btn = QPushButton(parent=self.mini_widget)
|
||||
self.launch_btn.setObjectName(f"{type(self).__name__}Button")
|
||||
self.launch_btn.setIcon(qta_icon("ei.play-alt", color="white"))
|
||||
self.launch_btn.setIconSize(QSize(20, 20))
|
||||
self.launch_btn.setFixedSize(QSize(widget.width() // 4, widget.width() // 4))
|
||||
|
||||
self.install_btn = QPushButton(parent=self.mini_widget)
|
||||
self.install_btn.setObjectName(f"{type(self).__name__}Button")
|
||||
self.install_btn.setIcon(qta_icon("ri.install-fill", color="white"))
|
||||
self.install_btn.setIconSize(QSize(20, 20))
|
||||
self.install_btn.setFixedSize(QSize(widget.width() // 4, widget.width() // 4))
|
||||
|
||||
# lk: do not focus on button
|
||||
# When the button gets clicked on, it receives keyboard focus. Disabling the button
|
||||
# afterwards leads to `focusNextChild` getting called. This makes the scrollarea
|
||||
# trying to ensure that `nextChild` is visible, essentially scrolling to a random widget
|
||||
self.launch_btn.setFocusPolicy(Qt.NoFocus)
|
||||
self.install_btn.setFocusPolicy(Qt.NoFocus)
|
||||
|
||||
# Create layouts
|
||||
# layout on top of the image, holds the status label, a spacer item and the mini widget
|
||||
image_layout = QVBoxLayout()
|
||||
image_layout.setContentsMargins(2, 2, 2, 2)
|
||||
|
||||
# layout for the mini widget, holds the top row and the info label
|
||||
mini_layout = QVBoxLayout()
|
||||
mini_layout.setSpacing(0)
|
||||
|
||||
# layout for the top row, holds the title and the launch button
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.setSpacing(0)
|
||||
row_layout.setAlignment(Qt.AlignTop)
|
||||
|
||||
# Layout the widgets
|
||||
# (from inner to outer)
|
||||
row_layout.addWidget(self.title_label, stretch=2)
|
||||
row_layout.addWidget(self.launch_btn)
|
||||
row_layout.addWidget(self.install_btn)
|
||||
mini_layout.addLayout(row_layout, stretch=2)
|
||||
mini_layout.addWidget(self.tooltip_label)
|
||||
self.mini_widget.setLayout(mini_layout)
|
||||
|
||||
image_layout.addWidget(self.status_label)
|
||||
image_layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Expanding))
|
||||
image_layout.addWidget(self.mini_widget)
|
||||
widget.setLayout(image_layout)
|
||||
|
||||
widget.setContextMenuPolicy(Qt.ActionsContextMenu)
|
||||
widget.leaveEvent(None)
|
||||
|
||||
self.translateUi(widget)
|
||||
|
||||
def translateUi(self, widget: QWidget):
|
||||
pass
|
||||
|
||||
def enterAnimation(self, widget: QWidget):
|
||||
self._animation = QPropertyAnimation(self.mini_effect, b"opacity")
|
||||
self._animation.setDuration(250)
|
||||
self._animation.setStartValue(0)
|
||||
self._animation.setEndValue(1)
|
||||
self._animation.setEasingCurve(QEasingCurve.InSine)
|
||||
self._animation.start(QPropertyAnimation.DeleteWhenStopped)
|
||||
|
||||
def leaveAnimation(self, widget: QWidget):
|
||||
self._animation = QPropertyAnimation(self.mini_effect, b"opacity")
|
||||
self._animation.setDuration(150)
|
||||
self._animation.setStartValue(1)
|
||||
self._animation.setEndValue(0)
|
||||
self._animation.setEasingCurve(QEasingCurve.OutSine)
|
||||
self._animation.start(QPropertyAnimation.DeleteWhenStopped)
|
131
rare/components/tabs/games/game_widgets/library_widget.py
Normal file
131
rare/components/tabs/games/game_widgets/library_widget.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
from typing import Optional, Tuple, List
|
||||
|
||||
from PyQt5.QtCore import Qt, QEvent, QObject
|
||||
from PyQt5.QtGui import QPainter, QPixmap, QFontMetrics, QImage, QBrush, QColor, QShowEvent
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
|
||||
from rare.widgets.image_widget import ImageWidget
|
||||
|
||||
|
||||
class ProgressLabel(QLabel):
|
||||
def __init__(self, parent=None):
|
||||
super(ProgressLabel, self).__init__(parent=parent)
|
||||
if self.parent() is not None:
|
||||
self.parent().installEventFilter(self)
|
||||
self.setObjectName(type(self).__name__)
|
||||
self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
self.setFrameStyle(QLabel.StyledPanel)
|
||||
|
||||
def __center_on_parent(self):
|
||||
fm = QFontMetrics(self.font())
|
||||
rect = fm.boundingRect(" 100% ")
|
||||
rect.moveCenter(self.parent().contentsRect().center())
|
||||
self.setGeometry(rect)
|
||||
|
||||
def event(self, e: QEvent) -> bool:
|
||||
if e.type() == QEvent.ParentAboutToChange:
|
||||
if self.parent() is not None:
|
||||
self.parent().removeEventFilter(self)
|
||||
if e.type() == QEvent.ParentChange:
|
||||
if self.parent() is not None:
|
||||
self.parent().installEventFilter(self)
|
||||
return super().event(e)
|
||||
|
||||
def showEvent(self, a0: QShowEvent) -> None:
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
self.__center_on_parent()
|
||||
super().showEvent(a0)
|
||||
|
||||
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
|
||||
if a0 is self.parent() and a1.type() == QEvent.Resize:
|
||||
self.__center_on_parent()
|
||||
return a0.event(a1)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def calculateColors(image: QImage) -> Tuple[QColor, QColor]:
|
||||
color: List[int] = [0, 0, 0]
|
||||
# take the two diagonals of the center square section
|
||||
min_d = min(image.width(), image.height())
|
||||
origin_w = (image.width() - min_d) // 2
|
||||
origin_h = (image.height() - min_d) // 2
|
||||
for x, y in zip(range(origin_w, min_d), range(origin_h, min_d)):
|
||||
pixel = image.pixelColor(x, y).getRgb()
|
||||
color = list(map(lambda t: sum(t) // 2, zip(pixel[:3], color)))
|
||||
# take the V component of the HSV color
|
||||
fg_color = QColor(0, 0, 0) if QColor(*color).value() < 127 else QColor(255, 255, 255)
|
||||
bg_color = QColor(*map(lambda c: 255 - c, color))
|
||||
return bg_color, fg_color
|
||||
|
||||
def setStyleSheetColors(self, bg: QColor, fg: QColor, brd: QColor):
|
||||
sheet = (
|
||||
f"QLabel#{type(self).__name__} {{"
|
||||
f"background-color: rgba({bg.red()}, {bg.green()}, {bg.blue()}, 65%);"
|
||||
f"color: rgb({fg.red()}, {fg.green()}, {fg.blue()});"
|
||||
f"border-color: rgb({brd.red()}, {brd.green()}, {brd.blue()});"
|
||||
f"}}"
|
||||
)
|
||||
self.setStyleSheet(sheet)
|
||||
|
||||
|
||||
class LibraryWidget(ImageWidget):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super(LibraryWidget, self).__init__(parent)
|
||||
self.progress_label = ProgressLabel(self)
|
||||
self.progress_label.setVisible(False)
|
||||
|
||||
self._color_pixmap: Optional[QPixmap] = None
|
||||
self._gray_pixmap: Optional[QPixmap] = None
|
||||
# lk: keep percentage to not over-generate the image
|
||||
self._progress: int = -1
|
||||
|
||||
def progressPixmap(self, color: QPixmap, gray: QPixmap, progress: int) -> QPixmap:
|
||||
"""
|
||||
Paints the color image over the gray images based on progress percentage
|
||||
|
||||
@param color:
|
||||
@param gray:
|
||||
@param progress:
|
||||
@return:
|
||||
"""
|
||||
|
||||
device = QPixmap(color.size())
|
||||
painter = QPainter(device)
|
||||
painter.setRenderHint(QPainter.SmoothPixmapTransform, self._smooth_transform)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_Source)
|
||||
# lk: Vertical loading
|
||||
# prog_h = (device.height() * progress // 100)
|
||||
# brush = QBrush(gray)
|
||||
# painter.fillRect(device.rect().adjusted(0, 0, 0, -prog_h), brush)
|
||||
# brush.setTexture(color)
|
||||
# painter.fillRect(device.rect().adjusted(0, device.height() - prog_h, 0, 0), brush)
|
||||
# lk: Horizontal loading
|
||||
prog_w = device.width() * progress // 100
|
||||
brush = QBrush(gray)
|
||||
painter.fillRect(device.rect().adjusted(prog_w, 0, 0, 0), brush)
|
||||
brush.setTexture(color)
|
||||
painter.fillRect(device.rect().adjusted(0, 0, prog_w - device.width(), 0), brush)
|
||||
painter.end()
|
||||
device.setDevicePixelRatio(color.devicePixelRatioF())
|
||||
return device
|
||||
|
||||
def showProgress(self, color_pm: QPixmap, gray_pm: QPixmap) -> None:
|
||||
self._color_pixmap = color_pm
|
||||
self._gray_pixmap = gray_pm
|
||||
bg_color, fg_color = self.progress_label.calculateColors(color_pm.toImage())
|
||||
self.progress_label.setStyleSheetColors(bg_color, fg_color, fg_color)
|
||||
self.progress_label.setVisible(True)
|
||||
self.updateProgress(0)
|
||||
|
||||
def updateProgress(self, progress: int):
|
||||
self.progress_label.setText(f"{progress:02}%")
|
||||
if progress > self._progress:
|
||||
self._progress = progress
|
||||
self.setPixmap(self.progressPixmap(self._color_pixmap, self._gray_pixmap, progress))
|
||||
|
||||
def hideProgress(self, stopped: bool):
|
||||
self._color_pixmap = None
|
||||
self._gray_pixmap = None
|
||||
self.progress_label.setVisible(stopped)
|
||||
self._progress = -1
|
141
rare/components/tabs/games/game_widgets/list_game_widget.py
Normal file
141
rare/components/tabs/games/game_widgets/list_game_widget.py
Normal file
|
@ -0,0 +1,141 @@
|
|||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import Qt, QEvent, QRect, pyqtSlot
|
||||
from PyQt5.QtGui import (
|
||||
QPalette,
|
||||
QBrush,
|
||||
QPaintEvent,
|
||||
QPainter,
|
||||
QLinearGradient,
|
||||
QPixmap,
|
||||
QImage,
|
||||
)
|
||||
|
||||
from rare.models.game import RareGame
|
||||
from rare.models.image import ImageSize
|
||||
from rare.utils.misc import format_size
|
||||
from .game_widget import GameWidget
|
||||
from .list_widget import ListWidget
|
||||
|
||||
logger = getLogger("ListGameWidget")
|
||||
|
||||
|
||||
class ListGameWidget(GameWidget):
|
||||
def __init__(self, rgame: RareGame, parent=None):
|
||||
super().__init__(rgame, parent)
|
||||
self.setObjectName(f"{rgame.app_name}")
|
||||
self.ui = ListWidget()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.ui.title_label.setText(self.rgame.app_title)
|
||||
self.ui.launch_btn.clicked.connect(self._launch)
|
||||
self.ui.launch_btn.setVisible(self.rgame.is_installed)
|
||||
self.ui.install_btn.clicked.connect(self._install)
|
||||
self.ui.install_btn.setVisible(not self.rgame.is_installed)
|
||||
|
||||
self.ui.launch_btn.setEnabled(self.rgame.can_launch)
|
||||
|
||||
self.ui.launch_btn.setText(
|
||||
self.tr("Launch") if not self.rgame.is_origin else self.tr("Link/Play")
|
||||
)
|
||||
self.ui.developer_label.setText(self.rgame.developer)
|
||||
# self.version_label.setVisible(self.is_installed)
|
||||
if self.rgame.igame:
|
||||
self.ui.version_label.setText(self.rgame.version)
|
||||
self.ui.size_label.setText(format_size(self.rgame.install_size) if self.rgame.install_size else "")
|
||||
|
||||
self.update_state()
|
||||
|
||||
# lk: "connect" the buttons' enter/leave events to this widget
|
||||
self.installEventFilter(self)
|
||||
self.ui.launch_btn.installEventFilter(self)
|
||||
self.ui.install_btn.installEventFilter(self)
|
||||
|
||||
@pyqtSlot()
|
||||
def update_pixmap(self):
|
||||
self.setPixmap(self.rgame.get_pixmap(ImageSize.LibraryWide, self.rgame.is_installed))
|
||||
|
||||
@pyqtSlot()
|
||||
def start_progress(self):
|
||||
self.showProgress(
|
||||
self.rgame.get_pixmap(ImageSize.LibraryWide, True),
|
||||
self.rgame.get_pixmap(ImageSize.LibraryWide, False)
|
||||
)
|
||||
|
||||
def enterEvent(self, a0: QEvent = None) -> None:
|
||||
if a0 is not None:
|
||||
a0.accept()
|
||||
self.ui.tooltip_label.setVisible(True)
|
||||
|
||||
def leaveEvent(self, a0: QEvent = None) -> None:
|
||||
if a0 is not None:
|
||||
a0.accept()
|
||||
self.ui.tooltip_label.setVisible(False)
|
||||
|
||||
"""
|
||||
Painting and progress overrides.
|
||||
Let them live here until a better alternative is divised.
|
||||
|
||||
The list widget and these painting functions can be
|
||||
refactored to be used in downloads and/or dlcs
|
||||
"""
|
||||
|
||||
def prepare_pixmap(self, pixmap: QPixmap) -> QPixmap:
|
||||
device: QImage = QImage(
|
||||
pixmap.size().width() * 1,
|
||||
int(self.sizeHint().height() * pixmap.devicePixelRatioF()) + 1,
|
||||
QImage.Format_ARGB32_Premultiplied
|
||||
)
|
||||
painter = QPainter(device)
|
||||
brush = QBrush(pixmap)
|
||||
painter.fillRect(device.rect(), brush)
|
||||
# the gradient could be cached and reused as it is expensive
|
||||
gradient = QLinearGradient(0, 0, device.width(), 0)
|
||||
gradient.setColorAt(0.02, Qt.transparent)
|
||||
gradient.setColorAt(0.5, Qt.black)
|
||||
gradient.setColorAt(0.98, Qt.transparent)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_DestinationIn)
|
||||
painter.fillRect(device.rect(), gradient)
|
||||
painter.end()
|
||||
ret = QPixmap.fromImage(device)
|
||||
ret.setDevicePixelRatio(pixmap.devicePixelRatioF())
|
||||
return ret
|
||||
|
||||
def setPixmap(self, pixmap: QPixmap) -> None:
|
||||
# lk: trade some possible delay and start-up time
|
||||
# lk: for faster rendering. Gradients are expensive
|
||||
# lk: so pre-generate the image
|
||||
if not pixmap.isNull():
|
||||
pixmap = self.prepare_pixmap(pixmap)
|
||||
super(ListGameWidget, self).setPixmap(pixmap)
|
||||
|
||||
def paint_image_cover(self, painter: QPainter, a0: QPaintEvent) -> None:
|
||||
painter.setOpacity(self._opacity)
|
||||
color = self.palette().color(QPalette.Window).darker(75)
|
||||
painter.fillRect(self.rect(), color)
|
||||
brush = QBrush(self._pixmap)
|
||||
brush.setTransform(self._transform)
|
||||
width = int(self._pixmap.width() / self._pixmap.devicePixelRatioF())
|
||||
origin = self.width() // 2
|
||||
painter.setBrushOrigin(origin, 0)
|
||||
fill_rect = QRect(origin, 0, width, self.height())
|
||||
painter.fillRect(fill_rect, brush)
|
||||
|
||||
def progressPixmap(self, color: QPixmap, gray: QPixmap, progress: int) -> QPixmap:
|
||||
# lk: so about that +1 after the in convertion, casting to int rounds down
|
||||
# lk: and that can create a weird line at the bottom, add 1 to round up.
|
||||
device = QPixmap(
|
||||
color.size().width(),
|
||||
int(self.sizeHint().height() * color.devicePixelRatioF()) + 1,
|
||||
)
|
||||
painter = QPainter(device)
|
||||
painter.setRenderHint(QPainter.SmoothPixmapTransform, self._smooth_transform)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_Source)
|
||||
prog_h = (device.height() * progress // 100)
|
||||
brush = QBrush(gray)
|
||||
painter.fillRect(device.rect().adjusted(0, 0, 0, -prog_h), brush)
|
||||
brush.setTexture(color)
|
||||
painter.fillRect(device.rect().adjusted(0, device.height() - prog_h, 0, 0), brush)
|
||||
painter.end()
|
||||
device.setDevicePixelRatio(color.devicePixelRatioF())
|
||||
return device
|
113
rare/components/tabs/games/game_widgets/list_widget.py
Normal file
113
rare/components/tabs/games/game_widgets/list_widget.py
Normal file
|
@ -0,0 +1,113 @@
|
|||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QSpacerItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
|
||||
|
||||
class ListWidget(object):
|
||||
def __init__(self):
|
||||
self.title_label = None
|
||||
self.status_label = None
|
||||
self.tooltip_label = None
|
||||
self.install_btn = None
|
||||
self.launch_btn = None
|
||||
|
||||
self.developer_label = None
|
||||
self.version_label = None
|
||||
self.size_label = None
|
||||
|
||||
def setupUi(self, widget: QWidget):
|
||||
self.title_label = ElideLabel(parent=widget)
|
||||
self.title_label.setObjectName(f"{type(self).__name__}TitleLabel")
|
||||
self.title_label.setWordWrap(False)
|
||||
|
||||
self.status_label = QLabel(parent=widget)
|
||||
self.status_label.setObjectName(f"{type(self).__name__}StatusLabel")
|
||||
self.status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.tooltip_label = QLabel(parent=widget)
|
||||
self.tooltip_label.setObjectName(f"{type(self).__name__}TooltipLabel")
|
||||
self.tooltip_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.install_btn = QPushButton(parent=widget)
|
||||
self.install_btn.setObjectName(f"{type(self).__name__}Button")
|
||||
self.install_btn.setIcon(qta_icon("ri.install-line"))
|
||||
self.install_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.install_btn.setFixedWidth(120)
|
||||
|
||||
self.launch_btn = QPushButton(parent=widget)
|
||||
self.launch_btn.setObjectName(f"{type(self).__name__}Button")
|
||||
self.launch_btn.setIcon(qta_icon("ei.play-alt"))
|
||||
self.launch_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.launch_btn.setFixedWidth(120)
|
||||
|
||||
# lk: do not focus on button
|
||||
# When the button gets clicked on, it receives keyboard focus. Disabling the button
|
||||
# afterwards leads to `focusNextChild` getting called. This makes the scrollarea
|
||||
# trying to ensure that `nextChild` is visible, essentially scrolling to a random widget
|
||||
self.launch_btn.setFocusPolicy(Qt.NoFocus)
|
||||
self.install_btn.setFocusPolicy(Qt.NoFocus)
|
||||
|
||||
self.developer_label = ElideLabel(parent=widget)
|
||||
self.developer_label.setObjectName(f"{type(self).__name__}InfoLabel")
|
||||
self.developer_label.setFixedWidth(120)
|
||||
|
||||
self.version_label = ElideLabel(parent=widget)
|
||||
self.version_label.setObjectName(f"{type(self).__name__}InfoLabel")
|
||||
self.version_label.setFixedWidth(120)
|
||||
|
||||
self.size_label = ElideLabel(parent=widget)
|
||||
self.size_label.setObjectName(f"{type(self).__name__}InfoLabel")
|
||||
self.size_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
self.size_label.setFixedWidth(60)
|
||||
|
||||
# Create layouts
|
||||
left_layout = QVBoxLayout()
|
||||
left_layout.setAlignment(Qt.AlignLeft)
|
||||
|
||||
bottom_layout = QHBoxLayout()
|
||||
bottom_layout.setAlignment(Qt.AlignRight)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(3, 3, 3, 3)
|
||||
|
||||
# Layout the widgets
|
||||
# (from inner to outer)
|
||||
left_layout.addWidget(self.title_label, stretch=1)
|
||||
|
||||
bottom_layout.addWidget(self.developer_label, stretch=0, alignment=Qt.AlignLeft)
|
||||
bottom_layout.addItem(QSpacerItem(20, 0, QSizePolicy.Fixed, QSizePolicy.Minimum))
|
||||
bottom_layout.addWidget(self.version_label, stretch=0, alignment=Qt.AlignLeft)
|
||||
bottom_layout.addItem(QSpacerItem(20, 0, QSizePolicy.Fixed, QSizePolicy.Minimum))
|
||||
bottom_layout.addWidget(self.size_label, stretch=0, alignment=Qt.AlignLeft)
|
||||
bottom_layout.addItem(QSpacerItem(20, 0, QSizePolicy.Fixed, QSizePolicy.Minimum))
|
||||
bottom_layout.addWidget(self.status_label, stretch=0, alignment=Qt.AlignLeft)
|
||||
bottom_layout.addItem(QSpacerItem(20, 0, QSizePolicy.Expanding, QSizePolicy.Minimum))
|
||||
bottom_layout.addWidget(self.tooltip_label, stretch=0, alignment=Qt.AlignRight)
|
||||
|
||||
left_layout.addLayout(bottom_layout)
|
||||
|
||||
layout.addLayout(left_layout)
|
||||
layout.addWidget(self.install_btn, stretch=0, alignment=Qt.AlignRight)
|
||||
layout.addWidget(self.launch_btn, stretch=0, alignment=Qt.AlignRight)
|
||||
|
||||
widget.setLayout(layout)
|
||||
|
||||
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
widget.setFixedHeight(widget.sizeHint().height())
|
||||
widget.leaveEvent(None)
|
||||
|
||||
self.translateUi(widget)
|
||||
|
||||
def translateUi(self, widget: QWidget):
|
||||
self.install_btn.setText(widget.tr("Install"))
|
216
rare/components/tabs/games/head_bar.py
Normal file
216
rare/components/tabs/games/head_bar.py
Normal file
|
@ -0,0 +1,216 @@
|
|||
import logging
|
||||
|
||||
from PyQt5.QtCore import QSettings, pyqtSlot, QSize, Qt
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QWidget,
|
||||
QPushButton,
|
||||
)
|
||||
from PyQt5.QtWidgets import (
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QMenu,
|
||||
QAction, QSpacerItem, QSizePolicy,
|
||||
)
|
||||
|
||||
from rare.models.options import options, LibraryFilter, LibraryOrder
|
||||
from rare.shared import RareCore
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.button_edit import ButtonLineEdit
|
||||
|
||||
|
||||
class LibraryHeadBar(QWidget):
|
||||
filterChanged = pyqtSignal(object)
|
||||
orderChanged = pyqtSignal(object)
|
||||
viewChanged = pyqtSignal(object)
|
||||
goto_import = pyqtSignal()
|
||||
goto_egl_sync = pyqtSignal()
|
||||
goto_eos_ubisoft = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(LibraryHeadBar, self).__init__(parent=parent)
|
||||
self.logger = logging.getLogger(type(self).__name__)
|
||||
self.rcore = RareCore.instance()
|
||||
self.settings = QSettings(self)
|
||||
|
||||
self.filter = QComboBox(self)
|
||||
filters = {
|
||||
LibraryFilter.ALL: self.tr("All games"),
|
||||
LibraryFilter.INSTALLED: self.tr("Installed"),
|
||||
LibraryFilter.OFFLINE: self.tr("Offline"),
|
||||
# LibraryFilter.HIDDEN: self.tr("Hidden"),
|
||||
}
|
||||
for data, text in filters.items():
|
||||
self.filter.addItem(text, data)
|
||||
|
||||
if self.rcore.bit32_games:
|
||||
self.filter.addItem(self.tr("32bit games"), LibraryFilter.WIN32)
|
||||
if self.rcore.mac_games:
|
||||
self.filter.addItem(self.tr("macOS games"), LibraryFilter.MAC)
|
||||
if self.rcore.origin_games:
|
||||
self.filter.addItem(self.tr("Exclude Origin"), LibraryFilter.INSTALLABLE)
|
||||
self.filter.addItem(self.tr("Include Unreal"), LibraryFilter.INCLUDE_UE)
|
||||
|
||||
try:
|
||||
_filter = LibraryFilter(self.settings.value(*options.library_filter))
|
||||
if (index := self.filter.findData(_filter, Qt.UserRole)) < 0:
|
||||
raise ValueError(f"Filter '{_filter}' is not available")
|
||||
else:
|
||||
self.filter.setCurrentIndex(index)
|
||||
except (TypeError, ValueError) as e:
|
||||
self.logger.error("Error while loading library: %s", e)
|
||||
self.settings.setValue(options.library_filter.key, options.library_filter.default)
|
||||
_filter = LibraryFilter(options.library_filter.default)
|
||||
self.filter.setCurrentIndex(self.filter.findData(_filter, Qt.UserRole))
|
||||
self.filter.currentIndexChanged.connect(self.__filter_changed)
|
||||
|
||||
self.order = QComboBox(parent=self)
|
||||
sortings = {
|
||||
LibraryOrder.TITLE: self.tr("Title"),
|
||||
LibraryOrder.RECENT: self.tr("Recently played"),
|
||||
LibraryOrder.NEWEST: self.tr("Newest"),
|
||||
LibraryOrder.OLDEST: self.tr("Oldest"),
|
||||
}
|
||||
for data, text in sortings.items():
|
||||
self.order.addItem(text, data)
|
||||
|
||||
try:
|
||||
_order = LibraryOrder(self.settings.value(*options.library_order))
|
||||
if (index := self.order.findData(_order, Qt.UserRole)) < 0:
|
||||
raise ValueError(f"Order '{_order}' is not available")
|
||||
else:
|
||||
self.order.setCurrentIndex(index)
|
||||
except (TypeError, ValueError) as e:
|
||||
self.logger.error("Error while loading library: %s", e)
|
||||
self.settings.setValue(options.library_order.key, options.library_order.default)
|
||||
_order = LibraryOrder(options.library_order.default)
|
||||
self.order.setCurrentIndex(self.order.findData(_order, Qt.UserRole))
|
||||
self.order.currentIndexChanged.connect(self.__order_changed)
|
||||
|
||||
integrations_menu = QMenu(parent=self)
|
||||
import_action = QAction(
|
||||
qta_icon("mdi.import", "fa.arrow-down"), self.tr("Import Game"), integrations_menu
|
||||
)
|
||||
|
||||
import_action.triggered.connect(self.goto_import)
|
||||
egl_sync_action = QAction(qta_icon("mdi.sync", "fa.refresh"), self.tr("Sync with EGL"), integrations_menu)
|
||||
egl_sync_action.triggered.connect(self.goto_egl_sync)
|
||||
|
||||
eos_ubisoft_action = QAction(
|
||||
qta_icon("mdi.rocket", "fa.rocket"), self.tr("Epic Overlay and Ubisoft"), integrations_menu
|
||||
)
|
||||
eos_ubisoft_action.triggered.connect(self.goto_eos_ubisoft)
|
||||
|
||||
integrations_menu.addAction(import_action)
|
||||
integrations_menu.addAction(egl_sync_action)
|
||||
integrations_menu.addAction(eos_ubisoft_action)
|
||||
|
||||
integrations = QPushButton(parent=self)
|
||||
integrations.setText(self.tr("Integrations"))
|
||||
integrations.setMenu(integrations_menu)
|
||||
|
||||
self.search_bar = ButtonLineEdit("fa.search", placeholder_text=self.tr("Search"))
|
||||
self.search_bar.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||||
self.search_bar.setObjectName("SearchBar")
|
||||
self.search_bar.setMinimumWidth(250)
|
||||
|
||||
installed_tooltip = self.tr("Installed games")
|
||||
self.installed_icon = QLabel(parent=self)
|
||||
self.installed_icon.setPixmap(qta_icon("ph.floppy-disk-back-fill").pixmap(QSize(16, 16)))
|
||||
self.installed_icon.setToolTip(installed_tooltip)
|
||||
self.installed_label = QLabel(parent=self)
|
||||
font = self.installed_label.font()
|
||||
font.setBold(True)
|
||||
self.installed_label.setFont(font)
|
||||
self.installed_label.setToolTip(installed_tooltip)
|
||||
available_tooltip = self.tr("Available games")
|
||||
self.available_icon = QLabel(parent=self)
|
||||
self.available_icon.setPixmap(qta_icon("ph.floppy-disk-back-light").pixmap(QSize(16, 16)))
|
||||
self.available_icon.setToolTip(available_tooltip)
|
||||
self.available_label = QLabel(parent=self)
|
||||
self.available_label.setToolTip(available_tooltip)
|
||||
|
||||
self.refresh_list = QPushButton(parent=self)
|
||||
self.refresh_list.setIcon(qta_icon("fa.refresh")) # Reload icon
|
||||
self.refresh_list.clicked.connect(self.__refresh_clicked)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.filter)
|
||||
layout.addWidget(self.order)
|
||||
layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed))
|
||||
layout.addWidget(self.search_bar)
|
||||
layout.addWidget(self.installed_icon)
|
||||
layout.addWidget(self.installed_label)
|
||||
layout.addWidget(self.available_icon)
|
||||
layout.addWidget(self.available_label)
|
||||
layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed))
|
||||
layout.addWidget(integrations)
|
||||
layout.addWidget(self.refresh_list)
|
||||
|
||||
def set_games_count(self, inst: int, avail: int) -> None:
|
||||
self.installed_label.setText(str(inst))
|
||||
self.available_label.setText(str(avail))
|
||||
|
||||
@pyqtSlot()
|
||||
def __refresh_clicked(self):
|
||||
self.rcore.fetch()
|
||||
|
||||
def current_filter(self) -> LibraryFilter:
|
||||
return self.filter.currentData(Qt.UserRole)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __filter_changed(self, index: int):
|
||||
data = self.filter.itemData(index, Qt.UserRole)
|
||||
self.filterChanged.emit(data)
|
||||
self.settings.setValue(options.library_filter.key, int(data))
|
||||
|
||||
def current_order(self) -> LibraryOrder:
|
||||
return self.order.currentData(Qt.UserRole)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def __order_changed(self, index: int):
|
||||
data = self.order.itemData(index, Qt.UserRole)
|
||||
self.orderChanged.emit(data)
|
||||
self.settings.setValue(options.library_order.key, int(data))
|
||||
|
||||
|
||||
class SelectViewWidget(QWidget):
|
||||
toggled = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, icon_view: bool, parent=None):
|
||||
super(SelectViewWidget, self).__init__(parent=parent)
|
||||
self.icon_button = QPushButton(self)
|
||||
self.icon_button.setObjectName(f"{type(self).__name__}Button")
|
||||
self.list_button = QPushButton(self)
|
||||
self.list_button.setObjectName(f"{type(self).__name__}Button")
|
||||
|
||||
if icon_view:
|
||||
self.icon_button.setIcon(qta_icon("mdi.view-grid-outline", "ei.th-large", color="orange"))
|
||||
self.list_button.setIcon(qta_icon("fa5s.list", "ei.th-list", color="#eee"))
|
||||
else:
|
||||
self.icon_button.setIcon(qta_icon("mdi.view-grid-outline", "ei.th-large", color="#eee"))
|
||||
self.list_button.setIcon(qta_icon("fa5s.list", "ei.th-list", color="orange"))
|
||||
|
||||
self.icon_button.clicked.connect(self.icon)
|
||||
self.list_button.clicked.connect(self.list)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.icon_button)
|
||||
layout.addWidget(self.list_button)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def icon(self):
|
||||
self.icon_button.setIcon(qta_icon("mdi.view-grid-outline", "ei.th-large", color="orange"))
|
||||
self.list_button.setIcon(qta_icon("fa5s.list", "ei.th-list", color="#eee"))
|
||||
self.toggled.emit(True)
|
||||
|
||||
def list(self):
|
||||
self.icon_button.setIcon(qta_icon("mdi.view-grid-outline", "ei.th-large", color="#eee"))
|
||||
self.list_button.setIcon(qta_icon("fa5s.list", "ei.th-list", color="orange"))
|
||||
self.toggled.emit(False)
|
||||
|
||||
|
68
rare/components/tabs/games/integrations/__init__.py
Normal file
68
rare/components/tabs/games/integrations/__init__.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QVBoxLayout, QWidget, QLabel, QSizePolicy
|
||||
|
||||
from rare.widgets.side_tab import SideTabWidget
|
||||
from .egl_sync_group import EGLSyncGroup
|
||||
from .eos_group import EosGroup
|
||||
from .import_group import ImportGroup
|
||||
from .ubisoft_group import UbisoftGroup
|
||||
|
||||
|
||||
class IntegrationsTabs(SideTabWidget):
|
||||
def __init__(self, parent=None):
|
||||
super(IntegrationsTabs, self).__init__(show_back=True, parent=parent)
|
||||
self.import_group = ImportGroup(self)
|
||||
self.import_widget = IntegrationsWidget(
|
||||
self.import_group,
|
||||
self.tr("To import games from Epic Games Store, please enable EGL Sync."),
|
||||
self,
|
||||
)
|
||||
self.import_index = self.addTab(self.import_widget, self.tr("Import Games"))
|
||||
|
||||
self.egl_sync_group = EGLSyncGroup(self)
|
||||
self.egl_sync_widget = IntegrationsWidget(
|
||||
self.egl_sync_group,
|
||||
self.tr("To import EGL games from directories, please use Import Game."),
|
||||
self,
|
||||
)
|
||||
self.egl_sync_index = self.addTab(self.egl_sync_widget, self.tr("Sync with EGL"))
|
||||
|
||||
self.eos_ubisoft = IntegrationsWidget(
|
||||
None,
|
||||
self.tr(""),
|
||||
self,
|
||||
)
|
||||
self.eos_group = EosGroup(self.eos_ubisoft)
|
||||
self.ubisoft_group = UbisoftGroup(self.eos_ubisoft)
|
||||
self.eos_ubisoft.addWidget(self.eos_group)
|
||||
self.eos_ubisoft.addWidget(self.ubisoft_group)
|
||||
self.eos_ubisoft_index = self.addTab(self.eos_ubisoft, self.tr("Epic Overlay and Ubisoft"))
|
||||
|
||||
self.setCurrentIndex(self.import_index)
|
||||
|
||||
def show_import(self, app_name: str = None):
|
||||
self.setCurrentIndex(self.import_index)
|
||||
self.import_group.set_game(app_name)
|
||||
|
||||
def show_egl_sync(self):
|
||||
self.setCurrentIndex(self.egl_sync_index)
|
||||
|
||||
def show_eos_ubisoft(self):
|
||||
self.setCurrentIndex(self.eos_ubisoft_index)
|
||||
|
||||
|
||||
class IntegrationsWidget(QWidget):
|
||||
def __init__(self, widget: Optional[QWidget], info: str, parent=None):
|
||||
super(IntegrationsWidget, self).__init__(parent=parent)
|
||||
self.info = QLabel(f"<b>{info}</b>")
|
||||
|
||||
self.__layout = QVBoxLayout(self)
|
||||
if widget is not None:
|
||||
self.__layout.addWidget(widget)
|
||||
self.__layout.addWidget(self.info)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
def addWidget(self, widget: QWidget, stretch: int = 0, alignment: Qt.AlignmentFlag = Qt.Alignment()):
|
||||
self.__layout.insertWidget(self.layout().count() - 1, widget, stretch, alignment)
|
397
rare/components/tabs/games/integrations/egl_sync_group.py
Normal file
397
rare/components/tabs/games/integrations/egl_sync_group.py
Normal file
|
@ -0,0 +1,397 @@
|
|||
import os
|
||||
import platform
|
||||
from abc import abstractmethod
|
||||
from logging import getLogger
|
||||
from typing import Tuple, Iterable, List, Union
|
||||
|
||||
from PyQt5.QtCore import Qt, QThreadPool, QRunnable, pyqtSlot, pyqtSignal
|
||||
from PyQt5.QtGui import QShowEvent
|
||||
from PyQt5.QtWidgets import QGroupBox, QListWidgetItem, QFileDialog, QMessageBox, QFrame, QFormLayout
|
||||
from legendary.models.egl import EGLManifest
|
||||
from legendary.models.game import InstalledGame
|
||||
|
||||
from rare.lgndr.glue.exception import LgndrException
|
||||
from rare.models.pathspec import PathSpec
|
||||
from rare.shared import RareCore
|
||||
from rare.shared.workers.wine_resolver import WinePathResolver
|
||||
from rare.ui.components.tabs.games.integrations.egl_sync_group import Ui_EGLSyncGroup
|
||||
from rare.ui.components.tabs.games.integrations.egl_sync_list_group import Ui_EGLSyncListGroup
|
||||
from rare.utils.compat import utils as compat_utils
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
|
||||
|
||||
logger = getLogger("EGLSync")
|
||||
|
||||
|
||||
class EGLSyncGroup(QGroupBox):
|
||||
def __init__(self, parent=None):
|
||||
super(EGLSyncGroup, self).__init__(parent=parent)
|
||||
self.ui = Ui_EGLSyncGroup()
|
||||
self.ui.setupUi(self)
|
||||
self.core = RareCore.instance().core()
|
||||
|
||||
self.egl_path_edit = PathEdit(
|
||||
path=self.core.egl.programdata_path,
|
||||
placeholder=self.tr(
|
||||
"Path to the Wine prefix where EGL is installed, or the Manifests folder"
|
||||
),
|
||||
file_mode=QFileDialog.DirectoryOnly,
|
||||
edit_func=self.egl_path_edit_edit_cb,
|
||||
save_func=self.egl_path_edit_save_cb,
|
||||
parent=self,
|
||||
)
|
||||
self.ui.egl_sync_layout.setWidget(
|
||||
self.ui.egl_sync_layout.getWidgetPosition(self.ui.egl_path_edit_label)[0],
|
||||
QFormLayout.FieldRole, self.egl_path_edit
|
||||
)
|
||||
|
||||
self.egl_path_info = ElideLabel(parent=self)
|
||||
self.ui.egl_sync_layout.setWidget(
|
||||
self.ui.egl_sync_layout.getWidgetPosition(self.ui.egl_path_info_label)[0],
|
||||
QFormLayout.FieldRole, self.egl_path_info
|
||||
)
|
||||
|
||||
if platform.system() == "Windows":
|
||||
self.ui.egl_path_edit_label.setEnabled(False)
|
||||
self.egl_path_edit.setEnabled(False)
|
||||
self.ui.egl_path_info_label.setEnabled(False)
|
||||
self.egl_path_info.setEnabled(False)
|
||||
else:
|
||||
self.egl_path_edit.textChanged.connect(self.egl_path_changed)
|
||||
if self.core.egl.programdata_path:
|
||||
self.ui.egl_path_info_label.setEnabled(True)
|
||||
self.egl_path_info.setEnabled(True)
|
||||
|
||||
self.ui.egl_sync_check.setChecked(self.core.egl_sync_enabled)
|
||||
self.ui.egl_sync_check.stateChanged.connect(self.egl_sync_changed)
|
||||
|
||||
# lk: Temporarily disable automatic sync with EGL
|
||||
self.ui.egl_sync_check_label.setHidden(True)
|
||||
self.ui.egl_sync_check.setHidden(True)
|
||||
|
||||
self.import_list = EGLSyncImportGroup(parent=self)
|
||||
self.ui.import_export_layout.addWidget(self.import_list)
|
||||
self.export_list = EGLSyncExportGroup(parent=self)
|
||||
self.ui.import_export_layout.addWidget(self.export_list)
|
||||
|
||||
# self.egl_watcher = QFileSystemWatcher([self.egl_path_edit.text()], self)
|
||||
# self.egl_watcher.directoryChanged.connect(self.update_lists)
|
||||
|
||||
def showEvent(self, a0: QShowEvent) -> None:
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
if not self.core.egl.programdata_path:
|
||||
self.__run_wine_resolver()
|
||||
self.update_lists()
|
||||
super().showEvent(a0)
|
||||
|
||||
def __run_wine_resolver(self):
|
||||
self.egl_path_info.setText(self.tr("Updating..."))
|
||||
wine_resolver = WinePathResolver(self.core, "default", str(PathSpec.egl_programdata()))
|
||||
wine_resolver.signals.result_ready.connect(self.__on_wine_resolver_result)
|
||||
QThreadPool.globalInstance().start(wine_resolver)
|
||||
|
||||
def __on_wine_resolver_result(self, path):
|
||||
self.egl_path_info.setText(path)
|
||||
if not path:
|
||||
self.egl_path_info.setText(
|
||||
self.tr(
|
||||
"Default Wine prefix is unset, or path does not exist. "
|
||||
"Create it or configure it in Settings -> Linux."
|
||||
)
|
||||
)
|
||||
elif not os.path.exists(path):
|
||||
self.egl_path_info.setText(
|
||||
self.tr(
|
||||
"Default Wine prefix is set but EGL manifests path does not exist. "
|
||||
"Your configured default Wine prefix might not be where EGL is installed."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.egl_path_edit.setText(path)
|
||||
|
||||
@staticmethod
|
||||
def egl_path_edit_edit_cb(path) -> Tuple[bool, str, int]:
|
||||
if not path:
|
||||
return True, path, IndicatorReasonsCommon.VALID
|
||||
if os.path.exists(os.path.join(path, "system.reg")) and os.path.exists(
|
||||
os.path.join(path, "dosdevices/c:")
|
||||
):
|
||||
# path is a wine prefix
|
||||
path = PathSpec.prefix_egl_programdata(path)
|
||||
elif not path.rstrip("/").endswith(PathSpec.wine_egl_programdata()):
|
||||
# lower() might or might not be needed in the check
|
||||
return False, path, IndicatorReasonsCommon.WRONG_FORMAT
|
||||
if os.path.exists(path):
|
||||
return True, path, IndicatorReasonsCommon.VALID
|
||||
return False, path, IndicatorReasonsCommon.DIR_NOT_EXISTS
|
||||
|
||||
def egl_path_edit_save_cb(self, path):
|
||||
if not path or not os.path.exists(path):
|
||||
# This is the same as "--unlink"
|
||||
self.core.egl.programdata_path = None
|
||||
self.core.lgd.config.remove_option("Legendary", "egl_programdata")
|
||||
self.core.lgd.config.remove_option("Legendary", "egl_sync")
|
||||
# remove EGL GUIDs from all games, DO NOT remove .egstore folders because that would fuck things up.
|
||||
for igame in self.core.get_installed_list():
|
||||
igame.egl_guid = ""
|
||||
self.core.install_game(igame)
|
||||
else:
|
||||
self.core.egl.programdata_path = path
|
||||
self.core.lgd.config.set("Legendary", "egl_programdata", path)
|
||||
|
||||
self.core.lgd.save_config()
|
||||
|
||||
def egl_path_changed(self, path):
|
||||
if self.egl_path_edit.is_valid:
|
||||
self.ui.egl_sync_check.setEnabled(bool(path))
|
||||
self.ui.egl_sync_check.setCheckState(Qt.Unchecked)
|
||||
# self.egl_watcher.removePaths([p for p in self.egl_watcher.directories()])
|
||||
# self.egl_watcher.addPaths([path])
|
||||
self.update_lists()
|
||||
|
||||
def egl_sync_changed(self, state):
|
||||
if state == Qt.Unchecked:
|
||||
self.import_list.setEnabled(bool(self.import_list.items))
|
||||
self.export_list.setEnabled(bool(self.export_list.items))
|
||||
self.core.lgd.config.remove_option("Legendary", "egl_sync")
|
||||
else:
|
||||
self.core.lgd.config.set("Legendary", "egl_sync", str(True))
|
||||
# lk: do import/export here since automatic sync was selected
|
||||
self.import_list.mark(Qt.Checked)
|
||||
self.export_list.mark(Qt.Checked)
|
||||
sync_worker = EGLSyncWorker(self.import_list, self.export_list)
|
||||
QThreadPool.globalInstance().start(sync_worker)
|
||||
self.import_list.setEnabled(False)
|
||||
self.export_list.setEnabled(False)
|
||||
# self.update_lists()
|
||||
self.core.lgd.save_config()
|
||||
|
||||
def update_lists(self):
|
||||
# self.egl_watcher.blockSignals(True)
|
||||
have_path = False
|
||||
if self.core.egl.programdata_path:
|
||||
have_path = os.path.exists(self.core.egl.programdata_path)
|
||||
if not have_path and os.path.isdir(os.path.dirname(self.core.egl.programdata_path)):
|
||||
# NOTE: This might happen if EGL is installed but no games have been installed through it
|
||||
os.mkdir(self.core.egl.programdata_path)
|
||||
have_path = os.path.isdir(self.core.egl.programdata_path)
|
||||
# NOTE: need to clear known manifests to force refresh
|
||||
self.core.egl.manifests.clear()
|
||||
self.ui.egl_sync_check_label.setEnabled(have_path)
|
||||
self.ui.egl_sync_check.setEnabled(have_path)
|
||||
self.import_list.populate(have_path)
|
||||
self.import_list.setEnabled(have_path)
|
||||
self.export_list.populate(have_path)
|
||||
self.export_list.setEnabled(have_path)
|
||||
# self.egl_watcher.blockSignals(False)
|
||||
|
||||
|
||||
class EGLSyncListItem(QListWidgetItem):
|
||||
def __init__(self, game: Union[EGLManifest,InstalledGame], parent=None):
|
||||
super(EGLSyncListItem, self).__init__(parent=parent)
|
||||
self.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
|
||||
self.setCheckState(Qt.Unchecked)
|
||||
self.core = RareCore.instance().core()
|
||||
self.game = game
|
||||
self.setText(self.app_title)
|
||||
|
||||
def is_checked(self) -> bool:
|
||||
return self.checkState() == Qt.Checked
|
||||
|
||||
@abstractmethod
|
||||
def action(self) -> Union[str, bool]:
|
||||
pass
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
return self.game.app_name
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def app_title(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class EGLSyncExportItem(EGLSyncListItem):
|
||||
def __init__(self, game: InstalledGame, parent=None):
|
||||
super(EGLSyncExportItem, self).__init__(game=game, parent=parent)
|
||||
|
||||
def action(self) -> Union[str,bool]:
|
||||
error = False
|
||||
try:
|
||||
self.core.egl_export(self.game.app_name)
|
||||
except LgndrException as ret:
|
||||
error = ret.message
|
||||
return error
|
||||
|
||||
@property
|
||||
def app_title(self) -> str:
|
||||
return self.game.title
|
||||
|
||||
|
||||
class EGLSyncImportItem(EGLSyncListItem):
|
||||
def __init__(self, game: EGLManifest, parent=None):
|
||||
super(EGLSyncImportItem, self).__init__(game=game, parent=parent)
|
||||
|
||||
def action(self) -> Union[str,bool]:
|
||||
error = False
|
||||
try:
|
||||
self.core.egl_import(self.game.app_name)
|
||||
except LgndrException as ret:
|
||||
error = ret.message
|
||||
return error
|
||||
|
||||
@property
|
||||
def app_title(self) -> str:
|
||||
return self.core.get_game(self.game.app_name).app_title
|
||||
|
||||
|
||||
class EGLSyncListGroup(QGroupBox):
|
||||
action_errors = pyqtSignal(list)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(EGLSyncListGroup, self).__init__(parent=parent)
|
||||
self.ui = Ui_EGLSyncListGroup()
|
||||
self.ui.setupUi(self)
|
||||
self.ui.list.setFrameShape(QFrame.NoFrame)
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = RareCore.instance().core()
|
||||
|
||||
self.ui.list.itemDoubleClicked.connect(
|
||||
lambda item: item.setCheckState(Qt.Unchecked)
|
||||
if item.checkState() != Qt.Unchecked
|
||||
else item.setCheckState(Qt.Checked)
|
||||
)
|
||||
self.ui.list.itemChanged.connect(self.has_selected)
|
||||
|
||||
self.ui.select_all_button.clicked.connect(lambda: self.mark(Qt.Checked))
|
||||
self.ui.select_none_button.clicked.connect(lambda: self.mark(Qt.Unchecked))
|
||||
|
||||
self.ui.action_button.clicked.connect(self.action)
|
||||
|
||||
self.action_errors.connect(self.show_errors)
|
||||
|
||||
def has_selected(self):
|
||||
for item in self.items:
|
||||
if item.is_checked():
|
||||
self.ui.action_button.setEnabled(True)
|
||||
return
|
||||
self.ui.action_button.setEnabled(False)
|
||||
|
||||
def mark(self, state):
|
||||
for item in self.items:
|
||||
item.setCheckState(state)
|
||||
|
||||
def populate(self, enabled: bool):
|
||||
self.ui.label.setVisible(not enabled or not bool(self.ui.list.count()))
|
||||
self.ui.list.setVisible(enabled and bool(self.ui.list.count()))
|
||||
self.ui.buttons_widget.setVisible(enabled and bool(self.ui.list.count()))
|
||||
|
||||
@abstractmethod
|
||||
def action(self):
|
||||
pass
|
||||
|
||||
@pyqtSlot(list)
|
||||
@abstractmethod
|
||||
def show_errors(self, errors: List):
|
||||
pass
|
||||
|
||||
@property
|
||||
def items(self) -> Iterable[EGLSyncListItem]:
|
||||
# for i in range(self.list.count()):
|
||||
# yield self.list.item(i)
|
||||
return map(self.ui.list.item, range(self.ui.list.count()))
|
||||
# return [self.ui.list.item(i) for i in range(self.ui.list.count())]
|
||||
|
||||
|
||||
class EGLSyncExportGroup(EGLSyncListGroup):
|
||||
def __init__(self, parent=None):
|
||||
super(EGLSyncExportGroup, self).__init__(parent=parent)
|
||||
self.setTitle(self.tr("Exportable games"))
|
||||
self.ui.label.setText(self.tr("No games to export to EGL"))
|
||||
self.ui.action_button.setText(self.tr("Export"))
|
||||
|
||||
def populate(self, enabled: bool):
|
||||
if enabled:
|
||||
self.ui.list.clear()
|
||||
for item in self.core.egl_get_exportable():
|
||||
try:
|
||||
i = EGLSyncExportItem(item, self.ui.list)
|
||||
except AttributeError:
|
||||
logger.error(f"{item.app_name} does not work. Ignoring")
|
||||
else:
|
||||
self.ui.list.addItem(i)
|
||||
super(EGLSyncExportGroup, self).populate(enabled)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def show_errors(self, errors: List):
|
||||
QMessageBox.warning(
|
||||
self.parent(),
|
||||
self.tr("The following errors occurred while exporting."),
|
||||
"\n".join(errors),
|
||||
)
|
||||
|
||||
def action(self):
|
||||
errors: List = []
|
||||
for item in self.items:
|
||||
if item.is_checked():
|
||||
if e := item.action():
|
||||
errors.append(e)
|
||||
self.populate(True)
|
||||
if errors:
|
||||
self.action_errors.emit(errors)
|
||||
|
||||
|
||||
class EGLSyncImportGroup(EGLSyncListGroup):
|
||||
def __init__(self, parent=None):
|
||||
super(EGLSyncImportGroup, self).__init__(parent=parent)
|
||||
self.setTitle(self.tr("Importable games"))
|
||||
self.ui.label.setText(self.tr("No games to import from EGL"))
|
||||
self.ui.action_button.setText(self.tr("Import"))
|
||||
self.list_func = self.core.egl_get_importable
|
||||
|
||||
def populate(self, enabled: bool):
|
||||
if enabled:
|
||||
self.ui.list.clear()
|
||||
for item in self.core.egl_get_importable():
|
||||
try:
|
||||
i = EGLSyncImportItem(item, self.ui.list)
|
||||
except AttributeError:
|
||||
logger.error(f"{item.app_name} does not work. Ignoring")
|
||||
else:
|
||||
self.ui.list.addItem(i)
|
||||
super(EGLSyncImportGroup, self).populate(enabled)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def show_errors(self, errors: List):
|
||||
QMessageBox.warning(
|
||||
self.parent(),
|
||||
self.tr("The following errors occurred while importing."),
|
||||
"\n".join(errors),
|
||||
)
|
||||
|
||||
def action(self):
|
||||
errors: List = []
|
||||
for item in self.items:
|
||||
if item.is_checked():
|
||||
if e := item.action():
|
||||
errors.append(e)
|
||||
else:
|
||||
self.rcore.get_game(item.app_name).set_installed(True)
|
||||
self.populate(True)
|
||||
if errors:
|
||||
self.action_errors.emit(errors)
|
||||
|
||||
|
||||
class EGLSyncWorker(QRunnable):
|
||||
def __init__(self, import_list: EGLSyncListGroup, export_list: EGLSyncListGroup):
|
||||
super(EGLSyncWorker, self).__init__()
|
||||
self.setAutoDelete(True)
|
||||
self.import_list = import_list
|
||||
self.export_list = export_list
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self):
|
||||
self.import_list.action()
|
||||
self.export_list.action()
|
280
rare/components/tabs/games/integrations/eos_group.py
Normal file
280
rare/components/tabs/games/integrations/eos_group.py
Normal file
|
@ -0,0 +1,280 @@
|
|||
import os
|
||||
import platform
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QRunnable, QObject, pyqtSignal, QThreadPool, Qt, pyqtSlot, QSize
|
||||
from PyQt5.QtGui import QShowEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QGroupBox,
|
||||
QMessageBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QSizePolicy,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QFormLayout,
|
||||
QComboBox,
|
||||
)
|
||||
|
||||
from rare.lgndr.core import LegendaryCore
|
||||
from rare.models.game import RareEosOverlay
|
||||
from rare.shared import RareCore
|
||||
from rare.ui.components.tabs.games.integrations.eos_widget import Ui_EosWidget
|
||||
from rare.utils import config_helper as config
|
||||
from rare.utils.misc import qta_icon
|
||||
from rare.widgets.elide_label import ElideLabel
|
||||
|
||||
logger = getLogger("EpicOverlay")
|
||||
|
||||
|
||||
class CheckForUpdateWorker(QRunnable):
|
||||
class CheckForUpdateSignals(QObject):
|
||||
update_available = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, core: LegendaryCore):
|
||||
super(CheckForUpdateWorker, self).__init__()
|
||||
self.signals = self.CheckForUpdateSignals()
|
||||
self.setAutoDelete(True)
|
||||
self.core = core
|
||||
|
||||
def run(self) -> None:
|
||||
self.core.check_for_overlay_updates()
|
||||
self.signals.update_available.emit(self.core.overlay_update_available)
|
||||
|
||||
|
||||
class EosPrefixWidget(QFrame):
|
||||
def __init__(self, overlay: RareEosOverlay, prefix: Optional[str], parent=None):
|
||||
super(EosPrefixWidget, self).__init__(parent=parent)
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
self.indicator = QLabel(parent=self)
|
||||
self.indicator.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||||
|
||||
self.prefix_label = ElideLabel(
|
||||
prefix.replace(os.path.expanduser("~"), "~") if prefix is not None else overlay.app_title,
|
||||
parent=self,
|
||||
)
|
||||
self.overlay_label = ElideLabel(parent=self)
|
||||
self.overlay_label.setDisabled(True)
|
||||
|
||||
self.path_select = QComboBox(self)
|
||||
self.path_select.setMaximumWidth(150)
|
||||
self.path_select.setMinimumWidth(150)
|
||||
|
||||
self.button = QPushButton(parent=self)
|
||||
self.button.setMinimumWidth(150)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(-1, 0, 0, 0)
|
||||
layout.addWidget(self.indicator)
|
||||
layout.addWidget(self.prefix_label, stretch=2)
|
||||
layout.addWidget(self.overlay_label, stretch=3)
|
||||
layout.addWidget(self.path_select)
|
||||
layout.addWidget(self.button)
|
||||
|
||||
self.overlay = overlay
|
||||
self.prefix = prefix
|
||||
|
||||
self.path_select.currentIndexChanged.connect(self.path_changed)
|
||||
self.button.clicked.connect(self.action)
|
||||
self.overlay.signals.game.installed.connect(self.update_state)
|
||||
self.overlay.signals.game.uninstalled.connect(self.update_state)
|
||||
|
||||
self.update_state()
|
||||
|
||||
@pyqtSlot(int)
|
||||
def path_changed(self, index: int) -> None:
|
||||
path = self.path_select.itemData(index, Qt.UserRole)
|
||||
active_path = os.path.normpath(p) if (p := self.overlay.active_path(self.prefix)) else ""
|
||||
if self.overlay.is_enabled(self.prefix) and (path == active_path):
|
||||
self.button.setText(self.tr("Disable overlay"))
|
||||
else:
|
||||
self.button.setText(self.tr("Enable overlay"))
|
||||
|
||||
@pyqtSlot()
|
||||
def update_state(self) -> None:
|
||||
active_path = os.path.normpath(p) if (p := self.overlay.active_path(self.prefix)) else ""
|
||||
|
||||
self.overlay_label.setText(f"<i>{active_path}</i>")
|
||||
self.path_select.clear()
|
||||
|
||||
if not self.overlay.is_installed and not self.overlay.available_paths(self.prefix):
|
||||
self.setDisabled(True)
|
||||
self.indicator.setPixmap(qta_icon("fa.circle-o", color="grey").pixmap(20, 20))
|
||||
self.overlay_label.setText(self.overlay.active_path(self.prefix))
|
||||
self.button.setText(self.tr("Unavailable"))
|
||||
return
|
||||
|
||||
if self.overlay.is_enabled(self.prefix):
|
||||
self.indicator.setPixmap(qta_icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
|
||||
else:
|
||||
self.indicator.setPixmap(qta_icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20)))
|
||||
|
||||
install_path = os.path.normpath(p) if (p := self.overlay.install_path) else ""
|
||||
|
||||
self.path_select.addItem("Auto-detect", "")
|
||||
self.path_select.setItemData(0, "Auto-detect", Qt.ToolTipRole)
|
||||
for path in self.overlay.available_paths(self.prefix):
|
||||
path = os.path.normpath(path)
|
||||
self.path_select.addItem("Legendary-managed" if path == install_path else "EGL-managed", path)
|
||||
self.path_select.setItemData(self.path_select.findData(path), path, Qt.ToolTipRole)
|
||||
self.path_select.setCurrentIndex(self.path_select.findData(active_path))
|
||||
|
||||
self.setEnabled(self.overlay.state == RareEosOverlay.State.IDLE)
|
||||
|
||||
@pyqtSlot()
|
||||
def action(self) -> None:
|
||||
path = self.path_select.currentData(Qt.UserRole)
|
||||
active_path = os.path.normpath(p) if (p := self.overlay.active_path(self.prefix)) else ""
|
||||
install_path = os.path.normpath(p) if (p := self.overlay.install_path) else ""
|
||||
if self.overlay.is_enabled(self.prefix) and (path == active_path):
|
||||
if not self.overlay.disable(prefix=self.prefix):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Warning",
|
||||
self.tr("Failed to completely disable the active EOS Overlay.{}").format(
|
||||
self.tr(
|
||||
" Since the previous overlay was managed by EGL you can safely ignore this is."
|
||||
)
|
||||
if active_path != install_path
|
||||
else ""
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.overlay.disable(prefix=self.prefix)
|
||||
if not self.overlay.enable(prefix=self.prefix, path=path):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Warning",
|
||||
self.tr("Failed to completely enable EOS overlay.{}").format(
|
||||
self.tr(
|
||||
" Since the previous overlay was managed by EGL you can safely ignore this is."
|
||||
)
|
||||
if active_path != install_path
|
||||
else ""
|
||||
),
|
||||
)
|
||||
self.update_state()
|
||||
|
||||
|
||||
class EosGroup(QGroupBox):
|
||||
def __init__(self, parent=None):
|
||||
super(EosGroup, self).__init__(parent=parent)
|
||||
self.ui = Ui_EosWidget()
|
||||
self.ui.setupUi(self)
|
||||
# lk: set object names for CSS properties
|
||||
self.ui.install_button.setObjectName("InstallButton")
|
||||
self.ui.uninstall_button.setObjectName("UninstallButton")
|
||||
|
||||
self.ui.install_page_layout.setAlignment(Qt.AlignTop)
|
||||
self.ui.info_page_layout.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.ui.install_button.setIcon(qta_icon("ri.install-line"))
|
||||
self.ui.uninstall_button.setIcon(qta_icon("ri.uninstall-line"))
|
||||
|
||||
self.installed_path_label = ElideLabel(parent=self)
|
||||
self.installed_version_label = ElideLabel(parent=self)
|
||||
|
||||
self.ui.info_label_layout.setWidget(0, QFormLayout.FieldRole, self.installed_version_label)
|
||||
self.ui.info_label_layout.setWidget(1, QFormLayout.FieldRole, self.installed_path_label)
|
||||
|
||||
self.rcore = RareCore.instance()
|
||||
self.core = self.rcore.core()
|
||||
self.signals = self.rcore.signals()
|
||||
self.overlay = self.rcore.get_overlay()
|
||||
|
||||
self.overlay.signals.widget.update.connect(self.update_state)
|
||||
self.overlay.signals.game.installed.connect(self.install_finished)
|
||||
self.overlay.signals.game.uninstalled.connect(self.uninstall_finished)
|
||||
|
||||
self.ui.install_button.clicked.connect(self.install_overlay)
|
||||
self.ui.update_button.clicked.connect(self.install_overlay)
|
||||
self.ui.uninstall_button.clicked.connect(self.uninstall_overlay)
|
||||
|
||||
if self.overlay.is_installed: # installed
|
||||
self.installed_version_label.setText(f"<b>{self.overlay.version}</b>")
|
||||
self.installed_path_label.setText(os.path.normpath(self.overlay.install_path))
|
||||
self.ui.overlay_stack.setCurrentWidget(self.ui.info_page)
|
||||
else:
|
||||
self.ui.overlay_stack.setCurrentWidget(self.ui.install_page)
|
||||
self.ui.update_button.setEnabled(False)
|
||||
|
||||
self.threadpool = QThreadPool.globalInstance()
|
||||
self.worker: Optional[CheckForUpdateWorker] = None
|
||||
|
||||
def showEvent(self, a0: QShowEvent) -> None:
|
||||
if a0.spontaneous():
|
||||
return super().showEvent(a0)
|
||||
self.check_for_update()
|
||||
self.update_prefixes()
|
||||
self.update_state()
|
||||
super().showEvent(a0)
|
||||
|
||||
@pyqtSlot()
|
||||
def update_state(self):
|
||||
self.ui.install_button.setEnabled(self.overlay.state == RareEosOverlay.State.IDLE)
|
||||
self.ui.update_button.setEnabled(self.overlay.state == RareEosOverlay.State.IDLE and self.overlay.has_update)
|
||||
self.ui.uninstall_button.setEnabled(self.overlay.state == RareEosOverlay.State.IDLE)
|
||||
|
||||
def update_prefixes(self):
|
||||
for widget in self.findChildren(EosPrefixWidget, options=Qt.FindDirectChildrenOnly):
|
||||
widget.deleteLater()
|
||||
|
||||
if platform.system() != "Windows":
|
||||
prefixes = config.get_prefixes()
|
||||
prefixes = {prefix for prefix, _ in prefixes if config.prefix_exists(prefix)}
|
||||
if platform.system() == "Darwin":
|
||||
# TODO: add crossover support
|
||||
pass
|
||||
for prefix in prefixes:
|
||||
widget = EosPrefixWidget(self.overlay, prefix)
|
||||
self.ui.eos_layout.addWidget(widget)
|
||||
logger.debug("Updated prefixes")
|
||||
else:
|
||||
widget = EosPrefixWidget(self.overlay, None)
|
||||
self.ui.eos_layout.addWidget(widget)
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def check_for_update_finished(self, update_available: bool):
|
||||
self.worker = None
|
||||
self.ui.update_button.setEnabled(update_available)
|
||||
|
||||
def check_for_update(self):
|
||||
self.ui.update_button.setEnabled(False)
|
||||
if not self.overlay.is_installed:
|
||||
return
|
||||
|
||||
if self.worker is not None:
|
||||
return
|
||||
|
||||
self.worker = CheckForUpdateWorker(self.core)
|
||||
self.worker.signals.update_available.connect(self.check_for_update_finished)
|
||||
QThreadPool.globalInstance().start(self.worker)
|
||||
|
||||
@pyqtSlot()
|
||||
def install_finished(self):
|
||||
if not self.overlay.is_installed:
|
||||
logger.error("Something went wrong while installing overlay")
|
||||
QMessageBox.warning(self, "Error", self.tr("Something went wrong while installing Overlay"))
|
||||
return
|
||||
self.ui.overlay_stack.setCurrentWidget(self.ui.info_page)
|
||||
self.installed_version_label.setText(f"<b>{self.overlay.version}</b>")
|
||||
self.installed_path_label.setText(self.overlay.install_path)
|
||||
self.ui.update_button.setEnabled(False)
|
||||
|
||||
@pyqtSlot()
|
||||
def uninstall_finished(self):
|
||||
self.ui.overlay_stack.setCurrentWidget(self.ui.install_page)
|
||||
|
||||
@pyqtSlot()
|
||||
def install_overlay(self):
|
||||
self.overlay.install()
|
||||
|
||||
def uninstall_overlay(self):
|
||||
if not self.overlay.is_installed:
|
||||
logger.error("No Legendary-managed overlay installation found.")
|
||||
self.ui.overlay_stack.setCurrentWidget(self.ui.install_page)
|
||||
return
|
||||
self.overlay.uninstall()
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue