1
0
Fork 0
mirror of synced 2024-06-03 03:04:42 +12:00

Compare commits

...

No commits in common. "0.1.1" and "main" have entirely different histories.
0.1.1 ... main

366 changed files with 71742 additions and 3708 deletions

4
.gitattributes vendored Normal file
View 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
View 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` |

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

32
.gitignore vendored
View file

@ -1,8 +1,26 @@
/images/
/.idea/
/Rare/__pycache__/
/CountLines.sh
/Rare/utils/__pycache__/
/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

10
.tx/config Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
include README.md
include rare/resources/images/*
include rare/resources/languages/*

200
README.md
View file

@ -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) |

View file

@ -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()

View file

@ -1,191 +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.utils.RareConfig import IMAGE_DIR
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"{IMAGE_DIR}/{game.app_name}/FinalArt.png"):
pixmap = QPixmap(f"{IMAGE_DIR}/{game.app_name}/FinalArt.png")
elif os.path.exists(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png"):
pixmap = QPixmap(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png")
elif os.path.exists(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png"):
pixmap = QPixmap(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png")
else:
logger.warning(f"No Image found: {self.game.title}")
pixmap=None
if pixmap:
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
if os.path.exists(f"{IMAGE_DIR}/{game.app_name}/UninstalledArt.png"):
pixmap = QPixmap(f"{IMAGE_DIR}/{game.app_name}/UninstalledArt.png")
pixmap = pixmap.scaled(120, 160)
self.image = QLabel()
self.image.setPixmap(pixmap)
else:
print(os.listdir(IMAGE_DIR)/game.app_name)
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")

View file

@ -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)

View file

@ -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")

View file

@ -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.INFO
)
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()

View file

@ -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")

View file

@ -1,260 +0,0 @@
obit = """
/*Copyright (c) DevSec Studio. All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/*-----QWidget-----*/
QWidget
{
background-color: #232430;
color: #000000;
border-color: #000000;
}
/*-----QLabel-----*/
QLabel
{
background-color: #232430;
color: #c1c1c1;
border-color: #000000;
}
/*-----QPushButton-----*/
QPushButton
{
background-color: #ff9c2b;
color: #000000;
font-weight: bold;
border-style: solid;
border-color: #000000;
padding: 6px;
}
QPushButton::hover
{
background-color: #ffaf5d;
}
QPushButton::pressed
{
background-color: #dd872f;
}
/*-----QToolButton-----*/
QToolButton
{
background-color: #ff9c2b;
color: #000000;
font-weight: bold;
border-style: solid;
border-color: #000000;
padding: 6px;
}
QToolButton::hover
{
background-color: #ffaf5d;
}
QToolButton::pressed
{
background-color: #dd872f;
}
/*-----QLineEdit-----*/
QLineEdit
{
background-color: #38394e;
color: #c1c1c1;
border-style: solid;
border-width: 1px;
border-color: #4a4c68;
}
/*-----QTableView-----*/
QTableView,
QHeaderView,
QTableView::item
{
background-color: #232430;
color: #c1c1c1;
border: none;
}
QTableView::item:selected
{
background-color: #41424e;
color: #c1c1c1;
}
QHeaderView::section:horizontal
{
background-color: #232430;
border: 1px solid #37384d;
padding: 5px;
}
QTableView::indicator{
background-color: #1d1d28;
border: 1px solid #37384d;
}
QTableView::indicator:checked{
image:url("./ressources/check.png"); /*To replace*/
background-color: #1d1d28;
}
/*-----QTabWidget-----*/
QTabWidget::pane
{
border: none;
}
QTabWidget::tab-bar
{
left: 5px;
}
QTabBar::tab
{
color: #c1c1c1;
min-width: 1px;
padding-left: 25px;
margin-left:-22px;
height: 28px;
border: none;
}
QTabBar::tab:selected
{
color: #c1c1c1;
font-weight: bold;
height: 28px;
}
QTabBar::tab:!first
{
margin-left: -20px;
}
QTabBar::tab:hover
{
color: #DDD;
}
/*-----QScrollBar-----*/
QScrollBar:horizontal
{
background-color: transparent;
height: 8px;
margin: 0px;
padding: 0px;
}
QScrollBar::handle:horizontal
{
border: none;
min-width: 100px;
background-color: #56576c;
}
QScrollBar::add-line:horizontal,
QScrollBar::sub-line:horizontal,
QScrollBar::add-page:horizontal,
QScrollBar::sub-page:horizontal
{
width: 0px;
background-color: transparent;
}
QScrollBar:vertical
{
background-color: transparent;
width: 8px;
margin: 0;
}
QScrollBar::handle:vertical
{
border: none;
min-height: 100px;
background-color: #56576c;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical,
QScrollBar::add-page:vertical,
QScrollBar::sub-page:vertical
{
height: 0px;
background-color: transparent;
}
"""

View file

@ -1,2 +0,0 @@
from Rare.Styles.dark import dark
from Rare.Styles.Obit import obit

File diff suppressed because it is too large Load diff

View file

@ -1,325 +0,0 @@
import os
import shutil
from logging import getLogger
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.Styles import dark, obit
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.THEME == "dark":
self.parent().parent().setStyleSheet(dark)
self.style_combo_box.setCurrentIndex(1)
if RareConfig.THEME == "obit":
self.parent().parent().setStyleSheet(obit)
self.style_combo_box.setCurrentIndex(2)
self.image_dir_edit.setText(RareConfig.IMAGE_DIR)
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(dark)
config["Rare"]["theme"] = "dark"
elif self.style_combo_box.currentIndex() == 2:
self.parent().parent().parent().setStyleSheet(obit)
config["Rare"]["theme"] = "obit"
else:
self.parent().parent().parent().setStyleSheet("")
config["Rare"]["theme"] = "light"
config["Rare"]["IMAGE_DIR"] = self.image_dir_edit.text()
if self.image_dir_edit.text() != RareConfig.IMAGE_DIR:
shutil.rmtree(RareConfig.IMAGE_DIR)
restart = True
else:
restart = False
RareConfig.set_config(config)
if restart:
logger.info("Restart App to download Images")
# TODO Restart automatically
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", "Obit"])
self.rare_form.addRow(QLabel("Style"), self.style_combo_box)
self.image_dir_edit = QLineEdit()
self.image_dir_edit.setPlaceholderText("Image directory")
self.rare_form.addRow(QLabel("Image Directory"),self.image_dir_edit)
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

View file

@ -1 +0,0 @@
__version__ = "0.3"

View file

@ -1,36 +0,0 @@
import configparser
import os
from logging import getLogger
config_path = os.path.join(os.path.expanduser("~"), ".config/Rare/")
rare_config = configparser.ConfigParser()
logger = getLogger("Config")
rare_config.read(config_path + "config.ini")
if not os.path.exists(config_path):
os.mkdir(config_path)
rare_config["Rare"] = {
"IMAGE_DIR": os.path.expanduser("~/.rare/images"),
"theme": "dark"
}
rare_config.write(open(config_path + "config.ini", "w"))
elif not rare_config.sections():
rare_config["Rare"] = {
"IMAGE_DIR": os.path.expanduser("~/.rare/images"),
"theme": "dark"
}
rare_config.write(open(config_path + "config.ini", "w"))
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"))
IMAGE_DIR = rare_config["Rare"]["IMAGE_DIR"]
THEME = rare_config["Rare"]["theme"]

View file

@ -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.utils.RareConfig 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)

View file

@ -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"))

View file

@ -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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

49
freeze.py Normal file
View 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]
)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
if __name__ == "__main__":
import sys
from rare.main import main
sys.exit(main())

View file

View 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

View 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()

View 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")

View 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
View file

13
rare/commands/webview.py Normal file
View 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
View 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

View file

View 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

View 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()

View 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))

View 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.")

View 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)

View 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

View 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

View 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)

View 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)

View 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

View 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)

View 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

View 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)

View 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)

View 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

View 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)))

View 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())

View 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)

View 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()

View 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()

View 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)

View 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)

View 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)

View 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()

View 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)

View 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)

View 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

View 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

View 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"))

View 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)

View 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)

View 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()

Some files were not shown because too many files have changed in this diff Show more