diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 000000000..b9c82e9a5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,27 @@ +name: Checks + +on: + push: + pull_request: + branches: [ develop ] + +jobs: + checks: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Test with pytest + run: | + python runtests.py -vv --full-trace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..26b6370ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release Version' + required: true + +jobs: + release: + environment: deploy + runs-on: windows-latest + env: + UID_VERSION: v4.0.6 + ICE_ADAPTER_VERSION: v3.3.7 + BUILD_VERSION: ${{ github.event.inputs.version }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Download ICE adapter and UID calculator + run: | + mkdir build_setup\ice-adapter + Invoke-WebRequest -Uri "https://github.com/FAForever/uid/releases/download/$($env:UID_VERSION)/faf-uid.exe" -OutFile ".\\build_setup\\faf-uid.exe" + Invoke-WebRequest -Uri "https://content.faforever.com/build/jre/windows-amd64-21.0.1.tar.gz" -OutFile ".\\windows-amd64-15.0.1.tar.gz" + 7z x windows-amd64-15.0.1.tar.gz + 7z x windows-amd64-15.0.1.tar -obuild_setup/ice-adapter/jre + Invoke-WebRequest -Uri "https://github.com/FAForever/java-ice-adapter/releases/download/$($env:ICE_ADAPTER_VERSION)/faf-ice-adapter-$($env:ICE_ADAPTER_VERSION)-win.jar" -OutFile ".\\build_setup\\ice-adapter\\faf-ice-adapter.jar" + + - name: Test with pytest + run: | + python runtests.py -vv --full-trace + + - name: Build application + run: | + python setup.py bdist_msi + + - name: Get Artifact Paths + id: artifact_paths + run: | + function getMsiPath { + $files = Get-ChildItem *.msi -Recurse | Select -First 1 + (Get-Item $files).FullName + } + $WINDOWS_MSI = getMsiPath + Write-Host "MSI path: $WINDOWS_MSI" + $WINDOWS_MSI_NAME = (Get-Item $WINDOWS_MSI).Name + Write-Host "MSI name: $WINDOWS_MSI_NAME" + echo "WINDOWS_MSI=$WINDOWS_MSI" >> "$env:GITHUB_ENV" + echo "WINDOWS_MSI_NAME=$WINDOWS_MSI_NAME" >> "$env:GITHUB_ENV" + + - name: Calculate checksum + id: checksum + run: | + Write-Host MSI path is: $env:WINDOWS_MSI + $MSI_SUM = $(Get-FileHash $env:WINDOWS_MSI).hash + Write-Host $MSI_SUM + echo "MSI_SUM=$MSI_SUM" >> "$env:GITHUB_ENV" + + - name: Create draft release + id: create_release + uses: ncipollo/release-action@v1.14.0 + with: + commit: ${{ github.sha }} + tag: ${{ github.event.inputs.version }} + body: "SHA256: ${{ env.MSI_SUM }}" + draft: true + prerelease: true + artifacts: ${{ env.WINDOWS_MSI }} diff --git a/.gitignore b/.gitignore index d237e45c1..f279bfdd6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,10 @@ *.pydev* *.vcproj .idea/ -lib/faf-uid -lib/faf-uid.exe +lib/ +natives/ build/ +build_setup/ dist/ Thumbs.db .DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..9a5293d5e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-docstring-first +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma +- repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 +- repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort diff --git a/.travis.sh b/.travis.sh deleted file mode 100644 index 1a2485251..000000000 --- a/.travis.sh +++ /dev/null @@ -1 +0,0 @@ -#!/bin/sh diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8f290d425..000000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: python -dist: trusty -sudo: required - -python: - - 3.6 - -install: - - "sudo apt-get update" - - "sudo apt-get install libjpeg8-dev zlib1g-dev python3-pytest" - - "sudo apt-get install libpulse-mainloop-glib0" # For qtmultimedia - - "pip3 install cx_Freeze" - - "pip3 install PyQt5" - - "pip3 install python-coveralls" - - "pip3 install -r requirements.txt --trusted-host content.faforever.com" - - curl -s https://api.github.com/repos/FAForever/uid/releases/latest | jq -r '.assets[] | select(.name == "faf-uid") | .browser_download_url' | wget -i - -O ./lib/faf-uid - - -before_script: - - "export DISPLAY=:99.0" - - "sh -e /etc/init.d/xvfb start" - -script: - - export PYTEST_QT_API=pyqt5 - - python3 runtests.py -xs --cov src --cov-report term-missing - -after_success: - - coveralls diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index 5a8646671..000000000 --- a/Vagrantfile +++ /dev/null @@ -1,27 +0,0 @@ -#-*- mode: ruby -*- -# vi: set ft=ruby : - -# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! -VAGRANTFILE_API_VERSION = "2" - -Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - # All Vagrant configuration is done here. The most common configuration - # options are documented and commented below. For a complete reference, - # please see the online documentation at vagrantup.com. - - # Every Vagrant virtual environment requires a box to build off of. - config.vm.box = "opentable/win-2012r2-standard-amd64-nocm" - config.vm.provision :shell, :path => "install.ps1" - config.vm.guest = :windows - config.ssh.insert_key = false - - # Provider-specific configuration so you can fine-tune various - # backing providers for Vagrant. These expose provider-specific options. - # Example for VirtualBox: - # - config.vm.provider "virtualbox" do |vb| - # Don't boot with headless mode - vb.gui = true - vb.customize ["modifyvm", :id, "--vram", "128"] - end -end diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 59d93ca31..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,71 +0,0 @@ -# GITHUB_TOKEN env var defined in appveyor project -# GITHUB_USER env var defined in appveyor project -environment: - global: - GITHUB_USER: faf-bot - GITHUB_TOKEN: - secure: Np481CGoJvTGB8O5rZTRswUZagGv7WtWT/TXTABQ184xUbL5vj9NoCtmtNRK6YxF - UID_VERSION: v4.0.4 - matrix: - - PYTHON: "C:\\Python36" - PYWHEEL_INFIX: "cp36" - -init: - - "ECHO %PYTHON% %APPVEYOR_REPO_TAG_NAME%" - -install: - # dump version - - git describe --tags --always - # install dependencies using pip - - "%PYTHON%\\Scripts\\pip install pyqt5" - - "%PYTHON%\\Scripts\\pip install https://github.com/FAForever/python-wheels/releases/download/2.0.0/pywin32-221-%PYWHEEL_INFIX%-%PYWHEEL_INFIX%m-win32.whl" - - "%PYTHON%\\Scripts\\pip install wheel" - - "%PYTHON%\\Scripts\\pip install pytest" - - "%PYTHON%\\Scripts\\pip install cx_Freeze==5.0.2" - - "%PYTHON%\\Scripts\\pip install -r requirements.txt --trusted-host content.faforever.com" - # copy required dlls for packaging in setup.py - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\plugins\\imageformats .\\imageformats /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\plugins\\platforms .\\platforms /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\libeay32.dll ." - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\ssleay32.dll ." - - "xcopy %PYTHON%\\lib\\site-packages\\pywin32_system32\\pywintypes36.dll ." - - "xcopy %PYTHON%\\lib\\site-packages\\pywin32_system32\\pythoncom36.dll ." - # for QtwebEngine until we get rid of it - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\QtWebEngineProcess.exe . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\Qt5WebEngine.dll . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\Qt5WebEngineCore.dll . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\Qt5WebEngineWidgets.dll . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\Qt5WebChannel.dll . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\libEGL.dll . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\bin\\libGLESv2.dll . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\resources\\icudtl.dat . /I" - - "xcopy %PYTHON%\\lib\\site-packages\\PyQt5\\Qt\\resources\\qtwebengine_resources.pak . /I" # Needed for JS to work - - - ps: "$env:faf_version = \"$(&C:\\Python36\\python .\\src\\config\\version.py)\".Replace(' ','')" - - ps: "$env:APPVEYOR_BUILD_VERSION = \"$($env:faf_version)$($env:APPVEYOR_BUILD_NUMBER)\"" - - ps: "Write-Host \"$($env:faf_version) + $($env:APPVEYOR_BUILD_NUMBER) = $($env:APPVEYOR_BUILD_VERSION)\"" - - ps: "$env:PYTEST_QT_API=\"pyqt5\"" - - ps: "$env:FAF_FORCE_PRODUCTION=true" - - ps: "Invoke-WebRequest -Uri \"https://github.com/FAForever/uid/releases/download/$($env:UID_VERSION)/faf-uid.exe\" -OutFile \".\\lib\\faf-uid.exe\"" - -test_script: - - "%PYTHON%\\python runtests.py -vv --full-trace" - -after_test: - - "%PYTHON%\\python setup.py bdist_msi" - -artifacts: - - path: '**\*.msi' - -deploy: - - provider: GitHub - release: $(appveyor_build_version) - auth_token: - secure: "09WRxoB8lu9lPzuk3qYu0brKVQubLpnsS1Wdn49nYZYi5RDiva3z37eITcjtZkTD" - artifact: /.*\.msi/ - draft: true - prerelease: true - on: - appveyor_repo_tag: true - -build: off diff --git a/changelog.md b/changelog.md index b190ce855..e18f81a6c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,280 @@ 0.x.x ===== +0.21.0 +===== +### Make it operational again +* Port the client to PyQt6 and python3.12 + + Qt stopped providing 32-bit packages several years ago, + therefore we have to move to 64-bit too. + + The previous port added QtWebEngine with the hope to eliminate + it in the future, and the future has come. This port removes + QtWebEngine and allows real web browsers to do their job. + But unfortunately this change hasn't slimmed down the client, + as previous maintainers had hoped, because java-ice-adapter + has become about **11 times larger**, not to mention jre and Qt itself + +* Fixes: + - Fix constant reconnect while in game + - Fix parsing Game.prefs file + - Fix sim mod downloads for coop games + - Fix updater not being able to download game files (#1133) + - Fix notifications being shown over taskbar + - Fix replay decompression + - Fix ice adapter usage + - Fix movies and sounds unpacking + - Fix maximizing/resizing window + - Fix updating MapGenerator +* Other: + - Connect to lobby and chat with websockets + - Move updater's work into its own thread and don't block UI + - Finally use the UpdaterProgressDialog and give the user more + information about what's going on + - Restore coop leaderboards + - Move from jsonschema to pydantic (also speeds up loading the replay tab) + - Log in with browser + - Add option to change application style + - Fetch coop missions from API and don't rely on lobby server + - Patch FA version by ourselves if the one in the downloaded file + is incorrect + +0.20.2 +===== +* Fix updater + +0.20.1 +===== +* Fixes + * Connect to chat faster + * Fix throwing an exception in chat when someone renames + * Fix map generator not being able to generate some maps + * Fix pre-release being recognised as stable release +* Features + * Add ability to set generator map size in 1.25km increments + * Display technical names of matchmaker queues +* Other + * Add support for game options in matchmaker queues + * Update ICE adapter to v3.1.2 + +0.20.0 +===== + * Features: + * Add advanced login parameters (#1096) + * Add full game title to the game tooltip + * Add leaderboards from API + * Add map generator support + * Add option to force switching to vault fallback location + * Add teammatchmaking, basic party system + * Allow to foe moderators + * Disable ICE adapter info window by default + * Exclude observers from game player count + * Separate 'ignore' from 'add fore' option + * Technical + * Login with OAuth tokens + * Use new server protocol + * Replace hardcoded irclib with external library + * Migrate to GitHub Actions + * Fixes: + * Fix broken map downloader + * Fix diplaying news + * Fix download cancellation in vaults + * Fix icon sizes in vaults + * Fix window flags (#867) + * Remove some deprecations + * Support zstandard compression in replay files + +Contributors: + - Askaholic + - Strogo + - Gatsik + +0.19.0 +===== + * Fix broken compatibility with server + * Fix basic searching in vaults + * Implement somewhat of a cache system like in the Java client + * Online replay vault: fix downloading replays + +Contributors: + - Gatsik + +0.18.9 +===== + * Online vault: display player name as "No data" instead of throwing an error + +Contributors: + - Strogo + +0.18.8 +===== + * News tab: disable automatic urls + * News tab: add filtering + +Contributors: + - Strogo + +0.18.7 +===== + * Fix units database + * Add Spooky database as an alternative + +Contributors: + - Brutus5000 + - Strogo + - Dragonite + +0.18.6 +===== + * Show "unofficial client" message via notifications and only once per day. + * Fetch featured mod info from API instead of lobby server + +Contributors: + - Askaholic + - Strogo + +0.18.5 +===== + * map vault fixes + * fix featured_mod_versions server problem + +Contributors: + - Strogo + +0.18.4 +===== + * Replay vault update for JSONAPI + +Contributors: + - Strogo + - muellni + +0.18.3 +===== + * ICE adapter debug window fixes + +Contributors: + - Geosearchef + - muellni + +0.18.2 +===== + * ICE adapter integration + * Don't disable autorelogin after sending credentials + * Fix 'connect'/'disconnect' naming + +Contributors: + - Wesmania + - muellni + +0.18.1 +===== + * Fix repeated opening of 'host game' widget filling mod selection with + duplicate entries + * Fix 'open config file' button opening the wrong directory on Windows + * Update ladder map pool forum link + +Contributors: + - Wesmania + - EvanGalea + +0.18.0 +===== + * Refactor updater code. Although tested to work, let's still pray that it + doesn't break everything! + * Pick the right SupCom directory even if the user picked some subdirectory of + it like 'bin' + + Contributors: + - Wesmania + +0.18.0-rc.5 +===== + * Prevent being able to foe or igore aeolus mods + * Fix game names being accidentally HTML-escaped + * Fix communication with nickserv sometimes breaking + + Contributors: + - Wesmania + +0.18.0-rc.4 +===== + * Fix wrong chat line colors in some rare cases + * Fix client crash at some points during live game when connection to + lobbyserver was lost + + Contributors: + - Wesmania + - muellni + +0.18.0-rc.3 +===== + * Fix an uncaught exception when handling unknown IRC chatter mode + * Log fatal errors like segmentation faults to crash.log + + Contributors: + - Wesmania + +0.18.0-rc.2 +===== + * Fix an issue with login dialog not starting + * Add a caveat about themes to the exception dialog + + Contributors: + - Wesmania + +0.17.4 +===== + * Integrate client with new Rackover's database + * Implement autojoining language channels + - Currently supports #german, #french and #russian. + - At first run, checks system UI language on Windows and LANG on unix. If no + matching channel is found, uses user's geoIP. + - Language channels to join configurable through settings. + * Log qt messages to FAF client log, silence some noisier messages. + * Re-add chat disconnect notifications. + * Temporarily hide Tutorials and Tournaments tabs. We'll re-add them once + they're in working state. + * Add icons to chat tabs to indicate activity in the channel. + * Restore private chat channels after reconnecting to chat. + * Fix a bunch of chat and mod tool bugs. + + Contributors: + - Wesmania + - Giebmasse + +0.17.3 +===== + * Fix all players being displayed as clannies if user is not in a clan (#1011) + * Fix player count in chat ignoring leaving players + * Restore copying text in chat area with Ctrl-C + * Restore joining game / replay when doubleclicking player status + * Fix several bugs in mod tools + * Add a "copy username" entry to chatter menu + * Allow to hide parts of chatter list interface: + - Options -> chat -> hide chatter controls the hiding of UI items. + + Contributors: + - Wesmania + +0.17.2 +===== + * Rewrite chat from the ground up. No changes in functionality, but should fix + plenty of chat mubs and make it easier to add new features. + * Ctrl-C now quits the client when ran from console (#1001, #1008) + * Add config file location entry to client menu. + + Contributors: + - Wesmania + +0.17.1 +===== + * Fix missing audio plugin causing sounds not to be played on Windows (#995) + + Contributors: + - Wesmania + 0.17.0 ===== * Fix the error message "failed to get uid" which has bad spacing @@ -9,6 +283,8 @@ * Send logs from every game to a separate log file (#875, #975) * Refactor downloading previews to fix issues with broken previews (#852) * User's own messages can no longer ping him (#906) + * Update "run from source" instructions for Linux (#980) + * Standarize client data model classes (#981) Contributors: - MathieuB8 diff --git a/conftest.py b/conftest.py index 78266d4c3..01227e12e 100644 --- a/conftest.py +++ b/conftest.py @@ -1,21 +1,29 @@ import os -import pytest import sys -if os.path.isdir("src"): - sys.path.insert(0, os.path.abspath("src")) -elif os.path.isdir("../src"): - sys.path.insert(0, os.path.abspath("../src")) +import pytest + +sys.path.insert( + 0, + os.path.abspath( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), "src", + ), + ), +) + +__all__ = ('application', 'signal_receiver') -import config @pytest.fixture(scope="module") def application(qapp, request): return qapp + @pytest.fixture(scope="function") def signal_receiver(application): - from PyQt5 import QtCore + from PyQt6 import QtCore + class SignalReceiver(QtCore.QObject): def __init__(self, parent=None): QtCore.QObject.__init__(self, parent) diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 2fa0acd85..000000000 --- a/install.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -$env:PYTHON = "C:\Python27" -$env:QTIMPL = "PyQt5" - -$BASE_PATH = "" - -$BASE_URL = "https://www.python.org/ftp/python/" -$GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -$LUA_PATH = "C:\Program Files (x86)\Lua\5.1" -$GET_PIP_PATH = "C:\get-pip.py" - -$webclient = (new-object net.webclient) -$python_home = "C:\Python27" - -$env:INCLUDE = $env:INCLUDE + ":" + $LUA_PATH + "\include" - -Write-Host "env | grep INCLUDE" -Write-Host $env:INCLUDE - - - -# Install choco -if (!(Get-Command "choco" -errorAction SilentlyContinue)) { - Write-Host "Installing chocolatey" - iex ($webclient.DownloadString('https://chocolatey.org/install.ps1')) -} -else { - Write-Host "chocolatey already installed" -} - -# Install Visual C++ Redistributable (We should probably ship this with the client) -# This is going to fail with error code 3010, which means 'screw you, reboot' -# being awesome, we ignore this silly advice. -try { - choco install -y vcredist2008 -x86 -} -catch {} - -if (!(Get-Command "python" -errorAction SilentlyContinue)) { - $webclient.DownloadFile("https://www.python.org/ftp/python/2.7.10/python-2.7.10.msi", "c:\python2.msi") - Start-Process -FilePath C:\python2.msi -ArgumentList "/passive" -Wait -Passthru - } - -choco install -y git -choco install -y xdelta3 - -"Reloading Path" -$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") - -if (-not(Test-Path "C:\vcc_py2-7.msi")) { - Write-Host "Downloading python2.7 compiler" - (new-object net.webclient).DownloadFile("http://download.microsoft.com/download/7/9/6/796EF2E4-801B-4FC4-AB28-B59FBF6D907B/VCForPython27.msi", "C:\vcc_py2-7.msi") -} -Write-Host "Running python2.7 compiler installer" -Start-Process -FilePath C:\vcc_py2-7.msi -ArgumentList "/passive" -Wait -Passthru - -Write-Host "Downloading stdint.h into include directory" -$webclient.DownloadFile("http://msinttypes.googlecode.com/svn/trunk/stdint.h", $python_home + "\include\stdint.h") - -# Install pip -$pip_path = $python_home + "\Scripts\pip.exe" -$python_path = $python_home + "\python.exe" -if (-not(Test-Path $pip_path)) { - Write-Host "Installing pip..." - $webclient = New-Object System.Net.WebClient - $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) - Write-Host "Executing:" $python_path $GET_PIP_PATH - Start-Process -FilePath "$python_path" -ArgumentList "$GET_PIP_PATH" -Wait -Passthru -} else { - Write-Host "pip already installed." -} - -function InstallPackage ($pkg) { - & $pip_path install $pkg -} -InstallPackage wheel -InstallPackage pytest -InstallPackage cx_Freeze -InstallPackage https://pypi.python.org/packages/cp27/p/pypiwin32/pypiwin32-219-cp27-none-win32.whl#md5=a8b0c1b608c1afeb18cd38d759ee5e29 - -if ($env:QTIMPL -eq "PyQt5"){ - Write-Host "Installing PyQt5" - $webclient.DownloadFile("https://downloads.sourceforge.net/project/pyqt/PyQt5/PyQt-5.6/PyQt5-5.6-gpl-Py3.5-Qt5.6.0-x32-2.exe", "C:\install-PyQt5.exe") - Start-Process -FilePath C:\install-PyQt5.exe -ArgumentList "/S" -Wait -Passthru -} - -& $pip_path install -r c:\vagrant\requirements.txt - -$env:Path = $python_home + ";" + $env:Path - -"Updating path" -[Environment]::SetEnvironmentVariable("Path", $env:Path, [System.EnvironmentVariableTarget]::Machine) -Write-Host "New path: " + $env:Path diff --git a/lib/qt.conf b/lib/qt.conf deleted file mode 100644 index d5ba6f1d1..000000000 --- a/lib/qt.conf +++ /dev/null @@ -1,3 +0,0 @@ -[Paths] -Prefix = . -Plugins = plugins diff --git a/lib/xdelta3.exe b/lib/xdelta3.exe deleted file mode 100755 index 2b4970de8..000000000 Binary files a/lib/xdelta3.exe and /dev/null differ diff --git a/readme.md b/readme.md index 0c4c802e6..acb96cfd1 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,7 @@ +Development status +================== +This is the legacy client. The offically supported version can be found [here](https://github.com/FAForever/downlords-faf-client). The development is more or less discontinued. + FA Forever Client ================= @@ -29,11 +33,11 @@ By contributing, you agree to license your work to the FAForever project in such ### Code-Style -[Downlord's FAF Client Contribution Guidelines](https://github.com/FAForever/downlords-faf-client/wiki/Contribution-guidelines#write-readable-code) -* [Quality has highest priority](https://github.com/FAForever/downlords-faf-client/wiki/Contribution-guidelines#choose-quality-over-quantity) -* [Write readable code](https://github.com/FAForever/downlords-faf-client/wiki/Contribution-guidelines#write-readable-code) -* [Use comments only when absolutely necessary to explain complex algorithms or inherently unintuitive reasons for how or why your code functions](https://github.com/FAForever/downlords-faf-client/wiki/Contribution-guidelines#avoid-javadoc-and-comments) -* Use the logger +[Downlord's FAF Client Contribution Guidelines](https://github.com/FAForever/java-guidelines/wiki/Contribution-Guidelines) +* [Quality has highest priority](https://github.com/FAForever/java-guidelines/wiki/Contribution-Guidelines#quality-has-highest-priority) +* [Write readable code](https://github.com/FAForever/java-guidelines/wiki/Contribution-Guidelines#write-readable-code) +* [Use comments only when absolutely necessary to explain complex algorithms or inherently unintuitive reasons for how or why your code functions](https://github.com/FAForever/java-guidelines/wiki/Contribution-Guidelines#avoid-javadoc-and-comments) +* [Use the logger](https://github.com/FAForever/java-guidelines/wiki/Contribution-Guidelines#logging) ### Issues, PRs, and commit formatting @@ -68,12 +72,14 @@ Create a python3(!) virtualenv for installing its dependencies: virtualenv ./faf-client-venv --system-site-packages ./faf-client-venv/bin/pip install -r ./faf-client/requirements.txt +**Note that many distributions have separate names for Python 2 and Python 3 virtualenv, such as "virtualenv" and "virtualenv3" - ensure you're using the Python 3 specific version on your distribution!** + Now download the `faf-uid` executable: - wget https://github.com/FAForever/uid/releases/download/v3.0.0/faf-uid -O ./faf-client/lib/faf-uid + wget https://github.com/FAForever/uid/releases/download/v4.0.4/faf-uid -O ./faf-client/lib/faf-uid chmod +x ./faf-client/lib/faf-uid -Note that the `faf-uid` smurf protection executable needs to run `xrandr`, `lspci`, `lsblk` and `uname` to gather unique system information. +Note that the `faf-uid` smurf protection executable needs to run `xrandr`, `lspci`, `lsblk` and `uname` to gather unique system information. Additionally the `lsblk` command must support the "--json" flag, which was added in util-linux-2.27. Run the client: diff --git a/requirements.txt b/requirements.txt index 60101f807..ae30a7404 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,16 @@ -cx_Freeze==5.0.2 +cx_Freeze +idna ipaddress +irc +jinja2 pathlib -pyqt5 +pydantic +pyqt6 +pyqt6-networkauth pytest pytest-cov pytest-mock pytest-qt +pywin32 semantic_version -idna -jsonschema +zstandard diff --git a/res/chat/channel.css b/res/chat/channel.css new file mode 100644 index 000000000..c6ffc1bcd --- /dev/null +++ b/res/chat/channel.css @@ -0,0 +1,120 @@ +/* Jinja template for chat line CSS. + * See src/chat/channel_view.py for supported tags. + * Unfortunately Qt does not support setting the width attribute through CSS. + */ + +/* CAVEAT: remember about CSS rules for overriding styles, otherwise you might + * end up with wrong colors! + * + * CSS specifity comes first. As a rule of thumb, rules with more classes + * override rules with less classes. If you have a general rule that should + * override specific rules, repeat the class a few times to give it more + * specifity. + * + * When specifity is equal, a later rule overrides eariler rules. + */ + +body { + font-family: 'Segoe UI'; + font-size: 9pt; + font-weight: 400; + font-style: normal; +} + +p { + margin-top: 0px; + margin-bottom: 0px; + margin-left: 0px; + margin-right: 0px; + -qt-block-indent: 0; + text-indent: 0px; +} + +span { + font-family: 'MS Shell Dlg 2'; + font-size: 8pt; +} + +.col_sender, .col_text, .col_time { + color: "{{ colors['default'] }}"; + padding-top: 3; /* align with avatar icon */ +} + +.col_text { + white-space: pre-wrap; +} + +.player .col_sender, .player .col_text, .player .col_time { + color: "{{ colors['player'] }}" +} + +.action .col_sender, .action .col_text { + font-style: italic; +} + + +{% if random_colors is not none %} + {% for name in random_colors %} + .randomcolor-{{loop.index0}} .col_sender, .randomcolor-{{loop.index0}} .col_text { + color: "{{name}}"; + } + {% endfor %} +{% endif %} + +.col_sender { + text-align: right; +} + +.col_time { + text-align: right; +} + +.clannie .col_sender, .clannie .col_text { + color: "{{ colors['clan'] }}"; +} + +.foe .col_sender, .foe .col_text { + color: "{{ colors['foe'] }}"; +} + +.friend .col_sender, .friend .col_text { + color: "{{ colors['friend'] }}"; +} + +.me .col_sender, .me .col_text { + color: "{{ colors['self'] }}"; +} + +.mod .col_sender, .mod .col_text { + color: "{{ colors['mod'] }}"; +} + +.info .col_sender, .info .col_text, .info .col_time { + color: "{{ colors['default'] }}"; +} + +.foe.mod .col_sender, .foe.mod .col_text { + color: "{{ colors['foe_mod'] }}"; +} + +.friend.mod .col_sender, .friend.mod .col_text, .clannie.mod .col_sender, .clannie.mod .col_text { + color: "{{ colors['friend_mod'] }}"; +} + +.me.mod .col_sender, .me.mod .col_text { + color: "{{ colors['self_mod'] }}"; +} + +/* Increase CSS specifity of 'mentions_me' tags to override selectors with 3 classes */ + +.mentions_me.mentions_me .col_sender, .mentions_me.mentions_me .col_text { + color: "{{ colors['you'] }}"; +} + +a { + color: cornflowerblue; +} + +a.game_link { + color "{{ colors['url'] }}" +} diff --git a/res/chat/channel.ui b/res/chat/channel.ui index e4e2bf5b9..90ff0b5d1 100644 --- a/res/chat/channel.ui +++ b/res/chat/channel.ui @@ -17,7 +17,16 @@ - + + 0 + + + 0 + + + 0 + + 0 @@ -36,8 +45,8 @@ - - 0 + + 4 0 @@ -45,7 +54,16 @@ 6 - + + 0 + + + 0 + + + 0 + + 0 @@ -86,6 +104,12 @@ + + + 0 + 0 + + Segoe UI @@ -105,21 +129,11 @@ QTextEdit::AutoNone - QTextEdit::FixedPixelWidth - - - 800 + QTextEdit::WidgetWidth true - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Segoe UI'; font-size:9pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8pt;"><br /></span></p></body></html> - Qt::TextBrowserInteraction @@ -152,9 +166,18 @@ p, li { white-space: pre-wrap; } + + true + + + + 1 + 0 + + - 300 + 16777215 16777215 @@ -162,15 +185,24 @@ p, li { white-space: pre-wrap; } 0 + + 0 + + + 0 + + + 0 + + + 0 + 0 4 - - 0 - @@ -194,9 +226,9 @@ p, li { white-space: pre-wrap; } - + - + 0 0 @@ -248,61 +280,9 @@ p, li { white-space: pre-wrap; } QAbstractItemView::ScrollPerItem - - false - - - Qt::NoPen - - + true - - false - - - false - - - 5 - - - false - - - true - - - 40 - - - false - - - 0 - - - false - - - false - - - true - - - 20 - - - false - - - 0 - - - - - @@ -317,6 +297,11 @@ p, li { white-space: pre-wrap; } QLineEdit
chat.chatlineedit
+ + ChatterListView + QListView +
chat.chatterlistview
+
chatEdit diff --git a/res/chat/chatline.qhtml b/res/chat/chatline.qhtml new file mode 100644 index 000000000..a484bd18a --- /dev/null +++ b/res/chat/chatline.qhtml @@ -0,0 +1 @@ +{avatar}{sender}{text}{time} diff --git a/res/chat/chatline_avatar.qhtml b/res/chat/chatline_avatar.qhtml new file mode 100644 index 000000000..fbf4104fb --- /dev/null +++ b/res/chat/chatline_avatar.qhtml @@ -0,0 +1 @@ + diff --git a/res/chat/chatter.ui b/res/chat/chatter.ui new file mode 100644 index 000000000..3f562a643 --- /dev/null +++ b/res/chat/chatter.ui @@ -0,0 +1,168 @@ + + + Form + + + + 0 + 0 + 150 + 24 + + + + + 150 + 24 + + + + Form + + + + 2 + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + 0 + 0 + + + + + 25 + 16 + + + + + 25 + 16 + + + + + + + + + 40 + 20 + + + + + 40 + 20 + + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 16 + 16 + + + + + 16 + 16 + + + + + + + + 0 + + + 2 + + + 2 + + + + + + + + + + + 0 + 0 + + + + + 20 + 20 + + + + + 20 + 20 + + + + + + + + + 0 + 0 + + + + + 20 + 20 + + + + + 20 + 20 + + + + + + + + + diff --git a/res/chat/formatters/a_style.qss b/res/chat/formatters/a_style.qss deleted file mode 100644 index fa34be2f5..000000000 --- a/res/chat/formatters/a_style.qss +++ /dev/null @@ -1 +0,0 @@ -color:cornflowerblue \ No newline at end of file diff --git a/res/chat/formatters/action.qthtml b/res/chat/formatters/action.qthtml deleted file mode 100644 index 44bf1cc01..000000000 --- a/res/chat/formatters/action.qthtml +++ /dev/null @@ -1 +0,0 @@ -{name} {text}{time} \ No newline at end of file diff --git a/res/chat/formatters/actionAvatar.qthtml b/res/chat/formatters/actionAvatar.qthtml deleted file mode 100644 index 4e476fa74..000000000 --- a/res/chat/formatters/actionAvatar.qthtml +++ /dev/null @@ -1,2 +0,0 @@ -{name}{text}{time} - diff --git a/res/chat/formatters/announcement.qthtml b/res/chat/formatters/announcement.qthtml deleted file mode 100644 index 5275efbf2..000000000 --- a/res/chat/formatters/announcement.qthtml +++ /dev/null @@ -1 +0,0 @@ -{text} \ No newline at end of file diff --git a/res/chat/formatters/message.qthtml b/res/chat/formatters/message.qthtml deleted file mode 100644 index 5c6bd4df7..000000000 --- a/res/chat/formatters/message.qthtml +++ /dev/null @@ -1 +0,0 @@ -{name}: {text}{time} \ No newline at end of file diff --git a/res/chat/formatters/messageAvatar.qthtml b/res/chat/formatters/messageAvatar.qthtml deleted file mode 100644 index 6e6412d09..000000000 --- a/res/chat/formatters/messageAvatar.qthtml +++ /dev/null @@ -1 +0,0 @@ -{name}: {text}{time} \ No newline at end of file diff --git a/res/chat/formatters/nicklist_columns.json b/res/chat/formatters/nicklist_columns.json deleted file mode 100644 index a9efeb7c6..000000000 --- a/res/chat/formatters/nicklist_columns.json +++ /dev/null @@ -1 +0,0 @@ -{"RANK" : 32, "AVATAR" : 46, "NAME" : "*", "STATUS" : 22, "MAP" : 26} \ No newline at end of file diff --git a/res/chat/formatters/operator_colors.json b/res/chat/formatters/operator_colors.json deleted file mode 100644 index 05f21c0bf..000000000 --- a/res/chat/formatters/operator_colors.json +++ /dev/null @@ -1 +0,0 @@ -{"~" : "#FFFFFF", "&" : "#FFFFFF", "@" : "#FFFFFF", "%" : "#FFFFFF", "+" : "#FFFFFF", "default": "grey"} diff --git a/res/chat/formatters/raw.qthtml b/res/chat/formatters/raw.qthtml deleted file mode 100644 index 663508eb2..000000000 --- a/res/chat/formatters/raw.qthtml +++ /dev/null @@ -1 +0,0 @@ -{name} {text} \ No newline at end of file diff --git a/res/chat/formatters/readme.txt b/res/chat/formatters/readme.txt deleted file mode 100644 index f8594aadf..000000000 --- a/res/chat/formatters/readme.txt +++ /dev/null @@ -1 +0,0 @@ -These formatters will break if your remove any of the {} elements from them. They will also break if a new version of the lobby adds more such paramters. diff --git a/res/chat/language_channel_config.ui b/res/chat/language_channel_config.ui new file mode 100644 index 000000000..91d30d2ac --- /dev/null +++ b/res/chat/language_channel_config.ui @@ -0,0 +1,77 @@ + + + Dialog + + + + 0 + 0 + 504 + 432 + + + + Select language channels to join + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::NoSelection + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + endDialogBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + endDialogBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/res/chat/rank/partyleader.png b/res/chat/rank/partyleader.png new file mode 100644 index 000000000..5a552230d Binary files /dev/null and b/res/chat/rank/partyleader.png differ diff --git a/res/chat/raw.qhtml b/res/chat/raw.qhtml new file mode 100644 index 000000000..b7e6600cf --- /dev/null +++ b/res/chat/raw.qhtml @@ -0,0 +1 @@ +{text} diff --git a/res/chat/tabicon/blink_active.png b/res/chat/tabicon/blink_active.png new file mode 100644 index 000000000..c15ddd772 Binary files /dev/null and b/res/chat/tabicon/blink_active.png differ diff --git a/res/chat/tabicon/blink_inactive.png b/res/chat/tabicon/blink_inactive.png new file mode 100644 index 000000000..b0b72503c Binary files /dev/null and b/res/chat/tabicon/blink_inactive.png differ diff --git a/res/chat/tabicon/idle.png b/res/chat/tabicon/idle.png new file mode 100644 index 000000000..b0b72503c Binary files /dev/null and b/res/chat/tabicon/idle.png differ diff --git a/res/chat/tabicon/new_message.png b/res/chat/tabicon/new_message.png new file mode 100644 index 000000000..7564af974 Binary files /dev/null and b/res/chat/tabicon/new_message.png differ diff --git a/res/client/arrowDown.png b/res/client/arrowDown.png new file mode 100644 index 000000000..33f583bc0 Binary files /dev/null and b/res/client/arrowDown.png differ diff --git a/res/client/arrowUp.png b/res/client/arrowUp.png new file mode 100644 index 000000000..425c4b51e Binary files /dev/null and b/res/client/arrowUp.png differ diff --git a/res/client/change_style.ui b/res/client/change_style.ui new file mode 100644 index 000000000..acdb8d50c --- /dev/null +++ b/res/client/change_style.ui @@ -0,0 +1,70 @@ + + + Dialog + + + + 0 + 0 + 400 + 300 + + + + Dialog + + + true + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Apply|QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/res/client/chboxChecked.png b/res/client/chboxChecked.png new file mode 100644 index 000000000..60162eb39 Binary files /dev/null and b/res/client/chboxChecked.png differ diff --git a/res/client/chboxCheked.png b/res/client/chboxCheked.png new file mode 100644 index 000000000..31a00af00 Binary files /dev/null and b/res/client/chboxCheked.png differ diff --git a/res/client/chboxUnchecked.png b/res/client/chboxUnchecked.png new file mode 100644 index 000000000..dab550508 Binary files /dev/null and b/res/client/chboxUnchecked.png differ diff --git a/res/client/chboxUncheked.png b/res/client/chboxUncheked.png new file mode 100644 index 000000000..f38266208 Binary files /dev/null and b/res/client/chboxUncheked.png differ diff --git a/res/client/client.css b/res/client/client.css index 6ee05238b..8bbc21fb4 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -79,6 +79,40 @@ QMenuBar::item height: 13px; } +QTabWidget#matchmakerQueues::pane +{ + background-color: none; + border: none; +} + +QTabWidget#matchmakerQueues::tab-bar +{ + min-width: 10000000; + alignment: center; +} + +QTabWidget#matchmakerQueues > QTabBar::tab +{ + min-width: 60px; + min-height: 25px; + background-color:#383838; + /* color: silver; */ + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; +} + +QTabWidget#matchmakerQueues > QTabBar::tab::hover +{ + color: silver; + background-color: #808080; +} + +QTabWidget#matchmakerQueues > QTabBar::tab::selected +{ + color: white; + background-color: #758fa0; +} + QTabWidget #lobbyTab,#gamesTab,#ladderTab,#tournamentTab,#coopTab,#replaysTab,#vaultsTab,#mapsTab,#modsTab { /* sets background color for every widget under a tab */ @@ -219,14 +253,103 @@ QSplitter::handle:hover background-color: #606060; } -QSpinBox#minRating, QLineEdit#mapName, QLineEdit#playerName, QComboBox#modList + QDateEdit + { + margin: 0; + background-color: #353535; + color: orange; + } + + QDateEdit:disabled + { + color: #8D8B85; + } + + QDateEdit::down-arrow + { + color: orange; + } + +QSpinBox#minRating, QLineEdit#mapName, #playerName, QSpinBox#quantity, #advQuantity, +QComboBox#modList, #value1, #value2, #value3, #value4, #value5, #value6, #leaderboardList +{ + background-color: #353535; + color: orange; +} + +QSpinBox#quantityBox, #pageBox { background-color: #353535; color: orange; + border: 1 solid; + border-color: #555450; +} + +QSpinBox#minRating, QSpinBox#quantity, #advQuantity +{ + border-style: solid; + border-color: #353535; +} + +QSpinBox#minRating::disabled +{ + color: #8D8B85; + background-color: #4b4b4b; +} + +QComboBox#filter1, #filter2, #filter3, #filter4, #filter5, #filter6, +#operator1, #operator2, #operator3, #operator4, #operator5, #operator6 +{ + background-color: #353535; + color: silver; +} + +QToolBox#replayToolBox::tab +{ + background-color: rgb(32, 32, 37); + color: silver; + border-radius: 3px; + padding-left: 145px; /* "alignment: center" doesn't work with toolboxes :| */ +} + +#advancedSearch, #mapPreview +{ + background-color: rgb(32, 32, 37); +} + +QTableView +{ + outline: none, +} + +QTableView::item:hover +{ + background-color: #606060; +} + +QTableView::item:selected +{ + background-color: #306030; + border:none; } /* Text controls */ -QTextEdit, QPlainTextEdit, QLineEdit, QListWidget, QListView, QTableWidget, QTreeWidget, QFrame#rankedFrame, QFrame#teamFaction, QFrame#teamSearch +QTextEdit, QPlainTextEdit, QLineEdit, QListWidget, QListView, QTableWidget, QTreeWidget, QFrame#rankedFrame, #teamFaction, #teamSearch, #partyInfo +{ + border-style:solid; + border-width:1px; + border-color:#353535; + color:silver; + padding:5px; + background-color:#202025; + alternate-background-color: #303035; + border-top-right-radius : 5px; + border-top-left-radius : 5px; + border-bottom-left-radius : 5px; + border-bottom-right-radius : 5px; +} + +QTableView { border-style:solid; border-width:1px; @@ -234,13 +357,68 @@ QTextEdit, QPlainTextEdit, QLineEdit, QListWidget, QListView, QTableWidget, QTre color:silver; padding:5px; background-color:#202025; + alternate-background-color: #303035; border-top-right-radius : 5px; border-top-left-radius : 5px; border-bottom-left-radius : 5px; border-bottom-right-radius : 5px; +} + +/* used in ConnectivityDialog */ +QTableCornerButton::section { + background-color:#333333; +} + +QHeaderView { + background-color:#202025; +} + +QHeaderView::section +{ + color: white; + background-color: #4b4b4b; + font: bold; + border-left: 1 solid; } +QHeaderView::down-arrow +{ + image: url('%THEMEPATH%/client/arrowDown.png'); +} + +QHeaderView::up-arrow +{ + image: url('%THEMEPATH%/client/arrowUp.png'); +} + +QHeaderView#VerticalHeader::section { + color:silver; + background-color: #333333; + border: none; + font: normal; + min-width: 50px; +} + +QHeaderView#VerticalHeader::section:checked +{ + color:white; + background-color: #306030; + border-bottom: 1px solid; + border-color: #202025; + width: auto; +} + +QHeaderView#VerticalHeader::section:hover +{ + color:silver; + font: bold; + background-color: #606060; + border-bottom: 1px solid; + border-color: #202025; + width: auto; +} + /* Nicklist controls */ QTableWidget::item @@ -279,18 +457,41 @@ QListWidget::item:hover, QListView::item:hover border-radius: 3px; } -QListWidget::item:selected, QListView::item:selected, QTreeWidget::item:previously-selected +QListView::item:selected, QTreeWidget::item:previously-selected { border: none; } -QListWidget#modList::item:selected, QListWidget#newsList::item:selected +QListWidget::item:selected +{ + color: orange; + background-color: #505050; + border: none; +} + +QListWidget#newsList::item:selected { background-color: #505050; color: #20A080; } +QListWidget#teamList +{ + outline: 0; +} + +QListWidget#teamList::item::selected +{ + color: white; + background-color: #353535; +} + +QListWidget#teamList::item::selected:!active +{ + color: silver; +} + /* for tournament*/ QListWidget#tourneyList { @@ -344,8 +545,13 @@ QScrollArea /* Scrollbars*/ -QScrollBar { +QScrollBar:horizontal { + background-color: grey; + height: 15px; + margin: 0 16px; +} +QScrollBar:vertical { background-color: grey; width: 15px; margin: 16px 0; @@ -365,8 +571,19 @@ QScrollBar::handle:hover { background-color: #d5d6d6; min-height: 24px; } - -QScrollBar::sub-line { +QScrollBar::sub-line:horizontal { + background: #2f2f2f; + width: 15px; + subcontrol-position: left; + subcontrol-origin: margin; +} +QScrollBar::add-line:horizontal { + background: #2f2f2f; + width: 15px; + subcontrol-position: right; + subcontrol-origin: margin; +} +QScrollBar::sub-line:vertical { border-top-left-radius: 3px; border-top-right-radius: 3px; background: #2f2f2f; @@ -374,8 +591,8 @@ QScrollBar::sub-line { subcontrol-position: top; subcontrol-origin: margin; } -QScrollBar::add-line { +QScrollBar::add-line:vertical { border-bottom-left-radius: 3px; border-bottom-right-radius: 3px; background: #2f2f2f; @@ -394,9 +611,19 @@ QScrollBar:down-arrow { background-image: url('%THEMEPATH%/client/scrollUp.png'); background-position: center center; background-repeat:no-repeat; +} - } +QScrollBar:right-arrow { + background-image: url('%THEMEPATH%/client/scrollRight.png'); + background-position: center center; + background-repeat:no-repeat; +} +QScrollBar:left-arrow { + background-image: url('%THEMEPATH%/client/scrollLeft.png'); + background-position: center center; + background-repeat:no-repeat; +} QScrollBar::add-page { @@ -414,7 +641,7 @@ QCheckBox#hideGamesWithPw background-color: #111111; } -QLabel#labelSortGames,#labelJoinGame,#labelHostGame,#labelAutomatch,#labelMyReplays,#labelLiveReplays,#labelHostTournament,#labelJoinTournament,#joinLabel,#labelTeamManagement +QLabel#labelSortGames,#labelJoinGame,#labelHostGame,#labelAutomatch,#labelMyReplays,#labelLiveReplays,#labelHostTournament,#labelJoinTournament,#joinLabel,#labelTeamManagement,#labelTMM { color:white; } @@ -430,6 +657,11 @@ QLabel#titleLabel font-weight:bold; } +QLabel#labelLoading,#labelAutomatchInfo +{ + color: gold; +} + QGroupBox { margin: 5px; @@ -437,6 +669,8 @@ QGroupBox border-radius: 5px; background-color: rgb(32, 32, 37); color: silver; + padding: 5px; + padding-top: 15px; } QLabel @@ -450,11 +684,50 @@ QDialog background-color: #575656; } -QCheckBox#spoilerCheckbox, #automaticCheckbox +QDialog#MapGenOptionsDialog +{ + background-color:#202025; + alternate-background-color: #303035; +} + +QCheckBox#spoilerCheckbox, #automaticCheckbox, #showLatestCheckbox, #hideUnrCheckbox, #landRandomDensity::enabled, #plateausRandomDensity::enabled, + #mountainsRandomDensity::enabled, #rampsRandomDensity::enabled, #mexRandomDensity::enabled, #reclaimRandomDensity::enabled, + #matchUsernameCheckbox { color: silver; } +QCheckBox#spoilerCheckbox::indicator:unchecked, #automaticCheckbox::indicator:unchecked, #showLatestCheckbox::indicator:unchecked, +#hideUnrCheckbox::indicator:unchecked, #matchUsernameCheckbox::indicator:unchecked, +#landRandomDensity::indicator::unchecked, #plateausRandomDensity::indicator::unchecked, #mountainsRandomDensity::indicator::unchecked, +#rampsRandomDensity::indicator::unchecked, #mexRandomDensity::indicator::unchecked ,#reclaimRandomDensity::indicator::unchecked +{ + image: url('%THEMEPATH%/client/chboxUncheked.png'); + } + +QCheckBox#spoilerCheckbox::indicator:checked, #automaticCheckbox::indicator:checked, #showLatestCheckbox::indicator:checked, +#hideUnrCheckbox::indicator:checked, #matchUsernameCheckbox::indicator:checked, +#landRandomDensity::indicator::checked, #plateausRandomDensity::indicator::checked, #mountainsRandomDensity::indicator::checked, +#rampsRandomDensity::indicator::checked, #mexRandomDensity::indicator::checked ,#reclaimRandomDensity::indicator::checked +{ + image: url('%THEMEPATH%/client/chboxCheked.png'); + } + + QCheckBox::enabled + { + color: silver; + } + + QCheckBox::indicator:unchecked + { + image: url('%THEMEPATH%/client/chboxUnchecked.png'); + } + + QCheckBox::indicator:checked + { + image: url('%THEMEPATH%/client/chboxChecked.png'); + } + /* Used for Ranked Buttons only at the moment*/ QToolButton#rankedPlay { @@ -463,6 +736,13 @@ QToolButton#rankedPlay border: 1px solid grey; } +QToolButton#rankedPlay::disabled +{ + color: grey; + border-radius: 5px; + border: 1px solid darkslategrey; +} + QProgressBar#searchProgress { background-color: silver; @@ -471,13 +751,19 @@ QProgressBar#searchProgress border: 1px solid grey; } -QToolButton#laddermapspool +QToolButton#mapsPool { color: silver; border-radius: 5px; border: 1px solid grey; } +QToolButton#showTMM +{ + color: silver; + border: 0.5px solid black; +} + QToolButton::pressed { border: none; @@ -506,9 +792,91 @@ QToolButton::checked padding:5px; background-color:#C0C0C0; border-radius : 5px; +} + +QPushButton +{ + color: silver; + background: #453939; +} + +QPushButton#refreshButton, #nextButton, #previousButton, #goToPageButton, #firstButton, #lastButton, +#resetButton, #searchPlayerButton, #UIButton, #uploadButton, #kickButton, #leaveButton +{ + color: silver; + background: #202025; + border: 1 solid #555450; + border-radius: 2; +} + +QPushButton#refreshButton::hover, #nextButton::hover, #previousButton::hover, #goToPageButton::hover, +#firstButton::hover, #lastButton::hover, #resetButton::hover, #searchPlayerButton::hover, +#UIButton::hover, #uploadButton::hover, #kickButton::hover, #leaveButton::hover +{ + background-color: #808080; +} + +QPushButton#searchButton, #advSearchButton, #fafDbButton, #spookyDbButton +{ + color: silver; + border-radius: 5px; + border: 2px solid rgb(70, 70, 70); + font: bold 12px; + min-height: 20px; + background-color: rgb(39, 39, 45); +} + +QPushButton#RefreshResetButton, #advResetButton +{ + color: silver; + border-radius: 5px; + border: 1px solid rgb(70, 70, 70); + font: 11px; + min-height: 20px; + background-color: rgb(39, 39, 45); +} + +QPushButton#generateMapButton +{ + color: silver; + border-radius: 2px; + border: 0.5px solid rgb(70, 70, 70); + font: bold 14px; + min-height: 30px; + background-color: rgb(39, 39, 45); +} +QPushButton#saveMapGenSettingsButton, #resetMapGenSettingsButton +{ + color: silver; + border: 0.5px solid rgb(70, 70, 70); + background-color: rgb(39, 39, 45); +} +QPushButton#saveMapGenSettingsButton::hover, #resetMapGenSettingsButton::hover,#generateMapButton::hover +{ + border: none; + + color:silver; + padding:5px; + background-color:#808080; + border-radius : 5px; } +QPushButton#newsSettings +{ + qproperty-icon: url('%THEMEPATH%/client/settings.png'); + background-color: rgb(39, 39, 45); + border: 1px solid rgb(70, 70, 70); + border-radius: 2px; +} + +QPushButton#showAllButton +{ + qproperty-icon: url('%THEMEPATH%/client/showAll.png'); + background-color: rgb(39, 39, 45); + border: 1px solid rgb(70, 70, 70); + border-radius: 2px; +} -QComboBox, QComboBox:selected +QComboBox, QComboBox:selected, QDoubleSpinBox#mapSize { color:orange; selection-color:orange; @@ -516,10 +884,28 @@ QComboBox, QComboBox:selected selection-background-color: #575656; } - +QDoubleSpinBox#mapSize +{ + border: 1 solid; + border-color: #555450; + selection-color: white; + selection-background-color: slateblue; +} QComboBox QAbstractItemView { border: 2px solid darkgray; selection-background-color: #575656; background-color: #575656; } + +QFrame#settingsFrame { + border-style:solid; + border-width:1px; + border-color:#353535; + background-color:#404040; + color:silver; + border-top-right-radius : 5px; + border-top-left-radius : 5px; + border-bottom-left-radius : 5px; + border-bottom-right-radius : 5px; +} diff --git a/res/client/client.ui b/res/client/client.ui index f16e7da14..477e67e8b 100644 --- a/res/client/client.ui +++ b/res/client/client.ui @@ -31,9 +31,6 @@ Qt::ToolButtonIconOnly - - QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks - false @@ -88,7 +85,7 @@ QTabWidget::Rounded - 0 + 7 @@ -226,32 +223,16 @@
+ + + 0 + 0 + + Units Database - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - about:blank - - - - - + @@ -338,6 +319,28 @@ + + + + + 0 + 0 + + + + + 75 + true + + + + + + + Qt::AlignCenter + + + @@ -346,7 +349,7 @@ 0 0 817 - 19 + 27 @@ -360,12 +363,14 @@ + - + + @@ -383,11 +388,23 @@ + &Chat... + + + &Hide chatter... + + + + + + + + @@ -396,8 +413,9 @@ - - + + + @@ -415,6 +433,7 @@ + @@ -423,11 +442,32 @@ + + + Keep game files in cache... + + + + + + + + + + + ICE Adapter... + + + + + + + @@ -457,7 +497,7 @@ - Tools + &Tools @@ -573,11 +613,6 @@ &Game Path... - - - Game &Port... - - true @@ -626,14 +661,6 @@ &Joins / Parts - - - true - - - &Map icons in chat - - true @@ -768,17 +795,181 @@ - Check player aliases + &Check player aliases + + + + + true + + + true + + + &Ignore foes + + + + + Show &client config file + + + + + true + + + &Nick + + + + + true + + + &Map + + + + + true + + + &Rank + + + + + true + + + &Avatar + + + + + true + + + &Country + + + + + true + + + &Status + + + + + Language channels + + + + + true + + + true + + + Do not keep + + + + + true + + + 15 days + + + + + true + + + 30 days + + + + + true + + + 60 days + + + + + true + + + Forever + + + + + true + + + Set your own time interval + + + + + true + + + Keep cache while in session + + + + + true + + + Automatic Generating of Maps + + + + + Map Generators + + + + + true + + + Vault fallback location + + + Sets "ProgramData/FAForever/user" instead of "User/Documents" folder as personal directory + + + + + true + + + Enable info window + + + + + false + + + true + + + Set window launch delay... - - - QWebEngineView - QWidget -
QtWebEngineWidgets/QWebEngineView
-
-
diff --git a/res/client/colors.json b/res/client/colors.json index 34592483c..09d9806ca 100644 --- a/res/client/colors.json +++ b/res/client/colors.json @@ -1 +1,18 @@ -{"you" : "red", "foe" : "crimson", "friend" : "lightskyblue", "friend_mod" : "medium orchid", "self_mod" : "medium orchid", "player" : "silver", "server" : "black", "default" : "grey", "self" : "orange", "url" : "cornflowerblue", "clan" : "limegreen"} +{ "you" : "red" + , "foe" : "crimson" + , "foe_mod" : "lightslategrey" + , "foe_chatterbox" : "darkred" + , "friend" : "lightskyblue" + , "friend_mod" : "medium orchid" + , "self_mod" : "medium orchid" + , "friend_chatterbox" : "purple" + , "player" : "silver" + , "server" : "black" + , "default" : "grey" + , "self" : "orange" + , "url" : "cornflowerblue" + , "clan" : "limegreen" + , "clan_chatterbox" : "darkgreen" + , "mod" : "white" + , "chatterbox" : "darkgoldenrod" +} diff --git a/res/client/crash.ui b/res/client/crash.ui index 316d914a9..88d958d6f 100644 --- a/res/client/crash.ui +++ b/res/client/crash.ui @@ -18,7 +18,7 @@ QLayout::SetDefaultConstraint - + 0 @@ -26,12 +26,10 @@ - An uncaught exception has occured in the client. You can continue if you want, but the client might become unstable. - -If you want, you can help us fix the issue - post the below snippet in the technical help forum linked below with the description of the problem. + An uncaught exception has occured in the client. You can continue if you want, but the client might become unstable.<br><br>If you want, you can help us fix the issue - post the below snippet in the technical help forum linked below with the description of the problem. - Qt::AutoText + Qt::RichText true diff --git a/res/client/kick.ui b/res/client/kick.ui index 855fea1b4..20e3453a2 100644 --- a/res/client/kick.ui +++ b/res/client/kick.ui @@ -7,7 +7,7 @@ 0 0 434 - 177 + 212 @@ -166,5 +166,38 @@ - + + + buttonBox + accepted() + Dialog + accept() + + + 216 + 188 + + + 216 + 105 + + + + + buttonBox + rejected() + Dialog + reject() + + + 216 + 188 + + + 216 + 105 + + + + diff --git a/res/client/login.ui b/res/client/login.ui index 5951617fa..f8bf6d617 100644 --- a/res/client/login.ui +++ b/res/client/login.ui @@ -6,8 +6,8 @@ 0 0 - 530 - 394 + 580 + 460 @@ -32,7 +32,16 @@ 0 - + + 0 + + + 0 + + + 0 + + 0 @@ -187,7 +196,7 @@ - + 0 0 @@ -200,29 +209,107 @@ 6 - - - Username - - - - - + 0 0 - - Qt::ImhHiddenText|Qt::ImhNoAutoUppercase|Qt::ImhNoPredictiveText|Qt::ImhSensitiveData - - - QLineEdit::Password + + Login parameters - - Password + + + + + + true + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Server port + + + + + + + + 0 + 0 + + + + Replay server port + + + + + + + API URL + + + + + + + + + + Replay server host + + + + + + + Server host + + + + + + + IRC server host + + + + + + + + 0 + 0 + + + + IRC server port + + + + @@ -331,7 +418,7 @@ - Login + Continue @@ -341,10 +428,16 @@ - loginField - passwordField loginButton - quitButton + extraOptionsToggle + serverHostField + serverPortField + replayServerHostField + replayServerPortField + ircServerHostField + ircServerPortField + apiURLField + environmentBox rememberCheckbox newAccountButton renameAccountButton @@ -352,6 +445,7 @@ forgotPasswordButton bugreportButton stayOfflineButton + quitButton @@ -371,6 +465,18 @@ + + extraOptionsToggle + released() + Dialog + on_toggle_extra_options() + + + environmentBox + currentIndexChanged() + Dialog + on_fill_extra_options() + steamLinkButton released() @@ -483,22 +589,6 @@ - - passwordField - selectionChanged() - passwordField - clear() - - - 345 - 110 - - - 345 - 110 - - - stayOfflineButton released() diff --git a/res/client/randomcolors.json b/res/client/randomcolors.json index 2bc1c5ffa..aa3f77545 100644 --- a/res/client/randomcolors.json +++ b/res/client/randomcolors.json @@ -1 +1 @@ -["IndianRed","LightCoral","Salmon","DarkSalmon","LightSalmon","Red","FireBrick","DarkRed","Pink","LightPink","HotPink","DeepPink","MediumVioletRed","PaleVioletRed","LightSalmon","Coral","Tomato","Gold","Yellow","LightYellow","LemonChiffon","LightGoldenrodYellow","PapayaWhip","Moccasin","PeachPuff","PaleGoldenrod","Khaki","DarkKhaki","Lavender","Thistle","Plum","Violet","Orchid","Fuchsia","Magenta","MediumOrchid","MediumPurple","Amethyst","BlueViolet","DarkViolet","DarkOrchid","DarkMagenta","Purple","SlateBlue","DarkSlateBlue","MediumSlateBlue","GreenYellow","Chartreuse","LawnGreen","Lime","LimeGreen","PaleGreen","LightGreen","MediumSpringGreen","SpringGreen","MediumSeaGreen","SeaGreen","ForestGreen","Green","DarkGreen","YellowGreen","OliveDrab","Olive","DarkOliveGreen","MediumAquamarine","DarkSeaGreen","LightSeaGreen","DarkCyan","Teal","Aqua","Cyan","LightCyan","PaleTurquoise","Aquamarine","Turquoise","MediumTurquoise","DarkTurquoise","CadetBlue","SteelBlue","LightSteelBlue","PowderBlue","SkyBlue","DeepSkyBlue","DodgerBlue","CornflowerBlue","MediumSlateBlue","RoyalBlue","Blue","MediumBlue"] \ No newline at end of file +["IndianRed","LightCoral","Salmon","DarkSalmon","LightSalmon","Red","FireBrick","DarkRed","Pink","LightPink","HotPink","DeepPink","MediumVioletRed","PaleVioletRed","LightSalmon","Coral","Tomato","Gold","Yellow","LightYellow","LemonChiffon","LightGoldenrodYellow","PapayaWhip","Moccasin","PeachPuff","PaleGoldenrod","Khaki","DarkKhaki","Lavender","Thistle","Plum","Violet","Orchid","Fuchsia","Magenta","MediumOrchid","MediumPurple","Amethyst","BlueViolet","DarkViolet","DarkOrchid","DarkMagenta","Purple","SlateBlue","DarkSlateBlue","MediumSlateBlue","GreenYellow","Chartreuse","LawnGreen","Lime","LimeGreen","PaleGreen","LightGreen","MediumSpringGreen","SpringGreen","MediumSeaGreen","SeaGreen","ForestGreen","Green","DarkGreen","YellowGreen","OliveDrab","Olive","DarkOliveGreen","MediumAquamarine","DarkSeaGreen","LightSeaGreen","DarkCyan","Teal","Aqua","Cyan","LightCyan","PaleTurquoise","Aquamarine","Turquoise","MediumTurquoise","DarkTurquoise","CadetBlue","SteelBlue","LightSteelBlue","PowderBlue","SkyBlue","DeepSkyBlue","DodgerBlue","CornflowerBlue","MediumSlateBlue","RoyalBlue","Blue","MediumBlue"] diff --git a/res/client/scrollLeft.png b/res/client/scrollLeft.png new file mode 100644 index 000000000..0f7e21b8a Binary files /dev/null and b/res/client/scrollLeft.png differ diff --git a/res/client/scrollRight.png b/res/client/scrollRight.png new file mode 100644 index 000000000..1f39a0b19 Binary files /dev/null and b/res/client/scrollRight.png differ diff --git a/res/client/settings.png b/res/client/settings.png new file mode 100644 index 000000000..617ddc2c0 Binary files /dev/null and b/res/client/settings.png differ diff --git a/res/client/showAll.png b/res/client/showAll.png new file mode 100644 index 000000000..68129b483 Binary files /dev/null and b/res/client/showAll.png differ diff --git a/res/client/unitdb.png b/res/client/unitdb.png new file mode 100644 index 000000000..c267eceb7 Binary files /dev/null and b/res/client/unitdb.png differ diff --git a/res/connectivity/connectivity.ui b/res/connectivity/connectivity.ui index 8c64997e8..1b4823d25 100644 --- a/res/connectivity/connectivity.ui +++ b/res/connectivity/connectivity.ui @@ -6,8 +6,8 @@ 0 0 - 477 - 326 + 870 + 575 @@ -19,8 +19,8 @@ Connectivity - - + + @@ -28,14 +28,14 @@ - Connectivity + ICE Adapter status - + - When the client starts, it runs a test to determine your capability of playing the game online with other players. Here you can re-run the test and perform additional stress testing of your connection. + The FAF client runs an executable in the background which manages P2P connections during the game: faf-ice-adapter.exe Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop @@ -45,72 +45,246 @@ - - - - - 14 - - - - Test Result - - + + + + + + General + + + + + + Version: + + + + + + + TextLabel + + + + + + + User: + + + + + + + TextLabel + + + + + + + RPC port: + + + + + + + TextLabel + + + + + + + GPGNET port: + + + + + + + TextLabel + + + + + + + LOBBY port: + + + + + + + TextLabel + + + + + + + Log path: + + + + + + + TextLabel + + + + + + + + + + Game state + + + + + + Connected: + + + + + + + GameState: + + + + + + + Mode: + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + Relay State + + + + + Qt::CustomContextMenu + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + + id + + + + + login + + + + + connected + + + + + local + + + + + remote + + + + + state + + + + + localOffer + + + + + timeToConnected + + + + + - - + + - Test relay + Open debug state window - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - + - - - - Unknown + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok diff --git a/res/coop/coop.ui b/res/coop/coop.ui index 74aeaa619..9476ee23e 100644 --- a/res/coop/coop.ui +++ b/res/coop/coop.ui @@ -32,7 +32,7 @@ 0 - + 0 @@ -41,7 +41,6 @@ Segoe UI 12 - 75 true @@ -81,7 +80,7 @@ p, li { white-space: pre-wrap; } - Qt::NoFocus + Qt::FocusPolicy::NoFocus true @@ -90,10 +89,10 @@ p, li { white-space: pre-wrap; } true - QAbstractItemView::NoDragDrop + QAbstractItemView::DragDropMode::NoDragDrop - QAbstractItemView::NoSelection + QAbstractItemView::SelectionMode::NoSelection @@ -102,7 +101,7 @@ p, li { white-space: pre-wrap; } - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel false @@ -128,12 +127,12 @@ p, li { white-space: pre-wrap; } false - - false - 300 + + false + true @@ -150,16 +149,16 @@ p, li { white-space: pre-wrap; } - QFrame::NoFrame + QFrame::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised 0 - + 0 @@ -175,7 +174,6 @@ p, li { white-space: pre-wrap; } Segoe UI 12 - 75 true @@ -183,17 +181,17 @@ p, li { white-space: pre-wrap; } Cooperative Games - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - Qt::NoFocus + Qt::FocusPolicy::NoFocus - QAbstractItemView::NoSelection + QAbstractItemView::SelectionMode::NoSelection @@ -202,25 +200,25 @@ p, li { white-space: pre-wrap; } - Qt::ElideMiddle + Qt::TextElideMode::ElideMiddle - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel - QListView::Static + QListView::Movement::Static - QListView::LeftToRight + QListView::Flow::LeftToRight - QListView::Adjust + QListView::ResizeMode::Adjust - QListView::IconMode + QListView::ViewMode::IconMode true @@ -233,10 +231,10 @@ p, li { white-space: pre-wrap; } - QFrame::StyledPanel + QFrame::Shape::StyledPanel - QFrame::Raised + QFrame::Shadow::Raised @@ -252,28 +250,27 @@ p, li { white-space: pre-wrap; } Segoe UI 12 - 75 true - Qt::LeftToRight + Qt::LayoutDirection::LeftToRight Leader Board - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - Qt::LeftToRight + Qt::LayoutDirection::LeftToRight - QTabWidget::North + QTabWidget::TabPosition::North 0 @@ -284,25 +281,13 @@ p, li { white-space: pre-wrap; } - - - - 525 - 0 - - - - - 525 - 16777215 - - - - false - - - false - + + + 50 + + + true + @@ -313,25 +298,13 @@ p, li { white-space: pre-wrap; } - - - - 525 - 0 - - - - - 525 - 16777215 - - - - false - - - false - + + + 50 + + + true + @@ -342,25 +315,13 @@ p, li { white-space: pre-wrap; } - - - - 525 - 0 - - - - - 525 - 16777215 - - - - false - - - false - + + + 50 + + + true + @@ -371,25 +332,13 @@ p, li { white-space: pre-wrap; } - - - - 525 - 0 - - - - - 525 - 16777215 - - - - false - - - false - + + + 50 + + + true + @@ -400,25 +349,13 @@ p, li { white-space: pre-wrap; } - - - - 525 - 0 - - - - - 525 - 16777215 - - - - false - - - false - + + + 50 + + + true + @@ -430,6 +367,13 @@ p, li { white-space: pre-wrap; } + + + CoopLeaderboardTableView + QTableView +
coop.cooptableview
+
+
diff --git a/res/coop/formatters/coop.qthtml b/res/coop/formatters/coop.qthtml index 6507bac61..0979d36c0 100644 --- a/res/coop/formatters/coop.qthtml +++ b/res/coop/formatters/coop.qthtml @@ -9,5 +9,3 @@ - - diff --git a/res/coop/formatters/style.css b/res/coop/formatters/style.css index e3e0eaf57..2384ef4e8 100644 --- a/res/coop/formatters/style.css +++ b/res/coop/formatters/style.css @@ -33,7 +33,7 @@ QTabWidget::pane /* Style the tab using the tab sub-control. Note that it reads QTabBar _not_ QTabWidget */ -QTabWidget > QTabBar::tab +QTabWidget > QTabBar::tab { background-color:#383838; color: silver; @@ -42,21 +42,21 @@ QTabWidget > QTabBar::tab } -QTabWidget > QTabBar::tab:!selected +QTabWidget > QTabBar::tab:!selected { background-color: #203040; } -QTabWidget > QTabBar::tab:hover +QTabWidget > QTabBar::tab:hover { color:white; background-color: #606060; } -QTabWidget > QTabBar::tab:selected +QTabWidget > QTabBar::tab:selected { color:white; background-color: #303030; @@ -106,4 +106,3 @@ body {background-color: #2f2f2f; overflow: hidden;} .pages .current_page {color: black;} .search_form {float: left;} - diff --git a/res/dialogs/avatar.ui b/res/dialogs/avatar.ui new file mode 100644 index 000000000..aebc1076d --- /dev/null +++ b/res/dialogs/avatar.ui @@ -0,0 +1,34 @@ + + + Dialog + + + + 0 + 0 + 400 + 300 + + + + Avatar manager + + + + + + true + + + QListView::Adjust + + + 5 + + + + + + + + diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui index ea13a733a..419d4f9a1 100644 --- a/res/fa/updater/updater.ui +++ b/res/fa/updater/updater.ui @@ -6,159 +6,293 @@ 0 0 - 258 - 305 + 360 + 364 - + 0 0 + + + 360 + 0 + + - Dialog + Updater - - - QLayout::SetMinimumSize - - - 0 - - - 0 - + - - - 0 - - - 2 - - - - - Updating Game Data - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - Abort - - - - - - - + 0 0 + + + 0 + 180 + + + + + 16777215 + 200 + + - QFrame::StyledPanel + QFrame::Shape::StyledPanel - QFrame::Raised - - - 0 + QFrame::Shadow::Raised - + 0 - + + 0 + + + 0 + + + 0 + + 0 - - - 100 + + + + 0 + 0 + - - 24 + + + Courier + - - Qt::AlignCenter - - + true - - Qt::Horizontal - - - false - - - Game - - - - - 24 - - - Qt::AlignCenter - - - Featured Mod - - + + + + + + + + 0 + 0 + + + + + 16777215 + 180 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + 0 + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 2 + + + + + + + Details + + + false + + + + - - - - 24 + + + + 2 - - Qt::AlignCenter + + + + 100 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + true + + + Qt::Orientation::Horizontal + + + false + + + Game (%v/%m) + + + + + + + Updating FAF... (1/1) + + + + + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Check hash (%v/%m) + + + + + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Update (%v/%m) + + + + + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Check Movies and Sounds (%v/%m) + + + + + + + + + 0 - - Map + + 0 - + + + + + 0 + 0 + + + + + 16777215 + 40 + + + + Updating Game Data + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Expanding + + + + 40 + 20 + + + + + + + + + 16777215 + 40 + + + + Abort + + + + - - - - Details + + + + Qt::Orientation::Vertical - - false + + + 20 + 40 + - - - - + @@ -166,54 +300,5 @@ - - - abortButton - clicked() - Dialog - reject() - - - 236 - 12 - - - 137 - 46 - - - - - detailsButton - clicked() - detailsButton - hide() - - - 128 - 100 - - - 128 - 100 - - - - - detailsButton - clicked() - logPlainTextEdit - show() - - - 128 - 100 - - - 128 - 196 - - - - + diff --git a/res/games/automatchframe.ui b/res/games/automatchframe.ui new file mode 100644 index 000000000..43566f798 --- /dev/null +++ b/res/games/automatchframe.ui @@ -0,0 +1,476 @@ + + + Form + + + + 0 + 0 + 278 + 174 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 25 + + + + Qt::NoFocus + + + + + + true + + + View maps pool + + + + + + + + 0 + 0 + + + + + 50 + 60 + + + + QFrame::StyledPanel + + + QFrame::Plain + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 4 + + + + Scanning for Opponents + + + 0 + + + 0 + + + 0 + + + Qt::AlignCenter + + + scanning + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 5 + + + + + + + + + 0 + 0 + + + + + 0 + 25 + + + + + 16777215 + 25 + + + + Qt::NoFocus + + + false + + + + + + + + + true + + + Play! + + + + + + + 5 + + + 0 + + + + + Matching In:- + + + Qt::AlignCenter + + + + + + + In Queue: - + + + Qt::AlignCenter + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 5 + 5 + + + + + + + + 0 + + + + + + 0 + 0 + + + + + 50 + 50 + + + + + 50 + 50 + + + + Qt::NoFocus + + + UEF Ranked Match + + + false + + + UEF + + + + 50 + 50 + + + + true + + + Qt::ToolButtonIconOnly + + + true + + + Qt::NoArrow + + + + + + + + 0 + 0 + + + + + 50 + 50 + + + + + 50 + 50 + + + + Qt::NoFocus + + + Aeon Ranked Match + + + false + + + Aeon + + + + 50 + 50 + + + + true + + + Qt::ToolButtonIconOnly + + + true + + + Qt::NoArrow + + + + + + + + 0 + 0 + + + + + 50 + 50 + + + + + 50 + 50 + + + + Qt::NoFocus + + + Cybran Ranked Match + + + false + + + Cybran + + + + 50 + 50 + + + + true + + + Qt::ToolButtonIconOnly + + + true + + + Qt::NoArrow + + + + + + + + 0 + 0 + + + + + 50 + 50 + + + + + 50 + 50 + + + + Qt::NoFocus + + + Seraphim Ranked Match + + + false + + + Seraphim + + + + 50 + 50 + + + + true + + + Qt::ToolButtonIconOnly + + + true + + + Qt::NoArrow + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 5 + 5 + + + + + + + + + 75 + true + + + + + + + Qt::AlignCenter + + + + + + + + + + + diff --git a/res/games/formatters/faf.qthtml b/res/games/formatters/faf.qthtml index 86abafb45..2a479cea5 100644 --- a/res/games/formatters/faf.qthtml +++ b/res/games/formatters/faf.qthtml @@ -15,5 +15,3 @@ - - diff --git a/res/games/gameitem.qthtml b/res/games/gameitem.qthtml new file mode 100644 index 000000000..5e45b210a --- /dev/null +++ b/res/games/gameitem.qthtml @@ -0,0 +1,130 @@ +{# Jinja template for the game tooltip #} +{%- macro playermacro(player, me, iconpath, teamalign) %} + {% if player.login is defined %} + {% set player_login = player.login %} + {% set player_country = player.country | lower %} + {% set player_country_path = iconpath~player_country~".png" %} + {% set player_global_rating = player.global_estimate %} + {% else %} + {% set player_login = player %} + {% set player_country_path = "" %} + {% set player_global_rating = "???" %} + {% endif %} + + {% if me.login is defined %} + {% set me_login = me.login %} + {% else %} + {% set me_login = me %} + {% endif %} + + {# This is needed to prevent a new line created on every "-" #} + {% if "-" in player_login %} + {% set player_login = player_login | replace("-", "‑") %} + {% endif %} + {% if "-" in me_login %} + {% set me_login = me_login | replace("-", "‑") %} + {% endif %} + + + {% if player.clan == me.clan and player.clan is not none and player.clan is defined and player != me %} + {% set player_login = ""~player_login~"" %} + {% elif player_login == me_login %} + {% set player_login = ""~player_login~"" %} + {% endif %} + + {% set width = 0 %} + + {% if teamalign == "left" -%} + + {{ player_login }} + + + + + + ({{ player_global_rating }}) + + {% else %} + + ({{ player_global_rating }}) + + + + + + {{ player_login }} + + {% endif %} + +{% endmacro -%} + + +{%- macro versus_string(fontsize) %} + + VS + +{% endmacro -%} + + +{%- macro modtip(mods) %} +
With:

+ {{"
".join(mods)}} +{% endmacro -%} + + +{%- macro observertooltip(observer) %} + {% if observer.login is defined %} + {% set observer_country = iconpath~observer.country~".png" %} + {% set observer_global_rating = observer.global_estimate %} + {% set observer_login = observer.login %} +
{{ observer_login }} ({{ observer_global_rating }}) + {% else %} +
{{ observer }} + {% endif %} +{% endmacro -%} + + +{%- macro TooltipFormatting(title, teams, mods, observers, me, iconpath) %} +
{{title}}
+ + + + {% for team in teams %} + {% if loop.last %} + + {% if observers %} + + + + {% endif %} + {% if mods %} + + + + {% endif %} +{% endmacro -%} + +{% if teams[0] is defined or observers is defined %} + {{ TooltipFormatting(title, teams, mods, observers, me, iconpath) }} +{% endif %} diff --git a/res/games/games.ui b/res/games/games.ui index 89fe6dcfa..fe9ae044e 100644 --- a/res/games/games.ui +++ b/res/games/games.ui @@ -20,167 +20,6 @@ Form - - - - QFrame::NoFrame - - - QFrame::Raised - - - 0 - - - - 6 - - - 0 - - - - - QFrame::NoFrame - - - QFrame::Raised - - - 0 - - - - 6 - - - 0 - - - - - - - - - - - - - Segoe UI - 12 - 75 - true - - - - Custom Games - - - 0 - - - 0 - - - - - - - - 0 - 0 - - - - - - - - - - - - Segoe UI - 8 - 75 - true - - - - Sort Games - - - - - - - - 0 - 0 - - - - Qt::NoFocus - - - - - - - Qt::NoFocus - - - Hide Private Games - - - - - - - - - - Qt::NoFocus - - - QAbstractItemView::NoSelection - - - - 100 - 100 - - - - Qt::ElideMiddle - - - QAbstractItemView::ScrollPerPixel - - - QAbstractItemView::ScrollPerPixel - - - QListView::Static - - - QListView::LeftToRight - - - QListView::Adjust - - - QListView::IconMode - - - true - - - - - - @@ -193,10 +32,16 @@ 0 - - 6 + + 0 + + + 0 - + + 0 + + 0 @@ -212,12 +57,11 @@ Segoe UI 12 - 75 true - 1 vs 1 Automatch + Automatch @@ -235,339 +79,174 @@ - + - + 0 0 - 0 - 25 + 50 + 100 - - Qt::NoFocus + + QTabWidget::TabPosition::South - - + + -1 - + + + 12 + 12 + + + + Qt::TextElideMode::ElideMiddle + + true - - View ladder maps pool + + true - - - - 0 - 0 - + + + Qt::Orientation::Vertical - + + QSizePolicy::Policy::Fixed + + - 50 - 60 + 20 + 5 + + + + - QFrame::StyledPanel + QFrame::Shape::StyledPanel - QFrame::Plain + QFrame::Shadow::Raised - - + + 0 - + 0 - - 6 + + 0 - - + + - 4 + 12 - - Scanning for Opponents - - - 0 - - - 0 - - - 0 - - - Qt::AlignCenter - - - scanning + + Your team: - - - + + + 0 - + - + 0 0 - - - 50 - 50 - - - 50 - 50 + 240 + 100 - - Qt::NoFocus + + + 11 + false + - - UEF Ranked Match + + QAbstractItemView::SelectionMode::SingleSelection - - false - - - UEF + + QAbstractItemView::SelectionBehavior::SelectRows - 50 - 50 + 25 + 25 - - true - - - Qt::ToolButtonIconOnly - - - true - - - Qt::NoArrow - - - - - - - - 0 - 0 - - - - - 50 - 50 - - - - - 50 - 50 - - - - Qt::NoFocus - - - Cybran Ranked Match - - - false - - - Cybran - - - - 50 - 50 - - - - true - - - Qt::ToolButtonIconOnly - - - true - - - Qt::NoArrow + + Qt::TextElideMode::ElideMiddle + + + + + + 0 + - - - - 0 - 0 - + + + Qt::Orientation::Horizontal - - - 50 - 50 - + + QSizePolicy::Policy::Preferred - + - 50 - 50 + 0 + 0 - - Qt::NoFocus - - - Aeon Ranked Match - - - false - - - Aeon - - - - 50 - 50 - - - - true - - - Qt::ToolButtonIconOnly - - - true - - - Qt::NoArrow - - + - + - + 0 0 - 50 - 50 + 75 + 25 - - - 50 - 50 - - - - Qt::NoFocus - - - Seraphim Ranked Match - - - false - - Seraphim - - - - 50 - 50 - - - - true - - - Qt::ToolButtonIconOnly - - - true - - - Qt::NoArrow + Leave Party - - - - - 0 - 0 - - - - - 0 - 25 - - - - Qt::NoFocus - - - false - - - - - - - - - true - - - Play! - - - @@ -577,7 +256,6 @@ Segoe UI 12 - 75 true @@ -605,13 +283,13 @@ - Qt::NoFocus + Qt::FocusPolicy::NoFocus - QAbstractItemView::InternalMove + QAbstractItemView::DragDropMode::InternalMove - QAbstractItemView::NoSelection + QAbstractItemView::SelectionMode::NoSelection @@ -620,25 +298,25 @@ - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel - QListView::Snap + QListView::Movement::Snap - QListView::TopToBottom + QListView::Flow::TopToBottom - QListView::Fixed + QListView::ResizeMode::Fixed - QListView::SinglePass + QListView::LayoutMode::SinglePass 0 - QListView::ListMode + QListView::ViewMode::ListMode true @@ -648,6 +326,171 @@ + + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + 0 + + + + 6 + + + 0 + + + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + 0 + + + + 6 + + + 0 + + + + + + 0 + 0 + + + + + + + + + + + + Segoe UI + 12 + true + + + + Custom Games + + + 0 + + + 0 + + + + + + + + 0 + 0 + + + + + + + + + + + + Segoe UI + 9 + true + + + + Sort Games + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::NoFocus + + + + + + + Qt::FocusPolicy::NoFocus + + + Hide Private Games + + + + + + + + + + Qt::FocusPolicy::NoFocus + + + QAbstractItemView::SelectionMode::NoSelection + + + + 100 + 100 + + + + Qt::TextElideMode::ElideMiddle + + + QAbstractItemView::ScrollMode::ScrollPerPixel + + + QAbstractItemView::ScrollMode::ScrollPerPixel + + + QListView::Movement::Static + + + QListView::Flow::LeftToRight + + + QListView::ResizeMode::Adjust + + + QListView::ViewMode::IconMode + + + true + + + + + + diff --git a/res/games/generated_map.png b/res/games/generated_map.png new file mode 100644 index 000000000..1d1b34a1b Binary files /dev/null and b/res/games/generated_map.png differ diff --git a/res/games/host.ui b/res/games/host.ui index 47af4e346..928cc3d6f 100644 --- a/res/games/host.ui +++ b/res/games/host.ui @@ -241,6 +241,19 @@ + + + + + 100 + 16777215 + + + + Map Generator + + + label titleEdit @@ -252,6 +265,7 @@ gamePreview hostButton modList + generateButton diff --git a/res/games/mapgen.ui b/res/games/mapgen.ui new file mode 100644 index 000000000..c4d04d292 --- /dev/null +++ b/res/games/mapgen.ui @@ -0,0 +1,1242 @@ + + + MapGenOptionsDialog + + + + 0 + 0 + 512 + 475 + + + + Map Generator Options + + + + + + + + + 0 + 0 + + + + + 140 + 25 + + + + + 16777215 + 25 + + + + + 9 + + + + Save settings and Close + + + + + + + + 0 + 0 + + + + + 100 + 25 + + + + + 16777215 + 25 + + + + + 9 + + + + Reset Settings + + + + + + + + 9 + + + + Qt::Horizontal + + + + 40 + 25 + + + + + + + + + 0 + 0 + + + + + 180 + 30 + + + + + 11 + 75 + true + + + + Generate map + + + + + + + + + + + + 10 + + + + Map Style + + + + + + + + 0 + 0 + + + + + 150 + 0 + + + + + 10 + + + + QComboBox::AdjustToContents + + + 2 + + + + RANDOM + + + + + DEFAULT + + + + + ONE_ISLAND + + + + + BIG_ISLANDS + + + + + SMALL_ISLANDS + + + + + CENTER_LAKE + + + + + VALLEY + + + + + DROP_PLATEAU + + + + + LITTLE_MOUNTAIN + + + + + MOUNTAIN_RANGE + + + + + LAND_BRIDGE + + + + + LOW_MEX + + + + + FLOODED + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 270 + 20 + + + + + + + + + + + + + + + 10 + + + + Gerenation Type + + + + + + + + 10 + + + + Casual + + + + Casual + + + + + Tournament + + + + + Blind + + + + + Unexplored + + + + + + + + + + + + + 10 + + + + Map Size (km) + + + + + + + + 0 + 0 + + + + + 10 + + + + false + + + 2.500000000000000 + + + 40.000000000000000 + + + 1.250000000000000 + + + + + + + + + + + + 10 + + + + Number of Spawns + + + + + + + + 10 + + + + 2 + + + + 2 + + + + + 4 + + + + + 6 + + + + + 8 + + + + + 10 + + + + + 12 + + + + + 14 + + + + + 16 + + + + + + + + + + + + + 9 + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 9 + + + + Qt::Vertical + + + + 451 + 20 + + + + + + + + + + + + + 10 + 75 + true + + + + Water + + + + + + + + 0 + 40 + + + + + 10 + + + + Random + + + + + + + + 9 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + + 10 + + + + Less + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 250 + 22 + + + + + 250 + 22 + + + + + 10 + + + + 0 + + + 127 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 10 + + + + More + + + + + + + + + + + + + + + 10 + 75 + true + + + + Plateaus + + + + + + + + 0 + 40 + + + + + 10 + + + + Random + + + + + + + + 9 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + + 10 + + + + Less + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 250 + 22 + + + + + 250 + 22 + + + + + 10 + + + + 0 + + + 127 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 10 + + + + More + + + + + + + + + + + + + + + 10 + 75 + true + + + + Mountains + + + + + + + + 0 + 40 + + + + + 10 + + + + Random + + + + + + + + 9 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + + 10 + + + + Less + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 250 + 22 + + + + + 250 + 22 + + + + + 10 + + + + 0 + + + 127 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 10 + + + + More + + + + + + + + + + + + + + + 10 + 75 + true + + + + Ramps + + + + + + + + 0 + 40 + + + + + 10 + + + + Random + + + + + + + + 9 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + + 10 + + + + Less + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 250 + 22 + + + + + 250 + 22 + + + + + 10 + + + + 0 + + + 127 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 10 + + + + More + + + + + + + + + + + + + + + 10 + 75 + true + + + + Mexes + + + + + + + + 0 + 40 + + + + + 10 + + + + Random + + + + + + + + 9 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + + 10 + + + + Less + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 250 + 22 + + + + + 250 + 22 + + + + + 10 + + + + 0 + + + 127 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 10 + + + + More + + + + + + + + + + + + + + + 10 + 75 + true + + + + Reclaim + + + + + + + + 0 + 40 + + + + + 10 + + + + Random + + + + + + + + 9 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + + 10 + + + + Less + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 250 + 22 + + + + + 250 + 22 + + + + + 10 + + + + 0 + + + 127 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 10 + + + + More + + + + + + + + + + + + + + + 9 + + + + Qt::Vertical + + + + 451 + 20 + + + + + + + + + diff --git a/res/modvault/modinfo.qthtml b/res/modvault/modinfo.qthtml deleted file mode 100644 index c1214fedc..000000000 --- a/res/modvault/modinfo.qthtml +++ /dev/null @@ -1,16 +0,0 @@ -{title} V{version} - -{modtype} -

-{description} -

-
+ {% for player in team %} + {{ playermacro(player, me, iconpath, teamalign="right") }} + {% endfor %} +
+ {% else %} + + {% for player in team %} + {{ playermacro(player, me, iconpath, teamalign="left") }} + {% endfor %} +
+ {{ versus_string(fontsize='+{}'.format(1 + (((teams[0])|length + (teams[1])|length)/2)|int)) }} + {% endif %} + {% endfor %} +
+ Observers: + {% for observer in observers %} + {{ observertooltip(observer) }} + {% endfor %} +
+ {{ modtip(mods) }} +
- - - - - - - -
Author: {author}
Downloads: {downloads}
Played: {played} times
{likes} Liked this
Uploaded {date}
- \ No newline at end of file diff --git a/res/modvault/modinfoui.qthtml b/res/modvault/modinfoui.qthtml deleted file mode 100644 index 21454f7d2..000000000 --- a/res/modvault/modinfoui.qthtml +++ /dev/null @@ -1,15 +0,0 @@ -{title} V{version} - -{modtype} -

-{description} -

- - - - - - - -
Author: {author}
Downloads: {downloads}
{likes} Liked this
Uploaded {date}
-
\ No newline at end of file diff --git a/res/modvault/modvault.ui b/res/modvault/modvault.ui deleted file mode 100644 index f9a7a8790..000000000 --- a/res/modvault/modvault.ui +++ /dev/null @@ -1,256 +0,0 @@ - - - Form - - - - 0 - 0 - 729 - 376 - - - - - 0 - 0 - - - - Form - - - - 0 - - - - - 5 - - - - - 5 - - - 5 - - - 10 - - - 5 - - - 5 - - - - - - 110 - 0 - - - - - 250 - 16777215 - - - - - - - - Server Search - - - - - - - - 50 - 16777215 - - - - Sort - - - - - - - - 110 - 0 - - - - - 16777215 - 16777215 - - - - - Alphabetic - - - - - Upload Date - - - - - Rating - - - - - Downloads - - - - - - - - Show - - - - - - - - 110 - 0 - - - - - 16777215 - 16777215 - - - - - All - - - - - UI Only - - - - - Sim only - - - - - Big Mods - - - - - Small Mods - - - - - Uploaded by You - - - - - Installed - - - - - - - - Qt::Horizontal - - - - 150 - 20 - - - - - - - - Manage UI Mods - - - - - - - Upload Mod - - - - - - - - - Qt::NoFocus - - - QAbstractItemView::NoSelection - - - - 100 - 100 - - - - Qt::ElideMiddle - - - QAbstractItemView::ScrollPerPixel - - - QAbstractItemView::ScrollPerPixel - - - QListView::TopToBottom - - - false - - - QListView::Adjust - - - QListView::ListMode - - - true - - - true - - - - - - - - - - diff --git a/res/news/news.ui b/res/news/news.ui index 642f47dde..05c95e5fe 100644 --- a/res/news/news.ui +++ b/res/news/news.ui @@ -30,48 +30,200 @@ - - - - 0 - 0 - - + 280 0 - - - - - - - 0 - 0 - + + QFrame::NoFrame - - - about:blank - + + QFrame::Raised + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 280 + 0 + + + + + + + + + 16777215 + 70 + + + + QFrame::Panel + + + QFrame::Plain + + + + + 10 + 30 + 251 + 25 + + + + + 25 + 0 + + + + + + + 10 + 10 + 200 + 13 + + + + + 200 + 0 + + + + Hide news which title contains: + + + + + + + + + 16777215 + 30 + + + + QFrame::NoFrame + + + QFrame::Raised + + + + 2 + + + 4 + + + 2 + + + 2 + + + + + NEWS HIDDEN: 0 + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + Show all + + + + + + + 24 + 24 + + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + 20 + 20 + + + + + + + + + + +
- - - QWebEngineView - QWidget -
QtWebEngineWidgets/QWebEngineView
-
-
diff --git a/res/news/news_page.html b/res/news/news_page.html new file mode 100644 index 000000000..19092e357 --- /dev/null +++ b/res/news/news_page.html @@ -0,0 +1,14 @@ + + + + + +

{title}

+
+
+ + {content} +
+ Open in your Web browser +
+ diff --git a/res/news/news_webview.css b/res/news/news_style.css similarity index 89% rename from res/news/news_webview.css rename to res/news/news_style.css index 098594891..62c44e455 100644 --- a/res/news/news_webview.css +++ b/res/news/news_style.css @@ -1,4 +1,6 @@ -img { display: block; max-width: 100%; height: auto !important; } +img { + float: left; +} body { font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 15px; diff --git a/res/news/news_webview_frame.html b/res/news/news_webview_frame.html deleted file mode 100644 index c91977642..000000000 --- a/res/news/news_webview_frame.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - -

{title}

-
-
-{content} -
- diff --git a/res/notification_system/dialog.ui b/res/notification_system/dialog.ui index c4f5fd3b0..e3137df19 100644 --- a/res/notification_system/dialog.ui +++ b/res/notification_system/dialog.ui @@ -15,7 +15,7 @@ QDialog { - + background-color: #272b30; } @@ -56,14 +56,23 @@ QPushButton:hover { background-color: rgba(0,0,0,0); width: 18px; height: 18px; - + } 0 - + + 0 + + + 0 + + + 0 + + 0 @@ -109,7 +118,7 @@ QPushButton:hover { QLabel { color: rgb(255, 255, 255); font-weight: bold; - font-family: Verdana; + font-family: Verdana; font-size:13pt; } @@ -198,6 +207,25 @@ QPushButton:hover { + + + + + 100 + 0 + + + + + 75 + true + + + + Accept + + + diff --git a/res/notification_system/party_invite.ui b/res/notification_system/party_invite.ui new file mode 100644 index 000000000..0ec34999f --- /dev/null +++ b/res/notification_system/party_invite.ui @@ -0,0 +1,124 @@ + + + Dialog + + + + 0 + 0 + 321 + 136 + + + + More - Party Invite + + + + + + QFrame::Panel + + + QFrame::Raised + + + 2 + + + + + + Inform you, each time a player invites you to their party + + + + + + + Show invites from all players + + + false + + + + + + + Show invites only from friends + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Save + + + + + + + Discard + + + + + + + + + + + pushButton + clicked() + Dialog + hide() + + + 256 + 115 + + + 521 + 123 + + + + + diff --git a/res/replays/replays.ui b/res/replays/replays.ui index 9ad97c457..8270bfada 100644 --- a/res/replays/replays.ui +++ b/res/replays/replays.ui @@ -6,8 +6,8 @@ 0 0 - 1057 - 686 + 1171 + 638 @@ -195,116 +195,6 @@ 0 - - - - - 0 - 1 - - - - - 400 - 200 - - - - - - - Qt::Vertical - - - - 0 - 5 - - - - - - - - Qt::Vertical - - - - 0 - 10 - - - - - - - - Qt::Horizontal - - - - 10 - 0 - - - - - - - - Qt::Horizontal - - - - 10 - 0 - - - - - - - - - 0 - 0 - - - - - 380 - 180 - - - - - 0 - 0 - - - - - 0 - 0 - - - - QFrame::Plain - - - Qt::ScrollBarAlwaysOff - - - Qt::TextSelectableByMouse - - - true - - - - - - @@ -316,7 +206,7 @@ 400 - 200 + 230 @@ -326,27 +216,24 @@ - - + + - Qt::Horizontal + Qt::Vertical - 10 - 0 + 0 + 5 - - + + Qt::Horizontal - - QSizePolicy::Expanding - 10 @@ -355,7 +242,7 @@ - + Qt::Vertical @@ -368,20 +255,23 @@ - - + + - Qt::Vertical + Qt::Horizontal + + + QSizePolicy::Expanding - 0 - 5 + 10 + 0 - + @@ -392,72 +282,25 @@ 380 - 180 + 230 380 - 180 + 300 - Search Options + Standard Search - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + Qt::AlignHCenter|Qt::AlignTop true - - - QFormLayout::AllNonFixedFieldsGrow - - - QFormLayout::DontWrapRows - - - 25 - - - 5 - - - 10 - - - 10 - - - 10 - - - 10 - - - - - 10 - - - - - false - - - - - - Player : - - - - - - - - + @@ -481,57 +324,38 @@ - - - - 10 + + + + + 0 + 0 + - - - - false - - - - - - Mod : - - - - - - - - 0 - 0 - - - - - 100 - 0 - - - - 50 - - - - All - - - - - + + + 80 + 20 + + + + Search for the exact name of the player + + + Match Whole Name + + + true + + - - + + 10 - + false @@ -539,12 +363,12 @@ - Min rating : + Quantity: - + 50 @@ -555,29 +379,44 @@ - -1400 + 1 - 3000 + 300 - 100 + 50 - -1400 + 100 - - - - Search + + + + + 0 + 0 + + + + + 80 + 20 + + + + Spoiler Free + + + true - + @@ -586,12 +425,12 @@ - Refresh Recent List + Reset Search to Recent - - + + 0 @@ -605,18 +444,103 @@ - reloads recent Replays on Tab reentry<br>'...same as it ever was ... same as it ever was' + - Automatic Refresh + Show latest - false + true - - + + + + 10 + + + + + false + + + + 0 + 0 + 0 + 2019 + 1 + 1 + + + + + 2050 + 12 + 31 + + + + + 2010 + 1 + 1 + + + + true + + + + + + + false + + + + 23 + 59 + 59 + 2019 + 1 + 1 + + + + + + + + 2050 + 12 + 31 + + + + + 2010 + 1 + 1 + + + + true + + + + + + + + + true + 0 @@ -629,15 +553,200 @@ 20 + + + - Spoiler Free + Hide unranked - true + false + + + + 10 + + + + + false + + + + + + Min rating : + + + + + + + + 50 + 0 + + + + + + + 0 + + + 3000 + + + 100 + + + 0 + + + + + + + + + 10 + + + + + false + + + + + + Player : + + + + + + + + + + + + Search + + + + + + + 10 + + + + + false + + + + + + Mod : + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + 50 + + + + All + + + + + + + + + + 0 + 0 + + + + + 80 + 20 + + + + reloads recent Replays on Tab reentry<br>'...same as it ever was ... same as it ever was' + + + Automatic Refresh + + + false + + + + + + + + + Leaderboard: + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + All + + + 0 + + + + All + + + + + + + @@ -648,14 +757,14 @@ - groupBox horizontalSpacer verticalSpacer verticalSpacer_2 horizontalSpacer_2 + groupBox - + @@ -682,7 +791,7 @@ - true + false 2 @@ -690,10 +799,10 @@ false - + 200 - + 200 @@ -708,9 +817,644 @@ + + + + + 0 + 1 + + + + + 400 + 200 + + + + + + + Qt::Horizontal + + + + 10 + 0 + + + + + + + + Qt::Horizontal + + + + 10 + 0 + + + + + + + + + 0 + 0 + + + + + 380 + 180 + + + + + 0 + 0 + + + + + 0 + 0 + + + + QFrame::Plain + + + Qt::ScrollBarAlwaysOff + + + Qt::TextSelectableByMouse + + + true + + + + + + + Qt::Vertical + + + + 0 + 5 + + + + + + + + Qt::Vertical + + + + 0 + 10 + + + + + + + + + + + + 0 + 350 + + + + + + + + 400 + 16777215 + + + + 0 + + + + + 0 + 0 + 400 + 251 + + + + Advanced Search + + + + + 0 + 220 + 391 + 25 + + + + + 80 + + + 0 + + + + + + 100 + 16777215 + + + + Qt::LeftToRight + + + Reset all + + + + + + + + 150 + 16777215 + + + + Search + + + + + + + + + 0 + 0 + 390 + 180 + + + + + 390 + 0 + + + + + + + + + + 130 + 0 + + + + + 130 + 16777215 + + + + false + + + 20 + + + + + + + + 80 + 16777215 + + + + + + + + true + + + + + + + + + + + + 130 + 0 + + + + + 130 + 16777215 + + + + 20 + + + + + + + + 80 + 16777215 + + + + + + + + true + + + + + + + + + + + + 130 + 0 + + + + + 130 + 16777215 + + + + 20 + + + + + + + + 80 + 16777215 + + + + + + + + true + + + + + + + + + + + + 130 + 0 + + + + + 130 + 16777215 + + + + 20 + + + + + + + + 80 + 16777215 + + + + + + + + true + + + + + + + + + + + + 130 + 0 + + + + + 130 + 16777215 + + + + 20 + + + + + + + + 80 + 16777215 + + + + + + + + true + + + + + + + + + + + + 130 + 0 + + + + + 130 + 16777215 + + + + 20 + + + + + + + + 80 + 16777215 + + + + + + + + true + + + + + + + + + + + 220 + 190 + 151 + 22 + + + + + 10 + + + + + false + + + + + + Quantity: + + + + + + + + 50 + 0 + + + + + + + 1 + + + 300 + + + 50 + + + 100 + + + + + + + + + 20 + 190 + 128 + 19 + + + + + + + + + + + 0 + 0 + 400 + 251 + + + + Map Preview + + + + + 0 + 0 + 391 + 256 + + + + + 0 + 256 + + + + + 391 + 16777215 + + + + + + 67 + 0 + 256 + 256 + + + + + 256 + 256 + + + + + 256 + 256 + + + + + + + true + + + + + + + + 0 + 0 + 400 + 251 + + + + Hide all + + + + + + + + + playerName + mapName + leaderboardList + modList + minRating + hideUnrCheckbox + showLatestCheckbox + dateStart + dateEnd + quantity + RefreshResetButton + searchButton + filter1 + operator1 + value1 + filter2 + operator2 + value2 + filter3 + operator3 + value3 + filter4 + operator4 + value4 + filter5 + operator5 + value5 + filter6 + operator6 + value6 + advQuantity + advResetButton + advSearchButton + replayInfos + onlineTree + diff --git a/res/stats/formatters/ladder_header.qthtml b/res/stats/formatters/ladder_header.qthtml index 629bebcb3..d1ae729a7 100644 --- a/res/stats/formatters/ladder_header.qthtml +++ b/res/stats/formatters/ladder_header.qthtml @@ -3,4 +3,3 @@ {name} {score} - diff --git a/res/stats/formatters/style.css b/res/stats/formatters/style.css index da0cd6457..27f4ae42d 100644 --- a/res/stats/formatters/style.css +++ b/res/stats/formatters/style.css @@ -14,7 +14,7 @@ QTabWidget::pane } /* Style the tab using the tab sub-control. Note that it reads QTabBar _not_ QTabWidget */ -QTabWidget > QTabBar::tab +QTabWidget > QTabBar::tab { background-color:#383838; color: silver; @@ -27,7 +27,7 @@ QTabWidget > QTabBar::tab:!selected background-color: #203040; } -QTabWidget > QTabBar::tab:hover +QTabWidget > QTabBar::tab:hover { color:white; background-color: #606060; diff --git a/res/stats/leaderboard.ui b/res/stats/leaderboard.ui new file mode 100644 index 000000000..10949cc38 --- /dev/null +++ b/res/stats/leaderboard.ui @@ -0,0 +1,792 @@ + + + Form + + + + 0 + 0 + 864 + 632 + + + + Form + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Show Columns: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 50 + 0 + + + + All + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Name + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Rating + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Mean + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Deviation + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Games + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Won + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Win rate + + + + + + + + 0 + 0 + + + + + 105 + 16777215 + + + + Updated + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 120 + 0 + + + + + 500 + 16777215 + + + + Find player (without absolute rank) + + + + + + + + 75 + 23 + + + + Search + + + + + + + + + + + + + + 120 + 0 + + + + Find player in page + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 50 + 0 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Updated within the last month + + + + + + + + 0 + 0 + 0 + 2020 + 7 + 2 + + + + + 0 + 0 + 0 + 2050 + 12 + 31 + + + + + 0 + 0 + 0 + 2020 + 7 + 2 + + + + true + + + + 2020 + 7 + 2 + + + + + + + + + 23 + 59 + 59 + 2020 + 7 + 2 + + + + + + + + 23 + 59 + 59 + 2050 + 12 + 31 + + + + + 23 + 59 + 59 + 2020 + 7 + 2 + + + + true + + + + 2020 + 7 + 2 + + + + + + + + + 0 + 25 + + + + + 8 + + + + Entries per page + + + + + + + + 75 + 20 + + + + Qt::AlignCenter + + + QAbstractSpinBox::UpDownArrows + + + 1 + + + 10000 + + + 100 + + + 1000 + + + + + + + + 0 + 0 + + + + + 75 + 23 + + + + Refresh + + + + + + + + + + + 6 + + + QLayout::SetDefaultConstraint + + + 5 + + + 5 + + + 5 + + + 0 + + + + + Qt::Horizontal + + + + 200 + 20 + + + + + + + + + 0 + 0 + + + + + 25 + 25 + + + + Page + + + + + + + + 50 + 0 + + + + + 100 + 16777215 + + + + Qt::AlignCenter + + + QAbstractSpinBox::NoButtons + + + 1 + + + 99999 + + + + + + + of + + + + + + + + 0 + 0 + + + + + 25 + 25 + + + + + 50 + 16777215 + + + + + + + + + + + + 0 + 0 + + + + + 65 + 25 + + + + + 100 + 16777215 + + + + Go to page + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + + 30 + 23 + + + + + 30 + 16777215 + + + + << + + + + + + + + 0 + 0 + + + + + 0 + 25 + + + + + 100 + 16777215 + + + + Previous + + + + + + + true + + + + 0 + 0 + + + + + 0 + 25 + + + + + 100 + 16777215 + + + + Next + + + + + + + + 30 + 23 + + + + + 30 + 16777215 + + + + >> + + + + + + + Qt::Horizontal + + + + 150 + 20 + + + + + + + + + 10 + + + + CAVEAT: Some data in the database may be incorrect + + + + + + + + + true + + + + + + + + + + + LeaderboardTableView + QTableView +
stats.itemviews.leaderboardtableview
+
+ + LeaderboardLineEdit + QLineEdit +
stats.leaderboardlineedit
+
+
+ + +
diff --git a/res/stats/stats.ui b/res/stats/stats.ui index 107a05e18..5aea04150 100644 --- a/res/stats/stats.ui +++ b/res/stats/stats.ui @@ -16,12 +16,47 @@ 0 + + + Leaderboards + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + -1 + + + + + Ladder - + + 0 + + + 0 + + + 0 + + 0 @@ -34,7 +69,16 @@ Scouts - + + 0 + + + 0 + + + 0 + + 0 @@ -50,7 +94,16 @@ All - + + 0 + + + 0 + + + 0 + + 0 @@ -60,7 +113,16 @@ Divisions - + + 0 + + + 0 + + + 0 + + 0 @@ -74,7 +136,16 @@ Tech 1 - + + 0 + + + 0 + + + 0 + + 0 @@ -90,7 +161,16 @@ All - + + 0 + + + 0 + + + 0 + + 0 @@ -100,7 +180,16 @@ Divisions - + + 0 + + + 0 + + + 0 + + 0 @@ -114,7 +203,16 @@ Tech 2 - + + 0 + + + 0 + + + 0 + + 0 @@ -130,7 +228,16 @@ All - + + 0 + + + 0 + + + 0 + + 0 @@ -140,7 +247,16 @@ Divisions - + + 0 + + + 0 + + + 0 + + 0 @@ -154,7 +270,16 @@ Tech 3 - + + 0 + + + 0 + + + 0 + + 0 @@ -170,7 +295,16 @@ All - + + 0 + + + 0 + + + 0 + + 0 @@ -180,7 +314,16 @@ Divisions - + + 0 + + + 0 + + + 0 + + 0 @@ -194,7 +337,16 @@ XPs - + + 0 + + + 0 + + + 0 + + 0 @@ -210,7 +362,16 @@ All - + + 0 + + + 0 + + + 0 + + 0 @@ -220,7 +381,16 @@ Divisions - + + 0 + + + 0 + + + 0 + + 0 @@ -234,7 +404,16 @@ Ladder Ratings - + + 0 + + + 0 + + + 0 + + 0 diff --git a/res/tournaments/formatters/open.qthtml b/res/tournaments/formatters/open.qthtml index 529aeea12..27420cddc 100644 --- a/res/tournaments/formatters/open.qthtml +++ b/res/tournaments/formatters/open.qthtml @@ -15,5 +15,3 @@ - - diff --git a/res/tournaments/formatters/style.css b/res/tournaments/formatters/style.css index 22d144b76..50504d6fe 100644 --- a/res/tournaments/formatters/style.css +++ b/res/tournaments/formatters/style.css @@ -13,4 +13,3 @@ QListWidget::item::hover { background-color: #555555; } - diff --git a/res/tutorials/formatters/tutorials.qthtml b/res/tutorials/formatters/tutorials.qthtml index b59a20ae2..92c6fb984 100644 --- a/res/tutorials/formatters/tutorials.qthtml +++ b/res/tutorials/formatters/tutorials.qthtml @@ -11,5 +11,3 @@ - - diff --git a/res/unitdb/unitdb.ui b/res/unitdb/unitdb.ui new file mode 100644 index 000000000..03c2f5156 --- /dev/null +++ b/res/unitdb/unitdb.ui @@ -0,0 +1,98 @@ + + + rootView + + + + 0 + 0 + 812 + 551 + + + + + 0 + 0 + + + + Form + + + + 4 + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 100 + + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 20 + + + + FAF + + + + + + + + 0 + 0 + + + + + 0 + 20 + + + + SPOOKY + + + + + + + + + + + diff --git a/res/vault/map_icons/army.png b/res/vaults/map_icons/army.png similarity index 100% rename from res/vault/map_icons/army.png rename to res/vaults/map_icons/army.png diff --git a/res/vault/map_icons/hydro.png b/res/vaults/map_icons/hydro.png similarity index 100% rename from res/vault/map_icons/hydro.png rename to res/vaults/map_icons/hydro.png diff --git a/res/vault/map_icons/mass.png b/res/vaults/map_icons/mass.png similarity index 100% rename from res/vault/map_icons/mass.png rename to res/vaults/map_icons/mass.png diff --git a/res/vaults/mapvault/map.ui b/res/vaults/mapvault/map.ui new file mode 100644 index 000000000..0e2790806 --- /dev/null +++ b/res/vaults/mapvault/map.ui @@ -0,0 +1,245 @@ + + + ModDialog + + + + 0 + 0 + 536 + 307 + + + + Dialog + + + + + + 4 + + + 0 + + + + + 0 + + + 0 + + + + + + 256 + 256 + + + + + 256 + 256 + + + + + + + true + + + Qt::AlignCenter + + + + + + + 1 + + + + + + 5000 + 25 + + + + + Segoe UI + 14 + 75 + true + + + + Title + + + Qt::AlignCenter + + + + + + + + 250 + 30 + + + + + 16777215 + 30 + + + + + Segoe UI + 11 + true + + + + Size + + + 10 + + + + + + + + 0 + 30 + + + + + 16777215 + 30 + + + + + Segoe UI + 11 + true + + + + Maximum players + + + 10 + + + + + + + true + + + + 0 + 60 + + + + + 16777215 + 60 + + + + + Segoe UI + 10 + true + + + + Extra info + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + 10 + + + + + + + + 16777215 + 500 + + + + + Segoe UI + 10 + true + + + + Description + + + true + + + 10 + + + + + + + + + + + 0 + + + + + + 100 + 0 + + + + + 150 + 16777215 + + + + Qt::NoFocus + + + Download Map + + + + + + + + + + + + diff --git a/res/vaults/mapvault/mapinfo.qthtml b/res/vaults/mapvault/mapinfo.qthtml new file mode 100644 index 000000000..ef4444343 --- /dev/null +++ b/res/vaults/mapvault/mapinfo.qthtml @@ -0,0 +1,14 @@ +{title} V{version} + +{modtype} +

+{description} +

+ + + + + + +
Size: {width} x {height} km
Rating: {rating} ({reviews})
Uploaded: {date}
+
diff --git a/res/modvault/comment.qthtml b/res/vaults/modvault/comment.qthtml similarity index 97% rename from res/modvault/comment.qthtml rename to res/vaults/modvault/comment.qthtml index 9275af5cf..a4fba559f 100644 --- a/res/modvault/comment.qthtml +++ b/res/vaults/modvault/comment.qthtml @@ -3,4 +3,4 @@

{text} -

\ No newline at end of file +

diff --git a/res/modvault/mod.ui b/res/vaults/modvault/mod.ui similarity index 100% rename from res/modvault/mod.ui rename to res/vaults/modvault/mod.ui diff --git a/res/vaults/modvault/modinfo.qthtml b/res/vaults/modvault/modinfo.qthtml new file mode 100644 index 000000000..067884c52 --- /dev/null +++ b/res/vaults/modvault/modinfo.qthtml @@ -0,0 +1,14 @@ +{title} V{version} + +{modtype} +

+{description} +

+ + + + + + +
Author: {author}
Rating: {rating} ({reviews})
Uploaded {date}
+
diff --git a/res/modvault/uimod.qthtml b/res/vaults/modvault/uimod.qthtml similarity index 93% rename from res/modvault/uimod.qthtml rename to res/vaults/modvault/uimod.qthtml index 0cf87d963..d5f17b1d7 100644 --- a/res/modvault/uimod.qthtml +++ b/res/vaults/modvault/uimod.qthtml @@ -3,4 +3,4 @@

{description}

-
\ No newline at end of file + diff --git a/res/modvault/uimod.ui b/res/vaults/modvault/uimod.ui similarity index 100% rename from res/modvault/uimod.ui rename to res/vaults/modvault/uimod.ui diff --git a/res/modvault/upload.ui b/res/vaults/modvault/upload.ui similarity index 100% rename from res/modvault/upload.ui rename to res/vaults/modvault/upload.ui diff --git a/res/vaults/vault.ui b/res/vaults/vault.ui new file mode 100644 index 000000000..9d85f4fc7 --- /dev/null +++ b/res/vaults/vault.ui @@ -0,0 +1,532 @@ + + + Form + + + + 0 + 0 + 775 + 429 + + + + + 0 + 0 + + + + Form + + + + + + 5 + + + 5 + + + 10 + + + 5 + + + 5 + + + + + + 110 + 0 + + + + + 250 + 16777215 + + + + + + + + + 75 + 25 + + + + Server Search + + + + + + + + 75 + 25 + + + + Reset Search + + + + + + + + 50 + 16777215 + + + + Sort + + + + + + + + 110 + 0 + + + + + 16777215 + 16777215 + + + + + Alphabetic + + + + + + + + Show + + + + + + + + 110 + 0 + + + + + 16777215 + 16777215 + + + + + All + + + + + + + + Qt::Horizontal + + + + 150 + 20 + + + + + + + + + 100 + 25 + + + + Manage UI Mods + + + + + + + + 75 + 25 + + + + Upload + + + + + + + + + Qt::NoFocus + + + QAbstractItemView::NoSelection + + + + 100 + 100 + + + + Qt::ElideMiddle + + + QAbstractItemView::ScrollPerPixel + + + QAbstractItemView::ScrollPerPixel + + + QListView::TopToBottom + + + false + + + QListView::Adjust + + + QListView::ListMode + + + true + + + true + + + + + + + 6 + + + QLayout::SetDefaultConstraint + + + 5 + + + 5 + + + 5 + + + 0 + + + + + Qt::Horizontal + + + + 200 + 20 + + + + + + + + + 0 + 0 + + + + + 25 + 25 + + + + Page + + + + + + + + 50 + 0 + + + + + 100 + 16777215 + + + + Qt::AlignCenter + + + QAbstractSpinBox::NoButtons + + + 1 + + + 99999 + + + + + + + of + + + + + + + + 0 + 0 + + + + + 25 + 25 + + + + + 50 + 16777215 + + + + + + + + + + + + 0 + 0 + + + + + 65 + 25 + + + + + 100 + 16777215 + + + + Go to page + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + + 30 + 23 + + + + + 30 + 16777215 + + + + << + + + + + + + + 0 + 0 + + + + + 0 + 25 + + + + + 100 + 16777215 + + + + Previous + + + + + + + true + + + + 0 + 0 + + + + + 0 + 25 + + + + + 100 + 16777215 + + + + Next + + + + + + + + 30 + 23 + + + + + 30 + 16777215 + + + + >> + + + + + + + Qt::Horizontal + + + + 150 + 20 + + + + + + + + + 0 + 25 + + + + Quantity on page + + + + + + + + 75 + 25 + + + + Qt::AlignCenter + + + QAbstractSpinBox::UpDownArrows + + + 1 + + + 500 + + + 50 + + + 50 + + + + + + + + + + diff --git a/runtests.py b/runtests.py index d7338fd39..3beb4c35d 100644 --- a/runtests.py +++ b/runtests.py @@ -1,7 +1,6 @@ -#! /usr/bin/env python3 +import sys import pytest -import sys if __name__ == '__main__': sys.exit(pytest.main(sys.argv[1:])) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..7023589a4 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[isort] +force_single_line = True + +[flake8] +max_line_length = 100 +per-file-ignores = + # E221: multiple spaces before operator + # E501: line too long + src/api/models/*: E221, E501 diff --git a/setup.py b/setup.py index 127924e50..728138e0c 100644 --- a/setup.py +++ b/setup.py @@ -1,146 +1,121 @@ import os import sys -import sip -from pathlib import Path - -sip.setapi('QString', 2) -sip.setapi('QVariant', 2) -sip.setapi('QStringList', 2) -sip.setapi('QList', 2) -sip.setapi('QProcess', 2) - -import PyQt5.uic -if sys.platform == 'win32': - from cx_Freeze import setup, Executable +if sys.platform == "win32": + from cx_Freeze import Executable + from cx_Freeze import setup else: from distutils.core import setup sys.path.insert(0, "src") -company_name = 'FAF Community' -product_name = 'Forged Alliance Forever' +company_name = "FAF Community" +product_name = "Forged Alliance Forever" -if sys.platform == 'win32': +if sys.platform == "win32": import config.version as version root_dir = os.path.dirname(os.path.abspath(__file__)) res_dir = os.path.join(root_dir, "res") - appveyor_build_version = os.getenv('APPVEYOR_BUILD_VERSION') - appveyor_build_version = appveyor_build_version.replace(' ','') - version.write_version_file(appveyor_build_version, res_dir) - - faf_version, git_revision = version.get_git_version() - msi_version = version.msi_version(faf_version) - print('Release version:', faf_version, - 'Build version:', appveyor_build_version) - if git_revision: - print('Git revision:', git_revision) - -# Ugly hack to fix broken PyQt5 (FIXME - necessary?) -for module in ["invoke.py", "load_plugin.py"]: - try: - silly_file = Path(PyQt5.__path__[0]) / "uic" / "port_v2" / module - print("Removing {}".format(silly_file)) - silly_file.unlink() - except OSError: - pass - -# Dependencies are automatically detected, but it might need fine tuning. -import PyQt5.uic -build_exe_options = { - 'include_files': ['res', - 'imageformats', - 'platforms', - 'libeay32.dll', - 'ssleay32.dll', - 'libEGL.dll', # For QtWebEngine - 'libGLESv2.dll', # ditto - 'icudtl.dat', #ditto - 'qtwebengine_resources.pak', # ditto - 'QtWebEngineProcess.exe', # ditto - ('lib/faf-uid.exe', 'lib/faf-uid.exe'), - ('lib/qt.conf', 'qt.conf'), - ('lib/xdelta3.exe', 'lib/xdelta3.exe')], - 'include_msvcr': True, - 'optimize': 2, - # cx_freeze >5.0.0 fails to add idna, we'll remove it once they fix it - 'packages': ['PyQt5', 'PyQt5.uic', 'idna', - 'PyQt5.QtWidgets', 'PyQt5.QtNetwork', 'win32com', 'win32com.client'], - 'silent': True, - 'excludes': ['numpy', 'scipy', 'matplotlib', 'tcl', 'Tkinter'], - - 'zip_include_packages': ["*"], # Place source files in zip archive, like in cx_freeze 4.3.4 - 'zip_exclude_packages': [], -} + build_version = os.getenv("BUILD_VERSION") + build_version = build_version.replace(" ", "") + version.write_version_file(build_version, res_dir) + + msi_version = version.msi_version(build_version) + shortcut_table = [ - ('DesktopShortcut', # Shortcut - 'DesktopFolder', # Directory_ - 'FA Forever', # Name - 'TARGETDIR', # Component_ - '[TARGETDIR]FAForever.exe', # Target - None, # Arguments - None, # Description - None, # Hotkey - None, # Icon - None, # IconIndex - None, # ShowCmd - 'TARGETDIR' # WkDir - ) + ( + "DesktopShortcut", # Shortcut + "DesktopFolder", # Directory_ + "FA Forever", # Name + "TARGETDIR", # Component_ + "[TARGETDIR]FAForever.exe", # Target + None, # Arguments + None, # Description + None, # Hotkey + None, # Icon + None, # IconIndex + None, # ShowCmd + "TARGETDIR", # WkDir + ), ] -target_dir = '[ProgramFilesFolder][ProductName]' -upgrade_code = '{ADE2A55B-834C-4D8D-A071-7A91A3A266B7}' +target_dir = "[ProgramFilesFolder][ProductName]" +upgrade_code = "{ADE2A55B-834C-4D8D-A071-7A91A3A266B7}" -if False: # Beta build +if os.getenv("BETA"): # Beta build product_name += " Beta" - upgrade_code = '{2A336240-1D51-4726-B36f-78B998DD3740}' + upgrade_code = "{2A336240-1D51-4726-B36f-78B998DD3740}" bdist_msi_options = { - 'upgrade_code': upgrade_code, - 'initial_target_dir': target_dir, - 'add_to_path': False, - 'data': {'Shortcut': shortcut_table}, + "upgrade_code": upgrade_code, + "initial_target_dir": target_dir, + "add_to_path": False, + "data": {"Shortcut": shortcut_table}, + "all_users": True, } -# GUI applications require a different base on Windows (the default is for a -# console application). -base = None -if sys.platform == 'win32': - base = 'Win32GUI' +# base="Win32GUI" should be used only for Windows GUI app +base = "Win32GUI" if sys.platform == "win32" else None + + +if sys.platform == "win32": + # Dependencies are automatically detected, but it might need fine tuning. + build_exe_options = { + "include_files": [ + "res", + ("build_setup/faf-uid.exe", "natives/faf-uid.exe"), + ("build_setup/ice-adapter", "natives/ice-adapter"), + ], + "include_msvcr": True, + "optimize": 2, + "silent": True, + + # copied from https://github.com/marcelotduarte/cx_Freeze/blob/5e42a97d2da321eae270cdcc65cdc777eb8e8fc4/samples/pyqt6-simplebrowser/setup.py # noqa: E501 + # and unexcluded overexcluded + "excludes": ["tkinter", "unittest", "pydoc", "tcl"], + + "zip_include_packages": ["*"], + "zip_exclude_packages": [], + } -if sys.platform == 'win32': platform_options = { - 'executables': [Executable( - 'src/__main__.py', - base=base, - targetName='FAForever.exe', - icon='res/faf.ico' - )], - 'requires': ['sip', 'PyQt5', 'cx_Freeze'], - 'options': {'build_exe': build_exe_options, - 'bdist_msi': bdist_msi_options}, - 'version': msi_version, - } - + "executables": [ + Executable( + "src/__main__.py", + base=base, + target_name="FAForever.exe", + icon="res/faf.ico", + ), + ], + "options": { + "build_exe": build_exe_options, + "bdist_msi": bdist_msi_options, + }, + "version": msi_version, + } + else: from setuptools import find_packages platform_options = { - 'packages': find_packages(), - 'version': os.getenv('FAFCLIENT_VERSION'), - } + "packages": find_packages(), + "version": os.getenv("FAFCLIENT_VERSION"), + } setup( name=product_name, - description='Forged Alliance Forever - Lobby Client', - long_description='FA Forever is a community project that allows you to play \ -Supreme Commander and Supreme Commander: Forged Alliance online \ -with people across the globe. Provides new game play modes, including cooperative play, \ -ranked ladder play, and featured mods.', - author='FA Forever Community', - maintainer='Sheeo', - url='http://www.faforever.com', - license='GNU General Public License, Version 3', - **platform_options + description="Forged Alliance Forever - Lobby Client", + long_description=( + "FA Forever is a community project that allows you to " + "play Supreme Commander and Supreme Commander: Forged " + "Alliance online with people across the globe. " + "Provides new game play modes, including cooperative " + "play, ranked ladder play, and featured mods." + ), + author="FA Forever Community", + maintainer="Sheeo", + url="http://www.faforever.com", + license="GNU General Public License, Version 3", + **platform_options, ) diff --git a/src/__main__.py b/src/__main__.py index 85bcce607..aa466060c 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -5,17 +5,20 @@ @author: thygrrr """ -# CRUCIAL: This must remain on top. -#import sip - -#sip.setapi('QString', 2) -#sip.setapi('QVariant', 2) -#sip.setapi('QStringList', 2) -#sip.setapi('QList', 2) -#sip.setapi('QProcess', 2) - import os import sys +from types import TracebackType + +from PyQt6 import uic +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QDialog +from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QStyleFactory + +import util +import util.crash +from config import Settings # Some linux distros (like Gentoo) make package scripts available # by copying and modifying them. This breaks path to our modules. @@ -32,66 +35,67 @@ import argparse -cmd_parser = argparse.ArgumentParser(description='FAF client commandline arguments.') -cmd_parser.add_argument('--qt-angle-workaround', - action='store_true', - help='Use Qt5 ANGLE backend. Enable if some client tabs appear frozen. On by default.') -cmd_parser.add_argument('--no-qt-angle-workaround', - action='store_true', - help='Do not use Qt5 ANGLE backend.') +cmd_parser = argparse.ArgumentParser( + description='FAF client commandline arguments.', +) args, trailing_args = cmd_parser.parse_known_args() -if sys.platform == 'win32' and not args.no_qt_angle_workaround: - os.environ.setdefault('QT_OPENGL', 'angle') - os.environ.setdefault('QT_ANGLE_PLATFORM', 'd3d9') -from PyQt5 import QtWidgets, uic -from PyQt5.QtCore import Qt -path = os.path.join(os.path.dirname(sys.argv[0]), "PyQt5.uic.widget-plugins") +path = os.path.join(os.path.dirname(sys.argv[0]), "PyQt6.uic.widget-plugins") uic.widgetPluginPath.append(path) -# According to PyQt5 docs we need to import this before we create QApplication -from PyQt5 import QtWebEngineWidgets - -import util - # Set up crash reporting excepthook_original = sys.excepthook -def excepthook(exc_type, exc_value, traceback_object): +def excepthook( + exc_type: type[BaseException], + exc_value: BaseException, + traceback_object: TracebackType | None, +) -> None: """ - This exception hook will stop the app if an uncaught error occurred, regardless where in the QApplication. + This exception hook will stop the app if an uncaught error occurred, + regardless where in the QApplication. """ sys.excepthook = excepthook_original - - logger.error("Uncaught exception", exc_info=(exc_type, exc_value, traceback_object)) - logger.error("Runtime Info:\n%s", util.runtime_info()) - dialog = util.CrashDialog((exc_type, exc_value, traceback_object)) - answer = dialog.exec_() - - if answer == QtWidgets.QDialog.Rejected: - QtWidgets.QApplication.exit(1) + if exc_type is KeyboardInterrupt: + raise exc_value + + logger.error( + "Uncaught exception", + exc_info=( + exc_type, exc_value, + traceback_object, + ), + ) + logger.error("Runtime Info:\n{}".format(util.crash.runtime_info())) + dialog = util.crash.CrashDialog((exc_type, exc_value, traceback_object)) + answer = dialog.exec() + + if answer == QDialog.DialogCode.Rejected: + QApplication.exit(1) sys.excepthook = excepthook -def AdminUserErrorDialog(): - from config import Settings +def admin_user_error_dialog() -> None: ignore_admin = Settings.get("client/ignore_admin", False, bool) if not ignore_admin: - box = QtWidgets.QMessageBox() - box.setText("FAF should not be run as an administrator!

This probably means you need " - "to fix the file permissions in C:\\ProgramData.
Proceed at your own risk.") - box.setStandardButtons(QtWidgets.QMessageBox.Ignore | QtWidgets.QMessageBox.Close) - box.setIcon(QtWidgets.QMessageBox.Critical) + box = QMessageBox() + box.setText( + "FAF should not be run as an administrator!

This " + "probably means you need to fix the file permissions in " + "C:\\ProgramData.
Proceed at your own risk.", + ) + box.setStandardButtons(QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Close) + box.setIcon(QMessageBox.Icon.Critical) box.setWindowTitle("FAF privilege error") - if box.exec_() == QtWidgets.QMessageBox.Ignore: + if box.exec() == QMessageBox.StandardButton.Ignore: Settings.set("client/ignore_admin", True) -def runFAF(): +def run_faf(): # Load theme from settings (one of the first things to be done) util.THEME.loadTheme() @@ -101,29 +105,45 @@ def runFAF(): faf_client = client.instance faf_client.setup() faf_client.show() - faf_client.doConnect() + faf_client.try_to_auto_login() # Main update loop - QtWidgets.QApplication.exec_() + QApplication.exec() + + +def set_style(app: QApplication) -> None: + styles = QStyleFactory.keys() + preferred_style = Settings.get("theme/style", "windowsvista") + if preferred_style in styles: + app.setStyle(QStyleFactory.create(preferred_style)) if __name__ == '__main__': import logging + import config - QtWidgets.QApplication.setAttribute(Qt.AA_ShareOpenGLContexts) - app = QtWidgets.QApplication(trailing_args) + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts) + app = QApplication(["FAF Python Client"] + trailing_args) + set_style(app) if sys.platform == 'win32': - import platform import ctypes + import platform if platform.release() != "XP": # legacy special :-) if config.admin.isUserAdmin(): - AdminUserErrorDialog() - - if getattr(ctypes.windll.shell32, "SetCurrentProcessExplicitAppUserModelID", None) is not None: + admin_user_error_dialog() + + attribute = getattr( + ctypes.windll.shell32, + "SetCurrentProcessExplicitAppUserModelID", + None, + ) + if attribute is not None: myappid = 'com.faforever.lobby' - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + myappid, + ) logger = logging.getLogger(__name__) logger.info(">>> --------------------------- Application Launch") @@ -136,16 +156,20 @@ def runFAF(): if len(trailing_args) == 0: # Do the magic - sys.path += ['.'] - runFAF() + sys.path.extend(['.']) + run_faf() else: # Try to interpret the argument as a replay. - if trailing_args[0].lower().endswith(".fafreplay") or trailing_args[0].lower().endswith(".scfareplay"): + if ( + trailing_args[0].lower().endswith(".fafreplay") + or trailing_args[0].lower().endswith(".scfareplay") + ): import fa fa.replay(trailing_args[0], True) # Launch as detached process # End of show app.closeAllWindows() + app.deleteLater() app.quit() # End the application, perform some housekeeping diff --git a/src/api/ApiAccessors.py b/src/api/ApiAccessors.py new file mode 100644 index 000000000..bc17ed6c5 --- /dev/null +++ b/src/api/ApiAccessors.py @@ -0,0 +1,103 @@ +import logging + +from PyQt6.QtCore import pyqtSignal + +from api.ApiBase import ApiBase + +logger = logging.getLogger(__name__) + + +class ApiAccessor(ApiBase): + def __init__(self, route: str = "") -> None: + super().__init__(route) + self.host_config_key = "api" + + +class UserApiAccessor(ApiBase): + def __init__(self, route: str = "") -> None: + super().__init__(route) + self.host_config_key = "user_api" + + +class DataApiAccessor(ApiAccessor): + data_ready = pyqtSignal(dict) + + def parse_message(self, message: dict) -> dict: + included = self.parseIncluded(message) + result = {} + result["data"] = self.parseData(message, included) + result["meta"] = self.parseMeta(message) + return result + + def parseIncluded(self, message: dict) -> dict: + result: dict = {} + relationships = [] + if "included" in message: + for inc_item in message["included"]: + if not inc_item["type"] in result: + result[inc_item["type"]] = {} + if "attributes" in inc_item: + type_ = inc_item["type"] + id_ = inc_item["id"] + result[type_][id_] = inc_item["attributes"] + if "relationships" in inc_item: + for key, value in inc_item["relationships"].items(): + relationships.append(( + inc_item["type"], inc_item["id"], key, value, + )) + message.pop('included') + # resolve relationships + for r in relationships: + result[r[0]][r[1]][r[2]] = self.parseData(r[3], result) + return result + + def parseData(self, message: dict, included: dict) -> dict | list: + if "data" in message: + if isinstance(message["data"], (list)): + result = [] + for data in message["data"]: + result.append(self.parseSingleData(data, included)) + return result + elif isinstance(message["data"], (dict)): + return self.parseSingleData(message["data"], included) + else: + logger.error("error in response", message) + if "included" in message: + logger.error("unexpected 'included' in message", message) + return {} + + def parseSingleData(self, data: dict, included: dict) -> dict: + result = {} + try: + if ( + data["type"] in included + and data["id"] in included[data["type"]] + ): + result = included[data["type"]][data["id"]] + result["id"] = data["id"] + if "type" not in result: + result["type"] = data["type"] + if "attributes" in data: + for key, value in data["attributes"].items(): + result[key] = value + if "relationships" in data: + for key, value in data["relationships"].items(): + result[key] = self.parseData(value, included) + except Exception as e: + logger.error(f"Erorr parsing {data}: {e}") + return result + + def parseMeta(self, message: dict) -> dict: + if "meta" in message: + return message["meta"] + return {} + + def requestData(self, query_dict: dict | None = None) -> None: + query_dict = query_dict or {} + self.get_by_query(query_dict, self.handle_response) + + def prepare_data(self, message: dict) -> dict: + return message + + def handle_response(self, message: dict) -> None: + self.data_ready.emit(self.prepare_data(message)) diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py new file mode 100644 index 000000000..d856bdf25 --- /dev/null +++ b/src/api/ApiBase.py @@ -0,0 +1,101 @@ +import json +import logging +from typing import Any +from typing import Callable + +from PyQt6 import QtWidgets +from PyQt6.QtCore import QByteArray +from PyQt6.QtCore import QEventLoop +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import QUrlQuery +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + +from config import Settings +from oauth.oauth_flow import OAuth2Flow +from oauth.oauth_flow import OAuth2FlowInstance + +logger = logging.getLogger(__name__) + +DO_NOT_ENCODE = QByteArray() +DO_NOT_ENCODE.append(b":/?&=.,") + + +class ApiBase(QObject): + oauth: OAuth2Flow = OAuth2FlowInstance + + def __init__(self, route: str = "") -> None: + QObject.__init__(self) + self.route = route + self.host_config_key = "" + self.manager = QNetworkAccessManager() + self.manager.finished.connect(self.onRequestFinished) + self._running = False + self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {} + + @classmethod + def set_oauth(cls, oauth: OAuth2Flow) -> None: + cls.oauth = oauth + + def build_query_url(self, query_dict: dict) -> QUrl: + query = QUrlQuery() + for key, value in query_dict.items(): + query.addQueryItem(key, str(value)) + stringQuery = query.toString(QUrl.ComponentFormattingOption.FullyDecoded) + percentEncodedByteArrayQuery = QUrl.toPercentEncoding( + stringQuery, + exclude=DO_NOT_ENCODE, + ) + percentEncodedStrQuery = percentEncodedByteArrayQuery.data().decode() + url = self._get_host_url().resolved(QUrl(self.route)) + url.setQuery(percentEncodedStrQuery) + return url + + def _get_host_url(self) -> QUrl: + return QUrl(Settings.get(self.host_config_key)) + + # query arguments like filter=login==Rhyza + def get_by_query(self, query_dict: dict, response_handler: Callable[[dict], Any]) -> None: + url = self.build_query_url(query_dict) + self.get(url, response_handler) + + def get_by_endpoint(self, endpoint: str, response_handler: Callable[[dict], Any]) -> None: + url = self._get_host_url().resolved(QUrl(endpoint)) + self.get(url, response_handler) + + @staticmethod + def prepare_request(url: QUrl | None) -> QNetworkRequest: + request = QNetworkRequest(url) if url else QNetworkRequest() + # last 2 args are unused, but for some reason they are required + ApiBase.oauth.prepareRequest(request, QByteArray(), QByteArray()) + # FIXME: remove when https://bugreports.qt.io/browse/QTBUG-123891 is deployed + request.setAttribute(QNetworkRequest.Attribute.Http2AllowedAttribute, False) + return request + + def get(self, url: QUrl, response_handler: Callable[[dict], Any]) -> None: + self._running = True + logger.debug("Sending API request with URL: {}".format(url.toString())) + reply = self.manager.get(self.prepare_request(url)) + self.handlers[reply] = response_handler + + def parse_message(self, message: dict) -> dict: + return message + + def onRequestFinished(self, reply: QNetworkReply) -> None: + self._running = False + if reply.error() != QNetworkReply.NetworkError.NoError: + logger.error("API request error: {}".format(reply.error())) + else: + message_bytes = reply.readAll().data() + message = json.loads(message_bytes.decode('utf-8')) + result = self.parse_message(message) + self.handlers[reply](result) + self.handlers.pop(reply) + reply.deleteLater() + + def waitForCompletion(self): + waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents + while self._running: + QtWidgets.QApplication.processEvents(waitFlag) diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/api/coop_api.py b/src/api/coop_api.py new file mode 100644 index 000000000..d1e1cf6c6 --- /dev/null +++ b/src/api/coop_api.py @@ -0,0 +1,58 @@ +from api.ApiAccessors import DataApiAccessor +from api.models.CoopResult import CoopResult +from api.models.CoopScenario import CoopScenario +from api.parsers.CoopResultParser import CoopResultParser +from api.parsers.CoopScenarioParser import CoopScenarioParser + + +class CoopApiAccessor(DataApiAccessor): + def __init__(self) -> None: + super().__init__("/data/coopScenario") + + def request_coop_scenarios(self) -> None: + self.requestData({"include": "maps"}) + + def prepare_data(self, message: dict) -> dict[str, list[CoopScenario]]: + return {"values": CoopScenarioParser.parse_many(message["data"])} + + +class CoopResultApiAccessor(DataApiAccessor): + def __init__(self) -> None: + super().__init__("/data/coopResult") + + def prepare_query_dict(self, mission: int) -> dict: + return { + "filter": f"mission=={mission}", + "include": "game,game.playerStats.player", + "sort": "duration", + "page[size]": 1000, + } + + def extend_filter(self, query_options: dict, filteroption: str) -> dict: + cur_filters = query_options.get("filter", "") + query_options["filter"] = ";".join((cur_filters, filteroption)).removeprefix(";") + return query_options + + def request_coop_results(self, mission: int, player_count: int) -> None: + default_query = self.prepare_query_dict(mission) + query = self.extend_filter(default_query, f"playerCount=={player_count}") + self.requestData(query) + + def request_coop_results_general(self, mission: int) -> None: + self.requestData(self.prepare_query_dict(mission)) + + def filter_unique_teams(self, results: list[CoopResult]) -> list[CoopResult]: + unique_results = [] + unique_teams = set() + for result in results: + player_ids = [player_stat.player.xd for player_stat in result.game.player_stats] + players_tuple = tuple(sorted(player_ids)) + if players_tuple not in unique_teams: + unique_results.append(result) + unique_teams.add(players_tuple) + return unique_results + + def prepare_data(self, message: dict) -> dict[str, list[CoopResult]]: + parsed = CoopResultParser.parse_many(message["data"]) + distinct = self.filter_unique_teams(parsed) + return {"values": distinct} diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py new file mode 100644 index 000000000..9d1f67b7c --- /dev/null +++ b/src/api/featured_mod_api.py @@ -0,0 +1,43 @@ +import logging + +from api.ApiAccessors import DataApiAccessor +from api.models.FeaturedMod import FeaturedMod +from api.models.FeaturedModFile import FeaturedModFile +from api.parsers.FeaturedModFileParser import FeaturedModFileParser +from api.parsers.FeaturedModParser import FeaturedModParser + +logger = logging.getLogger(__name__) + + +class FeaturedModApiConnector(DataApiAccessor): + def __init__(self) -> None: + super().__init__("/data/featuredMod") + + def prepare_data(self, message: dict) -> dict[str, list[FeaturedMod]]: + return {"values": FeaturedModParser.parse_many(message["data"])} + + def handle_featured_mod(self, message: dict) -> None: + self.featured_mod = FeaturedModParser.parse(message["data"][0]) + + def request_fmod_by_name(self, technical_name: str) -> None: + queryDict = {"filter": f"technicalName=={technical_name}"} + self.get_by_query(queryDict, self.handle_featured_mod) + + def request_and_get_fmod_by_name(self, technicalName) -> FeaturedMod: + self.request_fmod_by_name(technicalName) + self.waitForCompletion() + return self.featured_mod + + +class FeaturedModFilesApiConnector(DataApiAccessor): + def __init__(self, mod_id: str, version: str) -> None: + super().__init__(f"/featuredMods/{mod_id}/files/{version}") + self.featured_mod_files = [] + + def handle_response(self, message: dict) -> None: + self.featured_mod_files = FeaturedModFileParser.parse_many(message["data"]) + + def get_files(self) -> list[FeaturedModFile]: + self.requestData() + self.waitForCompletion() + return self.featured_mod_files diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py new file mode 100644 index 000000000..576a85a31 --- /dev/null +++ b/src/api/matchmaker_queue_api.py @@ -0,0 +1,26 @@ +import logging + +from api.ApiAccessors import DataApiAccessor + +logger = logging.getLogger(__name__) + + +class MatchmakerQueueApiConnector(DataApiAccessor): + def __init__(self) -> None: + super().__init__('/data/matchmakerQueue') + + def prepare_data(self, message: dict) -> None: + prepared_data = { + "command": "matchmaker_queue_info", + "values": [], + "meta": message["meta"], + } + for queue in message["data"]: + preparedQueue = { + "technicalName": queue["technicalName"], + "ratingType": queue["leaderboard"]["technicalName"], + "id": queue["id"], + "leaderboardId": queue["leaderboard"]["id"], + } + prepared_data["values"].append(preparedQueue) + return prepared_data diff --git a/src/api/models/AbstractEntity.py b/src/api/models/AbstractEntity.py new file mode 100644 index 000000000..c49e15a4b --- /dev/null +++ b/src/api/models/AbstractEntity.py @@ -0,0 +1,9 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel + + +class AbstractEntity(ConfiguredModel): + xd: str = Field(alias="id") + create_time: str = Field(alias="createTime") + update_time: str = Field(alias="updateTime") diff --git a/src/api/models/ConfiguredModel.py b/src/api/models/ConfiguredModel.py new file mode 100644 index 000000000..4c4e4e1c5 --- /dev/null +++ b/src/api/models/ConfiguredModel.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel +from pydantic import ConfigDict + + +class ConfiguredModel(BaseModel): + model_config = ConfigDict(populate_by_name=True) diff --git a/src/api/models/CoopMission.py b/src/api/models/CoopMission.py new file mode 100644 index 000000000..09a64acdc --- /dev/null +++ b/src/api/models/CoopMission.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel + + +class CoopMission(ConfiguredModel): + xd: int = Field(alias="id") + category: str + description: str + download_url: str = Field(alias="downloadUrl") + folder_name: str = Field(alias="folderName") + name: str + order: int + thumbnail_url_large: str = Field(alias="thumbnailUrlLarge") + thumbnail_url_small: str = Field(alias="thumbnailUrlSmall") + version: int diff --git a/src/api/models/CoopResult.py b/src/api/models/CoopResult.py new file mode 100644 index 000000000..8e4d34e02 --- /dev/null +++ b/src/api/models/CoopResult.py @@ -0,0 +1,14 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel +from api.models.Game import Game + + +class CoopResult(ConfiguredModel): + xd: str = Field(alias="id") + duration: int + mission: int + player_count: int = Field(alias="playerCount") + secondary_objectives: bool = Field(alias="secondaryObjectives") + + game: Game | None = Field(None) diff --git a/src/api/models/CoopScenario.py b/src/api/models/CoopScenario.py new file mode 100644 index 000000000..5d30ca789 --- /dev/null +++ b/src/api/models/CoopScenario.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel +from api.models.CoopMission import CoopMission + + +class CoopScenario(ConfiguredModel): + xd: int = Field(alias="id") + name: str + order: int + description: str | None + faction: str + maps: list[CoopMission] diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py new file mode 100644 index 000000000..cff4af044 --- /dev/null +++ b/src/api/models/FeaturedMod.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel + + +class FeaturedMod(ConfiguredModel): + xd: str = Field(alias="id") + name: str = Field(alias="technicalName") + fullname: str = Field(alias="displayName") + visible: bool + order: int = Field(0) + description: str = Field("No description provided") diff --git a/src/api/models/FeaturedModFile.py b/src/api/models/FeaturedModFile.py new file mode 100644 index 000000000..154f40b31 --- /dev/null +++ b/src/api/models/FeaturedModFile.py @@ -0,0 +1,15 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel + + +class FeaturedModFile(ConfiguredModel): + xd: str = Field(alias="id") + version: int + group: str + name: str + md5: str + url: str + cacheable_url: str = Field(alias="cacheableUrl") + hmac_token: str = Field(alias="hmacToken") + hmac_parameter: str = Field(alias="hmacParameter") diff --git a/src/api/models/Game.py b/src/api/models/Game.py new file mode 100644 index 000000000..00dde8dd1 --- /dev/null +++ b/src/api/models/Game.py @@ -0,0 +1,18 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel +from api.models.Player import Player +from api.models.PlayerStats import PlayerStats + + +class Game(ConfiguredModel): + end_time: str = Field(alias="endTime") + xd: str = Field(alias="id") + name: str + replay_available: bool = Field(alias="replayAvailable") + replay_ticks: int | None = Field(alias="replayTicks") + replay_url: str = Field(alias="replayUrl") + start_time: str = Field(alias="startTime") + + host: Player | None = Field(None) + player_stats: list[PlayerStats] | None = Field(None, alias="playerStats") diff --git a/src/api/models/GeneratedMapParams.py b/src/api/models/GeneratedMapParams.py new file mode 100644 index 000000000..d77eb15e2 --- /dev/null +++ b/src/api/models/GeneratedMapParams.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel +from api.models.Map import Map +from api.models.MapType import MapType +from api.models.MapVersion import MapVersion + + +class GeneratedMapParams(ConfiguredModel): + name: str = Field(alias="type") + spawns: int + size: int + gen_version: str = Field(alias="version") + + def to_map(self) -> Map: + uid = f"neroxis_map_generator_{self.gen_version}_{self.name}_{self.spawns}_{self.size}" + version = MapVersion( + xd=uid, + create_time="", + update_time="", + folder_name=uid, + games_played=0, + description="Randomly Generated Map", + max_players=self.spawns, + height=self.size, + width=self.size, + version=self.gen_version, + hidden=False, + ranked=True, + download_url="", + thumbnail_url_small="", + thumbnail_url_large="", + ) + return Map( + xd=uid, + create_time="", + update_time="", + display_name=self.name, + author=None, + recommended=False, + reviews_summary=None, + games_played=0, + map_type=MapType.SKIRMISH.value, + version=version, + ) diff --git a/src/api/models/Map.py b/src/api/models/Map.py new file mode 100644 index 000000000..97c983011 --- /dev/null +++ b/src/api/models/Map.py @@ -0,0 +1,36 @@ +from pydantic import Field +from pydantic import field_validator + +from api.models.AbstractEntity import AbstractEntity +from api.models.MapType import MapType +from api.models.MapVersion import MapVersion +from api.models.Player import Player +from api.models.ReviewsSummary import ReviewsSummary + + +class Map(AbstractEntity): + display_name: str = Field(alias="displayName") + recommended: int + author: Player | None = Field(None) + reviews_summary: ReviewsSummary | None = Field(None, alias="reviewsSummary") + games_played: int = Field(alias="gamesPlayed") + map_type: str = Field(alias="mapType") + version: MapVersion | None = Field(None) + + @property + def maptype(self) -> MapType: + return MapType.from_string(self.map_type) + + @field_validator("reviews_summary", mode="before") + @classmethod + def validate_reviews_summary(cls, value: dict) -> ReviewsSummary | None: + if not value: + return None + return ReviewsSummary(**value) + + @field_validator("author", mode="before") + @classmethod + def validate_author(cls, value: dict) -> Player | None: + if not value: + return None + return Player(**value) diff --git a/src/api/models/MapPoolAssignment.py b/src/api/models/MapPoolAssignment.py new file mode 100644 index 000000000..fa38d754c --- /dev/null +++ b/src/api/models/MapPoolAssignment.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pydantic import Field +from pydantic import field_validator + +from api.models.AbstractEntity import AbstractEntity +from api.models.GeneratedMapParams import GeneratedMapParams +from api.models.MapVersion import MapVersion + + +class MapPoolAssignment(AbstractEntity): + map_params: GeneratedMapParams | None = Field(None, alias="mapParams") + map_version: MapVersion | None = Field(None, alias="mapVersion") + weight: int + + @field_validator("map_params", mode="before") + @classmethod + def validate_map_params(cls, value: dict) -> GeneratedMapParams | None: + if not value: + return None + return GeneratedMapParams(**value) + + @field_validator("map_version", mode="before") + @classmethod + def validate_map_version(cls, value: dict) -> MapVersion | None: + if not value: + return None + return MapVersion(**value) diff --git a/src/api/models/MapType.py b/src/api/models/MapType.py new file mode 100644 index 000000000..2262ea7f4 --- /dev/null +++ b/src/api/models/MapType.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from enum import Enum + + +class MapType(Enum): + SKIRMISH = "skirmish" + COOP = "campaign_coop" + OTHER = "" + + @staticmethod + def from_string(map_type: str) -> MapType: + for mtype in list(MapType): + if mtype.value == map_type: + return mtype + else: + return MapType.OTHER diff --git a/src/api/models/MapVersion.py b/src/api/models/MapVersion.py new file mode 100644 index 000000000..08521a849 --- /dev/null +++ b/src/api/models/MapVersion.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from pydantic import Field + +from api.models.AbstractEntity import AbstractEntity + + +@dataclass +class MapSize: + height_px: int + width_px: int + + @property + def width_km(self) -> int: + return self.width_px / 51.2 + + @property + def height_km(self) -> int: + return self.height_px / 51.2 + + def __lt__(self, other: MapSize) -> bool: + return self.height_px * self.width_px < other.height_px * other.width_px + + def __ge__(self, other: MapSize) -> bool: + return not self.__lt__(other) + + def __str__(self) -> str: + return f"{self.width_km} x {self.height_km} km" + + +class MapVersion(AbstractEntity): + folder_name: str = Field(alias="folderName") + games_played: int = Field(alias="gamesPlayed") + description: str + max_players: int = Field(alias="maxPlayers") + height: int + width: int + version: int | str + hidden: bool + ranked: bool + download_url: str = Field(alias="downloadUrl") + thumbnail_url_small: str = Field(alias="thumbnailUrlSmall") + thumbnail_url_large: str = Field(alias="thumbnailUrlLarge") + + @property + def size(self) -> MapSize: + return MapSize(self.height, self.width) diff --git a/src/api/models/Mod.py b/src/api/models/Mod.py new file mode 100644 index 000000000..c2d113174 --- /dev/null +++ b/src/api/models/Mod.py @@ -0,0 +1,30 @@ +from pydantic import Field +from pydantic import field_validator + +from api.models.AbstractEntity import AbstractEntity +from api.models.ModVersion import ModVersion +from api.models.Player import Player +from api.models.ReviewsSummary import ReviewsSummary + + +class Mod(AbstractEntity): + display_name: str = Field(alias="displayName") + recommended: bool + author: str + reviews_summary: ReviewsSummary | None = Field(None, alias="reviewsSummary") + uploader: Player | None = Field(None) + version: ModVersion = Field(alias="latestVersion") + + @field_validator("reviews_summary", mode="before") + @classmethod + def validate_reviews_summary(cls, value: dict) -> ReviewsSummary | None: + if not value: + return None + return ReviewsSummary(**value) + + @field_validator("uploader", mode="before") + @classmethod + def validate_uploader(cls, value: dict) -> Player | None: + if not value: + return None + return Player(**value) diff --git a/src/api/models/ModType.py b/src/api/models/ModType.py new file mode 100644 index 000000000..823e3b478 --- /dev/null +++ b/src/api/models/ModType.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from enum import Enum + + +class ModType(Enum): + UI = "UI" + SIM = "SIM" + OTHER = "" + + @staticmethod + def from_string(string: str) -> ModType: + for modtype in list(ModType): + if modtype.value == string: + return modtype + return ModType.OTHER diff --git a/src/api/models/ModVersion.py b/src/api/models/ModVersion.py new file mode 100644 index 000000000..cbce8d237 --- /dev/null +++ b/src/api/models/ModVersion.py @@ -0,0 +1,20 @@ +from pydantic import Field + +from api.models.AbstractEntity import AbstractEntity +from api.models.ModType import ModType + + +class ModVersion(AbstractEntity): + description: str + download_url: str = Field(alias="downloadUrl") + filename: str + hidden: bool + ranked: bool + thumbnail_url: str = Field(alias="thumbnailUrl") + typ: str = Field(alias="type") + version: int + uid: str + + @property + def modtype(self) -> ModType: + return ModType.from_string(self.typ) diff --git a/src/api/models/Player.py b/src/api/models/Player.py new file mode 100644 index 000000000..18001ed06 --- /dev/null +++ b/src/api/models/Player.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pydantic import Field + +from api.models.AbstractEntity import AbstractEntity + + +class Player(AbstractEntity): + login: str + user_agent: str | None = Field(alias="userAgent") diff --git a/src/api/models/PlayerStats.py b/src/api/models/PlayerStats.py new file mode 100644 index 000000000..ea4ec724f --- /dev/null +++ b/src/api/models/PlayerStats.py @@ -0,0 +1,8 @@ +from pydantic import Field + +from api.models.ConfiguredModel import ConfiguredModel +from api.models.Player import Player + + +class PlayerStats(ConfiguredModel): + player: Player | None = Field(None) diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py new file mode 100644 index 000000000..dd7268b6b --- /dev/null +++ b/src/api/models/ReviewsSummary.py @@ -0,0 +1,18 @@ +from pydantic import Field +from pydantic import field_validator + +from api.models.ConfiguredModel import ConfiguredModel + + +class ReviewsSummary(ConfiguredModel): + positive: float + negative: float + score: float + average_score: float = Field(alias="averageScore") + num_reviews: int = Field(alias="reviews") + lower_bound: float = Field(alias="lowerBound") + + @field_validator("*", mode="before") + @classmethod + def avoid_none(cls, value: float | int | None) -> float | int: + return value or 0 diff --git a/src/api/models/__init__.py b/src/api/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/api/parsers/CoopResultParser.py b/src/api/parsers/CoopResultParser.py new file mode 100644 index 000000000..c9786db25 --- /dev/null +++ b/src/api/parsers/CoopResultParser.py @@ -0,0 +1,11 @@ +from api.models.CoopResult import CoopResult + + +class CoopResultParser: + @staticmethod + def parse(api_result: dict) -> CoopResult: + return CoopResult(**api_result) + + @staticmethod + def parse_many(api_result: list[dict]) -> list[CoopResult]: + return [CoopResultParser.parse(entry) for entry in api_result] diff --git a/src/api/parsers/CoopScenarioParser.py b/src/api/parsers/CoopScenarioParser.py new file mode 100644 index 000000000..8f269ce1f --- /dev/null +++ b/src/api/parsers/CoopScenarioParser.py @@ -0,0 +1,11 @@ +from api.models.CoopScenario import CoopScenario + + +class CoopScenarioParser: + @staticmethod + def parse(api_result: dict) -> CoopScenario: + return CoopScenario(**api_result) + + @staticmethod + def parse_many(api_result: list[dict]) -> list[CoopScenario]: + return [CoopScenarioParser.parse(entry) for entry in api_result] diff --git a/src/api/parsers/FeaturedModFileParser.py b/src/api/parsers/FeaturedModFileParser.py new file mode 100644 index 000000000..ef55a1395 --- /dev/null +++ b/src/api/parsers/FeaturedModFileParser.py @@ -0,0 +1,11 @@ +from api.models.FeaturedModFile import FeaturedModFile + + +class FeaturedModFileParser: + @staticmethod + def parse(api_result: dict) -> FeaturedModFile: + return FeaturedModFile(**api_result) + + @staticmethod + def parse_many(api_result: list[dict]) -> list[FeaturedModFile]: + return [FeaturedModFileParser.parse(file_info) for file_info in api_result] diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py new file mode 100644 index 000000000..da95bdac8 --- /dev/null +++ b/src/api/parsers/FeaturedModParser.py @@ -0,0 +1,12 @@ +from api.models.FeaturedMod import FeaturedMod + + +class FeaturedModParser: + + @staticmethod + def parse(data: dict) -> FeaturedMod: + return FeaturedMod(**data) + + @staticmethod + def parse_many(data: list[dict]) -> list[FeaturedMod]: + return [FeaturedModParser.parse(info) for info in data] diff --git a/src/api/parsers/GeneratedMapParamsParser.py b/src/api/parsers/GeneratedMapParamsParser.py new file mode 100644 index 000000000..43b50c2b4 --- /dev/null +++ b/src/api/parsers/GeneratedMapParamsParser.py @@ -0,0 +1,13 @@ +from api.models.Map import Map +from src.api.models.GeneratedMapParams import GeneratedMapParams + + +class GeneratedMapParamsParser: + + @staticmethod + def parse(params_info: dict) -> GeneratedMapParams: + return GeneratedMapParams(**params_info) + + @staticmethod + def parse_to_map(params_info: dict) -> Map: + return GeneratedMapParamsParser.parse(params_info).to_map() diff --git a/src/api/parsers/MapParser.py b/src/api/parsers/MapParser.py new file mode 100644 index 000000000..f8c063b9c --- /dev/null +++ b/src/api/parsers/MapParser.py @@ -0,0 +1,22 @@ +from api.models.Map import Map +from api.models.MapVersion import MapVersion + + +class MapParser: + + @staticmethod + def parse(api_result: dict) -> Map: + return Map(**api_result) + + @staticmethod + def parse_many(api_result: list[dict]) -> list[Map]: + return [ + MapParser.parse_version(info, info["latestVersion"]) + for info in api_result + ] + + @staticmethod + def parse_version(map_info: dict, version_info: dict) -> Map: + map_model = Map(**map_info) + map_model.version = MapVersion(**version_info) + return map_model diff --git a/src/api/parsers/MapPoolAssignmentParser.py b/src/api/parsers/MapPoolAssignmentParser.py new file mode 100644 index 000000000..8b2448bff --- /dev/null +++ b/src/api/parsers/MapPoolAssignmentParser.py @@ -0,0 +1,29 @@ +from api.models.Map import Map +from api.models.MapPoolAssignment import MapPoolAssignment +from api.parsers.MapParser import MapParser + + +class MapPoolAssignmentParser: + + @staticmethod + def parse(assignment_info: dict) -> MapPoolAssignment: + return MapPoolAssignment(**assignment_info) + + @staticmethod + def parse_many(assignment_info: list[dict]) -> list[MapPoolAssignment]: + return [MapPoolAssignmentParser.parse(info) for info in assignment_info] + + @staticmethod + def parse_to_map(assignment_info: dict) -> Map: + pool = MapPoolAssignmentParser.parse(assignment_info) + if pool.map_params is not None: + return pool.map_params.to_map() + if pool.map_version is not None: + map_model = MapParser.parse(assignment_info["mapVersion"]["map"]) + map_model.version = pool.map_version + return map_model + raise ValueError("MapPoolAssignment info does not contain mapVersion or mapParams") + + @staticmethod + def parse_many_to_maps(assignment_info: list[dict]) -> list[Map]: + return [MapPoolAssignmentParser.parse_to_map(info) for info in assignment_info] diff --git a/src/api/parsers/MapVersionParser.py b/src/api/parsers/MapVersionParser.py new file mode 100644 index 000000000..f603950d6 --- /dev/null +++ b/src/api/parsers/MapVersionParser.py @@ -0,0 +1,8 @@ +from api.models.MapVersion import MapVersion + + +class MapVersionParser: + + @staticmethod + def parse(version_info: dict) -> MapVersion: + return MapVersion(**version_info) diff --git a/src/api/parsers/ModParser.py b/src/api/parsers/ModParser.py new file mode 100644 index 000000000..b97711728 --- /dev/null +++ b/src/api/parsers/ModParser.py @@ -0,0 +1,12 @@ +from api.models.Mod import Mod + + +class ModParser: + + @staticmethod + def parse(mod_info: dict) -> Mod: + return Mod(**mod_info) + + @staticmethod + def parse_many(api_result: list[dict]) -> list[Mod]: + return [ModParser.parse(mod_info) for mod_info in api_result] diff --git a/src/api/parsers/ModVersionParser.py b/src/api/parsers/ModVersionParser.py new file mode 100644 index 000000000..d572046bd --- /dev/null +++ b/src/api/parsers/ModVersionParser.py @@ -0,0 +1,8 @@ +from api.models.ModVersion import ModVersion + + +class ModVersionParser: + + @staticmethod + def parse(api_result: dict) -> ModVersion: + return ModVersion(**api_result) diff --git a/src/api/parsers/PlayerParser.py b/src/api/parsers/PlayerParser.py new file mode 100644 index 000000000..cead4158a --- /dev/null +++ b/src/api/parsers/PlayerParser.py @@ -0,0 +1,11 @@ +from api.models.Player import Player + + +class PlayerParser: + + @staticmethod + def parse(player_info: dict) -> Player | None: + if not player_info: + return None + + return Player(**player_info) diff --git a/src/api/parsers/ReviewsSummaryParser.py b/src/api/parsers/ReviewsSummaryParser.py new file mode 100644 index 000000000..cabb60c57 --- /dev/null +++ b/src/api/parsers/ReviewsSummaryParser.py @@ -0,0 +1,10 @@ +from api.models.ReviewsSummary import ReviewsSummary + + +class ReviewsSummaryParser: + + @staticmethod + def parse(reviews_info: dict) -> ReviewsSummary | None: + if not reviews_info: + return None + return ReviewsSummary(**reviews_info) diff --git a/src/api/parsers/__init__.py b/src/api/parsers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/api/player_api.py b/src/api/player_api.py new file mode 100644 index 000000000..5253c8854 --- /dev/null +++ b/src/api/player_api.py @@ -0,0 +1,28 @@ +import logging + +from PyQt6.QtCore import pyqtSignal + +from api.ApiAccessors import DataApiAccessor + +logger = logging.getLogger(__name__) + + +class PlayerApiConnector(DataApiAccessor): + alias_info = pyqtSignal(dict) + + def __init__(self) -> None: + super().__init__('/data/player') + + def requestDataForAliasViewer(self, nameToFind: str) -> None: + queryDict = { + 'include': 'names', + 'filter': '(login=="{name}",names.name=="{name}")'.format( + name=nameToFind, + ), + 'fields[player]': 'login,names', + 'fields[nameRecord]': 'name,changeTime,player', + } + self.get_by_query(queryDict, self.handleDataForAliasViewer) + + def handleDataForAliasViewer(self, message: dict) -> None: + self.alias_info.emit(message) diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py new file mode 100644 index 000000000..a9a8e99b7 --- /dev/null +++ b/src/api/replaysapi.py @@ -0,0 +1,10 @@ +import logging + +from api.ApiAccessors import DataApiAccessor + +logger = logging.getLogger(__name__) + + +class ReplaysApiConnector(DataApiAccessor): + def __init__(self) -> None: + super().__init__('/data/game') diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py new file mode 100644 index 000000000..9b5bacbd2 --- /dev/null +++ b/src/api/sim_mod_updater.py @@ -0,0 +1,20 @@ +import logging + +from api.ApiAccessors import DataApiAccessor + +logger = logging.getLogger(__name__) + + +class SimModFiles(DataApiAccessor): + def __init__(self) -> None: + super().__init__('/data/modVersion') + self.mod_url = "" + + def get_url_from_message(self, message: dict) -> str: + self.mod_url = message["data"][0]["downloadUrl"] + + def request_and_get_sim_mod_url_by_id(self, uid: str) -> str: + query_dict = {"filter": f"uid=={uid}"} + self.get_by_query(query_dict, self.get_url_from_message) + self.waitForCompletion() + return self.mod_url diff --git a/src/api/stats_api.py b/src/api/stats_api.py new file mode 100644 index 000000000..af4301ab1 --- /dev/null +++ b/src/api/stats_api.py @@ -0,0 +1,20 @@ +import logging + +from api.ApiAccessors import DataApiAccessor + +logger = logging.getLogger(__name__) + + +class LeaderboardRatingApiConnector(DataApiAccessor): + def __init__(self, leaderboard_name: str) -> None: + super().__init__('/data/leaderboardRating') + self.leaderboard_name = leaderboard_name + + def prepare_data(self, message: dict) -> None: + message["leaderboard"] = self.leaderboard_name + return message + + +class LeaderboardApiConnector(DataApiAccessor): + def __init__(self) -> None: + super().__init__('/data/leaderboard') diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py new file mode 100644 index 000000000..c1cf6504a --- /dev/null +++ b/src/api/vaults_api.py @@ -0,0 +1,96 @@ +import logging +from collections.abc import Sequence + +from api.ApiAccessors import DataApiAccessor +from api.parsers.MapParser import MapParser +from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser +from api.parsers.ModParser import ModParser + +logger = logging.getLogger(__name__) + + +class VaultsApiConnector(DataApiAccessor): + def __init__(self, route: str) -> None: + super().__init__(route) + self._includes = ("latestVersion", "reviewsSummary") + + def _extend_query_options(self, query_options: dict) -> dict: + self._add_default_includes(query_options) + self._apply_default_filters(query_options) + return query_options + + def _copy_query_options(self, query_options: dict | None) -> dict: + query_options = query_options or {} + return query_options.copy() + + def request_data(self, query_options: dict | None = None) -> None: + query = self._copy_query_options(query_options) + self._extend_query_options(query) + self.get_by_query(query, self.handle_response) + + def _add_default_includes(self, query_options: dict) -> dict: + return self._extend_includes(query_options, self._includes) + + def _extend_includes(self, query_options: dict, to_include: Sequence[str]) -> dict: + cur_includes = query_options.get("include", "") + to_include_str = ",".join((cur_includes, *to_include)).removeprefix(",") + query_options["include"] = to_include_str + return query_options + + def _apply_default_filters(self, query_options: dict) -> dict: + cur_filters = query_options.get("filter", "") + additional_filter = "latestVersion.hidden=='false'" + query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";") + return query_options + + +class ModApiConnector(VaultsApiConnector): + def __init__(self) -> None: + super().__init__("/data/mod") + + def _extend_query_options(self, query_options: dict) -> dict: + super()._extend_query_options(query_options) + self._extend_includes(query_options, ["uploader"]) + return query_options + + def prepare_data(self, message: dict) -> dict: + return { + "values": ModParser.parse_many(message["data"]), + "meta": message["meta"], + } + + +class MapApiConnector(VaultsApiConnector): + def __init__(self) -> None: + super().__init__("/data/map") + + def _extend_query_options(self, query_options: dict) -> dict: + super()._extend_query_options(query_options) + self._extend_includes(query_options, ["author"]) + + def prepare_data(self, message: dict) -> None: + return { + "values": MapParser.parse_many(message["data"]), + "meta": message["meta"], + } + + +class MapPoolApiConnector(VaultsApiConnector): + def __init__(self) -> None: + super().__init__("/data/mapPoolAssignment") + self._includes = ( + "mapVersion", + "mapVersion.map", + "mapVersion.map.author", + "mapVersion.map.reviewsSummary", + ) + + def _extend_query_options(self, query_options: dict) -> dict: + self._add_default_includes(query_options) + return query_options + + def prepare_data(self, message: dict) -> None: + return { + "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]), + "meta": message["meta"], + } diff --git a/src/chat/__init__.py b/src/chat/__init__.py index 5dcf84b69..745895151 100644 --- a/src/chat/__init__.py +++ b/src/chat/__init__.py @@ -1,35 +1,29 @@ - - -# Initialize logging system -import logging -logger = logging.getLogger(__name__) - -IRC_ELEVATION = '%@~%+&' - - -def user2name(user): - return (user.split('!')[0]).strip(IRC_ELEVATION) - - -def parse_irc_source(src): - """ - :param src: IRC source argument - :return: (username, id, elevation, hostname) - """ - username, tail = src.split('!') - if username[0] in IRC_ELEVATION: - elevation, username = username[0], username[1:] - else: - elevation = None - id, hostname = tail.split('@') - try: - id = int(id) - except ValueError: - id = -1 - return username, id, elevation, hostname - - -from ._chatwidget import ChatWidget - -# CAVEAT: DO NOT REMOVE! These are promoted widgets and py2exe wouldn't include them otherwise +# CAVEAT: DO NOT REMOVE! These are promoted widgets and py2exe wouldn't +# include them otherwise from chat.chatlineedit import ChatLineEdit +from chat.chatterlistview import ChatterListView + +__all__ = ( + "ChatLineEdit", + "ChatterListView", +) + + +class ChatMVC: + def __init__( + self, model, line_metadata_builder, connection, controller, + autojoiner, restorer, greeter, announcer, view, + ): + self.model = model + self.line_metadata_builder = line_metadata_builder + self.connection = connection + self.controller = controller + # Technically part of controller? + self.autojoiner = autojoiner + # Ditto, also don't confuse with the other Restorer + self.restorer = restorer + # Ditto + self.announcer = announcer + # Ditto + self.greeter = greeter + self.view = view diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py index e7060644a..9e9e61dd1 100644 --- a/src/chat/_avatarWidget.py +++ b/src/chat/_avatarWidget.py @@ -1,207 +1,105 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest - -import base64, zlib, os -import util - - -class PlayerAvatar(QtWidgets.QDialog): - def __init__(self, users=[], idavatar=0, parent=None, *args, **kwargs): - QtWidgets.QDialog.__init__(self, *args, **kwargs) - - self.parent = parent - self.users = users - self.checkBox = {} - self.idavatar = idavatar - - self.setStyleSheet(self.parent.styleSheet()) - - self.grid = QtWidgets.QGridLayout(self) - self.userlist = None - - self.removeButton = QtWidgets.QPushButton("&Remove users") - self.grid.addWidget(self.removeButton, 1, 0) - - self.removeButton.clicked.connect(self.remove_them) - - self.setWindowTitle("Users using this avatar") - self.resize(480, 320) - - def process_list(self, users, idavatar): - self.checkBox = {} - self.users = users - self.idavatar = idavatar - self.userlist = self.create_user_selection() - self.grid.addWidget(self.userlist, 0, 0) - - def remove_them(self): - for user in self.checkBox : - if self.checkBox[user].checkState() == 2: - self.parent.lobby_connection.send(dict(command="admin", action="remove_avatar", iduser=user, idavatar=self.idavatar)) - self.close() - - def create_user_selection(self): - groupBox = QtWidgets.QGroupBox("Select the users you want to remove this avatar :") - vbox = QtWidgets.QVBoxLayout() - - for user in self.users: - self.checkBox[user["iduser"]] = QtWidgets.QCheckBox(user["login"]) - vbox.addWidget(self.checkBox[user["iduser"]]) - - vbox.addStretch(1) - groupBox.setLayout(vbox) - - return groupBox - - -class AvatarWidget(QtWidgets.QDialog): - def __init__(self, parent, user, personal=False, *args, **kwargs): - - QtWidgets.QDialog.__init__(self, *args, **kwargs) - - self.user = user - self.personal = personal - self.parent = parent - - self.setStyleSheet(self.parent.styleSheet()) - self.setWindowTitle("Avatar manager") - - self.groupLayout = QtWidgets.QVBoxLayout(self) - self.avatarList = QtWidgets.QListWidget() - - self.avatarList.setWrapping(1) - self.avatarList.setSpacing(5) - self.avatarList.setResizeMode(1) - - self.groupLayout.addWidget(self.avatarList) - - if not self.personal: - self.addAvatarButton = QtWidgets.QPushButton("Add/Edit avatar") - self.addAvatarButton.clicked.connect(self.add_avatar) - self.groupLayout.addWidget(self.addAvatarButton) - - self.item = [] - self.parent.lobby_info.avatarList.connect(self.avatar_list) - self.parent.lobby_info.playerAvatarList.connect(self.do_player_avatar_list) - - self.playerList = PlayerAvatar(parent=self.parent) - - self.nams = {} - self.avatars = {} - - self.finished.connect(self.cleaning) - - def showEvent(self, event): - self.parent.requestAvatars(self.personal) - - def add_avatar(self): - - options = QtWidgets.QFileDialog.Options() - options |= QtWidgets.QFileDialog.DontUseNativeDialog - - fileName = QtWidgets.QFileDialog.getOpenFileName(self, "Select the PNG file", "", "png Files (*.png)", options) - if fileName: - # check the properties of that file - pixmap = QtGui.QPixmap(fileName) - if pixmap.height() == 20 and pixmap.width() == 40: - - text, ok = QtWidgets.QInputDialog.getText(self, "Avatar description", - "Please enter the tooltip :", QtWidgets.QLineEdit.Normal, "") - - if ok and text != '': - - file = QtCore.QFile(fileName) - file.open(QtCore.QIODevice.ReadOnly) - fileDatas = base64.b64encode(zlib.compress(file.readAll())) - file.close() - - self.parent.lobby_connection.send(dict(command="avatar", action="upload_avatar", - name=os.path.basename(fileName), description=text, - file=fileDatas)) - - else: - QtWidgets.QMessageBox.warning(self, "Bad image", "The image must be in png, format is 40x20 !") - - def finish_request(self, reply): - - if reply.url().toString() in self.avatars: - img = QtGui.QImage() - img.loadFromData(reply.readAll()) - pix = QtGui.QPixmap(img) - self.avatars[reply.url().toString()].setIcon(QtGui.QIcon(pix)) - self.avatars[reply.url().toString()].setIconSize(pix.rect().size()) - - util.addrespix(reply.url().toString(), QtGui.QPixmap(img)) - - def clicked(self): - self.doit(None) - self.close() - - def create_connect(self, x): - return lambda: self.doit(x) - - def doit(self, val): - if self.personal: - self.parent.lobby_connection.send(dict(command="avatar", action="select", avatar=val)) - self.close() - - else: - if self.user is None: - self.parent.lobby_connection.send(dict(command="admin", action="list_avatar_users", avatar=val)) +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QSize +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QListWidgetItem +from PyQt6.QtWidgets import QPushButton + +from downloadManager import DownloadRequest + + +class AvatarWidget(QObject): + def __init__( + self, parent_widget, lobby_connection, lobby_info, avatar_dler, theme, + ): + QObject.__init__(self, parent_widget) + + self._parent_widget = parent_widget + self._lobby_connection = lobby_connection + self._lobby_info = lobby_info + self._avatar_dler = avatar_dler + + self.items = {} + self.requests = {} + self.buttons = {} + + self.set_theme(theme) + + self._lobby_info.avatarList.connect(self.set_avatar_list) + self.base.finished.connect(self.clean) + + @classmethod + def builder( + cls, parent_widget, lobby_connection, lobby_info, avatar_dler, + theme, **kwargs, + ): + return lambda: cls( + parent_widget, lobby_connection, lobby_info, avatar_dler, theme, + ) + + def set_theme(self, theme): + formc, basec = theme.loadUiType("dialogs/avatar.ui") + self.form = formc() + self.base = basec(self._parent_widget) + self.form.setupUi(self.base) + + @property + def avatar_list(self): + return self.form.avatarList + + def show(self): + self._lobby_connection.send({ + "command": "avatar", + "action": "list_avatar", + }) + self.base.show() + + def select_avatar(self, val): + self._lobby_connection.send({ + "command": "avatar", + "action": "select", + "avatar": val, + }) + self.base.close() + + def set_avatar_list(self, avatars): + self.avatar_list.clear() + + self._add_avatar_item(None) + for avatar in avatars: + self._add_avatar_item(avatar) + url = avatar["url"] + icon = self._avatar_dler.avatars.get(url, None) + if icon is not None: + self._set_avatar_icon(url, icon) else: - self.parent.lobby_connection.send(dict(command="admin", action="add_avatar", user=self.user, avatar=val)) - self.close() - - def do_player_avatar_list(self, message): - self.playerList = PlayerAvatar(parent=self.parent) - player_avatar_list = message["player_avatar_list"] - idavatar = message["avatar_id"] - self.playerList.process_list(player_avatar_list, idavatar) - self.playerList.show() - - def avatar_list(self, avatar_list): - self.avatarList.clear() - button = QtWidgets.QPushButton() - self.avatars["None"] = button - - item = QtWidgets.QListWidgetItem() - item.setSizeHint(QtCore.QSize(40,20)) - - self.item.append(item) - - self.avatarList.addItem(item) - self.avatarList.setItemWidget(item, button) - - button.clicked.connect(self.clicked) - - for avatar in avatar_list: - - avatarPix = util.respix(avatar["url"]) - button = QtWidgets.QPushButton() - - button.clicked.connect(self.create_connect(avatar["url"])) - - item = QtWidgets.QListWidgetItem() - item.setSizeHint(QtCore.QSize(40, 20)) - self.item.append(item) + req = DownloadRequest() + req.done.connect(self._handle_avatar_download) + self.requests[url] = req + self._avatar_dler.download_avatar(url, req) + + def _add_avatar_item(self, avatar): + val = None if avatar is None else avatar["url"] + button = QPushButton() + button.clicked.connect(lambda: self.select_avatar(val)) + self.buttons[val] = button + if avatar is not None: + button.setToolTip(avatar["tooltip"]) - self.avatarList.addItem(item) + item = QListWidgetItem() + item.setSizeHint(QSize(40, 20)) + self.items[val] = item - button.setToolTip(avatar["tooltip"]) - url = QtCore.QUrl(avatar["url"]) - self.avatars[avatar["url"]] = button + self.avatar_list.addItem(item) + self.avatar_list.setItemWidget(item, button) - self.avatarList.setItemWidget(item, self.avatars[avatar["url"]]) + def _set_avatar_icon(self, val, icon): + button = self.buttons[val] + button.setIcon(QIcon(icon)) + button.setIconSize(icon.rect().size()) - if not avatarPix: - self.nams[url] = QNetworkAccessManager(button) - self.nams[url].finished.connect(self.finish_request) - self.nams[url].get(QNetworkRequest(url)) - else: - self.avatars[avatar["url"]].setIcon(QtGui.QIcon(avatarPix)) - self.avatars[avatar["url"]].setIconSize(avatarPix.rect().size()) + def _handle_avatar_download(self, url, icon): + del self.requests[url] + self._set_avatar_icon(url, icon) - def cleaning(self): - if self != self.parent.avatarAdmin: - self.parent.lobby_info.avatarList.disconnect(self.avatar_list) - self.parent.lobby_info.playerAvatarList.disconnect(self.do_player_avatar_list) + def clean(self): + self.setParent(None) # let ourselves get GC'd diff --git a/src/chat/_chatwidget.py b/src/chat/_chatwidget.py deleted file mode 100644 index 29625c2c4..000000000 --- a/src/chat/_chatwidget.py +++ /dev/null @@ -1,552 +0,0 @@ -import logging - -logger = logging.getLogger(__name__) - -from PyQt5 import QtWidgets, QtCore, QtGui -from PyQt5.QtNetwork import QNetworkAccessManager -from PyQt5.QtCore import QSocketNotifier, QTimer - -from config import Settings, defaults -import util - -import re -import sys -import chat -from chat import user2name, parse_irc_source -from chat.channel import Channel -from chat.irclib import SimpleIRCClient, ServerConnectionError - -from model.ircuserset import IrcUserset -from model.ircuser import IrcUser - -PONG_INTERVAL = 60000 # milliseconds between pongs - -FormClass, BaseClass = util.THEME.loadUiType("chat/chat.ui") - - -class ChatWidget(FormClass, BaseClass, SimpleIRCClient): - - use_chat = Settings.persisted_property('chat/enabled', type=bool, default_value=True) - irc_port = Settings.persisted_property('chat/port', type=int, default_value=6667) - irc_host = Settings.persisted_property('chat/host', type=str, default_value='irc.' + defaults['host']) - irc_tls = Settings.persisted_property('chat/tls', type=bool, default_value=False) - - auto_join_channels = Settings.persisted_property('chat/auto_join_channels', default_value=[]) - - """ - This is the chat lobby module for the FAF client. - It manages a list of channels and dispatches IRC events (lobby inherits from irclib's client class) - """ - - def __init__(self, client, playerset, me, *args, **kwargs): - if not self.use_chat: - logger.info("Disabling chat") - return - - logger.debug("Lobby instantiating.") - BaseClass.__init__(self, *args, **kwargs) - SimpleIRCClient.__init__(self) - - self.setupUi(self) - - self.client = client - self._me = me - self._chatters = IrcUserset(playerset) - self.channels = {} - - # avatar downloader - self.nam = QNetworkAccessManager() - self.nam.finished.connect(self.finish_download_avatar) - - # nickserv stuff - self.identified = False - - # IRC parameters - self.crucialChannels = ["#aeolus"] - self.optionalChannels = [] - - # We can't send command until the welcome message is received - self.welcomed = False - - # Load colors and styles from theme - self.a_style = util.THEME.readfile("chat/formatters/a_style.qss") - - # load UI perform some tweaks - self.tabBar().setTabButton(0, 1, None) - - self.tabCloseRequested.connect(self.close_channel) - - # Hook with client's connection and autojoin mechanisms - self.client.authorized.connect(self.connect) - self.client.autoJoin.connect(self.auto_join) - self.channelsAvailable = [] - - self._notifier = None - self._timer = QTimer() - self._timer.timeout.connect(self.once) - - # disconnection checks - self.canDisconnect = False - - def disconnect(self): - self.canDisconnect = True - try: - self.irc_disconnect() - except ServerConnectionError: - pass - if self._notifier: - self._notifier.activated.disconnect(self.once) - self._notifier = None - - @QtCore.pyqtSlot(object) - def connect(self, me): - try: - logger.info("Connecting to IRC at: {}:{}. TLS: {}".format(self.irc_host, self.irc_port, self.irc_tls)) - self.irc_connect(self.irc_host, - self.irc_port, - me.login, - ssl=self.irc_tls, - ircname=me.login, - username=me.id) - self._notifier = QSocketNotifier(self.ircobj.connections[0]._get_socket().fileno(), QSocketNotifier.Read, self) - self._notifier.activated.connect(self.once) - self._timer.start(PONG_INTERVAL) - - except: - logger.debug("Unable to connect to IRC server.") - self.serverLogArea.appendPlainText("Unable to connect to the chat server, but you should still be able to host and join games.") - logger.error("IRC Exception", exc_info=sys.exc_info()) - - def finish_download_avatar(self, reply): - """ this take care of updating the avatars of players once they are downloaded """ - img = QtGui.QImage() - img.loadFromData(reply.readAll()) - url = reply.url().toString() - if not util.respix(url): - util.addrespix(url, QtGui.QPixmap(img)) - - for chatter in util.curDownloadAvatar(url): - # FIXME - hack to prevent touching chatter if it was removed - channel = chatter.channel - ircuser = chatter.user - if ircuser in channel.chatters: - chatter.update_avatar() - util.delDownloadAvatar(url) - - def add_channel(self, name, channel, index = None): - self.channels[name] = channel - if index is None: - self.addTab(self.channels[name], name) - else: - self.insertTab(index, self.channels[name], name) - - def sort_channels(self): - for channel in self.channels.values(): - channel.sort_chatters() - - def update_channels(self): - for channel in self.channels.values(): - channel.update_chatters() - - def close_channel(self, index): - """ - Closes a channel tab. - """ - channel = self.widget(index) - for name in self.channels: - if self.channels[name] is channel: - if not self.channels[name].private and self.connection.is_connected(): # Channels must be parted (if still connected) - self.connection.part([name], "tab closed") - else: - # Queries and disconnected channel windows can just be closed - self.removeTab(index) - del self.channels[name] - - break - - @QtCore.pyqtSlot(str) - def announce(self, broadcast): - """ - Notifies all crucial channels about the status of the client. - """ - logger.debug("BROADCAST:" + broadcast) - for channel in self.crucialChannels: - self.send_msg(channel, broadcast) - - def set_topic(self, chan, topic): - self.connection.topic(chan, topic) - - def send_msg(self, target, text): - if self.connection.is_connected(): - self.connection.privmsg(target, text) - return True - else: - logger.error("IRC connection lost.") - for channel in self.crucialChannels: - if channel in self.channels: - self.channels[channel].print_raw("Server", "IRC is disconnected") - return False - - def send_action(self, target, text): - if self.connection.is_connected(): - self.connection.action(target, text) - return True - else: - logger.error("IRC connection lost.") - for channel in self.crucialChannels: - if channel in self.channels: - self.channels[channel].print_action("IRC", "was disconnected.") - return False - - def open_query(self, chatter, activate=False): - # Ignore ourselves. - if chatter.name == self.client.login: - return False - - if chatter.name not in self.channels: - priv_chan = Channel(self, chatter.name, self._chatters, self._me, True) - self.add_channel(chatter.name, priv_chan) - - # Add participants to private channel - priv_chan.add_chatter(chatter) - - if self.client.me.login is not None: - my_login = self.client.me.login - if my_login in self._chatters: - priv_chan.add_chatter(self._chatters[my_login]) - - if activate: - self.setCurrentWidget(priv_chan) - - return True - - @QtCore.pyqtSlot(list) - def auto_join(self, channels): - for channel in channels: - if channel in self.channels: - continue - if (self.connection.is_connected()) and self.welcomed: - # directly join - self.connection.join(channel) - else: - # Note down channels for later. - self.optionalChannels.append(channel) - - def join(self, channel): - if channel not in self.channels: - self.connection.join(channel) - - def log_event(self, e): - self.serverLogArea.appendPlainText("[%s: %s->%s]" % (e.eventtype(), e.source(), e.target()) + "\n".join(e.arguments())) - - def should_ignore(self, chatter): - # Don't ignore mods from any crucial channels - if any(chatter.is_mod(c) for c in self.crucialChannels): - return False - if chatter.player is None: - return self.client.me.isFoe(name=chatter.name) - else: - return self.client.me.isFoe(id_=chatter.player.id) - -# SimpleIRCClient Class Dispatcher Attributes follow here. - def on_welcome(self, c, e): - self.log_event(e) - self.welcomed = True - - def nickserv_identify(self): - if not self.identified: - self.serverLogArea.appendPlainText("[Identify as : %s]" % self.client.login) - self.connection.privmsg('NickServ', 'identify %s %s' % (self.client.login, util.md5text(self.client.password))) - - def on_identified(self): - if self.connection.get_nickname() != self.client.login : - self.serverLogArea.appendPlainText("[Retrieving our nickname : %s]" % (self.client.login)) - self.connection.privmsg('NickServ', 'recover %s %s' % (self.client.login, util.md5text(self.client.password))) - # Perform any pending autojoins (client may have emitted autoJoin signals before we talked to the IRC server) - self.auto_join(self.optionalChannels) - self.auto_join(self.crucialChannels) - self.auto_join(self.auto_join_channels) - self._schedule_actions_at_player_available() - - def _schedule_actions_at_player_available(self): - self._me.playerAvailable.connect(self._at_player_available) - if self._me.player is not None: - self._at_player_available() - - def _at_player_available(self): - self._me.playerAvailable.disconnect(self._at_player_available) - self._autojoin_newbie_channel() - - def _autojoin_newbie_channel(self): - if not self.client.useNewbiesChannel: - return - game_number_threshold = 50 - if self.client.me.player.number_of_games <= game_number_threshold: - self.auto_join(["#newbie"]) - - def nickserv_register(self): - if hasattr(self, '_nickserv_registered'): - return - self.connection.privmsg('NickServ', 'register %s %s' % (util.md5text(self.client.password), '{}@users.faforever.com'.format(self.client.me.login))) - self._nickserv_registered = True - self.auto_join(self.optionalChannels) - self.auto_join(self.crucialChannels) - - def on_version(self, c, e): - self.connection.privmsg(e.source(), "Forged Alliance Forever " + util.VERSION_STRING) - - def on_motd(self, c, e): - self.log_event(e) - self.nickserv_identify() - - def on_endofmotd(self, c, e): - self.log_event(e) - - def on_namreply(self, c, e): - self.log_event(e) - channel = e.arguments()[1] - listing = e.arguments()[2].split() - - for user in listing: - name = user.strip(chat.IRC_ELEVATION) - elevation = user[0] if user[0] in chat.IRC_ELEVATION else None - hostname = '' - self._add_chatter(name, hostname) - self._add_chatter_channel(self._chatters[name], elevation, - channel, False) - - logger.debug("Added " + str(len(listing)) + " Chatters") - - def _add_chatter(self, name, hostname): - if name not in self._chatters: - self._chatters[name] = IrcUser(name, hostname) - else: - self._chatters[name].update(hostname=hostname) - - def _remove_chatter(self, name): - if name not in self._chatters: - return - del self._chatters[name] - # Channels listen to 'chatter removed' signal on their own - - def _add_chatter_channel(self, chatter, elevation, channel, join): - chatter.set_elevation(channel, elevation) - self.channels[channel].add_chatter(chatter, join) - - def _remove_chatter_channel(self, chatter, channel, msg): - chatter.set_elevation(channel, None) - self.channels[channel].remove_chatter(msg) - - def on_whoisuser(self, c, e): - self.log_event(e) - - def on_join(self, c, e): - channel = e.target() - - # If we're joining, we need to open the channel for us first. - if channel not in self.channels: - newch = Channel(self, channel, self._chatters, self._me) - if channel.lower() in self.crucialChannels: - self.add_channel(channel, newch, 1) # CAVEAT: This is assumes a server tab exists. - self.client.localBroadcast.connect(newch.print_raw) - newch.print_announcement("Welcome to Forged Alliance Forever!", "red", "+3") - wiki_link = Settings.get("WIKI_URL") - wiki_msg = "Check out the wiki: {} for help with common issues.".format(wiki_link) - newch.print_announcement(wiki_msg, "white", "+1") - newch.print_announcement("", "black", "+1") - newch.print_announcement("", "black", "+1") - else: - self.add_channel(channel, newch) - - if channel.lower() in self.crucialChannels: # Make the crucial channels not closeable, and make the last one the active one - self.setCurrentWidget(self.channels[channel]) - self.tabBar().setTabButton(self.currentIndex(), QtWidgets.QTabBar.RightSide, None) - - name, _id, elevation, hostname = parse_irc_source(e.source()) - self._add_chatter(name, hostname) - self._add_chatter_channel(self._chatters[name], elevation, - channel, True) - - def on_part(self, c, e): - channel = e.target() - name = user2name(e.source()) - if name not in self._chatters: - return - chatter = self._chatters[name] - - if name == self.client.login: # We left ourselves. - self.removeTab(self.indexOf(self.channels[channel])) - del self.channels[channel] - else: # Someone else left - self._remove_chatter_channel(chatter, channel, "left.") - - def on_quit(self, c, e): - name = user2name(e.source()) - self._remove_chatter(name) - - def on_nick(self, c, e): - oldnick = user2name(e.source()) - newnick = e.target() - if oldnick not in self._chatters: - return - - self._chatters[oldnick].update(name=newnick) - self.log_event(e) - - def on_mode(self, c, e): - if e.target() not in self.channels: - return - if len(e.arguments()) < 2: - return - name = user2name(e.arguments()[1]) - if name not in self._chatters: - return - chatter = self._chatters[name] - - self.elevate_chatter(chatter, e.target(), e.arguments()[0]) - - def elevate_chatter(self, chatter, channel, modes): - add = re.compile(".*\+([a-z]+)") - remove = re.compile(".*\-([a-z]+)") - - addmatch = re.search(add, modes) - if addmatch: - modes = addmatch.group(1) - mode = None - if "v" in modes: - mode = "+" - if "o" in modes: - mode = "@" - if "q" in modes: - mode = "~" - if mode is not None: - chatter.set_elevation(channel, mode) - - removematch = re.search(remove, modes) - if removematch: - modes = removematch.group(1) - el = chatter.elevation[channel] - chatter_mode = {"@": "o", "~": "q", "+": "v"}[el] - if chatter_mode in modes: - chatter.set_elevation(channel, None) - - def on_umode(self, c, e): - self.log_event(e) - - def on_notice(self, c, e): - self.log_event(e) - - def on_topic(self, c, e): - channel = e.target() - if channel in self.channels: - self.channels[channel].set_announce_text(" ".join(e.arguments())) - - def on_currenttopic(self, c, e): - channel = e.arguments()[0] - if channel in self.channels: - self.channels[channel].set_announce_text(" ".join(e.arguments()[1:])) - - def on_topicinfo(self, c, e): - self.log_event(e) - - def on_list(self, c, e): - self.log_event(e) - - def on_bannedfromchan(self, c, e): - self.log_event(e) - - def on_pubmsg(self, c, e): - name, id, elevation, hostname = parse_irc_source(e.source()) - target = e.target() - if name not in self._chatters or target not in self.channels: - return - - if not self.should_ignore(self._chatters[name]): - self.channels[target].print_msg(name, "\n".join(e.arguments())) - - def on_privnotice(self, c, e): - source = user2name(e.source()) - notice = e.arguments()[0] - prefix = notice.split(" ")[0] - target = prefix.strip("[]") - - if source and source.lower() == 'nickserv': - if notice.find("registered under your account") >= 0 or \ - notice.find("Password accepted") >= 0: - if not self.identified: - self.identified = True - self.on_identified() - - elif notice.find("isn't registered") >= 0: - self.nickserv_register() - - elif notice.find("RELEASE") >= 0: - self.connection.privmsg('nickserv', 'release %s %s' % (self.client.login, util.md5text(self.client.password))) - - elif notice.find("hold on") >= 0: - self.connection.nick(self.client.login) - - message = "\n".join(e.arguments()).lstrip(prefix) - if target in self.channels: - self.channels[target].print_msg(source, message) - elif source == "Global": - for channel in self.channels: - if not channel in self.crucialChannels: - continue - self.channels[channel].print_announcement(message, "yellow", "+2") - elif source == "AeonCommander": - for channel in self.channels: - if not channel in self.crucialChannels: - continue - self.channels[channel].print_msg(source, message) - else: - self.serverLogArea.appendPlainText("%s: %s" % (source, notice)) - - def on_disconnect(self, c, e): - if not self.canDisconnect: - logger.warning("IRC disconnected - reconnecting.") - self.serverLogArea.appendPlainText("IRC disconnected - reconnecting.") - self.identified = False - self._timer.stop() - self.connect(self.client.me) - - def on_privmsg(self, c, e): - name, id, elevation, hostname = parse_irc_source(e.source()) - if name not in self._chatters: - return - chatter = self._chatters[name] - - if self.should_ignore(chatter): - return - - # Create a Query if it's not open yet, and post to it if it exists. - if self.open_query(chatter): - self.channels[name].print_msg(name, "\n".join(e.arguments())) - - def on_action(self, c, e): - name, id, elevation, hostname = parse_irc_source(e.source()) - if name not in self._chatters: - return - chatter = self._chatters[name] - target = e.target() - - if self.should_ignore(chatter): - return - - # Create a Query if it's not an action intended for a channel - if target not in self.channels: - self.open_query(chatter) - self.channels[name].print_action(name, "\n".join(e.arguments())) - else: - self.channels[target].print_action(name, "\n".join(e.arguments())) - - def on_nosuchnick(self, c, e): - self.nickserv_register() - - def on_default(self, c, e): - self.serverLogArea.appendPlainText("[%s: %s->%s]" % (e.eventtype(), e.source(), e.target()) + "\n".join(e.arguments())) - if "Nickname is already in use." in "\n".join(e.arguments()): - self.connection.nick(self.client.login + "_") - - def on_kick(self, c, e): - pass diff --git a/src/chat/channel.py b/src/chat/channel.py deleted file mode 100644 index 1b51d95b3..000000000 --- a/src/chat/channel.py +++ /dev/null @@ -1,498 +0,0 @@ -from fa.replay import replay - -import util -from PyQt5 import QtWidgets, QtCore, QtGui -import time -from chat import logger -from chat.chatter import Chatter -import re -import json - -QUERY_BLINK_SPEED = 250 -CHAT_TEXT_LIMIT = 350 -CHAT_REMOVEBLOCK = 50 - -FormClass, BaseClass = util.THEME.loadUiType("chat/channel.ui") - - -class IRCPlayer(): - def __init__(self, name): - self.name = name - self.id = -1 - self.clan = None - - -class Formatters(object): - FORMATTER_ANNOUNCEMENT = str(util.THEME.readfile("chat/formatters/announcement.qthtml")) - FORMATTER_MESSAGE = str(util.THEME.readfile("chat/formatters/message.qthtml")) - FORMATTER_MESSAGE_AVATAR = str(util.THEME.readfile("chat/formatters/messageAvatar.qthtml")) - FORMATTER_ACTION = str(util.THEME.readfile("chat/formatters/action.qthtml")) - FORMATTER_ACTION_AVATAR = str(util.THEME.readfile("chat/formatters/actionAvatar.qthtml")) - FORMATTER_RAW = str(util.THEME.readfile("chat/formatters/raw.qthtml")) - NICKLIST_COLUMNS = json.loads(util.THEME.readfile("chat/formatters/nicklist_columns.json")) - - @classmethod - def convert_to_no_avatar(cls, formatter): - if formatter == cls.FORMATTER_MESSAGE_AVATAR: - return cls.FORMATTER_MESSAGE - if formatter == cls.FORMATTER_ACTION_AVATAR: - return cls.FORMATTER_ACTION - return formatter - - -# Helper class to schedule single event loop calls. -class ScheduledCall(QtCore.QObject): - _call = QtCore.pyqtSignal() - - def __init__(self, fn): - QtCore.QObject.__init__(self) - self._fn = fn - self._called = False - self._call.connect(self._run_call, QtCore.Qt.QueuedConnection) - - def schedule_call(self): - if self._called: - return - self._called = True - self._call.emit() - - def _run_call(self): - self._called = False - self._fn() - - -class Channel(FormClass, BaseClass): - """ - This is an actual chat channel object, representing an IRC chat room and the users currently present. - """ - def __init__(self, chat_widget, name, chatterset, me, private=False): - BaseClass.__init__(self, chat_widget) - - self.setupUi(self) - - # Special HTML formatter used to layout the chat lines written by people - self.chat_widget = chat_widget - self.chatters = {} - self.items = {} - self._chatterset = chatterset - self._me = me - chatterset.userRemoved.connect(self._check_user_quit) - - self.last_timestamp = None - - # Query flasher - self.blinker = QtCore.QTimer() - self.blinker.timeout.connect(self.blink) - self.blinked = False - - # Table width of each chatter's name cell... - self.max_chatter_width = 100 # TODO: This might / should auto-adapt - - # count the number of line currently in the chat - self.lines = 0 - - # Perform special setup for public channels as opposed to private ones - self.name = name - self.private = private - - self.sort_call = ScheduledCall(self.sort_chatters) - - if not self.private: - # Properly and snugly snap all the columns - self.nickList.horizontalHeader().setSectionResizeMode(Chatter.RANK_COLUMN, QtWidgets.QHeaderView.Fixed) - self.nickList.horizontalHeader().resizeSection(Chatter.RANK_COLUMN, Formatters.NICKLIST_COLUMNS['RANK']) - - self.nickList.horizontalHeader().setSectionResizeMode(Chatter.AVATAR_COLUMN, QtWidgets.QHeaderView.Fixed) - self.nickList.horizontalHeader().resizeSection(Chatter.AVATAR_COLUMN, Formatters.NICKLIST_COLUMNS['AVATAR']) - - self.nickList.horizontalHeader().setSectionResizeMode(Chatter.STATUS_COLUMN, QtWidgets.QHeaderView.Fixed) - self.nickList.horizontalHeader().resizeSection(Chatter.STATUS_COLUMN, Formatters.NICKLIST_COLUMNS['STATUS']) - - self.nickList.horizontalHeader().setSectionResizeMode(Chatter.MAP_COLUMN, QtWidgets.QHeaderView.Fixed) - self.resize_map_column() # The map column can be toggled. Make sure it respects the settings - - self.nickList.horizontalHeader().setSectionResizeMode(Chatter.SORT_COLUMN, QtWidgets.QHeaderView.Stretch) - - self.nickList.itemDoubleClicked.connect(self.nick_double_clicked) - self.nickList.itemPressed.connect(self.nick_pressed) - - self.nickFilter.textChanged.connect(self.filter_nicks) - - else: - self.nickFrame.hide() - self.announceLine.hide() - - self.chatArea.anchorClicked.connect(self.open_url) - self.chatEdit.returnPressed.connect(self.send_line) - self.chatEdit.set_chatters(self.chatters) - - def sort_chatters(self): - self.nickList.sortItems(Chatter.SORT_COLUMN) - - def join_channel(self, index): - """ join another channel """ - channel = self.channelsComboBox.itemText(index) - if channel.startswith('#'): - self.chat_widget.auto_join([channel]) - - def keyReleaseEvent(self, keyevent): - """ - Allow the ctrl-C event. - """ - if keyevent.key() == 67: - self.chatArea.copy() - - def resizeEvent(self, size): - BaseClass.resizeEvent(self, size) - self.set_text_width() - - def set_text_width(self): - self.chatArea.setLineWrapColumnOrWidth(self.chatArea.size().width() - 20) # Hardcoded, but seems to be enough (tabstop was a bit large) - - def showEvent(self, event): - self.stop_blink() - self.set_text_width() - return BaseClass.showEvent(self, event) - - @QtCore.pyqtSlot() - def clearWindow(self): - if self.isVisible(): - self.chatArea.setPlainText("") - self.last_timestamp = 0 - - @QtCore.pyqtSlot() - def filter_nicks(self): - for chatter in self.chatters.values(): - chatter.set_visible(chatter.is_filtered(self.nickFilter.text().lower())) - - def update_user_count(self): - count = len(self.chatters) - self.nickFilter.setPlaceholderText(str(count) + " users... (type to filter)") - - if self.nickFilter.text(): - self.filter_nicks() - - @QtCore.pyqtSlot() - def blink(self): - if self.blinked: - self.blinked = False - self.chat_widget.tabBar().setTabText(self.chat_widget.indexOf(self), self.name) - else: - self.blinked = True - self.chat_widget.tabBar().setTabText(self.chat_widget.indexOf(self), "") - - @QtCore.pyqtSlot() - def stop_blink(self): - self.blinker.stop() - self.chat_widget.tabBar().setTabText(self.chat_widget.indexOf(self), self.name) - - @QtCore.pyqtSlot() - def start_blink(self): - self.blinker.start(QUERY_BLINK_SPEED) - - @QtCore.pyqtSlot() - def ping_window(self): - QtWidgets.QApplication.alert(self.chat_widget.client) - - if not self.isVisible() or QtWidgets.QApplication.activeWindow() != self.chat_widget.client: - if self.one_minute_or_older(): - if self.chat_widget.client.soundeffects: - util.THEME.sound("chat/sfx/query.wav") - - if not self.isVisible(): - if not self.blinker.isActive() and not self == self.chat_widget.currentWidget(): - self.start_blink() - - @QtCore.pyqtSlot(QtCore.QUrl) - def open_url(self, url): - logger.debug("Clicked on URL: " + url.toString()) - if url.scheme() == "faflive": - replay(url) - elif url.scheme() == "fafgame": - self.chat_widget.client.joinGameFromURL(url) - else: - QtGui.QDesktopServices.openUrl(url) - - def print_announcement(self, text, color, size, scroll_forced=True): - # scroll if close to the last line of the log - scroll_current = self.chatArea.verticalScrollBar().value() - scroll_needed = scroll_forced or ((self.chatArea.verticalScrollBar().maximum() - scroll_current) < 20) - - cursor = self.chatArea.textCursor() - cursor.movePosition(QtGui.QTextCursor.End) - self.chatArea.setTextCursor(cursor) - - formatter = Formatters.FORMATTER_ANNOUNCEMENT - line = formatter.format(size=size, color=color, text=util.irc_escape(text, self.chat_widget.a_style)) - self.chatArea.insertHtml(line) - - if scroll_needed: - self.chatArea.verticalScrollBar().setValue(self.chatArea.verticalScrollBar().maximum()) - else: - self.chatArea.verticalScrollBar().setValue(scroll_current) - - def print_line(self, chname, text, scroll_forced=False, formatter=Formatters.FORMATTER_MESSAGE): - if self.lines > CHAT_TEXT_LIMIT: - cursor = self.chatArea.textCursor() - cursor.movePosition(QtGui.QTextCursor.Start) - cursor.movePosition(QtGui.QTextCursor.Down, QtGui.QTextCursor.KeepAnchor, CHAT_REMOVEBLOCK) - cursor.removeSelectedText() - self.lines = self.lines - CHAT_REMOVEBLOCK - - chatter = self._chatterset.get(chname) - if chatter is not None and chatter.player is not None: - player = chatter.player - else: - player = IRCPlayer(chname) - - displayName = chname - if player.clan is not None: - displayName = "[%s]%s" % (player.clan, chname) - - sender_is_not_me = chatter.name != self._me.login - - # Play a ping sound and flash the title under certain circumstances - mentioned = text.find(self.chat_widget.client.login) != -1 - is_quit_msg = formatter is Formatters.FORMATTER_RAW and text == "quit." - private_msg = self.private and not is_quit_msg - if (mentioned or private_msg) and sender_is_not_me: - self.ping_window() - - avatar = None - avatarTip = "" - if chatter is not None and chatter in self.chatters: - chatwidget = self.chatters[chatter] - color = chatwidget.foreground().color().name() - avatarTip = chatwidget.avatarTip or "" - if chatter.player is not None: - avatar = chatter.player.avatar - if avatar is not None: - avatar = avatar["url"] - else: - # Fallback and ask the client. We have no Idea who this is. - color = self.chat_widget.client.player_colors.getUserColor(player.id) - - if mentioned and sender_is_not_me: - color = self.chat_widget.client.player_colors.getColor("you") - - # scroll if close to the last line of the log - scroll_current = self.chatArea.verticalScrollBar().value() - scroll_needed = scroll_forced or ((self.chatArea.verticalScrollBar().maximum() - scroll_current) < 20) - - cursor = self.chatArea.textCursor() - cursor.movePosition(QtGui.QTextCursor.End) - self.chatArea.setTextCursor(cursor) - - chatter_has_avatar = False - line = None - if avatar is not None: - pix = util.respix(avatar) - if pix: - self._add_avatar_resource_to_chat_area(avatar, pix) - chatter_has_avatar = True - - if not chatter_has_avatar: - formatter = Formatters.convert_to_no_avatar(formatter) - - line = formatter.format(time=self.timestamp(), avatar=avatar, avatarTip=avatarTip, name=displayName, - color=color, width=self.max_chatter_width, text=util.irc_escape(text, self.chat_widget.a_style)) - self.chatArea.insertHtml(line) - self.lines += 1 - - if scroll_needed: - self.chatArea.verticalScrollBar().setValue(self.chatArea.verticalScrollBar().maximum()) - else: - self.chatArea.verticalScrollBar().setValue(scroll_current) - - def _add_avatar_resource_to_chat_area(self, avatar, pic): - doc = self.chatArea.document() - avatar_link = QtCore.QUrl(avatar) - image_enum = QtGui.QTextDocument.ImageResource - if not doc.resource(image_enum, avatar_link): - doc.addResource(image_enum, avatar_link, pic) - - def _chname_has_avatar(self, chname): - if chname not in self._chatterset: - return False - chatter = self._chatterset[chname] - - if chatter.player is None: - return False - if chatter.player.avatar is None: - return False - return True - - def print_msg(self, chname, text, scroll_forced=False): - if self._chname_has_avatar(chname) and not self.private: - fmt = Formatters.FORMATTER_MESSAGE_AVATAR - else: - fmt = Formatters.FORMATTER_MESSAGE - self.print_line(chname, text, scroll_forced, fmt) - - def print_action(self, chname, text, scroll_forced=False, server_action=False): - if server_action: - fmt = Formatters.FORMATTER_RAW - elif self._chname_has_avatar(chname) and not self.private: - fmt = Formatters.FORMATTER_ACTION_AVATAR - else: - fmt = Formatters.FORMATTER_ACTION - self.print_line(chname, text, scroll_forced, fmt) - - def print_raw(self, chname, text, scroll_forced=False): - """ - Print an raw message in the chatArea of the channel - """ - chatter = self._chatterset.get(chname) - try: - _id = chatter.player.id - except AttributeError: - _id = -1 - - color = self.chat_widget.client.player_colors.getUserColor(_id) - - # Play a ping sound - if self.private and chname != self.chat_widget.client.login: - self.ping_window() - - # scroll if close to the last line of the log - scroll_current = self.chatArea.verticalScrollBar().value() - scroll_needed = scroll_forced or ((self.chatArea.verticalScrollBar().maximum() - scroll_current) < 20) - - cursor = self.chatArea.textCursor() - cursor.movePosition(QtGui.QTextCursor.End) - self.chatArea.setTextCursor(cursor) - - formatter = Formatters.FORMATTER_RAW - line = formatter.format(time=self.timestamp(), name=chname, color=color, width=self.max_chatter_width, text=text) - self.chatArea.insertHtml(line) - - if scroll_needed: - self.chatArea.verticalScrollBar().setValue(self.chatArea.verticalScrollBar().maximum()) - else: - self.chatArea.verticalScrollBar().setValue(scroll_current) - - def timestamp(self): - """ returns a fresh timestamp string once every minute, and an empty string otherwise """ - timestamp = time.strftime("%H:%M") - if self.last_timestamp != timestamp: - self.last_timestamp = timestamp - return timestamp - else: - return "" - - def one_minute_or_older(self): - timestamp = time.strftime("%H:%M") - return self.last_timestamp != timestamp - - @QtCore.pyqtSlot(QtWidgets.QTableWidgetItem) - def nick_double_clicked(self, item): - chatter = self.nickList.item(item.row(), Chatter.SORT_COLUMN) # Look up the associated chatter object - chatter.double_clicked(item) - - @QtCore.pyqtSlot(QtWidgets.QTableWidgetItem) - def nick_pressed(self, item): - if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.RightButton: - # Look up the associated chatter object - chatter = self.nickList.item(item.row(), Chatter.SORT_COLUMN) - chatter.pressed(item) - - def update_chatters(self): - """ - Triggers all chatters to update their status. Called when toggling map icon display in settings - """ - for _, chatter in self.chatters.items(): - chatter.update() - - self.resize_map_column() - - def resize_map_column(self): - if util.settings.value("chat/chatmaps", False): - self.nickList.horizontalHeader().resizeSection(Chatter.MAP_COLUMN, Formatters.NICKLIST_COLUMNS['MAP']) - else: - self.nickList.horizontalHeader().resizeSection(Chatter.MAP_COLUMN, 0) - - def add_chatter(self, chatter, join=False): - """ - Adds an user to this chat channel, and assigns an appropriate icon depending on friendship and FAF player status - """ - if chatter not in self.chatters: - item = Chatter(self.nickList, chatter, self, - self.chat_widget, self._me) - self.chatters[chatter] = item - - self.chatters[chatter].update() - - self.update_user_count() - - if join and self.chat_widget.client.joinsparts: - self.print_action(chatter.name, "joined the channel.", server_action=True) - - def remove_chatter(self, chatter, server_action=None): - if chatter in self.chatters: - self.nickList.removeRow(self.chatters[chatter].row()) - del self.chatters[chatter] - - if server_action and (self.chat_widget.client.joinsparts or self.private): - self.print_action(chatter.name, server_action, server_action=True) - self.stop_blink() - - self.update_user_count() - - def verify_sort_order(self, chatter): - row = chatter.row() - next_chatter = self.nickList.item(row + 1, Chatter.SORT_COLUMN) - prev_chatter = self.nickList.item(row - 1, Chatter.SORT_COLUMN) - - if (next_chatter is not None and chatter > next_chatter or - prev_chatter is not None and chatter < prev_chatter): - self.sort_call.schedule_call() - - def set_announce_text(self, text): - self.announceLine.clear() - self.announceLine.setText("" + util.irc_escape(text) + "") - - @QtCore.pyqtSlot() - def send_line(self, target=None): - self.stop_blink() - - if not target: - target = self.name # pubmsg in channel - - line = self.chatEdit.text() - # Split into lines if newlines are present - fragments = line.split("\n") - for text in fragments: - # Compound wacky Whitespace - text = re.sub('\s', ' ', text) - text = text.strip() - - # Reject empty messages - if not text: - continue - - # System commands - if text.startswith("/"): - if text.startswith("/join "): - self.chat_widget.join(text[6:]) - elif text.startswith("/topic "): - self.chat_widget.set_topic(self.name, text[7:]) - elif text.startswith("/msg "): - blobs = text.split(" ") - self.chat_widget.send_msg(blobs[1], " ".join(blobs[2:])) - elif text.startswith("/me "): - if self.chat_widget.send_action(target, text[4:]): - self.print_action(self.chat_widget.client.login, text[4:], True) - else: - self.print_action("IRC", "action not supported", True) - elif text.startswith("/seen "): - if self.chat_widget.send_msg("nickserv", "info %s" % (text[6:])): - self.print_action("IRC", "info requested on %s" % (text[6:]), True) - else: - self.print_action("IRC", "not connected", True) - else: - if self.chat_widget.send_msg(target, text): - self.print_msg(self.chat_widget.client.login, text, True) - self.chatEdit.clear() - - def _check_user_quit(self, chatter): - self.remove_chatter(chatter, 'quit.') diff --git a/src/chat/channel_autojoiner.py b/src/chat/channel_autojoiner.py new file mode 100644 index 000000000..307d7dd1b --- /dev/null +++ b/src/chat/channel_autojoiner.py @@ -0,0 +1,133 @@ +from chat.lang import DEFAULT_LANGUAGE_CHANNELS +from util.lang import COUNTRY_TO_LANGUAGE + + +class ChannelAutojoiner: + DEFAULT_LANGUAGE_CHANNELS = { + "#french": ["fr"], + "#russian": ["ru", "be"], # Be conservative here + "#german": ["de"], + } + # Flip around for easier use + DEFAULT_LANGUAGE_CHANNELS = { + code: channel + for channel, codes in DEFAULT_LANGUAGE_CHANNELS.items() + for code in codes + } + + def __init__( + self, base_channels, model, controller, settings, lobby_info, + chat_config, lang_channel_checker, me, + ): + self.base_channels = base_channels + self._model = model + self._controller = controller + self._settings = settings + self._lobby_info = lobby_info + self._chat_config = chat_config + self._me = me + self._me.playerChanged.connect(self._autojoin_player_dependent) + self._lang_channel_checker = lang_channel_checker + + self._lobby_info.social.connect(self._autojoin_lobby) + self._saved_lobby_channels = [] + + self._model.connect_event.connect(self._autojoin_all) + if self._model.connected: + self._autojoin_all() + + @classmethod + def build( + cls, base_channels, model, controller, settings, lobby_info, + chat_config, me, **kwargs + ): + lang_channel_checker = LanguageChannelChecker(settings) + return cls( + base_channels, model, controller, settings, lobby_info, + chat_config, lang_channel_checker, me, + ) + + def _autojoin_all(self): + self._autojoin_base() + self._autojoin_saved_lobby() + self._autojoin_custom() + self._autojoin_player_dependent() + + def _autojoin_player_dependent(self): + if not self._model.connected: + return + if self._me.player is None: + return + self._autojoin_newbie() + self._autojoin_lang() + + def _join_all(self, channels): + for name in channels: + self._controller.join_public_channel(name) + + def _autojoin_base(self): + self._join_all(self.base_channels) + + def _autojoin_custom(self): + auto_channels = self._settings.get('chat/auto_join_channels', []) + # FIXME - sanity check since QSettings is iffy with lists + if not isinstance(auto_channels, list): + return + self._join_all(auto_channels) + + def _autojoin_lobby(self, message): + channels = message.get("autojoin", None) + if channels is None: + return + if self._model.connected: + self._join_all(channels) + else: + self._saved_lobby_channels = channels + + def _autojoin_saved_lobby(self): + self._join_all(self._saved_lobby_channels) + self._saved_lobby_channels = [] + + def _autojoin_newbie(self): + if not self._chat_config.newbies_channel: + return + threshold = self._chat_config.newbie_channel_game_threshold + if self._me.player.number_of_games > threshold: + return + self._join_all(["#newbie"]) + + def _autojoin_lang(self): + player = self._me.player + self._join_all(self._lang_channel_checker.get_channels(player)) + + +class LanguageChannelChecker: + def __init__(self, settings): + self._settings = settings + + def get_channels(self, player): + if not self._settings.contains('client/lang_channels'): + self._set_default_language_channel(player) + chan = self._settings.get('client/lang_channels') + if chan is None: + return [] + return [c for c in chan.split(';') if c] + + def _set_default_language_channel(self, player): + from_os = self._channel_from_os_language() + from_ip = self._channel_from_geoip(player) + default = from_os or from_ip + if default is None: + return + self._settings.set('client/lang_channels', default) + + def _channel_from_os_language(self): + lang = self._settings.get('client/language', None) + return DEFAULT_LANGUAGE_CHANNELS.get(lang, None) + + def _channel_from_geoip(self, player): + if player is None: + return None + flag = player.country + lang = COUNTRY_TO_LANGUAGE.get(flag, None) + return DEFAULT_LANGUAGE_CHANNELS.get(lang, None) diff --git a/src/chat/channel_tab.py b/src/chat/channel_tab.py new file mode 100644 index 000000000..1208c4cf0 --- /dev/null +++ b/src/chat/channel_tab.py @@ -0,0 +1,94 @@ +from enum import IntEnum + +from PyQt6.QtCore import QTimer +from PyQt6.QtMultimedia import QSoundEffect + +from chat.chat_widget import TabIcon + + +class TabInfo(IntEnum): + IDLE = 0 + NEW_MESSAGES = 1 + IMPORTANT = 2 + + +class ChannelTab: + def __init__(self, cid, widget, theme, chat_config): + self._cid = cid + self._widget = widget + self._theme = theme + self._chat_config = chat_config + self._info = TabInfo.IDLE + + self._timer = QTimer() + self._timer.setInterval(self._chat_config.channel_blink_interval) + self._timer.timeout.connect(self._switch_blink) + self._blink_phase = False + self._chat_config.updated.connect(self._config_updated) + + self._ping_timer = QTimer() + self._ping_timer.setSingleShot(True) + self._ping_timer.setInterval(self._chat_config.channel_ping_timeout) + + self._ping_sound = QSoundEffect() + self._ping_sound.setSource(self._theme.sound("chat/sfx/query.wav")) + + def _config_updated(self, option): + c = self._chat_config + if option == "channel_blink_interval": + self._timer.setInterval(c.channel_blink_interval) + if option == "channel_ping_timeout": + self._ping_timer.setInterval(c.channel_ping_timeout) + + @classmethod + def builder(cls, theme, chat_config, **kwargs): + def make(cid, widget): + return cls(cid, widget, theme, chat_config) + return make + + @property + def info(self): + return self._info + + @info.setter + def info(self, info): + if self._info == info: + return + if self._info > info and info != TabInfo.IDLE: + return + self._info = info + + if info == TabInfo.IMPORTANT: + self._start_blinking() + return + self._stop_blinking() + if info == TabInfo.NEW_MESSAGES: + self._widget.set_tab_icon(self._cid, TabIcon.NEW_MESSAGE) + if info == TabInfo.IDLE: + self._widget.set_tab_icon(self._cid, TabIcon.IDLE) + + def _start_blinking(self): + if not self._timer.isActive(): + self._switch_blink(False) + self._timer.start() + self._ping() + + def _ping(self) -> None: + self._widget.alert_tab() + if not self._chat_config.soundeffects: + return + if self._ping_timer.isActive(): + return + self._ping_timer.start() + self._ping_sound.play() + + def _stop_blinking(self): + self._timer.stop() + self._ping_timer.stop() + + def _switch_blink(self, val=None): + if val is None: + val = not self._blink_phase + self._blink_phase = val + icon = TabIcon.BLINK_ACTIVE if val else TabIcon.BLINK_INACTIVE + self._widget.set_tab_icon(self._cid, icon) diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py new file mode 100644 index 000000000..366bdeefe --- /dev/null +++ b/src/chat/channel_view.py @@ -0,0 +1,436 @@ +import html +import time + +import jinja2 +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QDesktopServices + +from chat.channel_tab import TabInfo +from chat.channel_widget import ChannelWidget +from chat.chatter_menu import ChatterMenu +from chat.chatter_model import ChatterEventFilter +from chat.chatter_model import ChatterFormat +from chat.chatter_model import ChatterItemDelegate +from chat.chatter_model import ChatterLayout +from chat.chatter_model import ChatterLayoutElements +from chat.chatter_model import ChatterModel +from chat.chatter_model import ChatterSortFilterModel +from downloadManager import DownloadRequest +from model.chat.channel import ChannelType +from model.chat.chatline import ChatLineType +from util import irc_escape +from util.gameurl import GameUrl + + +class ChannelView: + def __init__( + self, channel, controller, widget, channel_tab, + chatter_list_view, lines_view, + ): + self._channel = channel + self._controller = controller + self._chatter_list_view = chatter_list_view + self._lines_view = lines_view + self.widget = widget + self._channel_tab = channel_tab + + self.widget.line_typed.connect(self._at_line_typed) + if self._channel.id_key.type == ChannelType.PRIVATE: + self.widget.show_chatter_list(False) + + self._channel.added_chatter.connect(self._update_chatter_count) + self._channel.removed_chatter.connect(self._update_chatter_count) + self._update_chatter_count() + + def _update_chatter_count(self): + text = "{} users (type to filter)".format(len(self._channel.chatters)) + self.widget.set_nick_edit_label(text) + + @classmethod + def build(cls, channel, controller, channel_tab, **kwargs): + chat_css_template = ChatLineCssTemplate.build(**kwargs) + widget = ChannelWidget.build(channel, chat_css_template, **kwargs) + lines_view = ChatAreaView.build(channel, widget, channel_tab, **kwargs) + chatter_list_view = ChattersView.build( + channel, widget, controller, **kwargs + ) + return cls( + channel, controller, widget, channel_tab, chatter_list_view, + lines_view, + ) + + @classmethod + def builder(cls, controller, **kwargs): + def make(channel, channel_tab): + return cls.build(channel, controller, channel_tab, **kwargs) + return make + + def _at_line_typed(self, line): + self._controller.send_message(self._channel.id_key, line) + + def on_shown(self): + self._channel_tab.info = TabInfo.IDLE + + +class ChatAreaView: + def __init__( + self, channel, widget, widget_tab, game_runner, avatar_adder, + formatter, + ): + self._channel = channel + self._widget = widget + self._widget_tab = widget_tab + self._game_runner = game_runner + self._channel.lines.added.connect(self._add_line) + self._channel.lines.removed.connect(self._remove_lines) + self._channel.updated.connect(self._at_channel_updated) + self._widget.url_clicked.connect(self._at_url_clicked) + self._widget.css_reloaded.connect(self._at_css_reloaded) + self._avatar_adder = avatar_adder + self._formatter = formatter + + self._set_topic(self._channel.topic) + + @classmethod + def build(cls, channel, widget, widget_tab, game_runner, **kwargs): + avatar_adder = ChatAvatarPixAdder.build(widget, **kwargs) + formatter = ChatLineFormatter.build(**kwargs) + return cls( + channel, widget, widget_tab, game_runner, avatar_adder, formatter, + ) + + def _add_line(self): + data = self._channel.lines[-1] + if data.meta.player.avatar.url: + self._avatar_adder.add_avatar(data.meta.player.avatar.url()) + text = self._formatter.format(data) + self._widget.append_line(text) + self._set_tab_info(data) + + def _remove_lines(self, number): + self._widget.remove_lines(number) + + def _at_channel_updated(self, new, old): + if new.topic != old.topic: + self._set_topic(new.topic) + + def _set_topic(self, topic): + self._widget.set_topic(self._format_topic(topic)) + + def _format_topic(self, topic): + # FIXME - use CSS for this + fmt = ( + "" + "{}" + ) + return fmt.format(irc_escape(topic)) + + def _at_url_clicked(self, url): + if not GameUrl.is_game_url(url): + QDesktopServices.openUrl(url) + return + try: + gurl = GameUrl.from_url(url) + except ValueError: + return + self._game_runner.run_game_from_url(gurl) + + def _set_tab_info(self, data): + self._widget_tab.info = self._tab_info(data) + + def _tab_info(self, data): + if not self._widget.hidden: + return TabInfo.IDLE + if self._line_is_important(data): + return TabInfo.IMPORTANT + return TabInfo.NEW_MESSAGES + + def _line_is_important(self, data): + if data.line.type in [ + ChatLineType.INFO, ChatLineType.ANNOUNCEMENT, ChatLineType.RAW, + ]: + return False + if self._channel.id_key.type == ChannelType.PRIVATE: + return True + if data.meta.mentions_me and data.meta.mentions_me(): + return True + return False + + def _at_css_reloaded(self): + self._widget.clear_chat() + for line in self._channel.lines: + text = self._formatter.format(line) + self._widget.append_line(text) + + +class ChatAvatarPixAdder: + def __init__(self, widget, avatar_dler): + self._avatar_dler = avatar_dler + self._widget = widget + self._requests = {} + + @classmethod + def build(cls, widget, avatar_dler, **kwargs): + return cls(widget, avatar_dler) + + def add_avatar(self, url): + avatar_pix = self._avatar_dler.avatars.get(url, None) + if avatar_pix is not None: + self._add_avatar_resource(url, avatar_pix) + elif url not in self._requests: + req = DownloadRequest() + req.done.connect(self._add_avatar_resource) + self._requests[url] = req + self._avatar_dler.download_avatar(url, req) + + def _add_avatar_resource(self, url, pix): + if url in self._requests: + del self._requests[url] + self._widget.add_avatar_resource(url, pix) + + +class ChatLineCssTemplate(QObject): + changed = pyqtSignal() + + def __init__(self, theme, player_colors): + QObject.__init__(self) + self._player_colors = player_colors + self._theme = theme + self._player_colors.changed.connect(self._reload_css) + self._theme.stylesheets_reloaded.connect(self._load_template) + self._load_template() + + @classmethod + def build(cls, theme, player_colors, **kwargs): + return cls(theme, player_colors) + + def _load_template(self): + self._env = jinja2.Environment() + template_str = self._theme.readfile("chat/channel.css") + self._template = self._env.from_string(template_str) + self._reload_css() + + def _reload_css(self): + colors = self._player_colors.colors + if self._player_colors.colored_nicknames: + random_colors = self._player_colors.random_colors + else: + random_colors = None + self.css = self._template.render( + colors=colors, + random_colors=random_colors, + ) + self.changed.emit() + + +class ChatLineFormatter: + def __init__(self, theme, player_colors): + self._set_theme(theme) + self._player_colors = player_colors + self._last_timestamp = None + + @classmethod + def build(cls, theme, player_colors, **kwargs): + return cls(theme, player_colors) + + def _set_theme(self, theme): + self._chatline_template = theme.readfile("chat/chatline.qhtml") + self._avatar_template = theme.readfile("chat/chatline_avatar.qhtml") + + def _line_tags(self, data): + line = data.line + meta = data.meta + if line.type == ChatLineType.NOTICE: + yield "notice" + if line.type == ChatLineType.ACTION: + yield "action" + if line.type == ChatLineType.INFO: + yield "info" + if line.type == ChatLineType.ANNOUNCEMENT: + yield "announcement" + return # Let announcements decorate themselves + if line.type == ChatLineType.RAW: + yield "raw" + return # Ditto + if meta.chatter: + yield "chatter" + if meta.chatter.is_mod and meta.chatter.is_mod(): + yield "mod" + name = meta.chatter.name() + id_ = meta.player.id() if meta.player.id else None + yield ( + "randomcolor-{}".format( + self._player_colors.get_random_color_index(id_, name), + ) + ) + if meta.player: + yield "player" + if meta.is_friend and meta.is_friend(): + yield "friend" + if meta.is_foe and meta.is_foe(): + yield "foe" + if meta.is_me and meta.is_me(): + yield "me" + if meta.is_clannie and meta.is_clannie(): + yield "clannie" + if meta.mentions_me and meta.mentions_me(): + yield "mentions_me" + if meta.player.avatar and meta.player.avatar(): + yield "avatar" + + def format(self, data): + tags = " ".join(self._line_tags(data)) + avatar = self._avatar(data) + + if self._check_timestamp(data.line.time): + stamp = time.strftime('%H:%M', time.localtime(data.line.time)) + else: + stamp = "" + + text = data.line.text + if data.line.type not in [ChatLineType.ANNOUNCEMENT, ChatLineType.RAW]: + text = irc_escape(text) + if data.line.type == ChatLineType.RAW: + return text + + return self._chatline_template.format( + time=stamp, + sender=self._sender_name(data), + text=text, + avatar=avatar, + tags=tags, + ) + + def _avatar(self, data): + if data.line.type in [ + ChatLineType.INFO, ChatLineType.ANNOUNCEMENT, ChatLineType.RAW, + ]: + return "" + if not data.meta.player.avatar.url: + return "" + ava_meta = data.meta.player.avatar + avatar_url = ava_meta.url() + avatar_tip = ava_meta.tip() if ava_meta.tip else "" + return self._avatar_template.format(url=avatar_url, tip=avatar_tip) + + def _sender_name(self, data): + if data.line.sender is None: + return "" + mtype = data.line.type + sender = ChatterFormat.name(data.line.sender, data.meta.player.clan()) + sender = html.escape(sender) + if mtype in [ChatLineType.MESSAGE, ChatLineType.NOTICE]: + sender += ": " + return sender + + def _check_timestamp(self, stamp): + local = time.localtime(stamp) + new_stamp = ( + self._last_timestamp is None + or local.tm_hour != self._last_timestamp.tm_hour + or local.tm_min != self._last_timestamp.tm_min + ) + if new_stamp: + self._last_timestamp = local + return new_stamp + + +class ChattersViewParameters(QObject): + updated = pyqtSignal() + + def __init__(self, me, player_colors): + QObject.__init__(self) + self._me = me + self._me.playerChanged.connect(self._updated) + self._me.clan_changed.connect(self._updated) + self._player_colors = player_colors + self._player_colors.changed.connect(self._updated) + + def _updated(self): + self.updated.emit() + + @classmethod + def build(cls, me, player_colors, **kwargs): + return cls(me, player_colors) + + +class ChattersView: + def __init__( + self, widget, chatter_layout, delegate, model, controller, + event_filter, double_click_handler, view_parameters, + ): + self.chatter_layout = chatter_layout + self.delegate = delegate + self.model = model + self._controller = controller + self.event_filter = event_filter + self._double_click_handler = double_click_handler + self._view_parameters = view_parameters + self.widget = widget + + widget.set_chatter_delegate(self.delegate) + widget.set_chatter_model(self.model) + widget.set_chatter_event_filter(self.event_filter) + widget.chatter_list_resized.connect(self._at_chatter_list_resized) + view_parameters.updated.connect(self._at_view_parameters_updated) + self.event_filter.double_clicked.connect( + self._double_click_handler.handle, + ) + + def _at_chatter_list_resized(self, size): + self.delegate.update_width(size) + + def _at_view_parameters_updated(self): + self.model.invalidate_items() + + @classmethod + def build(cls, channel, widget, controller, user_relations, **kwargs): + model = ChatterModel.build( + channel, relation_trackers=user_relations.trackers, **kwargs + ) + sort_filter_model = ChatterSortFilterModel.build( + model, user_relations=user_relations.model, **kwargs + ) + + chatter_layout = ChatterLayout.build(**kwargs) + chatter_menu = ChatterMenu.build(**kwargs) + delegate = ChatterItemDelegate.build(chatter_layout, **kwargs) + event_filter = ChatterEventFilter.build( + chatter_layout, delegate, chatter_menu, **kwargs + ) + double_click_handler = ChatterDoubleClickHandler.build( + controller, **kwargs + ) + view_parameters = ChattersViewParameters.build(**kwargs) + + return cls( + widget, chatter_layout, delegate, sort_filter_model, + controller, event_filter, double_click_handler, view_parameters, + ) + + +class ChatterDoubleClickHandler: + def __init__(self, controller, game_runner): + self._controller = controller + self._game_runner = game_runner + + @classmethod + def build(cls, controller, game_runner, **kwargs): + return cls(controller, game_runner) + + def handle(self, data, elem): + if elem == ChatterLayoutElements.STATUS: + self._game_action(data) + else: + self._privmsg(data) + + def _privmsg(self, data): + self._controller.join_private_channel(data.chatter.name) + + def _game_action(self, data): + game = data.game + player = data.player + if game is None or player is None: + return + self._game_runner.run_game_with_url(game, player.id) diff --git a/src/chat/channel_widget.py b/src/chat/channel_widget.py new file mode 100644 index 000000000..a4a0af1fc --- /dev/null +++ b/src/chat/channel_widget.py @@ -0,0 +1,193 @@ +import logging +import re + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QTextCursor +from PyQt6.QtGui import QTextDocument + +from util.qt import monkeypatch_method + +logger = logging.getLogger(__name__) + + +class ChannelWidget(QObject): + line_typed = pyqtSignal(str) + chatter_list_resized = pyqtSignal(object) + url_clicked = pyqtSignal(QUrl) + css_reloaded = pyqtSignal() + + def __init__(self, channel, chat_area_css, theme, chat_config): + QObject.__init__(self) + self.channel = channel + self._chat_area_css = chat_area_css + self._chat_area_css.changed.connect(self._reload_css) + self._chat_config = chat_config + self.set_theme(theme) + + @classmethod + def build(cls, channel, chat_area_css, theme, chat_config, **kwargs): + return cls(channel, chat_area_css, theme, chat_config) + + @property + def chat_area(self): + return self.form.chatArea + + @property + def chat_edit(self): + return self.form.chatEdit + + @property + def nick_frame(self): + return self.form.nickFrame + + @property + def nick_list(self): + return self.form.nickList + + @property + def nick_filter(self): + return self.form.nickFilter + + @property + def announce_line(self): + return self.form.announceLine + + def set_theme(self, theme): + formc, basec = theme.loadUiType("chat/channel.ui") + self.form = formc() + self.base = basec() + self.form.setupUi(self.base) + + # Used by chat widget so it knows it corresponds to this widget + self.base.cid = self.channel.id_key + self.chat_edit.returnPressed.connect(self._at_line_typed) + self.nick_list.resized.connect(self._chatter_list_resized) + self.chat_edit.set_channel(self.channel) + self.nick_filter.textChanged.connect(self._set_chatter_filter) + self.chat_area.anchorClicked.connect(self._url_clicked) + self._override_widget_methods() + self._load_css() + self._sticky_scroll = ChatAreaStickyScroll( + self.chat_area.verticalScrollBar(), + ) + + def _override_widget_methods(self): + + def on_key_release(obj, old_fn, keyevent): + if keyevent.key() == 67: # Ctrl-C + self.chat_area.copy() + else: + old_fn(keyevent) + monkeypatch_method(self.base, "keyReleaseEvent", on_key_release) + + def _chatter_list_resized(self, size): + self.chatter_list_resized.emit(size) + + def _url_clicked(self, url): + self.url_clicked.emit(url) + + # This might be fairly expensive, as we reapply all chat lines to the area. + # Make sure it's not called really often! + def _reload_css(self): + logger.info("Reloading chat CSS...") + self._load_css() + self.css_reloaded.emit() # Qt does not reapply css on its own + + def _load_css(self): + self.chat_area.document().setDefaultStyleSheet(self._chat_area_css.css) + + def clear_chat(self): + self.chat_area.document().setHtml("") + + def add_avatar_resource(self, url, pix): + doc = self.chat_area.document() + link = QUrl(url) + if not doc.resource(QTextDocument.ResourceType.ImageResource, link): + doc.addResource(QTextDocument.ResourceType.ImageResource, link, pix) + + def _set_chatter_filter(self, text): + self.nick_list.model().setFilterFixedString(text) + + def _at_line_typed(self): + text = self.chat_edit.text() + self.chat_edit.clear() + fragments = text.split("\n") + for line in fragments: + # Compound wacky Whitespace + line = re.sub(r'\s', ' ', line).strip() + if not line: + continue + self.line_typed.emit(line) + + def show_chatter_list(self, should_show): + self.nick_frame.setVisible(should_show) + + def append_line(self, text): + # QTextEdit has its own ideas about scrolling and does not stay + # in place when adding content + self._sticky_scroll.save_scroll() + + cursor = self.chat_area.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.chat_area.setTextCursor(cursor) + self.chat_area.insertHtml(text) + + self._sticky_scroll.restore_scroll() + + def remove_lines(self, number): + cursor = self.chat_area.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.Start) + cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor, number) + cursor.removeSelectedText() + + def set_chatter_delegate(self, delegate): + self.nick_list.setItemDelegate(delegate) + + def set_chatter_model(self, model): + self.nick_list.setModel(model) + model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + def set_chatter_event_filter(self, event_filter): + self.nick_list.viewport().installEventFilter(event_filter) + + def set_nick_edit_label(self, text): + self.nick_filter.setPlaceholderText(text) + + @property + def hidden(self): + return not self.base.isVisible() + + def set_topic(self, topic): + self.announce_line.setText(topic) + + +class ChatAreaStickyScroll: + def __init__(self, scrollbar): + self._scrollbar = scrollbar + self._scrollbar.valueChanged.connect(self._track_maximum) + self._scrollbar.rangeChanged.connect(self._stick_at_range_changed) + self._is_set_to_maximum = True + self._old_value = self._scrollbar.value() + self._saved_scroll = 0 + + def save_scroll(self): + self._saved_scroll = self._scrollbar.value() + + def restore_scroll(self): + if self._is_set_to_maximum: + self._scrollbar.setValue(self._scrollbar.maximum()) + else: + self._scrollbar.setValue(self._saved_scroll) + + def _track_maximum(self, val): + self._is_set_to_maximum = val == self._scrollbar.maximum() + self._old_value = val + + def _stick_at_range_changed(self, min_, max_): + if self._is_set_to_maximum: + self._scrollbar.setValue(max_) + else: + self._scrollbar.setValue(self._old_value) diff --git a/src/chat/chat_announcer.py b/src/chat/chat_announcer.py new file mode 100644 index 000000000..e9ad8e6c0 --- /dev/null +++ b/src/chat/chat_announcer.py @@ -0,0 +1,36 @@ +from model.chat.channel import ChannelID, ChannelType +from model.chat.chatline import ChatLine, ChatLineType + + +class ChatAnnouncer: + def __init__( + self, model, chat_config, game_announcer, line_metadata_builder, + ): + self._model = model + self._chat_config = chat_config + self._game_announcer = game_announcer + self._line_metadata_builder = line_metadata_builder + self._game_announcer.announce.connect(self._announce) + self._model.disconnect_event.connect(self._at_chat_disconnected) + + @property + def _announcement_channels(self): + return self._chat_config.announcement_channels + + def _announce(self, msg, sender=None): + line = ChatLine(sender, msg, ChatLineType.ANNOUNCEMENT) + for name in self._announcement_channels: + cid = ChannelID(ChannelType.PUBLIC, name) + channel = self._model.channels.get(cid, None) + if channel is None: + continue + data = self._line_metadata_builder.get_meta(channel, line) + channel.lines.add_line(data) + + def _at_chat_disconnected(self): + self._announce( + ( + "Disconnected from chat! Right-click on the FAF icon " + "in the top-left to reconnect." + ), + ) diff --git a/src/chat/chat_controller.py b/src/chat/chat_controller.py new file mode 100644 index 000000000..0b97ec36f --- /dev/null +++ b/src/chat/chat_controller.py @@ -0,0 +1,375 @@ +from enum import Enum + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +from model.chat.channel import Channel +from model.chat.channel import ChannelID +from model.chat.channel import ChannelType +from model.chat.channel import Lines +from model.chat.channelchatter import ChannelChatter +from model.chat.chatline import ChatLine +from model.chat.chatline import ChatLineType +from model.chat.chatter import Chatter + + +class ChatController(QObject): + join_requested = pyqtSignal(object) + + def __init__( + self, connection, model, user_relations, chat_config, + line_metadata_builder, + ): + QObject.__init__(self) + self._connection = connection + self._model = model + self._user_relations = user_relations + self._chat_config = chat_config + self._chat_config.updated.connect(self._at_config_updated) + self._line_metadata_builder = line_metadata_builder + + c = connection + c.new_line.connect(self._at_new_line) + c.new_channel_chatters.connect(self._at_new_channel_chatters) + c.channel_chatter_left.connect(self._at_channel_chatter_left) + c.channel_chatter_joined.connect(self._at_channel_chatter_joined) + c.chatter_quit.connect(self._at_chatter_quit) + c.quit_channel.connect(self._at_quit_channel) + c.chatter_renamed.connect(self._at_chatter_renamed) + c.new_chatter_elevation.connect(self._at_new_chatter_elevation) + c.new_channel_topic.connect(self._at_new_channel_topic) + c.connected.connect(self._at_connected) + c.disconnected.connect(self._at_disconnected) + c.new_server_message.connect(self._at_new_server_message) + + @classmethod + def build( + cls, connection, model, user_relations, chat_config, + line_metadata_builder, **kwargs + ): + return cls( + connection, model, user_relations, chat_config, + line_metadata_builder, + ) + + @property + def _channels(self): + return self._model.channels + + @property + def _chatters(self): + return self._model.chatters + + @property + def _ccs(self): + return self._model.channelchatters + + def _check_add_new_channel(self, cid): + if cid not in self._channels: + channel = Channel(cid, Lines(), "") + self._channels[cid] = channel + if cid.type == ChannelType.PRIVATE: + self._add_me_to_channel(channel) + self._join_chatter_to_his_privchannel(cid.name) + return self._channels[cid] + + def _add_me_to_channel(self, channel): + my_name = self._connection.nickname + me = None if my_name is None else self._chatters.get(my_name, None) + if me is not None: + cc = ChannelChatter(channel, me, "") + self._ccs[(channel.id_key, me.id_key)] = cc + + def _join_chatter_to_his_privchannel(self, name): + channel = self._channels.get(ChannelID.private_cid(name), None) + if channel is None: + return + chatter = self._chatters.get(name, None) + if chatter is None: + return + key = (channel.id_key, chatter.id_key) + if key not in self._ccs: + self._ccs[key] = ChannelChatter(channel, chatter, "") + + def _check_add_new_chatter(self, cinfo): + if cinfo.name not in self._chatters: + chatter = Chatter(cinfo.name, cinfo.hostname) + self._chatters[chatter.name] = chatter + self._join_chatter_to_his_privchannel(chatter.name) + return self._chatters[cinfo.name] + + def _add_or_update_cc(self, cid, cinfo): + channel = self._check_add_new_channel(cid) + chatter = self._check_add_new_chatter(cinfo) + key = (channel.id_key, chatter.id_key) + if key not in self._ccs: + cc = ChannelChatter(channel, chatter, cinfo.elevation) + self._ccs[key] = cc + else: + self._ccs[key].update(elevation=cinfo.elevation) + + def _remove_cc(self, cid, cinfo): + key = (cid, cinfo.name) + self._ccs.pop(key, None) + + def _add_line(self, channel, line): + data = self._line_metadata_builder.get_meta(channel, line) + channel.lines.add_line(data) + self._trim_channel_lines(channel) + + def _at_new_line(self, cid, cinfo, line): + if cid.type == ChannelType.PUBLIC and cid not in self._channels: + return + + if self._should_ignore_chatter(cid, line.sender): + return + + if cid.type == ChannelType.PRIVATE: + self._check_add_new_channel(cid) + # If a chatter messages us without having joined any channel, this + # is where we first hear of him + if cinfo is not None: + if cinfo.name not in self._chatters: + self._check_add_new_chatter(cinfo) + self._add_or_update_cc(cid, cinfo) + + self._add_line(self._channels[cid], line) + + def _at_new_channel_chatters(self, cid, chatters): + for c in chatters: + self._add_or_update_cc(cid, c) + + def _at_channel_chatter_joined(self, cid, chatter): + self._at_new_channel_chatters(cid, [chatter]) + self._announce_join(cid, chatter) + + def _at_channel_chatter_left(self, cid, chatter): + self._announce_part(cid, chatter) + self._remove_cc(cid, chatter) + + def _at_chatter_quit(self, chatter, msg): + chatter_obj = self._chatters.get(chatter.name, None) + if chatter_obj is None: + return + for cc in chatter_obj.channels.values(): + self._announce_quit(cc.channel.id_key, chatter, msg) + self._chatters.pop(chatter.name, None) + + def _joinpart(fn): + def wrap(self, cid, chatter, *args, **kwargs): + if not self._chat_config.joinsparts: + return + if self._should_ignore_chatter(cid, chatter.name): + return + channel = self._channels.get(cid, None) + if channel is None: + return + return fn(self, channel, chatter, *args, **kwargs) + return wrap + + def _announce(self, channel, text): + line = ChatLine(None, text, ChatLineType.INFO) + self._add_line(channel, line) + + def _announce_chatter(self, channel, chatter, text): + line = ChatLine(chatter.name, text, ChatLineType.INFO) + self._add_line(channel, line) + + @_joinpart + def _announce_join(self, channel, chatter): + self._announce_chatter(channel, chatter, "joined the channel.") + + @_joinpart + def _announce_part(self, channel, chatter): + self._announce_chatter(channel, chatter, "left the channel.") + + def _announce_quit(self, cid, chatter, message): + if ( + not self._chat_config.joinsparts + and cid.type != ChannelType.PRIVATE + ): + return + if self._should_ignore_chatter(cid, chatter.name): + return + channel = self._channels.get(cid, None) + if channel is None: + return + prefix = "quit" + if chatter.name in message: # Silence default messages + message = "{}.".format(prefix) + else: + message = "{}: {}".format(prefix, message) + self._announce_chatter(channel, chatter, message) + + def _at_quit_channel(self, cid): + self._delete_channel_ignoring_connection(cid) + + def _at_chatter_renamed(self, old, new): + if old not in self._chatters: + return + self._chatters[old].update(name=new) + + def _at_new_chatter_elevation(self, cid, chatter, added, removed): + key = (cid, chatter.name) + if key not in self._ccs: + return + cc = self._ccs[key] + old = cc.elevation + new = ''.join(c for c in old + added if c not in removed) + cc.update(elevation=new) + + def _at_new_channel_topic(self, cid, topic): + channel = self._channels.get(cid) + if channel is None: + return + channel.update(topic=topic) + + def _at_connected(self): + privchannels = self._save_privchannels() + self._channels.clear() + self._chatters.clear() + self._ccs.clear() + self._model.connected = True + self._restore_privchannels(privchannels) + + def _save_privchannels(self): + return [c for c in self._channels if c.type == ChannelType.PRIVATE] + + def _restore_privchannels(self, cids): + for cid in cids: + self.join_channel(cid) + + def _at_disconnected(self): + self._model.connected = False + + def _at_new_server_message(self, msg): + self._model.add_server_message(msg) + + def _at_config_updated(self, option): + if option == "max_chat_lines": + for channel in self._channels.values: + self._trim_channel_lines(channel) + + def _trim_channel_lines(self, channel): + max_ = self._chat_config.max_chat_lines + trim_count = self._chat_config.chat_line_trim_count + if len(channel.lines) <= max_: + return + trim_amount = min(len(channel.lines), trim_count) + channel.lines.remove_lines(trim_amount) + + # User actions start here. + def send_message(self, cid, message): + action, msg = MessageAction.parse_message(message) + try: + if action == MessageAction.MSG: + if self._connection.send_message(cid.name, msg): + self._at_new_line(cid, None, self._user_chat_line(msg)) + elif action == MessageAction.PRIVMSG: + chatter_name, msg = msg.split(" ", 1) + if self._connection.send_message(chatter_name, msg): + cid = ChannelID.private_cid(chatter_name) + self._at_new_line(cid, None, self._user_chat_line(msg)) + elif action == MessageAction.ME: + if self._connection.send_action(cid.name, msg): + self._at_new_line( + cid, + None, + self._user_chat_line( + msg, ChatLineType.ACTION, + ), + ) + elif action == MessageAction.SEEN: + self._connection.send_action("nickserv", "info {}".format(msg)) + elif action == MessageAction.TOPIC: + self._connection.set_topic(cid.name, msg) + elif action == MessageAction.JOIN: + self._connection.join(msg) + else: + pass # TODO - raise 'Sending failed' error back to the view? + except ValueError: + notice = ( + "Sending failed. Message is too long or contains invalid" + " character." + ) + self._announce(self._channels[cid], notice) + except BaseException: + notice = "Sending failed. Check your connection." + self._announce(self._channels[cid], notice) + + def join_channel(self, cid): + # Don't join a private channel with ourselves + if ( + cid.type == ChannelType.PRIVATE + and cid.name == self._connection.nickname + ): + return + + self.join_requested.emit(cid) + if cid.type == ChannelType.PUBLIC: + self._connection.join(cid.name) + else: + self._check_add_new_channel(cid) + + def join_public_channel(self, name): + self.join_channel(ChannelID(ChannelType.PUBLIC, name)) + + def join_private_channel(self, name): + self.join_channel(ChannelID(ChannelType.PRIVATE, name)) + + def _user_chat_line(self, msg, type_=ChatLineType.MESSAGE): + return ChatLine(self._connection.nickname, msg, type_) + + def leave_channel(self, cid, reason): + if cid.type == ChannelType.PRIVATE: + self._delete_channel_ignoring_connection(cid) + else: + if not self._connection.part(cid.name, reason): + # We're disconnected from IRC - allow user to close tabs anyway + self._delete_channel_ignoring_connection(cid) + + def _delete_channel_ignoring_connection(self, cid): + self._channels.pop(cid, None) + + def _should_ignore_chatter(self, cid, name): + if name is None: + return False + if cid.type == ChannelType.PUBLIC: + cc = self._ccs.get((cid, name), None) + if cc is None or cc.is_mod() or cc.chatter.is_base_channel_mod(): + return False + + chatter = self._chatters.get(name, None) + if chatter is None: + return False + name = chatter.name + id_ = None if chatter.player is None else chatter.player.id + if self._user_relations.is_foe(id_, name): + if self._user_relations.is_chatterbox(id_, name): + return True + else: + return self._chat_config.ignore_foes + return self._user_relations.is_chatterbox(id_, name) + + +class MessageAction(Enum): + MSG = "message" + UNKNOWN = "unknown" + PRIVMSG = "/msg " + ME = "/me " + SEEN = "/seen " + TOPIC = "/topic " + JOIN = "/join " + + @classmethod + def parse_message(cls, msg): + if not msg.startswith("/"): + return cls.MSG, msg + + for cmd in cls: + if cmd in [cls.MSG, cls.UNKNOWN]: + continue + if msg.startswith(cmd.value): + return cmd, msg[len(cmd.value):] + + return cls.UNKNOWN, msg diff --git a/src/chat/chat_greeter.py b/src/chat/chat_greeter.py new file mode 100644 index 000000000..bcf0b980c --- /dev/null +++ b/src/chat/chat_greeter.py @@ -0,0 +1,41 @@ +from model.chat.channel import ChannelType +from model.chat.chatline import ChatLine, ChatLineType +from util import irc_escape + + +class ChatGreeter: + def __init__(self, model, theme, chat_config, line_metadata_builder): + self._model = model + self._model.channels.added.connect(self._at_channel_added) + self._chat_config = chat_config + self._line_metadata_builder = line_metadata_builder + self._greeted_channels = set() + self._greeting_format = theme.readfile("chat/raw.qhtml") + + @property + def _greeting(self): + return self._chat_config.channel_greeting + + @property + def _channels(self): + return self._chat_config.channels_to_greet_in + + def _at_channel_added(self, channel): + cid = channel.id_key + if cid in self._greeted_channels: + return + if cid.type != ChannelType.PUBLIC or cid.name not in self._channels: + return + self._print_greeting(channel) + self._greeted_channels.add(cid) + + def _print_greeting(self, channel): + for line in self._greeting: + text, color, size = line + text = irc_escape(text) + msg = self._greeting_format.format( + text=text, color=color, size=size, + ) + line = ChatLine(None, msg, ChatLineType.RAW) + data = self._line_metadata_builder.get_meta(channel, line) + channel.lines.add_line(data) diff --git a/src/chat/chat_view.py b/src/chat/chat_view.py new file mode 100644 index 000000000..2bed55bd1 --- /dev/null +++ b/src/chat/chat_view.py @@ -0,0 +1,94 @@ +from chat.channel_tab import ChannelTab +from chat.channel_view import ChannelView +from chat.chat_widget import ChatWidget +from model.chat.channel import ChannelType + + +class ChatView: + def __init__( + self, target_viewed_channel, model, controller, widget, + channel_view_builder, channel_tab_builder, + ): + self._target_viewed_channel = None + self._model = model + self._controller = controller + self._controller.join_requested.connect(self._at_join_requested) + self.widget = widget + self._channel_view_builder = channel_view_builder + self._channel_tab_builder = channel_tab_builder + self._channels = {} + self._model.channels.added.connect(self._add_channel) + self._model.channels.removed.connect(self._remove_channel) + self._model.new_server_message.connect(self._new_server_message) + self.widget.channel_quit_request.connect(self._at_channel_quit_request) + self.widget.tab_changed.connect(self._at_tab_changed) + self._add_channels() + + self.target_viewed_channel = target_viewed_channel + + @classmethod + def build(cls, target_viewed_channel, model, controller, **kwargs): + chat_widget = ChatWidget.build(**kwargs) + channel_view_builder = ChannelView.builder( + controller, channelchatterset=model.channelchatters, **kwargs + ) + channel_tab_builder = ChannelTab.builder(**kwargs) + return cls( + target_viewed_channel, model, controller, chat_widget, + channel_view_builder, channel_tab_builder, + ) + + def _add_channels(self): + for channel in self._model.channels.values(): + self._add_channel(channel) + + def _add_channel(self, channel): + if channel.id_key in self._channels: + return + tab = self._channel_tab_builder(channel.id_key, self.widget) + view = self._channel_view_builder(channel, tab) + self._channels[channel.id_key] = view + self.widget.add_channel(view.widget, channel.id_key) + self._try_to_join_target_channel() + + def _remove_channel(self, channel): + if channel.id_key not in self._channels: + return + self.widget.remove_channel(channel.id_key) + del self._channels[channel.id_key] + + def _new_server_message(self, msg): + self.widget.write_server_message(msg) + + def _at_channel_quit_request(self, cid): + self._controller.leave_channel(cid, "tab closed") + + def _at_tab_changed(self, cid): + self._channels[cid].on_shown() + + def _at_join_requested(self, cid): + if cid.type == ChannelType.PRIVATE: + self.target_viewed_channel = cid + + def entered(self): + current = self.widget.current_channel() + if current is None: + return + self._channels[current].on_shown() + + @property + def target_viewed_channel(self): + return self._target_viewed_channel + + @target_viewed_channel.setter + def target_viewed_channel(self, value): + self._target_viewed_channel = value + self._try_to_join_target_channel() + + def _try_to_join_target_channel(self): + if self._target_viewed_channel is None: + return + if self._target_viewed_channel not in self._channels: + return + self.widget.switch_to_channel(self._target_viewed_channel) + self._target_viewed_channel = None diff --git a/src/chat/chat_widget.py b/src/chat/chat_widget.py new file mode 100644 index 000000000..308b08ded --- /dev/null +++ b/src/chat/chat_widget.py @@ -0,0 +1,128 @@ +from enum import Enum + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QTabBar + +from model.chat.channel import PARTY_CHANNEL_SUFFIX +from model.chat.channel import ChannelType + + +class TabIcon(Enum): + IDLE = "idle" + NEW_MESSAGE = "new_message" + BLINK_ACTIVE = "blink_active" + BLINK_INACTIVE = "blink_inactive" + + +class ChatWidget(QObject): + channel_quit_request = pyqtSignal(object) + tab_changed = pyqtSignal(object) + + def __init__(self, theme): + QObject.__init__(self) + self._channels = {} + self._theme = theme + self.set_theme() + + @classmethod + def build(cls, theme, **kwargs): + return cls(theme) + + def set_theme(self): + formc, basec = self._theme.loadUiType("chat/chat.ui") + self.form = formc() + self.base = basec() + self.form.setupUi(self.base) + self.base.tabCloseRequested.connect(self._at_tab_close_request) + self.base.currentChanged.connect(self._at_tab_changed) + self.remove_server_tab_close_button() + + def remove_server_tab_close_button(self): + self.base.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None) + + def add_channel(self, widget, key, index=None): + if key in self._channels: + return + self._channels[key] = widget + if index is None: + self._add_tab_in_default_spot(widget, key) + else: + self.base.insertTab(index, widget.base, key.name) + self.set_tab_icon(key, TabIcon.IDLE) + + def _add_tab_in_default_spot(self, widget, key): + if key.name.endswith(PARTY_CHANNEL_SUFFIX): + tab_name = "Party Channel" + else: + tab_name = key.name + if key.type == ChannelType.PRIVATE: + self.base.addTab(widget.base, tab_name) + return + try: + last_public_tab = max([ + self.base.indexOf(w.base) + for cid, w in self._channels.items() + if cid.type == ChannelType.PUBLIC and cid != key + ]) + self.base.insertTab(last_public_tab + 1, widget.base, tab_name) + return + except ValueError: + pass + try: + first_private_tab = min([ + self.base.indexOf(w.base) + for cid, w in self._channels.items() + if cid.type == ChannelType.PRIVATE and cid != key + ]) + self.base.insertTab(first_private_tab, widget.base, tab_name) + return + except ValueError: + pass + self.base.addTab(widget.base, tab_name) + + def remove_channel(self, key): + widget = self._channels.pop(key, None) + if widget is None: + return + self.base.removeTab(self.base.indexOf(widget.base)) + + def write_server_message(self, msg): + self.form.serverLogArea.appendPlainText(msg) + + def _at_tab_close_request(self, idx): + self.channel_quit_request.emit(self.base.widget(idx).cid) + + def switch_to_channel(self, key): + widget = self._channels.get(key, None) + if widget is None: + return + self.base.setCurrentIndex(self.base.indexOf(widget.base)) + + def set_tab_icon(self, key, name): + icon = self._theme.icon("chat/tabicon/{}.png".format(name.value)) + widget = self._channels.get(key, None) + if widget is None: + return + idx = self.base.indexOf(widget.base) + self.base.setTabIcon(idx, icon) + + def alert_tab(self): + QApplication.alert(self.base) + + def _index_to_cid(self, idx): + for cid in self._channels: + if idx == self.base.indexOf(self._channels[cid].base): + return cid + return None + + def _at_tab_changed(self, idx): + cid = self._index_to_cid(idx) + if cid is None: + return + self.tab_changed.emit(cid) + + def current_channel(self): + current_idx = self.base.currentIndex() + return self._index_to_cid(current_idx) diff --git a/src/chat/chatlineedit.py b/src/chat/chatlineedit.py index 938437f52..0370531e0 100644 --- a/src/chat/chatlineedit.py +++ b/src/chat/chatlineedit.py @@ -4,15 +4,17 @@ @author: thygrrr """ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets class ChatLineEdit(QtWidgets.QLineEdit): """ - A special promoted QLineEdit that is used in channel.ui to provide a mirc-style editing experience - with completion and history. + A special promoted QLineEdit that is used in channel.ui to provide a + mirc-style editing experience with completion and history. LATER: History and tab completion support """ + def __init__(self, parent): QtWidgets.QLineEdit.__init__(self, parent) self.returnPressed.connect(self.on_line_entered) @@ -20,27 +22,28 @@ def __init__(self, parent): self.currentHistoryIndex = None self.historyShown = False self.completionStarted = False - self.chatters = {} + self.channel = None self.LocalChatterNameList = [] self.currenLocalChatter = None - def set_chatters(self, chatters): - self.chatters = chatters + def set_channel(self, channel): + self.channel = channel def event(self, event): - if event.type() == QtCore.QEvent.KeyPress: - # Swallow a selection of keypresses that we want for our history support. - if event.key() == QtCore.Qt.Key_Tab: + if event.type() == QtCore.QEvent.Type.KeyPress: + # Swallow a selection of keypresses that we want for our history + # support. + if event.key() == QtCore.Qt.Key.Key_Tab: self.try_completion() return True - elif event.key() == QtCore.Qt.Key_Space: + elif event.key() == QtCore.Qt.Key.Key_Space: self.accept_completion() return QtWidgets.QLineEdit.event(self, event) - elif event.key() == QtCore.Qt.Key_Up: + elif event.key() == QtCore.Qt.Key.Key_Up: self.cancel_completion() self.prev_history() return True - elif event.key() == QtCore.Qt.Key_Down: + elif event.key() == QtCore.Qt.Key.Key_Down: self.cancel_completion() self.next_history() return True @@ -57,7 +60,7 @@ def on_line_entered(self): self.currentHistoryIndex = len(self.history) - 1 def showEvent(self, event): - self.setFocus(True) + self.setFocus() return QtWidgets.QLineEdit.showEvent(self, event) def try_completion(self): @@ -67,28 +70,38 @@ def try_completion(self): return # no completion if last character is a space if self.text().rfind(" ") == (len(self.text()) - 1): - return + return - self.completionStarted = True + self.completionStarted = True self.LocalChatterNameList = [] - self.completionText = self.text().split()[-1] # take last word from line - self.completionLine = self.text().rstrip(self.completionText) # store line to be completed without the completion string - - # make a copy of users because the list might change frequently giving all kind of problems - for chatter in self.chatters: - if chatter.name.lower().startswith(self.completionText.lower()): - self.LocalChatterNameList.append(chatter.name) - + # take last word from line + self.completionText = self.text().split()[-1] + # store line to be completed without the completion string + self.completionLine = self.text().rstrip(self.completionText) + + # make a copy of users because the list might change frequently + # giving all kind of problems + if self.channel is not None: + for cc in self.channel.chatters.values(): + name = cc.chatter.name + if name.lower().startswith(self.completionText.lower()): + self.LocalChatterNameList.append(name) + if len(self.LocalChatterNameList) > 0: - self.LocalChatterNameList.sort(key=lambda chatter: chatter.lower()) + self.LocalChatterNameList.sort( + key=lambda chatter: chatter.lower(), + ) self.currenLocalChatter = 0 - self.setText(self.completionLine + self.LocalChatterNameList[self.currenLocalChatter]) + localName = self.LocalChatterNameList[self.currenLocalChatter] + self.setText(self.completionLine + localName) else: self.currenLocalChatter = None else: if self.currenLocalChatter is not None: - self.currenLocalChatter = (self.currenLocalChatter + 1) % len(self.LocalChatterNameList) - self.setText(self.completionLine + self.LocalChatterNameList[self.currenLocalChatter]) + self.currenLocalChatter += 1 + self.currenLocalChatter %= len(self.LocalChatterNameList) + localName = self.LocalChatterNameList[self.currenLocalChatter] + self.setText(self.completionLine + localName) def accept_completion(self): self.completionStarted = False @@ -98,14 +111,21 @@ def cancel_completion(self): def prev_history(self): if self.currentHistoryIndex is not None: # no history nothing to do - if self.currentHistoryIndex > 0 and self.historyShown: # check for boundaries and only change index is hostory is alrady shown + # check for boundaries and only change index is history is already + # shown + if self.currentHistoryIndex > 0 and self.historyShown: self.currentHistoryIndex -= 1 self.historyShown = True self.setText(self.history[self.currentHistoryIndex]) - + def next_history(self): if self.currentHistoryIndex is not None: - if self.currentHistoryIndex < len(self.history)-1 and self.historyShown: # check for boundaries and only change index is hostory is alrady shown + # check for boundaries and only change index is history is already + # shown + if ( + self.currentHistoryIndex < len(self.history) - 1 + and self.historyShown + ): self.currentHistoryIndex += 1 self.historyShown = True - self.setText(self.history[self.currentHistoryIndex]) + self.setText(self.history[self.currentHistoryIndex]) diff --git a/src/chat/chatter.py b/src/chat/chatter.py deleted file mode 100644 index 31669c6d4..000000000 --- a/src/chat/chatter.py +++ /dev/null @@ -1,562 +0,0 @@ -from PyQt5 import QtWidgets, QtCore, QtGui -from PyQt5.QtCore import QUrl -from PyQt5.QtNetwork import QNetworkRequest -from chat._avatarWidget import AvatarWidget -import time -from urllib import parse - -from fa.replay import replay -from fa import maps - -import util -from config import Settings - -from model.game import GameState -from client.aliasviewer import AliasWindow -from chat.gameinfo import SensitiveMapInfoChecker -from downloadManager import PreviewDownloadRequest - -""" -A chatter is the representation of a person on IRC, in a channel's nick list. -There are multiple chatters per channel. -There can be multiple chatters for every Player in the Client. -""" - - -class Chatter(QtWidgets.QTableWidgetItem): - SORT_COLUMN = 2 - AVATAR_COLUMN = 1 - RANK_COLUMN = 0 - STATUS_COLUMN = 3 - MAP_COLUMN = 4 - - RANK_ELEVATION = 0 - RANK_FRIEND = 1 - RANK_USER = 2 - RANK_NONPLAYER = 3 - RANK_FOE = 4 - - def __init__(self, parent, user, channel, chat_widget, me): - QtWidgets.QTableWidgetItem.__init__(self, None) - - # TODO: for now, userflags and ranks aren't properly interpreted :-/ - # This is impractical if an operator reconnects too late. - self.parent = parent - self.chat_widget = chat_widget - self.channel = channel - - self._me = me - self._me.relationsUpdated.connect(self._check_player_relation) - self._me.ircRelationsUpdated.connect(self._check_user_relation) - - self._map_dl_request = PreviewDownloadRequest() - self._map_dl_request.done.connect(self._on_map_downloaded) - - self._aliases = AliasWindow(self.parent) - self._game_info_hider = SensitiveMapInfoChecker(self._me) - - self.setFlags(QtCore.Qt.ItemIsEnabled) - self.setTextAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) - self.avatarTip = "" - - self.avatarItem = QtWidgets.QTableWidgetItem() - self.avatarItem.setFlags(QtCore.Qt.ItemIsEnabled) - self.avatarItem.setTextAlignment(QtCore.Qt.AlignHCenter) - - self.rankItem = QtWidgets.QTableWidgetItem() - self.rankItem.setFlags(QtCore.Qt.ItemIsEnabled) - self.rankItem.setTextAlignment(QtCore.Qt.AlignHCenter) - - self.statusItem = QtWidgets.QTableWidgetItem() - self.statusItem.setFlags(QtCore.Qt.ItemIsEnabled) - self.statusItem.setTextAlignment(QtCore.Qt.AlignHCenter) - - self.mapItem = QtWidgets.QTableWidgetItem() - self.mapItem.setFlags(QtCore.Qt.ItemIsEnabled) - self.mapItem.setTextAlignment(QtCore.Qt.AlignHCenter) - - self._user = None - self._user_player = None - self._user_game = None - # This updates the above three and the widget - self.user = user - - row = self.parent.rowCount() - self.parent.insertRow(row) - - self.parent.setItem(row, Chatter.SORT_COLUMN, self) - - self.parent.setItem(self.row(), Chatter.RANK_COLUMN, self.rankItem) - self.parent.setItem(self.row(), Chatter.AVATAR_COLUMN, self.avatarItem) - self.parent.setItem(self.row(), Chatter.STATUS_COLUMN, self.statusItem) - self.parent.setItem(self.row(), Chatter.MAP_COLUMN, self.mapItem) - - @property - def user(self): - return self._user - - @user.setter - def user(self, value): - if self._user is not None: - self.user_player = None # Clears game as well - self._user.updated.disconnect(self.update_user) - self._user.newPlayer.disconnect(self._set_user_player) - - self._user = value - self.update_user() - - if self._user is not None: - self._user.updated.connect(self.update_user) - self._user.newPlayer.connect(self._set_user_player) - self.user_player = self._user.player - - def _set_user_player(self, user, player): - self.user_player = player - - @property - def user_player(self): - return self._user_player - - @user_player.setter - def user_player(self, value): - if self._user_player is not None: - self.user_game = None - self._user_player.updated.disconnect(self.update_player) - self._user_player.newCurrentGame.disconnect(self._set_user_game) - - self._user_player = value - self.update_player() - - if self._user_player is not None: - self._user_player.updated.connect(self.update_player) - self._user_player.newCurrentGame.connect(self._set_user_game) - self.user_game = self._user_player.currentGame - - def _set_user_game(self, player, game): - self.user_game = game - - @property - def user_game(self): - return self._user_game - - @user_game.setter - def user_game(self, value): - if self._user_game is not None: - self._user_game.gameUpdated.disconnect(self.update_game) - self._user_game.liveReplayAvailable.disconnect(self.update_game) - - self._user_game = value - self.update_game() - - if self._user_game is not None: - self._user_game.gameUpdated.connect(self.update_game) - self._user_game.liveReplayAvailable.connect(self.update_game) - - def _check_player_relation(self, players): - if self.user_player is None: - return - - if self.user_player.id in players: - self.set_color() - self._verify_sort_order() - - def _check_user_relation(self, users): - if self.user.name in users: - self.set_color() - self._verify_sort_order() - - def is_filtered(self, _filter): - clan = None if self.user_player is None else self.user_player.clan - clan = clan if clan is not None else "" - name = self.user.name - if _filter in clan.lower() or _filter in name.lower(): - return True - return False - - def set_visible(self, visible): - if visible: - self.tableWidget().showRow(self.row()) - else: - self.tableWidget().hideRow(self.row()) - - def __ge__(self, other): - """ Comparison operator used for item list sorting """ - return not self.__lt__(other) - - def __lt__(self, other): - """ Comparison operator used for item list sorting """ - self_rank = self.get_user_rank(self) - other_rank = self.get_user_rank(other) - - if self._me.login is not None: - if self.user.name == self._me.login: - return True - if other.user.name == self._me.login: - return False - - # if not same rank sort - if self_rank != other_rank: - return self_rank < other_rank - - # Default: Alphabetical - return self.user.name.lower() < other.user.name.lower() - - def _verify_sort_order(self): - if self.row() != -1: - self.channel.verify_sort_order(self) - - def _get_id_name(self): - _id = -1 if self.user_player is None else self.user_player.id - name = self.user.name - return _id, name - - def get_user_rank(self, user): - # TODO: Add subdivision for admin? - me = self._me - _id, name = user._get_id_name() - if user.mod_elevation(): - return self.RANK_ELEVATION - if me.isFriend(_id, name): - return self.RANK_FRIEND - (2 if self.chat_widget.client.friendsontop else 0) - if me.isFoe(_id, name): - return self.RANK_FOE - if user.user_player is not None: - return self.RANK_USER - - return self.RANK_NONPLAYER - - def mod_elevation(self): - if not self.user.is_mod(self.channel.name): - return None - return self.user.elevation[self.channel.name] - - def update_avatar(self): - # FIXME: prodding the underlying C++ object to see if it exists - # Needed if we're gone while downloading our avatar - # We don't subclass QObject, so we have to do it this way - try: - self.isSelected() - except RuntimeError: - return - try: - avatar = self.user_player.avatar - except AttributeError: - avatar = None - - if avatar is not None: - self.avatarTip = avatar["tooltip"] - url = parse.unquote(avatar["url"]) - avatarPix = util.respix(url) - - if avatarPix: - self.avatarItem.setIcon(QtGui.QIcon(avatarPix)) - self.avatarItem.setToolTip(self.avatarTip) - else: - if util.addcurDownloadAvatar(url, self): - self.chat_widget.nam.get(QNetworkRequest(QUrl(url))) - else: - # No avatar set. - self.avatarItem.setIcon(QtGui.QIcon()) - self.avatarItem.setToolTip(None) - - def set_chatter_name(self): - if self.user_player is not None and self.user_player.clan is not None: - self.setText("[{}]{}".format(self.user_player.clan, - self.user.name)) - else: - self.setText(self.user.name) - - def update_user(self): - self.set_chatter_name() - self.set_color() - self._verify_sort_order() - - def update_player(self): - self.set_chatter_name() - self.update_rank() - self.update_country() - self.update_avatar() - - def update_country(self): - player = self.user_player - if player is None: - self.setIcon(QtGui.QIcon()) - self.setToolTip("") - return - # server sends '' for no ip2country-resolution - if player.country is None or player.country == '': - country = '__' - else: - country = player.country - self.setIcon(util.THEME.icon("chat/countries/{}.png".format(country.lower()))) - self.setToolTip(country) - - def update_rank(self): - player = self.user_player - if player is None: - self.rankItem.setIcon(util.THEME.icon("chat/rank/civilian.png")) - self.rankItem.setToolTip("IRC User") - return - # chr(0xB1) = +- - formatting = ("Global Rating: {} ({} Games) [{}\xb1{}]\n" - "Ladder Rating: {} [{}\xb1{}]") - tooltip_str = formatting.format((int(player.rating_estimate())), - player.number_of_games, - int(player.rating_mean), - int(player.rating_deviation), - int(player.ladder_estimate()), - int(player.ladder_rating_mean), - int(player.ladder_rating_deviation)) - league = player.league - if league is not None: - icon_str = league["league"] - tooltip_str = "Division : {}\n{}".format(league["division"], - tooltip_str) - else: - icon_str = "newplayer" - self.rankItem.setIcon(util.THEME.icon("chat/rank/{}.png".format(icon_str))) - self.rankItem.setToolTip(tooltip_str) - - def update_game(self): - self.update_status_tooltip() - self.update_status_icon() - self.update_map() - - def update_status_tooltip(self): - # Status tooltip handling - game = self.user_game - should_hide_info = self._game_info_hider.has_sensitive_data(game) - if game is not None and not game.closed(): - if should_hide_info: - game_map = "[delayed reveal]" - game_title = "[delayed reveal]" - else: - game_map = game.mapdisplayname - game_title = game.title - private_str = " (private)" if game.password_protected else "" - delay_str = "" - if game.state == GameState.OPEN: - if game.host == self.user.name: - head_str = "Hosting{private} game" - else: - head_str = "In{private} Lobby (host {host})" - elif game.state == GameState.PLAYING: - head_str = "Playing{delay}" - if not game.has_live_replay: - delay_str = " - LIVE DELAY (5 Min)" - else: # game.state == something else - head_str = "Playing maybe ..." - formatting = "{}
title: {}
mod: {}
map: {}
players: {} / {}
id: {}" - game_str = formatting.format(head_str.format(private=private_str, delay=delay_str, host=game.host), - game_title, game.featured_mod, game_map, - game.num_players, game.max_players, game.uid) - else: # game is None or closed - game_str = "Idle" - - self.statusItem.setToolTip(game_str) - - def update_status_icon(self): - # Status icon handling - game = self.user_game - if game is not None and not game.closed(): - if game.state == GameState.OPEN: - if game.host == self.user.name: - icon_str = "host" - else: - icon_str = "lobby" - elif game.state == GameState.PLAYING: - if game.has_live_replay: - icon_str = "playing" - else: - icon_str = "playing5" - else: # game.state == something else - icon_str = "unknown" - else: # game is None or closed - icon_str = "none" - - self.statusItem.setIcon(util.THEME.icon("chat/status/%s.png" % icon_str)) - - def update_map(self): - # Map icon handling - if we're in game, show the map if toggled on - game = self.user_game - should_hide_info = self._game_info_hider.has_sensitive_data(game) - if game is not None and not game.closed() and util.settings.value("chat/chatmaps", False): - if should_hide_info: - self.mapItem.setIcon(util.THEME.icon("chat/status/unknown.png")) - self.mapItem.setToolTip("[delayed reveal]") - else: - mapname = game.mapname - icon = maps.preview(mapname) - if not icon: - dler = self.chat_widget.client.map_downloader - dler.download_preview(mapname, self._map_dl_request) - else: - self.mapItem.setIcon(icon) - - self.mapItem.setToolTip(game.mapdisplayname) - else: - self.mapItem.setIcon(QtGui.QIcon()) - self.mapItem.setToolTip("") - - def _on_map_downloaded(self, mapname, result): - if self.user_game is None or self.user_game.mapname != mapname: - return - path, is_local = result - self.mapItem.setIcon(util.THEME.icon(path, is_local)) - - def update(self): - self.update_user() - self.update_player() - self.update_game() - - def set_color(self): - # FIXME - we should really get colors in the constructor - pcolors = self.chat_widget.client.player_colors - elevation = self.mod_elevation() - _id, name = self._get_id_name() - if elevation is not None: - color = pcolors.getModColor(elevation, _id, name) - else: - color = pcolors.getUserColor(_id, name) - self.setForeground(QtGui.QColor(color)) - - def view_aliases(self): - if self.user_player is not None: - player_id = self.user_player.id - else: - player_id = None - self._aliases.view_aliases(self.user.name, player_id) - - def select_avatar(self): - avatarSelection = AvatarWidget(self.chat_widget.client, self.user.name, personal=True) - avatarSelection.exec_() - - def add_avatar(self): - avatarSelection = AvatarWidget(self.chat_widget.client, self.user.name) - avatarSelection.exec_() - - def kick(self): - pass - - def double_clicked(self, item): - # filter yourself - if self._me.login is not None: - if self._me.login == self.user.name: - return - # Chatter name clicked - if item == self: - self.chat_widget.open_query(self.user, activate=True) # open and activate query window - - elif item == self.statusItem: - self._interact_with_game() - - def _interact_with_game(self): - game = self.user_game - if game is None or game.closed(): - return - - url = game.url(self.user_player.id) - if game.state == GameState.OPEN: - self.join_in_game(url) - elif game.state == GameState.PLAYING: - self.view_replay(url) - - def pressed(self, item): - menu = QtWidgets.QMenu(self.parent) - - def menu_add(action_str, action_connect, separator=False): - if separator: - menu.addSeparator() - action = QtWidgets.QAction(action_str, menu) - action.triggered.connect(action_connect) # Triggers - menu.addAction(action) - - player = self.user_player - game = self.user_game - _id, name = self._get_id_name() - - if player is None or self._me.player is None: - is_me = False - else: - is_me = player.id == self._me.player.id - - if is_me: # only for us. Either way, it will display our avatar, not anyone avatar. - menu_add("Select Avatar", self.select_avatar) - - # power menu - if self.chat_widget.client.power > 1: - # admin and mod menus - menu_add("Assign avatar", self.add_avatar, True) - - if self.chat_widget.client.power == 2: - - def send_the_orcs(): - route = Settings.get('mordor/host') - if _id != -1: - QtGui.QDesktopServices.openUrl(QUrl("{}/users/{}".format(route, _id))) - else: - QtGui.QDesktopServices.openUrl(QUrl("{}/users/{}".format(route, name))) - - menu_add("Send the Orcs", send_the_orcs, True) - menu_add("Close Game", lambda: self.chat_widget.client.closeFA(name)) - menu_add("Close FAF Client", lambda: self.chat_widget.client.closeLobby(name)) - - menu_add("View Aliases", self.view_aliases, True) - if player is not None: # not for irc user - if int(player.ladder_estimate()) != 0: # not for 'never played ladder' - menu_add("View in Leaderboards", self.view_in_leaderboards) - - # Don't allow self to be invited to a game, or join one - if game is not None and not is_me: - if game.state == GameState.OPEN: - menu_add("Join hosted Game", self._interact_with_game, True) - elif game.state == GameState.PLAYING: - time_running = time.time() - game.launched_at - if game.has_live_replay: - time_format = '%M:%S' if time_running < 60 * 60 else '%H:%M:%S' - duration_str = time.strftime(time_format, time.gmtime(time_running)) - action_str = "View Live Replay (runs " + duration_str + ")" - else: - wait_str = time.strftime('%M:%S', time.gmtime(game.LIVE_REPLAY_DELAY_SECS - time_running)) - action_str = "WAIT " + wait_str + " to view Live Replay" - menu_add(action_str, self._interact_with_game, True) - - if player is not None: # not for irc user - menu_add("View Replays in Vault", self.view_vault_replay, True) - - # Friends and Foes Lists - def player_or_irc_action(f, irc_f): - _id, name = self._get_id_name() - if player is not None: - return lambda: f(_id) - else: - return lambda: irc_f(name) - - cl = self.chat_widget.client - me = self._me - if is_me: # We're ourselves - pass - elif me.isFriend(_id, name): # We're a friend - menu_add("Remove friend", player_or_irc_action(cl.remFriend, me.remIrcFriend), True) - elif me.isFoe(_id, name): # We're a foe - menu_add("Remove foe", player_or_irc_action(cl.remFoe, me.remIrcFoe), True) - else: # We're neither - menu_add("Add friend", player_or_irc_action(cl.addFriend, me.addIrcFriend), True) - # FIXME - chatwidget sets mod status very inconsistently - if self.mod_elevation() is None: # so disable foeing mods for now - menu_add("Add foe", player_or_irc_action(cl.addFoe, me.addIrcFoe)) - - # Finally: Show the popup - menu.popup(QtGui.QCursor.pos()) - - def view_vault_replay(self): - self.chat_widget.client.searchUserReplays(self.user.name) - - def join_in_game(self, url): - self.chat_widget.client.joinGameFromURL(url) - - def view_replay(self, url): - replay(url) - - def view_in_leaderboards(self): - self.chat_widget.client.viewUserLeaderboards(self.user_player) diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py new file mode 100644 index 000000000..b1b304cb7 --- /dev/null +++ b/src/chat/chatter_menu.py @@ -0,0 +1,232 @@ +import logging +from enum import Enum + +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QMenu + +from model.game import GameState + +logger = logging.getLogger(__name__) + + +class ChatterMenuItems(Enum): + SELECT_AVATAR = "Select avatar" + SEND_ORCS = "Send the Orcs" + CLOSE_GAME = "Close Game" + KICK_PLAYER = "Close FAF Client" + VIEW_ALIASES = "View aliases" + VIEW_IN_LEADERBOARDS = "View in Leaderboards" + JOIN_GAME = "Join hosted Game" + VIEW_LIVEREPLAY = "View live replay" + VIEW_REPLAYS = "View Replays in Vault" + ADD_FRIEND = "Add friend" + ADD_FOE = "Add foe" + REMOVE_FRIEND = "Remove friend" + REMOVE_FOE = "Remove foe" + ADD_CHATTERBOX = "Ignore" + REMOVE_CHATTERBOX = "Unignore" + COPY_USERNAME = "Copy username" + INVITE_TO_PARTY = "Invite to party" + KICK_FROM_PARTY = "Kick from party" + + +class ChatterMenu: + def __init__( + self, me, power_tools, parent_widget, avatar_widget_builder, + alias_viewer, client_window, game_runner, + ): + self._me = me + self._power_tools = power_tools + self._parent_widget = parent_widget + self._avatar_widget_builder = avatar_widget_builder + self._alias_viewer = alias_viewer + self._client_window = client_window + self._game_runner = game_runner + + @classmethod + def build( + cls, me, power_tools, parent_widget, avatar_widget_builder, + alias_viewer, client_window, game_runner, **kwargs + ): + return cls( + me, power_tools, parent_widget, avatar_widget_builder, + alias_viewer, client_window, game_runner, + ) + + def actions(self, cc): + chatter = cc.chatter + player = chatter.player + game = None if player is None else player.currentGame + + if player is None or self._me.player is None: + is_me = False + else: + is_me = player.id == self._me.player.id + + yield list(self.me_actions(is_me)) + yield list(self.power_actions(self._power_tools.power)) + yield list(self.chatter_actions()) + yield list(self.player_actions(player, game, is_me)) + yield list(self.friend_actions(player, chatter, cc, is_me)) + yield list(self.ignore_actions(player, chatter, cc, is_me)) + yield list(self.party_actions(player, is_me)) + + def chatter_actions(self): + yield ChatterMenuItems.COPY_USERNAME + yield ChatterMenuItems.VIEW_ALIASES + + def me_actions(self, is_me): + if is_me: + yield ChatterMenuItems.SELECT_AVATAR + + def power_actions(self, power): + if power == 2: + yield ChatterMenuItems.SEND_ORCS + yield ChatterMenuItems.CLOSE_GAME + yield ChatterMenuItems.KICK_PLAYER + + def player_actions(self, player, game, is_me): + if game is not None and not is_me: + if game.state == GameState.OPEN: + yield ChatterMenuItems.JOIN_GAME + elif game.state == GameState.PLAYING: + yield ChatterMenuItems.VIEW_LIVEREPLAY + + if player is not None: + if player.ladder_estimate != 0: + yield ChatterMenuItems.VIEW_IN_LEADERBOARDS + yield ChatterMenuItems.VIEW_REPLAYS + + def friend_actions(self, player, chatter, cc, is_me): + if is_me: + return + id_ = -1 if player is None else player.id + name = chatter.name + if self._me.relations.model.is_friend(id_, name): + yield ChatterMenuItems.REMOVE_FRIEND + elif self._me.relations.model.is_foe(id_, name): + yield ChatterMenuItems.REMOVE_FOE + else: + yield ChatterMenuItems.ADD_FRIEND + yield ChatterMenuItems.ADD_FOE + + def ignore_actions(self, player, chatter, cc, is_me): + if is_me: + return + id_ = -1 if player is None else player.id + name = chatter.name + if self._me.relations.model.is_chatterbox(id_, name): + yield ChatterMenuItems.REMOVE_CHATTERBOX + else: + if not cc.is_mod() and not chatter.is_base_channel_mod(): + yield ChatterMenuItems.ADD_CHATTERBOX + + def party_actions(self, player, is_me): + if is_me: + return + if player is None: + return + else: + if player.id in self._client_window.games.party.memberIds: + if ( + self._me.player.id + == self._client_window.games.party.owner_id + ): + yield ChatterMenuItems.KICK_FROM_PARTY + elif player.currentGame is not None: + return + else: + yield ChatterMenuItems.INVITE_TO_PARTY + + def get_context_menu(self, data, point): + return self.menu(data.cc) + + def menu(self, cc): + menu = QMenu(self._parent_widget) + + def add_entry(item): + action = QAction(item.value, menu) + action.triggered.connect(self.handler(cc, item)) + menu.addAction(action) + + first = True + for category in self.actions(cc): + if not category: + continue + if not first: + menu.addSeparator() + for item in category: + add_entry(item) + first = False + return menu + + def handler(self, cc, kind): + chatter = cc.chatter + player = chatter.player + game = None if player is None else player.currentGame + return lambda: self._handle_action(chatter, player, game, kind) + + def _handle_action(self, chatter, player, game, kind): + Items = ChatterMenuItems + if kind == Items.COPY_USERNAME: + self._copy_username(chatter) + elif kind == Items.SEND_ORCS: + self._power_tools.actions.send_the_orcs(chatter.name) + elif kind == Items.CLOSE_GAME: + self._power_tools.view.close_game_dialog.show(chatter.name) + elif kind == Items.KICK_PLAYER: + self._power_tools.view.kick_dialog(chatter.name) + elif kind == Items.SELECT_AVATAR: + self._avatar_widget_builder().show() + elif kind in [ + Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, + Items.REMOVE_FOE, + ]: + self._handle_friends(chatter, player, kind) + elif kind in [Items.ADD_CHATTERBOX, Items.REMOVE_CHATTERBOX]: + self._handle_chatterboxes(chatter, player, kind) + elif kind == Items.VIEW_ALIASES: + self._view_aliases(chatter) + elif kind == Items.VIEW_REPLAYS: + self._client_window.view_replays(player.login) + elif kind == Items.VIEW_IN_LEADERBOARDS: + self._client_window.view_in_leaderboards(player) + elif kind in [Items.JOIN_GAME, Items.VIEW_LIVEREPLAY]: + self._game_runner.run_game_with_url(game, player.id) + elif kind == Items.INVITE_TO_PARTY: + self._client_window.invite_to_party(player.id) + elif kind == Items.KICK_FROM_PARTY: + self._client_window.games.kickPlayerFromParty(player.id) + + def _copy_username(self, chatter): + QApplication.clipboard().setText(chatter.name) + + def _handle_friends(self, chatter, player, kind): + ctl = self._me.relations.controller + ctl = ctl.faf if player is not None else ctl.irc + uid = player.id if player is not None else chatter.name + + Items = ChatterMenuItems + if kind == Items.ADD_FRIEND: + ctl.friends.add(uid) + elif kind == Items.REMOVE_FRIEND: + ctl.friends.remove(uid) + if kind == Items.ADD_FOE: + ctl.foes.add(uid) + elif kind == Items.REMOVE_FOE: + ctl.foes.remove(uid) + + def _handle_chatterboxes(self, chatter, player, kind): + ctl = self._me.relations.controller + ctl = ctl.faf if player is not None else ctl.irc + uid = player.id if player is not None else chatter.name + + Items = ChatterMenuItems + if kind == Items.ADD_CHATTERBOX: + ctl.chatterboxes.add(uid) + elif kind == Items.REMOVE_CHATTERBOX: + ctl.chatterboxes.remove(uid) + + def _view_aliases(self, chatter): + self._alias_viewer.view_aliases(chatter.name) diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py new file mode 100644 index 000000000..88262a380 --- /dev/null +++ b/src/chat/chatter_model.py @@ -0,0 +1,617 @@ +from enum import Enum +from enum import IntEnum +from typing import Any + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QRect +from PyQt6.QtCore import QSortFilterProxyModel +from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QColor +from PyQt6.QtGui import QIcon + +import util +from chat.chatter_model_item import ChatterModelItem +from chat.chatterlistview import ChatterListView +from chat.gameinfo import SensitiveMapInfoChecker +from fa import maps +from model.game import GameState +from model.rating import RatingType +from util.qt_list_model import QtListModel + + +class ChatterModel(QtListModel): + def __init__(self, channel, item_builder): + QtListModel.__init__(self, item_builder) + self._channel = channel + + if self._channel is not None: + self._channel.added_chatter.connect(self.add_chatter) + self._channel.removed_chatter.connect(self.remove_chatter) + + for chatter in self._channel.chatters: + self.add_chatter(chatter) + + @classmethod + def build(cls, channel, **kwargs): + builder = ChatterModelItem.builder(**kwargs) + return cls(channel, builder) + + def add_chatter(self, chatter): + self._add_item(chatter, chatter.id_key) + + def remove_chatter(self, chatter): + self._remove_item(chatter.id_key) + + def clear_chatters(self): + self._clear_items() + + def invalidate_items(self): + start = self.index(0) + end = self.index(len(self._itemlist) - 1) + self.dataChanged.emit(start, end) + + +class ChatterRank(IntEnum): + FRIEND_ON_TOP = -1 + ELEVATED = 0 + FRIEND = 1 + CLANNIE = 2 + USER = 3 + NONPLAYER = 4 + CHATTERBOX = 5 + FOE = 6 + + +class ChatterSortFilterModel(QSortFilterProxyModel): + def __init__(self, model, me, user_relations, chat_config): + QSortFilterProxyModel.__init__(self) + self._me = me + self._user_relations = user_relations + self._chat_config = chat_config + self._chat_config.updated.connect(self._check_sort_changed) + self.setSourceModel(model) + self.sort(0) + + @classmethod + def build(cls, model, me, user_relations, chat_config, **kwargs): + return cls(model, me, user_relations, chat_config) + + def lessThan(self, leftIndex, rightIndex): + source = self.sourceModel() + left = source.data(leftIndex, Qt.ItemDataRole.DisplayRole) + right = source.data(rightIndex, Qt.ItemDataRole.DisplayRole) + + comp_list = [self._lt_me, self._lt_rank, self._lt_alphabetical] + for lt in comp_list: + if lt(left, right): + return True + elif lt(right, left): + return False + return False + + def _lt_me(self, left, right): + if self._me.login is None: + return False + return ( + left.chatter.name == self._me.login + and right.chatter.name != self._me.login + ) + + def _lt_rank(self, left, right): + left_rank = self._get_user_rank(left) + right_rank = self._get_user_rank(right) + return left_rank < right_rank + + def _lt_alphabetical(self, left, right): + return left.chatter.name.lower() < right.chatter.name.lower() + + def _get_user_rank(self, item): + pid = item.player.id if item.player is not None else None + name = item.chatter.name + is_friend = self._user_relations.is_friend(pid, name) + if self._chat_config.friendsontop and is_friend: + return ChatterRank.FRIEND_ON_TOP + if item.cc.is_mod(): + return ChatterRank.ELEVATED + if is_friend: + return ChatterRank.FRIEND + if self._me.is_clannie(pid): + return ChatterRank.CLANNIE + if self._user_relations.is_foe(pid, name): + return ChatterRank.FOE + if self._user_relations.is_chatterbox(pid, name): + return ChatterRank.CHATTERBOX + if item.player is not None: + return ChatterRank.USER + return ChatterRank.NONPLAYER + + def filterAcceptsRow(self, row: int, parent: QtCore.QModelIndex) -> bool: + source = self.sourceModel() + index = source.index(row, 0, parent) + if not index.isValid(): + return False + data = source.data(index, Qt.ItemDataRole.DisplayRole) + displayed_name = ChatterFormat.chatter_name(data.chatter) + return self.filterRegularExpression().match(displayed_name).hasMatch() + + def _check_sort_changed(self, option): + if option == "friendsontop": + self.invalidate() + + def invalidate_items(self): + self.sourceModel().invalidate_items() + + +# TODO - place in some separate file? +class ChatterFormat: + @classmethod + def name(cls, chatter, clan): + if clan is not None: + return "[{}]{}".format(clan, chatter) + else: + return chatter + + @classmethod + def chatter_name(cls, chatter): + clan = None if chatter.player is None else chatter.player.clan + return cls.name(chatter.name, clan) + + +class ChatterItemFormatter: + def __init__(self, avatars, player_colors, info_hider): + self._avatars = avatars + self._player_colors = player_colors + self._info_hider = info_hider + + @classmethod + def build(cls, avatar_dler, player_colors, **kwargs): + info_hider = SensitiveMapInfoChecker.build(**kwargs) + return cls(avatar_dler, player_colors, info_hider) + + def map_icon(self, data): + game = data.game + if game is None or game.closed(): + should_hide_info = False + else: + should_hide_info = self._info_hider.has_sensitive_data(game) + if should_hide_info: + return None + + name = data.map_name() + return None if name is None else maps.preview(name) + + def chatter_name(self, data): + return ChatterFormat.chatter_name(data.chatter) + + def chatter_color(self, data): + pid = -1 if data.player is None else data.player.id + colors = self._player_colors + cc = data.cc + if cc.is_mod(): + return colors.get_mod_color(pid, data.chatter.name) + else: + return colors.get_user_color(pid, data.chatter.name) + + def chatter_status(self, data): + game = data.game + if game is None or game.closed(): + return "none" + if game.state == GameState.OPEN: + if game.host == data.chatter.name: + return "host" + return "lobby" + if game.state == GameState.PLAYING: + if game.has_live_replay: + return "playing" + return "playing5" + return "unknown" + + def chatter_rank(self, data): + if data.player is None: + return "civilian" + league = data.player.league + if league is None or "league" not in league: + return "newplayer" + return league["league"] + + def chatter_avatar_icon(self, data): + avatar_url = data.avatar_url() + if avatar_url is None: + return None + if avatar_url not in self._avatars.avatars: + return + return QIcon(self._avatars.avatars[avatar_url]) + + def chatter_country(self, data): + if data.player is None: + return None + country = data.player.country + if country is None or country == '': + return '__' + return country + + def rank_tooltip(self, data): + if data.player is None: + return "IRC User" + player = data.player + # chr(0xB1) = +- + formatting = ( + ("{} Rating: {} ({} Games) [{}\xb1{}]\n") * len(player.ratings) + ) + tooltip_info_list = [] + for rating_type in player.ratings.keys(): + if rating_type == RatingType.LADDER.value: + rating_name = "Ladder" + else: + rating_name = ( + rating_type.replace("_", " ").capitalize() + ) + tooltip_info_list.extend([ + rating_name, + player.rating_estimate(rating_type), + player.game_count(rating_type), + player.rating_mean(rating_type), + player.rating_deviation(rating_type), + ]) + + tooltip_str = formatting.format(*tooltip_info_list) + league = player.league + if league is not None and "division" in league: + tooltip_str = "Division : {}\n{}".format( + league["division"], + tooltip_str, + ) + return tooltip_str.strip() + + def status_tooltip(self, data): + # Status tooltip handling + game = data.game + if game is None or game.closed(): + return "Idle" + + if self._info_hider.has_sensitive_data(game): + game_map = "[delayed reveal]" + game_title = "[delayed reveal]" + else: + game_map = game.mapdisplayname + game_title = game.title + + private_str = " (private)" if game.password_protected else "" + if game.state == GameState.PLAYING and not game.has_live_replay: + delay_str = " - LIVE DELAY (5 Min)" + else: + delay_str = "" + + head_str = "" + if game.state == GameState.OPEN: + if game.host == data.player.login: + head_str = "Hosting{private} game
" + else: + head_str = "In{private} Lobby (host {host})" + elif game.state == GameState.PLAYING: + head_str = "Playing{delay}" + header = head_str.format( + private=private_str, delay=delay_str, + host=game.host, + ) + + formatting = ( + "{}
" + "title: {}
" + "mod: {}
" + "map: {}
" + "players: {} / {}
" + "id: {}" + ) + + game_str = formatting.format( + header, game_title, game.featured_mod, game_map, + game.num_players - len(game.observers), game.max_players, game.uid, + ) + return game_str + + def avatar_tooltip(self, data): + try: + return data.player.avatar["tooltip"] + except (TypeError, AttributeError, KeyError): + return None + + def map_tooltip(self, data): + if data.game is None: + return None + if self._info_hider.has_sensitive_data(data.game): + return "[delayed reveal]" + return data.game.mapdisplayname + + def country_tooltip(self, data): + return self.chatter_country(data) + + def nick_tooltip(self, data): + return self.country_tooltip(data) + + +class ChatterItemDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, layout, formatter): + QtWidgets.QStyledItemDelegate.__init__(self) + self.layout = layout + self._formatter = formatter + + @classmethod + def build(cls, layout, **kwargs): + formatter = ChatterItemFormatter.build(**kwargs) + return cls(layout, formatter) + + def update_width(self, size): + current_size = self.layout.size + if size.width() != current_size.width(): + current_size.setWidth(size.width()) + self.layout.size = current_size + + def paint(self, painter, option, index): + painter.save() + + data = index.data() + + self._draw_clear_option(painter, option) + self._handle_highlight(painter, option) + + painter.translate(option.rect.left(), option.rect.top()) + + Elems = ChatterLayoutElements + draw = { + Elems.NICK: self._draw_nick, + Elems.STATUS: self._draw_status, + Elems.MAP: self._draw_map, + Elems.RANK: self._draw_rank, + Elems.AVATAR: self._draw_avatar, + Elems.COUNTRY: self._draw_country, + } + for item in self.layout.visible_items(): + draw[item](painter, data) + + painter.restore() + + def _draw_clear_option(self, painter, option): + option.icon = QtGui.QIcon() + option.text = "" + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) + + def _handle_highlight( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + ) -> None: + if option.state & QtWidgets.QStyle.StateFlag.State_Selected: + painter.fillRect(option.rect, option.palette.highlight) + + def _draw_nick(self, painter: QtGui.QPainter, data: str) -> None: + text = self._formatter.chatter_name(data) + color = QColor(self._formatter.chatter_color(data)) + clip = QRect(self.layout.sizes[ChatterLayoutElements.NICK]) + text = self._get_elided_text(painter, text, clip.width()) + + painter.save() + pen = painter.pen() + pen.setColor(color) + painter.setPen(pen) + + painter.drawText(clip, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text) + + painter.restore() + + def _get_elided_text(self, painter: QtGui.QPainter, text: str, width: int) -> str: + metrics = painter.fontMetrics() + return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width) + + def _draw_status(self, painter, data): + status = self._formatter.chatter_status(data) + icon = util.THEME.icon("chat/status/{}.png".format(status)) + self._draw_icon(painter, icon, ChatterLayoutElements.STATUS) + + # TODO - handle optionality of maps + def _draw_map(self, painter, data): + icon = self._formatter.map_icon(data) + if not icon: + return + self._draw_icon(painter, icon, ChatterLayoutElements.MAP) + + def _draw_rank(self, painter, data): + rank = self._formatter.chatter_rank(data) + icon = util.THEME.icon("chat/rank/{}.png".format(rank)) + self._draw_icon(painter, icon, ChatterLayoutElements.RANK) + + def _draw_avatar(self, painter, data): + icon = self._formatter.chatter_avatar_icon(data) + if not icon: + return + self._draw_icon(painter, icon, ChatterLayoutElements.AVATAR) + + def _draw_country(self, painter, data): + country = self._formatter.chatter_country(data) + if country is None: + return + icon = util.THEME.icon("chat/countries/{}.png".format(country.lower())) + self._draw_icon(painter, icon, ChatterLayoutElements.COUNTRY) + + def _draw_icon(self, painter, icon, element): + rect = self.layout.sizes[element] + icon.paint(painter, rect, QtCore.Qt.AlignmentFlag.AlignCenter) + + def sizeHint(self, option, index): + return self.layout.size + + def get_tooltip(self, data, elem): + if elem is None: + return None + return self._tooltip(data, elem) + + def _tooltip(self, data, item): + if item == ChatterLayoutElements.RANK: + return self._formatter.rank_tooltip(data) + elif item == ChatterLayoutElements.STATUS: + return self._formatter.status_tooltip(data) + elif item == ChatterLayoutElements.AVATAR: + return self._formatter.avatar_tooltip(data) + elif item == ChatterLayoutElements.MAP: + return self._formatter.map_tooltip(data) + elif item == ChatterLayoutElements.COUNTRY: + return self._formatter.country_tooltip(data) + elif item == ChatterLayoutElements.NICK: + return self._formatter.nick_tooltip(data) + + +class ChatterLayoutElements(Enum): + RANK = "rankBox" + STATUS = "statusBox" + AVATAR = "avatarBox" + MAP = "mapBox" + COUNTRY = "countryBox" + NICK = "nickBox" + + +class ChatterLayout(QObject): + """Provides layout info for delegate using Qt widget layouts.""" + LAYOUT_FILE = "chat/chatter.ui" + + def __init__(self, theme, chat_config): + QObject.__init__(self) + self._theme = theme + self._chat_config = chat_config + self.sizes = {} + self.load_layout() + self._chat_config.updated.connect(self._at_chat_config_updated) + self._set_visibility() + + @classmethod + def build(cls, theme, chat_config, **kwargs): + return cls(theme, chat_config) + + def load_layout(self): + formc, basec = self._theme.loadUiType(self.LAYOUT_FILE) + self._form = formc() + self._base = basec() + self._form.setupUi(self._base) + self._size = self._base.size() + + def _at_chat_config_updated(self, setting): + if setting == "hide_chatter_items": + self._set_visibility() + + def _set_visibility(self): + for item in ChatterLayoutElements: + self._set_visible(item) + self._update_layout() + + def _set_visible(self, item): + getattr(self._form, item.value).setVisible(self.is_visible(item)) + + def is_visible(self, item): + return item not in self._chat_config.hide_chatter_items + + def visible_items(self): + return [i for i in ChatterLayoutElements if self.is_visible(i)] + + @property + def size(self): + return self._base.size() + + @size.setter + def size(self, size): + self._size = size + self._update_layout() + + def element_at_point(self, point): + for elem in ChatterLayoutElements: + if self.sizes[elem].contains(point) and self.is_visible(elem): + return elem + return None + + def _update_layout(self): + self._base.resize(self._size) + self._force_layout_recalculation() + for elem in ChatterLayoutElements: + self.sizes[elem] = self._get_widget_position(elem.value) + + def _force_layout_recalculation(self): + layout = self._base.layout() + layout.update() + layout.activate() + + def _get_widget_position(self, name): + widget = getattr(self._form, name) + size = widget.rect() + top_left = widget.mapTo(self._base, size.topLeft()) + size.moveTopLeft(top_left) + return size + + +class ChatterEventFilter(QObject): + double_clicked = pyqtSignal(object, object) + + def __init__(self, chatter_layout, tooltip_handler, menu_handler): + QObject.__init__(self) + self._chatter_layout = chatter_layout + self._tooltip_handler = tooltip_handler + self._menu_handler = menu_handler + + @classmethod + def build(cls, chatter_layout, tooltip_handler, menu_handler, **kwargs): + return cls(chatter_layout, tooltip_handler, menu_handler) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Type.ToolTip: + return self._handle_tooltip(obj, event) + elif event.type() == QtCore.QEvent.Type.MouseButtonRelease: + if event.button() == QtCore.Qt.MouseButton.RightButton: + return self._handle_context_menu(obj, event) + elif event.type() == QtCore.QEvent.Type.MouseButtonDblClick: + if event.button() == QtCore.Qt.MouseButton.LeftButton: + return self._handle_double_click(obj, event) + return super().eventFilter(obj, event) + + def _get_data_and_elem( + self, + widget: QtWidgets.QWidget, + event: QtGui.QMouseEvent, + ) -> tuple[Any, ChatterLayoutElements | None]: + view: ChatterListView = widget.parent() + idx = view.indexAt(event.pos()) + if not idx.isValid(): + return None, None + item_rect = view.visualRect(idx) + point = event.pos() - item_rect.topLeft() + elem = self._chatter_layout.element_at_point(point) + return idx.data(), elem + + def _handle_tooltip(self, widget: QtWidgets.QWidget, event: QtGui.QMouseEvent) -> bool: + data, elem = self._get_data_and_elem(widget, event) + if data is None: + return False + tooltip_text = self._tooltip_handler.get_tooltip(data, elem) + if tooltip_text is None: + return False + + QtWidgets.QToolTip.showText(event.globalPos(), tooltip_text, widget) + return True + + def _handle_context_menu(self, widget, event): + data, elem = self._get_data_and_elem(widget, event) + if data is None: + return False + + menu = self._menu_handler.get_context_menu(data, elem) + menu.popup(QtGui.QCursor.pos()) + return True + + def _handle_double_click(self, widget, event): + data, elem = self._get_data_and_elem(widget, event) + if data is None: + return False + self.double_clicked.emit(data, elem) + return True diff --git a/src/chat/chatter_model_item.py b/src/chat/chatter_model_item.py new file mode 100644 index 000000000..1cbd44ddf --- /dev/null +++ b/src/chat/chatter_model_item.py @@ -0,0 +1,139 @@ +from urllib import parse + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +from downloadManager import DownloadRequest +from fa import maps + + +class ChatterModelItem(QObject): + """ + UI representation of a chatter. + """ + updated = pyqtSignal(object) + + def __init__(self, cc, map_preview_dler, avatar_dler, relation_trackers): + QObject.__init__(self) + + self._preview_dler = map_preview_dler + self._avatar_dler = avatar_dler + self._relation = relation_trackers + + self._player = None + self._player_rel = None + self._game = None + self.cc = cc + + self.cc.updated.connect(self._updated) + self.chatter.updated.connect(self._updated) + self.chatter.newPlayer.connect(self._set_player) + self._chatter_rel = self._relation.chatters[self.chatter.id_key] + self._chatter_rel.updated.connect(self._updated) + + self._map_request = DownloadRequest() + self._map_request.done.connect(self._updated) + self._avatar_request = DownloadRequest() + self._avatar_request.done.connect(self._updated) + + self.player = self.chatter.player + + @classmethod + def builder( + cls, map_preview_dler, avatar_dler, relation_trackers, **kwargs + ): + def make(cc): + return cls(cc, map_preview_dler, avatar_dler, relation_trackers) + return make + + def _updated(self): + self.updated.emit(self) + + @property + def chatter(self): + return self.cc.chatter + + def _set_player(self, chatter, new_player, old_player): + self.player = new_player + self._updated() + + @property + def player(self): + return self._player + + @player.setter + def player(self, value): + if self._player is not None: + self.game = None + self._player.updated.disconnect(self._at_player_updated) + self._player.newCurrentGame.disconnect(self._set_game) + self._player_rel.updated.disconnect(self._updated) + self._player_rel = None + + self._player = value + + if self._player is not None: + self._player.updated.connect(self._at_player_updated) + self._player.newCurrentGame.connect(self._set_game) + self._player_rel = self._relation.players[self._player.id_key] + self._player_rel.updated.connect(self._updated) + self.game = self._player.currentGame + self._download_avatar_if_needed() + + def _at_player_updated(self): + self._download_avatar_if_needed() + self._updated() + + def _set_game(self, player, game): + self.game = game + self._updated() + + @property + def game(self): + return self._game + + @game.setter + def game(self, value): + if self._game is not None: + self._game.updated.disconnect(self._at_game_updated) + self._game.liveReplayAvailable.disconnect(self._updated) + + self._game = value + + if self._game is not None: + self._game.updated.connect(self._at_game_updated) + self._game.liveReplayAvailable.connect(self._updated) + self._download_map_preview_if_needed() + + def _at_game_updated(self, new, old): + if new.mapname != old.mapname: + self._download_map_preview_if_needed() + self._updated() + + def _download_map_preview_if_needed(self): + name = self.map_name() + if name is None: + return + if not maps.preview(name): + self._preview_dler.download_preview(name, self._map_request) + + def map_name(self): + game = self.game + if game is None or game.closed() or game.mapname is None: + return None + return self.game.mapname.lower() + + def _download_avatar_if_needed(self): + avatar_url = self.avatar_url() + if avatar_url is None: + return + if avatar_url in self._avatar_dler.avatars: + return + self._avatar_dler.download_avatar(avatar_url, self._avatar_request) + + def avatar_url(self): + try: + url = self.player.avatar["url"] + except (TypeError, AttributeError, KeyError): + return None + return parse.unquote(url) diff --git a/src/chat/chatterlistview.py b/src/chat/chatterlistview.py new file mode 100644 index 000000000..02723356d --- /dev/null +++ b/src/chat/chatterlistview.py @@ -0,0 +1,17 @@ +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QListView + + +class ChatterListView(QListView): + """ + Used to let chatter list delegate fit its width to list view's width. + """ + resized = pyqtSignal(object) + + def __init__(self, *args, **kwargs): + QListView.__init__(self, *args, **kwargs) + + def resizeEvent(self, event): + QListView.resizeEvent(self, event) + self.resized.emit(self.maximumViewportSize()) + self.updateGeometries() diff --git a/src/chat/colors.py b/src/chat/colors.py deleted file mode 100644 index a4d023e17..000000000 --- a/src/chat/colors.py +++ /dev/null @@ -1,11 +0,0 @@ -# This is potentially overriden by theming logic, sensible defaults provided - -OPERATOR_COLORS = {"~": "#FFFFFF", - "&": "#FFFFFF", - "@": "#FFFFFF", - "%": "#FFFFFF", - "+": "#FFFFFF"} - -CHAT_COLORS = { - "default": "grey" -} diff --git a/src/chat/gameinfo.py b/src/chat/gameinfo.py index ddef5edf0..857525c64 100644 --- a/src/chat/gameinfo.py +++ b/src/chat/gameinfo.py @@ -1,4 +1,5 @@ -from PyQt5.QtCore import QObject +from PyQt6.QtCore import QObject + from model.game import GameState @@ -7,6 +8,10 @@ def __init__(self, me): QObject.__init__(self) self._me = me + @classmethod + def build(cls, me, **kwargs): + return cls(me) + def has_sensitive_data(self, game): if game is None or game.closed(): return False diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py new file mode 100644 index 000000000..9ce80644f --- /dev/null +++ b/src/chat/ircconnection.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +import logging +import re +import sys + +from irc.client import Event +from irc.client import IRCError +from irc.client import ServerConnection +from irc.client import SimpleIRCClient +from irc.client import is_channel +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +import config +import util +from api.ApiAccessors import UserApiAccessor +from chat.socketadapter import ConnectionFactory +from chat.socketadapter import ReactorForSocketAdapter +from model.chat.channel import ChannelID +from model.chat.channel import ChannelType +from model.chat.chatline import ChatLine +from model.chat.chatline import ChatLineType + +logger = logging.getLogger(__name__) +IRC_ELEVATION = '%@~%+&' + + +def user2name(user): + return (user.split('!')[0]).strip(IRC_ELEVATION) + + +def parse_irc_source(src): + """ + :param src: IRC source argument + :return: (username, id, elevation, hostname) + """ + try: + username, tail = src.split('!') + except ValueError: + username = src.split('!')[0] + tail = None + + if username[0] in IRC_ELEVATION: + elevation, username = username[0], username[1:] + else: + elevation = "" + + if tail is not None: + id, hostname = tail.split('@') + try: + id = int(id) + except ValueError: + id = -1 + else: + id = -1 + hostname = None + + return username, id, elevation, hostname + + +class ChatterInfo: + def __init__(self, name, hostname, elevation): + self.name = name + self.hostname = hostname + self.elevation = elevation + + +class IrcSignals(QObject): + new_line = pyqtSignal(object, object, object) + new_server_message = pyqtSignal(str) + new_channel_chatters = pyqtSignal(object, list) + channel_chatter_joined = pyqtSignal(object, object) + channel_chatter_left = pyqtSignal(object, object) + chatter_quit = pyqtSignal(object, str) + quit_channel = pyqtSignal(object) + chatter_renamed = pyqtSignal(str, str) + new_chatter_elevation = pyqtSignal(object, object, str, str) + new_channel_topic = pyqtSignal(object, str) + connected = pyqtSignal() + disconnected = pyqtSignal() + + def __init__(self): + QObject.__init__(self) + + +class IrcConnection(IrcSignals, SimpleIRCClient): + reactor_class = ReactorForSocketAdapter + + token_received = pyqtSignal(str) + + def __init__(self, host: int, port: int) -> None: + IrcSignals.__init__(self) + SimpleIRCClient.__init__(self) + + self.host = host + self.port = port + self.api_accessor = UserApiAccessor() + self.token_received.connect(self.on_token_received) + self.connect_factory = ConnectionFactory() + + self._password = None + self._nick = None + + self._nickserv_registered = False + self._connected = False + + @classmethod + def build(cls, settings: config.Settings, **kwargs) -> IrcConnection: + port = settings.get("chat/port", 443, int) + host = settings.get("chat/host", "chat." + config.defaults["host"], str) + return cls(host, port) + + def setPortFromConfig(self) -> None: + self.port = config.Settings.get("chat/port", type=int) + + def setHostFromConfig(self): + self.host = config.Settings.get('chat/host', type=str) + + def disconnect_(self) -> None: + self.connection.disconnect() + + def set_nick_and_username(self, nick: str, username: str) -> None: + self._nick = nick + self._username = username + + def begin_connection_process(self) -> None: + self.api_accessor.get_by_endpoint("/irc/ergochat/token", self.handle_irc_token) + + def handle_irc_token(self, data: dict) -> None: + irc_token = data["value"] + self.token_received.emit(irc_token) + + def on_token_received(self, token: str) -> None: + self.connect_(self._nick, self._username, f"token:{token}") + + def connect_(self, nick: str, username: str, password: str) -> bool: + logger.info(f"Connecting to IRC at: {self.host}:{self.port}") + + self._nick = nick + self._username = username + self._password = password + + try: + self.connect( + self.host, + self.port, + nick, + connect_factory=self.connect_factory, + ircname=nick, + sasl_login=username, + password=password, + ) + self.connection.socket.message_received.connect(self.reactor.process_once) + return True + except IRCError: + logger.debug("Unable to connect to IRC server.") + logger.error("IRC Exception", exc_info=sys.exc_info()) + return False + + def is_connected(self): + return self.connection.is_connected() + + def _only_if_connected(fn): + def _if_connected(self, *args, **kwargs): + if not self.connection.is_connected(): + return False + fn(self, *args, **kwargs) + return True + return _if_connected + + @_only_if_connected + def set_topic(self, channel, topic): + self.connection.topic(channel, topic) + + @_only_if_connected + def send_message(self, target, text): + self.connection.privmsg(target, text) + + @_only_if_connected + def send_action(self, target, text): + self.connection.action(target, text) + + @_only_if_connected + def join(self, channel): + self.connection.join(channel) + + @_only_if_connected + def part(self, channel, reason=""): + self.connection.part([channel], reason) + + @property + def nickname(self): + return self._nick + + def _log_event(self, e): + text = ' | '.join(e.arguments) + self.new_server_message.emit( + "[{}: {}->{}] {}".format(e.type, e.source, e.target, text), + ) + + def _log_client_message(self, text): + self.new_server_message.emit(text) + + def on_welcome(self, c, e): + self._log_event(e) + if not self._connected: + self._connected = True + self.on_connected() + + def _send_nickserv_creds(self, fmt): + self._log_client_message( + fmt.format( + nick=self._nick, + password='[password_hash]', + ), + ) + + msg = fmt.format( + nick=self._nick, + password=util.md5text(self._password), + ) + self.connection.privmsg('NickServ', msg) + + def _nickserv_identify(self): + self._send_nickserv_creds('identify {nick} {password}') + + def _nickserv_register(self): + if self._nickserv_registered: + return + self._send_nickserv_creds( + 'register {password} {nick}@users.faforever.com', + ) + self._nickserv_registered = True + + def _nickserv_recover_if_needed(self): + if self.connection.get_nickname() != self._nick: + self._send_nickserv_creds('recover {nick} {password}') + + def on_connected(self): + self._nickserv_recover_if_needed() + self.connected.emit() + + def on_version(self, c, e): + msg = "Forged Alliance Forever " + util.VERSION_STRING + self.connection.privmsg(e.source, msg) + + def on_motd(self, c, e): + self._log_event(e) + + def on_endofmotd(self, c, e): + self._log_event(e) + self.connection.whois(self._nick) + self._nickserv_identify() + + def on_namreply(self, c, e): + channel = ChannelID(ChannelType.PUBLIC, e.arguments[1]) + listing = e.arguments[2].split() + + def userdata(data): + name = data.strip(IRC_ELEVATION) + elevation = data[0] if data[0] in IRC_ELEVATION else "" + hostname = '' + return ChatterInfo(name, hostname, elevation) + + chatters = [userdata(user) for user in listing] + self.new_channel_chatters.emit(channel, chatters) + + def on_whoisuser(self, c: ServerConnection, e: Event) -> None: + self._log_event(e) + + def _event_to_chatter(self, e): + name, _id, elevation, hostname = parse_irc_source(e.source) + return ChatterInfo(name, hostname, elevation) + + def on_join(self, c, e): + channel = ChannelID(ChannelType.PUBLIC, e.target) + chatter = self._event_to_chatter(e) + self.channel_chatter_joined.emit(channel, chatter) + + def on_part(self, c, e): + channel = ChannelID(ChannelType.PUBLIC, e.target) + chatter = self._event_to_chatter(e) + self.channel_chatter_left.emit(channel, chatter) + if chatter.name == self._nick: + self.quit_channel.emit(channel) + + def on_quit(self, c, e): + chatter = self._event_to_chatter(e) + self.chatter_quit.emit(chatter, e.arguments[0]) + + def on_nick(self, c, e): + oldnick = user2name(e.source) + newnick = e.target + + self.chatter_renamed.emit(oldnick, newnick) + self._log_event(e) + + def on_mode(self, c, e): + if len(e.arguments) < 2: + return + + name, _, elevation, hostname = parse_irc_source(e.arguments[1]) + chatter = ChatterInfo(name, hostname, elevation) + modes = e.arguments[0] + channel = ChannelID(ChannelType.PUBLIC, e.target) + added, removed = self._parse_elevation(modes) + self.new_chatter_elevation.emit( + channel, chatter, added, removed, + ) + + def _parse_elevation(self, modes): + add = re.compile(r".*\+([a-z]+)") + remove = re.compile(r".*\-([a-z]+)") + mode_to_elevation = {"o": "@", "q": "~", "v": "+"} + + def get_elevations(expr): + match = re.search(expr, modes) + if not match: + return "" + match = match.group(1) + return ''.join(mode_to_elevation.get(c, '') for c in match) + + return get_elevations(add), get_elevations(remove) + + def on_umode(self, c, e): + self._log_event(e) + + def on_notice(self, c, e): + self._log_event(e) + + def on_topic(self, c, e): + channel = ChannelID(ChannelType.PUBLIC, e.target) + announcement = " ".join(e.arguments) + self.new_channel_topic.emit(channel, announcement) + + def on_currenttopic(self, c, e): + channel = ChannelID(ChannelType.PUBLIC, e.arguments[0]) + announcement = " ".join(e.arguments[1:]) + self.new_channel_topic.emit(channel, announcement) + + def on_topicinfo(self, c, e): + self._log_event(e) + + def on_list(self, c, e): + self._log_event(e) + + def on_bannedfromchan(self, c, e): + self._log_event(e) + + def _emit_line( + self, chatter, target, channel_type, text, type_=ChatLineType.MESSAGE, + ): + if channel_type == ChannelType.PUBLIC: + channel_name = target + else: + channel_name = chatter.name + chid = ChannelID(channel_type, channel_name) + line = ChatLine(chatter.name, text, type_) + self.new_line.emit(chid, chatter, line) + + def on_pubmsg(self, c, e): + chatter = self._event_to_chatter(e) + target = e.target + text = "\n".join(e.arguments) + self._emit_line(chatter, target, ChannelType.PUBLIC, text) + + def on_privnotice(self, c, e): + if e.source == self.host: + self._log_event(e) + return + + chatter = self._event_to_chatter(e) + notice = e.arguments[0] + if chatter.name.lower() == 'nickserv': + self._log_event(e) + self._handle_nickserv_message(notice) + return + + text = "\n".join(e.arguments) + msg_target, text = self._parse_target_from_privnotice_message(text) + if msg_target is not None: + channel_type = ChannelType.PUBLIC + else: + channel_type = ChannelType.PRIVATE + self._emit_line( + chatter, msg_target, channel_type, text, ChatLineType.NOTICE, + ) + + # Parsing message to get target channel instead is non-standard. To limit + # abuse potential, we match the pattern used by bots as closely as + # possible, and mark the line as notice so views can display them + # differently. + def _parse_target_from_privnotice_message(self, text: str) -> tuple[str, str]: + if re.match(r'\[[^ ]+\] ', text) is None: + return None, text + prefix, rest = text.split(" ", 1) + prefix = prefix[1:-1] + target = prefix.strip("[]") + if not is_channel(target): + return None, text + return target, rest + + def _handle_nickserv_message(self, notice): + if ( + "registered under your account" in notice + or "You are already identified" in notice + ): + if not self._connected: + self._connected = True + self.on_connected() + elif "isn't registered" in notice: + self._nickserv_register() + elif "choose a different nick" in notice or "registered." in notice: + self._nickserv_identify() + elif "you are now recognized" in notice: + self._nickserv_recover_if_needed() + elif "RELEASE" in notice: + self.connection.privmsg('release {} {}') + elif "hold on" in notice or "You have regained control" in notice: + self.connection.nick(self._nick) + + def on_disconnect(self, c: ServerConnection, e: Event) -> None: + self._connected = False + self.disconnected.emit() + + def on_privmsg(self, c, e): + chatter = self._event_to_chatter(e) + text = "\n".join(e.arguments) + self._emit_line(chatter, None, ChannelType.PRIVATE, text) + + def on_action(self, c: ServerConnection, e: Event) -> None: + chatter = self._event_to_chatter(e) + target = e.target + text = "\n".join(e.arguments) + if is_channel(target): + chtype = ChannelType.PUBLIC + else: + chtype = ChannelType.PRIVATE + self._emit_line(chatter, target, chtype, text, ChatLineType.ACTION) + + def on_nosuchnick(self, c, e): + self._nickserv_register() + + def on_default(self, c, e): + self._log_event(e) + if "Nickname is already in use." in "\n".join(e.arguments): + self.connection.nick(self._nick + "_") + + def on_kick(self, c, e): + pass diff --git a/src/chat/irclib.py b/src/chat/irclib.py deleted file mode 100644 index 649e25f88..000000000 --- a/src/chat/irclib.py +++ /dev/null @@ -1,1599 +0,0 @@ - -# Copyright (C) 1999--2002 Joel Rosdahl -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# keltus -# -# $Id: irclib.py,v 1.47 2008/09/25 22:00:59 keltus Exp $ - -""" irclib -- Internet Relay Chat (IRC) protocol client library. - -This library is intended to encapsulate the IRC protocol at a quite -low level. It provides an event-driven IRC client framework. It has -a fairly thorough support for the basic IRC protocol, CTCP, DCC chat, -but DCC file transfers is not yet supported. - -In order to understand how to make an IRC client, I'm afraid you more -or less must understand the IRC specifications. They are available -here: [IRC specifications]. - -The main features of the IRC client framework are: - - * Abstraction of the IRC protocol. - * Handles multiple simultaneous IRC server connections. - * Handles server PONGing transparently. - * Messages to the IRC server are done by calling methods on an IRC - connection object. - * Messages from an IRC server triggers events, which can be caught - by event handlers. - * Reading from and writing to IRC server sockets are normally done - by an internal select() loop, but the select()ing may be done by - an external main loop. - * Functions can be registered to execute at specified times by the - event-loop. - * Decodes CTCP tagging correctly (hopefully); I haven't seen any - other IRC client implementation that handles the CTCP - specification subtilties. - * A kind of simple, single-server, object-oriented IRC client class - that dispatches events to instance methods is included. - -Current limitations: - - * The IRC protocol shines through the abstraction a bit too much. - * Data is not written asynchronously to the server, i.e. the write() - may block if the TCP buffers are stuffed. - * There are no support for DCC file transfers. - * The author haven't even read RFC 2810, 2811, 2812 and 2813. - * Like most projects, documentation is lacking... - -.. [IRC specifications] http://www.irchelp.org/irchelp/rfc/ -""" - -import bisect -import re -import select -import socket -import string -import time -import ssl -import logging - -VERSION = 0, 4, 8 -DEBUG = 0 - -# TODO -# ---- -# (maybe) thread safety -# (maybe) color parser convenience functions -# documentation (including all event types) -# (maybe) add awareness of different types of ircds -# send data asynchronously to the server (and DCC connections) -# (maybe) automatically close unused, passive DCC connections after a while - -# NOTES -# ----- -# connection.quit() only sends QUIT to the server. -# ERROR from the server triggers the error event and the disconnect event. -# dropping of the connection triggers the disconnect event. - - -class IRCError(Exception): - """Represents an IRC exception.""" - - -class IRC: - """Class that handles one or several IRC server connections. - - When an IRC object has been instantiated, it can be used to create - Connection objects that represent the IRC connections. The - responsibility of the IRC object is to provide an event-driven - framework for the connections and to keep the connections alive. - It runs a select loop to poll each connection's TCP socket and - hands over the sockets with incoming data for processing by the - corresponding connection. - - The methods of most interest for an IRC client writer are server, - add_global_handler, remove_global_handler, execute_at, - execute_delayed, process_once and process_forever. - - Here is an example: - - irc = irclib.IRC() - server = irc.server() - server.connect(\"irc.some.where\", 6667, \"my_nickname\") - server.privmsg(\"a_nickname\", \"Hi there!\") - irc.process_forever() - - This will connect to the IRC server irc.some.where on port 6667 - using the nickname my_nickname and send the message \"Hi there!\" - to the nickname a_nickname. - """ - - def __init__(self, fn_to_add_socket=None, - fn_to_remove_socket=None, - fn_to_add_timeout=None): - """Constructor for IRC objects. - - Optional arguments are fn_to_add_socket, fn_to_remove_socket - and fn_to_add_timeout. The first two specify functions that - will be called with a socket object as argument when the IRC - object wants to be notified (or stop being notified) of data - coming on a new socket. When new data arrives, the method - process_data should be called. Similarly, fn_to_add_timeout - is called with a number of seconds (a floating point number) - as first argument when the IRC object wants to receive a - notification (by calling the process_timeout method). So, if - e.g. the argument is 42.17, the object wants the - process_timeout method to be called after 42 seconds and 170 - milliseconds. - - The three arguments mainly exist to be able to use an external - main loop (for example Tkinter's or PyGTK's main app loop) - instead of calling the process_forever method. - - An alternative is to just call ServerConnection.process_once() - once in a while. - """ - - if fn_to_add_socket and fn_to_remove_socket: - self.fn_to_add_socket = fn_to_add_socket - self.fn_to_remove_socket = fn_to_remove_socket - else: - self.fn_to_add_socket = None - self.fn_to_remove_socket = None - - self.fn_to_add_timeout = fn_to_add_timeout - self.connections = [] - self.handlers = {} - self.delayed_commands = [] # list of tuples in the format (time, function, arguments) - - self.add_global_handler("ping", _ping_ponger, -42) - - def server(self): - """Creates and returns a ServerConnection object.""" - - c = ServerConnection(self) - self.connections.append(c) - return c - - def process_data(self, sockets): - """Called when there is more data to read on connection sockets. - - Arguments: - - sockets -- A list of socket objects. - - See documentation for IRC.__init__. - """ - for s in sockets: - for c in self.connections: - if s == c._get_socket(): - c.process_data() - - def process_timeout(self): - """Called when a timeout notification is due. - - See documentation for IRC.__init__. - """ - t = time.time() - while self.delayed_commands: - if t >= self.delayed_commands[0][0]: - self.delayed_commands[0][1](*self.delayed_commands[0][2]) - del self.delayed_commands[0] - else: - break - - def process_once(self, timeout=0.0): - """Process data from connections once. - - Arguments: - - timeout -- How long the select() call should wait if no - data is available. - - This method should be called periodically to check and process - incoming data, if there are any. If that seems boring, look - at the process_forever method. - """ - sockets = [x._get_socket() for x in self.connections] - sockets = [x for x in sockets if x is not None] - if sockets: - (i, o, e) = select.select(sockets, [], [], timeout) - self.process_data(i) - self.process_timeout() - - def process_forever(self, timeout=0.2): - """Run an infinite loop, processing data from connections. - - This method repeatedly calls process_once. - - Arguments: - - timeout -- Parameter to pass to process_once. - """ - while 1: - self.process_once(timeout) - - def disconnect_all(self, message=""): - """Disconnects all connections.""" - for c in self.connections: - c.disconnect(message) - - def add_global_handler(self, event, handler, priority=0): - """Adds a global handler function for a specific event type. - - Arguments: - - event -- Event type (a string). Check the values of the - numeric_events dictionary in irclib.py for possible event - types. - - handler -- Callback function. - - priority -- A number (the lower number, the higher priority). - - The handler function is called whenever the specified event is - triggered in any of the connections. See documentation for - the Event class. - - The handler functions are called in priority order (lowest - number is highest priority). If a handler function returns - \"NO MORE\", no more handlers will be called. - """ - if event not in self.handlers: - self.handlers[event] = [] - bisect.insort(self.handlers[event], (priority, handler)) - - def remove_global_handler(self, event, handler): - """Removes a global handler function. - - Arguments: - - event -- Event type (a string). - - handler -- Callback function. - - Returns 1 on success, otherwise 0. - """ - if event not in self.handlers: - return 0 - for h in self.handlers[event]: - if handler == h[1]: - self.handlers[event].remove(h) - return 1 - - def execute_at(self, at, function, arguments=()): - """Execute a function at a specified time. - - Arguments: - - at -- Execute at this time (standard \"time_t\" time). - - function -- Function to call. - - arguments -- Arguments to give the function. - """ - self.execute_delayed(at-time.time(), function, arguments) - - def execute_delayed(self, delay, function, arguments=()): - """Execute a function after a specified time. - - Arguments: - - delay -- How many seconds to wait. - - function -- Function to call. - - arguments -- Arguments to give the function. - """ - bisect.insort(self.delayed_commands, (delay+time.time(), function, arguments)) - if self.fn_to_add_timeout: - self.fn_to_add_timeout(delay) - - def dcc(self, dcctype="chat"): - """Creates and returns a DCCConnection object. - - Arguments: - - dcctype -- "chat" for DCC CHAT connections or "raw" for - DCC SEND (or other DCC types). If "chat", - incoming data will be split in newline-separated - chunks. If "raw", incoming data is not touched. - """ - c = DCCConnection(self, dcctype) - self.connections.append(c) - return c - - def _handle_event(self, connection, event): - """[Internal]""" - h = self.handlers - for handler in h.get("all_events", []) + h.get(event.eventtype(), []): - if handler[1](connection, event) == "NO MORE": - return - - def _remove_connection(self, connection): - """[Internal]""" - self.connections.remove(connection) - if self.fn_to_remove_socket: - self.fn_to_remove_socket(connection._get_socket()) - -_rfc_1459_command_regexp = re.compile("^(:(?P[^ ]+) +)?(?P[^ ]+)( *(?P .+))?") - - -class Connection: - """Base class for IRC connections. - - Must be overridden. - """ - def __init__(self, irclibobj): - self.irclibobj = irclibobj - - def _get_socket(self): - raise IRCError("Not overridden") - - ############################## - # Convenience wrappers. - - def execute_at(self, at, function, arguments=()): - self.irclibobj.execute_at(at, function, arguments) - - def execute_delayed(self, delay, function, arguments=()): - self.irclibobj.execute_delayed(delay, function, arguments) - - -class ServerConnectionError(IRCError): - pass - - -class ServerNotConnectedError(ServerConnectionError): - pass - - -# Huh!? Crrrrazy EFNet doesn't follow the RFC: their ircd seems to -# use \n as message separator! :P -_linesep_regexp = re.compile(b"\r?\n") - - -class ServerConnection(Connection): - """This class represents an IRC server connection. - - ServerConnection objects are instantiated by calling the server - method on an IRC object. - """ - - def __init__(self, irclibobj): - Connection.__init__(self, irclibobj) - self.connected = 0 # Not connected yet. - self.socket = None - self.ssl = None - - def connect(self, server, port, nickname, password=None, username=None, - ircname=None, localaddress="", localport=0, use_ssl=False, ipv6=False): - """Connect/reconnect to a server. - - Arguments: - - server -- Server name. - - port -- Port number. - - nickname -- The nickname. - - password -- Password (if any). - - username -- The username. - - ircname -- The IRC name ("realname"). - - localaddress -- Bind the connection to a specific local IP address. - - localport -- Bind the connection to a specific local port. - - ssl -- Enable support for ssl. - - ipv6 -- Enable support for ipv6. - - This function can be called to reconnect a closed connection. - - Returns the ServerConnection object. - """ - if self.connected: - self.disconnect("Changing servers") - - self.previous_buffer = b"" - self.handlers = {} - self.real_server_name = "" - self.real_nickname = nickname - self.server = server - self.port = port - self.nickname = nickname - self.username = username or nickname - self.ircname = ircname or nickname - self.password = password - self.localaddress = localaddress - self.localport = localport - self.localhost = socket.gethostname() - if ipv6: - self.socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - else: - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.bind((self.localaddress, self.localport)) - self.socket.connect((self.server, self.port)) - if use_ssl: - self.ssl = ssl.wrap_socket(self.socket) - self.ssl.settimeout(0.0) - else: - self.socket.settimeout(0.0) - except socket.error as x: - self.socket.close() - self.socket = None - raise ServerConnectionError("Couldn't connect to socket: %s" % x) - self.connected = 1 - if self.irclibobj.fn_to_add_socket: - self.irclibobj.fn_to_add_socket(self.socket) - - # Log on... - if self.password: - self.pass_(self.password) - self.nick(self.nickname) - self.user(self.username, self.ircname) - return self - - def close(self): - """Close the connection. - - This method closes the connection permanently; after it has - been called, the object is unusable. - """ - - self.disconnect("Closing object") - self.irclibobj._remove_connection(self) - - def _get_socket(self): - """[Internal]""" - return self.socket - - def get_server_name(self): - """Get the (real) server name. - - This method returns the (real) server name, or, more - specifically, what the server calls itself. - """ - - if self.real_server_name: - return self.real_server_name - else: - return "" - - def get_nickname(self): - """Get the (real) nick name. - - This method returns the (real) nickname. The library keeps - track of nick changes, so it might not be the nick name that - was passed to the connect() method. """ - - return self.real_nickname - - def process_data(self): - """[Internal]""" - try: - if self.ssl: - new_data = self.ssl.read(2**14) - else: - new_data = self.socket.recv(2**14) - except socket.timeout: - # Nothing was interesting - pass - except socket.error as x: - # The server hung up. - self.disconnect("Connection reset by peer") - return - - lines = _linesep_regexp.split(self.previous_buffer + new_data) - - # Save the last, unfinished line. - self.previous_buffer = lines.pop() - - for line in lines: - if DEBUG: - print("FROM SERVER:", line) - - if not line: - continue - - try: - line = line.decode("utf-8", "replace") # utf-8 support hacked in by thygrrr (may break in some scenarios - see chardet python package) - except: - print("irclib: non-utf-8 line, unexpected encoding error " + line) - line = "** encoding error - replaced by irclib.py **" - - prefix = None - command = None - arguments = None - self._handle_event(Event("all_raw_messages", - self.get_server_name(), - None, - [line])) - - m = _rfc_1459_command_regexp.match(line) - if m.group("prefix"): - prefix = m.group("prefix") - if not self.real_server_name: - self.real_server_name = prefix - - if m.group("command"): - command = m.group("command").lower() - - if m.group("argument"): - a = m.group("argument").split(" :", 1) - arguments = a[0].split() - if len(a) == 2: - arguments.append(a[1]) - - # Translate numerics into more readable strings. - if command in numeric_events: - command = numeric_events[command] - - if command == "nick": - if nm_to_n(prefix) == self.real_nickname: - self.real_nickname = arguments[0] - elif command == "welcome": - # Record the nickname in case the client changed nick - # in a nicknameinuse callback. - self.real_nickname = arguments[0] - - if command in ["privmsg", "notice"]: - target, message = arguments[0], arguments[1] - messages = _ctcp_dequote(message) - - if command == "privmsg": - if is_channel(target): - command = "pubmsg" - else: - if is_channel(target): - command = "pubnotice" - else: - command = "privnotice" - - for m in messages: - if type(m) is tuple: - if command in ["privmsg", "pubmsg"]: - command = "ctcp" - else: - command = "ctcpreply" - - m = list(m) - if DEBUG: - print("command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, m)) - self._handle_event(Event(command, prefix, target, m)) - if command == "ctcp" and m[0] == "ACTION": - self._handle_event(Event("action", prefix, target, m[1:])) - else: - if DEBUG: - print("command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, [m])) - self._handle_event(Event(command, prefix, target, [m])) - else: - target = None - - if command == "quit": - arguments = [arguments[0]] - elif command == "ping": - target = arguments[0] - else: - target = arguments[0] - arguments = arguments[1:] - - if command == "mode": - if not is_channel(target): - command = "umode" - - if DEBUG: - print("command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, arguments)) - self._handle_event(Event(command, prefix, target, arguments)) - - def _handle_event(self, event): - """[Internal]""" - try: - self.irclibobj._handle_event(self, event) - if event.eventtype() in self.handlers: - for fn in self.handlers[event.eventtype()]: - fn(self, event) - except Exception as e: - logging.getLogger('irclib').exception(e) - - def is_connected(self): - """Return connection status. - - Returns true if connected, otherwise false. - """ - return self.connected - - def add_global_handler(self, *args): - """Add global handler. - - See documentation for IRC.add_global_handler. - """ - self.irclibobj.add_global_handler(*args) - - def remove_global_handler(self, *args): - """Remove global handler. - - See documentation for IRC.remove_global_handler. - """ - self.irclibobj.remove_global_handler(*args) - - def action(self, target, action): - """Send a CTCP ACTION command.""" - self.ctcp("ACTION", target, action) - - def admin(self, server=""): - """Send an ADMIN command.""" - self.send_raw(" ".join(["ADMIN", server]).strip()) - - def ctcp(self, ctcptype, target, parameter=""): - """Send a CTCP command.""" - ctcptype = ctcptype.upper() - self.privmsg(target, "\001%s%s\001" % (ctcptype, parameter and (" " + parameter) or "")) - - def ctcp_reply(self, target, parameter): - """Send a CTCP REPLY command.""" - self.notice(target, "\001%s\001" % parameter) - - def disconnect(self, message=""): - """Hang up the connection. - - Arguments: - - message -- Quit message. - """ - if not self.connected: - return - - self.connected = 0 - - self.quit(message) - - try: - self.socket.close() - except socket.error as x: - pass - self.socket = None - self._handle_event(Event("disconnect", self.server, "", [message])) - - def globops(self, text): - """Send a GLOBOPS command.""" - self.send_raw("GLOBOPS :" + text) - - def info(self, server=""): - """Send an INFO command.""" - self.send_raw(" ".join(["INFO", server]).strip()) - - def invite(self, nick, channel): - """Send an INVITE command.""" - self.send_raw(" ".join(["INVITE", nick, channel]).strip()) - - def ison(self, nicks): - """Send an ISON command. - - Arguments: - - nicks -- List of nicks. - """ - self.send_raw("ISON " + " ".join(nicks)) - - def join(self, channel, key=""): - """Send a JOIN command.""" - self.send_raw("JOIN %s%s" % (channel, (key and (" " + key)))) - - def kick(self, channel, nick, comment=""): - """Send a KICK command.""" - self.send_raw("KICK %s %s%s" % (channel, nick, (comment and (" :" + comment)))) - - def links(self, remote_server="", server_mask=""): - """Send a LINKS command.""" - command = "LINKS" - if remote_server: - command = command + " " + remote_server - if server_mask: - command = command + " " + server_mask - self.send_raw(command) - - def list(self, channels=None, server=""): - """Send a LIST command.""" - command = "LIST" - if channels: - command = command + " " + ",".join(channels) - if server: - command = command + " " + server - self.send_raw(command) - - def lusers(self, server=""): - """Send a LUSERS command.""" - self.send_raw("LUSERS" + (server and (" " + server))) - - def mode(self, target, command): - """Send a MODE command.""" - self.send_raw("MODE %s %s" % (target, command)) - - def motd(self, server=""): - """Send an MOTD command.""" - self.send_raw("MOTD" + (server and (" " + server))) - - def names(self, channels=None): - """Send a NAMES command.""" - self.send_raw("NAMES" + (channels and (" " + ",".join(channels)) or "")) - - def nick(self, newnick): - """Send a NICK command.""" - self.send_raw("NICK " + newnick) - - def notice(self, target, text): - """Send a NOTICE command.""" - # Should limit len(text) here! - self.send_raw("NOTICE %s :%s" % (target, text)) - - def oper(self, nick, password): - """Send an OPER command.""" - self.send_raw("OPER %s %s" % (nick, password)) - - def part(self, channels, message=""): - """Send a PART command.""" - if type(channels) == bytes: - self.send_raw("PART " + channels + (message and (" " + message))) - else: - self.send_raw("PART " + ",".join(channels) + (message and (" " + message))) - - def pass_(self, password): - """Send a PASS command.""" - self.send_raw("PASS " + password) - - def ping(self, target, target2=""): - """Send a PING command.""" - self.send_raw("PING %s%s" % (target, target2 and (" " + target2))) - - def pong(self, target, target2=""): - """Send a PONG command.""" - self.send_raw("PONG %s%s" % (target, target2 and (" " + target2))) - - def privmsg(self, target, text): - """Send a PRIVMSG command.""" - # Should limit len(text) here! - self.send_raw("PRIVMSG %s :%s" % (target, text)) - - def privmsg_many(self, targets, text): - """Send a PRIVMSG command to multiple targets.""" - # Should limit len(text) here! - self.send_raw("PRIVMSG %s :%s" % (",".join(targets), text)) - - def quit(self, message=""): - """Send a QUIT command.""" - # Note that many IRC servers don't use your QUIT message - # unless you've been connected for at least 5 minutes! - self.send_raw("QUIT" + (message and (" :" + message))) - - def send_raw(self, string): - """Send raw string to the server. - - The string will be padded with appropriate CR LF. - """ - if self.socket is None: - raise ServerNotConnectedError("Not connected.") - try: - if self.ssl: - self.ssl.write((string + "\r\n").encode("utf-8")) #FIXME utf-8 support hacked in by thygrrrc(may break in some scenarios) - else: - self.socket.send((string + "\r\n").encode("utf-8")) #FIXME utf-8 support hacked in by thygrrr (may break in some scenarios) - if DEBUG: - print("TO SERVER:", string) - except socket.error as x: - # Ouch! - self.disconnect("Connection reset by peer.") - - def squit(self, server, comment=""): - """Send an SQUIT command.""" - self.send_raw("SQUIT %s%s" % (server, comment and (" :" + comment))) - - def stats(self, statstype, server=""): - """Send a STATS command.""" - self.send_raw("STATS %s%s" % (statstype, server and ("u " + server))) - - def time(self, server=""): - """Send a TIME command.""" - self.send_raw("TIME" + (server and (" " + server))) - - def topic(self, channel, new_topic=None): - """Send a TOPIC command.""" - if new_topic is None: - self.send_raw("TOPIC " + channel) - else: - self.send_raw("TOPIC %s :%s" % (channel, new_topic)) - - def trace(self, target=""): - """Send a TRACE command.""" - self.send_raw("TRACE" + (target and (" " + target))) - - def user(self, username, realname): - """Send a USER command.""" - self.send_raw("USER %s 0 * :%s" % (username, realname)) - - def userhost(self, nicks): - """Send a USERHOST command.""" - self.send_raw("USERHOST " + ",".join(nicks)) - - def users(self, server=""): - """Send a USERS command.""" - self.send_raw("USERS" + (server and (" " + server))) - - def version(self, server=""): - """Send a VERSION command.""" - self.send_raw("VERSION" + (server and (" " + server))) - - def wallops(self, text): - """Send a WALLOPS command.""" - self.send_raw("WALLOPS :" + text) - - def who(self, target="", op=""): - """Send a WHO command.""" - self.send_raw("WHO%s%s" % (target and (" " + target), op and (" o"))) - - def whois(self, targets): - """Send a WHOIS command.""" - self.send_raw("WHOIS " + ",".join(targets)) - - def whowas(self, nick, max="", server=""): - """Send a WHOWAS command.""" - self.send_raw("WHOWAS %s%s%s" % (nick, - max and (" " + max), - server and (" " + server))) - - -class DCCConnectionError(IRCError): - pass - - -class DCCConnection(Connection): - """This class represents a DCC connection. - - DCCConnection objects are instantiated by calling the dcc - method on an IRC object. - """ - def __init__(self, irclibobj, dcctype): - Connection.__init__(self, irclibobj) - self.connected = 0 - self.passive = 0 - self.dcctype = dcctype - self.peeraddress = None - self.peerport = None - - def connect(self, address, port): - """Connect/reconnect to a DCC peer. - - Arguments: - address -- Host/IP address of the peer. - - port -- The port number to connect to. - - Returns the DCCConnection object. - """ - self.peeraddress = socket.gethostbyname(address) - self.peerport = port - self.socket = None - self.previous_buffer = b"" - self.handlers = {} - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.passive = 0 - try: - self.socket.connect((self.peeraddress, self.peerport)) - except socket.error as x: - raise DCCConnectionError("Couldn't connect to socket: %s" % x) - self.connected = 1 - if self.irclibobj.fn_to_add_socket: - self.irclibobj.fn_to_add_socket(self.socket) - return self - - def listen(self): - """Wait for a connection/reconnection from a DCC peer. - - Returns the DCCConnection object. - - The local IP address and port are available as - self.localaddress and self.localport. After connection from a - peer, the peer address and port are available as - self.peeraddress and self.peerport. - """ - self.previous_buffer = b"" - self.handlers = {} - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.passive = 1 - try: - self.socket.bind((socket.gethostbyname(socket.gethostname()), 0)) - self.localaddress, self.localport = self.socket.getsockname() - self.socket.listen(10) - except socket.error as x: - raise DCCConnectionError("Couldn't bind socket: %s" % x) - return self - - def disconnect(self, message=""): - """Hang up the connection and close the object. - - Arguments: - - message -- Quit message. - """ - if not self.connected: - return - - self.connected = 0 - try: - self.socket.close() - except socket.error as x: - pass - self.socket = None - self.irclibobj._handle_event( - self, - Event("dcc_disconnect", self.peeraddress, "", [message])) - self.irclibobj._remove_connection(self) - - def process_data(self): - """[Internal]""" - - if self.passive and not self.connected: - conn, (self.peeraddress, self.peerport) = self.socket.accept() - self.socket.close() - self.socket = conn - self.connected = 1 - if DEBUG: - print("DCC connection from %s:%d" % ( - self.peeraddress, self.peerport)) - self.irclibobj._handle_event( - self, - Event("dcc_connect", self.peeraddress, None, None)) - return - - try: - new_data = self.socket.recv(2**14) - except socket.error as x: - # The server hung up. - self.disconnect("Connection reset by peer") - return - if not new_data: - # Read nothing: connection must be down. - self.disconnect("Connection reset by peer") - return - - if self.dcctype == "chat": - # The specification says lines are terminated with LF, but - # it seems safer to handle CR LF terminations too. - chunks = _linesep_regexp.split(self.previous_buffer + new_data) - - # Save the last, unfinished line. - self.previous_buffer = chunks[-1] - if len(self.previous_buffer) > 2**14: - # Bad peer! Naughty peer! - self.disconnect() - return - chunks = chunks[:-1] - else: - chunks = [new_data] - - command = "dccmsg" - prefix = self.peeraddress - target = None - for chunk in chunks: - if DEBUG: - print("FROM PEER:", chunk) - arguments = [chunk] - if DEBUG: - print("command: %s, source: %s, target: %s, arguments: %s" % ( - command, prefix, target, arguments)) - self.irclibobj._handle_event( - self, - Event(command, prefix, target, arguments)) - - def _get_socket(self): - """[Internal]""" - return self.socket - - def privmsg(self, string): - """Send data to DCC peer. - - The string will be padded with appropriate LF if it's a DCC - CHAT session. - """ - try: - self.socket.send(string) - if self.dcctype == "chat": - self.socket.send("\n") - if DEBUG: - print("TO PEER: %s\n" % string) - except socket.error as x: - # Ouch! - self.disconnect("Connection reset by peer.") - - -class SimpleIRCClient: - """A simple single-server IRC client class. - - This is an example of an object-oriented wrapper of the IRC - framework. A real IRC client can be made by subclassing this - class and adding appropriate methods. - - The method on_join will be called when a "join" event is created - (which is done when the server sends a JOIN messsage/command), - on_privmsg will be called for "privmsg" events, and so on. The - handler methods get two arguments: the connection object (same as - self.connection) and the event object. - - Instance attributes that can be used by sub classes: - - ircobj -- The IRC instance. - - connection -- The ServerConnection instance. - - dcc_connections -- A list of DCCConnection instances. - """ - def __init__(self): - self.ircobj = IRC() - self.connection = self.ircobj.server() - self.dcc_connections = [] - self.ircobj.add_global_handler("all_events", self._dispatcher, -10) - self.ircobj.add_global_handler("dcc_disconnect", self._dcc_disconnect, -10) - - def _dispatcher(self, c, e): - """[Internal]""" - m = "on_" + e.eventtype() - if hasattr(self, m): - getattr(self, m)(c, e) - else: - if m != "on_all_raw_messages": - if hasattr(self, "on_default"): - self.on_default(c, e) - - def _dcc_disconnect(self, c, e): - self.dcc_connections.remove(c) - - def irc_connect(self, server, port, nickname, password=None, username=None, - ircname=None, localaddress="", localport=0, ssl=False, ipv6=False): - """Connect/reconnect to a server. - - Arguments: - - server -- Server name. - - port -- Port number. - - nickname -- The nickname. - - password -- Password (if any). - - username -- The username. - - ircname -- The IRC name. - - localaddress -- Bind the connection to a specific local IP address. - - localport -- Bind the connection to a specific local port. - - ssl -- Enable support for ssl. - - ipv6 -- Enable support for ipv6. - - This function can be called to reconnect a closed connection. - """ - self.connection.connect(server, port, nickname, - password, username, ircname, - localaddress, localport, ssl, ipv6) - - def irc_disconnect(self, message="ctrl-k"): - self.connection.disconnect(message) - - def dcc_connect(self, address, port, dcctype="chat"): - """Connect to a DCC peer. - - Arguments: - - address -- IP address of the peer. - - port -- Port to connect to. - - Returns a DCCConnection instance. - """ - dcc = self.ircobj.dcc(dcctype) - self.dcc_connections.append(dcc) - dcc.connect(address, port) - return dcc - - def dcc_listen(self, dcctype="chat"): - """Listen for connections from a DCC peer. - - Returns a DCCConnection instance. - """ - dcc = self.ircobj.dcc(dcctype) - self.dcc_connections.append(dcc) - dcc.listen() - return dcc - - def start(self): - """Start the IRC client.""" - self.ircobj.process_forever() - - def once(self): - """Poll IRC server once.""" - self.ircobj.process_once(timeout=0.01) - - -class Event: - """Class representing an IRC event.""" - def __init__(self, eventtype, source, target, arguments=None): - """Constructor of Event objects. - - Arguments: - - eventtype -- A string describing the event. - - source -- The originator of the event (a nick mask or a server). - - target -- The target of the event (a nick or a channel). - - arguments -- Any event specific arguments. - """ - self._eventtype = eventtype - self._source = source - self._target = target - if arguments: - self._arguments = arguments - else: - self._arguments = [] - - def eventtype(self): - """Get the event type.""" - return self._eventtype - - def source(self): - """Get the event source.""" - return self._source - - def target(self): - """Get the event target.""" - return self._target - - def arguments(self): - """Get the event arguments.""" - return self._arguments - -_LOW_LEVEL_QUOTE = "\020" -_CTCP_LEVEL_QUOTE = "\134" -_CTCP_DELIMITER = "\001" - -_low_level_mapping = { - "0": "\000", - "n": "\n", - "r": "\r", - _LOW_LEVEL_QUOTE: _LOW_LEVEL_QUOTE -} - -_low_level_regexp = re.compile(_LOW_LEVEL_QUOTE + "(.)") - - -def mask_matches(nick, mask): - """Check if a nick matches a mask. - - Returns true if the nick matches, otherwise false. - """ - nick = irc_lower(nick) - mask = irc_lower(mask) - mask = mask.replace("\\", "\\\\") - for ch in ".$|[](){}+": - mask = mask.replace(ch, "\\" + ch) - mask = mask.replace("?", ".") - mask = mask.replace("*", ".*") - r = re.compile(mask, re.IGNORECASE) - return r.match(nick) - -_special = "-[]\\`^{}" -nick_characters = string.ascii_letters + string.digits + _special -_ircstring_translation = str.maketrans(string.ascii_uppercase + "[]\\^", - string.ascii_lowercase + "{}|~") - - -def irc_lower(s): - """Returns a lowercased string. - - The definition of lowercased comes from the IRC specification (RFC - 1459). - """ - return s.translate(_ircstring_translation) - - -def _ctcp_dequote(message): - """[Internal] Dequote a message according to CTCP specifications. - - The function returns a list where each element can be either a - string (normal message) or a tuple of one or two strings (tagged - messages). If a tuple has only one element (ie is a singleton), - that element is the tag; otherwise the tuple has two elements: the - tag and the data. - - Arguments: - - message -- The message to be decoded. - """ - - def _low_level_replace(match_obj): - ch = match_obj.group(1) - - # If low_level_mapping doesn't have the character as key, we - # should just return the character. - return _low_level_mapping.get(ch, ch) - - if _LOW_LEVEL_QUOTE in message: - # Yup, there was a quote. Release the dequoter, man! - message = _low_level_regexp.sub(_low_level_replace, message) - - if _CTCP_DELIMITER not in message: - return [message] - else: - # Split it into parts. (Does any IRC client actually *use* - # CTCP stacking like this?) - chunks = message.split(_CTCP_DELIMITER) - - messages = [] - i = 0 - while i < len(chunks)-1: - # Add message if it's non-empty. - if len(chunks[i]) > 0: - messages.append(chunks[i]) - - if i < len(chunks)-2: - # Aye! CTCP tagged data ahead! - messages.append(tuple(chunks[i+1].split(" ", 1))) - - i = i + 2 - - if len(chunks) % 2 == 0: - # Hey, a lonely _CTCP_DELIMITER at the end! This means - # that the last chunk, including the delimiter, is a - # normal message! (This is according to the CTCP - # specification.) - messages.append(_CTCP_DELIMITER + chunks[-1]) - - return messages - - -def is_channel(string): - """Check if a string is a channel name. - - Returns true if the argument is a channel name, otherwise false. - """ - return string and string[0] in "#&+!" - - -def ip_numstr_to_quad(num): - """Convert an IP number as an integer given in ASCII - representation (e.g. '3232235521') to an IP address string - (e.g. '192.168.0.1').""" - n = int(num) - p = list(map(str, list(map(int, [n >> 24 & 0xFF, n >> 16 & 0xFF, - n >> 8 & 0xFF, n & 0xFF])))) - return ".".join(p) - - -def ip_quad_to_numstr(quad): - """Convert an IP address string (e.g. '192.168.0.1') to an IP - number as an integer given in ASCII representation - (e.g. '3232235521').""" - p = list(map(int, quad.split("."))) - s = str((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) - if s[-1] == "L": - s = s[:-1] - return s - - -def nm_to_n(s): - """Get the nick part of a nickmask. - - (The source of an Event is a nickmask.) - """ - return s.split("!")[0] - - -def nm_to_uh(s): - """Get the userhost part of a nickmask. - - (The source of an Event is a nickmask.) - """ - return s.split("!")[1] - - -def nm_to_h(s): - """Get the host part of a nickmask. - - (The source of an Event is a nickmask.) - """ - return s.split("@")[1] - - -def nm_to_u(s): - """Get the user part of a nickmask. - - (The source of an Event is a nickmask.) - """ - s = s.split("!")[1] - return s.split("@")[0] - - -def parse_nick_modes(mode_string): - """Parse a nick mode string. - - The function returns a list of lists with three members: sign, - mode and argument. The sign is \"+\" or \"-\". The argument is - always None. - - Example: - - >>> irclib.parse_nick_modes(\"+ab-c\") - [['+', 'a', None], ['+', 'b', None], ['-', 'c', None]] - """ - - return _parse_modes(mode_string, "") - - -def parse_channel_modes(mode_string): - """Parse a channel mode string. - - The function returns a list of lists with three members: sign, - mode and argument. The sign is \"+\" or \"-\". The argument is - None if mode isn't one of \"b\", \"k\", \"l\", \"v\" or \"o\". - - Example: - - >>> irclib.parse_channel_modes(\"+ab-c foo\") - [['+', 'a', None], ['+', 'b', 'foo'], ['-', 'c', None]] - """ - - return _parse_modes(mode_string, "bklvo") - - -def _parse_modes(mode_string, unary_modes=""): - """[Internal]""" - modes = [] - arg_count = 0 - - # State variable. - sign = "" - - a = mode_string.split() - if len(a) == 0: - return [] - else: - mode_part, args = a[0], a[1:] - - if mode_part[0] not in "+-": - return [] - for ch in mode_part: - if ch in "+-": - sign = ch - elif ch == " ": - collecting_arguments = 1 - elif ch in unary_modes: - if len(args) >= arg_count + 1: - modes.append([sign, ch, args[arg_count]]) - arg_count = arg_count + 1 - else: - modes.append([sign, ch, None]) - else: - modes.append([sign, ch, None]) - return modes - - -def _ping_ponger(connection, event): - """[Internal]""" - connection.pong(event.target()) - -# Numeric table mostly stolen from the Perl IRC module (Net::IRC). -numeric_events = { - "001": "welcome", - "002": "yourhost", - "003": "created", - "004": "myinfo", - "005": "featurelist", # XXX - "200": "tracelink", - "201": "traceconnecting", - "202": "tracehandshake", - "203": "traceunknown", - "204": "traceoperator", - "205": "traceuser", - "206": "traceserver", - "207": "traceservice", - "208": "tracenewtype", - "209": "traceclass", - "210": "tracereconnect", - "211": "statslinkinfo", - "212": "statscommands", - "213": "statscline", - "214": "statsnline", - "215": "statsiline", - "216": "statskline", - "217": "statsqline", - "218": "statsyline", - "219": "endofstats", - "221": "umodeis", - "231": "serviceinfo", - "232": "endofservices", - "233": "service", - "234": "servlist", - "235": "servlistend", - "241": "statslline", - "242": "statsuptime", - "243": "statsoline", - "244": "statshline", - "250": "luserconns", - "251": "luserclient", - "252": "luserop", - "253": "luserunknown", - "254": "luserchannels", - "255": "luserme", - "256": "adminme", - "257": "adminloc1", - "258": "adminloc2", - "259": "adminemail", - "261": "tracelog", - "262": "endoftrace", - "263": "tryagain", - "265": "n_local", - "266": "n_global", - "300": "none", - "301": "away", - "302": "userhost", - "303": "ison", - "305": "unaway", - "306": "nowaway", - "311": "whoisuser", - "312": "whoisserver", - "313": "whoisoperator", - "314": "whowasuser", - "315": "endofwho", - "316": "whoischanop", - "317": "whoisidle", - "318": "endofwhois", - "319": "whoischannels", - "321": "liststart", - "322": "list", - "323": "listend", - "324": "channelmodeis", - "329": "channelcreate", - "331": "notopic", - "332": "currenttopic", - "333": "topicinfo", - "341": "inviting", - "342": "summoning", - "346": "invitelist", - "347": "endofinvitelist", - "348": "exceptlist", - "349": "endofexceptlist", - "351": "version", - "352": "whoreply", - "353": "namreply", - "361": "killdone", - "362": "closing", - "363": "closeend", - "364": "links", - "365": "endoflinks", - "366": "endofnames", - "367": "banlist", - "368": "endofbanlist", - "369": "endofwhowas", - "371": "info", - "372": "motd", - "373": "infostart", - "374": "endofinfo", - "375": "motdstart", - "376": "endofmotd", - "377": "motd2", # 1997-10-16 -- tkil - "381": "youreoper", - "382": "rehashing", - "384": "myportis", - "391": "time", - "392": "usersstart", - "393": "users", - "394": "endofusers", - "395": "nousers", - "401": "nosuchnick", - "402": "nosuchserver", - "403": "nosuchchannel", - "404": "cannotsendtochan", - "405": "toomanychannels", - "406": "wasnosuchnick", - "407": "toomanytargets", - "409": "noorigin", - "411": "norecipient", - "412": "notexttosend", - "413": "notoplevel", - "414": "wildtoplevel", - "421": "unknowncommand", - "422": "nomotd", - "423": "noadmininfo", - "424": "fileerror", - "431": "nonicknamegiven", - "432": "erroneusnickname", # Thiss iz how its speld in thee RFC. - "433": "nicknameinuse", - "436": "nickcollision", - "437": "unavailresource", # "Nick temporally unavailable" - "441": "usernotinchannel", - "442": "notonchannel", - "443": "useronchannel", - "444": "nologin", - "445": "summondisabled", - "446": "usersdisabled", - "451": "notregistered", - "461": "needmoreparams", - "462": "alreadyregistered", - "463": "nopermforhost", - "464": "passwdmismatch", - "465": "yourebannedcreep", # I love this one... - "466": "youwillbebanned", - "467": "keyset", - "471": "channelisfull", - "472": "unknownmode", - "473": "inviteonlychan", - "474": "bannedfromchan", - "475": "badchannelkey", - "476": "badchanmask", - "477": "nochanmodes", # "Channel doesn't support modes" - "478": "banlistfull", - "481": "noprivileges", - "482": "chanoprivsneeded", - "483": "cantkillserver", - "484": "restricted", # Connection is restricted - "485": "uniqopprivsneeded", - "491": "nooperhost", - "492": "noservicehost", - "501": "umodeunknownflag", - "502": "usersdontmatch", -} - -generated_events = [ - # Generated events - "dcc_connect", - "dcc_disconnect", - "dccmsg", - "disconnect", - "ctcp", - "ctcpreply", -] - -protocol_events = [ - # IRC protocol events - "error", - "join", - "kick", - "mode", - "part", - "ping", - "privmsg", - "privnotice", - "pubmsg", - "pubnotice", - "quit", - "invite", - "pong", -] - -all_events = generated_events + protocol_events + list(numeric_events.values()) diff --git a/src/chat/lang.py b/src/chat/lang.py new file mode 100644 index 000000000..835cb9aae --- /dev/null +++ b/src/chat/lang.py @@ -0,0 +1,11 @@ +LANGUAGE_CHANNELS = { + "#french": ["fr"], + "#russian": ["ru", "by"], # Be conservative here + "#german": ["de"], +} +# Flip around for easier use +DEFAULT_LANGUAGE_CHANNELS = { + code: channel + for channel, codes in LANGUAGE_CHANNELS.items() + for code in codes +} diff --git a/src/chat/language_channel_config.py b/src/chat/language_channel_config.py new file mode 100644 index 000000000..6f2588152 --- /dev/null +++ b/src/chat/language_channel_config.py @@ -0,0 +1,128 @@ +from PyQt6.QtCore import QAbstractListModel +from PyQt6.QtCore import Qt + +from chat.lang import LANGUAGE_CHANNELS + + +class ChannelEntry: + def __init__(self, name, icon, checked): + self.name = name + self.icon = icon + self.checked = checked + + +class LanguageChannelConfig: + def __init__(self, parent_widget, settings, theme): + self._parent_widget = parent_widget + self._settings = settings + self._theme = theme + self._base = None + self._form = None + self._model = None + self._setup_widget() + self._setup_model() + + def _setup_widget(self): + formc, basec = self._theme.loadUiType( + "chat/language_channel_config.ui", + ) + self._form = formc() + self._base = basec(self._parent_widget) + self._form.setupUi(self._base) + self._form.endDialogBox.accepted.connect(self._on_accepted) + self._form.endDialogBox.rejected.connect(self._on_rejected) + + def _setup_model(self): + self._model = CheckableStringListModel() + self._form.channelListView.setModel(self._model) + + def _load_data(self): + self._model.load_data(self._chan_flag_list()) + + def _chan_flag_list(self): + checked_channels = self._current_channels() + channels = [] + for name, langs in LANGUAGE_CHANNELS.items(): + icon = self._country_icon(langs[0]) + checked = name in checked_channels + channels.append(ChannelEntry(name, icon, checked)) + + channels.sort(key=lambda x: x.name) + return channels + + # TODO - move somewhere + def _country_icon(self, country): + return self._theme.icon("chat/countries/{}.png".format(country)) + + def _current_channels(self): + checked_channels = self._settings.get('client/lang_channels', "") + return [c for c in checked_channels.split(';') if c] + + def _save_channels(self): + channels = self._model.checked_channels() + self._settings.set('client/lang_channels', ';'.join(channels)) + + def _on_accepted(self): + self._save_channels() + self._base.accept() + + def _on_rejected(self): + self._base.reject() + + def run(self): + self._load_data() + self._base.show() + + +class CheckableStringListModel(QAbstractListModel): + def __init__(self): + QAbstractListModel.__init__(self) + self._items = [] + + def rowCount(self, parent): + if parent.isValid(): + return 0 + return len(self._items) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + item = self._index_item(index) + if item is None: + return None + if role == Qt.ItemDataRole.DisplayRole: + return item.name + if role == Qt.ItemDataRole.DecorationRole: + return item.icon + if role == Qt.ItemDataRole.CheckStateRole: + return item.checked + return None + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + item = self._index_item(index) + if item is None: + return False + if role == Qt.ItemDataRole.CheckStateRole: + item.checked = value + self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole]) + return True + return False + + def _index_item(self, index): + if not index.isValid(): + return None + row = index.row() + if row < 0 or row >= len(self._items): + return None + return self._items[row] + + def load_data(self, entries): + self.modelAboutToBeReset.emit() + self._items = entries + self.modelReset.emit() + + def flags(self, index): + if index.isValid(): + return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled + return 0 + + def checked_channels(self): + return [i.name for i in self._items if i.checked] diff --git a/src/chat/line_restorer.py b/src/chat/line_restorer.py new file mode 100644 index 000000000..9341e2ce8 --- /dev/null +++ b/src/chat/line_restorer.py @@ -0,0 +1,23 @@ +class ChatLineRestorer: + def __init__(self, model): + self._model = model + self._saved_channels = {} + self._model.channels.added.connect(self._at_channel_added) + self._model.channels.removed.connect(self._at_channel_removed) + + def _at_channel_removed(self, channel): + self._save_channel_lines(channel) + + def _save_channel_lines(self, channel): + self._saved_channels[channel.id_key] = [line for line in channel.lines] + + def _at_channel_added(self, channel): + self._restore_channel_lines(channel) + + def _restore_channel_lines(self, channel): + saved = self._saved_channels.get(channel.id_key, None) + if saved is None: + return + for line in saved: + channel.lines.add_line(line) + del self._saved_channels[channel.id_key] diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py new file mode 100644 index 000000000..3a4422025 --- /dev/null +++ b/src/chat/socketadapter.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import logging +import time + +from irc.client import Reactor +from PyQt6.QtCore import QEventLoop +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QAbstractSocket +from PyQt6.QtNetwork import QNetworkRequest +from PyQt6.QtWebSockets import QWebSocket + +logger = logging.getLogger(__name__) + + +class WebSocketToSocket(QObject): + """ Allows to use QWebSocket as a 'socket' """ + + message_received = pyqtSignal() + + def __init__(self) -> None: + super().__init__() + self.socket = QWebSocket() + self.socket.binaryMessageReceived.connect(self.on_bin_message_received) + self.socket.errorOccurred.connect(self.on_socket_error) + self.buffer = b"" + + def on_socket_error(self, error: QAbstractSocket.SocketError) -> None: + logger.error(f"SocketAdapter error: {error}. Details: {self.socket.errorString()}") + + def on_bin_message_received(self, message: bytes) -> None: + # according to https://ircv3.net/specs/extensions/websocket + # messages MUST NOT include trailing \r\n, but our non-websocket + # library (irc) requires them + self.buffer += message + b"\r\n" + self.message_received.emit() + + def read(self, size: int) -> bytes: + if self.socket.state() != QAbstractSocket.SocketState.ConnectedState: + raise OSError + ans, self.buffer = self.buffer[:size], self.buffer[size:] + return ans + + def recv(self, size: int) -> bytes: + """ Alias for read, just in case """ + return self.read(size) + + def shutdown(self, how: int) -> None: + self.socket.deleteLater() + + def write(self, message: bytes) -> None: + sent = self.socket.sendBinaryMessage(message.strip()) + if sent == 0: + raise OSError + + def send(self, message: bytes) -> None: + """ Alias for write, just in case """ + self.write(message) + + def _prepare_request(self, server_address: tuple[str, int]) -> QNetworkRequest: + host, port = server_address + request = QNetworkRequest() + request.setUrl(QUrl(f"wss://{host}:{port}")) + request.setRawHeader(b"Sec-WebSocket-Protocol", b"binary.ircv3.net") + return request + + def connect(self, server_address: tuple[str, int]) -> None: + self.socket.open(self._prepare_request(server_address)) + + # FIXME: maybe there are too many usages of this loop trick + loop = QEventLoop() + self.socket.connected.connect(loop.exit) + self.socket.errorOccurred.connect(loop.exit) + loop.exec() + + def close(self) -> None: + self.socket.close() + + +class ReactorForSocketAdapter(Reactor): + def process_once(self, timeout: float = 0.01) -> None: + if self.sockets: + self.process_data(self.sockets) + else: + time.sleep(timeout) + self.process_timeout() + + +class ConnectionFactory: + def connect(self, server_address: tuple[str, int]) -> None: + sock = WebSocketToSocket() + sock.connect(server_address) + return sock + + __call__ = connect diff --git a/src/client/__init__.py b/src/client/__init__.py index eadf5a530..1cb67b3b6 100644 --- a/src/client/__init__.py +++ b/src/client/__init__.py @@ -1,38 +1,14 @@ -# Initialize logging system import logging -from PyQt5.QtNetwork import QNetworkAccessManager -from enum import IntEnum - -from config import Settings - -logger = logging.getLogger(__name__) -# logger.setLevel(logging.DEBUG) - -# Initialize all important globals -LOBBY_HOST = Settings.get('lobby/host') -LOBBY_PORT = Settings.get('lobby/port') -LOCAL_REPLAY_PORT = Settings.get('lobby/relay/port') - - -class ClientState(IntEnum): - """ - Various states the client can be in. - """ - SHUTDOWN = -666 # Going... DOWN! - - DISCONNECTED = -2 - CONNECTING = -1 - NONE = 0 - CONNECTED = 1 - LOGGED_IN = 2 - +# Do not remove - promoted widget, py2exe does not include it otherwise +from client.theme_menu import ThemeMenu from ._clientwindow import ClientWindow -# Do not remove - promoted widget, py2exe does not include it otherwise -from client.theme_menu import ThemeMenu +__all__ = ( + "ThemeMenu", +) -instance = ClientWindow() +logger = logging.getLogger(__name__) -NetworkManager = QNetworkAccessManager(instance) +instance = ClientWindow() diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 5fcb79d86..ebbd2f6f0 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1,214 +1,311 @@ -from PyQt5 import QtCore, QtWidgets, QtGui +import logging +import time +from functools import partial + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkAccessManager import config -import connectivity -from connectivity.helper import ConnectivityHelper -from client import ClientState, LOBBY_HOST, LOBBY_PORT, LOCAL_REPLAY_PORT +import fa +import notifications as ns +import util +import util.crash +from chat import ChatMVC +from chat._avatarWidget import AvatarWidget +from chat.channel_autojoiner import ChannelAutojoiner +from chat.chat_announcer import ChatAnnouncer +from chat.chat_controller import ChatController +from chat.chat_greeter import ChatGreeter +from chat.chat_view import ChatView +from chat.chatter_model import ChatterLayoutElements +from chat.ircconnection import IrcConnection +from chat.language_channel_config import LanguageChannelConfig +from chat.line_restorer import ChatLineRestorer from client.aliasviewer import AliasSearchWindow -from client.connection import LobbyInfo, ServerConnection, \ - Dispatcher, ConnectionState, ServerReconnecter +from client.aliasviewer import AliasWindow +from client.chat_config import ChatConfig +from client.clientstate import ClientState +from client.connection import ConnectionState +from client.connection import Dispatcher +from client.connection import LobbyInfo +from client.connection import ServerConnection +from client.connection import ServerReconnecter from client.gameannouncer import GameAnnouncer -from client.kick_dialog import KickDialog from client.login import LoginWidget from client.playercolors import PlayerColors from client.theme_menu import ThemeMenu -from client.updater import UpdateChecker, UpdateDialog -from client.update_settings import UpdateSettingsDialog from client.user import User -from downloadManager import PreviewDownloader, MAP_PREVIEW_ROOT -import fa +from client.user import UserRelationController +from client.user import UserRelationModel +from client.user import UserRelations +from client.user import UserRelationTrackers +from connectivity.ConnectivityDialog import ConnectivityDialog +from coop import CoopWidget +from downloadManager import AvatarDownloader +from downloadManager import MapSmallPreviewDownloader from fa.factions import Factions +from fa.game_runner import GameRunner +from fa.game_session import GameSession from fa.maps import getUserMapsFolder -from functools import partial -from games.gamemodel import GameModel +from games import GamesWidget from games.gameitem import GameViewBuilder +from games.gamemodel import GameModel from games.hostgamewidget import build_launcher -import json +from mapGenerator.mapgenManager import MapGeneratorManager +from model.chat.channel import ChannelID +from model.chat.channel import ChannelType +from model.chat.chat import Chat +from model.chat.chatline import ChatLineMetadataBuilder from model.gameset import Gameset +from model.gameset import PlayerGameIndex from model.player import Player from model.playerset import Playerset -from modvault.utils import MODFOLDER -import notifications as ns +from model.rating import MatchmakerQueueType +from model.rating import RatingType +from news import NewsWidget +from oauth.oauth_flow import OAuth2FlowInstance +from power import PowerTools +from replays import ReplaysWidget from secondaryServer import SecondaryServer -import time -import util -from ui.status_logo import StatusLogo +from stats import StatsWidget from ui.busy_widget import BusyWidget +from ui.status_logo import StatusLogo +from unitdb.unitdbtab import UnitDBTab +from updater import ClientUpdateTools +from vaults.mapvault.mapvault import MapVault +from vaults.modvault.modvault import ModVault +from vaults.modvault.utils import getModFolder +from vaults.modvault.utils import setModFolder -''' -Created on Dec 1, 2011 - -@author: thygrrr -''' +from .mouse_position import MousePosition -import logging logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("client/client.ui") -class mousePosition(object): - def __init__(self, parent): - self.parent = parent - self.onLeftEdge = False - self.onRightEdge = False - self.onTopEdge = False - self.onBottomEdge = False - self.cursorShapeChange = False - self.warning_buttons = dict() - self.onEdges = False - - def computeMousePosition(self, pos): - self.onLeftEdge = pos.x() < 8 - self.onRightEdge = pos.x() > self.parent.size().width() - 8 - self.onTopEdge = pos.y() < 8 - self.onBottomEdge = pos.y() > self.parent.size().height() - 8 - - self.onTopLeftEdge = self.onTopEdge and self.onLeftEdge - self.onBottomLeftEdge = self.onBottomEdge and self.onLeftEdge - self.onTopRightEdge = self.onTopEdge and self.onRightEdge - self.onBottomRightEdge = self.onBottomEdge and self.onRightEdge - - self.onEdges = self.onLeftEdge or self.onRightEdge or self.onTopEdge or self.onBottomEdge - - def resetToFalse(self): - self.onLeftEdge = False - self.onRightEdge = False - self.onTopEdge = False - self.onBottomEdge = False - self.cursorShapeChange = False - - def isOnEdge(self): - return self.onEdges - - class ClientWindow(FormClass, BaseClass): """ - This is the main lobby client that manages the FAF-related connection and data, - in particular players, games, ranking, etc. + This is the main lobby client that manages the FAF-related connection and + data, in particular players, games, ranking, etc. Its UI also houses all the other UIs for the sub-modules. """ state_changed = QtCore.pyqtSignal(object) authorized = QtCore.pyqtSignal(object) - # These signals notify connected modules of game state changes (i.e. reasons why FA is launched) - viewingReplay = QtCore.pyqtSignal(QtCore.QUrl) + # These signals notify connected modules of game state changes + # (i.e. reasons why FA is launched) + viewing_replay = QtCore.pyqtSignal(object) # Game state controls - gameEnter = QtCore.pyqtSignal() - gameExit = QtCore.pyqtSignal() - gameFull = QtCore.pyqtSignal() + game_enter = QtCore.pyqtSignal() + game_exit = QtCore.pyqtSignal() + game_full = QtCore.pyqtSignal() # These signals propagate important client state changes to other modules - localBroadcast = QtCore.pyqtSignal(str, str) - autoJoin = QtCore.pyqtSignal(list) - channelsUpdated = QtCore.pyqtSignal(list) + local_broadcast = QtCore.pyqtSignal(str, str) + auto_join = QtCore.pyqtSignal(list) + channels_updated = QtCore.pyqtSignal(list) + unofficial_client = QtCore.pyqtSignal(str) + + matchmaker_info = QtCore.pyqtSignal(dict) + party_invite = QtCore.pyqtSignal(dict) - matchmakerInfo = QtCore.pyqtSignal(dict) + remember = config.Settings.persisted_property( + 'user/remember', type=bool, default_value=True, + ) + refresh_token = config.Settings.persisted_property( + 'user/refreshToken', persist_if=lambda self: self.remember, + ) - remember = config.Settings.persisted_property('user/remember', type=bool, default_value=True) - login = config.Settings.persisted_property('user/login', persist_if=lambda self: self.remember) - password = config.Settings.persisted_property('user/password', persist_if=lambda self: self.remember) + game_logs = config.Settings.persisted_property( + 'game/logs', type=bool, default_value=True, + ) - gamelogs = config.Settings.persisted_property('game/logs', type=bool, default_value=True) - useUPnP = config.Settings.persisted_property('game/upnp', type=bool, default_value=True) - gamePort = config.Settings.persisted_property('game/port', type=int, default_value=6112) + use_chat = config.Settings.persisted_property( + 'chat/enabled', type=bool, default_value=True, + ) def __init__(self, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) + super(ClientWindow, self).__init__(*args, **kwargs) logger.debug("Client instantiating") # Hook to Qt's application management system QtWidgets.QApplication.instance().aboutToQuit.connect(self.cleanup) + QtWidgets.QApplication.instance().applicationStateChanged.connect( + self.appStateChanged, + ) - self.uniqueId = None + self._network_access_manager = QNetworkAccessManager(self) + self.oauth_flow = OAuth2FlowInstance + self.oauth_flow.setParent(self) + self.oauth_flow.granted.connect(self.do_connect) + self.oauth_flow.granted.connect(self.save_refresh_token) + self.oauth_flow.requestFailed.connect(self.show_login_widget) - self.sendFile = False + self.unique_id = None + self._chat_config = ChatConfig(util.settings) + + self.send_file = False self.warning_buttons = {} # Tray icon self.tray = QtWidgets.QSystemTrayIcon() self.tray.setIcon(util.THEME.icon("client/tray_icon.png")) + self.tray.setToolTip("FAF Python Client") + self.tray.activated.connect(self.handle_tray_icon_activation) + tray_menu = QtWidgets.QMenu() + tray_menu.addAction("Open Client", self.show_normal) + tray_menu.addAction("Quit Client", self.close) + self.tray.setContextMenu(tray_menu) + # Mouse down on tray icon deactivates the application. + # So there is no way to know for sure if the tray icon was clicked from + # active application or from inactive application. So we assume that + # if the application was deactivated less than 0.5s ago, then the tray + # icon click (both left or right button) was made from the active app. + self._lastDeactivateTime = None + self.keepActiveForTrayIcon = 0.5 self.tray.show() self._state = ClientState.NONE self.session = None + self.game_session = None # This dictates whether we login automatically in the beginning or # after a disconnect. We turn it on if we're sure we have correct # credentials and want to use them (if we were remembered or after # login) and turn it off if we're getting fresh credentials or # encounter a serious server error. - self._autorelogin = self.remember + self._auto_relogin = self.remember self.lobby_dispatch = Dispatcher() - self.lobby_connection = ServerConnection(LOBBY_HOST, LOBBY_PORT, - self.lobby_dispatch.dispatch) - self.lobby_connection.state_changed.connect(self.on_connection_state_changed) - self.lobby_reconnecter = ServerReconnecter(self.lobby_connection) + self.lobby_connection = ServerConnection( + config.Settings.get('lobby/host'), + config.Settings.get('lobby/port', type=int), + self.lobby_dispatch.dispatch, + ) + self.lobby_connection.state_changed.connect( + self.on_connection_state_changed, + ) + self.lobby_reconnector = ServerReconnecter(self.lobby_connection) self.players = Playerset() # Players known to the client - self.gameset = Gameset(self.players) - fa.instance.gameset = self.gameset # FIXME (needed fa/game_process L81 for self.game = self.gameset[uid]) + self._player_game_relation = PlayerGameIndex( + self.gameset, self.players, + ) + + # FIXME (needed fa/game_process L81 for self.game = self.gameset[uid]) + fa.instance.gameset = self.gameset + + self.lobby_info = LobbyInfo( + self.lobby_dispatch, self.gameset, self.players, + ) # Handy reference to the User object representing the logged-in user. self.me = User(self.players) - - self.map_downloader = PreviewDownloader(util.MAP_PREVIEW_DIR, MAP_PREVIEW_ROOT) - self.mod_downloader = PreviewDownloader(util.MOD_PREVIEW_DIR, None) + self.login = None + self.id = None + + self._chat_model = Chat.build( + playerset=self.players, + base_channels=['#aeolus'], + ) + + relation_model = UserRelationModel.build() + relation_controller = UserRelationController.build( + relation_model, + me=self.me, + settings=config.Settings, + lobby_info=self.lobby_info, + lobby_connection=self.lobby_connection, + ) + relation_trackers = UserRelationTrackers.build( + relation_model, + playerset=self.players, + chatterset=self._chat_model.chatters, + ) + self.user_relations = UserRelations( + relation_model, relation_controller, relation_trackers, + ) + self.me.relations = self.user_relations + + self.map_preview_downloader = MapSmallPreviewDownloader(util.MAP_PREVIEW_SMALL_DIR) + self.avatar_downloader = AvatarDownloader() + + # Map generator + self.map_generator = MapGeneratorManager() # Qt model for displaying active games. - self.game_model = GameModel(self.me, self.map_downloader, self.gameset) + self.game_model = GameModel(self.me, self.map_preview_downloader, self.gameset) - self.lobby_info = LobbyInfo(self.lobby_dispatch, self.gameset, self.players) - self.gameset.newGame.connect(self.fill_in_session_info) + self.gameset.added.connect(self.fill_in_session_info) - self.lobby_dispatch["session"] = self.handle_session - self.lobby_dispatch["registration_response"] = self.handle_registration_response + self.lobby_info.serverSession.connect(self.handle_session) + self.lobby_dispatch["registration_response"] = ( + self.handle_registration_response + ) self.lobby_dispatch["game_launch"] = self.handle_game_launch self.lobby_dispatch["matchmaker_info"] = self.handle_matchmaker_info - self.lobby_dispatch["social"] = self.handle_social self.lobby_dispatch["player_info"] = self.handle_player_info self.lobby_dispatch["notice"] = self.handle_notice self.lobby_dispatch["invalid"] = self.handle_invalid - self.lobby_dispatch["update"] = self.handle_update self.lobby_dispatch["welcome"] = self.handle_welcome - self.lobby_dispatch["authentication_failed"] = self.handle_authentication_failed + self.lobby_dispatch["authentication_failed"] = ( + self.handle_authentication_failed + ) + self.lobby_dispatch["irc_password"] = self.handle_irc_password + self.lobby_dispatch["update_party"] = self.handle_update_party + self.lobby_dispatch["kicked_from_party"] = ( + self.handle_kicked_from_party + ) + self.lobby_dispatch["party_invite"] = self.handle_party_invite + self.lobby_dispatch["match_found"] = self.handle_match_found_message + self.lobby_dispatch["match_cancelled"] = self.handle_match_cancelled + self.lobby_dispatch["search_info"] = self.handle_search_info + self.lobby_info.social.connect(self.handle_social) # Process used to run Forged Alliance (managed in module fa) - fa.instance.started.connect(self.startedFA) - fa.instance.finished.connect(self.finishedFA) - fa.instance.error.connect(self.errorFA) - self.gameset.newGame.connect(fa.instance.newServerGame) + fa.instance.started.connect(self.started_fa) + fa.instance.finished.connect(self.finished_fa) + fa.instance.errorOccurred.connect(self.error_fa) + self.gameset.added.connect(fa.instance.newServerGame) # Local Replay Server self.replayServer = fa.replayserver.ReplayServer(self) - # GameSession - self.game_session = None # type: fa.GameSession - # ConnectivityTest - self.connectivity = None # type: ConnectivityHelper + self.connectivity = None # type - ConnectivityHelper # stat server - self.statsServer = SecondaryServer("Statistic", 11002, self.lobby_dispatch) + self.statsServer = SecondaryServer( + "Statistic", 11002, self.lobby_dispatch, + ) # create user interface (main window) and load theme self.setupUi(self) - util.THEME.setStyleSheet(self, "client/client.css") + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() - self.setWindowTitle("FA Forever " + util.VERSION_STRING) + self.setWindowTitle("FA Forever {}".format(util.VERSION_STRING)) # Frameless - self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.CustomizeWindowHint) + self.setWindowFlags( + QtCore.Qt.WindowType.FramelessWindowHint + | QtCore.Qt.WindowType.WindowSystemMenuHint + | QtCore.Qt.WindowType.WindowMinimizeButtonHint, + ) - self.rubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle) + self.rubber_band = QtWidgets.QRubberBand( + QtWidgets.QRubberBand.Shape.Rectangle, + ) - self.mousePosition = mousePosition(self) - self.installEventFilter(self) + self.mouse_position = MousePosition(self) + self.installEventFilter(self) # register events self.minimize = QtWidgets.QToolButton(self) self.minimize.setIcon(util.THEME.icon("client/minimize-button.png")) @@ -231,87 +328,117 @@ def __init__(self, *args, **kwargs): self.maximize.setProperty("windowControlBtn", True) self.minimize.setProperty("windowControlBtn", True) - self.logo = StatusLogo(self) - self.logo.disconnect_requested.connect(self.disconnect) - self.logo.reconnect_requested.connect(self.reconnect) - self.logo.about_dialog_requested.connect(self.linkAbout) - self.logo.connectivity_dialog_requested.connect(self.connectivityDialog) - self.menu = self.menuBar() - self.topLayout.addWidget(self.logo) - titleLabel = QtWidgets.QLabel("FA Forever" if not config.is_beta() else "FA Forever BETA") - titleLabel.setProperty('titleLabel', True) - self.topLayout.addWidget(titleLabel) + title_label = QtWidgets.QLabel( + "FA Forever" if not config.is_beta() else "FA Forever BETA", + ) + title_label.setProperty('titleLabel', True) + self.topLayout.addWidget(title_label) self.topLayout.addStretch(500) self.topLayout.addWidget(self.menu) self.topLayout.addWidget(self.minimize) self.topLayout.addWidget(self.maximize) self.topLayout.addWidget(close) self.topLayout.setSpacing(0) - self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - self.maxNormal = False + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.is_window_maximized = False close.clicked.connect(self.close) - self.minimize.clicked.connect(self.showSmall) - self.maximize.clicked.connect(self.showMaxRestore) + self.minimize.clicked.connect(self.showMinimized) + self.maximize.clicked.connect(self.show_max_restore) self.moving = False self.dragging = False - self.draggingHover = False + self.dragging_hover = False self.offset = None - self.curSize = None + self.current_geometry = None - sizeGrip = QtWidgets.QSizeGrip(self) - self.mainGridLayout.addWidget(sizeGrip, 2, 2) + self.mainGridLayout.addWidget(QtWidgets.QSizeGrip(self), 2, 2) # Wire all important signals self._main_tab = -1 - self.mainTabs.currentChanged.connect(self.mainTabChanged) + self.mainTabs.currentChanged.connect(self.main_tab_changed) self._vault_tab = -1 - self.topTabs.currentChanged.connect(self.vaultTabChanged) + self.topTabs.currentChanged.connect(self.vault_tab_changed) - self.player_colors = PlayerColors(self.me) + self.player_colors = PlayerColors( + self.me, self.user_relations.model, util.THEME, + ) - self.game_announcer = GameAnnouncer(self.gameset, self.me, - self.player_colors, self) + self.game_announcer = GameAnnouncer( + self.gameset, self.me, self.player_colors, + ) self.power = 0 # current user power self.id = 0 # Initialize the Menu Bar according to settings etc. + self._language_channel_config = LanguageChannelConfig( + self, config.Settings, util.THEME, + ) self.initMenus() # Load the icons for the tabs - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.whatNewTab), util.THEME.icon("client/feed.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.chatTab), util.THEME.icon("client/chat.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.gamesTab), util.THEME.icon("client/games.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.coopTab), util.THEME.icon("client/coop.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.vaultsTab), util.THEME.icon("client/mods.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.ladderTab), util.THEME.icon("client/ladder.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.tourneyTab), util.THEME.icon("client/tourney.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.unitdbTab), util.THEME.icon("client/twitch.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.replaysTab), util.THEME.icon("client/replays.png")) - self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.tutorialsTab), util.THEME.icon("client/tutorials.png")) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.whatNewTab), + util.THEME.icon("client/feed.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.chatTab), + util.THEME.icon("client/chat.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.gamesTab), + util.THEME.icon("client/games.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.coopTab), + util.THEME.icon("client/coop.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.vaultsTab), + util.THEME.icon("client/mods.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.ladderTab), + util.THEME.icon("client/ladder.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.tourneyTab), + util.THEME.icon("client/tourney.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.unitdbTab), + util.THEME.icon("client/unitdb.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.replaysTab), + util.THEME.icon("client/replays.png"), + ) + self.mainTabs.setTabIcon( + self.mainTabs.indexOf(self.tutorialsTab), + util.THEME.icon("client/tutorials.png"), + ) # for moderator - self.modMenu = None - - self._alias_window = AliasSearchWindow(self) - #self.nFrame = NewsFrame() - #self.whatsNewLayout.addWidget(self.nFrame) - #self.nFrame.collapse() - - #self.nFrame = NewsFrame() - #self.whatsNewLayout.addWidget(self.nFrame) + self.mod_menu = None + self.power_tools = PowerTools.build( + playerset=self.players, + lobby_connection=self.lobby_connection, + theme=util.THEME, + parent_widget=self, + settings=config.Settings, + ) - #self.nFrame = NewsFrame() - #self.whatsNewLayout.addWidget(self.nFrame) + self._alias_viewer = AliasWindow.build(parent_widget=self) + self._alias_search_window = AliasSearchWindow(self, self._alias_viewer) + self._game_runner = GameRunner(self.gameset, self) + self.connectivity_dialog = None - #self.WPApi = WPAPI(self) - #self.WPApi.newsDone.connect(self.on_wpapi_done) - #self.WPApi.download() - - #self.controlsContainerLayout.setAlignment(self.pageControlFrame, QtCore.Qt.AlignRight) + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) @property def state(self): @@ -337,241 +464,381 @@ def on_connection_state_changed(self, state): def on_connected(self): # Enable reconnect in case we used to explicitly stay offline - self.lobby_reconnecter.enabled = True - - self.lobby_connection.send(dict(command="ask_session", - version=config.VERSION, - user_agent="faf-client")) + self.lobby_reconnector.enabled = True + self.lobby_connection.send( + dict( + command="ask_session", + version=config.VERSION, + user_agent="faf-client", + ), + ) def on_disconnected(self): logger.warning("Disconnected from lobby server.") self.gameset.clear() self.clear_players() + self.games.stopSearch() - @QtCore.pyqtSlot(bool) - def on_actionSavegamelogs_toggled(self, value): - self.gamelogs = value - - @QtCore.pyqtSlot(bool) - def on_actionAutoDownloadMods_toggled(self, value): - config.Settings.set('mods/autodownload', value is True) - - @QtCore.pyqtSlot(bool) - def on_actionAutoDownloadMaps_toggled(self, value): - config.Settings.set('maps/autodownload', value is True) + def appStateChanged(self, state): + if state == QtCore.Qt.ApplicationState.ApplicationInactive: + self._lastDeactivateTime = time.monotonic() def eventFilter(self, obj, event): - if event.type() == QtCore.QEvent.HoverMove: - self.draggingHover = self.dragging + if event.type() == QtCore.QEvent.Type.HoverMove: + self.dragging_hover = self.dragging if self.dragging: - self.resizeWidget(self.mapToGlobal(event.pos())) + self.resize_widget(self.mapToGlobal(event.position())) else: - if self.maxNormal == False: - self.mousePosition.computeMousePosition(event.pos()) + if not self.is_window_maximized: + self.mouse_position.update_mouse_position(event.position()) else: - self.mousePosition.resetToFalse() - self.updateCursorShape(event.pos()) + self.mouse_position.reset_to_false() + self.update_cursor_shape() return False - def updateCursorShape(self, pos): - if self.mousePosition.onTopLeftEdge or self.mousePosition.onBottomRightEdge: - self.mousePosition.cursorShapeChange = True - self.setCursor(QtCore.Qt.SizeFDiagCursor) - elif self.mousePosition.onTopRightEdge or self.mousePosition.onBottomLeftEdge: - self.setCursor(QtCore.Qt.SizeBDiagCursor) - self.mousePosition.cursorShapeChange = True - elif self.mousePosition.onLeftEdge or self.mousePosition.onRightEdge: - self.setCursor(QtCore.Qt.SizeHorCursor) - self.mousePosition.cursorShapeChange = True - elif self.mousePosition.onTopEdge or self.mousePosition.onBottomEdge: - self.setCursor(QtCore.Qt.SizeVerCursor) - self.mousePosition.cursorShapeChange = True + def update_cursor_shape(self): + if ( + self.mouse_position.on_top_left_edge + or self.mouse_position.on_bottom_right_edge + ): + self.mouse_position.cursor_shape_change = True + self.setCursor(QtCore.Qt.CursorShape.SizeFDiagCursor) + elif ( + self.mouse_position.on_top_right_edge + or self.mouse_position.on_bottom_left_edge + ): + self.setCursor(QtCore.Qt.CursorShape.SizeBDiagCursor) + self.mouse_position.cursor_shape_change = True + elif ( + self.mouse_position.on_left_edge + or self.mouse_position.on_right_edge + ): + self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor) + self.mouse_position.cursor_shape_change = True + elif ( + self.mouse_position.on_top_edge + or self.mouse_position.on_bottom_edge + ): + self.setCursor(QtCore.Qt.CursorShape.SizeVerCursor) + self.mouse_position.cursor_shape_change = True else: - if self.mousePosition.cursorShapeChange == True: + if self.mouse_position.cursor_shape_change: self.unsetCursor() - self.mousePosition.cursorShapeChange = False - - def showSmall(self): - self.showMinimized() + self.mouse_position.cursor_shape_change = False + + def handle_tray_icon_activation( + self, + reason: QtWidgets.QSystemTrayIcon.ActivationReason, + ) -> None: + if reason is QtWidgets.QSystemTrayIcon.ActivationReason.Trigger: + if self._lastDeactivateTime is None: + self.showMinimized() + return - def showMaxRestore(self): - if (self.maxNormal): - self.maxNormal = False - if self.curSize: - self.setGeometry(self.curSize) + inactiveTime = time.monotonic() - self._lastDeactivateTime + if ( + self.isMinimized() + or inactiveTime >= self.keepActiveForTrayIcon + ): + self.show_normal() + else: + self.showMinimized() + elif reason is QtWidgets.QSystemTrayIcon.ActivationReason.Context: + position = QtGui.QCursor.pos() + position.setY(position.y() - self.tray.contextMenu().height()) + self.tray.contextMenu().popup(position) + + def show_normal(self): + self.showNormal() + self.activateWindow() + + def show_max_restore(self): + if self.is_window_maximized: + self.is_window_maximized = False + if self.current_geometry: + self.setGeometry(self.current_geometry) else: - self.maxNormal = True - self.curSize = self.geometry() - self.setGeometry(QtWidgets.QDesktopWidget().availableGeometry(self)) + self.is_window_maximized = True + self.current_geometry = self.geometry() + self.setGeometry(self.screen().availableGeometry()) def mouseDoubleClickEvent(self, event): - self.showMaxRestore() + self.show_max_restore() def mouseReleaseEvent(self, event): self.dragging = False self.moving = False - if self.rubberBand.isVisible(): - self.maxNormal = True - self.curSize = self.geometry() - self.setGeometry(self.rubberBand.geometry()) - self.rubberBand.hide() - # self.showMaxRestore() + if self.rubber_band.isVisible(): + self.is_window_maximized = True + self.current_geometry = self.geometry() + self.setGeometry(self.rubber_band.geometry()) + self.rubber_band.hide() + # self.show_max_restore() def mousePressEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - if self.mousePosition.isOnEdge() and not self.maxNormal: + if event.button() == QtCore.Qt.MouseButton.LeftButton: + if ( + self.mouse_position.is_on_edge() + and not self.is_window_maximized + ): self.dragging = True return else: self.dragging = False self.moving = True - self.offset = event.pos() + self.offset = event.position() def mouseMoveEvent(self, event): - if self.dragging and self.draggingHover == False: - self.resizeWidget(event.globalPos()) + if self.dragging and not self.dragging_hover: + self.resize_widget(event.globalPosition()) elif self.moving and self.offset is not None: - desktop = QtWidgets.QDesktopWidget().availableGeometry(self) - if event.globalPos().y() == 0: - self.rubberBand.setGeometry(desktop) - self.rubberBand.show() - elif event.globalPos().x() == 0: + desktop = self.screen().availableGeometry() + if event.globalPosition().y() == 0: + self.rubber_band.setGeometry(desktop) + self.rubber_band.show() + elif event.globalPosition().x() == 0: desktop.setRight(desktop.right() / 2.0) - self.rubberBand.setGeometry(desktop) - self.rubberBand.show() - elif event.globalPos().x() == desktop.right(): + self.rubber_band.setGeometry(desktop) + self.rubber_band.show() + elif event.globalPosition().x() == desktop.right(): desktop.setRight(desktop.right() / 2.0) desktop.moveLeft(desktop.right()) - self.rubberBand.setGeometry(desktop) - self.rubberBand.show() + self.rubber_band.setGeometry(desktop) + self.rubber_band.show() else: - self.rubberBand.hide() - if self.maxNormal: - self.showMaxRestore() - - self.move(event.globalPos() - self.offset) - - def resizeWidget(self, globalMousePos): - if globalMousePos.y() == 0: - self.rubberBand.setGeometry(QtWidgets.QDesktopWidget().availableGeometry(self)) - self.rubberBand.show() + self.rubber_band.hide() + if self.is_window_maximized: + self.show_max_restore() + + point_f = event.globalPosition() - self.offset + self.move(point_f.toPoint()) + + def resize_widget(self, mouse_position: QtCore.QRectF) -> None: + mouse_point = mouse_position.toPoint() + if mouse_point.y() == 0: + self.rubber_band.setGeometry(self.screen().availableGeometry()) + self.rubber_band.show() else: - self.rubberBand.hide() - - origRect = self.frameGeometry() - - left, top, right, bottom = origRect.getCoords() - minWidth = self.minimumWidth() - minHeight = self.minimumHeight() - if self.mousePosition.onTopLeftEdge: - left = globalMousePos.x() - top = globalMousePos.y() - - elif self.mousePosition.onBottomLeftEdge: - left = globalMousePos.x() - bottom = globalMousePos.y() - elif self.mousePosition.onTopRightEdge: - right = globalMousePos.x() - top = globalMousePos.y() - elif self.mousePosition.onBottomRightEdge: - right = globalMousePos.x() - bottom = globalMousePos.y() - elif self.mousePosition.onLeftEdge: - left = globalMousePos.x() - elif self.mousePosition.onRightEdge: - right = globalMousePos.x() - elif self.mousePosition.onTopEdge: - top = globalMousePos.y() - elif self.mousePosition.onBottomEdge: - bottom = globalMousePos.y() - - newRect = QtCore.QRect(QtCore.QPoint(left, top), QtCore.QPoint(right, bottom)) - if newRect.isValid(): - if minWidth > newRect.width(): - if left != origRect.left(): - newRect.setLeft(origRect.left()) + self.rubber_band.hide() + + orig_rect = self.frameGeometry() + + left, top, right, bottom = orig_rect.getCoords() + min_width = self.minimumWidth() + min_height = self.minimumHeight() + if self.mouse_position.on_top_left_edge: + left = mouse_point.x() + top = mouse_point.y() + elif self.mouse_position.on_bottom_left_edge: + left = mouse_point.x() + bottom = mouse_point.y() + elif self.mouse_position.on_top_right_edge: + right = mouse_point.x() + top = mouse_point.y() + elif self.mouse_position.on_bottom_right_edge: + right = mouse_point.x() + bottom = mouse_point.y() + elif self.mouse_position.on_left_edge: + left = mouse_point.x() + elif self.mouse_position.on_right_edge: + right = mouse_point.x() + elif self.mouse_position.on_top_edge: + top = mouse_point.y() + elif self.mouse_position.on_bottom_edge: + bottom = mouse_point.y() + + new_rect = QtCore.QRect( + QtCore.QPoint(left, top), + QtCore.QPoint(right, bottom), + ) + if new_rect.isValid(): + if min_width > new_rect.width(): + if left != orig_rect.left(): + new_rect.setLeft(orig_rect.left()) else: - newRect.setRight(origRect.right()) - if minHeight > newRect.height(): - if top != origRect.top(): - newRect.setTop(origRect.top()) + new_rect.setRight(orig_rect.right()) + if min_height > new_rect.height(): + if top != orig_rect.top(): + new_rect.setTop(orig_rect.top()) else: - newRect.setBottom(origRect.bottom()) + new_rect.setBottom(orig_rect.bottom()) - self.setGeometry(newRect) + self.setGeometry(new_rect) def setup(self): - from news import NewsWidget - from chat import ChatWidget - from coop import CoopWidget - from games import GamesWidget - from tutorials import TutorialsWidget - from stats import StatsWidget - from tourneys import TournamentsWidget - from vault import MapVault - from modvault import ModVault - from replays import ReplaysWidget - from chat._avatarWidget import AvatarWidget - - self.loadSettings() - - self.gameview_builder = GameViewBuilder(self.me, - self.player_colors) - self.game_launcher = build_launcher(self.players, self.me, - self, self.gameview_builder, - self.map_downloader) + self.load_settings() + self._chat_config.channel_blink_interval = 500 + self._chat_config.channel_ping_timeout = 60 * 1000 + self._chat_config.max_chat_lines = 200 + self._chat_config.chat_line_trim_count = 50 + self._chat_config.announcement_channels = ['#aeolus'] + self._chat_config.channels_to_greet_in = ['#aeolus'] + self._chat_config.newbie_channel_game_threshold = 50 + + wiki_link = util.Settings.get("WIKI_URL") + wiki_formatter = "Check out the wiki: {} for help with common issues." + wiki_msg = wiki_formatter.format(wiki_link) + + self._chat_config.channel_greeting = [ + ("Welcome to Forged Alliance Forever!", "red", "+3"), + (wiki_msg, "white", "+1"), + ("", "black", "+1"), + ("", "black", "+1"), + ] + + self.gameview_builder = GameViewBuilder(self.me, self.player_colors) + self.game_launcher = build_launcher( + self.players, self.me, + self, self.gameview_builder, + self.map_preview_downloader, + ) + self._avatar_widget_builder = AvatarWidget.builder( + parent_widget=self, + lobby_connection=self.lobby_connection, + lobby_info=self.lobby_info, + avatar_dler=self.avatar_downloader, + theme=util.THEME, + ) + + chat_connection = IrcConnection.build(settings=config.Settings) + line_metadata_builder = ChatLineMetadataBuilder.build( + me=self.me, + user_relations=self.user_relations.model, + ) + + chat_controller = ChatController.build( + connection=chat_connection, + model=self._chat_model, + user_relations=self.user_relations.model, + chat_config=self._chat_config, + me=self.me, + line_metadata_builder=line_metadata_builder, + ) + + target_channel = ChannelID(ChannelType.PUBLIC, '#aeolus') + chat_view = ChatView.build( + target_viewed_channel=target_channel, + model=self._chat_model, + controller=chat_controller, + parent_widget=self, + theme=util.THEME, + chat_config=self._chat_config, + player_colors=self.player_colors, + me=self.me, + user_relations=self.user_relations, + power_tools=self.power_tools, + map_preview_dler=self.map_preview_downloader, + avatar_dler=self.avatar_downloader, + avatar_widget_builder=self._avatar_widget_builder, + alias_viewer=self._alias_viewer, + client_window=self, + game_runner=self._game_runner, + ) + + channel_autojoiner = ChannelAutojoiner.build( + base_channels=['#aeolus'], + model=self._chat_model, + controller=chat_controller, + settings=config.Settings, + lobby_info=self.lobby_info, + chat_config=self._chat_config, + me=self.me, + ) + chat_greeter = ChatGreeter( + model=self._chat_model, + theme=util.THEME, + chat_config=self._chat_config, + line_metadata_builder=line_metadata_builder, + ) + chat_restorer = ChatLineRestorer(self._chat_model) + chat_announcer = ChatAnnouncer( + model=self._chat_model, + chat_config=self._chat_config, + game_announcer=self.game_announcer, + line_metadata_builder=line_metadata_builder, + ) + + self._chatMVC = ChatMVC( + self._chat_model, line_metadata_builder, + chat_connection, chat_controller, + channel_autojoiner, chat_greeter, + chat_restorer, chat_announcer, chat_view, + ) + + self.authorized.connect(self._connect_chat) + + self.logo = StatusLogo(self, self._chatMVC.model) + self.logo.disconnect_requested.connect(self.disconnect_) + self.logo.reconnect_requested.connect(self.reconnect) + self.logo.chat_reconnect_requested.connect(self.chat_reconnect) + self.logo.about_dialog_requested.connect(self.linkAbout) + self.logo.connectivity_dialog_requested.connect( + self.connectivityDialog, + ) + self.topLayout.insertWidget(0, self.logo) # build main window with the now active client self.news = NewsWidget(self) - self.chat = ChatWidget(self, self.players, self.me) - self.coop = CoopWidget(self, self.game_model, self.me, - self.gameview_builder, self.game_launcher) - self.games = GamesWidget(self, self.game_model, self.me, - self.gameview_builder, self.game_launcher) - self.tutorials = TutorialsWidget(self) + self.coop = CoopWidget( + self, self.game_model, self.me, + self.gameview_builder, self.game_launcher, + ) + self.games = GamesWidget( + self, self.game_model, self.me, + self.gameview_builder, self.game_launcher, + ) self.ladder = StatsWidget(self) - self.tourneys = TournamentsWidget(self) - self.replays = ReplaysWidget(self, self.lobby_dispatch, - self.gameset, self.players) + self.replays = ReplaysWidget( + self, self.lobby_dispatch, self.gameset, self.players, + ) self.mapvault = MapVault(self) self.modvault = ModVault(self) - self.notificationSystem = ns.Notifications(self, self.gameset, - self.players, self.me) + self.notificationSystem = ns.Notifications( + self, self.gameset, self.players, self.me, + ) - # TODO: some day when the tabs only do UI we'll have all this in the .ui file + self._unitdb = UnitDBTab() + + # TODO: some day when the tabs only do UI we'll have all this in the + # .ui file self.whatNewTab.layout().addWidget(self.news) - self.chatTab.layout().addWidget(self.chat) + self.chatTab.layout().addWidget(self._chatMVC.view.widget.base) self.coopTab.layout().addWidget(self.coop) self.gamesTab.layout().addWidget(self.games) - self.tutorialsTab.layout().addWidget(self.tutorials) self.ladderTab.layout().addWidget(self.ladder) - self.tourneyTab.layout().addWidget(self.tourneys) self.replaysTab.layout().addWidget(self.replays) - self.mapsTab.layout().addWidget(self.mapvault.ui) + self.mapsTab.layout().addWidget(self.mapvault) + self.unitdbTab.layout().addWidget(self._unitdb.db_widget) self.modsTab.layout().addWidget(self.modvault) - # set menu states - self.actionNsEnabled.setChecked(self.notificationSystem.settings.enabled) - # Other windows - self.avatarAdmin = self.avatarSelection = AvatarWidget(self, None) + # TODO: hiding some non-functional tabs. Either prune them or implement + # something useful in them. + self.mainTabs.removeTab(self.mainTabs.indexOf(self.tutorialsTab)) + self.mainTabs.removeTab(self.mainTabs.indexOf(self.tourneyTab)) - # units database (ex. live streams) - # old unitDB - self.unitdbWebView.setUrl(QtCore.QUrl(config.Settings.get("UNITDB_URL"))) + self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.whatNewTab)) + + # set menu states + self.actionNsEnabled.setChecked( + self.notificationSystem.settings.enabled, + ) # warning setup + self.labelAutomatchInfo.hide() self.warning = QtWidgets.QHBoxLayout() self.warnPlayer = QtWidgets.QLabel(self) self.warnPlayer.setText( - "A player of your skill level is currently searching for a 1v1 game. Click a faction to join them! ") - self.warnPlayer.setAlignment(QtCore.Qt.AlignHCenter) - self.warnPlayer.setAlignment(QtCore.Qt.AlignVCenter) + "A player of your skill level is currently searching for a 1v1 " + "game. Click a faction to join them! ", + ) + self.warnPlayer.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter) + self.warnPlayer.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter) self.warnPlayer.setProperty("warning", True) self.warning.addStretch() self.warning.addWidget(self.warnPlayer) @@ -579,21 +846,36 @@ def setup(self): def add_warning_button(faction): button = QtWidgets.QToolButton(self) button.setMaximumSize(25, 25) - button.setIcon(util.THEME.icon("games/automatch/%s.png" % faction.to_name())) - button.clicked.connect(partial(self.games.startSearchRanked, faction)) + button.setIcon( + util.THEME.icon( + "games/automatch/{}.png".format(faction.to_name()), + ), + ) + button.clicked.connect(partial(self.ladderWarningClicked, faction)) self.warning.addWidget(button) return button - self.warning_buttons = {faction: add_warning_button(faction) for faction in Factions} + self.warning_buttons = { + faction: add_warning_button(faction) + for faction in Factions + } self.warning.addStretch() self.mainGridLayout.addLayout(self.warning, 2, 0) self.warningHide() - self._update_checker = UpdateChecker(self) - self._update_checker.finished.connect(self.update_checked) - self._update_checker.start() + self._update_tools = ClientUpdateTools.build( + config.VERSION, self, self._network_access_manager, + ) + self._update_tools.mandatory_update_aborted.connect(self.close) + self._update_tools.checker.check() + + def _connect_chat(self, me): + if not self.use_chat: + return + self._chatMVC.connection.set_nick_and_username(me.login, f"{me.login}@FAF") + self._chatMVC.connection.begin_connection_process() def warningHide(self): """ @@ -612,26 +894,22 @@ def warningShow(self): i.show() def reconnect(self): - self._update_checker.start() - - self.lobby_reconnecter.enabled = True - self.lobby_connection.doConnect() - - def disconnect(self): - # Used when the user explicitly demanded to stay offline. - self.lobby_reconnecter.enabled = False - self.lobby_connection.disconnect() - self.chat.disconnect() - - @QtCore.pyqtSlot(list) - def update_checked(self, releases): - if len(releases) > 0: - update_dialog = UpdateDialog(self) - update_dialog.setup(releases) - update_dialog.show() - else: - QtWidgets.QMessageBox.information(self,"No updates found", - "No client updates were found") + self.lobby_reconnector.enabled = True + self.try_to_auto_login() + + def disconnect_(self): + if self.state != ClientState.DISCONNECTED: + # Used when the user explicitly demanded to stay offline. + self._auto_relogin = self.remember + self.lobby_reconnector.enabled = False + self.lobby_connection.disconnect_() + self._chatMVC.connection.disconnect_() + self.games.onLogOut() + self.oauth_flow.stop_checking_expiration() + config.Settings.set("oauth/token", None, persist=False) + + def chat_reconnect(self): + self._connect_chat(self.me) @QtCore.pyqtSlot() def cleanup(self): @@ -656,15 +934,19 @@ def cleanup(self): fa.instance.close() # Terminate Lobby Server connection - self.lobby_reconnecter.enabled = False + self.lobby_reconnector.enabled = False if self.lobby_connection.socket_connected(): progress.setLabelText("Closing main connection.") - self.lobby_connection.disconnect() + self.lobby_connection.disconnect_() - # Clear UPnP Mappings... - if self.useUPnP: - progress.setLabelText("Removing UPnP port mappings") - fa.upnp.removePortMappings() + # Close connectivity dialog + if self.connectivity_dialog is not None: + self.connectivity_dialog.close() + self.connectivity_dialog = None + # Close game session (and stop faf-ice-adapter.exe) + if self.game_session is not None: + self.game_session.closeIceAdapter() + self.game_session = None # Terminate local ReplayServer if self.replayServer: @@ -673,10 +955,16 @@ def cleanup(self): self.replayServer = None # Clean up Chat - if self.chat: + if self._chatMVC: progress.setLabelText("Disconnecting from IRC") - self.chat.disconnect() - self.chat = None + self._chatMVC.connection.disconnect_() + self._chatMVC = None + + # Clear cached game files if needed + util.clearGameCache() + + # Get rid of generated maps + util.clearGeneratedMaps() # Get rid of the Tray icon if self.tray: @@ -684,6 +972,9 @@ def cleanup(self): self.tray.deleteLater() self.tray = None + # Clear qt message handler to avoid crash at exit + config.clear_logging_handlers() + # Terminate UI if self.isVisible(): progress.setLabelText("Closing main window") @@ -696,10 +987,17 @@ def closeEvent(self, event): self.saveWindow() if fa.instance.running(): - if QtWidgets.QMessageBox.question(self, "Are you sure?", "Seems like you still have Forged Alliance " - "running!
Close anyway?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.No: + result = QtWidgets.QMessageBox.question( + self, + "Are you sure?", + ( + "Seems like you still have Forged Alliance running!" + "
Close anyway?" + ), + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + if result == QtWidgets.QMessageBox.StandardButton.No: event.ignore() return @@ -708,103 +1006,278 @@ def closeEvent(self, event): def initMenus(self): self.actionCheck_for_Updates.triggered.connect(self.check_for_updates) self.actionUpdate_Settings.triggered.connect(self.show_update_settings) - self.actionLink_account_to_Steam.triggered.connect(partial(self.open_url, config.Settings.get("STEAMLINK_URL"))) - self.actionLinkWebsite.triggered.connect(partial(self.open_url, config.Settings.get("WEBSITE_URL"))) - self.actionLinkWiki.triggered.connect(partial(self.open_url, config.Settings.get("WIKI_URL"))) - self.actionLinkForums.triggered.connect(partial(self.open_url, config.Settings.get("FORUMS_URL"))) - self.actionLinkUnitDB.triggered.connect(partial(self.open_url, config.Settings.get("UNITDB_URL"))) - self.actionLinkMapPool.triggered.connect(partial(self.open_url, config.Settings.get("MAPPOOL_URL"))) - self.actionLinkGitHub.triggered.connect(partial(self.open_url, config.Settings.get("GITHUB_URL"))) - - self.actionNsSettings.triggered.connect(lambda: self.notificationSystem.on_showSettings()) - self.actionNsEnabled.triggered.connect(lambda enabled: self.notificationSystem.setNotificationEnabled(enabled)) - - self.actionWiki.triggered.connect(partial(self.open_url, config.Settings.get("WIKI_URL"))) - self.actionReportBug.triggered.connect(partial(self.open_url, config.Settings.get("TICKET_URL"))) + self.actionLink_account_to_Steam.triggered.connect( + partial(self.open_url, config.Settings.get("STEAMLINK_URL")), + ) + self.actionLinkWebsite.triggered.connect( + partial(self.open_url, config.Settings.get("WEBSITE_URL")), + ) + self.actionLinkWiki.triggered.connect( + partial(self.open_url, config.Settings.get("WIKI_URL")), + ) + self.actionLinkForums.triggered.connect( + partial(self.open_url, config.Settings.get("FORUMS_URL")), + ) + self.actionLinkUnitDB.triggered.connect( + partial(self.open_url, config.Settings.get("UNITDB_URL")), + ) + self.actionLinkMapPool.triggered.connect( + partial(self.open_url, config.Settings.get("MAPPOOL_URL")), + ) + self.actionLinkGitHub.triggered.connect( + partial(self.open_url, config.Settings.get("GITHUB_URL")), + ) + + self.actionNsSettings.triggered.connect( + lambda: self.notificationSystem.on_showSettings(), + ) + self.actionNsEnabled.triggered.connect( + lambda enabled: self.notificationSystem.setNotificationEnabled( + enabled, + ), + ) + + self.actionWiki.triggered.connect( + partial(self.open_url, config.Settings.get("WIKI_URL")), + ) + self.actionReportBug.triggered.connect( + partial(self.open_url, config.Settings.get("TICKET_URL")), + ) self.actionShowLogs.triggered.connect(self.linkShowLogs) - self.actionTechSupport.triggered.connect(partial(self.open_url, config.Settings.get("SUPPORT_URL"))) + self.actionTechSupport.triggered.connect( + partial(self.open_url, config.Settings.get("SUPPORT_URL")), + ) self.actionAbout.triggered.connect(self.linkAbout) self.actionClearCache.triggered.connect(self.clearCache) self.actionClearSettings.triggered.connect(self.clearSettings) self.actionClearGameFiles.triggered.connect(self.clearGameFiles) + self.actionClearMapGenerators.triggered.connect( + self.clearMapGenerators, + ) self.actionSetGamePath.triggered.connect(self.switchPath) - self.actionSetGamePort.triggered.connect(self.switchPort) - self.actionShowMapsDir.triggered.connect(lambda: util.showDirInFileBrowser(getUserMapsFolder())) - self.actionShowModsDir.triggered.connect(lambda: util.showDirInFileBrowser(MODFOLDER)) - self.actionShowReplaysDir.triggered.connect(lambda: util.showDirInFileBrowser(util.REPLAY_DIR)) - self.actionShowThemesDir.triggered.connect(lambda: util.showDirInFileBrowser(util.THEME_DIR)) - # if game.prefs doesn't exist: show_dir -> empty folder / show_file -> 'file doesn't exist' message - self.actionShowGamePrefs.triggered.connect(lambda: util.showDirInFileBrowser(util.LOCALFOLDER)) - #self.actionShowGamePrefs.triggered.connect(lambda: util.showFileInFileBrowser(util.PREFSFILENAME)) + self.actionShowMapsDir.triggered.connect( + lambda: util.showDirInFileBrowser(getUserMapsFolder()), + ) + self.actionShowModsDir.triggered.connect( + lambda: util.showDirInFileBrowser(getModFolder()), + ) + self.actionShowReplaysDir.triggered.connect( + lambda: util.showDirInFileBrowser(util.REPLAY_DIR), + ) + self.actionShowThemesDir.triggered.connect( + lambda: util.showDirInFileBrowser(util.THEME_DIR), + ) + self.actionShowGamePrefs.triggered.connect( + lambda: util.showDirInFileBrowser(util.LOCALFOLDER), + ) + self.actionShowClientConfigFile.triggered.connect(util.showConfigFile) # Toggle-Options - self.actionSetAutoLogin.triggered.connect(self.updateOptions) + self.actionSetAutoLogin.triggered.connect(self.update_options) self.actionSetAutoLogin.setChecked(self.remember) - self.actionSetAutoDownloadMods.toggled.connect(self.on_actionAutoDownloadMods_toggled) - self.actionSetAutoDownloadMods.setChecked(config.Settings.get('mods/autodownload', type=bool, default=False)) - self.actionSetAutoDownloadMaps.toggled.connect(self.on_actionAutoDownloadMaps_toggled) - self.actionSetAutoDownloadMaps.setChecked(config.Settings.get('maps/autodownload', type=bool, default=False)) - self.actionSetSoundEffects.triggered.connect(self.updateOptions) - self.actionSetOpenGames.triggered.connect(self.updateOptions) - self.actionSetJoinsParts.triggered.connect(self.updateOptions) - self.actionSetNewbiesChannel.triggered.connect(self.updateOptions) - self.actionSetAutoJoinChannels.triggered.connect(self.show_autojoin_settings_dialog) - self.actionSetLiveReplays.triggered.connect(self.updateOptions) - self.actionSetChatMaps.triggered.connect(self.toggleChatMaps) - self.actionSaveGamelogs.toggled.connect(self.on_actionSavegamelogs_toggled) - self.actionSaveGamelogs.setChecked(self.gamelogs) - self.actionColoredNicknames.triggered.connect(self.updateOptions) - self.actionFriendsOnTop.triggered.connect(self.updateOptions) - - self.actionCheckPlayerAliases.triggered.connect(self.checkPlayerAliases) + self.actionSetAutoDownloadMods.toggled.connect( + self.on_action_auto_download_mods_toggled, + ) + self.actionSetAutoDownloadMods.setChecked( + config.Settings.get('mods/autodownload', type=bool, default=False), + ) + self.actionSetAutoDownloadMaps.toggled.connect( + self.on_action_auto_download_maps_toggled, + ) + self.actionSetAutoDownloadMaps.setChecked( + config.Settings.get('maps/autodownload', type=bool, default=False), + ) + self.actionSetAutoGenerateMaps.toggled.connect( + self.on_action_auto_generate_maps_toggled, + ) + self.actionSetAutoGenerateMaps.setChecked( + config.Settings.get( + 'mapGenerator/autostart', + type=bool, + default=False, + ), + ) + self.actionSetSoundEffects.triggered.connect(self.update_options) + self.actionSetOpenGames.triggered.connect(self.update_options) + self.actionSetJoinsParts.triggered.connect(self.update_options) + self.actionSetNewbiesChannel.triggered.connect(self.update_options) + self.actionIgnoreFoes.triggered.connect(self.update_options) + self.actionSetLiveReplays.triggered.connect(self.update_options) + self.actionSaveGamelogs.setChecked(self.game_logs) + self.actionColoredNicknames.triggered.connect(self.update_options) + self.actionFriendsOnTop.triggered.connect(self.update_options) + self.actionSetAutoJoinChannels.triggered.connect( + self.show_autojoin_settings_dialog, + ) + self.actionSaveGamelogs.toggled.connect( + self.on_action_save_game_logs_toggled, + ) + self.actionVaultFallback.toggled.connect( + self.on_action_fault_fallback_toggled, + ) + self.actionVaultFallback.setChecked( + config.Settings.get('vault/fallback', type=bool, default=False), + ) + self.actionLanguageChannels.triggered.connect( + self._language_channel_config.run, + ) + + self.actionEnableIceAdapterInfoWindow.triggered.connect( + self.on_action_enable_ice_adapter_info_window, + ) + self.actionEnableIceAdapterInfoWindow.setChecked( + config.Settings.get( + 'iceadapter/info_window', + type=bool, + default=False, + ), + ) + self.actionSetIceAdapterWindowLaunchDelay.triggered.connect( + self.set_ice_adapter_window_launch_delay, + ) + + self.actionDoNotKeep.setChecked( + config.Settings.get('cache/do_not_keep', type=bool, default=True), + ) + self.actionForever.setChecked( + config.Settings.get('cache/forever', type=bool, default=False), + ) + self.actionSetYourOwnTimeInterval.setChecked( + config.Settings.get( + 'cache/own_settings', type=bool, default=False, + ), + ) + self.actionKeepCacheWhileInSession.setChecked( + config.Settings.get('cache/in_session', type=bool, default=False), + ) + self.actionKeepCacheWhileInSession.setVisible( + config.Settings.get('cache/do_not_keep', type=bool, default=True), + ) + self.actionDoNotKeep.triggered.connect(self.saveCacheSettings) + self.actionForever.triggered.connect( + lambda: self.saveCacheSettings(own=False, forever=True), + ) + self.actionSetYourOwnTimeInterval.triggered.connect( + lambda: self.saveCacheSettings(own=True, forever=False), + ) + self.actionKeepCacheWhileInSession.toggled.connect(self.inSessionCache) + + self.actionCheckPlayerAliases.triggered.connect( + self.checkPlayerAliases, + ) self._menuThemeHandler = ThemeMenu(self.menuTheme) self._menuThemeHandler.setup(util.THEME.listThemes()) - self._menuThemeHandler.themeSelected.connect(lambda theme: util.THEME.setTheme(theme, True)) + self._menuThemeHandler.themeSelected.connect( + lambda theme: util.THEME.setTheme(theme, True), + ) + + self._chat_vis_actions = { + ChatterLayoutElements.RANK: self.actionHideChatterRank, + ChatterLayoutElements.AVATAR: self.actionHideChatterAvatar, + ChatterLayoutElements.COUNTRY: self.actionHideChatterCountry, + ChatterLayoutElements.NICK: self.actionHideChatterNick, + ChatterLayoutElements.STATUS: self.actionHideChatterStatus, + ChatterLayoutElements.MAP: self.actionHideChatterMap, + } + for action in self._chat_vis_actions.values(): + action.triggered.connect(self.update_options) @QtCore.pyqtSlot() - def updateOptions(self): + def update_options(self): + chat_config = self._chat_config + self.remember = self.actionSetAutoLogin.isChecked() - self.soundeffects = self.actionSetSoundEffects.isChecked() - self.game_announcer.announce_games = self.actionSetOpenGames.isChecked() - self.joinsparts = self.actionSetJoinsParts.isChecked() - self.useNewbiesChannel = self.actionSetNewbiesChannel.isChecked() - self.chatmaps = self.actionSetChatMaps.isChecked() - self.game_announcer.announce_replays = self.actionSetLiveReplays.isChecked() - - self.gamelogs = self.actionSaveGamelogs.isChecked() - self.player_colors.coloredNicknames = self.actionColoredNicknames.isChecked() - if self.friendsontop != self.actionFriendsOnTop.isChecked(): - self.friendsontop = self.actionFriendsOnTop.isChecked() - self.chat.sort_channels() + if self.remember and self.refresh_token: + config.Settings.set('user/refreshToken', self.refresh_token) + chat_config.soundeffects = self.actionSetSoundEffects.isChecked() + chat_config.joinsparts = self.actionSetJoinsParts.isChecked() + chat_config.newbies_channel = self.actionSetNewbiesChannel.isChecked() + chat_config.ignore_foes = self.actionIgnoreFoes.isChecked() + chat_config.friendsontop = self.actionFriendsOnTop.isChecked() + + invisible_items = [ + i for i, a in self._chat_vis_actions.items() if a.isChecked() + ] + chat_config.hide_chatter_items.clear() + chat_config.hide_chatter_items |= invisible_items + + announce_games = self.actionSetOpenGames.isChecked() + self.game_announcer.announce_games = announce_games + announce_replays = self.actionSetLiveReplays.isChecked() + self.game_announcer.announce_replays = announce_replays + + self.game_logs = self.actionSaveGamelogs.isChecked() + colored_nicknames = self.actionColoredNicknames.isChecked() + self.player_colors.colored_nicknames = colored_nicknames self.saveChat() - def toggleChatMaps(self): - self.updateOptions() - self.chat.update_channels() + @QtCore.pyqtSlot(bool) + def on_action_save_game_logs_toggled(self, value): + self.game_logs = value + + @QtCore.pyqtSlot(bool) + def on_action_auto_download_mods_toggled(self, value): + config.Settings.set('mods/autodownload', value is True) + + @QtCore.pyqtSlot(bool) + def on_action_auto_download_maps_toggled(self, value): + config.Settings.set('maps/autodownload', value is True) + + @QtCore.pyqtSlot(bool) + def on_action_auto_generate_maps_toggled(self, value): + config.Settings.set('mapGenerator/autostart', value is True) + + @QtCore.pyqtSlot(bool) + def on_action_fault_fallback_toggled(self, value): + config.Settings.set('vault/fallback', value is True) + util.setPersonalDir() + setModFolder() + + @QtCore.pyqtSlot(bool) + def on_action_enable_ice_adapter_info_window(self, value): + config.Settings.set('iceadapter/info_window', value is True) @QtCore.pyqtSlot() - def switchPath(self): - fa.wizards.Wizard(self).exec_() + def set_ice_adapter_window_launch_delay(self): + seconds, ok = QtWidgets.QInputDialog().getInt( + self, + 'Set time interval', + 'Delay the launch of the info window by seconds:', + config.Settings.get( + 'iceadapter/delay_ui_seconds', type=int, default=10, + ), + min=0, + max=2147483647, + step=1, + ) + if ok and seconds: + config.Settings.set('iceadapter/delay_ui_seconds', seconds) @QtCore.pyqtSlot() - def switchPort(self): - from . import loginwizards - loginwizards.gameSettingsWizard(self).exec_() + def switchPath(self): + fa.wizards.Wizard(self).exec() @QtCore.pyqtSlot() def clearSettings(self): - result = QtWidgets.QMessageBox.question(None, "Clear Settings", "Are you sure you wish to clear all settings, " - "login info, etc. used by this program?", - QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No) - if result == QtWidgets.QMessageBox.Yes: + result = QtWidgets.QMessageBox.question( + self, + "Clear Settings", + "Are you sure you wish to clear all settings, " + "login info, etc. used by this program?", + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + if result == QtWidgets.QMessageBox.StandardButton.Yes: util.settings.clear() util.settings.sync() - QtWidgets.QMessageBox.information(None, "Restart Needed", "FAF will quit now.") + QtWidgets.QMessageBox.information( + self, "Restart Needed", "FAF will quit now.", + ) QtWidgets.QApplication.quit() @QtCore.pyqtSlot() @@ -816,9 +1289,15 @@ def clearGameFiles(self): def clearCache(self): changed = util.clearDirectory(util.CACHE_DIR) if changed: - QtWidgets.QMessageBox.information(None, "Restart Needed", "FAF will quit now.") + QtWidgets.QMessageBox.information( + self, "Restart Needed", "FAF will quit now.", + ) QtWidgets.QApplication.quit() + @QtCore.pyqtSlot() + def clearMapGenerators(self): + util.clearDirectory(util.MAPGEN_DIR) + # Clear the online users lists def clear_players(self): self.players.clear() @@ -833,194 +1312,325 @@ def linkShowLogs(self): @QtCore.pyqtSlot() def connectivityDialog(self): - dialog = connectivity.ConnectivityDialog(self.connectivity) - dialog.exec_() + if ( + self.game_session is not None + and self.game_session.ice_adapter_client is not None + ): + self.connectivity_dialog = ConnectivityDialog( + self.game_session.ice_adapter_client, + ) + self.connectivity_dialog.show() + else: + QtWidgets.QMessageBox().information( + self, + "No game", + "The connectivity window is only available during the game.", + ) @QtCore.pyqtSlot() def linkAbout(self): dialog = util.THEME.loadUi("client/about.ui") dialog.version_label.setText("Version: {}".format(util.VERSION_STRING)) - dialog.exec_() + dialog.exec() @QtCore.pyqtSlot() def check_for_updates(self): - self._update_checker.respect_notify = False - self._update_checker.start(reset_server=False) + self._update_tools.checker.check(always_notify=True) @QtCore.pyqtSlot() def show_update_settings(self): - dialog = UpdateSettingsDialog(self) - dialog.setup() + dialog = self._update_tools.settings_dialog() dialog.show() def checkPlayerAliases(self): - self._alias_window.run() + self._alias_search_window.run() def saveWindow(self): util.settings.beginGroup("window") util.settings.setValue("geometry", self.saveGeometry()) + util.settings.setValue("maximized", self.is_window_maximized) util.settings.endGroup() def show_autojoin_settings_dialog(self): - autojoin_channels_list = config.Settings.get('chat/auto_join_channels', []) + autojoin_channels_list = config.Settings.get( + 'chat/auto_join_channels', + default=[], + ) text_of_autojoin_settings_dialog = """ - Enter the list of channels you want to autojoin at startup, separated by ; - For example: #poker;#newbie - To disable autojoining channels, leave the box empty and press OK. + Enter the list of channels you want to autojoin at startup, separated + by ; For example: #poker;#newbie To disable autojoining channels, + leave the box empty and press OK. """ - channels_input_of_user, ok = QtWidgets.QInputDialog.getText(self, 'Set autojoin channels', - text_of_autojoin_settings_dialog, QtWidgets.QLineEdit.Normal, ';'.join(autojoin_channels_list)) + channels_input_of_user, ok = QtWidgets.QInputDialog.getText( + self, + 'Set autojoin channels', + text_of_autojoin_settings_dialog, + QtWidgets.QLineEdit.Normal, + ';'.join(autojoin_channels_list), + ) if ok: - config.Settings.set('chat/auto_join_channels', list(map(str.strip, channels_input_of_user.split(';')))) + channels = [ + c.strip() + for c in channels_input_of_user.split(';') + if c + ] + config.Settings.set('chat/auto_join_channels', channels) + + @QtCore.pyqtSlot(bool) + def inSessionCache(self, value): + config.Settings.set('cache/in_session', value is True) + + @QtCore.pyqtSlot() + def saveCacheSettings(self, own=False, forever=False): + if forever: + util.settings.beginGroup('cache') + util.settings.setValue('do_not_keep', False) + util.settings.setValue('forever', True) + util.settings.setValue('own_settings', False) + util.settings.setValue('number_of_days', -1) + util.settings.endGroup() + self.actionKeepCacheWhileInSession.setChecked(False) + elif own: + days, ok = QtWidgets.QInputDialog().getInt( + self, + 'Set time interval', + 'Keep game files in cache for this number of days:', + config.Settings.get( + 'cache/number_of_days', type=int, default=30, + ), + min=1, + max=2147483647, + step=10, + ) + if ok and days: + util.settings.beginGroup('cache') + util.settings.setValue('do_not_keep', False) + util.settings.setValue('forever', False) + util.settings.setValue('own_settings', True) + util.settings.setValue('number_of_days', days) + util.settings.endGroup() + self.actionKeepCacheWhileInSession.setChecked(False) + else: + util.settings.beginGroup('cache') + util.settings.setValue('do_not_keep', True) + util.settings.setValue('forever', False) + util.settings.setValue('own_settings', False) + util.settings.setValue('number_of_days', 0) + util.settings.endGroup() + self.actionDoNotKeep.setChecked( + config.Settings.get('cache/do_not_keep', type=bool, default=True), + ) + self.actionForever.setChecked( + config.Settings.get('cache/forever', type=bool, default=False), + ) + self.actionSetYourOwnTimeInterval.setChecked( + config.Settings.get( + 'cache/own_settings', type=bool, default=False, + ), + ) + self.actionKeepCacheWhileInSession.setVisible( + config.Settings.get('cache/do_not_keep', type=bool, default=True), + ) def saveChat(self): util.settings.beginGroup("chat") - util.settings.setValue("soundeffects", self.soundeffects) - util.settings.setValue("livereplays", self.game_announcer.announce_replays) + util.settings.setValue( + "livereplays", self.game_announcer.announce_replays, + ) util.settings.setValue("opengames", self.game_announcer.announce_games) - util.settings.setValue("joinsparts", self.joinsparts) - util.settings.setValue("newbiesChannel", self.useNewbiesChannel) - util.settings.setValue("chatmaps", self.chatmaps) - util.settings.setValue("coloredNicknames", self.player_colors.coloredNicknames) - util.settings.setValue("friendsontop", self.friendsontop) + util.settings.setValue( + "coloredNicknames", self.player_colors.colored_nicknames, + ) util.settings.endGroup() + self._chat_config.save_settings() - def loadSettings(self): - self.loadChat() + def load_settings(self): + self.load_chat() # Load settings util.settings.beginGroup("window") geometry = util.settings.value("geometry", None) - if geometry: - self.restoreGeometry(geometry) - util.settings.endGroup() - - util.settings.beginGroup("ForgedAlliance") + # FIXME: looks like bug in Qt: restoring from maximized geometry doesn't work + # see https://bugreports.qt.io/browse/QTBUG-123335 (?) + maximized = util.settings.value("maximized", defaultValue=False, type=bool) util.settings.endGroup() + if maximized: + self.setGeometry(self.screen().availableGeometry()) + elif geometry: + self.restoreGeometry(geometry) - def loadChat(self): + def load_chat(self): + cc = self._chat_config try: util.settings.beginGroup("chat") - self.soundeffects = (util.settings.value("soundeffects", "true") == "true") - self.game_announcer.announce_games = (util.settings.value("opengames", "true") == "true") - self.joinsparts = (util.settings.value("joinsparts", "false") == "true") - self.chatmaps = (util.settings.value("chatmaps", "false") == "true") - self.game_announcer.announce_replays = (util.settings.value("livereplays", "true") == "true") - self.player_colors.coloredNicknames = (util.settings.value("coloredNicknames", "false") == "true") - self.friendsontop = (util.settings.value("friendsontop", "false") == "true") - self.useNewbiesChannel = (util.settings.value("newbiesChannel","true") == "true") - + self.game_announcer.announce_games = ( + util.settings.value("opengames", "true") == "true" + ) + self.game_announcer.announce_replays = ( + util.settings.value("livereplays", "true") == "true" + ) + self.player_colors.colored_nicknames = ( + util.settings.value("coloredNicknames", "false") == "true" + ) util.settings.endGroup() - self.actionColoredNicknames.setChecked(self.player_colors.coloredNicknames) - self.actionFriendsOnTop.setChecked(self.friendsontop) - self.actionSetSoundEffects.setChecked(self.soundeffects) - self.actionSetLiveReplays.setChecked(self.game_announcer.announce_replays) - self.actionSetOpenGames.setChecked(self.game_announcer.announce_games) - self.actionSetJoinsParts.setChecked(self.joinsparts) - self.actionSetChatMaps.setChecked(self.chatmaps) - self.actionSetNewbiesChannel.setChecked(self.useNewbiesChannel) - except: + cc.load_settings() + self.actionColoredNicknames.setChecked( + self.player_colors.colored_nicknames, + ) + self.actionFriendsOnTop.setChecked(cc.friendsontop) + + for item in ChatterLayoutElements: + self._chat_vis_actions[item].setChecked( + item in cc.hide_chatter_items, + ) + self.actionSetSoundEffects.setChecked(cc.soundeffects) + self.actionSetLiveReplays.setChecked( + self.game_announcer.announce_replays, + ) + self.actionSetOpenGames.setChecked( + self.game_announcer.announce_games, + ) + self.actionSetJoinsParts.setChecked(cc.joinsparts) + self.actionSetNewbiesChannel.setChecked(cc.newbies_channel) + self.actionIgnoreFoes.setChecked(cc.ignore_foes) + except BaseException: pass - def doConnect(self): - if not self.replayServer.doListen(LOCAL_REPLAY_PORT): + def save_refresh_token(self) -> None: + self.refresh_token = self.oauth_flow.refreshToken() + + def do_connect(self) -> bool: + if self.state in (ClientState.CONNECTING, ClientState.CONNECTED, ClientState.LOGGED_IN): + return True + + if not self.replayServer.doListen(): return False - self.lobby_connection.doConnect() + self.lobby_connection.do_connect() return True def set_remember(self, remember): self.remember = remember - self.actionSetAutoLogin.setChecked(self.remember) # FIXME - option updating is silly + # FIXME - option updating is silly + self.actionSetAutoLogin.setChecked(self.remember) - def get_creds_and_login(self): - # Try to autologin, or show login widget if we fail or can't do that. - if self._autorelogin and self.password and self.login: - if self.send_login(self.login, self.password): - return + def try_to_auto_login(self) -> None: + if ( + self._auto_relogin + and self.refresh_token + ): + self.oauth_flow.setRefreshToken(self.refresh_token) + self.oauth_flow.refreshAccessToken() + else: + self.show_login_widget() + def get_creds_and_login(self) -> None: + if self.send_token(self.oauth_flow.token()): + return + QtWidgets.QMessageBox.warning( + self, "Log In", "OAuth token verification failed, please relogin", + ) self.show_login_widget() def show_login_widget(self): - login_widget = LoginWidget(self.login, self.remember) + login_widget = LoginWidget(self.remember) login_widget.finished.connect(self.on_widget_login_data) login_widget.rejected.connect(self.on_widget_no_login) - login_widget.request_quit.connect(self.on_login_widget_quit) + login_widget.request_quit.connect( + self.on_login_widget_quit, QtCore.Qt.ConnectionType.QueuedConnection, + ) login_widget.remember.connect(self.set_remember) - login_widget.exec_() + login_widget.exec() - def on_widget_login_data(self, login, password): - self.login = login - self.password = password + def on_widget_login_data(self, api_changed): + self.lobby_connection.setHostFromConfig() + self.lobby_connection.setPortFromConfig() + self._chatMVC.connection.setHostFromConfig() + self._chatMVC.connection.setPortFromConfig() + if api_changed: + self.ladder.refreshLeaderboards() + self.games.refreshMods() - if self.send_login(login, password): - return - self.show_login_widget() + self.oauth_flow.setup_credentials() + self.oauth_flow.grant() def on_widget_no_login(self): - self.disconnect() + self.state = ClientState.DISCONNECTED def on_login_widget_quit(self): QtWidgets.QApplication.quit() - def send_login(self, login, password): - # Send login data once we have the creds. - self._autorelogin = False # Fresh credentials - if config.is_beta(): # Replace for develop here to not clobber the real pass - password = util.password_hash("foo") - self.uniqueId = util.uniqueID(self.login, self.session) - if not self.uniqueId: - QtWidgets.QMessageBox.critical(self, - "Failed to calculate UID", - "Failed to calculate your unique ID" - " (a part of our smurf prevention system).\n" - "Please report this to the tech support forum!") + def send_token(self, token): + # Send data once we have the creds. + self._autorelogin = False # Fresh credentials + self.unique_id = util.uniqueID(self.session) + if not self.unique_id: + QtWidgets.QMessageBox.critical( + self, + "Failed to calculate UID", + "Failed to calculate your unique ID" + " (a part of our smurf prevention system).\n" + "It is very likely this happens due to your antivirus software" + " deleting the faf-uid.exe file. If this has happened, please " + "add an exception and restore the file. The file " + "can also be restored by installing the client again.", + ) return False - self.lobby_connection.send(dict(command="hello", - login=login, - password=password, - unique_id=self.uniqueId, - session=self.session)) + self.lobby_connection.send( + dict( + command="auth", + token=token, + unique_id=self.unique_id, + session=self.session, + ), + ) return True @QtCore.pyqtSlot() - def startedFA(self): + def started_fa(self): """ Slot hooked up to fa.instance when the process has launched. It will notify other modules through the signal gameEnter(). """ logger.info("FA has launched in an attached process.") - self.gameEnter.emit() + self.game_enter.emit() @QtCore.pyqtSlot(int) - def finishedFA(self, exit_code): + def finished_fa(self, exit_code): """ Slot hooked up to fa.instance when the process has ended. It will notify other modules through the signal gameExit(). """ if not exit_code: - logger.info("FA has finished with exit code: " + str(exit_code)) + logger.info("FA has finished with exit code: {}".format(exit_code)) else: - logger.warning("FA has finished with exit code: " + str(exit_code)) - self.gameExit.emit() + logger.warning( + "FA has finished with exit code: {}".format(exit_code), + ) + self.game_exit.emit() @QtCore.pyqtSlot(QtCore.QProcess.ProcessError) - def errorFA(self, error_code): + def error_fa(self, error_code): """ Slot hooked up to fa.instance when the process has failed to start. """ logger.error("FA has died with error: " + fa.instance.errorString()) if error_code == 0: logger.error("FA has failed to start") - QtWidgets.QMessageBox.critical(self, "Error from FA", "FA has failed to start.") + QtWidgets.QMessageBox.critical( + self, "Error from FA", "FA has failed to start.", + ) elif error_code == 1: logger.error("FA has crashed or killed after starting") else: - text = "FA has failed to start with error code: " + str(error_code) + text = ( + "FA has failed to start with error code: {}" + .format(error_code) + ) logger.error(text) QtWidgets.QMessageBox.critical(self, "Error from FA", text) - self.gameExit.emit() + self.game_exit.emit() - def _tabChanged(self, tab, curr, prev): + def tab_changed(self, tab, curr, prev): """ The main visible tab (module) of the client's UI has changed. In this case, other modules may want to load some data or cease @@ -1037,158 +1647,101 @@ def _tabChanged(self, tab, curr, prev): tab = new_tab.layout().itemAt(0).widget() if isinstance(tab, BusyWidget): tab.busy_entered() + # FIXME - special concession for chat tab. In the future we should + # separate widgets from controlling classes, just like chat tab does - + # then we'll refactor this part. + if new_tab is self.chatTab: + self._chatMVC.view.entered() @QtCore.pyqtSlot(int) - def mainTabChanged(self, curr): - self._tabChanged(self.mainTabs, curr, self._main_tab) + def main_tab_changed(self, curr): + self.tab_changed(self.mainTabs, curr, self._main_tab) self._main_tab = curr @QtCore.pyqtSlot(int) - def vaultTabChanged(self, curr): - self._tabChanged(self.topTabs, curr, self._vault_tab) + def vault_tab_changed(self, curr): + self.tab_changed(self.topTabs, curr, self._vault_tab) self._vault_tab = curr - @QtCore.pyqtSlot() - def joinGameFromURL(self, url): - """ - Tries to join the game at the given URL - """ - logger.debug("joinGameFromURL: " + url.toString()) - if fa.instance.available(): - add_mods = [] - try: - modstr = QtCore.QUrlQuery(url).queryItemValue("mods") - add_mods = json.loads(modstr) # should be a list - except: - logger.info("Couldn't load urlquery value 'mods'") - if fa.check.game(self): - uid, mod, map = QtCore.QUrlQuery(url).queryItemValue('uid'), \ - QtCore.QUrlQuery(url).queryItemValue('mod'), \ - QtCore.QUrlQuery(url).queryItemValue('map') - if fa.check.check(mod, map, sim_mods=add_mods): - self.join_game(int(uid)) - - @QtCore.pyqtSlot() - def searchUserReplays(self, name): - self.replays.set_player(name) + def view_replays(self, name, leaderboardName=None): + self.replays.set_player(name, leaderboardName) self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.replaysTab)) - @QtCore.pyqtSlot() - def viewUserLeaderboards(self, user): - self.ladder.set_player(user) + def view_in_leaderboards(self, user): + self.ladder.setCurrentIndex( + self.ladder.indexOf(self.ladder.leaderboardsTab), + ) + self.ladder.leaderboards.widget(0).searchPlayerInLeaderboard(user) + self.ladder.leaderboards.widget(1).searchPlayerInLeaderboard(user) + self.ladder.leaderboards.widget(2).searchPlayerInLeaderboard(user) + self.ladder.leaderboards.setCurrentIndex(1) self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.ladderTab)) - @QtCore.pyqtSlot() - def forwardLocalBroadcast(self, source, message): - self.localBroadcast.emit(source, message) - def manage_power(self): """ update the interface accordingly to the power of the user """ - if self.power >= 1: - if self.modMenu is None: - self.modMenu = self.menu.addMenu("Administration") + if self.power_tools.power >= 1: + if self.mod_menu is None: + self.mod_menu = self.menu.addMenu("Administration") - actionAvatar = QtWidgets.QAction("Avatar manager", self.modMenu) - actionAvatar.triggered.connect(self.avatarManager) - self.modMenu.addAction(actionAvatar) + action_lobby_kick = QtWidgets.QAction( + "Close player's FAF Client...", self.mod_menu, + ) + action_lobby_kick.triggered.connect(self._on_lobby_kick_triggered) + self.mod_menu.addAction(action_lobby_kick) - self.modMenu.addSeparator() + action_close_fa = QtWidgets.QAction( + "Close Player's Game...", + self.mod_menu, + ) + action_close_fa.triggered.connect(self._close_game_dialog) + self.mod_menu.addAction(action_close_fa) - actionLobbyKick = QtWidgets.QAction("Close player's FAF Client...", self.modMenu) - actionLobbyKick.triggered.connect(lambda: self.closeLobby()) - self.modMenu.addAction(actionLobbyKick) + def _close_game_dialog(self): + self.power_tools.view.close_game_dialog.show() - actionCloseFA = QtWidgets.QAction("Close Player's Game...", self.modMenu) - actionCloseFA.triggered.connect(lambda: util.userNameAction(self, 'Player to close FA (do not typo!)', - lambda name: self.closeFA(name))) - self.modMenu.addAction(actionCloseFA) + # Needed so that we ignore the bool from the triggered() signal + def _on_lobby_kick_triggered(self): + self.power_tools.view.kick_dialog() - def requestAvatars(self, personal): - if personal: - self.lobby_connection.send(dict(command="avatar", action="list_avatar")) - else: - self.lobby_connection.send(dict(command="admin", action="requestavatars")) - - def joinChannel(self, username, channel): - """ Join users to a channel """ - self.lobby_connection.send(dict(command="admin", action="join_channel", - user_ids=[self.players.getID(username)], channel=channel)) - - def closeFA(self, username): - """ Close FA remotely """ - logger.info('closeFA for {}'.format(username)) - user_id = self.players.getID(username) - if user_id != -1: - self.lobby_connection.send(dict(command="admin", action="closeFA", user_id=user_id)) - - def closeLobby(self, username=""): - """ Close lobby remotely """ - logger.info('Opening kick dialog for {}'.format(username)) - kick_dialog = KickDialog(self) - kick_dialog.reset(username) - kick_dialog.show() - - def addFriend(self, friend_id): - if friend_id in self.players: - self.me.addFriend(int(friend_id)) - self.lobby_connection.send(dict(command="social_add", friend=friend_id)) - - def addFoe(self, foe_id): - if foe_id in self.players: - self.me.addFoe(int(foe_id)) - self.lobby_connection.send(dict(command="social_add", foe=foe_id)) - - def remFriend(self, friend_id): - if friend_id in self.players: - self.me.remFriend(int(friend_id)) - self.lobby_connection.send(dict(command="social_remove", friend=friend_id)) - - def remFoe(self, foe_id): - if foe_id in self.players: - self.me.remFoe(int(foe_id)) - self.lobby_connection.send(dict(command="social_remove", foe=foe_id)) + def close_fa(self, username): + self.power_tools.actions.close_fa(username) def handle_session(self, message): - self._update_checker.server_session() - self.session = str(message['session']) self.get_creds_and_login() - def handle_update(self, message): - # Remove geometry settings prior to updating - # could be incompatible with an updated client. - config.Settings.remove('window/geometry') - - logger.warning("Server says we need an update") - self._update_checker.server_update(message) - def handle_welcome(self, message): self.state = ClientState.LOGGED_IN - self._autorelogin = True - self.id = message["id"] - self.login = message["login"] + self._auto_relogin = True + self.id = message["me"]["id"] + self.login = message["me"]["login"] - self.me.onLogin(message["login"], message["id"]) - logger.debug("Login success") + self.me.onLogin(self.login, self.id) + logger.info("Login success") util.crash.CRASH_REPORT_USER = self.login - if self.useUPnP: - self.lobby_connection.set_upnp(self.gamePort) - - self.updateOptions() + self.update_options() self.authorized.emit(self.me) - # Run an initial connectivity test and initialize a gamesession object - # when done - self.connectivity = ConnectivityHelper(self, self.gamePort) - self.connectivity.connectivity_status_established.connect(self.initialize_game_session) - self.connectivity.start_test() + if self.game_session is None or self.game_session.game_uid is None: + self.game_session = GameSession( + player_id=self.id, + player_login=self.login, + ) + elif self.game_session.game_uid is not None: + self.lobby_connection.send({ + 'command': 'restore_game_session', + 'game_id': self.game_session.game_uid, + }) + + self.game_session.gameFullSignal.connect(self.emit_game_full) - def initialize_game_session(self): - self.game_session = fa.GameSession(self, self.connectivity) - self.game_session.gameFullSignal.connect(self.game_full) + def handle_irc_password(self, message: dict) -> None: + # DEPRECATED: this command is meaningless and can be removed at any time + # see https://github.com/FAForever/server/issues/977 + ... def handle_registration_response(self, message): if message["result"] == "SUCCESS": @@ -1196,98 +1749,136 @@ def handle_registration_response(self, message): self.handle_notice({"style": "notice", "text": message["error"]}) - def search_ranked(self, faction): - def request_launch(): - msg = { - 'command': 'game_matchmaking', - 'mod': 'ladder1v1', - 'state': 'start', - 'gameport': self.gamePort, - 'faction': faction - } - if self.connectivity.state == 'STUN': - msg['relay_address'] = self.connectivity.relay_address - self.lobby_connection.send(msg) - self.game_session.ready.disconnect(request_launch) - if self.game_session: - self.game_session.ready.connect(request_launch) - self.game_session.listen() - - def host_game(self, title, mod, visibility, mapname, password, is_rehost=False): - def request_launch(): - msg = { - 'command': 'game_host', - 'title': title, - 'mod': mod, - 'visibility': visibility, - 'mapname': mapname, - 'password': password, - 'is_rehost': is_rehost - } - if self.connectivity.state == 'STUN': - msg['relay_address'] = self.connectivity.relay_address - self.lobby_connection.send(msg) - self.game_session.ready.disconnect(request_launch) - if self.game_session: - self.game_session.game_password = password - self.game_session.ready.connect(request_launch) - self.game_session.listen() + def ladderWarningClicked(self, faction=Factions.RANDOM): + subFactions = [False] * 4 + if faction != Factions.RANDOM: + subFactions[faction.value - 1] = True + config.Settings.set( + "play/{}Factions".format(MatchmakerQueueType.LADDER.value), + subFactions, + ) + try: + self.games.matchmakerQueues.widget(0).subFactions = subFactions + self.games.matchmakerQueues.widget(0).setFactionIcons(subFactions) + self.games.matchmakerQueues.widget(0).startSearchRanked() + except BaseException: + QtWidgets.QMessageBox.information( + self, "Starting search failed", + "Something went wrong, please retry", + ) + + def search_ranked(self, queue_name): + msg = { + 'command': 'game_matchmaking', + 'queue_name': queue_name, + 'state': 'start', + } + self.lobby_connection.send(msg) + + def handle_match_found_message(self, message): + logger.info("Handling match_found via JSON {}".format(message)) + self.warningHide() + self.labelAutomatchInfo.setText("Match found! Pending game launch...") + self.labelAutomatchInfo.show() + self.games.handleMatchFound(message) + self.lobby_connection.send(dict(command="match_ready")) + + def handle_match_cancelled(self, message): + logger.info("Received match_cancelled via JSON {}".format(message)) + self.labelAutomatchInfo.setText("") + self.labelAutomatchInfo.hide() + self.games.handleMatchCancelled(message) + + def host_game( + self, + title, + mod, + visibility, + mapname, + password, + is_rehost=False, + ): + msg = { + 'command': 'game_host', + 'title': title, + 'mod': mod, + 'visibility': visibility, + 'mapname': mapname, + 'password': password, + 'is_rehost': is_rehost, + } + self.lobby_connection.send(msg) def join_game(self, uid, password=None): - def request_launch(): - msg = { - 'command': 'game_join', - 'uid': uid, - 'gameport': self.gamePort - } - if password: - msg['password'] = password - if self.connectivity.state == "STUN": - msg['relay_address'] = self.connectivity.relay_address - self.lobby_connection.send(msg) - self.game_session.ready.disconnect(request_launch) - if self.game_session: - self.game_session.game_password = password - self.game_session.ready.connect(request_launch) - self.game_session.listen() + msg = { + 'command': 'game_join', + 'uid': uid, + 'gameport': 0, + } + if password: + msg['password'] = password + self.lobby_connection.send(msg) def handle_game_launch(self, message): - if not self.game_session or not self.connectivity.is_ready: - logger.error("Not ready for game launch") + self.game_session.game_uid = message['uid'] + self.game_session.startIceAdapter() - logger.info("Handling game_launch via JSON " + str(message)) + logger.info("Handling game_launch via JSON {}".format(message)) silent = False # Do some special things depending of the reason of the game launch. - rank = False - # HACK: Ideally, this comes from the server, too. LATER: search_ranked message + # HACK: Ideally, this comes from the server, too. + # LATER: search_ranked message arguments = [] - if message["mod"] == "ladder1v1": - arguments.append('/' + Factions.to_name(self.games.race)) - # Player 1v1 rating + if self.games.matchFoundQueueName: + self.labelAutomatchInfo.setText("Launching the game...") + ratingType = message.get("rating_type", RatingType.GLOBAL.value) + factionSubset = config.Settings.get( + "play/{}Factions".format(self.games.matchFoundQueueName), + default=[False] * 4, + type=bool, + ) + faction = Factions.set_faction(factionSubset) + arguments.append('/' + Factions.to_name(faction)) + # Player rating arguments.append('/mean') - arguments.append(str(self.me.player.ladder_rating_mean)) + arguments.append( + str(self.me.player.rating_mean(ratingType)), + ) arguments.append('/deviation') - arguments.append(str(self.me.player.ladder_rating_deviation)) - arguments.append('/players 2') # Always 2 players in 1v1 ladder - arguments.append('/team 1') # Always FFA team + arguments.append( + str(self.me.player.rating_deviation(ratingType)), + ) + + arguments.append('/players') + arguments.append(str(message["expected_players"])) + arguments.append('/team') + arguments.append(str(message["team"])) + arguments.append('/startspot') + arguments.append(str(message["map_position"])) + if message.get("game_options"): + arguments.append('/gameoptions') + for key, value in message["game_options"].items(): + arguments.append('{}:{}'.format(key, value)) # Launch the auto lobby - self.game_session.init_mode = 1 - + self.game_session.setLobbyInitMode("auto") else: # Player global rating arguments.append('/mean') - arguments.append(str(self.me.player.rating_mean)) + arguments.append(str(self.me.player.global_rating_mean)) arguments.append('/deviation') - arguments.append(str(self.me.player.rating_deviation)) + arguments.append(str(self.me.player.global_rating_deviation)) if self.me.player.country is not None: arguments.append('/country ') arguments.append(self.me.player.country) # Launch the normal lobby - self.game_session.init_mode = 0 + self.game_session.setLobbyInitMode("normal") + + arguments.append('/numgames') + arguments.append(str(message["args"][1])) if self.me.player.clan is not None: arguments.append('/clan') @@ -1300,18 +1891,21 @@ def handle_game_launch(self, message): if "sim_mods" in message: fa.mods.checkMods(message['sim_mods']) - # UPnP Mapper - mappings are removed on app exit - if self.useUPnP: - self.lobby_connection.set_upnp(self.gamePort) - - info = dict(uid=message['uid'], recorder=self.login, featured_mod=message['mod'], launched_at=time.time()) + info = dict( + uid=message['uid'], + recorder=self.login, + featured_mod=message['mod'], + launched_at=time.time(), + ) - self.game_session.game_uid = message['uid'] - - fa.run(info, self.game_session.relay_port, arguments, self.game_session.game_uid) + fa.run( + info, self.game_session.relay_port, self.replayServer.serverPort(), + arguments, self.game_session.game_uid, + ) def fill_in_session_info(self, game): - # sometimes we get the game_info message before a game session was created + # sometimes we get the game_info message before a game session was + # created if self.game_session and game.uid == self.game_session.game_uid: self.game_session.game_map = game.mapname self.game_session.game_mod = game.featured_mod @@ -1319,43 +1913,44 @@ def fill_in_session_info(self, game): self.game_session.game_visibility = game.visibility.value def handle_matchmaker_info(self, message): + logger.debug( + "Handling matchmaker info with message {}".format(message), + ) if not self.me.player: return - if "action" in message: - self.matchmakerInfo.emit(message) - elif "queues" in message: - if self.me.player.ladder_rating_deviation > 200 or self.games.searching: - return - key = 'boundary_80s' if self.me.player.ladder_rating_deviation < 100 else 'boundary_75s' - show = False + self.matchmaker_info.emit(message) + if "queues" in message: + show = None for q in message['queues']: if q['queue_name'] == 'ladder1v1': + show = False mu = self.me.player.ladder_rating_mean + if self.me.player.ladder_rating_deviation < 100: + key = 'boundary_80s' + else: + key = 'boundary_75s' for min, max in q[key]: if min < mu < max: show = True - if show: - self.warningShow() - else: - self.warningHide() + if ( + self.me.player.ladder_rating_deviation > 200 + or self.games.searching.get("ladder1v1", False) + ): + return + if show is not None: + if show and not self.games.matchFoundQueueName: + self.warningShow() + else: + self.warningHide() def handle_social(self, message): - if "friends" in message: - self.me.setFriends(set([int(u) for u in message["friends"]])) - - if "foes" in message: - self.me.setFoes(set([int(u) for u in message["foes"]])) - if "channels" in message: # Add a delay to the notification system (insane cargo cult) self.notificationSystem.disabledStartup = False - self.channelsUpdated.emit(message["channels"]) - - if "autojoin" in message: - self.autoJoin.emit(message["autojoin"]) + self.channels_updated.emit(message["channels"]) if "power" in message: - self.power = message["power"] + self.power_tools.power = message["power"] self.manage_power() def handle_player_info(self, message): @@ -1368,32 +1963,50 @@ def handle_player_info(self, message): for player in players: id_ = int(player["id_"]) + logger.debug('Received update about player {}'.format(id_)) if id_ in self.players: self.players[id_].update(**player) else: self.players[id_] = Player(**player) - def avatarManager(self): - self.requestAvatars(0) - self.avatarSelection.show() - def handle_authentication_failed(self, message): - QtWidgets.QMessageBox.warning(self, "Authentication failed", message["text"]) - self._autorelogin = False - self.get_creds_and_login() + QtWidgets.QMessageBox.warning( + self, "Authentication failed", message["text"], + ) + self._auto_relogin = False + self.disconnect_() + self.show_login_widget() def handle_notice(self, message): if "text" in message: style = message.get('style', None) if style == "error": - QtWidgets.QMessageBox.critical(self, "Error from Server", message["text"]) + logger.error( + "Received an error message from server: {}" + .format(message), + ) + QtWidgets.QMessageBox.critical( + self, "Error from Server", message["text"], + ) elif style == "warning": - QtWidgets.QMessageBox.warning(self, "Warning from Server", message["text"]) + logger.warning( + "Received warning message from server: {}".format(message), + ) + QtWidgets.QMessageBox.warning( + self, "Warning from Server", message["text"], + ) elif style == "scores": - self.tray.showMessage("Scores", message["text"], QtWidgets.QSystemTrayIcon.Information, 3500) - self.localBroadcast.emit("Scores", message["text"]) + self.tray.showMessage( + "Scores", message["text"], + QtWidgets.QSystemTrayIcon.Information, 3500, + ) + self.local_broadcast.emit("Scores", message["text"]) + elif "You are using an unofficial client" in message["text"]: + self.unofficial_client.emit(message["text"]) else: - QtWidgets.QMessageBox.information(self, "Notice from Server", message["text"]) + QtWidgets.QMessageBox.information( + self, "Notice from Server", message["text"], + ) if message["style"] == "kill": logger.info("Server has killed your Forged Alliance Process.") @@ -1402,15 +2015,60 @@ def handle_notice(self, message): if message["style"] == "kick": logger.info("Server has kicked you from the Lobby.") - # This is part of the protocol - in this case we should not relogin automatically. + # This is part of the protocol - in this case we should not relogin + # automatically. if message["style"] in ["error", "kick"]: - self._autorelogin = False + self._auto_relogin = False def handle_invalid(self, message): # We did something wrong and the server will disconnect, let's not # reconnect and potentially cause the same error again and again - self.lobby_reconnecter.enabled = False + self.lobby_reconnector.enabled = False raise Exception(message) - def game_full(self): - self.gameFull.emit() + def emit_game_full(self): + self.game_full.emit() + + def invite_to_party(self, recipient_id): + self.games.stopSearch() + msg = { + 'command': 'invite_to_party', + 'recipient_id': recipient_id, + } + self.lobby_connection.send(msg) + + def handle_party_invite(self, message): + logger.info("Handling party_invite via JSON {}".format(message)) + self.party_invite.emit(message) + + def handle_update_party(self, message): + logger.info("Handling update_party via JSON {}".format(message)) + self.games.updateParty(message) + + def handle_kicked_from_party(self, message): + if self.me.player and self.me.player.currentGame is None: + QtWidgets.QMessageBox.information( + self, "Kicked", "You were kicked from party", + ) + msg = { + "owner": self.me.id, + "members": [ + { + "player": self.me.id, + "factions": ["uef", "cybran", "aeon", "seraphim"], + }, + ], + } + self.games.updateParty(msg) + + def set_faction(self, faction): + logger.info("Setting party factions to {}".format(faction)) + msg = { + 'command': 'set_party_factions', + 'factions': faction, + } + self.lobby_connection.send(msg) + + def handle_search_info(self, message): + logger.info("Handling search_info via JSON: {}".format(message)) + self.games.handleMatchmakerSearchInfo(message) diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py index 76a301343..6996511ce 100644 --- a/src/client/aliasviewer.py +++ b/src/client/aliasviewer.py @@ -1,218 +1,176 @@ -import urllib.request -import urllib.error -import urllib.parse -import json -import copy -import time -from PyQt5 import QtWidgets - import logging -logger = logging.getLogger(__name__) +from PyQt6 import QtWidgets +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QTimer -class ApiError(Exception): - def __init__(self, reason): - Exception.__init__(self) - self.reason = reason +from api.player_api import PlayerApiConnector +logger = logging.getLogger(__name__) -class AliasViewer: - def __init__(self): - pass - # TODO refactor once async api is implemented - def _api_request(self, link): - try: - with urllib.request.urlopen(link) as response: - return json.loads(response.read().decode()) - except urllib.error.URLError as e: - raise ApiError("Failed to get link {}: {}".format(link, e.reason)) - except json.decoder.JSONDecodeError as e: - raise ApiError("Failed to decode incoming JSON") - - def _parse_time(self, t): - return time.strptime(t, "%Y-%m-%dT%H:%M:%SZ") - - def player_id_by_name(self, checked_name): - api_link = 'https://api.faforever.com/data/player' \ - '?filter=login=={name}' \ - '&fields[player]=' - query = api_link.format(name=checked_name) - response = self._api_request(query) - if response is None or len(response['data']) == 0: - return None - return int(response['data'][0]['id']) - - def names_previously_known(self, user_id): - api_link = 'https://api.faforever.com/data/player/{id_}' \ - '?include=names' \ - '&fields[player]=login' \ - '&fields[nameRecord]=name,changeTime' - query = api_link.format(id_=user_id) - response = self._api_request(query) - if response is None or 'included' not in response: - return [] - - aliases = [] - for name in response['included']: - if name['type'] != 'nameRecord': - continue - nick_name = name['attributes']['name'] - try: - nick_time = self._parse_time(name['attributes']['changeTime']) - except ValueError: - continue - aliases.append({'name': nick_name, 'time': nick_time}) - - player = response['data'] - aliases.append({'name': player['attributes']['login'], - 'time': None}) - return aliases - - def name_used_by_others(self, checked_name): - api_link = 'https://api.faforever.com/data/player' \ - '?include=names' \ - '&filter=(login=={name},names.name=={name})' \ - '&fields[player]=login,names' \ - '&fields[nameRecord]=name,changeTime' - query = api_link.format(name=checked_name) - response = self._api_request(query) - if response is None or 'data' not in response: - return [] - - players = [p for p in response['data'] if p['type'] == 'player'] - if 'included' not in response: - names = [] - else: - names = [n for n in response['included'] if n['type'] == 'nameRecord' - and n['attributes']['name'] == checked_name] - result = [] - - for p in players: - p_login = p['attributes']['login'] - p_id = p['id'] - if 'relationships' not in p: - p_name_ids = [] +class AliasViewer: + def __init__(self, client, alias_formatter): + self.client = client + self.formatter = alias_formatter + self.api_connector = PlayerApiConnector() + self.api_connector.alias_info.connect(self.process_alias_info) + self.name_to_find = "" + self.searching = False + self.timer = QTimer() + self.timer.timeout.connect(self.stop_alias_search) + + def find_aliases(self, login): + if self.searching: + return + self.name_to_find = login + self.api_connector.requestDataForAliasViewer(login) + self.searching = True + self.timer.start(10000) + + def stop_alias_search(self): + self.searching = False + self.timer.stop() + + def process_alias_info(self, message): + self.stop_alias_search() + + player_aliases, other_users = [], [] + for player in message["data"]: + if player["login"].lower() == self.name_to_find.lower(): + player_aliases.append({ + "name": player["login"], + "changeTime": None, + }) + for name_record in player["names"]: + player_aliases.append(name_record) else: - p_name_ids = set(n['id'] for n in p['relationships']['names']['data']) - p_names = [n for n in names if n['id'] in p_name_ids] - result_entry = {'name': p_login, 'id': p_id} - - if p_login == checked_name: - result_entry['time'] = None - result.append(copy.copy(result_entry)) - for name in p_names: - try: - t = self._parse_time(name['attributes']['changeTime']) - result_entry['time'] = t - result.append(copy.copy(result_entry)) - except ValueError: - continue - return result + for name_record in player["names"]: + name = name_record["name"] + if name.lower() == self.name_to_find.lower(): + other_users.append({ + "name": player["login"], + "changeTime": name_record["changeTime"], + }) + + self.show_aliases(player_aliases, other_users) + + def show_aliases(self, player_aliases, other_users): + QtWidgets.QMessageBox.about( + self.client, + "Aliases : {}".format(self.name_to_find), + self.formatter.format_aliases(player_aliases, other_users), + ) class AliasFormatter: def __init__(self): pass - def nick_times(self, times): - past_times = [t for t in times if t['time'] is not None] - current_times = [t for t in times if t['time'] is None] - - past_times.sort(key=lambda t: t['time']) - name_format = "{}" - past_format = "{}" - current_format = "now" - past_strings = [(name_format.format(e['name']), - past_format.format(time.strftime('%Y-%m-%d   %H:%M', e['time']))) - for e in past_times] - current_strings = [(name_format.format(e['name']), - current_format) - for e in current_times] - return past_strings + current_strings + def nick_times(self, name_records): + past_records = [ + record + for record in name_records + if record["changeTime"] is not None + ] + current_records = [ + record + for record in name_records + if record["changeTime"] is None + ] + + for record in past_records: + isoTime = QDateTime.fromString(record["changeTime"], Qt.DateFormat.ISODate) + record["changeTime"] = isoTime.toLocalTime() + + past_records.sort(key=lambda record: record["changeTime"]) + + for record in past_records: + record["changeTime"] = QDateTime.toString( + record["changeTime"], "yyyy-MM-dd ' ' hh:mm", + ) + for record in current_records: + record["changeTime"] = "now" + + return past_records + current_records def nick_time_table(self, nicks): - table = '
' \ - '{}' \ - '
' - head = ' Name used until' + table = ( + '
{}
' + ) + head = ( + ' Name used until' + '' + ) line_fmt = '{}{}' - lines = [line_fmt.format(*n) for n in nicks] + lines = [ + line_fmt.format(nick["name"], nick["changeTime"]) + for nick in nicks + ] return table.format(head + "".join(lines)) - def name_used_by_others(self, others, original_user=None): - if others is None: - return '' - - others = [u for u in others if u['name'] != original_user] - if len(others) == 0 and original_user is None: + def name_used_by_others(self, player_aliases, other_users): + if len(player_aliases) == len(other_users) == 0: return 'The name has never been used.' - if len(others) == 0 and original_user is not None: + elif len(other_users) == 0: return 'The name has never been used by anyone else.' - return 'The name has previously been used by:{}'.format( - self.nick_time_table(self.nick_times(others))) + return ( + 'The name has previously been used by:{}' + .format(self.nick_time_table(self.nick_times(other_users))) + ) - def names_previously_known(self, response): - if response is None: + def names_previously_known(self, player_aliases): + if len(player_aliases) == 0: return '' - - if len(response) == 0: + elif len(player_aliases) == 1: return 'The user has never changed their name.' - return 'The player has previously been known as:{}'.format( - self.nick_time_table(self.nick_times(response))) + + return ( + 'The player has previously been known as:{}' + .format(self.nick_time_table(self.nick_times(player_aliases))) + ) + + def format_aliases(self, player_aliases, other_users): + alias_format = self.names_previously_known(player_aliases) + others_format = self.name_used_by_others(player_aliases, other_users) + result = '{}

{}'.format(alias_format, others_format) + return result class AliasWindow: - def __init__(self, parent): - self._parent = parent - self._api = AliasViewer() - self._fmt = AliasFormatter() - - def view_aliases(self, name, id_=None): - player_aliases = None - other_users = None - try: - other_users = self._api.name_used_by_others(name) - if id_ is None: - users_now = [u for u in other_users if u['time'] is None] - if len(users_now) > 0: - id_ = users_now[0]['id'] - if id_ is not None: - player_aliases = self._api.names_previously_known(id_) - except ApiError as e: - logger.error(e.reason) - warning_text = ("Failed to query the FAF API:
" - "{exception}
" - "Some info may be incomplete!") - warning_text = warning_text.format(exception=e.reason) - QtWidgets.QMessageBox.warning(self._parent, - "API read error", - warning_text) - - if player_aliases is None and other_users is None: - return + def __init__(self, parent_widget, alias_viewer): + self._parent_widget = parent_widget + self._alias_viewer = alias_viewer - alias_format = self._fmt.names_previously_known(player_aliases) - others_format = self._fmt.name_used_by_others(other_users, name) - result = '{}

{}'.format(alias_format, others_format) - QtWidgets.QMessageBox.about(self._parent, - "Aliases : {}".format(name), - result) + @classmethod + def build(cls, parent_widget, **kwargs): + alias_viewer = AliasViewer(parent_widget, AliasFormatter()) + return cls(parent_widget, alias_viewer) + + def view_aliases(self, name): + self._alias_viewer.find_aliases(name) class AliasSearchWindow: - def __init__(self, parent): - self._parent = parent - self._alias_window = AliasWindow(parent) + def __init__(self, parent_widget, alias_window): + self._parent_widget = parent_widget + self._alias_window = alias_window self._search_window = None + @classmethod + def build(cls, parent_widget, **kwargs): + alias_window = AliasWindow.build(parent_widget, **kwargs) + return cls(alias_window) + def search_alias(self, name): self._alias_window.view_aliases(name) self._search_window = None def run(self): - self._search_window = QtWidgets.QInputDialog(self._parent) + self._search_window = QtWidgets.QInputDialog(self._parent_widget) self._search_window.setInputMode(QtWidgets.QInputDialog.TextInput) self._search_window.textValueSelected.connect(self.search_alias) self._search_window.setLabelText("User name / alias:") diff --git a/src/client/chat_config.py b/src/client/chat_config.py new file mode 100644 index 000000000..ab153652c --- /dev/null +++ b/src/client/chat_config.py @@ -0,0 +1,88 @@ +from PyQt6 import QtCore + +from chat.chatter_model import ChatterLayoutElements +from client.user import SignallingSet # TODO - move to util + + +def signal_property(public): + private = "_{}".format(public) + + def get(self): + return getattr(self, private) + + def set_(self, v): + old = getattr(self, private) + if v != old: + setattr(self, private, v) + self.updated.emit(public) + + return property(get, set_) + + +class ChatConfig(QtCore.QObject): + updated = QtCore.pyqtSignal(str) + + soundeffects = signal_property("soundeffects") + joinsparts = signal_property("joinsparts") + friendsontop = signal_property("friendsontop") + newbies_channel = signal_property("newbies_channel") + channel_blink_interval = signal_property("channel_blink_interval") + channel_ping_timeout = signal_property("channel_ping_timeout") + max_chat_lines = signal_property("max_chat_lines") + ignore_foes = signal_property("ignore_foes") + + def __init__(self, settings): + QtCore.QObject.__init__(self) + self._settings = settings + self._soundeffects = None + self._joinsparts = None + self._friendsontop = None + self._newbies_channel = None + self._channel_blink_interval = None + self._channel_ping_timeout = None + self._max_chat_lines = None + self._ignore_foes = None + + self.hide_chatter_items = SignallingSet() + self.hide_chatter_items.added.connect(self._emit_hidden_items) + self.hide_chatter_items.removed.connect(self._emit_hidden_items) + + self.chat_line_trim_count = 1 + self.announcement_channels = [] + self.channel_greeting = [] + self.channels_to_greet_in = [] + self.newbie_channel_game_threshold = 0 + self.load_settings() + + def _emit_hidden_items(self): + self.updated.emit("hide_chatter_items") + + def load_settings(self): + s = self._settings + self.soundeffects = (s.value("chat/soundeffects", "true") == "true") + self.joinsparts = (s.value("chat/joinsparts", "false") == "true") + self.friendsontop = (s.value("chat/friendsontop", "false") == "true") + self.newbies_channel = ( + s.value("chat/newbiesChannel", "true") == "true" + ) + self.ignore_foes = (s.value("chat/ignoreFoes", "true") == "true") + + items = s.value("chat/hide_chatter_items", "") + items = items.split() + for item in items: + try: + enum_val = ChatterLayoutElements(item) + self.hide_chatter_items.add(enum_val) + except ValueError: + pass + + def save_settings(self): + s = self._settings + s.setValue("chat/soundeffects", self.soundeffects) + s.setValue("chat/joinsparts", self.joinsparts) + s.setValue("chat/newbiesChannel", self.newbies_channel) + s.setValue("chat/friendsontop", self.friendsontop) + s.setValue("chat/ignoreFoes", self.ignore_foes) + + items = " ".join(item.value for item in self.hide_chatter_items) + s.setValue("chat/hide_chatter_items", items) diff --git a/src/client/clientstate.py b/src/client/clientstate.py new file mode 100644 index 000000000..10b7f80a5 --- /dev/null +++ b/src/client/clientstate.py @@ -0,0 +1,15 @@ +from enum import IntEnum + + +class ClientState(IntEnum): + """ + Various states the client can be in. + """ + + SHUTDOWN = -666 # Going... DOWN! + + DISCONNECTED = -2 + CONNECTING = -1 + NONE = 0 + CONNECTED = 1 + LOGGED_IN = 2 diff --git a/src/client/connection.py b/src/client/connection.py index a6327b4ab..30339ece9 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -1,13 +1,21 @@ -from PyQt5 import QtCore, QtNetwork +from __future__ import annotations -import logging -import fa import json +import logging import sys - from enum import IntEnum -from model.game import Game, message_to_game_args +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6.QtCore import QByteArray +from PyQt6.QtCore import QUrl +from PyQt6.QtWebSockets import QWebSocket + +import fa +from api.ApiAccessors import UserApiAccessor +from config import Settings +from model.game import Game +from model.game import message_to_game_args logger = logging.getLogger(__name__) @@ -21,25 +29,25 @@ class ConnectionState(IntEnum): class ServerReconnecter(QtCore.QObject): - def __init__(self, connection): + def __init__(self, connection: ServerConnection) -> None: QtCore.QObject.__init__(self) self._connection = connection connection.state_changed.connect(self.on_state_changed) - connection.received_pong.connect(self._receive_pong) + connection.message_received.connect(self._receive_message) self._connection_attempts = 0 self._reconnect_timer = QtCore.QTimer(self) self._reconnect_timer.setSingleShot(True) - self._reconnect_timer.timeout.connect(self._connection.doConnect) + self._reconnect_timer.timeout.connect(self._connection.do_connect) # For explicit disconnect UI - self._enabled = True + self._enabled = False self._keepalive = False self._keepalive_timer = QtCore.QTimer(self) self._keepalive_timer.timeout.connect(self._ping_connection) - self.keepalive_interval = 10 * 1000 - self._waiting_for_pong = False + self.keepalive_interval = 60 * 1000 + self._waiting_for_message = False @property def enabled(self): @@ -54,6 +62,7 @@ def enabled(self, value): @property def keepalive(self): return self._keepalive + @keepalive.setter def keepalive(self, value): self._keepalive = value @@ -64,7 +73,7 @@ def keepalive(self, value): def _disable_keepalive(self): self._keepalive_timer.stop() - self._waiting_for_pong = False + self._waiting_for_message = False def _enable_keepalive(self): if not self._keepalive_timer.isActive(): @@ -91,6 +100,7 @@ def on_state_changed(self, state): def handle_connected(self): self._connection_attempts = 0 + self._reconnect_timer.stop() def handle_reconnecting(self): self._connection_attempts += 1 @@ -101,8 +111,7 @@ def handle_disconnected(self): if self._connection_attempts < 3: logger.info("Reconnecting immediately") - self._reconnect_timer.stop() - self._connection.doConnect() + self._connection.do_connect() elif self._reconnect_timer.isActive(): return else: @@ -115,58 +124,71 @@ def handle_disconnected(self): def _ping_connection(self): # If we're disconnected, we're already trying to reconnect often - if not self._enabled or self._connection.state != ConnectionState.CONNECTED: - self._waiting_for_pong = False + if ( + not self._enabled + or self._connection.state != ConnectionState.CONNECTED + ): + self._waiting_for_message = False return # Prepare to reconnect immediately self._connection_attempts = 0 - if self._waiting_for_pong: - self._waiting_for_pong = False + if self._waiting_for_message: + self._waiting_for_message = False # Force disconnect # Note that it will force disconnect and reconnect if we # reconnected on our own since last ping! - self._connection.disconnect() + self._connection.disconnect_() else: - self._waiting_for_pong = True - self._connection.writeToServer("PING") + self._waiting_for_message = True + self._connection.send({"command": "ping"}) - def _receive_pong(self): - self._waiting_for_pong = False + def _receive_message(self): + self._waiting_for_message = False + if self.keepalive: + self._keepalive_timer.start() # restart class ServerConnection(QtCore.QObject): - # These signals are emitted when the client is connected or disconnected from FAF + # These signals are emitted when the client is connected or disconnected + # from FAF state_changed = QtCore.pyqtSignal(object) connected = QtCore.pyqtSignal() disconnected = QtCore.pyqtSignal() - received_pong = QtCore.pyqtSignal() + message_received = QtCore.pyqtSignal() + access_url_ready = QtCore.pyqtSignal(QtCore.QUrl) def __init__(self, host, port, dispatch): QtCore.QObject.__init__(self) - self.socket = QtNetwork.QTcpSocket() - self.socket.readyRead.connect(self.readFromServer) - self.socket.error.connect(self.socketError) - self.socket.setSocketOption(QtNetwork.QTcpSocket.KeepAliveOption, 1) + self.socket = QWebSocket() + self.socket.binaryMessageReceived.connect(self.on_binary_message_received) + self.socket.binaryMessageReceived.connect(lambda: self.message_received.emit()) + self.socket.errorOccurred.connect(self.socketError) self.socket.stateChanged.connect(self.on_socket_state_change) self._host = host self._port = port self._state = ConnectionState.INITIAL - self.blockSize = 0 + self._data = "" self._disconnect_requested = False self._dispatch = dispatch + self.api_accessor = UserApiAccessor() + self.access_url_ready.connect(self.open_websocket) + def on_socket_state_change(self, state): - states = QtNetwork.QAbstractSocket + states = QtNetwork.QAbstractSocket.SocketState my_state = None if state == states.UnconnectedState or state == states.BoundState: my_state = ConnectionState.DISCONNECTED - elif state == states.HostLookupState or state == states.ConnectingState: + elif ( + state == states.HostLookupState + or state == states.ConnectingState + ): my_state = ConnectionState.CONNECTING elif state == states.ConnectedState or state == states.ClosingState: my_state = ConnectionState.CONNECTED @@ -190,10 +212,47 @@ def state(self, value): self._state = value self.state_changed.emit(value) - def doConnect(self): + @property + def host(self): + return self._host + + @host.setter + def host(self, value): + self._host = value + + @property + def port(self): + return self._port + + @port.setter + def port(self, value): + self._port = value + + def setHostFromConfig(self): + self.host = Settings.get('lobby/host', type=str) + + def setPortFromConfig(self): + self.port = Settings.get('lobby/port', type=int) + + def do_connect(self): self._disconnect_requested = False self.state = ConnectionState.CONNECTING - self.socket.connectToHost(self._host, self._port) + self.api_accessor.get_by_endpoint("/lobby/access", self.handle_lobby_access_api_response) + + def extract_url_from_api_response(self, data: dict) -> None: + # FIXME: remove this workaround when bug is resolved + # see https://bugreports.qt.io/browse/QTBUG-120492 + url = data["accessUrl"].replace("?verify", "/?verify") + return QUrl(url) + + def handle_lobby_access_api_response(self, data: dict) -> None: + url = self.extract_url_from_api_response(data) + self.access_url_ready.emit(url) + + @QtCore.pyqtSlot(QtCore.QUrl) + def open_websocket(self, url: QUrl) -> None: + logger.debug(f"Opening WebSocket url: {url}") + self.socket.open(url) def on_connecting(self): self.state = ConnectionState.CONNECTING @@ -203,64 +262,61 @@ def on_connected(self): self.connected.emit() def socket_connected(self): - return self.socket.state() == QtNetwork.QTcpSocket.ConnectedState + return self.socket.state() == QtNetwork.QTcpSocket.SocketState.ConnectedState - def disconnect(self): - self.socket.disconnectFromHost() + def disconnect_(self): + self.socket.close() def set_upnp(self, port): - fa.upnp.createPortMapping(self.socket.localAddress().toString(), port, "UDP") - - @QtCore.pyqtSlot() - def readFromServer(self): - ins = QtCore.QDataStream(self.socket) - ins.setVersion(QtCore.QDataStream.Qt_4_2) - - while not ins.atEnd(): - if self.blockSize == 0: - if self.socket.bytesAvailable() < 4: - return - self.blockSize = ins.readUInt32() - if self.socket.bytesAvailable() < self.blockSize: - return - - action = ins.readQString() -# logger.debug("Server: '%s'" % action) - - if action == "PING": - self.writeToServer("PONG") - self.blockSize = 0 - return - elif action == "PONG": - self.blockSize = 0 - self.received_pong.emit() - return - try: - self._dispatch(json.loads(action)) - except: - logger.error("Error dispatching JSON: " + action, exc_info=sys.exc_info()) - - self.blockSize = 0 + fa.upnp.createPortMapping( + self.socket.localAddress().toString(), port, "UDP", + ) + + def processDataFromServer(self, data: str) -> None: + self._data = "" + for line in data.splitlines(): + action = json.loads(line) + command = action.get("command", "").lower() + if command == "ping": + logger.debug("Server: PING") + self.send(dict(command="pong")) + elif command == "pong": + logger.debug("Server: PONG") + else: + try: + self._dispatch(action) + except BaseException: + logger.error( + "Error dispatching JSON: " + line, + exc_info=sys.exc_info(), + ) + + @QtCore.pyqtSlot(QByteArray) + def on_binary_message_received(self, message: QByteArray) -> None: + data = message.data().decode() + logger.debug("Server: '{}'".format(data)) + self._data += data + if self._data.endswith("\n"): + self.processDataFromServer(self._data) def writeToServer(self, action, *args, **kw): - """ - Writes data to the deprecated stream API. Do not use. - """ - logger.debug("Client: " + action) - - block = QtCore.QByteArray() - out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite) - out.setVersion(QtCore.QDataStream.Qt_4_2) - - out.writeUInt32(2 * len(action) + 4) - out.writeQString(action) - - self.socket.write(block) + message = (action + "\n").encode() + # it looks like there's a crash in Qt + # when sending to an unconnected socket + if self.socket.state() == QtNetwork.QAbstractSocket.SocketState.ConnectedState: + self.socket.sendBinaryMessage(message) def send(self, message): data = json.dumps(message) - if message.get('command') == 'hello': - logger.info('Logging in with {}'.format({k: v for k, v in list(message.items()) if k != 'password'})) + if message.get("command") == "auth": + logger.info( + "Logging in with {}".format({ + k: v for k, v in list(message.items()) + if k not in ["token", "unique_id"] + }), + ) + elif message.get("command") in ("ping", "pong"): + logger.debug("Outgoing message: {}".format(message.get("command"))) else: logger.info("Outgoing JSON Message: " + data) @@ -268,21 +324,27 @@ def send(self, message): def on_disconnect(self): logger.warning("Disconnected from lobby server.") - self.blockSize = 0 self.state = ConnectionState.DISCONNECTED + self._data = "" self.disconnected.emit() if self._disconnect_requested: return @QtCore.pyqtSlot(QtNetwork.QAbstractSocket.SocketError) def socketError(self, error): - if (error == QtNetwork.QAbstractSocket.SocketTimeoutError - or error == QtNetwork.QAbstractSocket.NetworkError - or error == QtNetwork.QAbstractSocket.ConnectionRefusedError - or error == QtNetwork.QAbstractSocket.RemoteHostClosedError): - logger.info("Timeout/network error: {}".format(self.socket.errorString())) + if ( + error == QtNetwork.QAbstractSocket.SocketError.SocketTimeoutError + or error == QtNetwork.QAbstractSocket.SocketError.NetworkError + or error == QtNetwork.QAbstractSocket.SocketError.ConnectionRefusedError + or error == QtNetwork.QAbstractSocket.SocketError.RemoteHostClosedError + ): + logger.info( + "Timeout/network error: {}".format(self.socket.errorString()), + ) else: - logger.error("Fatal TCP Socket Error: " + self.socket.errorString()) + logger.error( + "Fatal TCP Socket Error: {}".format(self.socket.errorString()), + ) class Dispatcher(): @@ -310,7 +372,8 @@ def dispatch(self, message): cmd = message['command'] if "target" in message: fn = self._receivers.get((message['target'], cmd)) - fn = self._receivers.get((message['target'], None)) if fn is None else fn + if fn is None: + fn = self._receivers.get((message['target'], None)) if fn is not None: fn(message) else: @@ -320,7 +383,9 @@ def dispatch(self, message): if fn is not None: fn(message) else: - logger.error("Unknown JSON command: %s" % message['command']) + logger.error( + "Unknown JSON command: {}".format(message['command']), + ) raise ValueError @@ -330,61 +395,54 @@ class LobbyInfo(QtCore.QObject): statsInfo = QtCore.pyqtSignal(dict) coopInfo = QtCore.pyqtSignal(dict) tutorialsInfo = QtCore.pyqtSignal(dict) - modInfo = QtCore.pyqtSignal(dict) - modVaultInfo = QtCore.pyqtSignal(dict) replayVault = QtCore.pyqtSignal(dict) coopLeaderBoard = QtCore.pyqtSignal(dict) avatarList = QtCore.pyqtSignal(list) - playerAvatarList = QtCore.pyqtSignal(dict) + social = QtCore.pyqtSignal(dict) + serverSession = QtCore.pyqtSignal(dict) def __init__(self, dispatcher, gameset, playerset): QtCore.QObject.__init__(self) self._dispatcher = dispatcher - self._dispatcher["updated_achievements"] = self.handle_updated_achievements - self._dispatcher["stats"] = self.handle_stats - self._dispatcher["coop_info"] = self.handle_coop_info - self._dispatcher["tutorials_info"] = self.handle_tutorials_info - self._dispatcher["mod_info"] = self.handle_mod_info + self._dispatcher["stats"] = self._simple_emit(self.statsInfo) + self._dispatcher["coop_info"] = self._simple_emit(self.coopInfo) self._dispatcher["game_info"] = self.handle_game_info - self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info - self._dispatcher["modvault_info"] = self.handle_modvault_info - self._dispatcher["replay_vault"] = self.handle_replay_vault - self._dispatcher["coop_leaderboard"] = self.handle_coop_leaderboard + self._dispatcher["replay_vault"] = self._simple_emit(self.replayVault) self._dispatcher["avatar"] = self.handle_avatar self._dispatcher["admin"] = self.handle_admin + self._dispatcher["social"] = self._simple_emit(self.social) + self._dispatcher["session"] = self._simple_emit(self.serverSession) + self._dispatcher["updated_achievements"] = (self.handle_updated_achievements) + self._dispatcher["coop_leaderboard"] = self._simple_emit(self.coopLeaderBoard) + self._dispatcher["tutorials_info"] = self._simple_emit(self.tutorialsInfo) + self._gameset = gameset self._playerset = playerset + def _simple_emit(self, signal): + def _emit(message): + signal.emit(message) + return _emit + def handle_updated_achievements(self, message): pass - def handle_stats(self, message): - self.statsInfo.emit(message) - - def handle_coop_info(self, message): - self.coopInfo.emit(message) - - def handle_tutorials_info(self, message): - self.tutorialsInfo.emit(message) - - def handle_mod_info(self, message): - self.modInfo.emit(message) - def handle_game_info(self, message): - if 'games' in message: # initial bunch of games from server after client start + if 'games' in message: # initial games from server after client start for game in message['games']: self._update_game(game) else: self._update_game(message) def _update_game(self, m): + logger.debug('Received info about game {}'.format(m.get("uid", None))) if not message_to_game_args(m): return uid = m["uid"] if uid not in self._gameset: - game = Game(playerset = self._playerset, **m) + game = Game(playerset=self._playerset, **m) try: self._gameset[uid] = game except ValueError: # Closed game! @@ -392,20 +450,6 @@ def _update_game(self, m): else: self._gameset[uid].update(**m) - def handle_modvault_list_info(self, message): - modList = message["modList"] - for mod in modList: - self.handle_modvault_info(mod) - - def handle_modvault_info(self, message): - self.modVaultInfo.emit(message) - - def handle_replay_vault(self, message): - self.replayVault.emit(message) - - def handle_coop_leaderboard(self, message): - self.coopLeaderBoard.emit(message) - def handle_avatar(self, message): if "avatarlist" in message: self.avatarList.emit(message["avatarlist"]) @@ -413,6 +457,3 @@ def handle_avatar(self, message): def handle_admin(self, message): if "avatarlist" in message: self.avatarList.emit(message["avatarlist"]) - - elif "player_avatar_list" in message: - self.playerAvatarList.emit(message) diff --git a/src/client/gameannouncer.py b/src/client/gameannouncer.py index ae35edf1a..5c84f7d85 100644 --- a/src/client/gameannouncer.py +++ b/src/client/gameannouncer.py @@ -1,17 +1,21 @@ -from PyQt5.QtCore import QTimer -from model.game import GameState +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import pyqtSignal from fa import maps +from model.game import GameState -class GameAnnouncer: +class GameAnnouncer(QObject): + announce = pyqtSignal(str, str) + ANNOUNCE_DELAY_SECS = 35 - def __init__(self, gameset, me, colors, client): + def __init__(self, gameset, me, colors): + QObject.__init__(self) self._gameset = gameset self._me = me self._colors = colors - self._client = client self._gameset.newLobby.connect(self._announce_hosting) self._gameset.newLiveReplay.connect(self._announce_replay) @@ -21,8 +25,10 @@ def __init__(self, gameset, me, colors, client): self._delayed_host_list = [] def _is_friend_host(self, game): - return (game.host_player is not None - and self._me.isFriend(game.host_player.id)) + return ( + game.host_player is not None + and self._me.relations.model.is_friend(game.host_player.id) + ) def _announce_hosting(self, game): if not self._is_friend_host(game) or not self.announce_games: @@ -37,9 +43,11 @@ def _announce_hosting(self, game): def _delayed_announce_hosting(self): timer, game = self._delayed_host_list.pop(0) - if (not self._is_friend_host(game) or - not self.announce_games or - game.state != GameState.OPEN): + if ( + not self._is_friend_host(game) + or not self.announce_games + or game.state != GameState.OPEN + ): return self._announce(game, "hosting") @@ -49,13 +57,14 @@ def _announce_replay(self, game): self._announce(game, "playing live") def _announce(self, game, activity): - url = game.url(game.host_player.id).toString() - url_color = self._colors.getColor("url") + if game.host_player is None: + return + url = game.url(game.host_player.id).to_url().toString() mapname = maps.getDisplayName(game.mapname) - fmt = 'is {} {}{} (on {})' + fmt = 'is {} {}{} (on {})' if game.featured_mod == "faf": modname = "" else: modname = game.featured_mod + " " - msg = fmt.format(activity, modname, url_color, url, game.title, mapname) - self._client.forwardLocalBroadcast(game.host, msg) + msg = fmt.format(activity, modname, url, game.title, mapname) + self.announce.emit(msg, game.host) diff --git a/src/client/kick_dialog.py b/src/client/kick_dialog.py deleted file mode 100644 index fb058f4bb..000000000 --- a/src/client/kick_dialog.py +++ /dev/null @@ -1,73 +0,0 @@ -from PyQt5 import QtCore, QtWidgets, QtGui - -from PyQt5.QtWidgets import QCompleter - -import util -import logging - -logger = logging.getLogger(__name__) - -FormClass, BaseClass = util.THEME.loadUiType("client/kick.ui") - -class KickDialog(FormClass, BaseClass): - PERIOD = ['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'] - - def __init__(self, client, *args, **kwargs): - BaseClass.__init__(self, client, *args, **kwargs) - - self.client = client - - self.setParent(client) - - self.setupUi(self) - self.setModal(True) - self.cbBan.stateChanged.connect(self.banChanged) - self.buttonBox.accepted.connect(self.accepted) - self.buttonBox.rejected.connect(self.rejected) - - def reset(self, name=""): - self.leUsername.setText(name) - self.cbBan.setChecked(False) - self.cbReason.setEnabled(False) - self.cbReason.setCurrentIndex(0) - self.sbDuration.setEnabled(False) - self.sbDuration.setValue(1) - self.cbPeriod.setEnabled(False) - self.cbPeriod.setCurrentIndex(1) - - online_players = [p.login for p in self.client.players.values()] - completer = QCompleter(online_players, self) - self.leUsername.setCompleter(completer) - - def banChanged(self, newState): - checked = self.cbBan.isChecked() - self.cbReason.setEnabled(checked) - self.sbDuration.setEnabled(checked) - self.cbPeriod.setEnabled(checked) - - def accepted(self): - username = self.leUsername.text() - logger.info('closeLobby for {}'.format(username)) - - user_id = self.client.players.getID(username) - if user_id != -1: - message = dict(command="admin", action="closelobby", user_id=user_id) - if self.cbBan.isChecked(): - reason = self.cbReason.currentText() - duration = self.sbDuration.value() - period = self.PERIOD[self.cbPeriod.currentIndex()] - - message['ban'] = dict(reason=reason, duration=duration, period=period) - self.client.lobby_connection.send(message) - self.hide() - else: - QtWidgets.QMessageBox.warning( - self, - "User cannot be found", - "User {} is not online as far as I can tell - did you typo the name?".format(username)) - - def rejected(self): - self.hide() - - - diff --git a/src/client/login.py b/src/client/login.py index ddde531f1..f6b50a28c 100644 --- a/src/client/login.py +++ b/src/client/login.py @@ -1,35 +1,107 @@ -from PyQt5 import QtCore, QtGui +import logging +from PyQt6 import QtCore +from PyQt6 import QtGui + +import config import util from config import Settings +from config.production import default_values as main_environment +from config.testing import default_values as testing_environment + +logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("client/login.ui") class LoginWidget(FormClass, BaseClass): - finished = QtCore.pyqtSignal(str, str) + finished = QtCore.pyqtSignal(bool) request_quit = QtCore.pyqtSignal() remember = QtCore.pyqtSignal(bool) + environments = dict( + main=main_environment, + test=testing_environment, + ) - def __init__(self, startLogin = None, remember = False): + def __init__(self, remember=False): # TODO - init with the parent to inherit the stylesheet # once we make some of our own css to go with it BaseClass.__init__(self) self.setupUi(self) - util.THEME.setStyleSheet(self, "client/login.css") + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() self.splash.setPixmap(util.THEME.pixmap("client/login_watermark.png")) - if startLogin: - self.loginField.setText(startLogin) self.rememberCheckbox.setChecked(remember) + self.serverPortField.setValidator(QtGui.QIntValidator(1, 65535)) + self.replayServerPortField.setValidator(QtGui.QIntValidator(1, 65535)) + self.ircServerPortField.setValidator(QtGui.QIntValidator(1, 65535)) + self.populateEnvironments() + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/login.css")) + + def populateEnvironments(self): + for key, env in self.environments.items(): + self.environmentBox.addItem(env["display_name"], key) + + @QtCore.pyqtSlot() + def on_toggle_extra_options(self): + if self.extraOptionsFrame.isVisible(): + self.extraOptionsFrame.hide() + else: + self.extraOptionsFrame.show() + + @QtCore.pyqtSlot() + def on_fill_extra_options(self): + env = self.environmentBox.currentData() + + self.serverHostField.setText(self.environments[env]["lobby/host"]) + self.serverPortField.setText(str(self.environments[env]["lobby/port"])) + + self.replayServerHostField.setText( + self.environments[env]["replay_server/host"], + ) + self.replayServerPortField.setText( + str(self.environments[env]["replay_server/port"]), + ) + + self.ircServerHostField.setText(self.environments[env]["chat/host"]) + self.ircServerPortField.setText( + str(self.environments[env]["chat/port"]), + ) + + self.apiURLField.setText(self.environments[env]["api"]) @QtCore.pyqtSlot() def on_accepted(self): - password = self.passwordField.text() - hashed_password = util.password_hash(password) - login = self.loginField.text().strip() + host = self.serverHostField.text() + port = int(self.serverPortField.text()) + replay_host = self.replayServerHostField.text().strip() + replay_port = int(self.replayServerPortField.text()) + irc_host = self.ircServerHostField.text().strip() + irc_port = int(self.ircServerPortField.text()) + api_url = self.apiURLField.text() + + logger.info( + "Setting connection options: [server: {}:{}, IRC: {}:{}, " + "replay_server: {}:{}, api_url: {}]".format( + host, port, irc_host, irc_port, + replay_host, replay_port, api_url, + ), + ) + + Settings.set('lobby/host', host, persist=False) + Settings.set('lobby/port', port, persist=False) + Settings.set('chat/host', irc_host, persist=False) + Settings.set('chat/port', irc_port, persist=False) + Settings.set('replay_server/host', replay_host, persist=False) + Settings.set('replay_server/port', replay_port, persist=False) + api_changed = Settings.get('api') != api_url + Settings.set('api', api_url, persist=False) + config.defaults = self.environments[self.environmentBox.currentData()] self.accept() - self.finished.emit(login, hashed_password) + self.finished.emit(api_changed) @QtCore.pyqtSlot() def on_request_quit(self): @@ -42,19 +114,27 @@ def on_remember_checked(self, checked): @QtCore.pyqtSlot() def on_new_account(self): - QtGui.QDesktopServices.openUrl(QtCore.QUrl(Settings.get("CREATE_ACCOUNT_URL"))) + QtGui.QDesktopServices.openUrl( + QtCore.QUrl(Settings.get("CREATE_ACCOUNT_URL")), + ) @QtCore.pyqtSlot() def on_rename_account(self): - QtGui.QDesktopServices.openUrl(QtCore.QUrl(Settings.get("NAME_CHANGE_URL"))) + QtGui.QDesktopServices.openUrl( + QtCore.QUrl(Settings.get("NAME_CHANGE_URL")), + ) @QtCore.pyqtSlot() def on_steamlink_account(self): - QtGui.QDesktopServices.openUrl(QtCore.QUrl(Settings.get("STEAMLINK_URL"))) + QtGui.QDesktopServices.openUrl( + QtCore.QUrl(Settings.get("STEAMLINK_URL")), + ) @QtCore.pyqtSlot() def on_forgot_password(self): - QtGui.QDesktopServices.openUrl(QtCore.QUrl(Settings.get("PASSWORD_RECOVERY_URL"))) + QtGui.QDesktopServices.openUrl( + QtCore.QUrl(Settings.get("PASSWORD_RECOVERY_URL")), + ) @QtCore.pyqtSlot() def on_bugreport(self): diff --git a/src/client/loginwizards.py b/src/client/loginwizards.py deleted file mode 100644 index 4a13eb698..000000000 --- a/src/client/loginwizards.py +++ /dev/null @@ -1,68 +0,0 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -import util - - -class gameSettingsWizard(QtWidgets.QWizard): - def __init__(self, client, *args, **kwargs): - QtWidgets.QWizard.__init__(self, *args, **kwargs) - - self.client = client - - self.settings = GameSettings() - self.settings.gamePortSpin.setValue(self.client.gamePort) - self.settings.checkUPnP.setChecked(self.client.useUPnP) - self.addPage(self.settings) - - self.setWizardStyle(1) - - self.setPixmap(QtWidgets.QWizard.BannerPixmap, - QtGui.QPixmap('client/banner.png')) - self.setPixmap(QtWidgets.QWizard.BackgroundPixmap, - QtGui.QPixmap('client/background.png')) - - self.setWindowTitle("Set Game Port") - - def accept(self): - self.client.gamePort = self.settings.gamePortSpin.value() - self.client.useUPnP = self.settings.checkUPnP.isChecked() - QtWidgets.QWizard.accept(self) - - -class GameSettings(QtWidgets.QWizardPage): - def __init__(self, parent=None): - super(GameSettings, self).__init__(parent) - - self.parent = parent - self.setTitle("Network Settings") - self.setPixmap(QtWidgets.QWizard.WatermarkPixmap, util.THEME.pixmap("client/settings_watermark.png")) - - self.label = QtWidgets.QLabel() - self.label.setText('Forged Alliance needs an open UDP port to play. If you have trouble connecting to other ' - 'players, try the UPnP option first. If that fails, you should try to open or forward the ' - 'port on your router and firewall.

Visit the Tech Support Forum if you need help.

') - self.label.setOpenExternalLinks(True) - self.label.setWordWrap(True) - - self.labelport = QtWidgets.QLabel() - self.labelport.setText("UDP Port (default 6112)") - self.labelport.setWordWrap(True) - - self.gamePortSpin = QtWidgets.QSpinBox() - self.gamePortSpin.setMinimum(1024) - self.gamePortSpin.setMaximum(65535) - self.gamePortSpin.setValue(6112) - - self.checkUPnP = QtWidgets.QCheckBox("use UPnP") - self.checkUPnP.setToolTip("FAF can try to open and forward your game port automatically using UPnP.
" - "Caution: This doesn't work for all connections, but may help with some routers.") - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.label) - layout.addWidget(self.labelport) - layout.addWidget(self.gamePortSpin) - layout.addWidget(self.checkUPnP) - self.setLayout(layout) - - def validatePage(self): - return 1 diff --git a/src/client/mouse_position.py b/src/client/mouse_position.py new file mode 100644 index 000000000..dda4c8137 --- /dev/null +++ b/src/client/mouse_position.py @@ -0,0 +1,60 @@ + +__all__ = ("MousePosition",) + + +class MousePosition(object): + """ + Instance holds mouse edge information. + """ + PADDING = 8 + + def __init__(self, parent): + self.parent = parent + self.on_left_edge = False + self.on_right_edge = False + self.on_top_edge = False + self.on_bottom_edge = False + self.cursor_shape_change = False + self.warning_buttons = dict() # TODO: remove, unused? + self.on_edges = False # TODO: remove, unused? + + def update_mouse_position(self, pos): + self.on_left_edge = pos.x() < MousePosition.PADDING + self.on_right_edge = ( + pos.x() > self.parent.size().width() - MousePosition.PADDING + ) + self.on_top_edge = pos.y() < MousePosition.PADDING + self.on_bottom_edge = ( + pos.y() > self.parent.size().height() - MousePosition.PADDING + ) + + def reset_to_false(self): + self.on_left_edge = False + self.on_right_edge = False + self.on_top_edge = False + self.on_bottom_edge = False + self.cursor_shape_change = False + + @property + def on_top_left_edge(self): + return self.on_top_edge and self.on_left_edge + + @property + def on_bottom_left_edge(self): + return self.on_bottom_edge and self.on_left_edge + + @property + def on_top_right_edge(self): + return self.on_top_edge and self.on_right_edge + + @property + def on_bottom_right_edge(self): + return self.on_bottom_edge and self.on_right_edge + + def is_on_edge(self): + return ( + self.on_left_edge + or self.on_right_edge + or self.on_top_edge + or self.on_bottom_edge + ) diff --git a/src/client/playercolors.py b/src/client/playercolors.py index feaf57830..f5960b895 100644 --- a/src/client/playercolors.py +++ b/src/client/playercolors.py @@ -1,11 +1,9 @@ -import util import json import random from enum import Enum - -def _loadcolors(filename): - return json.loads(util.THEME.readfile(filename)) +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal class PlayerAffiliation(Enum): @@ -13,44 +11,68 @@ class PlayerAffiliation(Enum): FRIEND = "friend" FOE = "foe" CLANNIE = "clan" + CHATTERBOX = "chatterbox" OTHER = "default" -class PlayerColors: - # Color table used by the following method - # CAVEAT: will break if theme is loaded after client module is imported - colors = _loadcolors("client/colors.json") - operatorColors = _loadcolors("chat/formatters/operator_colors.json") - randomcolors = _loadcolors("client/randomcolors.json") +class PlayerColors(QObject): + changed = pyqtSignal() + + def __init__(self, me, user_relations, theme): + QObject.__init__(self) + self._me = me + self._user_relations = user_relations + self._theme = theme + self._colored_nicknames = False + self.colors = self._load_colors("client/colors.json") + self.random_colors = self._load_colors("client/randomcolors.json") + + @property + def colored_nicknames(self): + return self._colored_nicknames - def __init__(self, user): - self._user = user - self.coloredNicknames = False + @colored_nicknames.setter + def colored_nicknames(self, value): + if self._colored_nicknames != value: + self._colored_nicknames = value + self.changed.emit() - def getColor(self, name): + def _load_colors(self, filename): + return json.loads(self._theme.readfile(filename)) + + def get_color(self, name): if name in self.colors: return self.colors[name] else: return self.colors["default"] - def getRandomColor(self, seed): - '''Generate a random color from a seed''' - random.seed(seed) - return random.choice(self.randomcolors) + def _seed(self, id_, name): + return id_ if id_ not in [-1, None] else name + + def get_random_color(self, id_, name): + random.seed(self._seed(id_, name)) + return random.choice(self.random_colors) + + def get_random_color_index(self, id_, name): + random.seed(self._seed(id_, name)) + return random.choice(range(len(self.random_colors))) - def getAffiliation(self, id_=-1, name=None): - if self._user.player and self._user.player.id == id_: + def _get_affiliation(self, id_=-1, name=None): + if self._me.player is not None and self._me.player.id == id_: return PlayerAffiliation.SELF - if self._user.isFriend(id_, name): + if self._user_relations.is_friend(id_, name): return PlayerAffiliation.FRIEND - if self._user.isFoe(id_, name): + if self._user_relations.is_foe(id_, name): return PlayerAffiliation.FOE - if self._user.isClannie(id_): + if self._me.is_clannie(id_): return PlayerAffiliation.CLANNIE return PlayerAffiliation.OTHER - def getUserColor(self, _id=-1, name=None): - affil = self.getAffiliation(_id, name) + def get_user_color(self, _id=-1, name=None): + if self._user_relations.is_chatterbox(_id, name): + return self.get_chatterbox_color(_id, name) + + affil = self._get_affiliation(_id, name) names = { PlayerAffiliation.SELF: "self", PlayerAffiliation.FRIEND: "friend", @@ -59,26 +81,33 @@ def getUserColor(self, _id=-1, name=None): } if affil in names: - return self.getColor(names[affil]) - if self.coloredNicknames: - return self.getRandomColor(_id if _id != -1 else name) + return self.get_color(names[affil]) + if self.colored_nicknames: + return self.get_random_color(_id, name) if _id == -1: # IRC user - return self.getColor("default") - return self.getColor("player") + return self.get_color("default") + return self.get_color("player") - def getModColor(self, elevation, _id=-1, name=None): - affil = self.getAffiliation(_id, name) + def get_mod_color(self, _id=-1, name=None): + affil = self._get_affiliation(_id, name) names = { PlayerAffiliation.SELF: "self_mod", PlayerAffiliation.FRIEND: "friend_mod", + PlayerAffiliation.FOE: "foe_mod", PlayerAffiliation.CLANNIE: "friend_mod", } - if affil in names: - return self.getColor(names[affil]) - - if elevation in self.operatorColors: - return self.operatorColors[elevation] + return self.get_color(names[affil]) + return self.get_color("mod") - return self.getColor("player") + def get_chatterbox_color(self, _id=-1, name=None): + affil = self._get_affiliation(_id, name) + names = { + PlayerAffiliation.FRIEND: "friend_chatterbox", + PlayerAffiliation.FOE: "foe_chatterbox", + PlayerAffiliation.CLANNIE: "clan_chatterbox", + } + if affil in names: + return self.get_color(names[affil]) + return self.get_color("chatterbox") diff --git a/src/client/theme_menu.py b/src/client/theme_menu.py index 2274a0935..065360867 100644 --- a/src/client/theme_menu.py +++ b/src/client/theme_menu.py @@ -1,5 +1,49 @@ -from PyQt5 import QtWidgets, QtCore +from PyQt6 import QtCore +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QPushButton +from PyQt6.QtWidgets import QStyleFactory + import util +from config import Settings + +FormClass, BaseClass = util.THEME.loadUiType("client/change_style.ui") + + +class ChangeAppStyleDialog(FormClass, BaseClass): + def __init__(self) -> None: + super(ChangeAppStyleDialog, self).__init__() + self.setupUi(self) + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + self.setWindowTitle("Select Application Style") + self.stylesList.addItems(QStyleFactory.keys()) + self.buttonBox.clicked.connect(self.on_button_clicked) + + def highlight_current_style(self) -> None: + current_stylename = QApplication.style().name() + match_flag = QtCore.Qt.MatchFlag.MatchFixedString + current_item, = self.stylesList.findItems(current_stylename, match_flag) + self.stylesList.setCurrentItem(current_item) + + def run(self) -> int: + self.highlight_current_style() + return self.exec() + + def on_button_clicked(self, button: QPushButton) -> None: + roles = self.buttonBox.ButtonRole + role = self.buttonBox.buttonRole(button) + style_name = self.stylesList.currentItem().text() + if role == roles.ApplyRole: + self.select_style(style_name, apply=True) + elif role == roles.AcceptRole: + self.select_style(style_name, apply=False) + + def save_preference(self, stylename: str) -> None: + Settings.set("theme/style", stylename) + + def select_style(self, stylename: str, apply: bool) -> None: + self.save_preference(stylename) + if apply: + QApplication.setStyle(QStyleFactory.create(stylename)) class ThemeMenu(QtCore.QObject): @@ -11,6 +55,7 @@ def __init__(self, menu): self._themes = {} # Hack to not process check signals when we're changing them ourselves self._updating = False + self.app_style_handler = ChangeAppStyleDialog() def setup(self, themes): for theme in themes: @@ -20,6 +65,8 @@ def setup(self, themes): action.setCheckable(True) self._menu.addSeparator() self._menu.addAction("Reload Stylesheet", util.THEME.reloadStyleSheets) + self._menu.addSeparator() + self._menu.addAction("Change Style", self.app_style_handler.run) self._updateThemeChecks() diff --git a/src/client/update_settings.py b/src/client/update_settings.py deleted file mode 100644 index ebf8e0e12..000000000 --- a/src/client/update_settings.py +++ /dev/null @@ -1,38 +0,0 @@ -import util -from config import Settings -from enum import Enum -from decorators import with_logger - - -class UpdateBranch(Enum): - Stable = 0 - Prerelease = 1 - Unstable = 2 - -FormClass, BaseClass = util.THEME.loadUiType("client/update_settings.ui") - - -@with_logger -class UpdateSettingsDialog(FormClass, BaseClass): - updater_branch = Settings.persisted_property('updater/branch', type=str, default_value=UpdateBranch.Prerelease.name) - updater_downgrade = Settings.persisted_property('updater/downgrade', type=bool, default_value=False) - - def __init__(self, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - - self.setModal(True) - - def setup(self): - self.setupUi(self) - - self.cbChannel.setCurrentIndex(UpdateBranch[self.updater_branch].value) - self.cbDowngrade.setChecked(self.updater_downgrade) - - self.buttonBox.accepted.connect(self.accepted) - self.buttonBox.rejected.connect(lambda: self.close()) - - def accepted(self): - branch = UpdateBranch(self.cbChannel.currentIndex()) - self.updater_branch = branch.name - self.updater_downgrade = self.cbDowngrade.isChecked() - self.close() diff --git a/src/client/updater.py b/src/client/updater.py deleted file mode 100644 index 8b901fa51..000000000 --- a/src/client/updater.py +++ /dev/null @@ -1,343 +0,0 @@ -import tempfile -import client -import subprocess -import json -from semantic_version import Version -import config -import os -from config import Settings -import util - -from client.update_settings import UpdateBranch, UpdateSettingsDialog - -from decorators import with_logger -from PyQt5 import QtWidgets, QtCore -from PyQt5.QtWidgets import QLabel, QLayout -from PyQt5.QtCore import QUrl, QObject -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply - - -@with_logger -class UpdateSettings: - updater_branch = Settings.persisted_property('updater/branch', type=str, default_value=UpdateBranch.Prerelease.name) - - def should_notify(self, releases, force=True): - self._logger.debug(releases) - if force: - return True - - server_releases = [release for release in releases if release['branch']=='server'] - stable_releases = [release for release in releases if release['branch']=='stable'] - pre_releases = [release for release in releases if release['branch']=='pre'] - beta_releases = [release for release in releases if release['branch']=='beta'] - - have_server = len(server_releases) > 0 - have_stable = len(stable_releases) > 0 - have_pre = len(pre_releases) > 0 - have_beta = len(beta_releases) > 0 - - current_version = Version(config.VERSION) - # null out build because we don't care about it - current_version.build = () - - notify_stable = have_stable and self.updater_branch == UpdateBranch.Stable.name \ - and Version(stable_releases[0]['new_version']) > current_version - notify_pre = have_pre and self.updater_branch == UpdateBranch.Prerelease.name \ - and Version(pre_releases[0]['new_version']) > current_version - notify_beta = have_beta and self.updater_branch == UpdateBranch.Unstable.name \ - and Version(beta_releases[0]['new_version']) > current_version - - return have_server or notify_stable or notify_pre or notify_beta - -FormClass, BaseClass = util.THEME.loadUiType("client/update.ui") - - -@with_logger -class UpdateDialog(FormClass, BaseClass): - changelog_url = Settings.persisted_property('updater/changelog_url', type=str, - default_value='https://github.com/FAForever/client/releases/tag') - - def __init__(self, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - - self._logger.debug("UpdateDialog instantiating") - self.setModal(True) - - def setup(self, releases): - self.setupUi(self) - - self.btnStart.clicked.connect(self.startUpdate) - self.btnAbort.clicked.connect(self.abort) - self.btnSettings.clicked.connect(self.showSettings) - - self.cbReleases.currentIndexChanged.connect(self.indexChanged) - - self.layout().setSizeConstraint(QLayout.SetFixedSize) - - self.releases = releases - self.reset_controls() - - def reset_controls(self): - self.pbDownload.hide() - self.btnCancel.hide() - self.btnAbort.setEnabled(True) - - current_version = Version(config.VERSION) - - if any([release['branch']=='server' for release in self.releases]): - self.lblUpdatesFound.setText('Your client version is outdated - you must update to play.') - else: - self.lblUpdatesFound.setText('Client releases were found.') - - if len(self.releases) > 0: - currIdx = 0 - preferIdx = None - - labels = dict([('server', 'Server Version'), ('stable', 'Stable Version'), ('pre', 'Stable Prerelease'), ('beta', 'Unstable')]) - self.cbReleases.blockSignals(True) - self.cbReleases.clear() - for currIdx, release in enumerate(self.releases): - self._logger.debug(release) - - key = release['branch'] - label = labels[key] - branch_to_key = dict(Stable='stable', Prerelease='pre', Unstable='beta') - prefer = key == branch_to_key[UpdateSettings().updater_branch] - - is_update = ' [New!]' if Version(release['new_version']) > current_version else '' - - self.cbReleases.insertItem(99, '{} {}{}'.format(label, release['new_version'], is_update), release) - - if prefer and preferIdx is None: - preferIdx = currIdx - - if preferIdx is None: - preferIdx = 0 - - self.cbReleases.setCurrentIndex(preferIdx) - self.indexChanged(preferIdx) - self.cbReleases.blockSignals(False) - - self.btnStart.setEnabled(True) - - @QtCore.pyqtSlot(int) - def indexChanged(self, index): - def _format_changelog(version): - if version is not None: - return "Release Info".format(self.changelog_url, version) - else: - return 'Not available' - - release = self.cbReleases.itemData(index) - - self.lblInfo.setText(_format_changelog(release['new_version'])) - - def startUpdate(self): - sender = self.sender() - - release = self.cbReleases.itemData(self.cbReleases.currentIndex()) - url = release['update'] - - self.btnStart.setEnabled(False) - self.btnAbort.setEnabled(False) - - client_updater = ClientUpdater(parent=self, progress_bar=self.pbDownload, cancel_btn=self.btnCancel) - client_updater.finished.connect(self.finishUpdate) - client_updater.exec_(url) - - def finishUpdate(self): - self.reset_controls() - - def abort(self): - self.close() - - def showSettings(self): - dialog = UpdateSettingsDialog(self) - dialog.setup() - dialog.show() - - -@with_logger -class UpdateChecker(QObject): - gh_releases_url = Settings.persisted_property('updater/gh_release_url', type=str, - default_value='https://api.github.com/repos/' - 'FAForever/client/releases?per_page=20') - updater_downgrade = Settings.persisted_property('updater/downgrade', type=bool, default_value=False) - - finished = QtCore.pyqtSignal(list) - - def __init__(self, parent, respect_notify=True): - QObject.__init__(self, parent) - self._network_manager = client.NetworkManager - self.respect_notify = respect_notify - self._releases = None - - def start(self, reset_server=True): - gh_url = QUrl(self.gh_releases_url) - self._rep = self._network_manager.get(QNetworkRequest(gh_url)) - self._rep.finished.connect(self._req_done) - if reset_server: - self._server_info = None - - def server_update(self, message): - self._server_info = message - self._check_updates_complete() - - def server_session(self): - self._server_info = {} - self._check_updates_complete() - - def _parse_releases(self, release_info): - def _parse_release(release_dict): - client_version = Version(config.VERSION) - for asset in release_dict['assets']: - if '.msi' in asset['browser_download_url']: - download_url = asset['browser_download_url'] - tag = release_dict['tag_name'] - release_version = Version(tag) - # We never want to return the current version itself, - # but if `updater_downgrade` is set, we do return - # older releases. - # strange comparison logic is because of semantic_version - # so that build info is ignored - if self.updater_downgrade: - if not (release_version < client_version or client_version < release_version): - return None - else: - if not (release_version > client_version): - return None - - branch = None - if release_version.minor % 2 == 1: - branch = 'beta' - elif release_version.minor % 2 == 0: - if release_version.prerelease == (): - branch = 'stable' - else: - branch = 'pre' - return dict( - branch=branch, - update=download_url, - new_version=tag) - try: - releases = json.loads(release_info.decode('utf-8')) - if not isinstance(releases, list): - releases = [releases] - self._logger.debug('Loaded {} github releases'.format(len(releases))) - - return [ release for release in [_parse_release(release) for release in releases] if release is not None] - except: - self._logger.exception("Error parsing network reply: {}".format(repr(release_info))) - return [] - - def _req_done(self): - if self._rep.error() == QNetworkReply.NoError: - self._releases = self._parse_releases(bytes(self._rep.readAll())) - else: - self._releases = [] - - self._check_updates_complete() - - def _check_updates_complete(self): - if self._server_info is not None and self._releases is not None: - releases = self._releases - if self._server_info != {}: - releases.append(dict( - branch='server', - update=self._server_info['update'], - new_version=self._server_info['new_version'] - )) - if UpdateSettings().should_notify(releases, force = not self.respect_notify): - self.finished.emit(releases) - - -@with_logger -class ClientUpdater(QObject): - - finished = QtCore.pyqtSignal() - - def __init__(self, parent, progress_bar, cancel_btn): - QObject.__init__(self, parent) - self._progress = None - self._network_manager = client.NetworkManager - self._progress_bar = progress_bar - self._cancel_btn = cancel_btn - self._tmp = None - self._req = None - self._rep = None - - def exec_(self, url): - self._logger.info('Downloading {}'.format(url)) - self._setup_progress() - self._prepare_download(url) - - def _prepare_download(self, url): - self._logger.debug('_prepare_download') - self._tmp = tempfile.NamedTemporaryFile(mode='w+b', - suffix=".msi", - delete=False) - self._req = QNetworkRequest(QUrl(url)) - self._rep = self._network_manager.get(self._req) - self._rep.setReadBufferSize(0) - self._rep.downloadProgress.connect(self._on_progress) - self._rep.finished.connect(self._on_finished) - self._rep.error.connect(self.error) - self._rep.readyRead.connect(self._buffer) - self._rep.sslErrors.connect(self.ssl_error) - - def ssl_error(self, errors): - estrings = [e.errorString() for e in errors] - self._logger.error('ssl errors: {}'.format(estrings)) - self._rep.ignoreSslErrors() - - def error(self, code): - self._logger.error(self._rep.errorString()) - - def _buffer(self): - self._tmp.write(self._rep.read(self._rep.bytesAvailable())) - - def _on_finished(self): - self._logger.debug('_on_finished') - assert self._tmp - assert self._rep.atEnd() - if self._rep.error() != QNetworkReply.NoError: - self._logger.error(self._rep.errorString()) - return # FIXME - handle - - self._tmp.close() - - redirected = self._rep.attribute(QNetworkRequest.RedirectionTargetAttribute) - if redirected is not None: - self._logger.debug('redirected to {}'.format(redirected)) - os.remove(self._tmp.name) - if redirected.isRelative(): - url = self._rep.url().resolved(redirected) - else: - url = redirected - self._prepare_download(url) - else: - self._run_installer() - - def _run_installer(self): - command = 'msiexec /i "{msiname}" & del "{msiname}"'.format(msiname=self._tmp.name) - self._logger.debug(r'Running msi installation command: ' + command) - subprocess.Popen(command, shell=True) - client.instance.close() - - def _on_progress(self, bytesReceived, bytesTotal): - # only show for "real" download, i.e. bytesTotal > 5MB - if (bytesTotal > 5*1024**2): - self._progress_bar.setMaximum(bytesTotal) - self._progress_bar.setValue(bytesReceived) - - def cancel(self): - self._rep.abort() - self.finished.emit() - - def _setup_progress(self): - self._cancel_btn.show() - - self._progress_bar.show() - self._progress_bar.setValue(0) - - self._cancel_btn.clicked.connect(self.cancel) diff --git a/src/client/user.py b/src/client/user.py index 4ac243993..ca0221061 100644 --- a/src/client/user.py +++ b/src/client/user.py @@ -1,89 +1,18 @@ -from PyQt5 import QtCore -from config import Settings -from enum import Enum +from collections.abc import MutableSet - -class UserRelation(QtCore.QObject): - """ - Represents some sort of relation user has with other players. - """ - updated = QtCore.pyqtSignal(set) - - def __init__(self): - QtCore.QObject.__init__(self) - self._relations = set() - - def add(self, value): - self._relations.add(value) - self.updated.emit(set([value])) - - def rem(self, value): - self._relations.discard(value) - self.updated.emit(set([value])) - - def set(self, values): - changed = self._relations.union(set(values)) - self._relations = set(values) - self.updated.emit(changed) - - def clear(self): - self.set(set()) - - def has(self, value): - return value in self._relations - - -class IrcUserRelation(UserRelation): - """ - Represents a relation user has with IRC users. Remembers the relation - in the Settings. - """ - - def __init__(self, key=None): - UserRelation.__init__(self) - self.key = key - - def _loadRelations(self): - if self._key is not None: - rel = Settings.get(self._key) - self._relations = set(rel) if rel is not None else set() - else: - self._relations = set() - - def _saveRelations(self): - if self._key is not None: - Settings.set(self._key, list(self._relations)) - - def add(self, value): - UserRelation.add(self, value) - self._saveRelations() - - def rem(self, value): - UserRelation.rem(self, value) - self._saveRelations() - - def set(self, values): - UserRelation.set(self, values) - self._saveRelations() - - @property - def key(self): - return self._key - - @key.setter - def key(self, value): - self._key = value - self._loadRelations() +from PyQt6 import QtCore +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal class User(QtCore.QObject): """ Represents the person using the FAF Client. May have a player assigned to - himself if he's logged in, has foes, friends and clannies. + himself if he's logged in. For convenience, forwards and signals some + underlying player information. """ - relationsUpdated = QtCore.pyqtSignal(set) - ircRelationsUpdated = QtCore.pyqtSignal(set) - playerAvailable = QtCore.pyqtSignal() + playerChanged = pyqtSignal(object) + clan_changed = pyqtSignal(object, object) def __init__(self, playerset): QtCore.QObject.__init__(self) @@ -93,122 +22,403 @@ def __init__(self, playerset): self.login = None self._players = playerset - self._players.playerAdded.connect(self._on_player_change) - self._players.playerRemoved.connect(self._on_player_change) - - self._friends = UserRelation() - self._foes = UserRelation() - self._friends.updated.connect(self.relationsUpdated.emit) - self._foes.updated.connect(self.relationsUpdated.emit) + self._players.added.connect(self._on_player_change) + self._players.removed.connect(self._on_player_change) - self._irc_friends = IrcUserRelation() - self._irc_foes = IrcUserRelation() - self._irc_friends.updated.connect(self.ircRelationsUpdated.emit) - self._irc_foes.updated.connect(self.ircRelationsUpdated.emit) + self.relations = None # FIXME - circular me -> rels -> me dep @property def player(self): return self._player + @player.setter + def player(self, value): + new = value + old = self._player + if old is not None: + old.updated.disconnect(self._at_player_update) + if new is not None: + new.updated.connect(self._at_player_update) + self._player = value + self.playerChanged.emit(self._player) + self._emit_clan_changed(new, old) + + def _at_player_update(self, new, old): + if new.clan != old.clan: + self._emit_clan_changed(new, old) + + def _emit_clan_changed(self, new_player, old_player): + def pclan(p): + return None if p is None else p.clan + self.clan_changed.emit(pclan(new_player), pclan(old_player)) + def onLogin(self, login, id_): self.login = login self.id = id_ self._update_player() def _update_player(self): - if self.id is None or self.id not in self._players: - self._player = None - return - if self._player is not None: + new_player = self._players.get(self.id, None) + if self._player is new_player: return - self._player = self._players[self.id] - self.playerAvailable.emit() + self.player = new_player def _on_player_change(self, player): if self.id is None or player.id != self.id: return self._update_player() - def _irc_key(self, name): - if self.player is None: - return None - return "chat.irc_" + name + "/" + str(self.player.id) - def resetPlayer(self): self._player = None - self._friends.clear() - self._foes.clear() - self._clannies.clear() - def isClannie(self, _id): - if not self._player: + def is_clannie(self, pid): + if pid is None: return False - return self._isClannie(_id, self._player.clan) - - def _isClannie(self, _id, my_clan): - if my_clan is None: + player = self._players.get(pid, None) + if player is None or self._player is None: return False - other = self._players.get(_id) - if other is None: + if self._player.clan is None: return False - return my_clan == other.clan + return player.clan == self._player.clan - def _getClannies(self, clan): - return [p.id for p in self._players.values() if self._isClannie(p.id, clan)] + def player_clan(self): + return None if self.player is None else self.player.clan - def _checkClanChange(self, new, old): - if new.clan == old.clan: - return - oldClannies = self._getClannies(old.clan) - newClannies = self._getClannies(new.clan) - self.relationsUpdated.emit(set(oldClannies + newClannies)) - - def addFriend(self, id_): - self._friends.add(id_) - - def remFriend(self, id_): - self._friends.rem(id_) - def setFriends(self, ids): - self._friends.set(ids) +class SetSignals(QObject): + """ + Defined separately since QObject and MutableSet metaclasses clash. + """ + added = pyqtSignal(object) + removed = pyqtSignal(object) - def addFoe(self, id_): - self._foes.add(id_) + def __init__(self): + QObject.__init__(self) - def remFoe(self, id_): - self._foes.rem(id_) - def setFoes(self, ids): - self._foes.set(ids) +class SignallingSet(MutableSet): + def __init__(self): + MutableSet.__init__(self) + self._set = set() + self._signals = SetSignals() - def addIrcFriend(self, id_): - self._irc_friends.add(id_) + @property + def added(self): + return self._signals.added - def remIrcFriend(self, id_): - self._irc_friends.rem(id_) + @property + def removed(self): + return self._signals.removed - def setIrcFriends(self, ids): - self._irc_friends.set(ids) + def __contains__(self, value): + return value in self._set - def addIrcFoe(self, id_): - self._irc_foes.add(id_) + def __iter__(self): + return iter(self._set) - def remIrcFoe(self, id_): - self._irc_foes.rem(id_) + def __len__(self): + return len(self._set) - def setIrcFoes(self, ids): - self._irc_foes.set(ids) + def add(self, value): + if value not in self._set: + self._set.add(value) + self.added.emit(value) + + def discard(self, value): + if value in self._set: + self._set.discard(value) + self.removed.emit(value) + + +class FriendFoeModel: + def __init__(self, friends, foes, chatterboxes): + self.friends = friends + self.foes = foes + self.chatterboxes = chatterboxes + + @classmethod + def build(cls, **kwargs): + friends = SignallingSet() + foes = SignallingSet() + chatterboxes = SignallingSet() + return cls(friends, foes, chatterboxes) + + +class UserRelationModel: + def __init__(self, player_relations, irc_relations): + self.faf = player_relations + self.irc = irc_relations + + @classmethod + def build(cls, **kwargs): + player_relations = FriendFoeModel.build() + irc_relations = FriendFoeModel.build() + return cls(player_relations, irc_relations) + + def is_friend(self, id_=None, name=None): + if id_ not in [None, -1]: + return id_ in self.faf.friends + if name is not None: + return name in self.irc.friends + return False - def isFriend(self, id_=-1, name=None): - if id_ != -1: - return self._friends.has(id_) - elif name is not None: - return self._irc_friends.has(name) + def is_foe(self, id_=None, name=None): + if id_ not in [None, -1]: + return id_ in self.faf.foes + if name is not None: + return name in self.irc.foes return False - def isFoe(self, id_=-1, name=None): - if id_ != -1: - return self._foes.has(id_) - elif name is not None: - return self._irc_foes.has(name) + def is_chatterbox(self, id_=None, name=None): + if id_ not in [None, -1]: + return id_ in self.faf.chatterboxes + if name is not None: + return name in self.irc.chatterboxes return False + + +class IrcRelationController: + def __init__(self, keyname, set_, me, settings): + self._keyname = keyname + self._set = set_ + self._me = me + self._me.playerChanged.connect(self._at_player_changed) + self._settings = settings + self._key = None + self._at_player_changed(self._me.player) + + @classmethod + def build(cls, keyname, set_, me, settings, **kwargs): + return cls(keyname, set_, me, settings) + + def _load(self): + if self._key is None: + loaded = [] + else: + loaded = self._settings.get(self._key, []) + self._set.clear() + if self._keyname == "chatterboxes": + self._set |= (int(pid) for pid in loaded) + else: + self._set |= loaded + + def _save(self): + if self._key is not None: + self._settings.set(self._key, list(self._set)) + + @property + def key(self): + return self._key + + @key.setter + def key(self, value): + self._key = value + self._load() + + def _at_player_changed(self, player): + self.key = self._irc_key(player) + + def _irc_key(self, player): + if player is None: + return None + return "chat.{}/{}".format(self._keyname, player.id) + + def add(self, item): + self._set.add(item) + self._save() + + def remove(self, item): + self._set.discard(item) + self._save() + + +class FafRelationController: + def __init__(self, msg_in, msg_out, set_, lobby_info, lobby_connection): + self._msg_in = msg_in + self._msg_out = msg_out + self._set = set_ + self._lobby_info = lobby_info + self._lobby_info.social.connect(self._handle_social) + self._lobby_connection = lobby_connection + + @classmethod + def build( + cls, msg_in, msg_out, set_, lobby_info, lobby_connection, **kwargs + ): + return cls(msg_in, msg_out, set_, lobby_info, lobby_connection) + + def _handle_social(self, message): + data = message.get(self._msg_in, None) + if data is None: + return + self._set.clear() + self._set |= (int(pid) for pid in data) + + def _send_message(self, action, pid): + self._lobby_connection.send({ + "command": action, + self._msg_out: pid, + }) + + def add(self, pid): + if pid not in self._set: + self._send_message("social_add", pid) + self._set.add(pid) + + def remove(self, pid): + if pid in self._set: + self._send_message("social_remove", pid) + self._set.remove(pid) + + +class IrcFriendFoeController: + def __init__(self, friends, foes, chatterboxes): + self.friends = friends + self.foes = foes + self.chatterboxes = chatterboxes + + @classmethod + def build(cls, irc_relations, **kwargs): + friends = IrcRelationController.build( + "irc_friends", irc_relations.friends, **kwargs + ) + foes = IrcRelationController.build( + "irc_foes", irc_relations.foes, **kwargs + ) + chatterboxes = IrcRelationController.build( + "irc_chatterboxes", irc_relations.chatterboxes, **kwargs + ) + return cls(friends, foes, chatterboxes) + + +class FafFriendFoeController: + def __init__(self, friends, foes, chatterboxes): + self.friends = friends + self.foes = foes + self.chatterboxes = chatterboxes + + @classmethod + def build(cls, faf_relations, **kwargs): + friends = FafRelationController.build( + "friends", "friend", faf_relations.friends, **kwargs + ) + foes = FafRelationController.build( + "foes", "foe", faf_relations.foes, **kwargs + ) + chatterboxes = IrcRelationController.build( + "chatterboxes", faf_relations.chatterboxes, **kwargs + ) + return cls(friends, foes, chatterboxes) + + +class UserRelationController: + def __init__(self, player_controller, irc_controller): + self.faf = player_controller + self.irc = irc_controller + + @classmethod + def build(cls, user_relations, **kwargs): + player_controller = FafFriendFoeController.build( + user_relations.faf, **kwargs + ) + irc_controller = IrcFriendFoeController.build( + user_relations.irc, **kwargs + ) + return cls(player_controller, irc_controller) + + +class UserRelationship(QObject): + """ + Used to notify about relationship changes of a particular user. + For now we need it only to update views, so a single 'update' signal is + enough. + """ + updated = pyqtSignal() + + def __init__(self): + QObject.__init__(self) + + +class RelationshipTracker(QObject): + """ + This class listens to relationship change events and distributes them among + objects corresponding to particular chatters / players. This is done so + that a single relationship change does not trigger 1k chatter view slots. + It also reports any updates to any of the items. + """ + updated = pyqtSignal(object) + + def __init__(self, item_set): + QObject.__init__(self) + self._item_set = item_set + self._item_set.removed.connect(self._at_item_removed) + self._trackers = {} + + # Since users of this class might listen to addition and removal of + # chatters or players and the add / remove signal slots are + # executed in an unspecified order, we can't just create trackers + # at an add signal - we have to do it on-demand. + def __getitem__(self, key): + if key not in self._trackers: + if key not in self._item_set: + raise KeyError + self._trackers[key] = self._create_tracker(key) + return self._trackers[key] + + def _create_tracker(self, key): + return UserRelationship() + + def _at_item_removed(self, item): + if item.id_key in self._trackers: + del self._trackers[item.id_key] + + def _at_relation_updated(self, key): + tracker = self._trackers.get(key, None) + if tracker is None: + return + tracker.updated.emit() + self.updated.emit(key) + + +class FriendFoeTracker(RelationshipTracker): + def __init__(self, friendfoes, item_set): + RelationshipTracker.__init__(self, item_set) + self._friendfoes = friendfoes + for s in [ + friendfoes.friends, + friendfoes.foes, + friendfoes.chatterboxes, + ]: + for sig in [s.added, s.removed]: + sig.connect(self._at_relation_updated) + + @classmethod + def build_for_players(cls, friendfoes, playerset, **kwargs): + return cls(friendfoes, playerset) + + @classmethod + def build_for_chatters(cls, friendfoes, chatterset, **kwargs): + return cls(friendfoes, chatterset) + + +class UserRelationTrackers: + def __init__(self, chatter_tracker, player_tracker): + self.chatters = chatter_tracker + self.players = player_tracker + + @classmethod + def build(cls, relation_model, **kwargs): + chatter_tracker = FriendFoeTracker.build_for_chatters( + relation_model.irc, **kwargs + ) + player_tracker = FriendFoeTracker.build_for_players( + relation_model.faf, **kwargs + ) + return cls(chatter_tracker, player_tracker) + + +class UserRelations: + def __init__(self, model, controller, trackers): + self.model = model + self.controller = controller + self.trackers = trackers diff --git a/src/config/__init__.py b/src/config/__init__.py index 1530a85a6..258749839 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -1,21 +1,39 @@ -from . import version +import faulthandler +import locale +import logging import os import sys -import logging -import fafpath import traceback -from PyQt5 import QtCore -from logging.handlers import RotatingFileHandler, MemoryHandler +from logging.handlers import MemoryHandler +from logging.handlers import RotatingFileHandler + +from PyQt6 import QtCore + +import fafpath +from config import version +from config.develop import default_values as develop_defaults +from config.production import default_values as production_defaults +from config.testing import default_values as testing_defaults if sys.platform == 'win32': + import ctypes + import win32api import win32con import win32security + from . import admin -_settings = QtCore.QSettings(QtCore.QSettings.IniFormat, QtCore.QSettings.UserScope, "ForgedAllianceForever", "FA Lobby") +_settings = QtCore.QSettings( + QtCore.QSettings.Format.IniFormat, + QtCore.QSettings.Scope.UserScope, + "ForgedAllianceForever", + "FA Lobby", +) _unpersisted_settings = {} +CONFIG_PATH = os.path.dirname(_settings.fileName()) + class Settings: """ @@ -52,7 +70,12 @@ def remove(key): _settings.remove(key) @staticmethod - def persisted_property(key, default_value=None, persist_if=lambda self: True, type=str): + def persisted_property( + key, + default_value=None, + persist_if=lambda self: True, + type=str, + ): """ Create a magically persisted property @@ -63,22 +86,41 @@ def persisted_property(key, default_value=None, persist_if=lambda self: True, ty :param type: Type of values for persisting :return: a property suitable for a class """ - return property(lambda s: Settings.get(key, default=default_value, type=type), - lambda s, v: Settings.set(key, v, persist=persist_if(s)), - doc='Persisted property: {}. Default: '.format(key, default_value)) + return property( + lambda s: Settings.get(key, default=default_value, type=type), + lambda s, v: Settings.set(key, v, persist=persist_if(s)), + doc='Persisted property: {}. Default: {}'.format( + key, default_value, + ), + ) @staticmethod def sync(): _settings.sync() + @staticmethod + def fileName(): + return _settings.fileName() + + @staticmethod + def contains(key): + return key in _unpersisted_settings or _settings.contains(key) + def set_data_path_permissions(): """ - Set the owner of C:\ProgramData\FAForever recursively to the current user + Set the owner of C:\\ProgramData\\FAForever recursively to the current user """ if not admin.isUserAdmin(): - win32api.MessageBox(0, "FA Forever needs to fix folder permissions due to user change. Please confirm the following two admin prompts.", "User changed") - if sys.platform == 'win32' and (not 'CI' in os.environ): + win32api.MessageBox( + 0, + ( + "FA Forever needs to fix folder permissions due to user " + "change. Please confirm the following two admin prompts." + ), + "User changed", + ) + if sys.platform == 'win32' and ('CI' not in os.environ): data_path = Settings.get('client/data_path') if os.path.exists(data_path): my_user = win32api.GetUserNameEx(win32con.NameSamCompatible) @@ -88,37 +130,49 @@ def set_data_path_permissions(): def check_data_path_permissions(): """ - Checks if the current user is owner of C:\ProgramData\FAForever + Checks if the current user is owner of C:\\ProgramData\\FAForever Fixes the permissions in case that FAF was run as different user before """ - if sys.platform == 'win32' and (not 'CI' in os.environ): + if sys.platform == 'win32' and ('CI' not in os.environ): data_path = Settings.get('client/data_path') if os.path.exists(data_path): try: my_user = win32api.GetUserNameEx(win32con.NameSamCompatible) - sd = win32security.GetFileSecurity(data_path, win32security.OWNER_SECURITY_INFORMATION) + sd = win32security.GetFileSecurity( + data_path, win32security.OWNER_SECURITY_INFORMATION, + ) owner_sid = sd.GetSecurityDescriptorOwner() - name, domain, type = win32security.LookupAccountSid(None, owner_sid) - data_path_owner = "%s\\%s" % (domain, name) + name, domain, type = win32security.LookupAccountSid( + None, owner_sid, + ) + data_path_owner = "{}\\{}".format(domain, name) if my_user != data_path_owner: set_data_path_permissions() - except Exception as e: - # we encountered error 1332 in win32security.LookupAccountSid here: http://forums.faforever.com/viewtopic.php?f=3&t=13728 - # https://msdn.microsoft.com/en-us/library/windows/desktop/aa379166(v=vs.85).aspx states: - # "It also occurs for SIDs that have no corresponding account name, such as a logon SID that identifies a logon session." - # so let's just fix permissions on every exception for now and wait for someone stuck in a permission-loop - win32api.MessageBox(0, - "FA Forever ran into an exception checking the data folder permissions: '{}'\n" - "If you get this popup more than one time, please report a screenshot of this popup to tech support forum. " - "Full stacktrace:\n{}".format(e, traceback.format_exc()), - "Permission check exception") + except BaseException as e: + # we encountered error 1332 in win32security.LookupAccountSid + # here: http://forums.faforever.com/viewtopic.php?f=3&t=13728 + # msdn.microsoft.com/en-us/library/windows/desktop/aa379166.aspx + # states: + # "It also occurs for SIDs that have no corresponding account + # name, such as a logon SID that identifies a logon session." + # so let's just fix permissions on every exception for now and + # wait for someone stuck in a permission-loop + win32api.MessageBox( + 0, + "FA Forever ran into an exception " + "checking the data folder permissions: '{}'\n" + "If you get this popup more than one time, please report " + "a screenshot of this popup to tech support forum. " + "Full stacktrace:\n{}".format(e, traceback.format_exc()), + "Permission check exception", + ) set_data_path_permissions() def make_dirs(): check_data_path_permissions() - for dir in [ + for dir_ in [ 'client/data_path', 'game/logs/path', 'game/bin/path', @@ -126,17 +180,21 @@ def make_dirs(): 'game/engine/path', 'game/maps/path', ]: - path = Settings.get(dir) + path = Settings.get(dir_) if path is None: - raise Exception("Missing configured path for {}".format(dir)) + raise Exception("Missing configured path for {}".format(dir_)) if not os.path.isdir(path): try: os.makedirs(path) - except IOError as e: + except IOError: set_data_path_permissions() os.makedirs(path) -VERSION = version.get_release_version(dir=fafpath.get_resdir(), git_dir=fafpath.get_srcdir()) + +VERSION = version.get_release_version( + dir=fafpath.get_resdir(), + git_dir=fafpath.get_srcdir(), +) def is_development_version(): @@ -152,34 +210,85 @@ def is_development_version(): def is_beta(): return environment == 'development' +# TODO: move stuff below to Settings __init__ once we make it an actual object + + if _settings.contains('client/force_environment'): environment = _settings.value('client/force_environment', 'development') + +class FormatDefault(dict): + def __missing__(self, key: str) -> str: + # if key wasn't formatted leave it to format later + # "{foo}{bar}".format_map(FormatDefault(foo="FOO")) -> "FOO{bar}" + return f"{{{key}}}" + + +for defaults in [production_defaults, develop_defaults, testing_defaults]: + for key, value in defaults.items(): + if isinstance(value, str): + defaults[key] = value.format_map(FormatDefault(host=Settings.get("host"))) + if environment == 'production': - from .production import defaults + defaults = production_defaults elif environment == 'development': - from .develop import defaults + defaults = develop_defaults +elif environment == 'test': + defaults = testing_defaults + + +def os_language(): + # locale is unreliable on Windows + if sys.platform == 'win32': + windll = ctypes.windll.kernel32 + locale_code = windll.GetUserDefaultUILanguage() + os_locale = locale.windows_locale.get(locale_code, None) + else: + os_locale = locale.getlocale()[0] + + # sanity checks + if os_locale is None: + return None + if len(os_locale) < 2: + return None + country = os_locale[:2].lower() + if not country.isalpha(): + return None + return country + + +if not Settings.contains('client/language'): + Settings.set('client/language', os_language()) -for k, v in defaults.items(): - if isinstance(v, str): - defaults[k] = v.format(host = Settings.get('host')) # Setup normal rotating log handler make_dirs() + def setup_file_handler(filename): - # check permissions of writing the log file first (which fails when changing users) + # check permissions of writing the log file first + # (which fails when changing users) log_file = os.path.join(Settings.get('client/logs/path'), filename) try: - with open(log_file, "a") as f: + with open(log_file, "a"): pass - except IOError as e: + except IOError: set_data_path_permissions() - rotate = RotatingFileHandler(os.path.join(Settings.get('client/logs/path'), filename), - maxBytes=int(Settings.get('client/logs/max_size')), - backupCount=1) - rotate.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(name)-30s %(message)s')) - return MemoryHandler(int(Settings.get('client/logs/buffer_size')), target=rotate) + rotate = RotatingFileHandler( + os.path.join(Settings.get('client/logs/path'), filename), + maxBytes=int(Settings.get('client/logs/max_size')), + backupCount=1, + ) + rotate.setFormatter( + logging.Formatter( + '%(asctime)s %(levelname)-8s %(name)-30s %(message)s', + ), + ) + return MemoryHandler( + int(Settings.get('client/logs/buffer_size')), + target=rotate, + ) + client_handler = setup_file_handler('forever.log') @@ -189,8 +298,78 @@ def setup_file_handler(filename): if Settings.get('client/logs/console', False, type=bool): # Setup logging output to console devh = logging.StreamHandler() - devh.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(name)-30s %(message)s')) + devh.setFormatter( + logging.Formatter( + '%(asctime)s %(levelname)-8s %(name)-30s %(message)s', + ), + ) logging.getLogger().addHandler(devh) logging.getLogger().setLevel(Settings.get('client/logs/level', type=int)) -logging.getLogger().info("FAF version: {} Environment: {}".format(VERSION, environment)) +logging.getLogger().info( + "FAF version: {} Environment: {}".format(VERSION, environment), +) + + +def qt_log_handler(type_, context, text): + loglvl = None + if type_ == QtCore.QtMsgType.QtDebugMsg: + loglvl = logging.DEBUG + elif type_ == QtCore.QtMsgType.QtInfoMsg: + loglvl = logging.INFO + elif type_ == QtCore.QtMsgType.QtWarningMsg: + loglvl = logging.WARNING + elif type_ == QtCore.QtMsgType.QtCriticalMsg: + loglvl = logging.ERROR + elif type_ == QtCore.QtMsgType.QtFatalMsg: + loglvl = logging.CRITICAL + if loglvl is None: + return + logging.getLogger().log(loglvl, "Qt: " + text) + + +QtCore.qInstallMessageHandler(qt_log_handler) + +fault_handler_file = None + + +def setup_fault_handler(): + global fault_handler_file + log_path = os.path.join(Settings.get('client/logs/path'), 'crash.log') + try: + max_sz = int(Settings.get('client/logs/max_size')) + rotate = RotatingFileHandler( + log_path, + maxBytes=max_sz, + backupCount=1, + ) + # Rollover does it unconditionally, not looking at max size, + # so we need to check it manually + try: + finfo = os.stat(log_path) + if finfo.st_size > max_sz: + rotate.doRollover() + except FileNotFoundError: + pass + rotate.close() + + # This file must be kept open so that faulthandler can write to the + # same file descriptor no matter the circumstances + fault_handler_file = open(log_path, 'a') + except IOError as e: + logging.getLogger().error( + 'Failed to setup crash.log for the fault handler: ' + e.strerror, + ) + return + + faulthandler.enable(fault_handler_file) + + +setup_fault_handler() + + +def clear_logging_handlers(): + global fault_handler_file + QtCore.qInstallMessageHandler(None) + faulthandler.disable() + fault_handler_file.close() diff --git a/src/config/admin.py b/src/config/admin.py index 7c7967e68..7d6db2b84 100644 --- a/src/config/admin.py +++ b/src/config/admin.py @@ -1,22 +1,26 @@ #!/usr/bin/env python -# -*- coding: utf-8; mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# -*- coding: utf-8; mode: python; py-indent-offset: 4; +# indent-tabs-mode: nil -*- # vim: fileencoding=utf-8 tabstop=4 expandtab shiftwidth=4 # (C) COPYRIGHT © Preston Landers 2010 # Released under the same license as Python 2.6.5 -import sys, os, traceback, types +import os +import sys +import traceback def isUserAdmin(): if os.name == 'nt': import ctypes + # WARNING: requires Windows XP SP2 or higher! try: return ctypes.windll.shell32.IsUserAnAdmin() - except: + except BaseException: traceback.print_exc() print("Admin check failed, assuming not an admin.") return False @@ -24,7 +28,9 @@ def isUserAdmin(): # Check for root on Posix return os.getuid() == 0 else: - raise RuntimeError("Unsupported operating system for this module: %s" % (os.name,)) + raise RuntimeError( + "Unsupported operating system for this module: {}".format(os.name), + ) def runAsAdmin(cmdLine=None, wait=True): @@ -32,22 +38,24 @@ def runAsAdmin(cmdLine=None, wait=True): if os.name != 'nt': raise RuntimeError("This function is only implemented on Windows.") - import win32api, win32con, win32event, win32process - from win32com.shell.shell import ShellExecuteEx + import win32con + import win32event + import win32process from win32com.shell import shellcon + from win32com.shell.shell import ShellExecuteEx python_exe = sys.executable if cmdLine is None: cmdLine = [python_exe] + sys.argv - elif type(cmdLine) not in (tuple,list): + elif type(cmdLine) not in (tuple, list): raise ValueError("cmdLine is not a sequence.") - cmd = '"%s"' % (cmdLine[0],) - # XXX TODO: isn't there a function or something we can call to massage command line params? - params = " ".join(['"%s"' % (x,) for x in cmdLine[1:]]) - cmdDir = '' + cmd = '"{}"'.format(cmdLine[0]) + # XXX TODO: isn't there a function or something we can call to message + # command line params? + params = " ".join(['"{}"'.format(x) for x in cmdLine[1:]]) showCmd = win32con.SW_SHOWNORMAL - #showCmd = win32con.SW_HIDE + # showCmd = win32con.SW_HIDE lpVerb = 'runas' # causes UAC elevation prompt. # print "Running", cmd, params @@ -56,19 +64,19 @@ def runAsAdmin(cmdLine=None, wait=True): # of the process, so we can't get anything useful from it. Therefore # the more complex ShellExecuteEx() must be used. - # procHandle = win32api.ShellExecute(0, lpVerb, cmd, params, cmdDir, showCmd) - - procInfo = ShellExecuteEx(nShow=showCmd, - fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, - lpVerb=lpVerb, - lpFile=cmd, - lpParameters=params) + procInfo = ShellExecuteEx( + nShow=showCmd, + fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, + lpVerb=lpVerb, + lpFile=cmd, + lpParameters=params, + ) if wait: procHandle = procInfo['hProcess'] - obj = win32event.WaitForSingleObject(procHandle, win32event.INFINITE) + win32event.WaitForSingleObject(procHandle, win32event.INFINITE) rc = win32process.GetExitCodeProcess(procHandle) - #print "Process handle %s returned code %s" % (procHandle, rc) + # print("Process handle {} returned code {}".format(procHandle, rc)) else: rc = None @@ -79,12 +87,12 @@ def test(): rc = 0 if not isUserAdmin(): print("You're not an admin.", os.getpid(), "params: ", sys.argv) - #rc = runAsAdmin(["c:\\Windows\\notepad.exe"]) + # rc = runAsAdmin(["c:\\Windows\\notepad.exe"]) rc = runAsAdmin() else: print("You are an admin!", os.getpid(), "params: ", sys.argv) rc = 0 - x = input('Press Enter to exit.') + input('Press Enter to exit.') return rc diff --git a/src/config/develop.py b/src/config/develop.py index b9e2a881b..e2fa0c99e 100644 --- a/src/config/develop.py +++ b/src/config/develop.py @@ -1,6 +1,6 @@ import os -from .production import defaults as production_defaults +from .production import default_values as production_defaults # These directories are in Appdata (e.g. C:\ProgramData on some Win7 versions) if 'ALLUSERSPROFILE' in os.environ: @@ -8,6 +8,6 @@ else: APPDATA_DIR = os.path.join(os.environ['HOME'], "FAForever") -defaults = production_defaults.copy() -defaults['host'] = 'test.faforever.com' -defaults['client/logs/console'] = True +default_values = production_defaults.copy() +default_values['host'] = 'test.faforever.com' +default_values['client/logs/console'] = True diff --git a/src/config/production.py b/src/config/production.py index 2eaef5bf0..4c8b74e4d 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -1,8 +1,7 @@ +import logging from os import environ from os.path import join -import logging - # These directories are in Appdata (e.g. C:\ProgramData on some Win7 versions) if 'ALLUSERSPROFILE' in environ: APPDATA_DIR = join(environ['ALLUSERSPROFILE'], "FAForever") @@ -10,44 +9,64 @@ APPDATA_DIR = join(environ['HOME'], "FAForever") -defaults = { +default_values = { + 'display_name': 'Main Server (recommended)', + 'api': 'https://api.{host}', + 'user_api': 'https://user.{host}', + 'chat/host': 'chat.{host}', + 'chat/port': 443, 'client/data_path': APPDATA_DIR, 'client/logs/path': join(APPDATA_DIR, 'logs'), 'client/logs/level': logging.INFO, - 'client/logs/max_size': 512*1024, - 'client/logs/buffer_size': 8*1024, + 'client/logs/max_size': 512 * 1024, + 'client/logs/buffer_size': 8 * 1024, 'client/logs/console': False, - 'content/host': 'http://content.{host}', + 'content/host': 'https://content.{host}', 'chat/enabled': True, 'game/bin/path': join(APPDATA_DIR, 'bin'), 'game/engine/path': join(join(APPDATA_DIR, 'repo'), 'binary-patch'), 'game/logs/path': join(APPDATA_DIR, 'logs'), 'game/mods/path': join(join(APPDATA_DIR, 'repo'), 'mods'), 'game/maps/path': join(join(APPDATA_DIR, 'repo'), 'maps'), + 'game/exe-url': 'https://content.{host}/faf/updaterNew/updates_faf_files/ForgedAlliance.exe', + 'game/exe-name': "ForgedAlliance.exe", 'host': 'faforever.com', 'proxy/host': 'proxy.{host}', 'proxy/port': 9124, - 'lobby/relay/port': 15000, 'lobby/host': 'lobby.{host}', - 'lobby/port': 8001, + 'lobby/port': 8002, + 'updater/host': 'lobby.{host}', 'mordor/host': 'http://mordor.{host}', + 'news/host': 'https://direct.{host}', 'turn/host': '{host}', 'turn/port': 3478, + 'oauth/client_id': 'faf-python-client', + 'oauth/host': "https://hydra.{host}", + 'oauth/redirect_uri': "http://localhost", + 'oauth/scope': ["openid", "offline", "public_profile", "lobby"], + 'oauth/token': None, + 'oauth/auth_endpoint': '/oauth2/auth', + 'oauth/token_endpoint': '/oauth2/token', + 'replay_vault/host': 'https://replay.{host}', 'replay_server/host': 'lobby.{host}', 'replay_server/port': 15000, 'relay_server/host': 'lobby.{host}', 'relay_server/port': 8000, - 'FORUMS_URL': 'http://forums.faforever.com/', - 'WEBSITE_URL': 'http://www.faforever.com', - 'UNITDB_URL': 'http://direct.faforever.com/faf/unitsDB/', - 'MAPPOOL_URL': 'http://forums.faforever.com/viewtopic.php?f=2&t=13742', - 'GITHUB_URL': 'http://www.github.com/FAForever', - 'WIKI_URL': 'http://wiki.faforever.com', - 'SUPPORT_URL': 'http://forums.faforever.com/viewforum.php?f=3', - 'TICKET_URL': 'http://forums.faforever.com/viewforum.php?f=3', + 'vault/map_preview_url': 'https://content.{host}/maps/previews/{size}/{name}.png', + 'vault/map_download_url': "https://content.{host}/maps/{name}.zip", + 'FORUMS_URL': 'https://forums.faforever.com/', + 'WEBSITE_URL': 'https://www.{host}', + # FIXME - temporary address below + # The base64 settings string disables expensive loading of all previews + 'UNITDB_URL': 'https://unitdb.faforever.com?settings64=eyJwcmV2aWV3Q29ybmVyIjoiTm9uZSJ9', + 'UNITDB_SPOOKY_URL': 'https://spooky.github.io/unitdb/', + 'MAPPOOL_URL': 'https://forum.faforever.com/topic/148/matchmaker-pools-thread', + 'GITHUB_URL': 'https://www.github.com/FAForever', + 'WIKI_URL': 'https://wiki.faforever.com', + 'SUPPORT_URL': 'https://forum.faforever.com/category/9/faf-support-client-and-account-issues', + 'TICKET_URL': 'https://forum.faforever.com/category/9/faf-support-client-and-account-issues', 'CREATE_ACCOUNT_URL': 'https://faforever.com/account/register', 'STEAMLINK_URL': 'https://faforever.com/account/link', 'PASSWORD_RECOVERY_URL': 'https://faforever.com/account/password/reset', 'NAME_CHANGE_URL': 'https://faforever.com/account/username/change', - 'USER_ALIASES_URL': 'http://app.faforever.com/faf/userName.php', } diff --git a/src/config/testing.py b/src/config/testing.py new file mode 100644 index 000000000..916f9972b --- /dev/null +++ b/src/config/testing.py @@ -0,0 +1,6 @@ +from .production import default_values as production_defaults + +default_values = production_defaults.copy() +default_values['display_name'] = 'Test Server' +default_values['host'] = 'faforever.xyz' +default_values['oauth/client_id'] = 'faf-java-client' diff --git a/src/config/version.py b/src/config/version.py index eb13fbe3d..b58ebc423 100644 --- a/src/config/version.py +++ b/src/config/version.py @@ -30,13 +30,17 @@ # Note that the RELEASE-VERSION file should *not* be checked into git; # please add it to your top-level .gitignore file. -from subprocess import check_output -import sys import os +import sys +from subprocess import check_output + from semantic_version import Version -__all__ = ["is_development_version", "is_prerelease_version", - "get_git_version", "build_version", "get_release_version", "write_version_file"] +__all__ = [ + "is_development_version", "is_prerelease_version", + "get_git_version", "build_version", "get_release_version", + "write_version_file", +] def is_development_version(version): @@ -70,7 +74,7 @@ def read_version_file(dir): def write_version_file(version, dir): with open(version_filename(dir), "w") as f: - f.write("%s\n" % version) + f.write("{}\n".format(version)) def get_git_version(git_dir=None): @@ -95,15 +99,18 @@ def get_cmd_line(cmd): return tag, commit_tag - except Exception as e: + except BaseException as e: sys.stderr.write("Error grabbing git version: {}".format(e)) return None def build_version(version, revision, build=None): - return version + '+' + \ - (revision + '.' if revision else '') + \ - (build if build else '') + return ( + version + + '+' + + (revision + '.' if revision else '') + + (build if build else '') + ) # Distutils expect an x.y.z (non-semver) format @@ -126,7 +133,10 @@ def get_release_version(dir=None, git_dir=None): else: # If we still don't have anything, that's an error. sys.stderr.write("Could not get git version" + os.linesep) - raise ValueError("Cannot find the version number! Please provide RELEASE-VERSION file or run from git.") + raise ValueError( + "Cannot find the version number! Please provide " + "RELEASE-VERSION file or run from git.", + ) if __name__ == "__main__": diff --git a/src/connectivity/ConnectivityDialog.py b/src/connectivity/ConnectivityDialog.py new file mode 100644 index 000000000..2c3aad6fa --- /dev/null +++ b/src/connectivity/ConnectivityDialog.py @@ -0,0 +1,163 @@ +import pprint + +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QHeaderView +from PyQt6.QtWidgets import QInputDialog +from PyQt6.QtWidgets import QTableWidgetItem + +import client as clientwindow +from connectivity.IceAdapterClient import IceAdapterClient +from decorators import with_logger +from util import THEME + + +@with_logger +class ConnectivityDialog(object): + COLUMN_ID = 0 + COLUMN_LOGIN = 1 + COLUMN_CONNECTED = 2 + COLUMN_LOCAL = 3 + COLUMN_REMOTE = 4 + COLUMN_ICESTATE = 5 + COLUMN_LOCALOFFER = 6 + COLUMN_TIMETOCONNECTED = 7 + + columnCount = 8 + + def __init__(self, ice_adapter_client: IceAdapterClient) -> None: + self.client = ice_adapter_client + self.client.statusChanged.connect(self.onStatus) + self.client.gpgnetmessageReceived.connect(self.onGpgnetMessage) + + self.dialog = THEME.loadUi('connectivity/connectivity.ui') + # need to set the parent like this to make sure this dialog closes on + # closing the client. also needed for consistent theming + self.dialog.setParent(clientwindow.instance, Qt.WindowType.Dialog) + + # the table header needs theming, + # and using "QHeaderView::section { background-color: green; }" + # in client.css didn't work for horizontal headers + stylesheet = """ + ::section{ + color:silver; + background-color: #606060; + border: none; + } + """ + self.dialog.table_relays.horizontalHeader().setStyleSheet(stylesheet) + self.dialog.table_relays.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Stretch, + ) + self.dialog.table_relays.horizontalHeader().setFixedHeight(30) + self.dialog.table_relays.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + self.dialog.table_relays.verticalHeader().hide() + + self.dialog.finished.connect(self.close) + + self.statusTimer = QTimer() + self.statusTimer.timeout.connect(self.getStatus) + self.statusTimer.start(2000) + + self.status = None + self.dialog.pushButton_debugState.clicked.connect(self.showDebugState) + + def show(self): + self.dialog.show() + self.getStatus() + + def close(self): + self.dialog.close() + + def showDebugState(self): + if self.status: + QInputDialog.getMultiLineText( + self.dialog, + "ICE adapter state", + "", + pprint.pformat(self.status, width=-1), + ) + + def getStatus(self): + if self.client.isConnected(): + self.client.call("status", callback_result=self.client.onStatus) + + def onGpgnetMessage(self, *unused): + self.getStatus() + + def onStatus(self, status): + self.status = status + self.dialog.label_version.setText(str(status["version"])) + self.dialog.label_user.setText( + "{} ({})".format( + status["options"]["player_login"], + status["options"]["player_id"], + ), + ) + self.dialog.label_rpc_port.setText(str(status["options"]["rpc_port"])) + self.dialog.label_gpgnet_port.setText( + str(status["options"]["gpgnet_port"]), + ) + self.dialog.label_lobby_port.setText(str(status["lobby_port"])) + + if "log_file" in status["options"]: + self.dialog.label_log_file.setText( + str(status["options"]["log_file"]), + ) + else: + self.dialog.label_log_file.setText("") + + self.dialog.label_connected.setText(str(status["gpgnet"]["connected"])) + self.dialog.label_gamestate.setText( + str(status["gpgnet"]["game_state"]), + ) + + self.dialog.label_mode.setText(str(status["gpgnet"]["task_string"])) + + self.dialog.table_relays.setRowCount(len(status["relays"])) + for row, relay in enumerate(status["relays"]): + self.dialog.table_relays.setItem( + row, + self.COLUMN_ID, + self.tableItem(str(relay["remote_player_id"])), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_LOGIN, + self.tableItem(relay["remote_player_login"]), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_CONNECTED, + self.tableItem(str(relay["ice"]["connected"])), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_LOCAL, + self.tableItem(relay["ice"]["loc_cand_type"]), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_REMOTE, + self.tableItem(relay["ice"]["rem_cand_type"]), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_ICESTATE, + self.tableItem(relay["ice"]["state"]), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_LOCALOFFER, + self.tableItem(str(relay["ice"]["offerer"])), + ) + self.dialog.table_relays.setItem( + row, + self.COLUMN_TIMETOCONNECTED, + self.tableItem(str(relay["ice"]["time_to_connected"])), + ) + + def tableItem(self, data): + item = QTableWidgetItem(str(data)) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + return item diff --git a/src/connectivity/IceAdapterClient.py b/src/connectivity/IceAdapterClient.py new file mode 100644 index 000000000..b51e6b4e0 --- /dev/null +++ b/src/connectivity/IceAdapterClient.py @@ -0,0 +1,91 @@ +import json + +from PyQt6.QtCore import pyqtSignal + +import client +from client.connection import ConnectionState +from connectivity.JsonRpcTcpClient import JsonRpcTcpClient +from decorators import with_logger + + +@with_logger +class IceAdapterClient(JsonRpcTcpClient): + + statusChanged = pyqtSignal(dict) + gpgnetmessageReceived = pyqtSignal(str, list) + + def __init__(self, game_session): + JsonRpcTcpClient.__init__(self, request_handler_instance=self) + self.connected = False + self.game_session = game_session + self.socket.connected.connect(self.onSocketConnected) + self.iceMsgCache = [] + client.instance.lobby_connection.connected.connect( + self.onLobbyConnected, + ) + + def onIceMsg(self, localId, remoteId, iceMsg): + self._logger.debug( + "onIceMsg {} {} {}".format( + localId, remoteId, + iceMsg, + ), + ) + if client.instance.lobby_connection.state == ConnectionState.CONNECTED: + self.game_session.send("IceMsg", [remoteId, iceMsg]) + elif isinstance(iceMsg, dict) and "type" in iceMsg: + if iceMsg["type"] != "candidate": + self.iceMsgCache.clear() + self.iceMsgCache.append((remoteId, iceMsg)) + self._logger.debug( + "lobby disconnected, caching ICE message {}" + .format(len(self.iceMsgCache)), + ) + + def onConnectionStateChanged(self, newState): + self._logger.debug("onConnectionStateChanged {}".format(newState)) + if self.game_session and newState == "Connected": + self.game_session._new_game_connection() + self.call("status", callback_result=self.onStatus) + + def onGpgNetMessageReceived(self, header, chunks): + self._logger.debug( + "onGpgNetMessageReceived {} {}".format(header, chunks), + ) + self.game_session._on_game_message(header, chunks) + self.gpgnetmessageReceived.emit(header, chunks) + + def onIceConnectionStateChanged(self, *unused): + self.call("status", callback_result=self.onStatus) + + def onSocketConnected(self): + self._logger.debug("connected to ice-adapter") + self.connected = True + self.call("status", callback_result=self.onStatus) + + def onConnected(self, localId, remoteId, connected): + if connected: + self._logger.debug( + "ice-adapter connected to player {}".format(remoteId), + ) + else: + self._logger.debug( + "ice-adapter disconnected from player {}".format(remoteId), + ) + self.call("status", callback_result=self.onStatus) + + def onStatus(self, status): + if isinstance(status, str): + status = json.loads(status) + if "gpgpnet" in status: # issue in current java-ice-adapter + status["gpgnet"] = status["gpgpnet"] + self.statusChanged.emit(status) + + def onLobbyConnected(self): + if len(self.iceMsgCache) > 0: + self._logger.debug( + "sending {} cached ICE messages".format(len(self.iceMsgCache)), + ) + for remoteId, iceMsg in self.iceMsgCache: + self.game_session.send("IceMsg", [remoteId, iceMsg]) + self.iceMsgCache.clear() diff --git a/src/connectivity/IceAdapterProcess.py b/src/connectivity/IceAdapterProcess.py new file mode 100644 index 000000000..d513aeefa --- /dev/null +++ b/src/connectivity/IceAdapterProcess.py @@ -0,0 +1,132 @@ +import os +import sys + +from PyQt6.QtCore import QProcess +from PyQt6.QtCore import QProcessEnvironment +from PyQt6.QtNetwork import QHostAddress +from PyQt6.QtNetwork import QTcpServer +from PyQt6.QtWidgets import QMessageBox + +import fafpath +from config import Settings +from decorators import with_logger + + +@with_logger +class IceAdapterProcess(object): + def __init__(self, player_id: int, player_login: str, game_id: int) -> None: + + # determine free listen port for the RPC server inside the ice adapter + # process + s = QTcpServer() + s.listen(QHostAddress.SpecialAddress.LocalHost, 0) + self._rpc_server_port = s.serverPort() + s.close() + + if sys.platform == 'win32': + exe_path = fafpath.get_java_path() + args = [ + "-jar", os.path.join(fafpath.get_libdir(), "ice-adapter", "faf-ice-adapter.jar"), + ] + else: # Expect it to be in PATH already + exe_path = "faf-ice-adapter" + args = [] + show_adapter_window = Settings.get( + "iceadapter/info_window", default=False, type=bool, + ) + delay_adapter_ui = 1000 * Settings.get( + "iceadapter/delay_ui_seconds", default=10, type=int, + ) + self.ice_adapter_process = QProcess() + args.extend([ + "--id", str(player_id), + "--login", player_login, + "--game-id", str(game_id), + "--rpc-port", str(self._rpc_server_port), + ]) + if show_adapter_window: + args.extend(["--info-window", "--delay-ui", str(delay_adapter_ui)]) + if Settings.contains('iceadapter/args'): + args.extend( + Settings.get('iceadapter/args', "", type=str).split(" "), + ) + + self._logger.debug( + "running ice adapter with {} {}".format(exe_path, " ".join(args)), + ) + + # set log directory via ENV + env = QProcessEnvironment.systemEnvironment() + env.insert( + "LOG_DIR", + os.path.join( + Settings.get('client/logs/path', type=str), 'iceAdapterLogs', + ), + ) + self.ice_adapter_process.setProcessEnvironment(env) + + self.ice_adapter_process.start(exe_path, args) + + # wait for the first message which usually means the ICE adapter is + # listening for JSONRPC connections + if not self.ice_adapter_process.waitForStarted(5000): + self._logger.error("error starting the ice adapter process") + QMessageBox.critical( + None, + "ICE adapter error", + "The ICE adapter did not start. Please refaf.", + ) + + self.ice_adapter_process.readyReadStandardOutput.connect( + self.on_log_ready, + ) + self.ice_adapter_process.readyReadStandardError.connect( + self.on_error_ready, + ) + self.ice_adapter_process.finished.connect(self.on_exit) + + def on_log_ready(self): + standard_output = str(self.ice_adapter_process.readAllStandardOutput()) + for line in standard_output.splitlines(): + self._logger.debug("ICE: " + line) + + def on_error_ready(self): + standard_error = str(self.ice_adapter_process.readAllStandardError()) + for line in standard_error.splitlines(): + self._logger.debug("ICEERROR: " + line) + + def on_exit(self, code: int, status: QProcess.ExitStatus) -> None: + if status == QProcess.ExitStatus.CrashExit: + self._logger.error("the ICE crashed") + QMessageBox.critical( + None, "ICE adapter error", + "The ICE adapter crashed. Please refaf.", + ) + return + if code != 0: + self._logger.error("The ICE adapter closed with error code", code) + QMessageBox.critical( + None, + "ICE adapter error", + ( + "The ICE adapter closed with error code {}. Please refaf." + .format(code) + ), + ) + return + else: + self._logger.debug("The ICE adapter closed with exit code 0") + + def rpc_port(self): + return self._rpc_server_port + + def close(self): + if self.ice_adapter_process.state() == QProcess.ProcessState.Running: + self._logger.info("Waiting for ice adapter process shutdown") + if not self.ice_adapter_process.waitForFinished(1000): + if self.ice_adapter_process.state() == QProcess.ProcessState.Running: + self._logger.error("Terminating ice adapter process") + self.ice_adapter_process.terminate() + if not self.ice_adapter_process.waitForFinished(1000): + self._logger.error("Killing ice adapter process") + self.ice_adapter_process.kill() diff --git a/src/connectivity/IceServersPoller.py b/src/connectivity/IceServersPoller.py new file mode 100644 index 000000000..1e10eb96c --- /dev/null +++ b/src/connectivity/IceServersPoller.py @@ -0,0 +1,38 @@ +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +from api.ApiAccessors import ApiAccessor +from connectivity.IceAdapterClient import IceAdapterClient +from decorators import with_logger + + +@with_logger +class IceServersPoller(QObject): + ice_servers_received = pyqtSignal(list) + + def __init__(self, ice_adapter_client: IceAdapterClient, game_uid: int) -> None: + QObject.__init__(self) + self._ice_adapter_client = ice_adapter_client + self._game_uid = game_uid + + self.ice_servers_received.connect(self.set_ice_servers) + + self._api_accessor = ApiAccessor() + self.request_ice_servers() + + def request_ice_servers(self) -> None: + self._api_accessor.get_by_endpoint( + f"/ice/session/game/{self._game_uid}", + self.handle_ice_servers, + ) + + def handle_ice_servers(self, message: dict) -> None: + servers = message["servers"] + self.ice_servers_received.emit(servers) + + def set_ice_servers(self, servers: list[dict]) -> None: + if self._ice_adapter_client.connected: + self._logger.debug(f"Settings IceServers to: {servers}") + self._ice_adapter_client.call("setIceServers", [servers]) + else: + self._logger.warn("ICE servers received, but not connected to ice-adapter") diff --git a/src/connectivity/JsonRpcTcpClient.py b/src/connectivity/JsonRpcTcpClient.py new file mode 100644 index 000000000..bf4f86858 --- /dev/null +++ b/src/connectivity/JsonRpcTcpClient.py @@ -0,0 +1,187 @@ +import json + +from PyQt6 import QtCore +from PyQt6.QtCore import QObject +from PyQt6.QtNetwork import QAbstractSocket +from PyQt6.QtNetwork import QTcpSocket + +from decorators import with_logger + + +@with_logger +class JsonRpcTcpClient(QObject): + def __init__(self, request_handler_instance): + QObject.__init__(self) + self.socket = QTcpSocket(self) + self.connectionAttempts = 1 + self.socket.readyRead.connect(self.onData) + self.socket.errorOccurred.connect(self.onSocketError) + self.request_handler_instance = request_handler_instance + self.nextid = 1 + self.callbacks_result = {} + self.callbacks_error = {} + self.buffer = b'' + + def connect_(self, host, port, blocking=False): + self.host = host + self.port = port + self.socket.connectToHost(host, port) + if blocking: + self.socket.waitForConnected(5000) + + def isConnected(self): + return self.socket.state() == QAbstractSocket.SocketState.ConnectedState + + @QtCore.pyqtSlot(QAbstractSocket.SocketError) + def onSocketError(self, error): + if (error == QAbstractSocket.SocketError.ConnectionRefusedError): + self.socket.connectToHost(self.host, self.port) + self.connectionAttempts += 1 + # self._logger.info("Reconnecting to JSONRPC server {}" + # .format(self.connectionAttempts)) + else: + raise RuntimeError( + "Connection error to JSON RPC server: {} ({})" + .format(self.socket.errorString(), error), + ) + + def close(self): + self.socket.close() + + def parseRequest(self, request): + try: + m = getattr(self.request_handler_instance, request["method"]) + if "params" in request and len(request["params"]) > 0: + result = m(*request["params"]) + else: + result = m() + + # we do not only have a notification, + # but a request which awaits a response + if "id" in request: + responseObject = { + "id": request["id"], + "result": result, + "jsonrpc": "2.0", + } + self.socket.write( + json.dumps(responseObject).encode('utf8') + b'\n', + ) + except AttributeError: + if "id" in request: + responseObject = { + "id": request["id"], + "error": "no such method", + "jsonrpc": "2.0", + } + self.socket.write( + json.dumps(responseObject).encode('utf8') + b'\n', + ) + + def parseResponse(self, response): + if "error" in response: + self._logger.error("response error {}".format(response)) + if "id" in response: + if response["id"] in self.callbacks_error: + self.callbacks_error[response["id"]](response["error"]) + elif "result" in response: + if "id" in response: + if response["id"] in self.callbacks_result: + self.callbacks_result[response["id"]](response["result"]) + if "id" in response: + self.callbacks_error.pop(response["id"], None) + self.callbacks_result.pop(response["id"], None) + + @QtCore.pyqtSlot() + def onData(self): + newData = b'' + while self.socket.bytesAvailable(): + newData += bytes(self.socket.readAll()) + + # this seems to be a new notification, which invalidates out buffer. + # This may happen on malformed JSON data + if newData.startswith(b"{\"jsonrpc\":\"2.0\""): + if len(self.buffer) > 0: + self._logger.error( + "parse error: discarding old possibly " + "malformed buffer data {}".format(self.buffer), + ) + self.buffer = newData + else: + self.buffer += newData + self.buffer = self.processBuffer(self.buffer.strip()) + + # from https://github.com/joncol/jcon-cpp/blob/master/src/jcon/ + # json_rpc_endpoint.cpp#L107 + def processBuffer(self, buf): + if len(buf) == 0: + return b'' + if not buf.startswith(b'{'): + self._logger.error( + "parse error: buffer expected to start: {}".format(buf), + ) + return b'' + in_string = False + brace_nesting_level = 0 + for i, c in enumerate(buf): + if c == ord('"'): + in_string = not in_string + + if not in_string: + if c == ord('{'): + brace_nesting_level += 1 + if c == ord('}'): + brace_nesting_level -= 1 + if brace_nesting_level < 0: + self._logger.error( + "parse error: brace_nesting_level " + "< 0: {}".format(buf), + ) + return b'' + if brace_nesting_level == 0: + complete_json_buf = buf[:i + 1] + remaining_buf = buf[i + 1:] + try: + request = json.loads( + complete_json_buf.decode('utf-8'), + ) + except ValueError: + self._logger.error( + "json.loads failed for {}" + .format(complete_json_buf), + ) + return b'' + # is this a request? + if "method" in request: + self.parseRequest(request) + # this is only a response + else: + self.parseResponse(request) + return self.processBuffer(remaining_buf.strip()) + return buf + + def call( + self, + method, + args=[], + callback_result=None, + callback_error=None, + blocking=False, + ): + if self.socket.state() != QAbstractSocket.SocketState.ConnectedState: + raise RuntimeError("Not connected to the JSONRPC server.") + rpcObject = { + "method": method, + "params": args, + "jsonrpc": "2.0", + } + if callback_result: + rpcObject["id"] = self.nextid + self.callbacks_result[self.nextid] = callback_result + if callback_error: + self.callbacks_error[self.nextid] = callback_error + self.nextid += 1 + self._logger.debug("sending JSONRPC object {}".format(rpcObject)) + self.socket.write(json.dumps(rpcObject).encode('utf8') + b'\n') + if blocking: + self.socket.waitForBytesWritten() diff --git a/src/connectivity/__init__.py b/src/connectivity/__init__.py index 84e1c5040..e69de29bb 100644 --- a/src/connectivity/__init__.py +++ b/src/connectivity/__init__.py @@ -1,42 +0,0 @@ -import logging - -from PyQt5.QtCore import QObject - -import util - -from .qturnsocket import QTurnSocket -from .helper import ConnectivityHelper - -logger = logging.getLogger(__name__) - - -class ConnectivityDialog(QObject): - def __init__(self, connectivity): - QObject.__init__(self) - self.connectivity = connectivity - self.dialog = util.THEME.loadUi('connectivity/connectivity.ui') - self.dialog.runTestButton.clicked.connect(self.run_relay_test) - - def update_relay_info(self): - if self.connectivity.relay_address: - self.dialog.relay_test_label.setText("{}:{}".format(*self.connectivity.relay_address)) - - def run_relay_test(self): - self.dialog.runTestButton.setEnabled(False) - - self.connectivity.start_relay_test() - self.connectivity.relay_bound.connect(self.update_relay_info) - self.connectivity.relay_test_finished.connect(self.end) - self.connectivity.relay_test_progress.connect(self.report_relay_test) - - def report_relay_test(self, text): - self.dialog.relay_test_label.setText(text) - - def end(self): - self.dialog.runTestButton.setEnabled(True) - - def exec_(self): - self.dialog.test_result_label.setText( - "State: {}. Resolved address: {}:{}".format(self.connectivity.state, *self.connectivity.mapped_address) - ) - self.dialog.exec_() diff --git a/src/connectivity/helper.py b/src/connectivity/helper.py deleted file mode 100644 index c96f3c8c9..000000000 --- a/src/connectivity/helper.py +++ /dev/null @@ -1,239 +0,0 @@ - -from functools import partial - -from PyQt5.QtCore import QObject, pyqtSignal, QTimer, Qt -from PyQt5.QtNetwork import QUdpSocket, QHostAddress, QAbstractSocket -import time - -from connectivity import QTurnSocket -from connectivity.relay import Relay -from connectivity.turn import TURNState -from decorators import with_logger - -from PyQt5 import QtWidgets, uic - - -@with_logger -class RelayTest(QObject): - finished = pyqtSignal() - progress = pyqtSignal(str) - - def __init__(self, socket): - QObject.__init__(self) - self._socket = socket - self.start_time, self.end_time = None, None - self.addr = None - self.received = set() - self._sent, self._total = 0, 250 - self.host, self.port = None, None - self._sendtimer = QTimer() - self._sendtimer.timeout.connect(self.send) - - def start_relay_test(self, address): - self.addr = address - self._logger.info("Starting relay test") - self._socket.data.connect(self.receive) - self._socket.permit(self.addr) - - self.start_time, self.end_time = time.time(), None - host, port = self.addr - self.host, self.port = QHostAddress(host), port - - self._sent = 0 - self.received = set() - self._sendtimer.start(20) - - end_timer = QTimer() - end_timer.singleShot(10000, self.end) - - @property - def report(self): - return "Relay address: {}\nReceived {} packets in {}s. {}% loss.". \ - format("{}:{}".format(*self.addr), - len(self.received), - round((time.time()-self.start_time), 2), - round(100-(len(self.received)/self._sent) * 100), 2) - - def send(self): - self._socket.writeDatagram(('{}'.format(self._sent)).encode(), self.host, self.port) - if self._sent >= self._total: - self._sendtimer.stop() - self._sent += 1 - - def end(self): - if self.end_time: - return - self.end_time = time.time() - self._sendtimer.stop() - self._logger.info('Relay test finished') - self.finished.emit() - self.socket.data.disconnect(self.receive) - - def receive(self, sender, data): - self.received.add(int(data.decode())) - self.progress.emit(self.report) - if len(self.received) == self._total: - self.end() - - -@with_logger -class ConnectivityHelper(QObject): - connectivity_status_established = pyqtSignal(str, str) - - # Emitted when a peer is bound to a local port - peer_bound = pyqtSignal(str, int, int) - - ready = pyqtSignal() - - relay_test_finished = pyqtSignal() - relay_test_progress = pyqtSignal(str) - - error = pyqtSignal(str) - - def __init__(self, client, port): - QObject.__init__(self) - self._client = client - self._port = port - self.game_port = port+1 - - self._socket = QTurnSocket(port, self._on_data) - self._socket.state_changed.connect(self.turn_state_changed) - - dispatch = self._client.lobby_dispatch - dispatch.subscribe_to('connectivity', self.handle_SendNatPacket, "SendNatPacket") - dispatch.subscribe_to('connectivity', self.handle_ConnectivityState, "ConnectivityState") - dispatch.subscribe_to('connectivity', self.handle_message) - - self.relay_address, self.mapped_address = None, None - self._relay_test = None - self._relays = {} - self.state = None - self.addr = None - - @property - def is_ready(self): - return (self.relay_address is not None - and self.relay_address is not [None, None] - and self.mapped_address is not None - and self._socket.state() == QAbstractSocket.BoundState) - - def start_test(self): - self.send('InitiateTest', [self._port]) - - def start_relay_test(self): - if not self._relay_test: - self._relay_test = RelayTest(self._socket) - self._relay_test.finished.connect(self.relay_test_finished.emit) - self._relay_test.progress.connect(self.relay_test_progress.emit) - - if not self._socket.turn_state == TURNState.BOUND: - self._socket.connect_to_relay() - self._socket.bound.connect(self._relay_test.start_relay_test, Qt.UniqueConnection) - - def _cleanup(): - try: - self._socket.bound.disconnect(self._relay_test.start_relay_test) - except TypeError: - # For some reason pyqt raises _TypeError_ here - pass - - self._relay_test.finished.connect(_cleanup, Qt.UniqueConnection) - else: - self._relay_test.start_relay_test(self.mapped_address) - - def turn_state_changed(self, state): - if state == TURNState.BOUND: - self.relay_address = self._socket.relay_address - self.mapped_address = self._socket.relay_address - self.ready.emit() - - def handle_SendNatPacket(self, msg): - target, message = msg['args'] - host, port = target.split(':') - if self.state is None and self._socket.localPort() == self._port: - self._socket.randomize_port() - self._socket.writeDatagram(b'\x08'+message.encode(), QHostAddress(host), int(port)) - - def handle_ConnectivityState(self, msg): - state, addr = msg['args'] - if state == 'BLOCKED': - self._logger.warning("Outbound traffic is blocked") - QtWidgets.QMessageBox.warning(None, "Traffic Blocked", "Your outbound traffic appears to be blocked. Try " - "restarting FAF.
If the error persists please " - "contact a moderator and send your logs.
We " - "are already working on a solution to this problem.") - else: - host, port = addr.split(':') - self.state, self.mapped_address = state, (host, port) - self.connectivity_status_established.emit(self.state, self.addr) - self._logger.info("Connectivity state is {}, mapped address: {}".format(state, addr)) - - def handle_message(self, msg): - command = msg.get('command') - if command == 'CreatePermission': - self._socket.permit(msg['args']) - - def bind(self, addr, login, peer_id): - (host, port) = addr - host, port = host, int(port) - relay = Relay(self.game_port, login, peer_id, partial(self.send_udp, (host, port))) - relay.bound.connect(partial(self.peer_bound.emit, login, peer_id)) - relay.listen() - self._relays[(host, port)] = relay - - def send(self, command, args): - self._client.lobby_connection.send({ - 'command': command, - 'target': 'connectivity', - 'args': args or [] - }) - - def prepare(self): - if self.state == 'STUN' and not self._socket.turn_state == TURNState.BOUND: - self._socket.connect_to_relay() - elif self.state == 'BLOCKED': - pass - else: - self.ready.emit() - - def send_udp(self, addr, data): - (host, port) = addr - host, port = host, int(port) - self._socket.sendto(data, (host, port)) - - def _on_data(self, addr, data): - host, port = addr - if not self._process_natpacket(data, addr): - try: - relay = self._relays[(host, int(port))] - self._logger.debug('{}<<{} len: {}'.format(relay.peer_id, addr, len(data))) - relay.send(data) - except KeyError: - self._logger.debug("No relay for data from {}:{}".format(host, port)) - - def _process_natpacket(self, data, addr): - """ - Process data from given address as a natpacket - - Returns true iff it was processed as such - :param data: - :param addr: - :return: - """ - try: - if data.startswith(b'\x08'): - host, port = addr - msg = data[1:].decode() - self.send('ProcessNatPacket', - ["{}:{}".format(host, port), msg]) - if msg.startswith('Bind'): - peer_id = int(msg[4:]) - if (host, port) not in self._socket.bindings: - self._logger.info("Binding {} to {}".format((host, port), peer_id)) - self._socket.bind_address((host, port)) - self._logger.info("Processed bind request") - else: - self._logger.info("Unknown natpacket") - return True - except UnicodeDecodeError: - return diff --git a/src/connectivity/qturnsocket.py b/src/connectivity/qturnsocket.py deleted file mode 100644 index c3cac09b8..000000000 --- a/src/connectivity/qturnsocket.py +++ /dev/null @@ -1,161 +0,0 @@ -import config - -from PyQt5.QtCore import QTimer, pyqtSignal -from PyQt5.QtNetwork import QUdpSocket, QHostAddress, QHostInfo - -from connectivity.stun import STUNMessage -from connectivity.turn import TURNSession, TURNState -from decorators import with_logger - - -class QTurnSession(TURNSession): - def __init__(self, turn_client): - super(QTurnSession, self).__init__() - self.turn_client = turn_client # type: QTurnSocket - - def _call_in(self, func, timeout): - self.turn_client.call_in(func, timeout) - - def _recvfrom(self, sender, data): - self.turn_client.recvfrom(sender, data) - - def state_changed(self, new_state): - self.turn_client.turn_state = new_state - - def channel_bound(self, address, channel): - self.turn_client.channel_bound(address, channel) - - def _write(self, bytes): - self.turn_client.send(bytes) - - def _recv(self, channel, data): - self.turn_client.recv(channel, data) - - -@with_logger -class QTurnSocket(QUdpSocket): - """ - Qt based TURN client, abstracts a normal socket - and provides transparent TURN tunnelling functionality. - """ - # Emitted when the TURN session changes state - state_changed = pyqtSignal(TURNState) - - # Emitted when the TURN session is bound - bound = pyqtSignal(tuple) - - @property - def mapped_address(self): - return self._session.mapped_addr - - @property - def relay_address(self): - return self._session.relayed_addr - - @property - def turn_state(self): - return self._state - - @turn_state.setter - def turn_state(self, val): - self._state = val - self._logger.info("TURN state changed: {}".format(val)) - self.state_changed.emit(val) - if val == TURNState.BOUND: - self.bound.emit(self.relay_address) - - def __init__(self, port, data_cb): - QUdpSocket.__init__(self) - self._session = QTurnSession(self) - self._state = TURNState.UNBOUND - self.bindings = {} - self.initial_port = port - self._data_cb = data_cb - self.turn_host, self.turn_port = config.Settings.get('turn/host', type=str, default='dev.faforever.com'), \ - config.Settings.get('turn/port', type=int, default=3478) - self._logger.info("Turn socket initialized: {}".format(self.turn_host)) - self.turn_address = None - QHostInfo.lookupHost(self.turn_host, self._looked_up) - self.bind(port) - self.readyRead.connect(self._readyRead) - self.error.connect(self._error) - - def randomize_port(self): - self.abort() - self.bind() - - def reset_port(self, to=None): - self.abort() - self.bind(to or self.initial_port) - - def _looked_up(self, info): - self.turn_address = info.addresses()[0] - - def connect_to_relay(self): - self._session.start() - - def stop(self): - self.close() - - def permit(self, addr): - self._session.permit(addr) - - def bind_address(self, addr): - self._session.bind(addr) - - def channel_bound(self, addr, channel): - (host, port) = addr - self._logger.info("Bound channel {} to {}".format(channel, (host, port))) - self.bindings[channel] = (host, int(port)) - - def call_in(self, func, sec): - timer = QTimer(self) - timer.singleShot(sec * 1000, func) - - def _error(self): - pass - - def recvfrom(self, sender, data): - self._data_cb(sender, data) - - def recv(self, channel, data): - self._logger.debug("{}/TURNData<<: {}".format(channel, data)) - try: - self._data_cb(self.bindings[channel], data) - except KeyError: - self._logger.debug("No binding for channel: {}. Known: {}".format(channel, self.bindings)) - - def send(self, data): - """ - Write directly to the TURN relay - :param data: - :return: - """ - self.writeDatagram(data, self.turn_address, self.turn_port) - - def sendto(self, data, address): - if address in list(self.bindings.values()): - self._logger.debug("Sending to {} through relay".format(address)) - self._session.send_to(data, address) - else: - host, port = address - self._logger.debug("Sending to {} directly".format(address)) - self.writeDatagram(data, QHostAddress(host), port) - - def handle_data(self, addr, data): - (host, port) = addr - self._logger.debug("{}:{}/UDP<<".format(host, port)) - if self._session and self._session.is_stun_message(data): - self._logger.debug("Handling using turn session") - response = STUNMessage.from_bytes(data) - self._session.handle_response(response) - else: - self._logger.debug("Emitting data, len: {}".format(len(data))) - self._data_cb((host, port), data) - - def _readyRead(self): - while self.hasPendingDatagrams(): - data, host, port = self.readDatagram(self.pendingDatagramSize()) - ipv4_address_string = QHostAddress(host.toIPv4Address()).toString() # host.toString() is expressed as IPv6 otherwise e.g. ::ffff:91.64.56.230 - if data is not None: - self.handle_data((ipv4_address_string, int(port)), data) diff --git a/src/connectivity/relay.py b/src/connectivity/relay.py deleted file mode 100644 index e68314dad..000000000 --- a/src/connectivity/relay.py +++ /dev/null @@ -1,38 +0,0 @@ -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtNetwork import QUdpSocket, QHostAddress - -from decorators import with_logger - - -@with_logger -class Relay(QObject): - bound = pyqtSignal(int) - - def __init__(self, game_port, login, peer_id, recv): - QObject.__init__(self) - self._logger.info("Allocating local relay for {}, {}".format(login, peer_id)) - self._socket = QUdpSocket() - self._socket.stateChanged.connect(self._state_changed) - self._socket.readyRead.connect(self._ready_read) - self.game_port = game_port - self.login, self.peer_id = login, peer_id - self.recv = recv - - def listen(self): - self._socket.bind() - - def send(self, message): - self._logger.debug("game at 127.0.0.1:{}<<{} len: {}".format(self.game_port, self.peer_id, len(message))) - self._socket.writeDatagram(message, QHostAddress.LocalHost, self.game_port) - - def _state_changed(self, state): - if state == QUdpSocket.BoundState: - self.bound.emit(self._socket.localPort()) - - def _ready_read(self): - while self._socket.hasPendingDatagrams(): - data, host, port = self._socket.readDatagram(self._socket.pendingDatagramSize()) - if data is None: # Rare race condition when disconnecting - continue - self._logger.debug("{}>>{}/{}".format(self._socket.localPort(), self.login, self.peer_id)) - self.recv(data) diff --git a/src/connectivity/stun.py b/src/connectivity/stun.py deleted file mode 100644 index edf783b19..000000000 --- a/src/connectivity/stun.py +++ /dev/null @@ -1,213 +0,0 @@ -import binascii -import random -import struct -import ipaddress - -STUN_MAGIC_COOKIE = 0x2112A442 -STUN_METHODS = { - "Binding": 0x001, - "BindingSuccess": 0x101, - "Allocate": 0x003, - "AllocateSuccess": 0x103, - "Refresh": 0x004, - "RefreshSuccess": 0x104, - "RefreshError": 0x114, - "Send": 0x006, - "SendError": 0x116, - "Data": 0x007, - "DataIndication": 0x017, - "CreatePermission": 0x008, - "CreatePermissionSuccess": 0x108, - "ChannelBind": 0x009, - "ChannelBindSuccess": 0x109, - "ChannelData": 0x999, -} -STUN_METHOD_VALUES = {v: k for k, v in list(STUN_METHODS.items())} -STUN_ATTRIBUTES = { - 'MAPPED-ADDRESS': 0x0001, - 'CHANNEL-NUMBER': 0x000c, - 'LIFETIME': 0x000d, - 'Reserved (was BANDWIDTH)': 0x0010, - 'XOR-PEER-ADDRESS': 0x0012, - 'DATA': 0x0013, - 'XOR-RELAYED-ADDRESS': 0x0016, - 'EVEN-PORT': 0x0018, - 'REQUESTED-TRANSPORT': 0x0019, - 'XOR-MAPPED-ADDRESS': 0x0020, - 'DONT-FRAGMENT': 0x001a, - 'Reserved (was TIMER-VAL)': 0x0021, - 'RESERVATION-TOKEN': 0x0022, - 'RESPONSE-ORIGIN': 0x802b, - 'SOFTWARE': 0x8022 -} -STUN_ATTRIBUTE_VALUES = {v: k for k, v in list(STUN_ATTRIBUTES.items())} - - -class STUNAttribute: - # Type, Value - _header_format = struct.Struct('!HH') - # family, port, addr - _address_header = struct.Struct('!xBH') - - def __init__(self, type=None, val=None, buffer=None): - self.body = buffer or None - if self.body: - self.type, _, self.val = STUNAttribute.decode(self.body) - else: - self.body = STUNAttribute.encode(type, val) - self.type = type - self.val = val - - @staticmethod - def decode_address(buffer, xor=False): - family, port = STUNAttribute._address_header.unpack_from(buffer) - if family != 0x01: - raise ValueError("IPv6 not supported") - addr, = struct.unpack_from('!I', buffer, 4) - if xor: - addr ^= STUN_MAGIC_COOKIE - port ^= (STUN_MAGIC_COOKIE & 0xFFFF0000) >> 16 - return str(ipaddress.IPv4Address(addr)), port - - @staticmethod - def decode(buffer): - header_sz = 4 - type, value_length = STUNAttribute._header_format.unpack_from(buffer[:header_sz]) - type_str = STUN_ATTRIBUTE_VALUES.get(type) - padding = 4-(value_length % 4) # align to 32-bit boundaries - if padding == 4: - padding = 0 - total_length = header_sz + padding + value_length - val = '' - if type_str in ('XOR-MAPPED-ADDRESS', - 'XOR-RELAYED-ADDRESS', - 'XOR-PEER-ADDRESS', - 'MAPPED-ADDRESS', - 'RESPONSE-ORIGIN'): - val = STUNAttribute.decode_address(buffer[header_sz:], xor='XOR' in type_str) - elif type_str == 'LIFETIME': - val = struct.unpack_from('!I', buffer[header_sz:]) - elif type_str == 'DATA': - val = buffer[header_sz:header_sz+value_length] - return type_str, total_length, val - - @staticmethod - def encode(type, val): - """ - STUN attributes are 'TLV'-encoded - - :param type: The STUN attribute type to encode - :param val: The value to encode - :return: packed binary sequence - """ - type_val = STUN_ATTRIBUTES.get(type) - if type == 'DATA': - hd = struct.pack('!HH', type_val, len(val)) - return hd + val - elif type == "REQUESTED-TRANSPORT": - return struct.pack('!HHB3x', type_val, 4, val) - elif type == "LIFETIME": - return struct.pack('!HHI', type_val, 4, val) - elif type == "CHANNEL-NUMBER": - return struct.pack('!HHHxx', type_val, 4, val) - elif type == "XOR-PEER-ADDRESS": - addr, port = val - addr = int(ipaddress.IPv4Address(str(addr))) - port ^= (STUN_MAGIC_COOKIE & 0xFFFF0000) >> 16 - addr ^= STUN_MAGIC_COOKIE - return struct.pack('!HHxBHI', type_val, 8, 0x1, port, addr) - else: - length = len(val) - return struct.pack('!HH%ip' % length, type, length, val) - - -class STUNMessage: - _header_format = struct.Struct('!HHl12s') - - def __init__(self, method=None, attributes=None, transaction_id=None, body=None, header=None): - if isinstance(method, str): - self.method = STUN_METHODS[method] - elif isinstance(method, int): - self.method = method - else: - raise ValueError("Method must be str or int from STUN_METHODS") - - self.attributes = attributes or [] - - self.transaction_id = transaction_id or self._make_transaction_id() - self.body = body or self._make_body() - self.header = header or self._make_header() - - @property - def method_str(self): - return STUN_METHOD_VALUES.get(self.method) - - def _make_header(self): - buf = bytearray(20) - self._header_format.pack_into(buf, - 0, - self.method, - len(self.body), - STUN_MAGIC_COOKIE, - self.transaction_id) - return buf - - def _make_body(self): - return b''.join([STUNAttribute.encode(*t) for t in self.attributes]) - - @staticmethod - def _make_transaction_id(): - a = ''.join([random.choice('0123456789ABCDEF') for x in range(24)]) - return binascii.a2b_hex(a) - - @staticmethod - def parse_header(data): - """ - Parse the binary stun header - - :param data: buffer to parse, must be of length 20 - :return: method, length, magic token, tx_id - """ - return STUNMessage._header_format.unpack_from(data) - - @staticmethod - def parse_body(data): - """ - Parse the binary stun body - - :param data: buffer to parse, must not contain stun header - :return: list of key-value tuples - """ - pos = 0 - attrs = [] - while pos < len(data): - type, eaten, value = STUNAttribute.decode(data[pos:]) - pos += eaten - attrs.append((type, value)) - return attrs - - @staticmethod - def from_bytes(buffer): - channel, len = struct.unpack('!HH', buffer[:4]) - if 0x4000 <= channel <= 0x7FFF: - return STUNMessage('ChannelData', - [('CHANNEL-NUMBER', channel), - ('DATA', buffer[4:4+len])]) - - header = buffer[:20] - method, length, magic, tx_id = STUNMessage.parse_header(header) - if length: - body = buffer[20:length] - attributes = STUNMessage.parse_body(body) - else: - body = b'' - attributes = [] - return STUNMessage(method=method, attributes=attributes, transaction_id=tx_id, body=body, header=header) - - def to_bytes(self): - return self.header + self.body - - def __str__(self): - return "STUNMessage({}, {}, {})".format(STUN_METHOD_VALUES.get(self.method), - binascii.hexlify(self.transaction_id), - len(self.body)) diff --git a/src/connectivity/turn.py b/src/connectivity/turn.py deleted file mode 100644 index 0828195d2..000000000 --- a/src/connectivity/turn.py +++ /dev/null @@ -1,196 +0,0 @@ -import logging -from abc import ABCMeta, abstractmethod - -import struct -from enum import Enum - -from connectivity.stun import STUNMessage, STUN_MAGIC_COOKIE - - -class TURNState(Enum): - INITIALIZING = 0 - BOUND = 1 - UNBOUND = 2 - STOPPED = 3 - - -class TURNSession(metaclass=ABCMeta): - """ - Abstract TURN session abstraction. - - Handles details of the TURN protocol. - """ - - def __init__(self): - self._pending_tx = {} - self.logger = logging.getLogger(__name__) - self.bindings = {} - self._next_channel = 0x4000 - self.permissions = {} - self._pending_bindings = [] - self._state = TURNState.INITIALIZING - self.mapped_addr = (None, None) - self.relayed_addr = (None, None) - self.lifetime = 0 - - @abstractmethod - def _write(self, bytes): - pass - - @abstractmethod - def _call_in(self, timeout, func): - pass - - @abstractmethod - def _recv(self, channel, data): - pass - - @abstractmethod - def _recvfrom(self, sender, data): - pass - - @abstractmethod - def channel_bound(self, address, channel): - pass - - @abstractmethod - def state_changed(self, new_state): - pass - - @property - def state(self): - return self._state - - @state.setter - def state(self, val): - if self._state != val: - self._state = val - self.state_changed(val) - - def start(self): - self.logger.info("Requesting relay allocation") - # Remove any previous allocation we may have - self._write(STUNMessage('Refresh', - [('LIFETIME', 0)]).to_bytes()) - # Allocate a new UDP relay address - self._send(STUNMessage('Allocate', - [('REQUESTED-TRANSPORT', 17)])) - self._call_in(self._retransmit, 15) - - def stop(self): - self.state = TURNState.STOPPED - - def is_stun_message(self, data): - try: - channel, len = struct.unpack('!HH', data[:4]) - if 0x4000 <= channel <= 0x7FFF: - return True - else: - method, length, magic, tx_id = STUNMessage.parse_header(data[:20]) - return magic == STUN_MAGIC_COOKIE - except: - return False - - def bind(self, addr): - if addr in self.bindings or addr in [addr for (_, addr, _) - in self._pending_bindings]: - return - self.permit(addr) - self.logger.info("Requesting channel bind for {}:{}".format(self._next_channel, addr)) - msg = STUNMessage('ChannelBind', - [('CHANNEL-NUMBER', self._next_channel), - ('XOR-PEER-ADDRESS', addr)]) - self._send(msg) - self._pending_bindings.append((msg.transaction_id, addr, self._next_channel)) - self._next_channel += 1 - - def permit(self, addr): - self.logger.info("Permitting sends from {}".format(addr)) - msg = STUNMessage('CreatePermission', - [('XOR-PEER-ADDRESS', addr)]) - self._send(msg) - - def _send(self, stun_msg): - self._pending_tx[stun_msg.transaction_id] = stun_msg - self._write(stun_msg.to_bytes()) - - _channeldata_format = struct.Struct('!HH') - def send_to(self, data, addr): - if isinstance(addr, int): - msg = struct.pack('!HH', addr, len(data)) - self._write(msg + data) - elif addr in self.bindings: - header = TURNSession._channeldata_format.pack(self.bindings[addr], len(data)) - self._write(header + data) - else: - self._write(STUNMessage('Send', - [('XOR-PEER-ADDRESS', addr), - ('DATA', data)]).to_bytes()) - - def _retransmit(self): - if not self.state == TURNState.STOPPED: - for tx, msg in list(self._pending_tx.items()): - self.logger.debug("Retransmitting {}".format(tx)) - # avoid retransmitting retransmissions - self._write(msg.to_bytes()) - self._call_in(self._retransmit, 1) - - def handle_response(self, stun_msg): - """ - Handle the given stun message, assumed to be a response from the server - to a prior sent request - - :param stun_msg: STUNMessage - """ - self.logger.debug("<<: {}".format(stun_msg)) - attr = dict(stun_msg.attributes) - if stun_msg.method_str == 'DataIndication': - self.logger.debug(stun_msg.attributes) - data, (sender_addr, sender_port) = attr.get('DATA'), attr.get('XOR-PEER-ADDRESS') - self.logger.debug("<<({}:{}): {}".format(sender_addr, sender_port, data)) - self._recvfrom((sender_addr, sender_port), data) - if stun_msg.method_str == 'ChannelData': - self._recv(attr['CHANNEL-NUMBER'], attr['DATA']) - elif stun_msg.method_str == 'AllocateSuccess': - self.logger.info("Relay allocated: {}".format(attr.get('XOR-RELAYED-ADDRESS'))) - self.handle_allocate_success(stun_msg) - elif stun_msg.method_str == 'ChannelBindSuccess': - for txid, (addr, port), channel_id in self._pending_bindings: - if txid == stun_msg.transaction_id: - self.logger.info("Successfully bound {}:{} to {}".format(addr, port, channel_id)) - self.bindings[(addr, port)] = channel_id - self.channel_bound((addr, port), channel_id) - self._pending_bindings.remove((txid, (addr,port), channel_id)) - elif stun_msg.method_str == 'CreatePermissionSuccess': - pass - elif stun_msg.method_str == 'RefreshSuccess': - attr = dict(stun_msg.attributes) - self.lifetime, = attr.get('LIFETIME') - self.state = TURNState.BOUND - self.schedule_refresh() - if stun_msg.transaction_id in list(self._pending_tx.keys()): - del self._pending_tx[stun_msg.transaction_id] - - def handle_allocate_success(self, stun_msg): - attr = dict(stun_msg.attributes) - self.mapped_addr = attr.get('XOR-MAPPED-ADDRESS') - self.relayed_addr = attr.get('XOR-RELAYED-ADDRESS') - self.lifetime, = attr.get('LIFETIME') - self.state = TURNState.BOUND - self.schedule_refresh() - self.permit(self.mapped_addr) - self.permit(('37.58.123.2', 6112)) - self.permit(('37.58.123.3', 6112)) - - def schedule_refresh(self): - self._call_in(self.refresh, 30) - - def refresh(self): - self._write(STUNMessage('Refresh').to_bytes()) - for addr, channel in list(self.bindings.items()): - self._write(STUNMessage('ChannelBind', - [('CHANNEL-NUMBER', channel), - ('XOR-PEER-ADDRESS', addr)]).to_bytes()) - - def __str__(self): - return "TURNSession({}, {}, {}, {})".format(self.state, self.mapped_addr, self.relayed_addr, self.lifetime) diff --git a/src/coop/__init__.py b/src/coop/__init__.py index 6ca2e813e..35041c273 100644 --- a/src/coop/__init__.py +++ b/src/coop/__init__.py @@ -1,6 +1,13 @@ import logging -from fa import factions -logger = logging.getLogger(__name__) + +from coop.cooptableview import CoopLeaderboardTableView # For use by other modules from ._coopwidget import CoopWidget + +__all__ = ( + "CoopLeaderboardTableView", + "CoopWidget", +) + +logger = logging.getLogger(__name__) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 14b601028..78fb5dc39 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -1,30 +1,58 @@ -from PyQt5 import QtCore, QtGui, QtWidgets -import fa -from fa.replay import replay -import util +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest +from PyQt6 import QtCore +from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest -from coop.coopmapitem import CoopMapItem, CoopMapItemDelegate +import fa +import util +from api.coop_api import CoopApiAccessor +from api.coop_api import CoopResultApiAccessor +from api.models.CoopResult import CoopResult +from api.models.CoopScenario import CoopScenario +from client.user import User +from coop.coopmapitem import CoopMapItem +from coop.coopmapitem import CoopMapItemDelegate from coop.coopmodel import CoopGameFilterModel +from coop.cooptableitemdelegate import CoopLeaderboardItemDelegate +from coop.cooptablemodel import CoopLeaderBoardModel +from fa.replay import replay +from games.gameitem import GameViewBuilder +from games.gamemodel import GameModel +from games.hostgamewidget import GameLauncher +from model.game import Game from ui.busy_widget import BusyWidget -import os +from util.qt import qopen + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow -import logging logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("coop/coop.ui") class CoopWidget(FormClass, BaseClass, BusyWidget): - def __init__(self, client, game_model, me, - gameview_builder, game_launcher): + def __init__( + self, + client: ClientWindow, + game_model: GameModel, + me: User, + gameview_builder: GameViewBuilder, + game_launcher: GameLauncher, + ) -> None: BaseClass.__init__(self) self.setupUi(self) - self.client = client + self.client = client # type - ClientWindow self._me = me self._game_model = CoopGameFilterModel(self._me, game_model) self._game_launcher = game_launcher @@ -39,58 +67,67 @@ def __init__(self, client, game_model, me, self.options = [] - self.client.lobby_info.coopInfo.connect(self.processCoopInfo) + self.coop_api = CoopApiAccessor() + self.coop_api.data_ready.connect(self.process_coop_info) - self.coopList.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + self.coop_result_api = CoopResultApiAccessor() + self.coop_result_api.data_ready.connect(self.process_leaderboard_infos) + + self.coopList.header().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) self.coopList.setItemDelegate(CoopMapItemDelegate(self)) self.gameview = self._gameview_builder(self._game_model, self.gameList) - self.gameview.game_double_clicked.connect(self.gameDoubleClicked) + self.gameview.game_double_clicked.connect(self.game_double_clicked) - self.coopList.itemDoubleClicked.connect(self.coopListDoubleClicked) - self.coopList.itemClicked.connect(self.coopListClicked) + self.coopList.itemDoubleClicked.connect(self.coop_list_double_clicked) + self.coopList.itemClicked.connect(self.coop_list_clicked) - self.client.lobby_info.coopLeaderBoard.connect(self.processLeaderBoardInfos) - self.tabLeaderWidget.currentChanged.connect(self.askLeaderBoard) + self.client.lobby_info.coopLeaderBoard.connect(self.process_leaderboard_infos) + self.tabLeaderWidget.currentChanged.connect(self.ask_leaderboard) self.leaderBoard.setVisible(0) - self.FORMATTER_LADDER = str(util.THEME.readfile("coop/formatters/ladder.qthtml")) - self.FORMATTER_LADDER_HEADER = str(util.THEME.readfile("coop/formatters/ladder_header.qthtml")) - util.THEME.setStyleSheet(self.leaderBoard, "coop/formatters/style.css") + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() - self.leaderBoardTextGeneral.anchorClicked.connect(self.openUrl) - self.leaderBoardTextOne.anchorClicked.connect(self.openUrl) - self.leaderBoardTextTwo.anchorClicked.connect(self.openUrl) - self.leaderBoardTextThree.anchorClicked.connect(self.openUrl) - self.leaderBoardTextFour.anchorClicked.connect(self.openUrl) + self.leaderBoardTextGeneral.url_clicked.connect(self.open_url) + self.leaderBoardTextOne.url_clicked.connect(self.open_url) + self.leaderBoardTextTwo.url_clicked.connect(self.open_url) + self.leaderBoardTextThree.url_clicked.connect(self.open_url) + self.leaderBoardTextFour.url_clicked.connect(self.open_url) - self.replayDownload = QNetworkAccessManager() - self.replayDownload.finished.connect(self.finishRequest) + self.replay_download = QNetworkAccessManager() + self.replay_download.finished.connect(self.finish_request) self.selectedItem = None + def load_stylesheet(self): + self.setStyleSheet( + util.THEME.readstylesheet("coop/formatters/style.css"), + ) + def _addExistingGames(self, gameset): for game in gameset.values(): self._addGame(game) @QtCore.pyqtSlot(QtCore.QUrl) - def openUrl(self, url): - self.replayDownload.get(QNetworkRequest(url)) - - def finishRequest(self, reply): - faf_replay = QtCore.QFile(os.path.join(util.CACHE_DIR, "temp.fafreplay")) - faf_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate) - faf_replay.write(reply.readAll()) - faf_replay.flush() - faf_replay.close() + def open_url(self, url: QtCore.QUrl) -> None: + self.replay_download.get(QNetworkRequest(url)) + + def finish_request(self, reply: QNetworkReply) -> None: + filepath = os.path.join(util.CACHE_DIR, "temp.fafreplay") + open_mode = QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.OpenModeFlag.Truncate + with qopen(filepath, open_mode) as faf_replay: + faf_replay.write(reply.readAll()) replay(os.path.join(util.CACHE_DIR, "temp.fafreplay")) - def processLeaderBoardInfos(self, message): + def process_leaderboard_infos(self, message: dict[str, list[CoopResult]]): """ Process leaderboard""" - values = message["leaderboard"] - table = message["table"] + self.tabLeaderWidget.setEnabled(True) + table = self.tabLeaderWidget.currentIndex() if table == 0: w = self.leaderBoardTextGeneral elif table == 1: @@ -101,138 +138,99 @@ def processLeaderBoardInfos(self, message): w = self.leaderBoardTextThree elif table == 4: w = self.leaderBoardTextFour - - doc = QtGui.QTextDocument() - doc.addResource(3, QtCore.QUrl("style.css"), self.leaderBoard.styleSheet()) - html = "" - - if self.selectedItem: - html += '

'+self.selectedItem.name+'


' - html += "" - - formatter = self.FORMATTER_LADDER - formatter_header = self.FORMATTER_LADDER_HEADER - cursor = w.textCursor() - cursor.movePosition(QtGui.QTextCursor.End) - w.setTextCursor(cursor) - color = "lime" - line = formatter_header.format(rank="rank", names="names", time="time", color=color) - html += line - rank = 1 - for val in values: - # val = values[uid] - players = ", ".join(val["players"]) - numPlayers = str(len(val["players"])) - timing = val["time"] - gameuid = str(val["gameuid"]) - if val["secondary"] == 1: - secondary = "Yes" - else: - secondary = "No" - if rank % 2 == 0: - line = formatter.format(rank=str(rank), numplayers=numPlayers, gameuid=gameuid, players=players, - objectives=secondary, timing=timing, type="even") - else: - line = formatter.format(rank=str(rank), numplayers=numPlayers, gameuid=gameuid, players=players, - objectives=secondary, timing=timing, type="") - - rank = rank + 1 - - html += line - - html += "
" - - doc.setHtml(html) - w.setDocument(doc) - + model = CoopLeaderBoardModel(message) + w.setModel(model) + w.setSortingEnabled(False) + w.setItemDelegate(CoopLeaderboardItemDelegate(self)) self.leaderBoard.setVisible(True) def busy_entered(self): if not self.loaded: - self.client.lobby_connection.send(dict(command="coop_list")) - self.loaded = True + self.coop_api.request_coop_scenarios() - def askLeaderBoard(self): + def ask_leaderboard(self) -> None: """ - ask the server for stats + ask the API for stats """ - if self.selectedItem: - self.client.statsServer.send(dict(command="coop_stats", mission=self.selectedItem.uid, - type=self.tabLeaderWidget.currentIndex())) + if not self.selectedItem: + return + + if (player_count := self.tabLeaderWidget.currentIndex()) == 0: + self.coop_result_api.request_coop_results_general(self.selectedItem.uid) + else: + self.coop_result_api.request_coop_results(self.selectedItem.uid, player_count) + self.tabLeaderWidget.setEnabled(False) - def coopListClicked(self, item): + def coop_list_clicked(self, item: CoopMapItem) -> None: """ Hosting a coop event """ - if not hasattr(item, "mapUrl"): + if not hasattr(item, "mapname"): if item.isExpanded(): item.setExpanded(False) else: item.setExpanded(True) return - if item != self.selectedItem: + if item != self.selectedItem: self.selectedItem = item - self.client.statsServer.send(dict(command="coop_stats", mission=item.uid, - type=self.tabLeaderWidget.currentIndex())) + self.ask_leaderboard() - def coopListDoubleClicked(self, item): + def coop_list_double_clicked(self, item: CoopMapItem) -> None: """ Hosting a coop event """ - if not hasattr(item, "mapUrl"): + if not hasattr(item, "mapname"): return - mapname = fa.maps.link2name(item.mapUrl) if not fa.instance.available(): return - self.client.games.stopSearchRanked() + self.client.games.stopSearch() - if not fa.check.check("coop"): - return - - self._game_launcher.host_game(item.name, item.mod, mapname) + self._game_launcher.host_game(item.name, "coop", item.mapname) @QtCore.pyqtSlot(dict) - def processCoopInfo(self, message): + def process_coop_info(self, message: dict[str, list[CoopScenario]]) -> None: """ - Slot that interprets and propagates coop_info messages into the coop list + Slot that interprets coop data from API into the coop list """ - uid = message["uid"] - - if uid not in self.coop: - typeCoop = message["type"] + for campaign in message["values"]: + type_coop = campaign.name - if typeCoop not in self.cooptypes: + if type_coop not in self.cooptypes: root_item = QtWidgets.QTreeWidgetItem() self.coopList.addTopLevelItem(root_item) - root_item.setText(0, "%s" % typeCoop) - self.cooptypes[typeCoop] = root_item + root_item.setText(0, f"{type_coop}") + root_item.setToolTip(0, campaign.description) + self.cooptypes[type_coop] = root_item root_item.setExpanded(False) else: - root_item = self.cooptypes[typeCoop] - - itemCoop = CoopMapItem(uid, self) - itemCoop.update(message) + root_item = self.cooptypes[type_coop] - root_item.addChild(itemCoop) + for mission in campaign.maps: + item_coop = CoopMapItem(mission.xd, self) + item_coop.update(mission) + root_item.addChild(item_coop) - self.coop[uid] = itemCoop + self.coop[mission.xd] = item_coop + self.loaded = True - def gameDoubleClicked(self, game): + def game_double_clicked(self, game: Game) -> None: """ Slot that attempts to join a game. """ if not fa.instance.available(): return - if not fa.check.check(game.featured_mod, game.mapname, None, game.sim_mods): + if not fa.check.check(game.featured_mod, game.mapname, sim_mods=game.sim_mods): return if game.password_protected: - passw, ok = QtWidgets.QInputDialog.getText(self.client, "Passworded game", "Enter password :", - QtWidgets.QLineEdit.Normal, "") + passw, ok = QtWidgets.QInputDialog.getText( + self.client, "Passworded game", "Enter password :", + QtWidgets.QLineEdit.Normal, "", + ) if ok: self.client.join_game(uid=game.uid, password=passw) else: diff --git a/src/coop/coopmapitem.py b/src/coop/coopmapitem.py index 9d0cbe537..2c8b32836 100644 --- a/src/coop/coopmapitem.py +++ b/src/coop/coopmapitem.py @@ -1,64 +1,70 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from fa import maps +from __future__ import annotations + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + import util -import os -import client +from api.models.CoopMission import CoopMission class CoopMapItemDelegate(QtWidgets.QStyledItemDelegate): - - def __init__(self, *args, **kwargs): + + def __init__(self, *args, **kwargs) -> None: QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) - + def paint(self, painter, option, index, *args, **kwargs): self.initStyleOption(option, index) - + painter.save() - + html = QtGui.QTextDocument() textOption = QtGui.QTextOption() - textOption.setWrapMode(QtGui.QTextOption.WordWrap) + textOption.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap) html.setDefaultTextOption(textOption) html.setTextWidth(option.rect.width()) html.setHtml(option.text) - - icon = QtGui.QIcon(option.icon) - iconsize = icon.actualSize(option.rect.size()) -# -# #clear icon and text before letting the control draw itself because we're rendering these parts ourselves -# option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) -# -# #Icon -# icon.paint(painter, option.rect.adjusted(5-2, -2, 0, 0), QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) -# -# -# #Description - painter.translate(option.rect.left() , option.rect.top()) + + # clear text before letting the control draw itself because we're + # rendering these parts ourselves + option.text = "" + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) + # Description + painter.translate(option.rect.left(), option.rect.top()) clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height()) html.drawContents(painter, clip) - + painter.restore() - def sizeHint(self, option, index, *args, **kwargs): + def sizeHint( + self, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + *args, + **kwargs, + ) -> None: self.initStyleOption(option, index) html = QtGui.QTextDocument() textOption = QtGui.QTextOption() - textOption.setWrapMode(QtGui.QTextOption.WordWrap) + textOption.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap) html.setTextWidth(option.rect.width()) html.setDefaultTextOption(textOption) html.setHtml(option.text) - - return QtCore.QSize(int(html.size().width()) + 10, int(html.size().height() + 10)) + + return QtCore.QSize( + int(html.size().width()) + 10, + int(html.size().height()) + 10, + ) class CoopMapItem(QtWidgets.QTreeWidgetItem): FORMATTER_COOP = str(util.THEME.readfile("coop/formatters/coop.qthtml")) - def __init__(self, uid, parent, *args, **kwargs): + def __init__(self, uid: int, parent: QtWidgets.QWidget, *args, **kwargs) -> None: QtWidgets.QTreeWidgetItem.__init__(self, *args, **kwargs) self.uid = uid @@ -66,47 +72,48 @@ def __init__(self, uid, parent, *args, **kwargs): self.name = None self.description = None - self.mapUrl = None + self.mapname = None self.options = [] self.setHidden(True) - def update(self, message): + def update(self, mission: CoopMission) -> None: """ Updates this item from the message dictionary supplied """ - self.name = message["name"] - self.mapUrl = message["filename"] - self.description = message["description"] - self.mod = message["featured_mod"] + self.name = mission.name + self.mapname = mission.folder_name + self.description = mission.description + self.mission = mission -# self.icon = maps.preview(self.mapname) -# if not self.icon: -# self.client.downloader.downloadMapPreview(self.mapname, self, True) -# self.icon = util.THEME.icon("games/unknown_map.png") -# self.setIcon(0, self.icon) + self.viewtext = self.FORMATTER_COOP.format( + name=self.name, + description=self.description, + ) - self.viewtext = (self.FORMATTER_COOP.format(name=self.name, description=self.description)) + # adding tag is just a silly trick to make text rich and force + # QToolTip to enable word wrap + self.setToolTip(0, f"{self.description}") def display(self, column): if column == 0: return self.viewtext if column == 1: - return self.viewtext + return self.viewtext def data(self, column, role): - if role == QtCore.Qt.DisplayRole: - return self.display(column) - elif role == QtCore.Qt.UserRole: + if role == QtCore.Qt.ItemDataRole.DisplayRole: + return self.display(column) + elif role == QtCore.Qt.ItemDataRole.UserRole: return self return super(CoopMapItem, self).data(column, role) - + def __ge__(self, other): """ Comparison operator used for item list sorting """ return not self.__lt__(other) - def __lt__(self, other): + def __lt__(self, other: CoopMapItem) -> bool: """ Comparison operator used for item list sorting """ # Default: uid return self.uid > other.uid diff --git a/src/coop/cooptableitemdelegate.py b/src/coop/cooptableitemdelegate.py new file mode 100644 index 000000000..60afc71d3 --- /dev/null +++ b/src/coop/cooptableitemdelegate.py @@ -0,0 +1,42 @@ +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QStyleOptionViewItem + +from qt.itemviews.tableitemdelegte import TableItemDelegate +from util.qt import qpainter + + +class CoopLeaderboardItemDelegate(TableItemDelegate): + def _customize_style_option( + self, + option: QStyleOptionViewItem, + index: QModelIndex, + ) -> QStyleOptionViewItem: + opt = TableItemDelegate._customize_style_option(self, option, index) + if option.styleObject.hover_index() == index: + opt.state |= QStyle.StateFlag.State_HasFocus + return opt + + def paint( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex, + ) -> None: + opt = self._customize_style_option(option, index) + text = opt.text + + replay_col = 4 + + with qpainter(painter): + self._draw_clear_option(painter, opt) + if index.column() == replay_col and opt.state & QStyle.StateFlag.State_HasFocus: + font = opt.font + font.setUnderline(True) + painter.setFont(font) + painter.setPen(opt.palette.link().color()) + else: + self._set_pen(painter, opt) + painter.drawText(opt.rect, Qt.AlignmentFlag.AlignCenter, text) diff --git a/src/coop/cooptablemodel.py b/src/coop/cooptablemodel.py new file mode 100644 index 000000000..7f155144b --- /dev/null +++ b/src/coop/cooptablemodel.py @@ -0,0 +1,64 @@ +from PyQt6.QtCore import QAbstractTableModel +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QUrl + +from api.models.CoopResult import CoopResult + + +class CoopLeaderBoardModel(QAbstractTableModel): + def __init__(self, data: dict[str, list[CoopResult]]) -> None: + QAbstractTableModel.__init__(self) + self._headers = ("Players", "Names", "Duration", "Secondary Objectives", "Replay") + self.load_data(data) + + def load_data(self, data: dict[str, list[CoopResult]]) -> None: + self.values = data["values"] + self.column_count = len(self._headers) + self.row_count = len(self.values) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return self.row_count + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return self.column_count + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: Qt.ItemDataRole, + ) -> str | Qt.AlignmentFlag | None: + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return self._headers[section] + else: + return str(section + 1) + elif role == Qt.ItemDataRole.TextAlignmentRole: + return Qt.AlignmentFlag.AlignCenter + + def data( + self, + index: QModelIndex, + role: Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole, + ) -> str | QUrl | None: + column = index.column() + row = index.row() + + if role == Qt.ItemDataRole.DisplayRole: + coopres = self.values[row] + if column == 0: + return str(coopres.player_count) + elif column == 1: + return ", ".join([stats.player.login for stats in coopres.game.player_stats]) + elif column == 2: + mm, ss = divmod(coopres.duration, 60) + hh, mm = divmod(mm, 60) + return f"{hh:02}:{mm:02}:{ss:02}" + elif column == 3: + return "Yes" if coopres.secondary_objectives else "No" + elif column == 4: + return "Watch" + if role == Qt.ItemDataRole.UserRole and column == 4: + coopres = self.values[row] + return QUrl(coopres.game.replay_url) diff --git a/src/coop/cooptableview.py b/src/coop/cooptableview.py new file mode 100644 index 000000000..d77e7ee5b --- /dev/null +++ b/src/coop/cooptableview.py @@ -0,0 +1,19 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QMouseEvent + +from qt.itemviews.tableview import TableView + + +class CoopLeaderboardTableView(TableView): + url_clicked = pyqtSignal(QUrl) + + def mousePressEvent(self, event: QMouseEvent) -> None: + index = self.indexAt(event.position().toPoint()) + if index.column() == 4: + url = self.model().data(index, Qt.ItemDataRole.UserRole) + self.url_clicked.emit(url) + return + + return TableView.mousePressEvent(self, event) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 6aee1f2c7..22daafad8 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -1,25 +1,49 @@ -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply -from PyQt5 import QtWidgets, QtCore -import urllib.request, urllib.error, urllib.parse +from __future__ import annotations + import logging import os -import util -import warnings +import zipfile +from io import BytesIO + +from PyQt6 import QtGui +from PyQt6.QtCore import QEventLoop +from PyQt6.QtCore import QFile +from PyQt6.QtCore import QIODevice +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + from config import Settings logger = logging.getLogger(__name__) -class FileDownload(object): +class BaseDownload(QObject): """ A simple async one-shot file downloader. """ - def __init__(self, nam, addr, dest, destpath=None, - start=lambda _: None, progress=lambda _: None, finished=lambda _: None): + start = pyqtSignal(object) + progress = pyqtSignal(object) + finished = pyqtSignal(object) + + def __init__( + self, + nam: QNetworkAccessManager, + addr: str, + dest: QFile | BytesIO, + destpath: str | None = None, + request_params: dict | None = None, + ) -> None: + QObject.__init__(self) self._nam = nam self.addr = addr self.dest = dest self.destpath = destpath + self.request_params = request_params or {} self.canceled = False self.error = False @@ -28,11 +52,7 @@ def __init__(self, nam, addr, dest, destpath=None, self.bytes_total = 0 self.bytes_progress = 0 - self._dfile = None - - self.cb_start = start - self.cb_progress = progress - self.cb_finished = finished + self._dfile: QNetworkReply | None = None self._reading = False self._running = False @@ -42,6 +62,7 @@ def _stop(self): ran = self._running self._running = False if ran: + self._about_to_finish() self._finish() def _error(self): @@ -50,27 +71,40 @@ def _error(self): def cancel(self): self.canceled = True + if not self._dfile.isFinished(): + self._dfile.abort() self._stop() - def _finish(self): + def _handle_status(self) -> None: # check status code - statusCode = self._dfile.attribute(QNetworkRequest.HttpStatusCodeAttribute) + statusCode = self._dfile.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) if statusCode != 200: - logger.warning('Download failed: %s -> %s', self.addr, statusCode) + logger.debug(f"Download failed: {self.addr} -> {statusCode}") self.error = True - self.cb_finished(self) - def run(self): - self._running = True - req = QNetworkRequest(QtCore.QUrl(self.addr)) + def _about_to_finish(self) -> None: + self._handle_status() + + def _finish(self) -> None: + self.finished.emit(self) + + def prepare_request(self) -> QNetworkRequest: + qurl = QUrl(self.addr) + # in https://github.com/FAForever/faf-java-api/pull/637 + # hmac verification was introduced + req = QNetworkRequest(qurl) + for key, value in self.request_params.items(): + req.setRawHeader(key.encode(), value.encode()) req.setRawHeader(b'User-Agent', b"FAF Client") - req.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) req.setMaximumRedirectsAllowed(3) + return req - self.cb_start(self) + def run(self): + self._running = True + self.start.emit(self) - self._dfile = self._nam.get(req) - self._dfile.error.connect(self._error) + self._dfile = self._nam.get(self.prepare_request()) + self._dfile.errorOccurred.connect(self._error) self._dfile.finished.connect(self._atFinished) self._dfile.downloadProgress.connect(self._atProgress) self._dfile.readyRead.connect(self._kick_read) @@ -80,9 +114,10 @@ def _atFinished(self): self._sock_finished = True self._kick_read() - def _atProgress(self, recv, total): + def _atProgress(self, recv: int, total: int) -> None: self.bytes_progress = recv self.bytes_total = total + self.progress.emit(self) def _kick_read(self): # Don't run the read loop more than once at a time if self._reading: @@ -95,95 +130,170 @@ def _read(self): while self._dfile.bytesAvailable() > 0 and self._running: self._readloop() if self._sock_finished: - # Sock can be marked as finished either before read or inside readloop - # Either way we've read everything after it was marked + # Sock can be marked as finished either before read or inside + # readloop. Either way we've read everything after it was marked self._stop() def _readloop(self): - bs = self.blocksize if self.blocksize is not None else self._dfile.bytesAvailable() - self.dest.write(self._dfile.read(bs)) - self.cb_progress(self) + if self.blocksize is None: + bs = self._dfile.bytesAvailable() + else: + bs = self.blocksize + self.dest.write(self._dfile.read(bs)) def succeeded(self): return not self.error and not self.canceled - def waitForCompletion(self): - waitFlag = QtCore.QEventLoop.WaitForMoreEvents - while self._running: - QtWidgets.QApplication.processEvents(waitFlag) + def failed(self) -> bool: + return not self.succeeded() + def error_string(self) -> str: + if self._dfile is not None: + return self._dfile.errorString() + return "" -MAP_PREVIEW_ROOT = "{}/faf/vault/map_previews/small/".format(Settings.get('content/host')) + def waitForCompletion(self) -> None: + if not self._running: + return + wait_flag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents + loop = QEventLoop() + self.finished.connect(loop.quit) + loop.exec(wait_flag) + + +class FileDownload(BaseDownload): + def __init__( + self, + target_path: str, + nam: QNetworkAccessManager, + addr: str, + request_params: dict | None = None, + ) -> None: + self._target_path = target_path + self._cache_path = f"{target_path}.part" + + self._output = QFile(self._cache_path) + self._output.open(QIODevice.OpenModeFlag.WriteOnly) + super().__init__(nam, addr, self._output, request_params=request_params) + + def _about_to_finish(self) -> None: + super()._about_to_finish() + self.cleanup() + + def cleanup(self) -> None: + self._output.close() + if self.failed(): + try: + os.unlink(self._cache_path) + except OSError as e: + logger.warning(f"Couldn't remove {self._cache_path}: {e}") + else: + logger.debug(f"Finished download from {self.addr}") + self._output.rename(self._target_path) -class PreviewDownload(QtCore.QObject): - done = QtCore.pyqtSignal(object, object) - def __init__(self, nam, name, url, target_dir, delay_timer=None): - QtCore.QObject.__init__(self) +class ZipDownloadExtract(BaseDownload): + """ + Download a zip archive in-memory and extract it into target_dir + """ + + def __init__( + self, + target_dir: str, + nam: QNetworkAccessManager, + addr: str, + request_params: dict | None = None, + exist_ok: bool = False, + ) -> None: + self._target_dir = target_dir + self._output = BytesIO() + self._exist_ok = exist_ok + super().__init__(nam, addr, self._output, request_params=request_params) + + def _about_to_finish(self) -> None: + super()._about_to_finish() + if self.succeeded(): + self.extract_archive() + self.cleanup() + + def extract_archive(self) -> None: + with zipfile.ZipFile(self._output) as zfile: + dirname = os.path.dirname(zfile.namelist()[0]) + destpath = os.path.join(self._target_dir, dirname) + if os.path.exists(destpath): + if not self._exist_ok: + logger.warning(f"Cannot extract: {destpath!r} already exists") + self.error = True + return + try: + zfile.extractall(self._target_dir) + logger.debug( + f"Successfully downloaded and extracted to {destpath!r} from: {self.addr!r}", + ) + except Exception as e: + logger.error(f"Extract error: {e}") + self.error = True + + def cleanup(self) -> None: + self._output.close() + + +class DownloadWrapper(QObject): + done = pyqtSignal(object, object) + + def __init__( + self, + nam: QNetworkAccessManager, + name: str, + url: str, + target_dir: str, + delay_timer: QTimer | None, + ) -> None: + super().__init__() self.requests = set() self.name = name self._url = url self._nam = nam self._target_dir = target_dir self._delay_timer = delay_timer - self._dl = None + self._dl: FileDownload | None = None if delay_timer is None: self._start_download() else: delay_timer.timeout.connect(self._start_download) - def _start_download(self): + def _start_download(self) -> None: if self._delay_timer is not None: self._delay_timer.disconnect(self._start_download) self._dl = self._prepare_dl() self._dl.run() - def _prepare_dl(self): - img, imgpath = self._get_cachefile(self.name + ".png.part") - dl = FileDownload(self._nam, self._url, img, imgpath) - dl.cb_finished = self._finished + def _prepare_dl(self) -> FileDownload: + filepath = os.path.join(self._target_dir, self.name) + dl = FileDownload(filepath, self._nam, self._url) + dl.finished.connect(self._finished) dl.blocksize = None return dl - def _get_cachefile(self, name): - imgpath = os.path.join(self._target_dir, name) - img = QtCore.QFile(imgpath) - img.open(QtCore.QIODevice.WriteOnly) - return img, imgpath - - def remove_request(self, req): + def remove_request(self, req: DownloadRequest) -> None: self.requests.remove(req) - def add_request(self, req): + def add_request(self, req: DownloadRequest) -> None: self.requests.add(req) - def _finished(self, dl): - dl.dest.close() - logger.info("Finished download from " + dl.addr) - if self.failed(): - logger.debug("Web Preview failed for: {}".format(self.name)) - os.unlink(dl.destpath) - filepath = "games/unknown_map.png" - is_local = True - else: - logger.debug("Web Preview used for: {}".format(self.name)) - # Remove '.part' - partpath = dl.destpath - filepath = partpath[:-5] - QtCore.QDir().rename(partpath, filepath) - is_local = False - self.done.emit(self, (filepath, is_local)) + def _finished(self, dl: FileDownload) -> None: + self.done.emit(self, dl.dest.fileName()) def failed(self): return not self._dl.succeeded() -class PreviewDownloadRequest(QtCore.QObject): - done = QtCore.pyqtSignal(object, object) +class DownloadRequest(QObject): + done = pyqtSignal(object, object) def __init__(self): - QtCore.QObject.__init__(self) + QObject.__init__(self) self._dl = None @property @@ -202,71 +312,83 @@ def finished(self, name, result): self.done.emit(name, result) -class PreviewDownloader(QtCore.QObject): +class Downloader(QObject): """ - Class for downloading previews. Clients ask to download by giving download + Class for downloading. Clients ask to download by giving download requests, which are stored by name. After download is complete, all download requests get notified (neatly avoiding the 'requester died while we were downloading' issue). Requests can be resubmitted. That reclassifies them to a new name. """ - PREVIEW_REDOWNLOAD_TIMEOUT = 5 * 60 * 1000 - PREVIEW_DOWN_FAILS_TO_TIMEOUT = 3 + REDOWNLOAD_TIMEOUT = 5 * 60 * 1000 + DOWNLOAD_FAILS_TO_TIMEOUT = 3 - def __init__(self, target_dir, default_url_prefix): - QtCore.QObject.__init__(self) + def __init__(self, target_dir: str) -> None: + super().__init__() self._nam = QNetworkAccessManager(self) self._target_dir = target_dir - self._default_url_prefix = default_url_prefix - self._downloads = {} - self._timeouts = DownloadTimeouts(self.PREVIEW_REDOWNLOAD_TIMEOUT, - self.PREVIEW_DOWN_FAILS_TO_TIMEOUT) - - def download_preview(self, name, req, url=None): - target_url = self._target_url(name, url) - if target_url is None: - msg = "Missing url for a preview download {}".format(name) - raise ValueError(msg) - self._add_request(name, req, target_url) - - def _target_url(self, name, url): - if url is not None: - return url - if self._default_url_prefix is None: - return None - return self._default_url_prefix + urllib.parse.quote(name) + ".png" - - def _add_request(self, name, req, url): + self._downloads: dict[str, DownloadWrapper] = {} + self._timeouts = DownloadTimeouts(self.REDOWNLOAD_TIMEOUT, self.DOWNLOAD_FAILS_TO_TIMEOUT) + + def set_target_dir(self, target_dir: str) -> None: + self._target_dir = target_dir + + def download(self, name: str, request: DownloadRequest, url: str) -> None: + self._add_request(name, request, url) + + def _add_request(self, name: str, req: DownloadRequest, url: str) -> None: if name not in self._downloads: self._add_download(name, url) dl = self._downloads[name] req.dl = dl - def _add_download(self, name, url): + def _add_download(self, name: str, url: str) -> None: if self._timeouts.on_timeout(name): delay = self._timeouts.timer else: delay = None - dl = PreviewDownload(self._nam, name, url, self._target_dir, delay) + dl = DownloadWrapper(self._nam, name, url, self._target_dir, delay) dl.done.connect(self._finished_download) self._downloads[name] = dl - def _finished_download(self, dl, result): - self._timeouts.update_fail_count(dl.name, dl.failed()) - requests = set(dl.requests) # Don't change it during iteration + def _finished_download(self, download: DownloadWrapper, download_path: str) -> None: + self._timeouts.update_fail_count(download.name, download.failed()) + requests = set(download.requests) # Don't change it during iteration for req in requests: req.dl = None - del self._downloads[dl.name] + del self._downloads[download.name] for req in requests: - req.finished(dl.name, result) + req.finished(download.name, (download_path, download.failed())) + + +class MapPreviewDownloader(Downloader): + def __init__(self, target_dir: str, size: str) -> None: + super().__init__(target_dir) + self.size = size + + def download_preview(self, name: str, req: DownloadRequest) -> None: + self._add_request(f"{name}.png", req, self._target_url(name)) + + def _target_url(self, name: str) -> str: + return Settings.get("vault/map_preview_url").format(size=self.size, name=name) + + +class MapSmallPreviewDownloader(MapPreviewDownloader): + def __init__(self, target_dir: str) -> None: + super().__init__(target_dir, "small") + + +class MapLargePreviewDownloader(MapPreviewDownloader): + def __init__(self, target_dir: str) -> None: + super().__init__(target_dir, "large") class DownloadTimeouts: def __init__(self, timeout_interval, fail_count_to_timeout): self._fail_count_to_timeout = fail_count_to_timeout self._timed_out_items = {} - self.timer = QtCore.QTimer() + self.timer = QTimer() self.timer.setInterval(timeout_interval) self.timer.timeout.connect(self._clear_timeouts) @@ -290,3 +412,31 @@ def update_fail_count(self, item, failed): def _clear_timeouts(self): self._timed_out_items.clear() + + +class AvatarDownloader: + def __init__(self): + self._nam = QNetworkAccessManager() + self._requests = {} + self.avatars = {} + self._nam.finished.connect(self._avatar_download_finished) + + def download_avatar(self, url, req): + self._add_request(url, req) + + def _add_request(self, url, req): + should_download = url not in self._requests + self._requests.setdefault(url, set()).add(req) + if should_download: + self._nam.get(QNetworkRequest(QUrl(url))) + + def _avatar_download_finished(self, reply): + img = QtGui.QImage() + img.loadFromData(reply.readAll()) + url = reply.url().toString() + if url not in self.avatars: + self.avatars[url] = QtGui.QPixmap(img) + + reqs = self._requests.pop(url, []) + for req in reqs: + req.finished(url, self.avatars[url]) diff --git a/src/fa/__init__.py b/src/fa/__init__.py index 0e0b22a85..523458cd9 100644 --- a/src/fa/__init__.py +++ b/src/fa/__init__.py @@ -1,28 +1,30 @@ - -# Initialize logging system import logging -logger = logging.getLogger(__name__) - -GPGNET_HOST = "lobby.faforever.com" -GPGNET_PORT = 8000 - -DEFAULT_LIVE_REPLAY = True -DEFAULT_RECORD_REPLAY = True -DEFAULT_WRITE_GAME_LOG = False - -# We only want one instance of Forged Alliance to run, so we use a singleton here -# (other modules may wish to connect to its signals so it needs persistence) -from .game_process import instance -from .game_session import GameSession -from .play import run -from .replay import replay - +# We only want one instance of Forged Alliance to run, so we use a singleton +# here (other modules may wish to connect to its signals so it needs +# persistence) from . import check +from . import factions +from . import game_updater from . import maps from . import mods from . import replayserver -from . import updater -from . import upnp -from . import factions from . import wizards +from .game_process import instance +from .play import run +from .replay import replay + +__all__ = ( + "check", + "factions", + "maps", + "mods", + "replayserver", + "game_updater", + "wizards", + "instance", + "run", + "replay", +) + +logger = logging.getLogger(__name__) diff --git a/src/fa/check.py b/src/fa/check.py index ab344e04a..d735ccc2f 100644 --- a/src/fa/check.py +++ b/src/fa/check.py @@ -1,21 +1,28 @@ +from __future__ import annotations + import logging -import os -import zipfile -import binascii +from typing import TYPE_CHECKING -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets -import fa import config +import fa +import util +from fa.game_updater.misc import UpdaterResult +from fa.game_updater.updater import Updater from fa.mods import checkMods -from fa.path import writeFAPathLua, validatePath +from fa.path import validatePath +from fa.path import writeFAPathLua from fa.wizards import Wizard -import util +from mapGenerator.mapgenUtils import isGeneratedMap + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow logger = logging.getLogger(__name__) -def map_(mapname, force=False, silent=False): +def map_(mapname: str, force: bool = False, silent: bool = False) -> bool: """ Assures that the map is available in FA, or returns false. """ @@ -25,23 +32,39 @@ def map_(mapname, force=False, silent=False): logger.info("Map is available.") return True + if isGeneratedMap(mapname): + import client + + # FIXME: generateMap, downloadMap should also return bool + return bool(client.instance.map_generator.generateMap(mapname)) + if force: - return fa.maps.downloadMap(mapname, silent=silent) + return bool(fa.maps.downloadMap(mapname, silent=silent)) auto = config.Settings.get('maps/autodownload', default=False, type=bool) if not auto: msgbox = QtWidgets.QMessageBox() msgbox.setWindowTitle("Download Map") - msgbox.setText("Seems that you don't have the map used this game. Do you want to download it?
" + mapname + "") - msgbox.setInformativeText("If you respond 'Yes to All' maps will be downloaded automatically in the future") - msgbox.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.YesToAll | QtWidgets.QMessageBox.No) - result = msgbox.exec_() - if result == QtWidgets.QMessageBox.No: + msgbox.setText( + "Seems that you don't have the map used this game. Do " + "you want to download it?
{}".format(mapname), + ) + msgbox.setInformativeText( + "If you respond 'Yes to All' maps will be " + "downloaded automatically in the future", + ) + msgbox.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.YesToAll + | QtWidgets.QMessageBox.StandardButton.No, + ) + result = msgbox.exec() + if result == QtWidgets.QMessageBox.StandardButton.No: return False - elif result == QtWidgets.QMessageBox.YesToAll: + elif result == QtWidgets.QMessageBox.StandardButton.YesToAll: config.Settings.set('maps/autodownload', True) - return fa.maps.downloadMap(mapname, silent=silent) + return bool(fa.maps.downloadMap(mapname, silent=silent)) def featured_mod(featured_mod, version): @@ -52,12 +75,21 @@ def sim_mod(sim_mod, version): pass -def path(parent): - while not validatePath(util.settings.value("ForgedAlliance/app/path", "", type=str)): - logger.warning("Invalid game path: " + util.settings.value("ForgedAlliance/app/path", "", type=str)) +def path(parent: ClientWindow) -> bool: + while not validatePath( + util.settings.value( + "ForgedAlliance/app/path", "", + type=str, + ), + ): + logger.warning( + "Invalid game path: {}".format( + util.settings.value("ForgedAlliance/app/path", "", type=str), + ), + ) wizard = Wizard(parent) - result = wizard.exec_() - if result == QtWidgets.QWizard.Rejected: + result = wizard.exec() + if result == QtWidgets.QWizard.DialogCode.Rejected: return False logger.info("Writing fa_path.lua config file.") @@ -69,52 +101,19 @@ def game(parent): return True -def crc32(fname): - try: - with open(fname) as stream: - return binascii.crc32(stream.read()) - except: - logger.exception('CRC check fail!') - return None - - -def checkMovies(files): - """ - Unpacks movies (based on path in zipfile) to the movies folder. - - Movies must be unpacked for FA to be able to play them. - - This is a hack needed because the game updater can only handle bin and gamedata. - """ - - logger.info('checking updated files: {}'.format(files)) - - # construct dirs - gd = os.path.join(util.APPDATA_DIR, 'gamedata') - - for fname in files: - origpath = os.path.join(gd, fname) - - if os.path.exists(origpath) and zipfile.is_zipfile(origpath): - try: - zf = zipfile.ZipFile(origpath) - except: - logger.exception('Failed to open Game File {}'.format(origpath)) - continue - - for zi in zf.infolist(): - if zi.filename.startswith('movies'): - tgtpath = os.path.join(util.APPDATA_DIR, zi.filename) - # copy only if file is different - check first if file exists, then if size is changed, then crc - if not os.path.exists(tgtpath) or os.stat(tgtpath).st_size != zi.file_size or crc32(tgtpath) != zi.CRC: - zf.extract(zi, util.APPDATA_DIR) - - -def check(featured_mod, mapname=None, version=None, modVersions=None, sim_mods=None, silent=False): +def check( + featured_mod: str, + mapname: str | None = None, + version: int | None = None, + modVersions: dict | None = None, + sim_mods: dict[str, str] | None = None, + silent: bool = False, +): """ - This checks whether the mods are properly updated and player has the correct map. + This checks whether the mods are properly updated and player has the + correct map. """ - logger.info("Checking FA for: " + str(featured_mod) + " and map " + str(mapname)) + logger.info("Checking FA for: {} and map {}".format(featured_mod, mapname)) assert featured_mod @@ -122,24 +121,18 @@ def check(featured_mod, mapname=None, version=None, modVersions=None, sim_mods=N logger.info("Version unknown, assuming latest") # Perform the actual comparisons and updating - logger.info("Updating FA for mod: " + str(featured_mod) + ", version " + str(version)) - - import client # FIXME: forced by circular imports + logger.info( + "Updating FA for mod: {}, version {}".format(featured_mod, version), + ) + import client # FIXME: forced by circular imports if not path(client.instance): return False # Spawn an update for the required mod - game_updater = fa.updater.Updater(featured_mod, version, modVersions, silent=silent) + game_updater = Updater(featured_mod, version, modVersions, silent=silent) result = game_updater.run() - if result != fa.updater.Updater.RESULT_SUCCESS: - return False - - try: - if len(game_updater.updatedFiles) > 0: - checkMovies(game_updater.updatedFiles) - except: - logger.exception('Error checking game files for movies') + if result != UpdaterResult.SUCCESS: return False # Now it's down to having the right map diff --git a/src/fa/factions.py b/src/fa/factions.py index 601d2ca43..adabef1c0 100644 --- a/src/fa/factions.py +++ b/src/fa/factions.py @@ -5,7 +5,8 @@ @unique class Factions(Enum): """ - Enum to represent factions. Numbers match up with faction identification ids from the game. + Enum to represent factions. Numbers match up with faction identification + ids from the game. """ UEF = 1 AEON = 2 @@ -24,6 +25,17 @@ def get_random_faction(): possibilities.pop() return random.choice(possibilities) + @staticmethod + def set_faction(sub_factions=[]): + if any(sub_factions): + possibilities = [] + for faction, selected in zip(list(Factions)[:-1], sub_factions): + if selected: + possibilities.append(faction) + else: + possibilities = list(Factions)[:-1] + return random.choice(possibilities) + @staticmethod def from_name(name): name = name.lower() @@ -38,7 +50,7 @@ def from_name(name): elif name == "random": return Factions.RANDOM - raise ValueError("Invalid faction name provided: %s" % name) + raise ValueError("Invalid faction name provided: {}".format(name)) def to_name(self): if self == Factions.UEF: @@ -52,4 +64,4 @@ def to_name(self): elif self == Factions.RANDOM: return "random" - raise ValueError("Invalid faction id provided: %i" % self) + raise ValueError("Invalid faction id provided: {}".format(self)) diff --git a/src/fa/game_connection.py b/src/fa/game_connection.py index 90e7dcc3f..57a68a294 100644 --- a/src/fa/game_connection.py +++ b/src/fa/game_connection.py @@ -1,5 +1,9 @@ -from PyQt5.QtCore import QObject, pyqtSignal, QDataStream -from struct import pack, unpack +from struct import pack +from struct import unpack + +from PyQt6.QtCore import QDataStream +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal from decorators import with_logger @@ -41,9 +45,11 @@ def _packLuaVal(self, val): if isinstance(val, int): return pack("=bi", 0, val) elif isinstance(val, str) or isinstance(val, str): - return pack("=bi%ds" % len(val), 1, len(val), val.encode()) + return pack("=bi{}s".format(len(val)), 1, len(val), val.encode()) else: - raise Exception("Unknown GameConnection Field Type: %s" % type(val)) + raise Exception( + "Unknown GameConnection Field Type: {}".format(type(val)), + ) def _readLuaVal(self, ds): if self._socket.bytesAvailable() < 5: @@ -61,11 +67,13 @@ def _readLuaVal(self, ds): ds.readRawData(5) datastring = ds.readRawData(fieldSize).decode('utf-8') - fixedStr = datastring.replace("/t","\t").replace("/n","\n") + fixedStr = datastring.replace("/t", "\t").replace("/n", "\n") return str(fixedStr) else: - raise Exception("Unknown GameConnection Field Type: %d" % fieldType) + raise Exception( + "Unknown GameConnection Field Type: {}".format(fieldType), + ) # Non-reentrant def _onReadyRead(self): @@ -100,7 +108,9 @@ def _onReadyRead(self): self.chunks.append(chunk) # Packet pair reading done. - self._logger.info("GC >> : %s : %s", self.header, self.chunks) + self._logger.info( + "GC >> : {} : {}".format(self.header, self.chunks), + ) self.messageReceived.emit(self.header, self.chunks) self.header = None self.nchunks = -1 diff --git a/src/fa/game_process.py b/src/fa/game_process.py index e673bf514..db1bb0285 100644 --- a/src/fa/game_process.py +++ b/src/fa/game_process.py @@ -1,16 +1,17 @@ +import logging import os +import re import sys -from PyQt5 import QtCore, QtWidgets -import config -import re +from PyQt6 import QtCore +from PyQt6 import QtWidgets +import config import util -import logging -logger = logging.getLogger(__name__) - from model.game import GameState +logger = logging.getLogger(__name__) + __author__ = 'Thygrrr' @@ -33,11 +34,11 @@ def game(self): @game.setter def game(self, value): if self._game is not None: - self._game.gameUpdated.disconnect(self._trackGameUpdate) + self._game.updated.disconnect(self._trackGameUpdate) self._game = value if self._game is not None: - self._game.gameUpdated.connect(self._trackGameUpdate) + self._game.updated.connect(self._trackGameUpdate) self._trackGameUpdate() # Check new games from the server to find one matching our uid @@ -64,61 +65,81 @@ def _trackGameUpdate(self, _=None): logger.info("Game Info Complete: " + str(self._info)) def run(self, info, arguments, detach=False, init_file=None): - """ - Performs the actual running of ForgedAlliance.exe - in an attached process. - """ - - if self._info is not None: # Stop tracking current game - self.game = None - - self._info = info # This can be none if we're running a replay - if self._info is not None: - self._info.setdefault('complete', False) - if not self._info['complete']: - uid = self._info['uid'] - try: - self.game = self.gameset[uid] - except KeyError: - pass - - executable = os.path.join(config.Settings.get('game/bin/path'), - "ForgedAlliance.exe") - if sys.platform == 'win32': - command = '"' + executable + '" ' + " ".join(arguments) - else: - command = util.wine_cmd_prefix + " " + util.wine_exe + ' "' + executable + '" ' + " ".join(arguments) - if util.wine_prefix: - wine_env = QtCore.QProcessEnvironment.systemEnvironment() - wine_env.insert("WINEPREFIX", util.wine_prefix) - QtCore.QProcess.setProcessEnvironment(self, wine_env) - logger.info("Running FA with info: " + str(info)) - logger.info("Running FA via command: " + command) - logger.info("Running FA via executable: " + executable) - - # Launch the game as a stand alone process - if not instance.running(): - - self.setWorkingDirectory(os.path.dirname(executable)) - if not detach: - self.start(command) - else: - # Remove the wrapping " at the start and end of some arguments as QT will double wrap when launching - arguments = [re.sub('(^"|"$)', '', element) for element in arguments] - self.startDetached(executable, arguments, os.path.dirname(executable)) - return True + """ + Performs the actual running of ForgedAlliance.exe + in an attached process. + """ + + if self._info is not None: # Stop tracking current game + self.game = None + + self._info = info # This can be none if we're running a replay + if self._info is not None: + self._info.setdefault('complete', False) + if not self._info['complete']: + uid = self._info['uid'] + try: + self.game = self.gameset[uid] + except KeyError: + pass + + executable = os.path.join( + config.Settings.get('game/bin/path'), "ForgedAlliance.exe", + ) + if sys.platform == 'win32': + command = '"{}" '.format(executable) + command += " ".join(arguments) + else: + command = '{} {} "{}" '.format( + util.wine_cmd_prefix, util.wine_exe, executable, + ) + command += " ".join(arguments) + if util.wine_prefix: + wine_env = QtCore.QProcessEnvironment.systemEnvironment() + wine_env.insert("WINEPREFIX", util.wine_prefix) + QtCore.QProcess.setProcessEnvironment(self, wine_env) + logger.info("Running FA with info: " + str(info)) + logger.info("Running FA via command: " + command) + logger.info("Running FA via executable: " + executable) + + # Launch the game as a stand alone process + if not instance.running(): + + self.setWorkingDirectory(os.path.dirname(executable)) + if not detach: + self.startCommand(command) else: - QtWidgets.QMessageBox.warning(None, "ForgedAlliance.exe", "Another instance of FA is already running.") - return False + # Remove the wrapping " at the start and end of some + # arguments as QT will double wrap when launching + arguments = [ + re.sub('(^"|"$)', '', element) + for element in arguments + ] + self.startDetached( + executable, arguments, os.path.dirname(executable), + ) + return True + else: + QtWidgets.QMessageBox.warning( + None, + "ForgedAlliance.exe", + "Another instance of FA is already running.", + ) + return False def running(self): - return self.state() == QtCore.QProcess.Running + return self.state() == QtCore.QProcess.ProcessState.Running def available(self): if self.running(): - QtWidgets.QMessageBox.warning(QtWidgets.QApplication.activeWindow(), "ForgedAllianceForever.exe", - "Forged Alliance is already running.
You can only run one " - "instance of the game.") + QtWidgets.QMessageBox.warning( + QtWidgets.QApplication.activeWindow(), + "ForgedAllianceForever.exe", + ( + "Forged Alliance is already running.
You can " + "only run one instance of the game." + ), + ) return False return True @@ -126,7 +147,9 @@ def close(self): if self.running(): progress = QtWidgets.QProgressDialog() progress.setCancelButtonText("Terminate") - progress.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint) + progress.setWindowFlags( + QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, + ) progress.setAutoClose(False) progress.setAutoReset(False) progress.setMinimum(0) @@ -134,10 +157,15 @@ def close(self): progress.setValue(0) progress.setModal(1) progress.setWindowTitle("Waiting for Game to Close") - progress.setLabelText("FA Forever exited, but ForgedAlliance.exe is still running.

    " - "Are you still in a game?

    You may choose to:
  • press ALT+TAB " - "to return to the game
  • kill ForgedAlliance.exe by clicking Terminate" - "

") + progress.setLabelText( + "FA Forever exited, but ForgedAlliance.exe " + "is still running.

    " + "Are you still in a game?

    You " + "may choose to:
  • press ALT+TAB to " + "return to the game
  • kill " + "ForgedAlliance.exe byclicking Terminate" + "

", + ) progress.show() while self.running() and progress.isVisible(): diff --git a/src/fa/game_runner.py b/src/fa/game_runner.py new file mode 100644 index 000000000..0ff0f6a60 --- /dev/null +++ b/src/fa/game_runner.py @@ -0,0 +1,38 @@ +import logging + +import fa +from fa.replay import replay +from model.game import GameState +from util.gameurl import GameUrl + +logger = logging.getLogger(__name__) + + +class GameRunner: + def __init__(self, gameset, client_window): + self._gameset = gameset + self._client_window = client_window # FIXME + + def run_game_with_url(self, game, pid): + gurl = game.url(pid) + if gurl is None: + return + self.run_game_from_url(gurl) + + def run_game_from_url(self, gurl): + game = self._gameset.get(gurl.uid, None) + if game is None or game.closed(): + return + + if game.state == GameState.OPEN: + self._join_game_from_url(gurl) + elif game.state == GameState.PLAYING: + replay(gurl) + + def _join_game_from_url(self, gurl: GameUrl) -> None: + logger.debug("Joining game from URL: " + gurl.to_url().toString()) + if fa.instance.available(): + add_mods = gurl.mods or {} + if fa.check.game(self): + if fa.check.check(gurl.mod, gurl.map, sim_mods=add_mods): + self._client_window.join_game(gurl.uid) diff --git a/src/fa/game_session.py b/src/fa/game_session.py index 2dc528d4b..39fa1e6d8 100644 --- a/src/fa/game_session.py +++ b/src/fa/game_session.py @@ -1,19 +1,23 @@ -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtNetwork import QTcpServer, QHostAddress +import logging from enum import IntEnum -from connectivity.turn import TURNState +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +import client from config import setup_file_handler -from fa.game_connection import GPGNetConnection -from fa.game_process import instance +from connectivity.IceAdapterClient import IceAdapterClient +from connectivity.IceAdapterProcess import IceAdapterProcess +from connectivity.IceServersPoller import IceServersPoller +from fa.game_process import instance as game_process_instance -import logging logger = logging.getLogger(__name__) - # Log to a separate file to not pollute normal log with huge json dumps logger.propagate = False logger.addHandler(setup_file_handler('gamesession.log')) + class GameSessionState(IntEnum): # Game services are entirely off OFF = 0 @@ -31,7 +35,7 @@ class GameSession(QObject): ready = pyqtSignal() gameFullSignal = pyqtSignal() - def __init__(self, client, connectivity): + def __init__(self, player_id, player_login): QObject.__init__(self) self._state = GameSessionState.OFF self._rehost = False @@ -41,41 +45,65 @@ def __init__(self, client, connectivity): self.game_visibility = None self.game_map = None self.game_password = None + self.player_id = player_id + self.player_login = player_login + client.instance.lobby_dispatch.subscribe_to( + 'game', self.handle_message, + ) - # Subscribe to messages targeted at 'game' from the server - client.lobby_dispatch.subscribe_to('game', self.handle_message) - - # Connectivity helper - self.connectivity = connectivity - self.connectivity.ready.connect(self.ready.emit) - self.connectivity.peer_bound.connect(self._peer_bound) - - # Keep a parent pointer so we can use it to send - # relay messages about the game state - self._client = client # type: Client - self.me = client.me - - self.game_port = client.gamePort - - # Use the normal lobby by default - self.init_mode = 0 self._joins, self._connects = [], [] - # 'GPGNet' TCP listener - self._game_listener = QTcpServer(self) - self._game_listener.newConnection.connect(self._new_game_connection) - self._game_listener.listen(QHostAddress.LocalHost) - - # We only allow one game connection at a time - self._game_connection = None - - self._process = instance # type:'GameProcess' + self._process = game_process_instance # type - GameProcess self._process.started.connect(self._launched) self._process.finished.connect(self._exited) + self.state = GameSessionState.LISTENING + + self._relay_port = 0 + + self.ice_adapter_process = None + self.ice_adapter_client = None + self.ice_servers_poller = None + + def startIceAdapter(self): + self.ice_adapter_process = IceAdapterProcess( + player_id=self.player_id, + player_login=self.player_login, + game_id=self.game_uid, + ) + self.ice_adapter_client = IceAdapterClient(game_session=self) + self.ice_adapter_client.statusChanged.connect(self.onIceAdapterStarted) + self.ice_adapter_client.connect_( + "127.0.0.1", self.ice_adapter_process.rpc_port(), + ) + while self._relay_port == 0: + QCoreApplication.processEvents() + + def onIceAdapterStarted(self, status: dict) -> None: + self._relay_port = status["gpgnet"]["local_port"] + logger.info( + "ICE adapter started an listening on port {} for GPGNet " + "connections".format(self._relay_port), + ) + self.ice_adapter_client.statusChanged.disconnect(self.onIceAdapterStarted) + self.ice_servers_poller = IceServersPoller(self.ice_adapter_client, self.game_uid) + + def closeIceAdapter(self): + if self.ice_adapter_client: + try: + self.ice_adapter_client.call("quit", blocking=True) + except RuntimeError: + pass + self.ice_adapter_client.close() + self.ice_adapter_client = None + if self.ice_adapter_process: + self.ice_adapter_process.close() + self.ice_adapter_process = None + self._relay_port = 0 + @property def relay_port(self): - return self._game_listener.serverPort() + return self._relay_port @property def state(self): @@ -85,117 +113,90 @@ def state(self): def state(self, val): self._state = val - def listen(self): - """ - Start listening for remote commands - - Call this in good time before hosting a game, - e.g. when the host game dialog is being shown. - """ - assert self.state == GameSessionState.OFF - self.state = GameSessionState.LISTENING - if self.connectivity.is_ready: - self.ready.emit() - else: - self.connectivity.prepare() - - def _needs_game_connection(fn): - def wrap(self, *args, **kwargs): - if self._game_connection is None: - logger.warning("{}.{}: tried to run without a game connection".format( - self.__class__.__name__, fn.__name__)) - else: - return fn(self, *args, **kwargs) - return wrap - - @_needs_game_connection def handle_message(self, message): command, args = message.get('command'), message.get('args', []) if command == 'SendNatPacket': - addr_and_port, message = args - host, port = addr_and_port.split(':') - self.connectivity.send(message, (host, port)) + # we ignore that for now with the ICE Adapter + pass elif command == 'CreatePermission': - addr_and_port = args[0] - host, port = addr_and_port.split(':') - self.connectivity.permit((host, port)) + # we ignore that for now with the ICE Adapter + pass elif command == 'JoinGame': - addr, login, peer_id = args - self._joins.append(peer_id) - self.connectivity.bind(addr, login, peer_id) + login, peer_id = args + self.ice_adapter_client.call("joinGame", [login, peer_id]) + elif command == 'HostGame': + self.ice_adapter_client.call("hostGame", [args[0]]) elif command == 'ConnectToPeer': - addr, login, peer_id = args - self._connects.append(peer_id) - self.connectivity.bind(addr, login, peer_id) + login, peer_id, offer = args + self.ice_adapter_client.call( + "connectToPeer", [login, peer_id, offer], + ) + elif command == 'DisconnectFromPeer': + self.ice_adapter_client.call("disconnectFromPeer", [args[0]]) + elif command == "IceMsg": + peer_id, ice_msg = args + self.ice_adapter_client.call("iceMsg", [peer_id, ice_msg]) else: - self._game_connection.send(command, *args) + logger.warning( + "sending unhandled GPGNet message {} {}".format(command, args), + ) + self.ice_adapter_client.call("sendToGpgNet", [command, args]) def send(self, command_id, args): logger.info("Outgoing relay message {} {}".format(command_id, args)) - self._client.lobby_connection.send({ + client.instance.lobby_connection.send({ 'command': command_id, 'target': 'game', - 'args': args or [] + 'args': args or [], }) - @_needs_game_connection - def _peer_bound(self, login, peer_id, port): - logger.info("Bound peer {}/{} to {}".format(login, peer_id, port)) - if peer_id in self._connects: - self._game_connection.send('ConnectToPeer', '127.0.0.1:{}'.format(port), login, peer_id) - self._connects.remove(peer_id) - elif peer_id in self._joins: - self._game_connection.send('JoinGame', '127.0.0.1:{}'.format(port), login, peer_id) - self._joins.remove(peer_id) + def setLobbyInitMode(self, lobby_init_mode): + # to do: make this call synchronous/blocking, because init_mode must be + # set before game_launch. + # See ClientWindow.handle_game_launch() + if ( + not self.ice_adapter_client + or not self.ice_adapter_client.connected + ): + logger.error( + "ICE adapter client not connected when calling " + "setLobbyInitMode", + ) + return + self.ice_adapter_client.call("setLobbyInitMode", [lobby_init_mode]) def _new_game_connection(self): logger.info("Game connected through GPGNet") - assert not self._game_connection - self._game_connection = GPGNetConnection(self._game_listener.nextPendingConnection()) - self._game_connection.messageReceived.connect(self._on_game_message) self.state = GameSessionState.RUNNING + self.ready.emit() - @_needs_game_connection def _on_game_message(self, command, args): logger.info("Incoming GPGNet: {} {}".format(command, args)) - if command == "GameState": - if args[0] == 'Idle': - # autolobby, port, nickname, uid, hasSupcom - self._game_connection.send("CreateLobby", - self.init_mode, - self.game_port + 1, - self.me.player.login, - self.me.player.id, - 1) - elif args[0] == 'Lobby': - # TODO: Eagerly initialize the game by hosting/joining early - pass - elif command == 'Rehost': + if command == 'Rehost': self._rehost = True elif command == 'GameFull': self.gameFullSignal.emit() self.send(command, args) - def _turn_state_changed(self, val): - if val == TURNState.BOUND: - self.ready.emit() - def _launched(self): logger.info("Game has started") + client.instance.lobby_reconnector.keepalive = True def _exited(self, status): - self._game_connection = None self.state = GameSessionState.OFF logger.info("Game has exited with status code: {}".format(status)) self.send('GameState', ['Ended']) + client.instance.lobby_reconnector.keepalive = False if self._rehost: - self._client.host_game(title=self.game_name, - mod=self.game_mod, - visibility=self.game_visibility, - mapname=self.game_map, - password=self.game_password, - is_rehost=True) + client.instance.host_game( + title=self.game_name, + mod=self.game_mod, + visibility=self.game_visibility, + mapname=self.game_map, + password=self.game_password, + is_rehost=True, + ) self._rehost = False self.game_uid = None @@ -204,3 +205,4 @@ def _exited(self, status): self.game_visibility = None self.game_map = None self.game_password = None + self.closeIceAdapter() diff --git a/src/fa/game_updater/__init__.py b/src/fa/game_updater/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fa/game_updater/misc.py b/src/fa/game_updater/misc.py new file mode 100644 index 000000000..09774e5a9 --- /dev/null +++ b/src/fa/game_updater/misc.py @@ -0,0 +1,75 @@ +import logging +import time +from enum import Enum +from typing import NamedTuple + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QMessageBox + + +# A set of exceptions we use to see what goes wrong during asynchronous data +# transfer waits +class UpdaterCancellation(Exception): + pass + + +class UpdaterFailure(Exception): + pass + + +class UpdaterTimeout(Exception): + pass + + +class UpdaterResult(Enum): + SUCCESS = 0 # Update successful + NONE = -1 # Update operation is still ongoing + FAILURE = 1 # An error occured during updating + CANCEL = 2 # User cancelled the download process + + +class ProgressInfo(NamedTuple): + progress: int + total: int + description: str = "" + + +# This contains a complete dump of everything that was supplied to logOutput +debug_log = [] + + +def clear_log() -> None: + global debug_log + debug_log = [] + + +def log(string: str, loger: logging.Logger) -> None: + loger.debug(string) + debug_log.append(str(string)) + + +def dump_plain_text() -> str: + return "\n".join(debug_log) + + +def dump_HTML() -> str: + return "
".join(debug_log) + + +def timestamp() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +# It works, but will need some work later +def failure_dialog() -> None: + """ + The dialog that shows the user the log if something went wrong. + """ + mbox = QMessageBox() + mbox.setParent(QApplication.activeWindow()) + mbox.setWindowFlags(Qt.WindowType.Dialog) + mbox.setWindowTitle("Update Failed") + mbox.setText("An error occurred during downloading/copying/moving files") + mbox.setDetailedText(dump_plain_text()) + mbox.exec() diff --git a/src/fa/game_updater/patcher.py b/src/fa/game_updater/patcher.py new file mode 100644 index 000000000..028c52d02 --- /dev/null +++ b/src/fa/game_updater/patcher.py @@ -0,0 +1,30 @@ +import logging + +from PyQt6.QtCore import QFile + +from util.qt import qopen + +logger = logging.getLogger(__name__) + + +class FAPatcher: + version_addresses = (0xd3d40, 0x47612d, 0x476666) + + @staticmethod + def read_version(path: str) -> int: + with qopen(path, QFile.OpenModeFlag.ReadOnly) as file: + if not file.isOpen(): + return -1 + file.seek(FAPatcher.version_addresses[0]) + return int.from_bytes(file.read(4), "little") + + @staticmethod + def patch(path: str, version: int) -> bool: + with qopen(path, QFile.OpenModeFlag.ReadWrite) as file: + if not file.isOpen(): + return False + for address in FAPatcher.version_addresses: + file.seek(address) + file.write(version.to_bytes(4, "little")) + logger.info(f"Patched {path!r} to version {version!r}") + return True diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py new file mode 100644 index 000000000..09d90f3c8 --- /dev/null +++ b/src/fa/game_updater/updater.py @@ -0,0 +1,227 @@ + +""" +This is the FORGED ALLIANCE updater. + +It ensures, through communication with faforever.com, that Forged Alliance +is properly updated, patched, and all required files for a given mod are +installed + +@author thygrrr +""" +from __future__ import annotations + +import logging + +from PyQt6.QtCore import QEventLoop +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QThread +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtGui import QTextCursor +from PyQt6.QtWidgets import QDialog + +import util +from downloadManager import FileDownload +from fa.game_updater.misc import ProgressInfo +from fa.game_updater.misc import UpdaterResult +from fa.game_updater.misc import clear_log +from fa.game_updater.misc import failure_dialog +from fa.game_updater.misc import log +from fa.game_updater.misc import timestamp +from fa.game_updater.worker import UpdaterWorker + +logger = logging.getLogger(__name__) + + +FormClass, BaseClass = util.THEME.loadUiType("fa/updater/updater.ui") + + +class UpdaterProgressDialog(FormClass, BaseClass): + aborted = pyqtSignal() + + def __init__(self, parent: QObject, silent: bool = False) -> None: + BaseClass.__init__(self, parent) + self.setupUi(self) + self.setModal(True) + self.logPlainTextEdit.setLineWrapMode(self.logPlainTextEdit.LineWrapMode.NoWrap) + self.logFrame.setVisible(False) + self.adjustSize() + self.watches = [] + + if silent: + self.abortButton.hide() + + self.rejected.connect(self.abort) + self.abortButton.clicked.connect(self.reject) + self.detailsButton.clicked.connect(self.change_details_visibility) + self.load_stylesheet() + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + def change_details_visibility(self) -> None: + visible = self.logFrame.isVisible() + self.logFrame.setVisible(not visible) + self.adjustSize() + + def abort(self) -> None: + self.aborted.emit() + + @pyqtSlot(str) + def append_log(self, text: str) -> None: + self.logPlainTextEdit.appendPlainText(text) + + def replace_last_log_line(self, text: str) -> None: + self.logPlainTextEdit.moveCursor( + QTextCursor.MoveOperation.StartOfLine, + QTextCursor.MoveMode.KeepAnchor, + ) + self.logPlainTextEdit.textCursor().removeSelectedText() + self.logPlainTextEdit.insertPlainText(text) + + @pyqtSlot(QObject) + def add_watch(self, watch: QObject) -> None: + self.watches.append(watch) + watch.finished.connect(self.watch_finished) + + @pyqtSlot() + def watch_finished(self) -> None: + for watch in self.watches: + if not watch.isFinished(): + return + # equivalent to self.accept(), but clearer + self.done(QDialog.DialogCode.Accepted) + + def on_processed_mod_changed(self, info: ProgressInfo) -> None: + text = f"Updating {info.description.upper()}... ({info.progress}/{info.total})" + self.currentModLabel.setText(text) + self.hashProgress.setValue(0) + self.modProgress.setValue(0) + self.extrasProgress.setValue(0) + + def on_movies_progress(self, info: ProgressInfo) -> None: + self.extrasProgress.setMaximum(info.total) + self.extrasProgress.setValue(info.progress) + self.append_log(f"Checking for movies and sounds: {info.description}") + + def on_hash_progress(self, info: ProgressInfo) -> None: + self.hashProgress.setMaximum(info.total) + self.hashProgress.setValue(info.progress) + self.append_log(f"Calculating md5: {info.description}") + + def on_game_progress(self, info: ProgressInfo) -> None: + self.gameProgress.setMaximum(info.total) + self.gameProgress.setValue(info.progress) + self.append_log(f"Checking/copying game file: {info.description}") + + def on_mod_progress(self, info: ProgressInfo) -> None: + if info.total == 0: + self.modProgress.setMaximum(1) + self.modProgress.setValue(1) + self.append_log("Everything is up to date.") + else: + self.append_log(f"Updating file: {info.description}") + self.modProgress.setMaximum(info.total) + self.modProgress.setValue(info.progress) + + def on_download_progress(self, dler: FileDownload) -> None: + if dler.bytes_total == 0: + return + + total = dler.bytes_total + ready = dler.bytes_progress + + total_mb = round(total / (1024 ** 2), 2) + ready_mb = round(ready / (1024 ** 2), 2) + + def construct_bar(blockchar: str = "=", fillchar: str = " ") -> str: + num_blocks = round(20 * ready / total) + empty_blocks = 20 - num_blocks + return f"[{blockchar * num_blocks}{fillchar * empty_blocks}]" + + bar = construct_bar() + percent_text = f"{100 * ready / total:.1f}%" + text = f"{bar} {percent_text} ({ready_mb} MB / {total_mb} MB)" + self.replace_last_log_line(text) + + def on_download_finished(self, dler: FileDownload) -> None: + self.append_log("Finished downloading.") + + def on_download_started(self, dler: FileDownload) -> None: + self.append_log(f"Downloading file from {dler.addr}\n") + + +class Updater(QObject): + """ + This is the class that does the actual installation work. + """ + + finished = pyqtSignal() + + def __init__( + self, + featured_mod: str, + version: int | None = None, + modversions: dict | None = None, + silent: bool = False, + *args, + **kwargs, + ): + """ + Constructor + """ + super().__init__(*args, **kwargs) + + self.progress = UpdaterProgressDialog(None, silent) + self.progress.aborted.connect(self.abort) + + self.worker_thread = QThread() + self.worker = UpdaterWorker(featured_mod, version, modversions, silent) + self.worker.moveToThread(self.worker_thread) + + self.worker.done.connect(self.on_update_done) + self.worker.current_mod.connect(self.progress.on_processed_mod_changed) + self.worker.hash_progress.connect(self.progress.on_hash_progress) + self.worker.extras_progress.connect(self.progress.on_movies_progress) + self.worker.game_progress.connect(self.progress.on_game_progress) + self.worker.mod_progress.connect(self.progress.on_mod_progress) + self.worker.download_progress.connect(self.progress.on_download_progress) + self.worker.download_finished.connect(self.progress.on_download_finished) + self.worker.download_started.connect(self.progress.on_download_started) + self.worker_thread.started.connect(self.worker.do_update) + self.result = UpdaterResult.NONE + + def run(self) -> UpdaterResult: + clear_log() + log(f"Update started at {timestamp()}", logger) + log(f"Using appdata: {util.APPDATA_DIR}", logger) + + self.progress.show() + self.worker_thread.start() + + loop = QEventLoop() + self.worker_thread.finished.connect(loop.quit) + loop.exec() + + self.progress.accept() + log(f"Update finished at {timestamp()}", logger) + return self.result + + def on_update_done(self, result: UpdaterResult) -> None: + self.result = result + self.handle_result_if_needed(result) + self.stop_thread() + + def handle_result_if_needed(self, result: UpdaterResult) -> None: + # Integrated handlers for the various things that could go wrong + if result == UpdaterResult.CANCEL: + pass # The user knows damn well what happened here. + elif result == UpdaterResult.FAILURE: + failure_dialog() + + def abort(self) -> None: + self.worker.abort() + + def stop_thread(self) -> None: + self.worker_thread.quit() + self.worker_thread.wait(1000) diff --git a/src/fa/game_updater/worker.py b/src/fa/game_updater/worker.py new file mode 100644 index 000000000..25b93eb7d --- /dev/null +++ b/src/fa/game_updater/worker.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import logging +import os +import shutil +import stat +from functools import wraps + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkAccessManager + +import util +from api.featured_mod_api import FeaturedModApiConnector +from api.featured_mod_api import FeaturedModFilesApiConnector +from api.models.FeaturedMod import FeaturedMod +from api.models.FeaturedModFile import FeaturedModFile +from config import Settings +from downloadManager import FileDownload +from fa.game_updater.misc import ProgressInfo +from fa.game_updater.misc import UpdaterCancellation +from fa.game_updater.misc import UpdaterFailure +from fa.game_updater.misc import UpdaterResult +from fa.game_updater.misc import log +from fa.game_updater.patcher import FAPatcher +from fa.utils import unpack_movies_and_sounds + +logger = logging.getLogger(__name__) + + +class UpdaterWorker(QObject): + done = pyqtSignal(UpdaterResult) + + current_mod = pyqtSignal(ProgressInfo) + hash_progress = pyqtSignal(ProgressInfo) + extras_progress = pyqtSignal(ProgressInfo) + game_progress = pyqtSignal(ProgressInfo) + mod_progress = pyqtSignal(ProgressInfo) + + download_started = pyqtSignal(FileDownload) + download_progress = pyqtSignal(FileDownload) + download_finished = pyqtSignal(FileDownload) + + def __init__( + self, + featured_mod: str, + version: int | None, + modversions: dict | None, + silent: bool = False, + ) -> None: + super().__init__() + self.featured_mod = featured_mod + self.version = version + self.modversions = modversions + self.silent = silent + + self.nam = QNetworkAccessManager(self) + self.result = UpdaterResult.NONE + + keep_cache = not Settings.get("cache/do_not_keep", type=bool, default=True) + in_session_cache = Settings.get("cache/in_session", type=bool, default=False) + self.cache_enabled = keep_cache or in_session_cache + + self.dlers: list[FileDownload] = [] + self._interruption_requested = False + self.fa_patcher = FAPatcher() + + def _check_interruption(fn): + @wraps(fn) + def wrapper(self, *args, **kwargs): + if self._interruption_requested: + raise UpdaterCancellation("User aborted the update") + return fn(self, *args, **kwargs) + return wrapper + + def get_files_to_update(self, mod_id: str, version: str) -> list[dict]: + return FeaturedModFilesApiConnector(mod_id, version).get_files() + + def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod: + return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name) + + @staticmethod + def _filter_files_to_update( + files: list[FeaturedModFile], + precalculated_md5s: dict[str, str], + ) -> list[FeaturedModFile]: + return [file for file in files if precalculated_md5s[file.md5] != file.md5] + + @_check_interruption + def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]: + total = len(files) + result = {} + for index, file in enumerate(files, start=1): + filepath = os.path.join(util.APPDATA_DIR, file.group, file.name) + result[file.md5] = util.md5(filepath) + self.hash_progress.emit(ProgressInfo(index, total, file.name)) + return result + + def fetch_fmod_file(self, file: FeaturedModFile) -> None: + target_path = os.path.join(util.APPDATA_DIR, file.group, file.name) + url = file.cacheable_url + self._download(target_path, url, {file.hmac_parameter: file.hmac_token}) + + def move_from_cache(self, file: FeaturedModFile) -> None: + src_dir = os.path.join(util.APPDATA_DIR, file.group) + cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group) + if os.path.exists(os.path.join(cache_dir, file.md5)): + shutil.move( + os.path.join(cache_dir, file.md5), + os.path.join(src_dir, file.name), + ) + + def move_to_cache( + self, + file: FeaturedModFile, + precalculated_md5s: dict[str, str] | None = None, + ) -> None: + precalculated_md5s = precalculated_md5s or {} + src_dir = os.path.join(util.APPDATA_DIR, file.group) + cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group) + if os.path.exists(os.path.join(src_dir, file.name)): + md5 = precalculated_md5s.get(file.md5, util.md5(os.path.join(src_dir, file.name))) + shutil.move( + os.path.join(src_dir, file.name), + os.path.join(cache_dir, md5), + ) + util.setAccessTime(os.path.join(cache_dir, md5)) + + @staticmethod + def _is_cached(file: FeaturedModFile) -> bool: + cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.md5) + return os.path.isfile(cached_file) + + def ensure_subdirs(self, files: list[FeaturedModFile]) -> None: + for file in files: + cache = os.path.join(util.GAME_CACHE_DIR, file.group) + os.makedirs(cache, exist_ok=True) + os.makedirs(util.GAMEDATA_DIR, exist_ok=True) + + @_check_interruption + def update_file( + self, + file: FeaturedModFile, + precalculated_md5s: dict[str, str] | None = None, + ) -> None: + self.move_to_cache(file, precalculated_md5s) + if self._is_cached(file): + self.move_from_cache(file) + else: + self.fetch_fmod_file(file) + + @_check_interruption + def update_files(self, files: list[FeaturedModFile]) -> None: + """ + Updates the files in the destination + subdirectory of the Forged Alliance path. + """ + self.ensure_subdirs(files) + md5s = self._calculate_md5s(files) + + to_update = self._filter_files_to_update(files, md5s) + total = len(to_update) + + if total == 0: + self.mod_progress.emit(ProgressInfo(0, 0, "")) + + for index, file in enumerate(to_update, start=1): + self.update_file(file, md5s) + self.mod_progress.emit(ProgressInfo(index, total, file.name)) + + self.unpack_movies_and_sounds(files) + self.patch_fa_exe_if_needed(files) + + @_check_interruption + def unpack_movies_and_sounds(self, files: list[FeaturedModFile]) -> None: + logger.info("Checking files for movies and sounds") + + total = len(files) + for index, file in enumerate(files, start=1): + unpack_movies_and_sounds(file) + self.extras_progress.emit(ProgressInfo(index, total, file.name)) + + def prepare_bin_FAF(self) -> None: + """ + Creates all necessary files in the binFAF folder, which contains + a modified copy of all that is in the standard bin folder of + Forged Alliance + """ + # now we check if we've got a binFAF folder + FABindir = os.path.join(Settings.get("ForgedAlliance/app/path"), "bin") + FAFdir = util.BIN_DIR + + # Try to copy without overwriting, but fill in any missing files, + # otherwise it might miss some files to update + root_src_dir = FABindir + root_dst_dir = FAFdir + + for src_dir, _, files in os.walk(root_src_dir): + dst_dir = src_dir.replace(root_src_dir, root_dst_dir) + os.makedirs(dst_dir, exist_ok=True) + total_files = len(files) + for index, file in enumerate(files, start=1): + src_file = os.path.join(src_dir, file) + dst_file = os.path.join(dst_dir, file) + if not os.path.exists(dst_file): + shutil.copy(src_file, dst_dir) + st = os.stat(dst_file) + # make all files we were considering writable, because we may + # need to patch them + os.chmod(dst_file, st.st_mode | stat.S_IWRITE) + self.game_progress.emit(ProgressInfo(index, total_files, file)) + + def _download(self, target_path: str, url: str, params: dict) -> None: + logger.info(f"Updater: Downloading {url}") + dler = FileDownload(target_path, self.nam, url, params) + dler.blocksize = None + dler.progress.connect(self.download_progress.emit) + dler.start.connect(self.download_started.emit) + dler.finished.connect(self.download_finished.emit) + self.dlers.append(dler) + dler.run() + dler.waitForCompletion() + if dler.canceled: + raise UpdaterCancellation(dler.error_string()) + elif dler.failed(): + raise UpdaterFailure(f"Update failed: {dler.error_sring()}") + + def patch_fa_executable(self, exe_info: FeaturedModFile) -> None: + exe_path = os.path.join(util.BIN_DIR, exe_info.name) + version = int(self._resolve_base_version(exe_info)) + + if version == self.fa_patcher.read_version(exe_path): + return + + for attempt in range(10): # after download antimalware can interfere in our update process + if self.fa_patcher.patch(exe_path, version): + return + logger.warning(f"Could not open fa exe for patching. Attempt #{attempt + 1}") + self.thread().msleep(500) + else: + raise UpdaterFailure("Could not update FA exe to the correct version") + + def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None: + for file in files: + if file.name == Settings.get("game/exe-name"): + self.patch_fa_executable(file) + return + + @_check_interruption + def update_featured_mod(self, modname: str, modversion: str) -> list[FeaturedModFile]: + fmod = self.get_featured_mod_by_name(modname) + files = self.get_files_to_update(fmod.xd, modversion) + self.update_files(files) + return files + + def _resolve_modversion(self) -> str: + if self.modversions: + return str(max(self.modversions.values())) + return "latest" + + def _resolve_base_version(self, exe_info: FeaturedModFile | None = None) -> str: + if self.version: + return str(self.version) + if exe_info: + return str(exe_info.version) + return "latest" + + def do_update(self) -> None: + """ The core function that does most of the actual update work.""" + try: + # Prepare FAF directory & all necessary files + self.prepare_bin_FAF() + # Update the mod if it's requested + if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"): + self.current_mod.emit(ProgressInfo(1, 1, self.featured_mod)) + self.update_featured_mod(self.featured_mod, self._resolve_base_version()) + else: + # update faf first + self.current_mod.emit(ProgressInfo(1, 2, "FAF")) + self.update_featured_mod("faf", self._resolve_base_version()) + # update featured mod then + self.current_mod.emit(ProgressInfo(2, 2, self.featured_mod)) + self.update_featured_mod(self.featured_mod, self._resolve_modversion()) + except UpdaterCancellation as e: + log(f"CANCELLED: {e}", logger) + self.result = UpdaterResult.CANCEL + except Exception as e: + log(f"EXCEPTION: {e}", logger) + logger.exception(f"EXCEPTION: {e}") + self.result = UpdaterResult.FAILURE + else: + self.result = UpdaterResult.SUCCESS + self.done.emit(self.result) + + def abort(self) -> None: + for dler in self.dlers: + dler.cancel() + self._interruption_requested = True diff --git a/src/fa/maps.py b/src/fa/maps.py index c68261fa9..c973e9b87 100644 --- a/src/fa/maps.py +++ b/src/fa/maps.py @@ -1,33 +1,29 @@ # system imports import logging -import string -import sys -from urllib.error import HTTPError -from PyQt5 import QtCore, QtGui -import io -import util import os +import shutil import stat +import string import struct -import shutil -import urllib.request, urllib.error, urllib.parse -import zipfile +import sys import tempfile -import re +import zipfile +from typing import Callable + +from PyQt6 import QtCore +from PyQt6 import QtGui + # module imports -import fa +import util # local imports from config import Settings -from vault.dialogs import downloadVaultAssetNoMsg +from mapGenerator.mapgenUtils import isGeneratedMap +from model.game import OFFICIAL_MAPS as maps +from vaults.dialogs import downloadVaultAssetNoMsg logger = logging.getLogger(__name__) route = Settings.get('content/host') -VAULT_PREVIEW_ROOT = "{}/faf/vault/map_previews/small/".format(route) -VAULT_DOWNLOAD_ROOT = "{}/faf/vault/".format(route) -VAULT_COUNTER_ROOT = "{}/faf/vault/map_vault/inc_downloads.php".format(route) - -from model.game import OFFICIAL_MAPS as maps __exist_maps = None @@ -42,14 +38,15 @@ def isBase(mapname): def getUserMaps(): maps = [] if os.path.isdir(getUserMapsFolder()): - maps = os.listdir(getUserMapsFolder()) + for _dir in os.listdir(getUserMapsFolder()): + maps.append(_dir.lower()) return maps def getDisplayName(filename): """ - Tries to return a pretty name for the map (for official maps, it looks up the name) - For nonofficial maps, it tries to clean up the filename + Tries to return a pretty name for the map (for official maps, it looks up + the name) For nonofficial maps, it tries to clean up the filename """ if str(filename) in maps: return maps[filename][0] @@ -61,19 +58,19 @@ def getDisplayName(filename): return pretty -def name2link(name): +def name2link(name: str) -> str: """ Returns a quoted link for use with the VAULT_xxxx Urls TODO: This could be cleaned up a little later. """ - return urllib.parse.quote("maps/" + name + ".zip") + return Settings.get("vault/map_download_url").format(name=name) def link2name(link): """ Takes a link and tries to turn it into a local mapname """ - name = link.rsplit("/")[1].rsplit(".zip")[0] + name = link.rsplit("/", 1)[1].rsplit(".zip")[0] logger.info("Converted link '" + link + "' to name '" + name + "'") return name @@ -107,7 +104,7 @@ def isMapFolderValid(folder): baseName + ".scmap", baseName + "_save.lua", baseName + "_scenario.lua", - baseName + "_script.lua" + baseName + "_script.lua", } files_present = set(os.listdir(folder)) @@ -166,7 +163,8 @@ def getBaseMapsFolder(): if gamepath: return os.path.join(gamepath, "maps") else: - return "maps" # This most likely isn't the valid maps folder, but it's the best guess. + # This most likely isn't the valid maps folder, but it's the best guess + return "maps" def getUserMapsFolder(): @@ -178,10 +176,11 @@ def getUserMapsFolder(): "My Games", "Gas Powered Games", "Supreme Commander Forged Alliance", - "Maps") + "Maps", + ) -def genPrevFromDDS(sourcename, destname, small=False): +def genPrevFromDDS(sourcename: str, destname: str, small: bool = False) -> None: """ this opens supcom's dds file (format: bgra8888) and saves to png """ @@ -194,29 +193,35 @@ def genPrevFromDDS(sourcename, destname, small=False): img += buf[:3] + buf[4:7] + buf[8:11] + buf[12:15] file.close() - size = int((len(img)/3) ** (1.0/2)) + size = int((len(img) / 3) ** (1.0 / 2)) if small: imageFile = QtGui.QImage( img, size, size, - QtGui.QImage.Format_RGB888).rgbSwapped().scaled( - 100, - 100, - transformMode=QtCore.Qt.SmoothTransformation) + QtGui.QImage.Format.Format_RGB888, + ).rgbSwapped().scaled( + 100, + 100, + transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, + ) else: imageFile = QtGui.QImage( img, size, size, - QtGui.QImage.Format_RGB888).rgbSwapped() + QtGui.QImage.Format.Format_RGB888, + ).rgbSwapped() imageFile.save(destname) except IOError: logger.debug('IOError exception in genPrevFromDDS', exc_info=True) raise -def __exportPreviewFromMap(mapname, positions=None): +def export_preview_from_map( + mapname: str | None, + positions: dict | None = None, +) -> None | dict[str, None | str | list[str]]: """ This method auto-upgrades the maps to have small and large preview images """ @@ -238,7 +243,11 @@ def __exportPreviewFromMap(mapname, positions=None): return previews mapname = os.path.basename(mapdir).lower() - mapfilename = os.path.join(mapdir, mapname.split(".")[0]+".scmap") + mapname_no_version, *_ = mapname.partition(".") + if isGeneratedMap(mapname): + mapfilename = os.path.join(mapdir, mapname + ".scmap") + else: + mapfilename = os.path.join(mapdir, f"{mapname_no_version}.scmap") mode = os.stat(mapdir)[0] if not (mode and stat.S_IWRITE): @@ -248,10 +257,21 @@ def __exportPreviewFromMap(mapname, positions=None): if not os.path.isdir(mapdir): os.mkdir(mapdir) - previewsmallname = os.path.join(mapdir, mapname + ".small.png") - previewlargename = os.path.join(mapdir, mapname + ".large.png") - previewddsname = os.path.join(mapdir, mapname + ".dds") - cachepngname = os.path.join(util.MAP_PREVIEW_DIR, mapname + ".png") + def plausible_mapname_preview_name(suffix: str) -> str: + casefold_names = ( + f"{mapname}{suffix}".casefold(), + f"{mapname_no_version}{suffix}".casefold(), + ) + for entry in os.listdir(mapdir): + plausible_preview = os.path.join(mapdir, entry) + if os.path.isfile(plausible_preview) and entry.casefold() in casefold_names: + return plausible_preview + return suffix + + previewsmallname = plausible_mapname_preview_name(".small.png") + previewlargename = plausible_mapname_preview_name(".large.png") + previewddsname = plausible_mapname_preview_name(".dds") + cachepngname = os.path.join(util.MAP_PREVIEW_SMALL_DIR, mapname + ".png") logger.debug("Generating preview from user maps for: " + mapname) logger.debug("Using directory: " + mapdir) @@ -259,9 +279,9 @@ def __exportPreviewFromMap(mapname, positions=None): # Unknown / Unavailable mapname? if not os.path.isfile(mapfilename): logger.warning( - "Unable to find the .scmap for: {}, was looking here: {}".format( - mapname, mapfilename - )) + "Unable to find the .scmap for: {}, was looking here: " + "{}".format(mapname, mapfilename), + ) return previews # Small preview already exists? @@ -303,12 +323,15 @@ def __exportPreviewFromMap(mapname, positions=None): unk_32 = struct.unpack('i', mapfile.read(4))[0] unk_16 = struct.unpack('h', mapfile.read(2))[0] """ - mapfile.seek(30) # Shortcut. Maybe want to clean out some of the magic numbers some day + # Shortcut. Maybe want to clean out some of the magic numbers some day + mapfile.seek(30) + size = struct.unpack('i', mapfile.read(4))[0] data = mapfile.read(size) # version_minor = struct.unpack('i', mapfile.read(4))[0] mapfile.close() - # logger.debug("SCMAP version %i.%i" % (version_major, version_minor)) + # logger.debug("SCMAP version {}.{}".format(version_major, + # version_minor)) try: with open(previewddsname, "wb") as previewfile: @@ -318,33 +341,41 @@ def __exportPreviewFromMap(mapname, positions=None): if os.path.isfile(previewddsname): previews["tozip"].append(previewddsname) else: - logger.debug("Failed to make DDS for: " + mapname) + logger.debug("Failed to make DDS for: {}".format(mapname)) return previews except IOError: pass if not smallExists: - logger.debug("Making small preview from DDS for: " + mapname) + logger.debug("Making small preview from DDS for: {}".format(mapname)) try: genPrevFromDDS(previewddsname, previewsmallname, small=True) previews["tozip"].append(previewsmallname) shutil.copyfile(previewsmallname, cachepngname) previews["cache"] = cachepngname except IOError: - logger.debug("Failed to make small preview for: " + mapname) + logger.debug( + "Failed to make small preview for: {}".format(mapname), + ) return previews if not largeExists: - logger.debug("Making large preview from DDS for: " + mapname) + logger.debug("Making large preview from DDS for: {}".format(mapname)) if not isinstance(positions, dict): - logger.debug("Icon positions were not passed or they were wrong for: " + mapname) + logger.debug( + "Icon positions were not passed or they were wrong " + "for: {}".format(mapname), + ) return previews try: genPrevFromDDS(previewddsname, previewlargename, small=False) mapimage = util.THEME.pixmap(previewlargename) - armyicon = util.THEME.pixmap("vault/map_icons/army.png").scaled(8, 9, 1, 1) - massicon = util.THEME.pixmap("vault/map_icons/mass.png").scaled(8, 8, 1, 1) - hydroicon = util.THEME.pixmap("vault/map_icons/hydro.png").scaled(10, 10, 1, 1) + armypixmap = util.THEME.pixmap("vaults/map_icons/army.png") + masspixmap = util.THEME.pixmap("vaults/map_icons/mass.png") + hydropixmap = util.THEME.pixmap("vaults/map_icons/hydro.png") + massicon = masspixmap.scaled(8, 8, 1, 1) + armyicon = armypixmap.scaled(8, 9, 1, 1) + hydroicon = hydropixmap.scaled(10, 10, 1, 1) painter = QtGui.QPainter() @@ -355,22 +386,25 @@ def __exportPreviewFromMap(mapname, positions=None): if "hydro" in positions: for pos in positions["hydro"]: target = QtCore.QRectF( - positions["hydro"][pos][0]-5, - positions["hydro"][pos][1]-5, 10, 10) + positions["hydro"][pos][0] - 5, + positions["hydro"][pos][1] - 5, 10, 10, + ) source = QtCore.QRectF(0.0, 0.0, 10.0, 10.0) painter.drawPixmap(target, hydroicon, source) if "mass" in positions: for pos in positions["mass"]: target = QtCore.QRectF( - positions["mass"][pos][0]-4, - positions["mass"][pos][1]-4, 8, 8) + positions["mass"][pos][0] - 4, + positions["mass"][pos][1] - 4, 8, 8, + ) source = QtCore.QRectF(0.0, 0.0, 8.0, 8.0) painter.drawPixmap(target, massicon, source) if "army" in positions: for pos in positions["army"]: target = QtCore.QRectF( - positions["army"][pos][0]-4, - positions["army"][pos][1]-4, 8, 9) + positions["army"][pos][0] - 4, + positions["army"][pos][1] - 4, 8, 9, + ) source = QtCore.QRectF(0.0, 0.0, 8.0, 9.0) painter.drawPixmap(target, armyicon, source) painter.end() @@ -382,63 +416,69 @@ def __exportPreviewFromMap(mapname, positions=None): return previews -iconExtensions = ["png"] # "jpg" removed to have fewer of those costly 404 misses. + +# "jpg" removed to have fewer of those costly 404 misses. +iconExtensions = ["png"] def preview(mapname, pixmap=False): try: # Try to load directly from cache for extension in iconExtensions: - img = os.path.join(util.MAP_PREVIEW_DIR, mapname + "." + extension) + img = os.path.join( + util.MAP_PREVIEW_SMALL_DIR, + mapname + "." + extension, + ) if os.path.isfile(img): logger.log(5, "Using cached preview image for: " + mapname) return util.THEME.icon(img, False, pixmap) # Try to find in local map folder - img = __exportPreviewFromMap(mapname) - - if img and 'cache' in img and img['cache'] and os.path.isfile(img['cache']): + img = export_preview_from_map(mapname) + + if ( + img + and 'cache' in img + and img['cache'] + and os.path.isfile(img['cache']) + ): logger.debug("Using fresh preview image for: " + mapname) return util.THEME.icon(img['cache'], False, pixmap) + if isGeneratedMap(mapname): + return util.THEME.icon("games/generated_map.png") + return None - except: - logger.error("Error raised in maps.preview(...) for " + mapname) - logger.error("Map Preview Exception", exc_info=sys.exc_info()) + except BaseException: + logger.debug("Error raised in maps.preview(...) for " + mapname) + logger.debug("Map Preview Exception", exc_info=sys.exc_info()) -def downloadMap(name, silent=False): +def downloadMap(name: str, silent: bool = False) -> bool: """ Download a map from the vault with the given name """ link = name2link(name) ret, msg = _doDownloadMap(name, link, silent) - if not ret: + if not ret and msg is None: name = name.replace(" ", "_") link = name2link(name) ret, msg = _doDownloadMap(name, link, silent) - if not ret: - msg() - return ret - - # Count the map downloads - try: - url = VAULT_COUNTER_ROOT + "?map=" + urllib.parse.quote(link) - req = urllib.request.Request(url, headers={'User-Agent': "FAF Client"}) - urllib.request.urlopen(req) - logger.debug("Successfully sent download counter request for: " + url) - except: - logger.warning("Request to map download counter failed for: " + url) - logger.error("Download Count Exception", exc_info=sys.exc_info()) - - return True + if not ret and msg is not None: + msg() + return ret -def _doDownloadMap(name, link, silent): - url = VAULT_DOWNLOAD_ROOT + link - logger.debug("Getting map from: " + url) - return downloadVaultAssetNoMsg(url, getUserMapsFolder(), lambda m, d: True, - name, "map", silent) +def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None] | None]: + logger.debug(f"Getting map from: {link}") + return downloadVaultAssetNoMsg( + url=link, + target_dir=getUserMapsFolder(), + exist_handler=lambda m, d: True, + name=name, + category="map", + silent=silent, + ) def processMapFolderForUpload(mapDir, positions): @@ -446,7 +486,7 @@ def processMapFolderForUpload(mapDir, positions): Zipping the file and creating thumbnails """ # creating thumbnail - files = __exportPreviewFromMap(mapDir, positions)["tozip"] + files = export_preview_from_map(mapDir, positions)["tozip"] # abort zipping if there is insufficient previews if len(files) != 3: logger.debug("Insufficient previews for making an archive.") @@ -467,7 +507,10 @@ def processMapFolderForUpload(mapDir, positions): zipped = zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED) for filename in files: - zipped.write(filename, os.path.join(os.path.basename(mapDir), os.path.basename(filename))) + zipped.write( + filename, + os.path.join(os.path.basename(mapDir), os.path.basename(filename)), + ) temp.flush() diff --git a/src/fa/mods.py b/src/fa/mods.py index b77e87f11..773ba4d1d 100644 --- a/src/fa/mods.py +++ b/src/fa/mods.py @@ -1,59 +1,69 @@ -from PyQt5 import QtWidgets -import fa -import modvault import logging + +from PyQt6 import QtWidgets + import config +from api.sim_mod_updater import SimModFiles +from vaults.modvault.utils import downloadMod +from vaults.modvault.utils import getInstalledMods +from vaults.modvault.utils import setActiveMods logger = logging.getLogger(__name__) -def checkMods(mods): # mods is a dictionary of uid-name pairs +def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name pairs """ Assures that the specified mods are available in FA, or returns False. Also sets the correct active mods in the ingame mod manager. """ - logger.info("Updating FA for mods %s" % ", ".join(mods)) - to_download = [] - inst = modvault.getInstalledMods() - uids = [mod.uid for mod in inst] - for uid in mods: - if uid not in uids: - to_download.append(uid) + logger.info("Updating FA for mods {}".format(", ".join(mods))) + + inst = set(mod.uid for mod in getInstalledMods()) + to_download = {uid: name for uid, name in mods.items() if uid not in inst} auto = config.Settings.get('mods/autodownload', default=False, type=bool) if not auto: - mod_names = ", ".join([mods[uid] for uid in mods]) + mod_names = ", ".join(mods.values()) msgbox = QtWidgets.QMessageBox() msgbox.setWindowTitle("Download Mod") - msgbox.setText("Seems that you don't have mods used in this game. Do you want to download them?
" + mod_names + "") - msgbox.setInformativeText("If you respond 'Yes to All' mods will be downloaded automatically in the future") - msgbox.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.YesToAll | QtWidgets.QMessageBox.No) - result = msgbox.exec_() - if result == QtWidgets.QMessageBox.No: + msgbox.setText( + "Seems that you don't have mods used in this game. Do " + "you want to download them?
{}".format(mod_names), + ) + msgbox.setInformativeText( + "If you respond 'Yes to All' mods will be " + "downloaded automatically in the future", + ) + msgbox.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.YesToAll + | QtWidgets.QMessageBox.StandardButton.No, + ) + result = msgbox.exec() + if result == QtWidgets.QMessageBox.StandardButton.No: return False - elif result == QtWidgets.QMessageBox.YesToAll: + elif result == QtWidgets.QMessageBox.StandardButton.YesToAll: config.Settings.set('mods/autodownload', True) - for uid in to_download: - # Spawn an update for the required mod - updater = fa.updater.Updater(uid, sim=True) - result = updater.run() - if result != fa.updater.Updater.RESULT_SUCCESS: - logger.warning("Failure getting {}: {}".format(uid, mods[uid])) + api_accessor = SimModFiles() + for uid, name in to_download.items(): + url = api_accessor.request_and_get_sim_mod_url_by_id(uid) + if not downloadMod(url, name): + logger.warning(f"Failure getting {name!r} with uid {uid!r}") return False actual_mods = [] - inst = modvault.getInstalledMods() - uids = {} - for mod in inst: - uids[mod.uid] = mod - for uid in mods: + uids = {mod.uid: mod for mod in getInstalledMods()} + for uid, name in mods.items(): if uid not in uids: - QtWidgets.QMessageBox.warning(None, "Mod not Found", - "%s was apparently not installed correctly. Please check this." % mods[uid]) + QtWidgets.QMessageBox.warning( + None, + "Mod not Found", + f"{name} was apparently not installed correctly. Please check this.", + ) return actual_mods.append(uids[uid]) - if not modvault.setActiveMods(actual_mods): + if not setActiveMods(actual_mods): logger.warning("Couldn't set the active mods in the game.prefs file") return False diff --git a/src/fa/path.py b/src/fa/path.py index 9d533d47d..8c27dcc79 100644 --- a/src/fa/path.py +++ b/src/fa/path.py @@ -1,6 +1,7 @@ +import logging import os import sys -import logging + import config import util @@ -10,15 +11,22 @@ def steamPath(): try: import winreg - steam_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Software\\Valve\\Steam", 0, (winreg.KEY_WOW64_64KEY + winreg.KEY_ALL_ACCESS)) - return winreg.QueryValueEx(steam_key, "SteamPath")[0].replace("/", "\\") - except Exception as e: + steam_key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + "Software\\Valve\\Steam", + 0, + (winreg.KEY_WOW64_64KEY + winreg.KEY_ALL_ACCESS), + ) + query_value = winreg.QueryValueEx(steam_key, "SteamPath") + return query_value[0].replace("/", "\\") + except BaseException: return None def writeFAPathLua(): """ - Writes a small lua file to disk that helps the new SupComDataPath.lua find the actual install of the game + Writes a small lua file to disk that helps the new + SupComDataPath.lua find the actual install of the game """ name = os.path.join(util.APPDATA_DIR, "fa_path.lua") gamepath_fa = config.Settings.get("ForgedAlliance/app/path", type=str) @@ -28,33 +36,48 @@ def writeFAPathLua(): with open(name, "w+", encoding='utf-8') as lua: lua.write(code) lua.flush() - os.fsync(lua.fileno()) # Ensuring the file is absolutely, positively on disk. + # Ensuring the file is absolutely, positively on disk. + os.fsync(lua.fileno()) def typicalForgedAlliancePaths(): """ - Returns a list of the most probable paths where Supreme Commander: Forged Alliance might be installed + Returns a list of the most probable paths where Supreme Commander: + Forged Alliance might be installed """ pathlist = [ config.Settings.get("ForgedAlliance/app/path", "", type=str), # Retail path - os.path.expandvars("%ProgramFiles%\\THQ\\Gas Powered Games\\Supreme Commander - Forged Alliance"), + os.path.expandvars( + "%ProgramFiles%\\THQ\\Gas Powered Games\\" + "Supreme Commander - Forged Alliance", + ), # Direct2Drive Paths # ... allegedly identical to impulse paths - need to confirm this # Impulse/GameStop Paths - might need confirmation yet - os.path.expandvars("%ProgramFiles%\\Supreme Commander - Forged Alliance"), + os.path.expandvars( + "%ProgramFiles%\\Supreme Commander - Forged Alliance", + ), # Guessed Steam path - os.path.expandvars("%ProgramFiles%\\Steam\\steamapps\\common\\supreme commander forged alliance") + os.path.expandvars( + "%ProgramFiles%\\Steam\\steamapps\\common\\" + "supreme commander forged alliance", + ), ] # Registry Steam path steam_path = steamPath() if steam_path: - pathlist.append(os.path.join(steam_path, "SteamApps", "common", "Supreme Commander Forged Alliance")) + pathlist.append( + os.path.join( + steam_path, "SteamApps", "common", + "Supreme Commander Forged Alliance", + ), + ) return list(filter(validatePath, pathlist)) @@ -76,14 +99,15 @@ def validatePath(path): # Reject or fix paths that end with a slash. # LATER: this can have all sorts of intelligent logic added - # Suggested: Check if the files are actually the right ones, if not, tell the user what's wrong with them. + # Suggested: Check if the files are actually the right ones, if not, + # tell the user what's wrong with them. if path.endswith("/"): return False if path.endswith("\\"): return False return True - except: + except BaseException: _, value, _ = sys.exc_info() logger.error("Path validation failed: " + str(value)) return False diff --git a/src/fa/play.py b/src/fa/play.py index 46a422eb3..1e72aaa8d 100644 --- a/src/fa/play.py +++ b/src/fa/play.py @@ -1,20 +1,27 @@ -from .game_process import instance - -from config import Settings import util +from config import Settings + +from .game_process import instance __author__ = 'Thygrrr' import logging + logger = logging.getLogger(__name__) -def build_argument_list(game_info, port, arguments=None, log_suffix=None): +def build_argument_list( + game_info, + port, + replayPort, + arguments=None, + log_suffix=None, +): """ - Compiles an argument list to run the game with POpen style process invocation methods. - Extends a potentially pre-existing argument list to allow for injection of special parameters + Compiles an argument list to run the game with POpen style process + invocation methods. Extends a potentially pre-existing argument list + to allow for injection of special parameters """ - import client arguments = arguments or [] if '/init' in arguments: @@ -22,9 +29,9 @@ def build_argument_list(game_info, port, arguments=None, log_suffix=None): # Init file arguments.append('/init') - arguments.append('init_{}.lua'.format(game_info.get('featured_mod', 'faf'))) - - arguments.append('/numgames {}'.format(client.instance.me.player.number_of_games)) + arguments.append( + 'init_{}.lua'.format(game_info.get('featured_mod', 'faf')), + ) # log file if Settings.get("game/logs", False, type=bool): @@ -32,17 +39,26 @@ def build_argument_list(game_info, port, arguments=None, log_suffix=None): if log_suffix is None: log_file = util.LOG_FILE_GAME else: - log_file = (util.LOG_FILE_GAME_PREFIX + - util.LOG_FILE_GAME_INFIX + - "{}".format(log_suffix) + ".log") - arguments.append('"' + log_file + '"') + log_file = ( + util.LOG_FILE_GAME_PREFIX + + util.LOG_FILE_GAME_INFIX + + "{}".format(log_suffix) + + ".log" + ) + arguments.append('"{}"'.format(log_file)) # Disable defunct bug reporter arguments.append('/nobugreport') # live replay arguments.append('/savereplay') - arguments.append('"gpgnet://localhost/' + str(game_info['uid']) + "/" + str(game_info['recorder']) + '.SCFAreplay"') + arguments.append( + '"gpgnet://localhost:{}/{}/{}.SCFAreplay"'.format( + replayPort, + game_info['uid'], + game_info['recorder'], + ), + ) # gpg server emulation arguments.append('/gpgnet 127.0.0.1:' + str(port)) @@ -50,10 +66,12 @@ def build_argument_list(game_info, port, arguments=None, log_suffix=None): return arguments -def run(game_info, port, arguments=None, log_suffix=None): +def run(game_info, port, replayPort, arguments=None, log_suffix=None): """ Launches Forged Alliance with the given arguments """ - logger.info("Play received arguments: %s" % arguments) - arguments = build_argument_list(game_info, port, arguments, log_suffix) + logger.info("Play received arguments: {}".format(arguments)) + arguments = build_argument_list( + game_info, port, replayPort, arguments, log_suffix, + ) return instance.run(game_info, arguments) diff --git a/src/fa/replay.py b/src/fa/replay.py index 01c2ef8e5..359eba922 100644 --- a/src/fa/replay.py +++ b/src/fa/replay.py @@ -1,138 +1,206 @@ import json +import logging import os -from PyQt5 import QtCore, QtWidgets + +import zstandard +from PyQt6 import QtCore +from PyQt6 import QtWidgets + import fa +import util from fa.check import check from fa.replayparser import replayParser -import util -from . import mods +from util.gameurl import GameUrl +from util.gameurl import GameUrlType -import logging logger = logging.getLogger(__name__) __author__ = 'Thygrrr' +def decompressReplayData(fileobj, compressionType): + if compressionType == "zstd": + decompressor = zstandard.ZstdDecompressor() + with decompressor.stream_reader(fileobj) as reader: + data = QtCore.QByteArray(reader.read()) + else: + b_data = fileobj.read() + data = QtCore.qUncompress(QtCore.QByteArray().fromBase64(b_data)) + return data + + def replay(source, detach=False): """ - Launches FA streaming the replay from the given location. Source can be a QUrl or a string + Launches FA streaming the replay from the given location. + Source can be a QUrl or a string """ logger.info("fa.exe.replay(" + str(source) + ", detach = " + str(detach)) - if fa.instance.available(): - version = None - featured_mod_versions = None - arg_string = None - replay_id = None - # Convert strings to URLs - if isinstance(source, str): - if os.path.isfile(source): - if source.endswith(".fafreplay"): # the new way of doing things - replay = open(source, "rt") + if not fa.instance.available(): + return False + + version = None + featured_mod_versions = None + arg_string = None + replay_id = None + compression_type = None + # Convert strings to URLs + if isinstance(source, str): + if os.path.isfile(source): + if source.endswith(".fafreplay"): + with open(source, "rb") as replay: info = json.loads(replay.readline()) - - binary = QtCore.qUncompress(QtCore.QByteArray.fromBase64(replay.read().encode('utf-8'))) - logger.info("Extracted " + str(binary.size()) + " bytes of binary data from .fafreplay.") - replay.close() + compression_type = info.get("compression") + try: + binary = decompressReplayData(replay, compression_type) + except Exception as e: + logger.error(f"Could not decompress replay: {e}") + binary = QtCore.QByteArray() + logger.info( + "Extracted {} bytes of binary data from " + ".fafreplay.".format(binary.size()), + ) if binary.size() == 0: logger.info("Invalid replay") - QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "Sorry, this replay is corrupted.") + QtWidgets.QMessageBox.critical( + None, + "FA Forever Replay", + "Sorry, this replay is corrupted.", + ) return False - scfa_replay = QtCore.QFile(os.path.join(util.CACHE_DIR, "temp.scfareplay")) - scfa_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate) - scfa_replay.write(binary) - scfa_replay.flush() - scfa_replay.close() - - mapname = info.get('mapname', None) - mod = info['featured_mod'] - replay_id = info['uid'] - featured_mod_versions = info.get('featured_mod_versions', None) - arg_string = scfa_replay.fileName() - - parser = replayParser(arg_string) - version = parser.getVersion() - - elif source.endswith(".scfareplay"): # compatibility mode - filename = os.path.basename(source) - if len(filename.split(".")) > 2: - mod = filename.rsplit(".", 2)[1] - logger.info("mod guessed from " + source + " is " + str(mod)) - else: - mod = "faf" # TODO: maybe offer a list of mods for the user. - logger.warning("no mod could be guessed, using fallback ('faf') ") - - mapname = None - arg_string = source - parser = replayParser(arg_string) - version = parser.getVersion() + scfa_replay = QtCore.QFile( + os.path.join(util.CACHE_DIR, "temp.scfareplay"), + ) + open_mode = ( + QtCore.QIODevice.OpenModeFlag.WriteOnly + | QtCore.QIODevice.OpenModeFlag.Truncate + ) + scfa_replay.open(open_mode) + scfa_replay.write(binary) + scfa_replay.flush() + scfa_replay.close() + + mapname = info.get('mapname') + mod = info['featured_mod'] + replay_id = info['uid'] + featured_mod_versions = info.get('featured_mod_versions') + arg_string = scfa_replay.fileName() + + parser = replayParser(arg_string) + version = parser.getVersion() + if mapname == "None": + mapname = parser.getMapName() + + elif source.endswith(".scfareplay"): # compatibility mode + filename = os.path.basename(source) + if len(filename.split(".")) > 2: + mod = filename.rsplit(".", 2)[1] + logger.info( + "mod guessed from {} is {}".format(source, mod), + ) else: - QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "Sorry, FAF has no idea how to replay " - "this file:
" + source + "") - - logger.info("Replaying " + str(arg_string) + " with mod " + str(mod) + " on map " + str(mapname)) - - # Wrap up file path in "" to ensure proper parsing by FA.exe - arg_string = '"' + arg_string + '"' - + # TODO: maybe offer a list of mods for the user. + mod = "faf" + logger.warning( + "no mod could be guessed, using " + "fallback ('faf') ", + ) + + mapname = None + arg_string = source + parser = replayParser(arg_string) + version = parser.getVersion() else: - source = QtCore.QUrl( - source) # Try to interpret the string as an actual url, it may come from the command line - - if isinstance(source, QtCore.QUrl): - url = source - # Determine if it's a faflive url - if url.scheme() == "faflive": - mod = QtCore.QUrlQuery(url).queryItemValue("mod") - mapname = QtCore.QUrlQuery(url).queryItemValue("map") - replay_id = url.path().split("/")[0] - # whip the URL into shape so ForgedAllianceForever.exe understands it - arg_url = QtCore.QUrl(url) - arg_url.setScheme("gpgnet") - arg_url.setQuery(QtCore.QUrlQuery("")) - arg_string = arg_url.toString() - else: - QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "App doesn't know how to play replays from " - "that scheme:
" + url.scheme() + "") - return False - - # We couldn't construct a decent argument format to tell ForgedAlliance for this replay - if not arg_string: - QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "App doesn't know how to play replays from that " - "source:
" + str(source) + "") - return False - - # Launch preparation: Start with an empty arguments list - arguments = ['/replay', arg_string] - - # Proper mod loading code - mod = "faf" if mod == "ladder1v1" else mod - - if '/init' not in arguments: - arguments.append('/init') - arguments.append("init_" + mod + ".lua") - - # Disable defunct bug reporter - arguments.append('/nobugreport') + QtWidgets.QMessageBox.critical( + None, + "FA Forever Replay", + ( + "Sorry, FAF has no idea how to replay " + "this file:
{}".format(source) + ), + ) + + logger.info( + "Replaying {} with mod {} on map {}" + .format(arg_string, mod, mapname), + ) + + # Wrap up file path in "" to ensure proper parsing by FA.exe + arg_string = '"' + arg_string + '"' - # log file - arguments.append("/log") - arguments.append('"' + util.LOG_FILE_REPLAY + '"') - - if replay_id: - arguments.append("/replayid") - arguments.append(str(replay_id)) - - # Update the game appropriately - if not check(mod, mapname, version, featured_mod_versions): - logger.error("Can't watch replays without an updated Forged Alliance game!") - return False - - if fa.instance.run(None, arguments, detach): - logger.info("Viewing Replay.") - return True else: - logger.error("Replaying failed. Guru meditation: {}".format(arguments)) + # Try to interpret the string as an actual url, it may come + # from the command line + source = QtCore.QUrl(source) + + if isinstance(source, GameUrl): + url = source.to_url() + # Determine if it's a faflive url + if source.game_type == GameUrlType.LIVE_REPLAY: + mod = source.mod + mapname = source.map + replay_id = source.uid + # whip the URL into shape so ForgedAllianceForever.exe + # understands it + url.setScheme("gpgnet") + url.setQuery(QtCore.QUrlQuery("")) + arg_string = url.toString() + else: + QtWidgets.QMessageBox.critical( + None, + "FA Forever Replay", + ( + "App doesn't know how to play replays from " + "that scheme:
{}".format(url.scheme()) + ), + ) return False + + # We couldn't construct a decent argument format to tell + # ForgedAlliance for this replay + if not arg_string: + QtWidgets.QMessageBox.critical( + None, + "FA Forever Replay", + ( + "App doesn't know how to play replays from that " + "source:
{}".format(source) + ), + ) + return False + + # Launch preparation: Start with an empty arguments list + arguments = ['/replay', arg_string] + + # Proper mod loading code + mod = "faf" if mod == "ladder1v1" else mod + + if '/init' not in arguments: + arguments.append('/init') + arguments.append("init_" + mod + ".lua") + + # Disable defunct bug reporter + arguments.append('/nobugreport') + + # log file + arguments.append("/log") + arguments.append('"' + util.LOG_FILE_REPLAY + '"') + + if replay_id: + arguments.append("/replayid") + arguments.append(str(replay_id)) + + # Update the game appropriately + if not check(mod, mapname, version, featured_mod_versions): + msg = "Can't watch replays without an updated Forged Alliance game!" + logger.error(msg) + return False + + if fa.instance.run(None, arguments, detach): + logger.info("Viewing Replay.") + return True + else: + logger.error("Replaying failed. Guru meditation: {}".format(arguments)) + return False diff --git a/src/fa/replayparser.py b/src/fa/replayparser.py index 4fa8e45a1..6bc708e1b 100644 --- a/src/fa/replayparser.py +++ b/src/fa/replayparser.py @@ -6,11 +6,11 @@ class replayParser: def __init__(self, filepath): self.file = filepath - def __readLine(self, offset, bin): + def __readLine(self, offset, bin_): line = b'' while True: - char = struct.unpack("s", bin[offset:offset+1]) + char = struct.unpack("s", bin_[offset:offset + 1]) offset = offset + 1 if char[0] == b'\r': @@ -28,12 +28,21 @@ def __readLine(self, offset, bin): return offset, line def getVersion(self): - f = open(self.file, 'rb') - bin = f.read() - offset = 0 - offset, supcomVersion = self.__readLine(offset, bin) - f.close() + with open(self.file, 'rb') as f: + bin_ = f.read() + offset = 0 + offset, supcomVersion = self.__readLine(offset, bin_) if not supcomVersion.startswith("Supreme Commander v1"): return None else: - return supcomVersion.split(".")[-1] + return int(supcomVersion.split(".")[-1]) + + def getMapName(self): + with open(self.file, 'rb') as f: + bin_ = f.read() + offset = 45 + offset, mapname = self.__readLine(offset, bin_) + if not mapname.strip().startswith("/maps/"): + return 'None' + else: + return mapname.split('/')[2] diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py index 199a698ee..02eceea7c 100644 --- a/src/fa/replayserver.py +++ b/src/fa/replayserver.py @@ -1,53 +1,74 @@ +from __future__ import annotations -from PyQt5 import QtCore, QtNetwork, QtWidgets - -import os -import logging -import util -import fa import json +import logging +import os import time +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6 import QtWidgets + +import fa +import util from config import Settings -INTERNET_REPLAY_SERVER_HOST = Settings.get('replay_server/host') -INTERNET_REPLAY_SERVER_PORT = Settings.get('replay_server/port') +GPGNET_HOST = "lobby.faforever.com" +GPGNET_PORT = 8000 + +DEFAULT_LIVE_REPLAY = True -from . import DEFAULT_LIVE_REPLAY -from . import DEFAULT_RECORD_REPLAY -class ReplayRecorder(QtCore.QObject): +class ReplayRecorder(QtCore.QObject): """ - This is a simple class that takes all the FA replay data input from its inputSocket, writes it to a file, - and relays it to an internet server via its relaySocket. + This is a simple class that takes all the FA replay data input from + its inputSocket, writes it to a file, and relays it to an internet + server via its relaySocket. """ __logger = logging.getLogger(__name__) - def __init__(self, parent, local_socket, *args, **kwargs): + def __init__( + self, + parent: ReplayServer, + local_socket: QtNetwork.QTcpSocket, + *args, + **kwargs, + ) -> None: QtCore.QObject.__init__(self, *args, **kwargs) self.parent = parent self.inputSocket = local_socket - self.inputSocket.setSocketOption(QtNetwork.QTcpSocket.KeepAliveOption, 1) + self.inputSocket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1) self.inputSocket.readyRead.connect(self.readDatas) self.inputSocket.disconnected.connect(self.inputDisconnected) - self.__logger.info("FA connected locally.") + self.__logger.info("FA connected locally.") # Create a file to write the replay data into self.replayData = QtCore.QByteArray() self.replayInfo = fa.instance._info - + + self._host = Settings.get('replay_server/host') + self._port = Settings.get('replay_server/port', type=int) # Open the relay socket to our server self.relaySocket = QtNetwork.QTcpSocket(self.parent) - self.relaySocket.connectToHost(INTERNET_REPLAY_SERVER_HOST, INTERNET_REPLAY_SERVER_PORT) - - if util.settings.value("fa.live_replay", DEFAULT_LIVE_REPLAY, type=bool): - if self.relaySocket.waitForConnected(1000): # Maybe make this asynchronous - self.__logger.debug("internet replay server " + self.relaySocket.peerName() + ":" + str(self.relaySocket.peerPort())) + self.relaySocket.connectToHost(self._host, self._port) + + if util.settings.value( + "fa.live_replay", DEFAULT_LIVE_REPLAY, type=bool, + ): + # Maybe make this asynchronous + if self.relaySocket.waitForConnected(1000): + self.__logger.debug( + "internet replay server {}:{}".format( + self.relaySocket.peerName(), + self.relaySocket.peerPort(), + ), + ) else: self.__logger.error("no connection to internet replay server") def __del__(self): - # Clean up our socket objects, in accordance to the hint from the Qt docs (recommended practice) + # Clean up our socket objects, in accordance to the hint from the Qt + # docs (recommended practice) self.__logger.debug("destructor entered") self.inputSocket.deleteLater() self.relaySocket.deleteLater() @@ -57,7 +78,9 @@ def readDatas(self): read = self.inputSocket.read(self.inputSocket.bytesAvailable()) if not isinstance(read, bytes): - self.__logger.warning("Read failure on inputSocket: " + bytes.decode()) + self.__logger.warning( + "Read failure on inputSocket: {}".format(bytes.decode()), + ) return # Convert data into a bytearray for easier processing @@ -65,11 +88,14 @@ def readDatas(self): # Record locally if self.replayData.isEmpty(): - # This prefix means "P"osting replay in the livereplay protocol of FA, - # this needs to be stripped from the local file + # This prefix means "P"osting replay in the livereplay protocol of + # FA, this needs to be stripped from the local file if data.startsWith(b"P/"): rest = data.indexOf(b"\x00") + 1 - self.__logger.info("Stripping prefix '" + str(data.left(rest - 1)) + "' from replay.") + self.__logger.info( + "Stripping prefix '{}' from replay." + .format(data.left(rest - 1)), + ) self.replayData.append(data.right(data.size() - rest)) else: self.replayData.append(data) @@ -88,17 +114,27 @@ def done(self): @QtCore.pyqtSlot() def inputDisconnected(self): self.__logger.info("FA disconnected locally.") - - # Part of the hardening - ensure all buffered local replay data is read and relayed + + # Part of the hardening - ensure all buffered local replay data is read + # and relayed if self.inputSocket.bytesAvailable(): - self.__logger.info("Relaying remaining bytes:" + str(self.inputSocket.bytesAvailable())) + self.__logger.info( + "Relaying remaining bytes: {}" + .format(self.inputSocket.bytesAvailable()), + ) self.readDatas() - - # Part of the hardening - ensure successful sending of the rest of the replay to the server - if self.relaySocket.bytesToWrite(): - self.__logger.info("Waiting for replay transmission to finish: " + str(self.relaySocket.bytesToWrite()) + " bytes") - progress = QtWidgets.QProgressDialog("Finishing Replay Transmission", "Cancel", 0, 0) + # Part of the hardening - ensure successful sending of the rest of the + # replay to the server + if self.relaySocket.bytesToWrite(): + self.__logger.info( + "Waiting for replay transmission to finish: {} " + "bytes".format(self.relaySocket.bytesToWrite()), + ) + + progress = QtWidgets.QProgressDialog( + "Finishing Replay Transmission", "Cancel", 0, 0, + ) progress.show() while self.relaySocket.bytesToWrite() and progress.isVisible(): @@ -107,64 +143,87 @@ def inputDisconnected(self): progress.close() self.relaySocket.disconnectFromHost() - + self.writeReplayFile() - + self.done() def writeReplayFile(self): # Update info block if possible. - if fa.instance._info and fa.instance._info['uid'] == self.replayInfo['uid']: + if ( + fa.instance._info + and fa.instance._info['uid'] == self.replayInfo['uid'] + ): if fa.instance._info.setdefault('complete', False): self.__logger.info("Found Complete Replay Info") else: self.__logger.warning("Replay Info not Complete") - + self.replayInfo = fa.instance._info - + self.replayInfo['game_end'] = time.time() - - filename = os.path.join(util.REPLAY_DIR, str(self.replayInfo['uid']) + "-" + self.replayInfo['recorder'] + ".fafreplay") - self.__logger.info("Writing local replay as " + filename + ", containing " + str(self.replayData.size()) + " bytes of replay data.") - + + basename = "{}-{}.fafreplay".format( + self.replayInfo['uid'], self.replayInfo['recorder'], + ) + filename = os.path.join(util.REPLAY_DIR, basename) + self.__logger.info( + "Writing local replay as {}, containing {} bytes " + "of replay data.".format(filename, self.replayData.size()), + ) + replay = QtCore.QFile(filename) - replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text) + replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.OpenModeFlag.Text) replay.write(json.dumps(self.replayInfo).encode('utf-8')) replay.write(b'\n') replay.write(QtCore.qCompress(self.replayData).toBase64()) replay.close() - + class ReplayServer(QtNetwork.QTcpServer): """ This is a local listening server that FA can send its replay data to. - It will instantiate a fresh ReplayRecorder for each FA instance that launches. + It will instantiate a fresh ReplayRecorder for each FA instance that + launches. """ __logger = logging.getLogger(__name__) def __init__(self, client, *args, **kwargs): QtNetwork.QTcpServer.__init__(self, *args, **kwargs) self.recorders = [] - self.client = client + self.client = client # type - ClientWindow self.__logger.debug("initializing...") self.newConnection.connect(self.acceptConnection) - def doListen(self,local_port): + def doListen(self) -> bool: while not self.isListening(): - self.listen(QtNetwork.QHostAddress.LocalHost, local_port) + self.listen(QtNetwork.QHostAddress.SpecialAddress.LocalHost, 0) if self.isListening(): - self.__logger.info("listening on address " + self.serverAddress().toString() + ":" + str(self.serverPort())) + self.__logger.info( + "listening on address {}:{}".format( + self.serverAddress().toString(), + self.serverPort(), + ), + ) else: - self.__logger.error("cannot listen, port probably used by another application: " + str(local_port)) - answer = QtWidgets.QMessageBox.warning(None, "Port Occupied", "FAF couldn't start its local replay " - "server, which is needed to play Forged " - "Alliance online. Possible reasons:
    " - "
  • FAF is already running (most " - "likely)
  • another program is " - "listening on port {port}
" - .format(port=local_port), - QtWidgets.QMessageBox.Retry, QtWidgets.QMessageBox.Abort) - if answer == QtWidgets.QMessageBox.Abort: + self.__logger.error( + "cannot listen, port probably used by " + "another application: {}".format(self.serverPort()), + ) + answer = QtWidgets.QMessageBox.warning( + None, + "Port Occupied", + ( + "FAF couldn't start its local replay server, which is " + "needed to play Forged Alliance online. Possible " + "reasons:
  • FAF is already running (most " + "likely)
  • another program is listening on port " + "{}
".format(self.serverPort()) + ), + QtWidgets.QMessageBox.StandardButton.Retry, + QtWidgets.QMessageBox.StandardButton.Abort, + ) + if answer == QtWidgets.QMessageBox.StandardButton.Abort: return False return True diff --git a/src/fa/updater.py b/src/fa/updater.py deleted file mode 100644 index d2b759583..000000000 --- a/src/fa/updater.py +++ /dev/null @@ -1,763 +0,0 @@ - -""" -This is the FORGED ALLIANCE updater. - -It ensures, through communication with faforever.com, that Forged Alliance is properly updated, -patched, and all required files for a given mod are installed - -@author thygrrr -""" -import os -import stat -import subprocess -import time -import shutil -import logging -import urllib.request, urllib.error, urllib.parse -import sys -import tempfile -import json -import ast - -import config -from config import Settings -import fafpath - -from PyQt5 import QtWidgets, QtCore, QtNetwork - -import util -import modvault - - -logger = logging.getLogger(__name__) - -# This contains a complete dump of everything that was supplied to logOutput -debugLog = [] - - -# Interface for the user of a connection. -class ConnectionHandler(object): - def __init__(self): - pass - - def atConnectionError(self, socketError): - pass - - def atDisconnect(self): - pass - - def atConnectionRead(self): - pass - - def atNewBlock(self, blocksize): - pass - - def atBlockProgress(self, avail, blocksize): - pass - - def atBlockComplete(self, blocksize, block): - pass - - -class UpdateConnection(object): - def __init__(self, handler, host, port): - self.host = host - self.port = port - self.handler = handler - - self.blockSize = 0 - self.updateSocket = QtNetwork.QTcpSocket() - self.updateSocket.setSocketOption(QtNetwork.QTcpSocket.KeepAliveOption, 1) - self.updateSocket.setSocketOption(QtNetwork.QTcpSocket.LowDelayOption, 1) - - self.updateSocket.error.connect(self.handleServerError) - self.updateSocket.readyRead.connect(self.readDataFromServer) - self.updateSocket.disconnected.connect(self.disconnected) - - def connect(self): - self.updateSocket.connectToHost(self.host, self.port) - - def connected(self): - return self.updateSocket.state() == QtNetwork.QAbstractSocket.ConnectedState - - def disconnect(self): - self.updateSocket.close() - - def handleServerError(self, socketError): - """ - Simple error handler that flags the whole operation as failed, not very graceful but what can you do... - """ - if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError: - log("FA Server down: The server is down for maintenance, please try later.") - - elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError: - log("Connection to Host lost. Please check the host name and port settings.") - - elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError: - log("The connection was refused by the peer.") - else: - log("The following error occurred: %s." % self.updateSocket.errorString()) - - self.handler.atConnectionError(socketError) - - def disconnected(self): - # This isn't necessarily an error so we won't change self.result here. - log("Disconnected from server at " + timestamp()) - self.handler.atDisconnect() - - def readDataFromServer(self): - self.handler.atConnectionRead() - - ins = QtCore.QDataStream(self.updateSocket) - ins.setVersion(QtCore.QDataStream.Qt_4_2) - - while not ins.atEnd(): - # Nothing was read yet, commence a new block. - if self.blockSize == 0: - # wait for enough bytes to piece together block size information - if self.updateSocket.bytesAvailable() < 4: - return - - self.blockSize = ins.readUInt32() - self.handler.atNewBlock(self.blockSize) - - avail = self.updateSocket.bytesAvailable() - # We have an incoming block, wait for enough bytes to accumulate - if avail < self.blockSize: - self.handler.atBlockProgress(avail, self.blockSize) - return # until later, this slot is reentrant - - # Enough bytes accumulated. Carry on. - self.handler.atBlockComplete(self.blockSize, ins) - - # Prepare to read the next block - self.blockSize = 0 - - def writeToServer(self, action, *args, **kw): - log(("writeToServer(" + action + ", [" + ', '.join(args) + "])")) - self.lastData = time.time() - - block = QtCore.QByteArray() - out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite) - out.setVersion(QtCore.QDataStream.Qt_4_2) - out.writeUInt32(0) - out.writeQString(action) - - for arg in args: - if type(arg) is int: - out.writeInt(arg) - elif isinstance(arg, str): - out.writeQString(arg) - elif type(arg) is float: - out.writeFloat(arg) - elif type(arg) is list: - out.writeQVariantList(arg) - else: - log("Uninterpreted Data Type: " + str(type(arg)) + " of value: " + str(arg)) - out.writeQString(str(arg)) - - out.device().seek(0) - out.writeUInt32(block.size() - 4) - - self.bytesToSend = block.size() - 4 - self.updateSocket.write(block) - - -FormClass, BaseClass = util.THEME.loadUiType("fa/updater/updater.ui") - - -class UpdaterProgressDialog(FormClass, BaseClass): - def __init__(self, parent): - BaseClass.__init__(self, parent) - self.setupUi(self) - self.logPlainTextEdit.setVisible(False) - self.adjustSize() - self.watches = [] - - @QtCore.pyqtSlot(str) - def appendLog(self, text): - self.logPlainTextEdit.appendPlainText(text) - - @QtCore.pyqtSlot(QtCore.QObject) - def addWatch(self, watch): - self.watches.append(watch) - watch.finished.connect(self.watchFinished) - - @QtCore.pyqtSlot() - def watchFinished(self): - for watch in self.watches: - if not watch.isFinished(): - return - self.done(QtWidgets.QDialog.Accepted) # equivalent to self.accept(), but clearer - - -def clearLog(): - global debugLog - debugLog = [] - - -def log(string): - logger.debug(string) - debugLog.append(str(string)) - - -def dumpPlainText(): - return "\n".join(debugLog) - - -def dumpHTML(): - return "
".join(debugLog) - - -# A set of exceptions we use to see what goes wrong during asynchronous data transfer waits -class UpdaterCancellation(Exception): - pass - - -class UpdaterFailure(Exception): - pass - - -class UpdaterTimeout(Exception): - pass - - -class Updater(QtCore.QObject, ConnectionHandler): - """ - This is the class that does the actual installation work. - """ - # Network configuration - SOCKET = 9001 - HOST = Settings.get('lobby/host') - TIMEOUT = 20 # seconds - - # Return codes to expect from run() - RESULT_SUCCESS = 0 # Update successful - RESULT_NONE = -1 # Update operation is still ongoing - RESULT_FAILURE = 1 # An error occured during updating - RESULT_CANCEL = 2 # User cancelled the download process - RESULT_ILLEGAL = 3 # User has the wrong version of FA - RESULT_BUSY = 4 # Server is currently busy - RESULT_PASS = 5 # User refuses to update by canceling the wizard - - def __init__(self, featured_mod, version=None, modversions=None, sim=False, silent=False, *args, **kwargs): - """ - Constructor - """ - QtCore.QObject.__init__(self, *args, **kwargs) - - self.filesToUpdate = [] - self.updatedFiles = [] - - self.connection = UpdateConnection(self, self.HOST, self.SOCKET) - self.lastData = time.time() - - self.featured_mod = featured_mod - self.version = version - self.modversions = modversions - - self.sim = sim - self.modpath = None - - self.result = self.RESULT_NONE - - self.destination = None - - self.silent = silent - self.progress = QtWidgets.QProgressDialog() - if self.silent: - self.progress.setCancelButton(None) - else: - self.progress.setCancelButtonText("Cancel") - self.progress.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint) - self.progress.setAutoClose(False) - self.progress.setAutoReset(False) - self.progress.setModal(1) - self.progress.setWindowTitle("Updating %s" % str(self.featured_mod).upper()) - - self.bytesToSend = 0 - - def run(self, *args, **kwargs): - clearLog() - log("Update started at " + timestamp()) - log("Using appdata: " + util.APPDATA_DIR) - - self.progress.show() - QtWidgets.QApplication.processEvents() - - # Actual network code adapted from previous version - self.progress.setLabelText("Connecting to update server...") - - self.connection.connect() - - while not (self.connection.connected()) and self.progress.isVisible(): - QtWidgets.QApplication.processEvents() - - if not self.progress.wasCanceled(): - log("Connected to update server at " + timestamp()) - - self.doUpdate() - - self.progress.setLabelText("Cleaning up.") - self.connection.disconnect() - self.progress.close() - else: - log("Cancelled connecting to server.") - self.result = self.RESULT_CANCEL - - log("Update finished at " + timestamp()) - return self.result - - def fetchFile(self, url, toFile): - try: - logger.info('Updater: Downloading {}'.format(url)) - progress = QtWidgets.QProgressDialog() - progress.setCancelButtonText("Cancel") - progress.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint) - progress.setAutoClose(True) - progress.setAutoReset(False) - - req = urllib.request.Request(url, headers={'User-Agent': "FAF Client"}) - downloadedfile = urllib.request.urlopen(req) - meta = downloadedfile.info() - - # Fix for #241, sometimes the server sends an error and no content-length. - file_size = int(meta.get_all("Content-Length")[0]) - progress.setMinimum(0) - progress.setMaximum(file_size) - progress.setModal(1) - progress.setWindowTitle("Downloading Update") - label = QtWidgets.QLabel() - label.setOpenExternalLinks(True) - progress.setLabel(label) - progress.setLabelText('Downloading FA file : ' + url + '
File size: ' + str( - int(file_size / 1024 / 1024)) + ' MiB') - progress.show() - - # Download the file as a series of up to 4 KiB chunks, then uncompress it. - - output = tempfile.NamedTemporaryFile(mode='w+b', delete=False) - - file_size_dl = 0 - block_sz = 4096 - - while progress.isVisible(): - QtWidgets.QApplication.processEvents() - if not progress.isVisible(): - break - read_buffer = downloadedfile.read(block_sz) - if not read_buffer: - break - file_size_dl += len(read_buffer) - output.write(read_buffer) - progress.setValue(file_size_dl) - - output.flush() - os.fsync(output.fileno()) - output.close() - - shutil.move(output.name, toFile) - - if (progress.value() == file_size) or progress.value() == -1: - logger.debug("File downloaded successfully.") - return True - else: - QtWidgets.QMessageBox.information(None, "Aborted", "Download not complete.") - logger.warning("File download not complete.") - return False - except: - logger.error("Updater error: ", exc_info=sys.exc_info()) - QtWidgets.QMessageBox.information(None, "Download Failed", "The file wasn't properly sent by the server. " - "
Try again later.") - return False - - def updateFiles(self, destination, filegroup): - """ - Updates the files in a given file group, in the destination subdirectory of the Forged Alliance path. - If existing=True, the existing contents of the directory will be added to the current self.filesToUpdate - list. - """ - QtWidgets.QApplication.processEvents() - - self.progress.setLabelText("Updating files: " + filegroup) - self.destination = destination - - self.connection.writeToServer("GET_FILES_TO_UPDATE", filegroup) - self.waitForFileList() - - # Ensure our list is unique - self.filesToUpdate = list(set(self.filesToUpdate)) - - targetdir = os.path.join(util.APPDATA_DIR, destination) - if not os.path.exists(targetdir): - os.makedirs(targetdir) - - for fileToUpdate in self.filesToUpdate: - md5File = util.md5(os.path.join(util.APPDATA_DIR, destination, fileToUpdate)) - if md5File is None: - if self.version: - if self.featured_mod == "faf" or self.featured_mod == "ladder1v1" or \ - filegroup == "FAF" or filegroup == "FAFGAMEDATA": - self.connection.writeToServer("REQUEST_VERSION", destination, fileToUpdate, str(self.version)) - else: - self.connection.writeToServer("REQUEST_MOD_VERSION", destination, fileToUpdate, - json.dumps(self.modversions)) - else: - - self.connection.writeToServer("REQUEST_PATH", destination, fileToUpdate) - else: - if self.version: - if self.featured_mod == "faf" or self.featured_mod == "ladder1v1" or \ - filegroup == "FAF" or filegroup == "FAFGAMEDATA": - self.connection.writeToServer("PATCH_TO", destination, fileToUpdate, md5File, str(self.version)) - else: - - self.connection.writeToServer("MOD_PATCH_TO", destination, fileToUpdate, md5File, - json.dumps(self.modversions)) - else: - self.connection.writeToServer("UPDATE", destination, fileToUpdate, md5File) - - self.waitUntilFilesAreUpdated() - - def waitForSimModPath(self): - """ - A simple loop that waits until the server has transmitted a sim mod path. - """ - self.lastData = time.time() - - self.progress.setValue(0) - self.progress.setMinimum(0) - self.progress.setMaximum(0) - - while self.modpath is None: - if self.progress.wasCanceled(): - raise UpdaterCancellation("Operation aborted while waiting for sim mod path.") - - if self.result != self.RESULT_NONE: - raise UpdaterFailure("Operation failed while waiting for sim mod path.") - - if time.time() - self.lastData > self.TIMEOUT: - raise UpdaterTimeout("Operation timed out while waiting for sim mod path.") - - QtWidgets.QApplication.processEvents() - - def waitForFileList(self): - """ - A simple loop that waits until the server has transmitted a file list. - """ - self.lastData = time.time() - - self.progress.setValue(0) - self.progress.setMinimum(0) - self.progress.setMaximum(0) - - while len(self.filesToUpdate) == 0: - if self.progress.wasCanceled(): - raise UpdaterCancellation("Operation aborted while waiting for file list.") - - if self.result != self.RESULT_NONE: - raise UpdaterFailure("Operation failed while waiting for file list.") - - if time.time() - self.lastData > self.TIMEOUT: - raise UpdaterTimeout("Operation timed out while waiting for file list.") - - QtWidgets.QApplication.processEvents() - - log("Files to update: [" + ', '.join(self.filesToUpdate) + "]") - - def waitUntilFilesAreUpdated(self): - """ - A simple loop that updates the progress bar while the server sends actual file data - """ - self.lastData = time.time() - - self.progress.setValue(0) - self.progress.setMinimum(0) - self.progress.setMaximum(0) - - while len(self.filesToUpdate) > 0: - if self.progress.wasCanceled(): - raise UpdaterCancellation("Operation aborted while waiting for data.") - - if self.result != self.RESULT_NONE: - raise UpdaterFailure("Operation failed while waiting for data.") - - if time.time() - self.lastData > self.TIMEOUT: - raise UpdaterTimeout("Connection timed out while waiting for data.") - - QtWidgets.QApplication.processEvents() - - log("Updates applied successfully.") - - def prepareBinFAF(self): - """ - Creates all necessary files in the binFAF folder, which contains a modified copy of all - that is in the standard bin folder of Forged Alliance - """ - self.progress.setLabelText("Preparing binFAF...") - - # now we check if we've got a binFAF folder - FABindir = os.path.join(config.Settings.get("ForgedAlliance/app/path"), 'bin') - FAFdir = util.BIN_DIR - - # Try to copy without overwriting, but fill in any missing files, otherwise it might miss some files to update - root_src_dir = FABindir - root_dst_dir = FAFdir - - for src_dir, _, files in os.walk(root_src_dir): - dst_dir = src_dir.replace(root_src_dir, root_dst_dir) - if not os.path.exists(dst_dir): - os.mkdir(dst_dir) - for file_ in files: - src_file = os.path.join(src_dir, file_) - dst_file = os.path.join(dst_dir, file_) - if not os.path.exists(dst_file): - shutil.copy(src_file, dst_dir) - st = os.stat(dst_file) - os.chmod(dst_file, st.st_mode | stat.S_IWRITE) # make all files we were considering writable, because we may need to patch them - - def doUpdate(self): - """ The core function that does most of the actual update work.""" - try: - if self.sim: - self.connection.writeToServer("REQUEST_SIM_PATH", self.featured_mod) - self.waitForSimModPath() - if self.result == self.RESULT_SUCCESS: - if modvault.downloadMod(self.modpath): - self.connection.writeToServer("ADD_DOWNLOAD_SIM_MOD", self.featured_mod) - - else: - # Prepare FAF directory & all necessary files - self.prepareBinFAF() - - # Update the mod if it's requested - if self.featured_mod == "faf" or self.featured_mod == "ladder1v1": # HACK - ladder1v1 "is" FAF. :-) - self.updateFiles("bin", "FAF") - self.updateFiles("gamedata", "FAFGAMEDATA") - pass - elif self.featured_mod: - self.updateFiles("bin", "FAF") - self.updateFiles("gamedata", "FAFGAMEDATA") - self.updateFiles("bin", self.featured_mod) - self.updateFiles("gamedata", self.featured_mod + "Gamedata") - - except UpdaterTimeout as e: - log("TIMEOUT: {}".format(e)) - self.result = self.RESULT_FAILURE - except UpdaterCancellation as e: - log("CANCELLED: {}".format(e)) - self.result = self.RESULT_CANCEL - except Exception as e: - log("EXCEPTION: {}".format(e)) - self.result = self.RESULT_FAILURE - else: - self.result = self.RESULT_SUCCESS - finally: - self.connection.disconnect() - - # Hide progress dialog if it's still showing. - self.progress.close() - - # Integrated handlers for the various things that could go wrong - if self.result == self.RESULT_CANCEL: - pass # The user knows damn well what happened here. - elif self.result == self.RESULT_PASS: - QtWidgets.QMessageBox.information(QtWidgets.QApplication.activeWindow(), "Installation Required", - "You can't play without a legal version of Forged Alliance.") - elif self.result == self.RESULT_BUSY: - QtWidgets.QMessageBox.information(QtWidgets.QApplication.activeWindow(), "Server Busy", - "The Server is busy preparing new patch files.
Try again later.") - elif self.result == self.RESULT_FAILURE: - failureDialog() - - # If nothing terribly bad happened until now, - # the operation is a success and/or the client can display what's up. - return self.result - - def handleAction(self, bytecount, action, stream): - """ - Process server responses by interpreting its intent and then acting upon it - """ - log("handleAction(%s) - %d bytes" % (action, bytecount)) - - if action == "PATH_TO_SIM_MOD": - path = stream.readQString() - self.modpath = path - self.result = self.RESULT_SUCCESS - return - - elif action == "SIM_MOD_NOT_FOUND": - log("Error: Unknown sim mod requested.") - self.modpath = "" - self.result = self.RESULT_FAILURE - return - - elif action == "LIST_FILES_TO_UP": - # Used to be an eval() here, this is a safe backwards-compatible fix - listStr = str(stream.readQString()) - self.filesToUpdate = ast.literal_eval(listStr) - - if self.filesToUpdate is None: - self.filesToUpdate = [] - return - - elif action == "UNKNOWN_APP": - log("Error: Unknown app/mod requested.") - self.result = self.RESULT_FAILURE - return - - elif action == "THIS_PATCH_IS_IN_CREATION EXCEPTION": - log("Error: Patch is in creation.") - self.result = self.RESULT_BUSY - return - - elif action == "VERSION_PATCH_NOT_FOUND": - response = stream.readQString() - log("Error: Patch version %s not found for %s." % (self.version, response)) - self.connection.writeToServer("REQUEST_VERSION", self.destination, response, self.version) - return - - elif action == "VERSION_MOD_PATCH_NOT_FOUND": - response = stream.readQString() - log("Error: Patch version %s not found for %s." % (str(self.modversions), response)) - self.connection.writeToServer("REQUEST_MOD_VERSION", self.destination, response, json.dumps(self.modversions)) - return - - elif action == "PATCH_NOT_FOUND": - response = stream.readQString() - log("Error: Patch not found for %s." % response) - self.connection.writeToServer("REQUEST", self.destination, response) - - return - - elif action == "UP_TO_DATE": - response = stream.readQString() - log("file : " + response) - log("%s is up to date." % response) - self.filesToUpdate.remove(str(response)) - return - - elif action == "ERROR_FILE": - response = stream.readQString() - log("ERROR: File not found on server : %s." % response) - self.filesToUpdate.remove(str(response)) - self.result = self.RESULT_FAILURE - return - - elif action == "SEND_FILE_PATH": - path = stream.readQString() - fileToCopy = stream.readQString() - url = stream.readQString() - - toFile = os.path.join(util.APPDATA_DIR, str(path), str(fileToCopy)) - self.fetchFile(url, toFile) - self.filesToUpdate.remove(str(fileToCopy)) - self.updatedFiles.append(str(fileToCopy)) - - elif action == "SEND_FILE": - path = stream.readQString() - - # HACK for feature/new-patcher - path = util.LUA_DIR if path == "bin" else path - - fileToCopy = stream.readQString() - size = stream.readInt() - fileDatas = stream.readRawData(size) - - toFile = os.path.join(util.APPDATA_DIR, str(path), str(fileToCopy)) - - writeFile = QtCore.QFile(toFile) - - if writeFile.open(QtCore.QIODevice.WriteOnly): - writeFile.write(fileDatas) - writeFile.close() - else: - logger.warning("%s is not writeable in in %s. Skipping." % ( - fileToCopy, path)) # This may or may not be desirable behavior - - log("%s is copied in %s." % (fileToCopy, path)) - self.filesToUpdate.remove(str(fileToCopy)) - self.updatedFiles.append(str(fileToCopy)) - - elif action == "SEND_PATCH_URL": - destination = str(stream.readQString()) - fileToUpdate = str(stream.readQString()) - url = str(stream.readQString()) - - toFile = os.path.join(util.CACHE_DIR, "temp.patch") - # - if self.fetchFile(url, toFile): - completePath = os.path.join(util.APPDATA_DIR, destination, fileToUpdate) - self.applyPatch(completePath, toFile) - - log("%s/%s is patched." % (destination, fileToUpdate)) - self.filesToUpdate.remove(str(fileToUpdate)) - self.updatedFiles.append(str(fileToUpdate)) - else: - log("Failed to update file :'(") - else: - log("Unexpected server command received: " + action) - self.result = self.RESULT_FAILURE - - def applyPatch(self, original, patch): - toFile = os.path.join(util.CACHE_DIR, "patchedFile") - # applying delta - if sys.platform == 'win32': - xdelta = os.path.join(fafpath.get_libdir(), "xdelta3.exe") - else: - xdelta = "xdelta3" - subprocess.call([xdelta, '-d', '-f', '-s', original, patch, toFile], stdout=subprocess.PIPE) - shutil.copy(toFile, original) - os.remove(toFile) - os.remove(patch) - - # Connection handler methods start here - - def atConnectionError(self, error): - self.result = self.RESULT_FAILURE - - def atConnectionRead(self): - self.lastData = time.time() # Keep resetting that timeout counter - - def atNewBlock(self, blockSize): - if blockSize > 65536: - self.progress.setLabelText("Downloading...") - self.progress.setValue(0) - self.progress.setMaximum(blockSize) - else: - self.progress.setValue(0) - self.progress.setMinimum(0) - self.progress.setMaximum(0) - - # Update our Gui at least once before proceeding - # (we might be receiving a huge file and this is not the first time we get here) - self.lastData = time.time() - QtWidgets.QApplication.processEvents() - - def atBlockProgress(self, avail, blockSize): - self.progress.setValue(avail) - - def atBlockComplete(self, blockSize, block): - self.progress.setValue(blockSize) - # Update our Gui at least once before proceeding (we might have to write a big file) - self.lastData = time.time() - QtWidgets.QApplication.processEvents() - - action = block.readQString() - self.handleAction(blockSize, action, block) - self.progress.setValue(0) - self.progress.setMinimum(0) - self.progress.setMaximum(0) - self.progress.reset() - - -def timestamp(): - return time.strftime("%Y-%m-%d %H:%M:%S") - - -# This is a pretty rough port of the old installer wizard. It works, but will need some work later -def failureDialog(): - """ - The dialog that shows the user the log if something went wrong. - """ - raise Exception(dumpPlainText()) diff --git a/src/fa/upnp.py b/src/fa/upnp.py deleted file mode 100644 index 3ef1b77cc..000000000 --- a/src/fa/upnp.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Created on Mar 22, 2012 - -@author: thygrrr -""" -import logging -import sys -import util -import platform -logger = logging.getLogger(__name__) - -UPNP_APP_NAME = "Forged Alliance Forever" -# Fields in mappingPort -# UpnpPort.Description -# UpnpPort.ExternalPort -# UpnpPort.ExternalIPAddress -# UpnpPort.InternalClient -# UpnpPort.InternalPort -# UpnpPort.Protocol -# UpnpPort.Enabled - - -def dumpMapping(mappingPort): - logger.info("-> %s mapping of %s:%d to %s:%d" % (mappingPort.Protocol, mappingPort.InternalClient, - mappingPort.InternalPort, mappingPort.ExternalIPAddress, - mappingPort.ExternalPort)) - -if platform.system() == "Windows": - def createPortMapping(ip, port, protocol="UDP"): - logger.info("UPnP mapping {}:{}".format(ip, port)) - try: - import win32com.client - NATUPnP = win32com.client.Dispatch("HNetCfg.NATUPnP") - mappingPorts = NATUPnP.StaticPortMappingCollection - - if mappingPorts: - mappingPorts.Add(port, protocol, port, ip, True, UPNP_APP_NAME) - for mappingPort in mappingPorts: - if mappingPort.Description == UPNP_APP_NAME: - dumpMapping(mappingPort) - else: - logger.error("Couldn't get StaticPortMappingCollection") - except: - logger.error("Exception in UPnP createPortMapping.", - exc_info=sys.exc_info()) - - def removePortMappings(): - logger.info("Removing UPnP port mapping.") - try: - import win32com.client - NATUPnP = win32com.client.Dispatch("HNetCfg.NATUPnP") - mappingPorts = NATUPnP.StaticPortMappingCollection - - if mappingPorts: - if mappingPorts.Count: - for mappingPort in mappingPorts: - if mappingPort.Description == UPNP_APP_NAME: - dumpMapping(mappingPort) - mappingPorts.Remove(mappingPort.ExternalPort, mappingPort.Protocol) - else: - logger.info("No mappings found / collection empty.") - else: - logger.error("Couldn't get StaticPortMappingCollection") - except: - logger.error("Exception in UPnP removePortMappings.", exc_info=sys.exc_info()) -else: - def createPortMapping(ip, port, protocol='UDP'): - logger.info("FIXME: Create a UPNP mapper for platform != Windows") - - def removePortMappings(): - logger.info("FIXME: Create a UPNP mapper for platform != Windows") diff --git a/src/fa/utils.py b/src/fa/utils.py new file mode 100644 index 000000000..f45e1ef52 --- /dev/null +++ b/src/fa/utils.py @@ -0,0 +1,54 @@ +import binascii +import logging +import os +import zipfile + +from api.models.FeaturedModFile import FeaturedModFile +from util import APPDATA_DIR + +logger = logging.getLogger(__name__) + + +def crc32(filepath: str) -> int | None: + try: + with open(filepath, "rb") as stream: + return binascii.crc32(stream.read()) + except Exception as e: + logger.exception(f"CRC check for {filepath!r} fail! Details: {e}") + return None + + +def unpack_movies_and_sounds(file: FeaturedModFile) -> None: + """ + Unpacks movies and sounds (based on path in zipfile) to the corresponding + folder. Movies must be unpacked for FA to be able to play them. + This is a hack needed because the game updater can only handle bin and + gamedata. + """ + # construct dirs + gd = os.path.join(APPDATA_DIR, "gamedata") + + origpath = os.path.join(gd, file.name) + + if os.path.exists(origpath) and zipfile.is_zipfile(origpath): + try: + zf = zipfile.ZipFile(origpath) + except Exception as e: + logger.exception(f"Failed to open Game File {origpath!r}: {e}") + return + + for zi in zf.infolist(): + movie_or_sound = ( + zi.filename.startswith("movies") + or zi.filename.startswith("sounds") + ) + if movie_or_sound and not zi.is_dir(): + tgtpath = os.path.join(APPDATA_DIR, zi.filename) + # copy only if file is different - check first if file + # exists, then if size is changed, then crc + if ( + not os.path.exists(tgtpath) + or os.stat(tgtpath).st_size != zi.file_size + or crc32(tgtpath) != zi.CRC + ): + zf.extract(zi, APPDATA_DIR) diff --git a/src/fa/wizards.py b/src/fa/wizards.py index 69f618031..2a68b328a 100644 --- a/src/fa/wizards.py +++ b/src/fa/wizards.py @@ -1,26 +1,43 @@ -from PyQt5 import QtWidgets, QtCore -from fa.path import validatePath, typicalForgedAlliancePaths +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util +from fa.path import typicalForgedAlliancePaths +from fa.path import validatePath + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + __author__ = 'Thygrrr' class UpgradePage(QtWidgets.QWizardPage): - def __init__(self, parent=None): + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super(UpgradePage, self).__init__(parent) self.setTitle("Specify Forged Alliance folder") - self.setPixmap(QtWidgets.QWizard.WatermarkPixmap, util.THEME.pixmap("fa/updater/forged_alliance_watermark.png")) + self.setPixmap( + QtWidgets.QWizard.WizardPixmap.WatermarkPixmap, + util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"), + ) layout = QtWidgets.QVBoxLayout() - self.label = QtWidgets.QLabel("FAF needs a version of Supreme Commander: Forged Alliance to launch games and " - "replays.

Please choose the installation you wish to use.
" - "
The following versions are equally supported:
  • 3596(Retail " - "version)
  • 3599 (Retail patch)
  • 3603beta (GPGnet beta patch)
  • " - "
  • 1.6.6 (Steam Version)
FAF doesn't modify your existing files.
" - "
Select folder:") + self.label = QtWidgets.QLabel( + "FAF needs a version of Supreme Commander: Forged Alliance to " + "launch games and replays.

Please choose the " + "installation you wish to use.

The following versions" + " are equally supported:
  • 3596(Retail version)
  • " + "
  • 3599 (Retail patch)
  • 3603beta (GPGnet beta patch)
  • " + "
  • 1.6.6 (Steam Version)
FAF doesn't modify your " + "existing files.

Select folder:", + ) self.label.setWordWrap(True) layout.addWidget(self.label) @@ -42,30 +59,35 @@ def __init__(self, parent=None): self.setCommitPage(True) def comboChanged(self): + tgt_dir = self.comboBox.currentText() + if not validatePath(tgt_dir): + # User picked some subdirectory (most likely bin) + parent = os.path.dirname(tgt_dir) + if validatePath(parent): + self.comboBox.setCurrentText(parent) self.completeChanged.emit() @QtCore.pyqtSlot() - def showChooser(self): - path = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Forged Alliance folder", - self.comboBox.currentText(), - QtWidgets.QFileDialog.DontResolveSymlinks | - QtWidgets.QFileDialog.ShowDirsOnly) + def showChooser(self) -> None: + path = QtWidgets.QFileDialog.getExistingDirectory( + self, + "Select Forged Alliance folder", + self.comboBox.currentText(), + ( + QtWidgets.QFileDialog.Option.DontResolveSymlinks + | QtWidgets.QFileDialog.Option.ShowDirsOnly + ), + ) if path: self.comboBox.insertItem(0, path) self.comboBox.setCurrentIndex(0) self.completeChanged.emit() def isComplete(self, *args, **kwargs): - if validatePath(self.comboBox.currentText()): - return True - else: - return False + return validatePath(self.comboBox.currentText()) def validatePage(self, *args, **kwargs): - if validatePath(self.comboBox.currentText()): - return True - else: - return False + return validatePath(self.comboBox.currentText()) class Wizard(QtWidgets.QWizard): @@ -73,28 +95,33 @@ class Wizard(QtWidgets.QWizard): The actual Wizard which walks the user through the install. """ - def __init__(self, client, *args, **kwargs): + def __init__(self, client: ClientWindow, *args, **kwargs) -> None: QtWidgets.QWizard.__init__(self, client, *args, **kwargs) - self.client = client + self.client = client # type - ClientWindow self.upgrade = UpgradePage() self.addPage(self.upgrade) - self.setWizardStyle(QtWidgets.QWizard.ModernStyle) + self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle) self.setWindowTitle("Forged Alliance Game Path") - self.setPixmap(QtWidgets.QWizard.WatermarkPixmap, util.THEME.pixmap("fa/updater/forged_alliance_watermark.png")) + self.setPixmap( + QtWidgets.QWizard.WizardPixmap.WatermarkPixmap, + util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"), + ) - self.setOption(QtWidgets.QWizard.NoBackButtonOnStartPage, True) + self.setOption(QtWidgets.QWizard.WizardOption.NoBackButtonOnStartPage, True) def accept(self): - util.settings.setValue("ForgedAlliance/app/path", self.upgrade.comboBox.currentText()) + util.settings.setValue( + "ForgedAlliance/app/path", self.upgrade.comboBox.currentText(), + ) QtWidgets.QWizard.accept(self) -def constructPathChoices(combobox, validated_choices): +def constructPathChoices(combobox: QtWidgets.QComboBox, validated_choices: list[str]) -> None: """ Creates a combobox with all potentially valid paths for FA on this system """ combobox.clear() for path in validated_choices: - if combobox.findText(path, QtCore.Qt.MatchFixedString) == -1: - combobox.addItem(path) + if combobox.findText(path, QtCore.Qt.MatchFlag.MatchFixedString) == -1: + combobox.addItem(path) diff --git a/src/fafpath.py b/src/fafpath.py index c6c1c4c1c..ece91f082 100644 --- a/src/fafpath.py +++ b/src/fafpath.py @@ -50,10 +50,14 @@ def get_libdir(): """ if run_from_frozen(): # lib dir should be where our executable lives - return os.path.join(os.path.dirname(sys.executable), "lib") + return os.path.join(os.path.dirname(sys.executable), "natives") elif run_from_unix_install(): # Everything should be in PATH return None else: # We are most likely running from source - return os.path.join(get_srcdir(), "lib") + return os.path.join(get_srcdir(), "natives") + + +def get_java_path(): + return os.path.join(get_libdir(), "ice-adapter", "jre", "bin", "java.exe") diff --git a/src/games/__init__.py b/src/games/__init__.py index 94f250b2a..771706343 100644 --- a/src/games/__init__.py +++ b/src/games/__init__.py @@ -1,6 +1,10 @@ import logging -from fa import factions -logger = logging.getLogger(__name__) # For use by other modules from ._gameswidget import GamesWidget + +__all__ = ( + "GamesWidget", +) + +logger = logging.getLogger(__name__) diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index 9a79f9b02..9d1a2b057 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -1,77 +1,127 @@ -from functools import partial -import random +import logging -from PyQt5 import QtWidgets -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtCore import QUrl, pyqtSlot +from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtGui import QColor +from PyQt6.QtGui import QCursor +import fa import util +from api.featured_mod_api import FeaturedModApiConnector from config import Settings -from games.moditem import ModItem, mod_invisible +from games.automatchframe import MatchmakerQueue from games.gamemodel import CustomGameFilterModel -from fa.factions import Factions -import fa - -import logging +from games.moditem import ModItem +from games.moditem import mod_invisible +from model.chat.channel import PARTY_CHANNEL_SUFFIX logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("games/games.ui") +class Party: + def __init__(self, owner_id=-1, owner=None): + self.owner_id = owner_id + self.members = [owner] if owner else [] + + @property + def memberCount(self): + return len(self.memberList) + + @property + def memberList(self): + return self.members + + def addMember(self, member): + self.memberList.append(member) + + @property + def memberIds(self): + uids = [] + if len(self.members) > 0: + for member in self.members: + uids.append(member.id_) + return uids + + def __eq__(self, other): + if ( + sorted(self.memberIds) == sorted(other.memberIds) + and self.owner_id == other.owner_id + ): + return True + else: + return False + + +class PartyMember: + def __init__(self, id_=-1, factions=None): + self.id_ = id_ + self.factions = ["uef", "cybran", "aeon", "seraphim"] + + class GamesWidget(FormClass, BaseClass): hide_private_games = Settings.persisted_property( - "play/hidePrivateGames", default_value=False, type=bool) + "play/hidePrivateGames", + default_value=False, + type=bool, + ) sort_games_index = Settings.persisted_property( - "play/sortGames", default_value=0, type=int) # Default is by player count - sub_factions = Settings.persisted_property( - "play/subFactions", default_value=[False, False, False, False]) - - def __init__(self, client, game_model, me, gameview_builder, game_launcher): - BaseClass.__init__(self) + "play/sortGames", + default_value=0, + type=int, + ) + + matchmaker_search_info = pyqtSignal(dict) + match_found_message = pyqtSignal(dict) + stop_search_ranked_game = pyqtSignal() + party_updated = pyqtSignal() + + def __init__( + self, + client, + game_model, + me, + gameview_builder, + game_launcher, + ): + BaseClass.__init__(self, client) self.setupUi(self) self._me = me - self.client = client + self.client = client # type - ClientWindow self.mods = {} self._game_model = CustomGameFilterModel(self._me, game_model) self._game_launcher = game_launcher + self.apiConnector = FeaturedModApiConnector() + self.apiConnector.data_ready.connect(self.process_mod_info) + self.gameview = gameview_builder(self._game_model, self.gameList) self.gameview.game_double_clicked.connect(self.gameDoubleClicked) - # Ranked search UI - self._ranked_icons = { - Factions.AEON: self.rankedAeon, - Factions.CYBRAN: self.rankedCybran, - Factions.SERAPHIM: self.rankedSeraphim, - Factions.UEF: self.rankedUEF, - } - self.rankedAeon.setIcon(util.THEME.icon("games/automatch/aeon.png")) - self.rankedCybran.setIcon(util.THEME.icon("games/automatch/cybran.png")) - self.rankedSeraphim.setIcon(util.THEME.icon("games/automatch/seraphim.png")) - self.rankedUEF.setIcon(util.THEME.icon("games/automatch/uef.png")) - - # Fixup ini file type loss - self.sub_factions = [True if x == 'true' else False for x in self.sub_factions] - - self.searchProgress.hide() - - # Ranked search state variables - self.searching = False - self.race = None + self.matchFoundQueueName = "" self.ispassworded = False - - self.generateSelectSubset() - - self.client.lobby_info.modInfo.connect(self.processModInfo) - - self.client.gameEnter.connect(self.stopSearchRanked) - self.client.viewingReplay.connect(self.stopSearchRanked) - - self.sortGamesComboBox.addItems(['By Players', 'By avg. Player Rating', 'By Map', 'By Host', 'By Age']) - self.sortGamesComboBox.currentIndexChanged.connect(self.sortGamesComboChanged) + self.party = None + + self.client.matchmaker_info.connect(self.handleMatchmakerInfo) + self.client.game_enter.connect(self.stopSearch) + self.client.viewing_replay.connect(self.stopSearch) + self.client.authorized.connect(self.onAuthorized) + + self.sortGamesComboBox.addItems([ + 'By Players', + 'By avg. Player Rating', + 'By Map', + 'By Host', + 'By Age', + ]) + self.sortGamesComboBox.currentIndexChanged.connect( + self.sortGamesComboChanged, + ) try: CustomGameFilterModel.SortType(self.sort_games_index) safe_sort_index = self.sort_games_index @@ -86,170 +136,80 @@ def __init__(self, client, game_model, me, gameview_builder, game_launcher): self.hideGamesWithPw.setChecked(self.hide_private_games) self.modList.itemDoubleClicked.connect(self.hostGameClicked) + self.teamList.itemPressed.connect(self.teamListItemClicked) - self.updatePlayButton() + self.hidePartyInfo() + self.leaveButton.clicked.connect(self.leave_party) - @pyqtSlot(dict) - def processModInfo(self, message): - """ - Slot that interprets and propagates mod_info messages into the mod list - """ - mod = message['name'] - old_mod = self.mods.get(mod, None) - self.mods[mod] = ModItem(message) - - if old_mod: - if mod in mod_invisible: - del mod_invisible[mod] - for i in range(0, self.modList.count()): - if self.modList.item(i) == old_mod: - self.modList.takeItem(i) - continue - - if message["publish"]: - self.modList.addItem(self.mods[mod]) - else: - mod_invisible[mod] = self.mods[mod] - - self.client.replays.modList.addItem(message["name"]) - - @pyqtSlot(int) - def togglePrivateGames(self, state): - self.hide_private_games = state - self._game_model.hide_private_games = state + self.apiConnector.requestData() - def selectFaction(self, enabled, factionID=0): - logger.debug('selectFaction: enabled={}, factionID={}'.format(enabled, factionID)) - if len(self.sub_factions) < factionID: - logger.warning('selectFaction: len(self.sub_factions) < factionID, aborting') - return + self.searching = {"ladder1v1": False} + self.matchmakerShortcuts = [] - logger.debug('selectFaction: selected was {}'.format(self.sub_factions)) - self.sub_factions[factionID-1] = enabled + self.matchmakerFramesInitialized = False - Settings.set("play/subFactions", self.sub_factions) - logger.debug('selectFaction: selected is {}'.format(self.sub_factions)) + def refreshMods(self): + self.apiConnector.requestData() - if self.searching: - self.stopSearchRanked() + def onAuthorized(self, me): + if not self.mods: + self.refreshMods() + if self.party is None: + self.party = Party(me.id, PartyMember(me.id)) + if not self.matchmakerFramesInitialized: + self.client.lobby_connection.send(dict(command="matchmaker_info")) - self.updatePlayButton() + def onLogOut(self): + self.stopSearch() + self.party = None + while self.matchmakerQueues.widget(0) is not None: + self.matchmakerQueues.widget(0).deleteLater() + self.matchmakerQueues.removeTab(0) + for shortcut in self.matchmakerShortcuts: + shortcut.setEnabled(False) + shortcut.deleteLater() + self.matchmakerShortcuts.clear() + self.matchmakerFramesInitialized = False - def startSubRandomRankedSearch(self): + @pyqtSlot(dict) + def process_mod_info(self, message: dict) -> None: """ - This is a wrapper around startRankedSearch where a faction will be chosen based on the selected checkboxes + Slot that interprets and propagates mod_info messages into the mod list """ - if self.searching: - self.stopSearchRanked() - else: - factionSubset = [] - - if self.rankedUEF.isChecked(): - factionSubset.append("uef") - if self.rankedCybran.isChecked(): - factionSubset.append("cybran") - if self.rankedAeon.isChecked(): - factionSubset.append("aeon") - if self.rankedSeraphim.isChecked(): - factionSubset.append("seraphim") - - l = len(factionSubset) - if l in [0, 4]: - self.startSearchRanked(Factions.RANDOM) + for featured_mod in message["values"]: + mod = featured_mod.name + old_mod = self.mods.get(mod, None) + self.mods[mod] = ModItem(featured_mod) + + if old_mod: + if mod in mod_invisible: + del mod_invisible[mod] + for i in range(0, self.modList.count()): + if self.modList.item(i) == old_mod: + self.modList.takeItem(i) + for i in range(self.client.replays.modList.count()): + if self.client.replays.modList.itemText(i) == old_mod.mod: + self.client.replays.modList.removeItem(i) + + if featured_mod.visible: + self.modList.addItem(self.mods[mod]) else: - # chooses a random factionstring from factionsubset and converts it to a Faction - self.startSearchRanked(Factions.from_name( - factionSubset[random.randint(0, l - 1)])) - - def startViewLadderMapsPool(self): - QDesktopServices.openUrl(QUrl(Settings.get("MAPPOOL_URL"))) - - def generateSelectSubset(self): - if self.searching: # you cannot search for a match while changing/creating the UI - self.stopSearchRanked() - - self.rankedPlay.clicked.connect(self.startSubRandomRankedSearch) - self.rankedPlay.show() - self.laddermapspool.clicked.connect(self.startViewLadderMapsPool) - self.labelRankedHint.show() - for faction, icon in list(self._ranked_icons.items()): - try: - icon.clicked.disconnect() - except TypeError: - pass - - icon.setChecked(self.sub_factions[faction.value-1]) - icon.clicked.connect(partial(self.selectFaction, factionID=faction.value)) - - def updatePlayButton(self): - if self.searching: - s = "Stop search" - else: - c = self.sub_factions.count(True) - if c in [0, 4]: # all or none selected - s = "Play as random!" - else: - s = "Play!" + mod_invisible[mod] = self.mods[mod] - self.rankedPlay.setText(s) + self.client.replays.modList.addItem(mod) - def startSearchRanked(self, race): - if race == Factions.RANDOM: - race = Factions.get_random_faction() - - if fa.instance.running(): - QtWidgets.QMessageBox.information( - None, "ForgedAllianceForever.exe", "FA is already running.") - self.stopSearchRanked() - return + @pyqtSlot(int) + def togglePrivateGames(self, state): + self.hide_private_games = state + self._game_model.hide_private_games = state - if not fa.check.check("ladder1v1"): - self.stopSearchRanked() - logger.error("Can't play ranked without successfully updating Forged Alliance.") - return - - if self.searching: - logger.info("Switching Ranked Search to Race " + str(race)) - self.race = race - self.client.lobby_connection.send(dict(command="game_matchmaking", mod="ladder1v1", state="settings", - faction=self.race.value)) - else: - # Experimental UPnP Mapper - mappings are removed on app exit - if self.client.useUPnP: - self.client.lobby_connection.set_upnp(self.client.gamePort) - - logger.info("Starting Ranked Search as " + str(race) + - ", port: " + str(self.client.gamePort)) - self.searching = True - self.race = race - self.searchProgress.setVisible(True) - self.labelAutomatch.setText("Searching...") - self.updatePlayButton() - self.client.search_ranked(faction=self.race.value) - - @pyqtSlot() - def stopSearchRanked(self, *args): - if self.searching: - logger.debug("Stopping Ranked Search") - self.client.lobby_connection.send(dict(command="game_matchmaking", mod="ladder1v1", state="stop")) - self.searching = False - - self.updatePlayButton() - self.searchProgress.setVisible(False) - self.labelAutomatch.setText("1 vs 1 Automatch") - - @pyqtSlot(bool) - def toggle_search(self, enabled, race=None): - """ - Handler called when a ladder search button is pressed. They're really checkboxes, and the - state flag is used to decide whether to start or stop the search. - :param state: The checkedness state of the search checkbox that was pushed - :param player_faction: The faction corresponding to that checkbox - """ - if enabled and not self.searching: - self.startSearchRanked(race) - else: - self.stopSearchRanked() + def stopSearch(self): + self.searching = {"ladder1v1": False} + self.client.labelAutomatchInfo.setText("") + self.client.labelAutomatchInfo.hide() + if self.matchFoundQueueName: + self.matchFoundQueueName = "" + self.stop_search_ranked_game.emit() def gameDoubleClicked(self, game): """ @@ -258,15 +218,29 @@ def gameDoubleClicked(self, game): if not fa.instance.available(): return - self.stopSearchRanked() # Actually a workaround + if ( + self.party is not None + and self.party.memberCount > 1 + and not self.leave_party() + ): + return + self.stopSearch() if not fa.check.game(self.client): return - if fa.check.check(game.featured_mod, mapname=game.mapname, version=None, sim_mods=game.sim_mods): + if fa.check.check( + game.featured_mod, mapname=game.mapname, + version=None, sim_mods=game.sim_mods, + ): if game.password_protected: passw, ok = QtWidgets.QInputDialog.getText( - self.client, "Passworded game", "Enter password :", QtWidgets.QLineEdit.Normal, "") + self.client, + "Passworded game", + "Enter password :", + QtWidgets.QLineEdit.Normal, + "", + ) if ok: self.client.join_game(uid=game.uid, password=passw) else: @@ -279,9 +253,191 @@ def hostGameClicked(self, item): """ if not fa.instance.available(): return - self.stopSearchRanked() + + if ( + self.party is not None + and self.party.memberCount > 1 + and not self.leave_party() + ): + return + self.stopSearch() self._game_launcher.host_game(item.name, item.mod) def sortGamesComboChanged(self, index): self.sort_games_index = index self._game_model.sort_type = CustomGameFilterModel.SortType(index) + + def teamListItemClicked(self, item): + if QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.LeftButton: + # for no good reason doesn't always work as expected + item.setSelected(False) + + if ( + QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.RightButton + and self.party.owner_id == self._me.id + ): + self.teamList.setCurrentItem(item) + playerLogin = item.data(0) + playerId = self.client.players[playerLogin].id + menu = QtWidgets.QMenu(self) + actionKick = QtWidgets.QAction("Kick from party", menu) + actionKick.triggered.connect( + lambda: self.kickPlayerFromParty(playerId), + ) + menu.addAction(actionKick) + menu.popup(QCursor.pos()) + + def updateParty(self, message): + players_ids = [] + for member in message["members"]: + players_ids.append(member["player"]) + + old_owner = self.client.players[self.party.owner_id] + new_owner = self.client.players[message["owner"]] + if ( + old_owner.id != new_owner.id + or self._me.id not in players_ids + or len(message["members"]) < 2 + ): + self.client._chatMVC.connection.part( + "#{}{}".format(old_owner.login, PARTY_CHANNEL_SUFFIX), + ) + + new_party = Party() + if len(message["members"]) > 1 and self._me.id in players_ids: + new_party.owner_id = new_owner.id + for member in message["members"]: + players_id = member["player"] + new_party.addMember( + PartyMember(id_=players_id, factions=member["factions"]), + ) + else: + new_party.owner_id = self._me.id + new_party.addMember(PartyMember(id_=self._me.id)) + + if self.party != new_party: + self.stopSearch() + self.party = new_party + if self.party.memberCount > 1: + self.client._chatMVC.connection.join( + "#{}{}".format(new_owner.login, PARTY_CHANNEL_SUFFIX), + ) + self.updateTeamList() + + self.updatePartyInfoFrame() + self.party_updated.emit() + + def showPartyInfo(self): + self.partyInfo.show() + + def hidePartyInfo(self): + self.partyInfo.hide() + + def updatePartyInfoFrame(self): + if self.party.memberCount > 1: + self.showPartyInfo() + else: + self.hidePartyInfo() + + def updateTeamList(self): + self.teamList.clear() + for member_id in self.party.memberIds: + if member_id != self._me.id: + item = QtWidgets.QListWidgetItem( + self.client.players[member_id].login, + ) + if member_id == self.party.owner_id: + item.setIcon(util.THEME.icon("chat/rank/partyleader.png")) + else: + item.setIcon(util.THEME.icon("chat/rank/newplayer.png")) + self.teamList.addItem(item) + + def accept_party_invite(self, sender_id): + self.stopSearch() + logger.info("Accepting paryt invite from {}".format(sender_id)) + msg = { + 'command': 'accept_party_invite', + 'sender_id': sender_id, + } + self.client.lobby_connection.send(msg) + + def kickPlayerFromParty(self, playerId): + login = self.client.players[playerId].login + result = QtWidgets.QMessageBox.question( + self, "Kick Player: {}".format(login), + "Are you sure you want to kick {} from party?".format(login), + QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No, + ) + if result == QtWidgets.QMessageBox.StandardButton.Yes: + self.stopSearch() + msg = { + 'command': 'kick_player_from_party', + 'kicked_player_id': playerId, + } + self.client.lobby_connection.send(msg) + + def leave_party(self): + result = QtWidgets.QMessageBox.question( + self, "Leaving Party", "Are you sure you want to leave party?", + QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No, + ) + if result == QtWidgets.QMessageBox.StandardButton.Yes: + msg = { + 'command': 'leave_party', + } + self.client.lobby_connection.send(msg) + + if self.isInGame(self._me.id): + self.client.players[self._me.id]._currentGame = None + return True + else: + return False + + def handleMatchmakerSearchInfo(self, message): + self.matchmaker_search_info.emit(message) + + def handleMatchFound(self, message): + self.matchFoundQueueName = message.get("queue_name", "") + self.match_found_message.emit(message) + + def handleMatchCancelled(self, message): + # the match cancelled message from the server can appear way too late, + # so any notifications or actions may be confusing if the user found a + # match but then aborted it and found a new one or joined/hosted a + # custom game + ... + + def isInGame(self, player_id): + if self.client.players[player_id].currentGame is None: + return False + else: + return True + + def handleMatchmakerInfo(self, message): + # there were cases when ladder info came earlier than the answer + # to client's matchmaker_info request, so probably it will need to be + # fully hardcoded when everything comes out, but for now just + # need to be sure that there are at least 2 queues in message + if ( + not self.matchmakerFramesInitialized + and len(message.get("queues", {})) > 1 + ): + logger.info("Initializing matchmaker queue frames") + queues = message.get("queues", {}) + queues.sort(key=lambda queue: queue["team_size"]) + for index, queue in enumerate(queues): + self.matchmakerQueues.insertTab( + index, + MatchmakerQueue( + self, self.client, + queue["queue_name"], queue["team_size"], + ), + "&{teamSize} vs {teamSize}".format( + teamSize=queue["team_size"], + ), + ) + for index in range(self.matchmakerQueues.tabBar().count()): + self.matchmakerQueues.tabBar().setTabTextColor( + index, QColor("silver"), + ) + self.matchmakerFramesInitialized = True diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py new file mode 100644 index 000000000..2a2aa75ea --- /dev/null +++ b/src/games/automatchframe.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import logging +from functools import partial +from typing import TYPE_CHECKING + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + +import fa +import util +from api.matchmaker_queue_api import MatchmakerQueueApiConnector +from config import Settings +from fa.factions import Factions + +FormClass, BaseClass = util.THEME.loadUiType("games/automatchframe.ui") + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + from games._gameswidget import GamesWidget + + +class MatchmakerQueue(FormClass, BaseClass): + + def __init__( + self, + games: GamesWidget, + client: ClientWindow, + queueName: str, + teamSize: int, + ) -> None: + BaseClass.__init__(self, games) + self.setupUi(self) + + self.queueName = queueName + self.teamSize = teamSize + self.subFactions = Settings.get( + "play/{}Factions".format(self.queueName), + default=[False] * 4, + type=bool, + ) + self.games = games + self.client = client + self.client.matchmaker_info.connect(self.handleQueueInfo) + self.games.matchmaker_search_info.connect(self.handleSearchInfo) + self.games.match_found_message.connect(self.handleMatchFound) + self.games.stop_search_ranked_game.connect(self.stopSearchRanked) + self.games.party_updated.connect(self.handlePartyUpdate) + + self._rankedIcons = { + Factions.AEON: self.rankedAeon, + Factions.CYBRAN: self.rankedCybran, + Factions.SERAPHIM: self.rankedSeraphim, + Factions.UEF: self.rankedUEF, + } + self.rankedUEF.setIcon(util.THEME.icon("games/automatch/uef.png")) + self.rankedAeon.setIcon(util.THEME.icon("games/automatch/aeon.png")) + self.rankedCybran.setIcon( + util.THEME.icon("games/automatch/cybran.png"), + ) + self.rankedSeraphim.setIcon( + util.THEME.icon("games/automatch/seraphim.png"), + ) + + self.searching = False + self.updatePlayButton() + + self.rankedPlay.clicked.connect(self.startSearchRanked) + self.rankedPlay.show() + self.mapsPool.clicked.connect(self.startViewMapsPool) + + self.setFactionIcons(self.subFactions) + + keys = ( + QtCore.Qt.Key.Key_1, QtCore.Qt.Key.Key_2, QtCore.Qt.Key.Key_3, QtCore.Qt.Key.Key_4, + ) + self.shortcut = QtGui.QShortcut( + QtGui.QKeySequence(QtCore.Qt.Key.Key_Control + keys[self.teamSize - 1]), + self.client, + self.startSearchRanked, + ) + self.games.matchmakerShortcuts.append(self.shortcut) + + self.matchmakerTimer = QtCore.QTimer() + self.matchmakerTimer.timeout.connect(self.updateMatchmakerTimer) + self.secondsToAutomatch = 0 + + self.ratingType = "" + self.apiConnector = MatchmakerQueueApiConnector() + self.apiConnector.data_ready.connect(self.handleApiQueueInfo) + self.apiConnector.requestData({"include": "leaderboard"}) + + title = self.queueName.replace("_", " ").capitalize() + self.automatchTitle.setText(title) + + def setFactionIcons(self, subFactions): + for faction, icon in self._rankedIcons.items(): + try: + icon.clicked.disconnect() + except TypeError: + pass + icon.setChecked(subFactions[faction.value - 1]) + icon.clicked.connect( + partial(self.selectFaction, factionID=faction.value), + ) + + def handleApiQueueInfo(self, message): + for queue in message.get("values", {}): + if queue["technicalName"] == self.queueName: + self.ratingType = queue["ratingType"] + + def handleQueueInfo(self, message): + for queue in message.get("queues", {}): + if queue["queue_name"] == self.queueName: + self.labelInQueue.setText( + "In Queue: {}".format(queue["num_players"]), + ) + self.secondsToAutomatch = int(queue["queue_pop_time_delta"]) + self.updateLabelMatchingIn() + self.matchmakerTimer.start(1 * 1000) + + def handleSearchInfo(self, message): + if message["queue_name"] == self.queueName: + self.searching = message["state"] == "start" + self.games.searching[self.queueName] = self.searching + self.updatePlayButton() + + def handleMatchFound(self, message): + if message.get("queue_name", "") == self.queueName: + # clear but do not cancel search + self.searching = False + self.games.searching[self.queueName] = False + self.updatePlayButton() + + def updateMatchmakerTimer(self): + if self.secondsToAutomatch > 0: + self.secondsToAutomatch -= 1 + self.updateLabelMatchingIn() + + def updateLabelMatchingIn(self): + minutes, seconds = divmod(self.secondsToAutomatch, 60) + self.labelMatchingIn.setText( + "Matching In: {:02}:{:02}".format(int(minutes), int(seconds)), + ) + + def startSearchRanked(self): + if ( + self.games.party.memberCount > self.teamSize + or self.games.party.owner_id != self.client.me.id + ): + return + + if self.searching: + self.stopSearchRanked() + return + + if not any(self.games.searching.values()): + if fa.instance.running(): + QtWidgets.QMessageBox.information( + self.client, + "ForgedAllianceForever.exe", + "FA is already running.", + ) + self.stopSearchRanked() + return + + if not fa.check.check("ladder1v1"): + self.stopSearchRanked() + logger.error( + "Can't play ranked without successfully " + "updating Forged Alliance.", + ) + return + + logger.debug( + "Starting Ranked Search. Queue: {}".format(self.queueName), + ) + self.client.search_ranked(queue_name=self.queueName) + + def stopSearchRanked(self): + if self.searching: + logger.debug("Stopping Ranked Search") + self.client.lobby_connection.send( + dict( + command="game_matchmaking", + queue_name=self.queueName, + state="stop", + ), + ) + self.searching = False + self.games.searching[self.queueName] = False + self.updatePlayButton() + + def handlePartyUpdate(self): + if ( + self.games.party.memberCount > self.teamSize + or self.games.party.owner_id != self.client.me.id + ): + self.rankedPlay.setEnabled(False) + else: + self.rankedPlay.setEnabled(True) + + def updatePlayButton(self): + index = self.games.matchmakerQueues.indexOf(self) + if self.searching: + s = "Stop search" + self.searchProgress.show() + self.games.matchmakerQueues.tabBar().setTabTextColor( + index, QtGui.QColor("orange"), + ) + else: + c = self.subFactions.count(True) + if c in [0, 4]: + s = "Play as random!" + else: + s = "Play!" + self.searchProgress.hide() + self.games.matchmakerQueues.tabBar().setTabTextColor( + index, QtGui.QColor("silver"), + ) + self.rankedPlay.setText(s) + + def startViewMapsPool(self): + if self.client.me.id is None: + QtGui.QDesktopServices.openUrl( + QtCore.QUrl(Settings.get("MAPPOOL_URL")), + ) + else: + rating = self.client.me.player.rating_estimate(self.ratingType) + self.client.mapvault.requestMapPool(self.queueName, rating) + self.client.mainTabs.setCurrentIndex( + self.client.mainTabs.indexOf(self.client.vaultsTab), + ) + self.client.topTabs.setCurrentIndex(0) + + def selectFaction(self, enabled, factionID=0): + if len(self.subFactions) < factionID: + return + self.subFactions[factionID - 1] = enabled + Settings.set( + "play/{}Factions".format(self.queueName), self.subFactions, + ) + self.updatePlayButton() diff --git a/src/games/gameitem.py b/src/games/gameitem.py index 32c3546dc..0d89a848b 100644 --- a/src/games/gameitem.py +++ b/src/games/gameitem.py @@ -1,6 +1,12 @@ +import html import os + +import jinja2 +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + import util -from PyQt5 import QtCore, QtWidgets, QtGui from fa import maps @@ -70,52 +76,66 @@ def paint(self, painter, option, index): def _draw_clear_option(self, painter, option): option.icon = QtGui.QIcon() option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, - option, painter, option.widget) + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) def _draw_icon_shadow(self, painter, option): - painter.fillRect(option.rect.left() + self.ICON_SHADOW_OFFSET, - option.rect.top() + self.ICON_SHADOW_OFFSET, - self.ICON_RECT, - self.ICON_RECT, - self.SHADOW_COLOR) + painter.fillRect( + option.rect.left() + self.ICON_SHADOW_OFFSET, + option.rect.top() + self.ICON_SHADOW_OFFSET, + self.ICON_RECT, + self.ICON_RECT, + self.SHADOW_COLOR, + ) def _draw_icon(self, painter, option, icon): - rect = option.rect.adjusted(self.ICON_CLIP_TOP_LEFT, - self.ICON_CLIP_TOP_LEFT, - self.ICON_CLIP_BOTTOM_RIGHT, - self.ICON_CLIP_BOTTOM_RIGHT) - icon.paint(painter, rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + rect = option.rect.adjusted( + self.ICON_CLIP_TOP_LEFT, + self.ICON_CLIP_TOP_LEFT, + self.ICON_CLIP_BOTTOM_RIGHT, + self.ICON_CLIP_BOTTOM_RIGHT, + ) + alignment_flags = QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop + icon.paint(painter, rect, alignment_flags) def _draw_frame(self, painter, option): pen = QtGui.QPen() pen.setWidth(self.FRAME_THICKNESS) pen.setBrush(self.FRAME_COLOR) - pen.setCapStyle(QtCore.Qt.RoundCap) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) painter.setPen(pen) - painter.drawRect(option.rect.left() + self.ICON_CLIP_TOP_LEFT, - option.rect.top() + self.ICON_CLIP_TOP_LEFT, - self.ICON_RECT, - self.ICON_RECT) + painter.drawRect( + option.rect.left() + self.ICON_CLIP_TOP_LEFT, + option.rect.top() + self.ICON_CLIP_TOP_LEFT, + self.ICON_RECT, + self.ICON_RECT, + ) def _draw_text(self, painter, option, text): left_off = self.ICON_RECT + self.TEXT_OFFSET top_off = self.TEXT_OFFSET right_off = self.TEXT_RIGHT_MARGIN bottom_off = 0 - painter.translate(option.rect.left() + left_off, - option.rect.top() + top_off) - clip = QtCore.QRectF(0, - 0, - option.rect.width() - left_off - right_off, - option.rect.height() - top_off - bottom_off) + painter.translate( + option.rect.left() + left_off, + option.rect.top() + top_off, + ) + clip = QtCore.QRectF( + 0, + 0, + option.rect.width() - left_off - right_off, + option.rect.height() - top_off - bottom_off, + ) html = QtGui.QTextDocument() html.setHtml(text) html.drawContents(painter, clip) def sizeHint(self, option, index): - return QtCore.QSize(self.ICON_SIZE + self.TEXT_WIDTH + self.PADDING, - self.ICON_SIZE) + return QtCore.QSize( + self.ICON_SIZE + self.TEXT_WIDTH + self.PADDING, + self.ICON_SIZE, + ) class GameTooltipFilter(QtCore.QObject): @@ -124,7 +144,7 @@ def __init__(self, formatter): self._formatter = formatter def eventFilter(self, obj, event): - if event.type() == QtCore.QEvent.ToolTip: + if event.type() == QtCore.QEvent.Type.ToolTip: return self._handle_tooltip(obj, event) else: return super().eventFilter(obj, event) @@ -154,24 +174,25 @@ def _featured_mod(self, game): def _host_color(self, game): hostid = game.host_player.id if game.host_player is not None else -1 - return self._colors.getUserColor(hostid) + return self._colors.get_user_color(hostid) def text(self, data): game = data.game + players = game.num_players - len(game.observers) formatting = { "color": self._host_color(game), "mapslots": game.max_players, - "mapdisplayname": game.mapdisplayname, - "title": game.title, - "host": game.host, - "players": game.num_players, - "playerstring": "player" if game.num_players == 1 else "players", - "avgrating": int(game.average_rating) + "mapdisplayname": html.escape(game.mapdisplayname), + "title": html.escape(game.title), + "host": html.escape(game.host), + "players": players, + "playerstring": "player" if players == 1 else "players", + "avgrating": int(game.average_rating), } if self._featured_mod(game): return self.FORMATTER_FAF.format(**formatting) else: - formatting["mod"] = game.featured_mod + formatting["mod"] = html.escape(game.featured_mod) return self.FORMATTER_MOD.format(**formatting) def icon(self, data): @@ -194,122 +215,60 @@ def needed_map_preview(self, data): return name def _game_teams(self, game): - teams = {index: [game.to_player(name) if game.is_connected(name) - else name for name in team] - for index, team in game.playing_teams.items()} + teams = { + index: [ + game.to_player(name) + if game.is_connected(name) + else name + for name in team + ] + for index, team in game.playing_teams.items() + } # Sort teams into a list # TODO - I believe there's a convention where team 1 is 'no team' - teamlist = [indexed_team for indexed_team in teams.items()] - teamlist.sort() + teamlist = sorted([indexed_team for indexed_team in teams.items()]) teamlist = [team for index, team in teamlist] return teamlist def _game_observers(self, game): - return [game.to_player(name) for name in game.observers - if game.is_connected(name)] + return [ + game.to_player(name) + for name in game.observers + if game.is_connected(name) + ] def tooltip(self, data): game = data.game teams = self._game_teams(game) observers = self._game_observers(game) - return self._tooltip_formatter.format(teams, observers, game.sim_mods) + title = game.title + title = title.replace("<", "<") + title = title.replace(">", ">") + return self._tooltip_formatter.format( + title, teams, observers, game.sim_mods, + ) class GameTooltipFormatter: - TIP_FORMAT = str(util.THEME.readfile("games/formatters/tool.qthtml")) def __init__(self, me): self._me = me - - def _teams_tooltip(self, teams): - versus_string = ( - "" - "VS" - "") - - def alignment(teams): - for i, team in enumerate(teams): - if i == 0: - yield 'left', team - elif i == len(teams) - 1: - yield 'right', team - else: - yield 'middle', team - - team_tables = [self._team_table(team, align) - for align, team in alignment(teams)] - return versus_string.join(team_tables) - - def _team_table(self, team, align): - team_table_start = "" - team_table_end = "
" - rows = [self._player_table_row(player, align) for player in team] - return team_table_start + "".join(rows) + team_table_end - - def _player_table_row(self, player, align): - if isinstance(player, str): - country = "" - else: - country = "{country_icon}" - pname = ("" - "{player}" - "") - order = [pname, country] if align == 'right' else [country, pname] - player_row = "{}{}".format(*order) - - if isinstance(player, str): - return player_row.format(alignment=align, player=player) - else: - return player_row.format( - country_icon=self._country_icon_fmt(player), - alignment=align, - player=self._player_fmt(player)) - - def _country_icon_fmt(self, player): - icon_path_fmt = os.path.join("chat", "countries", "{}.png") - icon_path = icon_path_fmt.format(player.country.lower()) + template_abs_path = os.path.join( + util.COMMON_DIR, "games", "gameitem.qthtml", + ) + with open(template_abs_path, "r") as templatefile: + self._template = jinja2.Template(templatefile.read()) + + def format(self, title, teams, observers, mods): + icon_path = os.path.join("chat", "countries/") icon_abs_path = os.path.join(util.COMMON_DIR, icon_path) - return "".format(icon_abs_path) - - def _player_fmt(self, player): - if player == self._me.player: - pformat = "{}" - else: - pformat = "{}" - player_string = pformat.format(player.login) - if player.rating_deviation < 200: # FIXME: magic number - player_string += " ({})".format(player.rating_estimate()) - return player_string - - def _observers_tooltip(self, observers): - if not observers: - return "" - - observer_fmt = "{country_icon} {observer}" - - observer_strings = [observer_fmt.format( - country_icon=self._country_icon_fmt(observer), - observer=observer.login) - for observer in observers] - return "Observers: " + ", ".join(observer_strings) - - def _mods_tooltip(self, mods): - if not mods: - return "" - return "
With: " + "
".join(mods.values()) - - def format(self, teams, observers, mods): - teamtip = self._teams_tooltip(teams) - obstip = self._observers_tooltip(observers) - modtip = self._mods_tooltip(mods) - - if modtip: - modtip = "
" + modtip - - return self.TIP_FORMAT.format(teams=teamtip, - observers=obstip, - mods=modtip) + return self._template.render( + title=title, teams=teams, + mods=mods.values(), observers=observers, + me=self._me.player, + iconpath=icon_abs_path, + ) class GameViewBuilder: diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py index d2b78f07e..69c07471c 100644 --- a/src/games/gamemodel.py +++ b/src/games/gamemodel.py @@ -1,77 +1,35 @@ -from PyQt5.QtCore import QAbstractListModel, Qt, QSortFilterProxyModel, QModelIndex -from .gamemodelitem import GameModelItem from enum import Enum +from PyQt6.QtCore import QSortFilterProxyModel +from PyQt6.QtCore import Qt + from games.moditem import mod_invisible from model.game import GameState +from util.qt_list_model import QtListModel +from .gamemodelitem import GameModelItem -class GameModel(QAbstractListModel): - def __init__(self, me, preview_dler, gameset=None): - QAbstractListModel.__init__(self) - self._me = me - self._preview_dler = preview_dler - self._gameitems = {} - self._itemlist = [] # For queries +class GameModel(QtListModel): + def __init__(self, me, preview_dler, gameset=None): + builder = GameModelItem.builder(me, preview_dler) + QtListModel.__init__(self, builder) self._gameset = gameset if self._gameset is not None: - self._gameset.newGame.connect(self.add_game) + self._gameset.added.connect(self.add_game) self._gameset.newClosedGame.connect(self.remove_game) - for game in self._gameset.values(): self.add_game(game) - def rowCount(self, parent): - if parent.isValid(): - return 0 - return len(self._itemlist) - - def data(self, index, role): - if not index.isValid() or index.row() >= len(self._itemlist): - return None - if role != Qt.DisplayRole: - return None - return self._itemlist[index.row()] - - # TODO - insertion and removal are O(n). Server bandwidth would probably - # become a bigger issue if number of games increased too much though. - def add_game(self, game): - assert game.uid not in self._gameitems - - next_index = len(self._itemlist) - self.beginInsertRows(QModelIndex(), next_index, next_index) - - item = GameModelItem(game, self._me, self._preview_dler) - item.updated.connect(self._at_item_updated) - - self._gameitems[game.uid] = item - self._itemlist.append(item) - - self.endInsertRows() + self._add_item(game, game.uid) def remove_game(self, game): - assert game.uid in self._gameitems - - item = self._gameitems[game.uid] - item_index = self._itemlist.index(item) - self.beginRemoveRows(QModelIndex(), item_index, item_index) - - item.updated.disconnect(self._at_item_updated) - del self._gameitems[game.uid] - self._itemlist.pop(item_index) - self.endRemoveRows() + self._remove_item(game.uid) def clear_games(self): - for data in list(self._gameitems.values()): - self.remove_game(data.game) - - def _at_item_updated(self, item): - item_index = self._itemlist.index(item) - index = self.index(item_index, 0) - self.dataChanged.emit(index, index) + self._clear_items() class GameSortModel(QSortFilterProxyModel): @@ -90,8 +48,8 @@ def __init__(self, me, model): self.sort(0) def lessThan(self, leftIndex, rightIndex): - left = self.sourceModel().data(leftIndex, Qt.DisplayRole).game - right = self.sourceModel().data(rightIndex, Qt.DisplayRole).game + left = self.sourceModel().data(leftIndex, Qt.ItemDataRole.DisplayRole).game + right = self.sourceModel().data(rightIndex, Qt.ItemDataRole.DisplayRole).game comp_list = [self._lt_friend, self._lt_type, self._lt_fallback] @@ -105,7 +63,10 @@ def lessThan(self, leftIndex, rightIndex): def _lt_friend(self, left, right): hostl = -1 if left.host_player is None else left.host_player.id hostr = -1 if right.host_player is None else right.host_player.id - return self._me.isFriend(hostl) and not self._me.isFriend(hostr) + return ( + self._me.relations.model.is_friend(hostl) + and not self._me.relations.model.is_friend(hostr) + ) def _lt_type(self, left, right): stype = self._sort_type diff --git a/src/games/gamemodelitem.py b/src/games/gamemodelitem.py index 20ae3dcf4..5eab67fa3 100644 --- a/src/games/gamemodelitem.py +++ b/src/games/gamemodelitem.py @@ -1,5 +1,7 @@ -from PyQt5.QtCore import QObject, pyqtSignal -from downloadManager import PreviewDownloadRequest +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +from downloadManager import DownloadRequest from fa import maps @@ -14,23 +16,31 @@ def __init__(self, game, me, preview_dler): QObject.__init__(self) self.game = game - self.game.gameUpdated.connect(self._game_updated) + self.game.updated.connect(self._game_updated) self._me = me - self._me.relationsUpdated.connect(self._check_host_relation_changed) + self._me.relations.trackers.players.updated.connect( + self._host_relation_changed, + ) + self._me.clan_changed.connect(self._host_relation_changed) self._preview_dler = preview_dler - self._preview_dl_request = PreviewDownloadRequest() + self._preview_dl_request = DownloadRequest() self._preview_dl_request.done.connect(self._at_preview_downloaded) + @classmethod + def builder(cls, me, preview_dler): + def build(game): + return cls(game, me, preview_dler) + return build + def _game_updated(self): self.updated.emit(self) self._download_preview_if_needed() - def _check_host_relation_changed(self, players): + def _host_relation_changed(self): # This should never happen bar server screwups. if self.game.host_player is None: return - if self.game.host_player.id in players: - self.updated.emit(self) + self.updated.emit(self) def _download_preview_if_needed(self): if self.game.mapname is None: diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py index e9a44d2c6..99c1dabf0 100644 --- a/src/games/hostgamewidget.py +++ b/src/games/hostgamewidget.py @@ -1,13 +1,17 @@ -from PyQt5 import QtCore -import modvault +import logging + +from PyQt6 import QtCore -from fa import maps -import util import fa.check -from model.game import Game, GameState, GameVisibility +import games.mapgenoptionsdialog as MapGenDialog +import util +import vaults.modvault.utils +from fa import maps from games.gamemodel import GameModel +from model.game import Game +from model.game import GameState +from model.game import GameVisibility -import logging logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("games/host.ui") @@ -46,37 +50,50 @@ def _build_hosted_game(self, main_mod, mapname=None): map_file_path="", # Mock teams={1: [host]}, featured_mod=main_mod, - featured_mod_versions={}, sim_mods={}, password_protected=False, # Filled in later - visibility=(GameVisibility.FRIENDS if friends_only - else GameVisibility.PUBLIC) - ) + visibility=( + GameVisibility.FRIENDS + if friends_only + else GameVisibility.PUBLIC + ), + ) def host_game(self, title, main_mod, mapname=None): game = self._build_hosted_game(main_mod, mapname) self._game_widget.setup(title, game) - return self._game_widget.exec_() + + mapname = util.settings.value("fa.games/gamemap", None) + if mapname is not None: + self._game_widget.set_map(mapname) + + return self._game_widget.exec() def _launch_game(self, game, password, mods): - # Make sure the binaries are all up to date, and abort if the update fails or is cancelled. + # Make sure the binaries are all up to date, and abort if the update + # fails or is cancelled. if not fa.check.game(self._client): return - # Ensure all mods are up-to-date, and abort if the update process fails. + # Ensure all mods are up-to-date, and abort if the update process + # fails. if not fa.check.check(game.featured_mod): return - if (game.featured_mod == "coop" - and not fa.check.map_(game.mapname, force=True)): + if ( + game.featured_mod == "coop" + and not fa.check.map_(game.mapname, force=True) + ): return - modvault.utils.setActiveMods(mods, True, False) + vaults.modvault.utils.setActiveMods(mods, True, False) - self._client.host_game(title=game.title, - mod=game.featured_mod, - visibility=game.visibility.value, - mapname=game.mapname, - password=password) + self._client.host_game( + title=game.title, + mod=game.featured_mod, + visibility=game.visibility.value, + mapname=game.mapname, + password=password, + ) class HostGameWidget(FormClass, BaseClass): @@ -86,20 +103,28 @@ def __init__(self, client, gameview_builder, preview_model): BaseClass.__init__(self, client) self.setupUi(self) - self.client = client + self.client = client # type - ClientWindow self.game = None self._preview_model = preview_model - self.game_preview_logic = gameview_builder(preview_model, - self.gamePreview) + self.game_preview_logic = gameview_builder( + preview_model, self.gamePreview, + ) + self.mods = {} - self.setStyleSheet(self.client.styleSheet()) + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() self.mapList.currentIndexChanged.connect(self.map_changed) self.hostButton.released.connect(self.hosting) + self.generateButton.released.connect(self.generateMap) self.titleEdit.textChanged.connect(self.update_text) self.passCheck.toggled.connect(self.update_pass_check) self.radioFriends.toggled.connect(self.update_visibility) + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + def setup(self, title, game): + self._reset() self.game = game self.password = util.settings.value("fa.games/password", "") @@ -109,18 +134,57 @@ def setup(self, title, game): self.passEdit.setText(self.password) self.passCheck.setChecked(self.game.password_protected) self.radioFriends.setChecked( - self.game.visibility == GameVisibility.FRIENDS) + self.game.visibility == GameVisibility.FRIENDS, + ) - self._preview_model.clear_games() self._preview_model.add_game(self.game) + self.setupMapList() + + # this makes it so you can select every non-ui_only mod + for mod in vaults.modvault.utils.getInstalledMods(): + if mod.ui_only: + continue + self.mods[mod.totalname] = mod + self.modList.addItem(mod.totalname) + + names = [ + mod.totalname + for mod in vaults.modvault.utils.getActiveMods( + uimods=False, + temporary=False, + ) + ] + logger.debug("Active Mods detected: {}".format(str(names))) + for name in names: + ml = self.modList.findItems(name, QtCore.Qt.MatchFlag.MatchExactly.MatchExactly) + logger.debug("found item: {}".format(ml[0].text())) + if ml: + ml[0].setSelected(True) + + def _reset(self): + self._preview_model.clear_games() + self.mapList.clear() + self.mods.clear() + self.modList.clear() + + def setupMapList(self): + ''' + Need this as separate function so it can be called after generateMap() + ''' + self.mapList.clear() + + game = self.game i = 0 index = 0 if game.featured_mod != "coop": allmaps = {} for map_ in list(maps.maps.keys()) + maps.getUserMaps(): allmaps[map_] = maps.getDisplayName(map_) - for (map_, name) in sorted(iter(allmaps.items()), key=lambda x: x[1]): + for (map_, name) in sorted( + iter(allmaps.items()), + key=lambda x: x[1], + ): if map_ == game.mapname: index = i self.mapList.addItem(name, map_) @@ -129,21 +193,11 @@ def setup(self, title, game): else: self.mapList.hide() - self.mods = {} - # this makes it so you can select every non-ui_only mod - for mod in modvault.utils.getInstalledMods(): - if mod.ui_only: - continue - self.mods[mod.totalname] = mod - self.modList.addItem(mod.totalname) - - names = [mod.totalname for mod in modvault.utils.getActiveMods(uimods=False, temporary=False)] - logger.debug("Active Mods detected: %s" % str(names)) - for name in names: - ml = self.modList.findItems(name, QtCore.Qt.MatchExactly) - logger.debug("found item: %s" % ml[0].text()) - if ml: - ml[0].setSelected(True) + def set_map(self, mapname): + for i in range(self.mapList.count()): + if self.mapList.itemData(i) == mapname: + self.mapList.setCurrentIndex(i) + return def update_text(self, text): self.game.update(title=text.strip()) @@ -152,8 +206,13 @@ def update_pass_check(self, checked): self.game.update(password_protected=checked) def update_visibility(self, friends): - self.game.update(visibility=(GameVisibility.FRIENDS if friends - else GameVisibility.PUBLIC)) + self.game.update( + visibility=( + GameVisibility.FRIENDS + if friends + else GameVisibility.PUBLIC + ), + ) def map_changed(self, index): mapname = self.mapList.itemData(index) @@ -170,9 +229,12 @@ def hosting(self): self.save_last_hosted_settings(password) - modnames = [str(moditem.text()) for moditem in self.modList.selectedItems()] + modnames = [ + str(moditem.text()) + for moditem in self.modList.selectedItems() + ] mods = [self.mods[modstr] for modstr in modnames] - modvault.utils.setActiveMods(mods, True, False) + vaults.modvault.utils.setActiveMods(mods, True, False) self.launch.emit(self.game, password, mods) self.done(1) @@ -189,6 +251,11 @@ def save_last_hosted_settings(self, password): util.settings.setValue("password", self.password) util.settings.endGroup() + @QtCore.pyqtSlot() + def generateMap(self): + dialog = MapGenDialog.MapGenDialog(self) + dialog.exec() + def build_launcher(playerset, me, client, view_builder, map_preview_dler): model = GameModel(me, map_preview_dler) diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py new file mode 100644 index 000000000..faac64ab3 --- /dev/null +++ b/src/games/mapgenoptionsdialog.py @@ -0,0 +1,304 @@ +from enum import Enum + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import config +import util + +FormClass, BaseClass = util.THEME.loadUiType("games/mapgen.ui") + + +class MapStyle(Enum): + RANDOM = "RANDOM" + DEFAULT = "DEFAULT" + ONE_ISLAND = "ONE_ISLAND" + BIG_ISLANDS = "BIG_ISLANDS" + SMALL_ISLANDS = "SMALL_ISLANDS" + CENTER_LAKE = "CENTER_LAKE" + VALLEY = "VALLEY" + DROP_PLATEAU = "DROP_PLATEAU" + LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" + MOUNTAIN_RANGE = "MOUNTAIN_RANGE" + LAND_BRIDGE = "LAND_BRIDGE" + LOW_MEX = "LOW_MEX" + FLOODED = "FLOODED" + + def getMapStyle(index): + return list(MapStyle)[index] + + +class MapGenDialog(FormClass, BaseClass): + def __init__(self, parent, *args, **kwargs): + BaseClass.__init__(self, *args, **kwargs) + + self.setupUi(self) + + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() + + self.parent = parent + + self.generationType.currentIndexChanged.connect( + self.generationTypeChanged, + ) + self.numberOfSpawns.currentIndexChanged.connect( + self.numberOfSpawnsChanged, + ) + self.mapSize.valueChanged.connect(self.mapSizeChanged) + self.mapStyle.currentIndexChanged.connect(self.mapStyleChanged) + self.generateMapButton.clicked.connect(self.generateMap) + self.saveMapGenSettingsButton.clicked.connect(self.saveMapGenPrefs) + self.resetMapGenSettingsButton.clicked.connect(self.resetMapGenPrefs) + + self.random_buttons = [ + self.landRandomDensity, + self.plateausRandomDensity, + self.mountainsRandomDensity, + self.rampsRandomDensity, + self.mexRandomDensity, + self.reclaimRandomDensity, + ] + self.sliders = [ + self.landDensity, + self.plateausDensity, + self.mountainsDensity, + self.rampsDensity, + self.mexDensity, + self.reclaimDensity, + ] + + self.option_frames = [ + self.landOptions, + self.plateausOptions, + self.mountainsOptions, + self.rampsOptions, + self.mexOptions, + self.reclaimOptions, + ] + + for random_button in self.random_buttons: + random_button.setChecked( + config.Settings.get( + "mapGenerator/{}".format(random_button.objectName()), + type=bool, + default=True, + ), + ) + random_button.toggled.connect(self.configOptionFrames) + + for slider in self.sliders: + slider.setValue( + config.Settings.get( + "mapGenerator/{}".format(slider.objectName()), + type=int, + default=0, + ), + ) + + self.generation_type = "casual" + self.number_of_spawns = 2 + self.map_size = 256 + self.map_style = MapStyle.RANDOM + self.generationType.setCurrentIndex( + config.Settings.get( + "mapGenerator/generationTypeIndex", type=int, default=0, + ), + ) + self.numberOfSpawns.setCurrentIndex( + config.Settings.get( + "mapGenerator/numberOfSpawnsIndex", type=int, default=0, + ), + ) + self.mapSize.setValue( + config.Settings.get( + "mapGenerator/mapSize", type=float, default=5.0, + ), + ) + self.mapStyle.setCurrentIndex( + config.Settings.get( + "mapGenerator/mapStyleIndex", type=int, default=0, + ), + ) + + self.configOptionFrames() + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key.Key_Enter + or event.key() == QtCore.Qt.Key.Key_Return + ): + return + QtWidgets.QDialog.keyPressEvent(self, event) + + @QtCore.pyqtSlot(int) + def numberOfSpawnsChanged(self, index): + self.number_of_spawns = 2 * (index + 1) + + @QtCore.pyqtSlot(float) + def mapSizeChanged(self, value): + if (value % 1.25): + # nearest to multiple of 1.25 + value = ((value + 0.625) // 1.25) * 1.25 + self.mapSize.blockSignals(True) + self.mapSize.setValue(value) + self.mapSize.blockSignals(False) + self.map_size = int(value * 51.2) + + @QtCore.pyqtSlot(int) + def generationTypeChanged(self, index): + if index == -1 or index == 0: + self.generation_type = "casual" + elif index == 1: + self.generation_type = "tournament" + elif index == 2: + self.generation_type = "blind" + elif index == 3: + self.generation_type = "unexplored" + + if index == -1 or index == 0: + self.mapStyle.setEnabled(True) + self.mapStyle.setCurrentIndex( + config.Settings.get( + "mapGenerator/mapStyleIndex", type=int, default=0, + ), + ) + else: + self.mapStyle.setEnabled(False) + self.mapStyle.setCurrentIndex(0) + + self.checkRandomButtons() + + @QtCore.pyqtSlot(int) + def mapStyleChanged(self, index): + if index == -1 or index == 0: + self.map_style = MapStyle.RANDOM + else: + self.map_style = MapStyle.getMapStyle(index) + + self.checkRandomButtons() + + @QtCore.pyqtSlot() + def checkRandomButtons(self): + for random_button in self.random_buttons: + if ( + self.generation_type != "casual" + or self.map_style != MapStyle.RANDOM + ): + random_button.setEnabled(False) + random_button.setChecked(True) + else: + random_button.setEnabled(True) + random_button.setChecked( + config.Settings.get( + "mapGenerator/{}".format(random_button.objectName()), + type=bool, + default=True, + ), + ) + + @QtCore.pyqtSlot() + def configOptionFrames(self): + for random_button in self.random_buttons: + option_frame = self.option_frames[ + self.random_buttons.index(random_button) + ] + if random_button.isChecked(): + option_frame.setEnabled(False) + else: + option_frame.setEnabled(True) + + @QtCore.pyqtSlot() + def saveMapGenPrefs(self): + config.Settings.set( + "mapGenerator/generationTypeIndex", + self.generationType.currentIndex(), + ) + config.Settings.set( + "mapGenerator/mapSize", + self.mapSize.value(), + ) + config.Settings.set( + "mapGenerator/numberOfSpawnsIndex", + self.numberOfSpawns.currentIndex(), + ) + config.Settings.set( + "mapGenerator/mapStyleIndex", + self.mapStyle.currentIndex(), + ) + for random_button in self.random_buttons: + config.Settings.set( + "mapGenerator/{}".format(random_button.objectName()), + random_button.isChecked(), + ) + for slider in self.sliders: + config.Settings.set( + "mapGenerator/{}".format(slider.objectName()), slider.value(), + ) + self.done(1) + + @QtCore.pyqtSlot() + def resetMapGenPrefs(self): + self.generationType.setCurrentIndex(0) + self.mapSize.setValue(5.0) + self.numberOfSpawns.setCurrentIndex(0) + self.mapStyle.setCurrentIndex(0) + + for random_button in self.random_buttons: + random_button.setChecked(True) + for slider in self.sliders: + slider.setValue(0) + + @QtCore.pyqtSlot() + def generateMap(self): + map_ = self.parent.client.map_generator.generateMap( + args=self.setArguments(), + ) + if map_: + self.parent.setupMapList() + self.parent.set_map(map_) + self.saveMapGenPrefs() + + def setArguments(self): + args = [] + args.append("--map-size") + args.append(str(self.map_size)) + args.append("--spawn-count") + args.append(str(self.number_of_spawns)) + + if self.map_style != MapStyle.RANDOM: + args.append("--style") + args.append(self.map_style.value) + else: + if self.generation_type == "tournament": + args.append("--tournament-style") + elif self.generation_type == "blind": + args.append("--blind") + elif self.generation_type == "unexplored": + args.append("--unexplored") + + slider_args = [ + ["--land-density", None], + ["--plateau-density", None], + ["--mountain-density", None], + ["--ramp-density", None], + ["--mex-density", None], + ["--reclaim-density", None], + ] + for index, slider in enumerate(self.sliders): + if slider.isEnabled(): + if slider == self.landDensity: + value = float(1 - (slider.value() / 127)) + else: + value = float(slider.value() / 127) + slider_args[index][1] = value + + for arg_key, arg_value in slider_args: + if arg_value is not None: + args.append(arg_key) + args.append(str(arg_value)) + + return args diff --git a/src/games/moditem.py b/src/games/moditem.py index b61b9bf0a..7142083f1 100644 --- a/src/games/moditem.py +++ b/src/games/moditem.py @@ -1,8 +1,12 @@ -from PyQt5 import QtWidgets, QtGui -import util -import client import os +from PyQt6 import QtGui +from PyQt6 import QtWidgets + +import client +import util +from api.models.FeaturedMod import FeaturedMod + # Maps names of featured mods to ModItem objects. mods = {} @@ -15,27 +19,26 @@ class ModItem(QtWidgets.QListWidgetItem): - def __init__(self, message, *args, **kwargs): - QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) + def __init__(self, mod_info: FeaturedMod, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) - self.mod = message["name"] - self.order = message.get("order", 0) - self.name = message["fullname"] + self.mod = mod_info.name + self.order = mod_info.order + self.name = mod_info.fullname # Load Icon and Tooltip - tip = message["desc"] - self.setToolTip(tip) + self.setToolTip(mod_info.description) - icon = util.THEME.icon(os.path.join("games/mods/", self.mod + ".png")) + icon = util.THEME.icon(os.path.join("games/mods/", f"{self.mod}.png")) if icon.isNull(): icon = util.THEME.icon("games/mods/default.png") self.setIcon(icon) if self.mod in mod_crucial: - color = client.instance.player_colors.getColor("self") + color = client.instance.player_colors.get_color("self") else: - color = client.instance.player_colors.getColor("player") - + color = client.instance.player_colors.get_color("player") + self.setForeground(QtGui.QColor(color)) self.setText(self.name) @@ -53,6 +56,6 @@ def __lt__(self, other): if self.order == other.order: # Default: Alphabetical - return self.name.lower() < other.mod.lower() + return self.name.lower() < other.name.lower() return self.order < other.order diff --git a/src/mapGenerator/__init__.py b/src/mapGenerator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py new file mode 100644 index 000000000..baef51ea8 --- /dev/null +++ b/src/mapGenerator/mapgenManager.py @@ -0,0 +1,189 @@ +import logging +import os +import random + +from PyQt6 import QtWidgets +from PyQt6.QtCore import QEventLoop +from PyQt6.QtCore import QObject +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + +import util +from config import Settings +from fa.maps import getUserMapsFolder +from mapGenerator.mapgenProcess import MapGeneratorProcess +from mapGenerator.mapgenUtils import generatedMapPattern +from vaults.dialogs import download_file + +logger = logging.getLogger(__name__) + +RELEASE_URL = "https://github.com/FAForever/Neroxis-Map-Generator/releases/" +RELEASE_VERSION_PATH = "download/{version}/NeroxisGen_{version}.jar" +GENERATOR_JAR_NAME = "MapGenerator_{}.jar" + + +class MapGeneratorManager(QObject): + version_received = pyqtSignal() + + def __init__(self) -> None: + super().__init__() + self.latestVersion = None + + self.currentVersion = Settings.get('mapGenerator/version', "0", str) + + def generateMap(self, mapname=None, args=None): + if mapname is None: + ''' + Requests latest version once per session + ''' + if self.currentVersion == "0" or not self.latestVersion: + self.checkUpdates() + + if ( + self.latestVersion + and self.versionController(self.latestVersion) + ): + # mapgen is up-to-date + self.currentVersion = self.latestVersion + Settings.set('mapGenerator/version', self.currentVersion) + + # if not "0", use older version, otherwise we don't have any + # generator at all + elif self.currentVersion == "0": + return False + version = self.currentVersion + args = args + else: + matcher = generatedMapPattern.match(mapname) + version = matcher.group(1) + args = ['--map-name', mapname] + + actualPath = self.versionController(version) + + if actualPath: + auto = Settings.get( + 'mapGenerator/autostart', default=False, type=bool, + ) + if not auto and mapname is not None: + msgbox = QtWidgets.QMessageBox() + msgbox.setWindowTitle("Generate map") + msgbox.setText( + "It looks like you don't have the map being used by this " + "lobby. Do you want to generate it?
{}" + .format(mapname), + ) + msgbox.setInformativeText( + "Map generation is a CPU intensive task and may take some " + "time.", + ) + msgbox.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.YesToAll + | QtWidgets.QMessageBox.StandardButton.No, + ) + result = msgbox.exec() + if result == QtWidgets.QMessageBox.StandardButton.No: + return False + elif result == QtWidgets.QMessageBox.StandardButton.YesToAll: + Settings.set('mapGenerator/autostart', True) + + mapsFolder = getUserMapsFolder() + if not os.path.exists(mapsFolder): + os.makedirs(mapsFolder) + + # Start generator with progress bar + self.generatorProcess = MapGeneratorProcess( + actualPath, mapsFolder, args, + ) + + map_ = self.generatorProcess.mapname + # Check if map exists or generator failed + if os.path.isdir(os.path.join(mapsFolder, map_)): + return map_ + else: + return False + else: + return False + + def generateRandomMap(self): + ''' + Called when user click "generate map" in host widget. + Prepares seed and requests latest version once per session + ''' + + if self.currentVersion == "0" or not self.latestVersion: + self.checkUpdates() + + if ( + self.latestVersion + and self.versionController(self.latestVersion) + ): + # mapgen is up-to-date + self.currentVersion = self.latestVersion + Settings.set('mapGenerator/version', self.currentVersion) + + # if not "0", use older version, otherwise we don't have any + # generator at all + elif self.currentVersion == "0": + return False + + seed = random.randint(-9223372036854775808, 9223372036854775807) + mapName = "neroxis_map_generator_{}_{}".format( + self.currentVersion, seed, + ) + + return self.generateMap(mapName) + + def versionController(self, version: str) -> str: + name = GENERATOR_JAR_NAME.format(version) + file_path = os.path.join(util.MAPGEN_DIR, name) + + # Check if required version is already in folder + if os.path.isdir(util.MAPGEN_DIR): + for infile in os.listdir(util.MAPGEN_DIR): + if infile.lower() == name.lower(): + return file_path + + # Download from github if not + url = RELEASE_URL + RELEASE_VERSION_PATH.format(version=version) + if download_file(url, util.MAPGEN_DIR, name, "map generator", silent=False): + return file_path + return "" + + def checkUpdates(self) -> None: + ''' + Not downloading anything here. + Just requesting latest version and return the number + ''' + self.manager = QNetworkAccessManager() + self.manager.finished.connect(self.on_request_finished) + + request = QNetworkRequest(QUrl(RELEASE_URL).resolved(QUrl("latest"))) + self.manager.get(request) + + progress = QtWidgets.QProgressDialog() + progress.setCancelButtonText("Cancel") + progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) + progress.setAutoClose(False) + progress.setAutoReset(False) + progress.setMinimum(0) + progress.setMaximum(0) + progress.setValue(0) + progress.setModal(1) + progress.setWindowTitle("Looking for updates") + progress.show() + + loop = QEventLoop() + self.version_received.connect(loop.quit) + loop.exec() + progress.close() + + def on_request_finished(self, reply: QNetworkReply) -> None: + redirect_url = reply.url() + if "releases/tag/" in redirect_url.toString(): + self.latestVersion = redirect_url.fileName() + self.version_received.emit() diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py new file mode 100644 index 000000000..8eb56db90 --- /dev/null +++ b/src/mapGenerator/mapgenProcess.py @@ -0,0 +1,125 @@ +import logging +import re + +from PyQt6.QtCore import QEventLoop +from PyQt6.QtCore import QProcess +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QProgressDialog + +import fafpath +from config import setup_file_handler + +from . import mapgenUtils + +logger = logging.getLogger(__name__) +# Separate log file for map generator +generatorLogger = logging.getLogger(__name__) +generatorLogger.propagate = False +generatorLogger.addHandler(setup_file_handler('map_generator.log')) + + +class MapGeneratorProcess(object): + def __init__(self, gen_path, out_path, args): + self._progress = QProgressDialog() + self._progress.setWindowTitle("Generating map, please wait...") + self._progress.setCancelButtonText("Cancel") + self._progress.setWindowFlags( + Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, + ) + self._progress.setAutoReset(False) + self._progress.setModal(1) + self._progress.setMinimum(0) + self._progress.setMaximum(30) + self._progress.canceled.connect(self.close) + self.progressCounter = 1 + + self.map_generator_process = QProcess() + self.map_generator_process.setWorkingDirectory(out_path) + self.map_generator_process.readyReadStandardOutput.connect( + self.on_log_ready, + ) + self.map_generator_process.readyReadStandardError.connect( + self.on_error_ready, + ) + self.map_generator_process.finished.connect(self.on_exit) + self.map_name = None + + self.java_path = fafpath.get_java_path() + self.args = ["-jar", gen_path] + self.args.extend(args) + + logger.info( + "Starting map generator with {} {}" + .format(self.java_path, " ".join(self.args)), + ) + generatorLogger.info(">>> --------------------- MapGenerator Launch") + + self.map_generator_process.start(self.java_path, self.args) + + if not self.map_generator_process.waitForStarted(5000): + logger.error("error starting the map generator process") + QMessageBox.critical( + None, "Map generator error", + "The map generator did not start.", + ) + else: + self._progress.show() + self._running = True + self.waitForCompletion() + + @property + def mapname(self): + return str(self.map_name) + + def on_log_ready(self): + standard_output = self.map_generator_process.readAllStandardOutput() + data = standard_output.data().decode('utf8').split('\n') + for line in data: + if ( + re.match(mapgenUtils.generatedMapPattern, line) + and self.map_name is None + ): + self.map_name = line.strip() + if line != '': + generatorLogger.info(line.strip()) + # Kinda fake progress bar. Better than nothing :) + if len(line) > 4: + self._progress.setLabelText(line[:25] + "...") + self.progressCounter += 1 + self._progress.setValue(self.progressCounter) + + def on_error_ready(self): + standard_error = str(self.map_generator_process.readAllStandardError()) + for line in standard_error.splitlines(): + generatorLogger.error("Error: " + line) + self.close() + QMessageBox.critical( + None, + "Map generator error", + "Something went wrong. Probably because of bad combination of " + "generator options. Please retry with different options", + ) + + def on_exit(self, code, status): + self._progress.reset() + self._running = False + generatorLogger.info("<<< --------------------- MapGenerator Shutdown") + + def close(self): + if self.map_generator_process.state() == QProcess.ProcessState.Running: + logger.info("Waiting for map generator process shutdown") + if not self.map_generator_process.waitForFinished(300): + if self.map_generator_process.state() == QProcess.ProcessState.Running: + logger.error("Terminating map generator process") + self.map_generator_process.terminate() + if not self.map_generator_process.waitForFinished(300): + logger.error("Killing map generator process") + self.map_generator_process.kill() + + def waitForCompletion(self) -> None: + '''Copied from downloadManager. I hope it's ok :)''' + waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents + while self._running: + QApplication.processEvents(waitFlag) diff --git a/src/mapGenerator/mapgenUtils.py b/src/mapGenerator/mapgenUtils.py new file mode 100644 index 000000000..d6160fe0c --- /dev/null +++ b/src/mapGenerator/mapgenUtils.py @@ -0,0 +1,14 @@ +import re + +versionPattern = re.compile("\\d\\d?\\d?\\.\\d\\d?\\d?\\.\\d\\d?\\d?") +generatedMapPattern = re.compile( + "neroxis_map_generator_({})_(.*)".format(versionPattern.pattern), +) + + +def isGeneratedMap(name): + ''' + Can't even place it in mapgenManager file outside object as separate + function without getting import errors on start + ''' + return re.match(generatedMapPattern, name) diff --git a/src/model/chat/__init__.py b/src/model/chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/model/chat/channel.py b/src/model/chat/channel.py new file mode 100644 index 000000000..25c4d0942 --- /dev/null +++ b/src/model/chat/channel.py @@ -0,0 +1,103 @@ +from enum import Enum + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +from model.modelitem import ModelItem +from model.transaction import transactional + +PARTY_CHANNEL_SUFFIX = "'sParty" + + +class ChannelType(Enum): + PUBLIC = 1 + PRIVATE = 2 + + +class ChannelID: + def __init__(self, type_, name): + self.type = type_ + self.name = name + + def __eq__(self, other): + return self.type == other.type and self.name == other.name + + def __hash__(self): + return hash((self.name, self.type)) + + @classmethod + def private_cid(cls, name): + return cls(ChannelType.PRIVATE, name) + + +class Lines(QObject): + added = pyqtSignal() + removed = pyqtSignal(int) + + def __init__(self): + QObject.__init__(self) + self._lines = [] + + def add_line(self, line): + self._lines.append(line) + self.added.emit() + + def remove_lines(self, number): + number = min(number, len(self)) + if number < 0: + raise ValueError + if number == 0: + return + del self._lines[0:number] + self.removed.emit(number) + + def __getitem__(self, n): + return self._lines[n] + + def __iter__(self): + return iter(self._lines) + + def __len__(self): + return len(self._lines) + + +class Channel(ModelItem): + added_chatter = pyqtSignal(object) + removed_chatter = pyqtSignal(object) + + def __init__(self, id_, lines, topic, is_base=False): + ModelItem.__init__(self) + self.add_field("topic", topic) + self.add_field("is_base", is_base) + self.lines = lines + self.id = id_ + self.chatters = {} + + @property + def id_key(self): + return self.id + + def copy(self): + return Channel(self.id, self.lines, **self.field_dict) + + @transactional + def update(self, **kwargs): + _transaction = kwargs.pop("_transaction") + + old = self.copy() + ModelItem.update(self, **kwargs) + self.emit_update(old, _transaction) + + @transactional + def set_topic(self, topic, _transaction=None): + self.update(topic=topic, _transaction=_transaction) + + @transactional + def add_chatter(self, cc, _transaction=None): + self.chatters[cc.id_key] = cc + _transaction.emit(self.added_chatter, cc) + + @transactional + def remove_chatter(self, cc, _transaction=None): + del self.chatters[cc.id_key] + _transaction.emit(self.removed_chatter, cc) diff --git a/src/model/chat/channelchatter.py b/src/model/chat/channelchatter.py new file mode 100644 index 000000000..73cc89175 --- /dev/null +++ b/src/model/chat/channelchatter.py @@ -0,0 +1,34 @@ +from model.modelitem import ModelItem +from model.transaction import transactional + + +class ChannelChatter(ModelItem): + MOD_ELEVATIONS = "~&@%+" + + def __init__(self, channel, chatter, elevation): + ModelItem.__init__(self) + self.channel = channel + self.chatter = chatter + self.add_field("elevation", elevation) + + @property + def id_key(self): + return (self.channel.id_key, self.chatter.id_key) + + def copy(self): + return ChannelChatter(self.channel, self.chatter, **self.field_dict) + + @transactional + def update(self, **kwargs): + _transaction = kwargs.pop("_transaction") + old = self.copy() + ModelItem.update(self, **kwargs) + self.emit_update(old, _transaction) + + @transactional + def set_elevation(self, value, _transaction=None): + self.update(elevation=value, _transaction=_transaction) + + def is_mod(self): + e = self.elevation + return e != '' and e in self.MOD_ELEVATIONS diff --git a/src/model/chat/channelchatterset.py b/src/model/chat/channelchatterset.py new file mode 100644 index 000000000..d1c76e62f --- /dev/null +++ b/src/model/chat/channelchatterset.py @@ -0,0 +1,79 @@ +from model.modelitemset import ModelItemSet +from model.transaction import transactional + + +class ChannelChatterset(ModelItemSet): + def __init__(self): + ModelItemSet.__init__(self) + + @transactional + def set_item(self, key, cc, _transaction=None): + ModelItemSet.set_item(self, key, cc, _transaction) + self.emit_added(cc, _transaction) + + @transactional + def del_item(self, key, _transaction=None): + chatter = ModelItemSet.del_item(self, key, _transaction) + if chatter is None: + return + self.emit_removed(chatter, _transaction) + + +class ChatterChannelIndex: + def __init__(self): + self._by_channel = {} + self._by_chatter = {} + + def ccs_by_chatter(self, chatter): + return self._by_chatter.setdefault(chatter.id_key, set()) + + def ccs_by_channel(self, channel): + return self._by_channel.setdefault(channel.id_key, set()) + + def add_cc(self, cc): + self.ccs_by_chatter(cc.chatter).add(cc) + self.ccs_by_channel(cc.channel).add(cc) + + def remove_cc(self, cc): + chat_ccs = self.ccs_by_chatter(cc.chatter) + chat_ccs.remove(cc) + if not chat_ccs: + del self._by_chatter[cc.chatter.id_key] + + chan_ccs = self.ccs_by_channel(cc.channel) + chan_ccs.remove(cc) + if not chan_ccs: + del self._by_channel[cc.channel.id_key] + + +class ChannelChatterRelation: + def __init__(self, channels, chatters, channelchatters): + self._channels = channels + self._chatters = chatters + self._channelchatters = channelchatters + self._index = ChatterChannelIndex() + + self._channelchatters.before_added.connect(self._new_cc) + self._channelchatters.before_removed.connect(self._removed_cc) + self._chatters.before_removed.connect(self._removed_chatter) + self._channels.before_removed.connect(self._removed_channel) + + def _new_cc(self, cc, _transaction=None): + self._index.add_cc(cc) + cc.channel.add_chatter(cc, _transaction) + cc.chatter.add_channel(cc, _transaction) + + def _removed_cc(self, cc, _transaction=None): + self._index.remove_cc(cc) + cc.channel.remove_chatter(cc, _transaction) + cc.chatter.remove_channel(cc, _transaction) + + def _removed_chatter(self, chatter, _transaction): + ccs = set(self._index.ccs_by_chatter(chatter)) + for cc in ccs: + self._channelchatters.del_item(cc.id_key, _transaction) + + def _removed_channel(self, channel, _transaction): + ccs = set(self._index.ccs_by_channel(channel)) + for cc in ccs: + self._channelchatters.del_item(cc.id_key, _transaction) diff --git a/src/model/chat/channelset.py b/src/model/chat/channelset.py new file mode 100644 index 000000000..1fc62394e --- /dev/null +++ b/src/model/chat/channelset.py @@ -0,0 +1,30 @@ +from model.chat.channel import ChannelType +from model.modelitemset import ModelItemSet +from model.transaction import transactional + + +class Channelset(ModelItemSet): + + def __init__(self, base_channels): + ModelItemSet.__init__(self) + self.base_channels = base_channels + + @classmethod + def build(cls, base_channels, **kwargs): + return cls(base_channels) + + @transactional + def set_item(self, key, value, _transaction=None): + value.is_base = ( + key.type == ChannelType.PUBLIC + and key.name in self.base_channels + ) + ModelItemSet.set_item(self, key, value, _transaction) + self.emit_added(value, _transaction) + + @transactional + def del_item(self, key, _transaction=None): + channel = ModelItemSet.del_item(self, key, _transaction) + if channel is None: + return + self.emit_removed(channel, _transaction) diff --git a/src/model/chat/chat.py b/src/model/chat/chat.py new file mode 100644 index 000000000..7af356e8a --- /dev/null +++ b/src/model/chat/chat.py @@ -0,0 +1,47 @@ +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal + +from model.chat.channelchatterset import ChannelChatterRelation +from model.chat.channelchatterset import ChannelChatterset +from model.chat.channelset import Channelset +from model.chat.chatterset import Chatterset + + +class Chat(QObject): + new_server_message = pyqtSignal(str) + connect_event = pyqtSignal() + disconnect_event = pyqtSignal() + + def __init__(self, channelset, chatterset, channelchatterset, cc_relation): + QObject.__init__(self) + self.channels = channelset + self.chatters = chatterset + self.channelchatters = channelchatterset + self._cc_relation = cc_relation + self._connected = False + + @classmethod + def build(cls, playerset, **kwargs): + channels = Channelset.build(**kwargs) + chatters = Chatterset(playerset) + channelchatters = ChannelChatterset() + cc_relation = ChannelChatterRelation( + channels, chatters, channelchatters, + ) + return cls(channels, chatters, channelchatters, cc_relation) + + def add_server_message(self, msg): + self.new_server_message.emit(msg) + + # Does not affect model contents, only tells if the user is connected. + @property + def connected(self): + return self._connected + + @connected.setter + def connected(self, value): + self._connected = value + if self._connected: + self.connect_event.emit() + else: + self.disconnect_event.emit() diff --git a/src/model/chat/chatline.py b/src/model/chat/chatline.py new file mode 100644 index 000000000..6116a951c --- /dev/null +++ b/src/model/chat/chatline.py @@ -0,0 +1,103 @@ +import time +from enum import Enum + +from util.magic_dict import MagicDict + + +# Notices differ from messages in that notices in public channels are visible +# only to the user. Due to that, it's important to be able to tell the +# difference between the two. +class ChatLineType(Enum): + MESSAGE = 0 + NOTICE = 1 + ACTION = 2 + INFO = 3 + ANNOUNCEMENT = 4 + RAW = 5 + + +class ChatLine: + def __init__(self, sender, text, type_, timestamp=None): + self.sender = sender + self.text = text + if timestamp is None: + timestamp = time.time() + self.time = timestamp + self.type = type_ + + +class ChatLineMetadata: + def __init__(self, line, meta): + self.line = line + self.meta = meta + + +class ChatLineMetadataBuilder: + def __init__(self, me, user_relations): + self._me = me + self._user_relations = user_relations + + @classmethod + def build(cls, me, user_relations, **kwargs): + return cls(me, user_relations) + + def get_meta(self, channel, line): + if line.sender is None: + cc = None + else: + key = (channel.id_key, line.sender) + cc = channel.chatters.get(key, None) + chatter = None + player = None + if cc is not None: + chatter = cc.chatter + player = chatter.player + + meta = MagicDict() + self._chatter_metadata(meta, cc) + self._player_metadata(meta, player) + self._relation_metadata(meta, chatter, player) + self._mention_metadata(line, meta) + return ChatLineMetadata(line, meta) + + def _chatter_metadata(self, meta, cc): + if cc is None: + return + cmeta = meta.put("chatter") + cmeta.is_mod = cc.is_mod() + cmeta.name = cc.chatter.name + + def _player_metadata(self, meta, player): + if player is None: + return + pmeta = meta.put("player") + pmeta.clan = player.clan + pmeta.id = player.id + self._avatar_metadata(pmeta, player.avatar) + + def _relation_metadata(self, meta, chatter, player): + me = self._me + name = None if chatter is None else chatter.name + id_ = None if player is None else player.id + meta.is_friend = self._user_relations.is_friend(id_, name) + meta.is_foe = self._user_relations.is_foe(id_, name) + meta.is_me = me.player is not None and me.player.login == name + meta.is_clannie = me.is_clannie(id_) + + def _mention_metadata(self, line, meta): + meta.mentions_me = ( + self._me.login is not None + and self._me.login in line.text + and line.sender != self._me.login + ) + + def _avatar_metadata(self, pmeta, avatar): + if avatar is None: + return + tip = avatar.get("tooltip", "") + url = avatar.get("url", None) + + ameta = pmeta.put("avatar") + ameta.tip = tip + if url is not None: + ameta.url = url diff --git a/src/model/chat/chatter.py b/src/model/chat/chatter.py new file mode 100644 index 000000000..7c191108c --- /dev/null +++ b/src/model/chat/chatter.py @@ -0,0 +1,63 @@ +from PyQt6.QtCore import pyqtSignal + +from model.modelitem import ModelItem +from model.transaction import transactional + + +class Chatter(ModelItem): + newPlayer = pyqtSignal(object, object, object) + added_channel = pyqtSignal(object) + removed_channel = pyqtSignal(object) + + def __init__(self, name, hostname): + ModelItem.__init__(self) + self.add_field("name", name) + self.add_field("hostname", hostname) + self._player = None + self.channels = {} + + @property + def id_key(self): + return self.name + + def copy(self): + return Chatter(**self.field_dict) + + @transactional + def update(self, **kwargs): + _transaction = kwargs.pop("_transaction") + olduser = self.copy() + ModelItem.update(self, **kwargs) + self.emit_update(olduser, _transaction) + + @property + def player(self): + return self._player + + @transactional + def set_player(self, val, _transaction=None): + oldplayer = self._player + self._player = val + _transaction.emit(self.newPlayer, self, val, oldplayer) + + @player.setter + def player(self, val): + # CAVEAT: this will emit signals immediately! + self.set_player(val) + + @transactional + def add_channel(self, cc, _transaction=None): + self.channels[cc.id_key] = cc + _transaction.emit(self.added_channel, cc) + + @transactional + def remove_channel(self, cc, _transaction=None): + del self.channels[cc.id_key] + _transaction.emit(self.removed_channel, cc) + + def is_base_channel_mod(self): + return any( + cc.is_mod() + for cc in self.channels.values() + if cc.channel.is_base + ) diff --git a/src/model/chat/chatterset.py b/src/model/chat/chatterset.py new file mode 100644 index 000000000..0686803df --- /dev/null +++ b/src/model/chat/chatterset.py @@ -0,0 +1,56 @@ +from model.chat.chatter import Chatter +from model.modelitemset import ModelItemSet +from model.transaction import transactional + + +class Chatterset(ModelItemSet): + def __init__(self, playerset): + ModelItemSet.__init__(self) + self._playerset = playerset + playerset.before_added.connect(self._at_player_added) + playerset.before_removed.connect(self._at_player_removed) + + @transactional + def set_item(self, key, value, _transaction=None): + if not isinstance(key, str) or not isinstance(value, Chatter): + raise TypeError + ModelItemSet.set_item(self, key, value, _transaction) + + # Don't put newly added element's signal in the transaction + if value.id_key in self._playerset: + value.player = self._playerset[value.id_key] + + value.before_updated.connect(self._at_user_updated) + self.emit_added(value, _transaction) + + @transactional + def del_item(self, key, _transaction=None): + chatter = ModelItemSet.del_item(self, key, _transaction) + if chatter is None: + return + chatter.before_updated.disconnect(self._at_user_updated) + self.emit_removed(chatter, _transaction) + + def _at_player_added(self, player, _transaction=None): + if player.login in self: + self[player.login].set_player(player, _transaction) + + def _at_player_removed(self, player, _transaction=None): + if player.login in self: + self[player.login].set_player(None, _transaction) + + def _at_user_updated(self, user, olduser, _transaction=None): + if user.name != olduser.name: + self._handle_rename(user, olduser, _transaction) + + def _handle_rename(self, user, olduser, _transaction=None): + # We should never rename to an existing user, but let's handle it + if user.name in self: + self.del_item(user.name, _transaction) + + if olduser.name in self._items: + del self._items[olduser.name] + self._items[user.name] = user + + newplayer = self._playerset.get(user.name) + user.set_player(newplayer, _transaction) diff --git a/src/model/game.py b/src/model/game.py index 7b068daa2..0a575b869 100644 --- a/src/model/game.py +++ b/src/model/game.py @@ -1,10 +1,16 @@ -from PyQt5.QtCore import QObject, pyqtSignal, QUrl, QUrlQuery, QTimer - -from enum import Enum -from decorators import with_logger +import html +import string import time +from enum import Enum -import string +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import pyqtSignal + +from decorators import with_logger +from model.modelitem import ModelItem +from model.transaction import transactional +from util.gameurl import GameUrl +from util.gameurl import GameUrlType class GameState(Enum): @@ -20,7 +26,7 @@ class GameVisibility(Enum): @with_logger -class Game(QObject): +class Game(ModelItem): """ Represents a game happening on the server. Updates for the game state are sent from the server, identified by game uid. Updates are propagated with @@ -31,57 +37,53 @@ class Game(QObject): shouldn't be updated or ended again. Update and game end are propagated with signals. """ - gameUpdated = pyqtSignal(object, object) + before_replay_available = pyqtSignal(object, object) liveReplayAvailable = pyqtSignal(object) - connectedPlayerAdded = pyqtSignal(object, object) - connectedPlayerRemoved = pyqtSignal(object, object) - ingamePlayerAdded = pyqtSignal(object, object) ingamePlayerRemoved = pyqtSignal(object, object) OBSERVER_TEAMS = ['-1', 'null'] LIVE_REPLAY_DELAY_SECS = 60 * 5 - SENTINEL = object() - - def __init__(self, - playerset, - uid, - state, - launched_at, - num_players, - max_players, - title, - host, - mapname, - map_file_path, - teams, - featured_mod, - featured_mod_versions, - sim_mods, - password_protected, - visibility): - - QObject.__init__(self) + def __init__( + self, + playerset, + uid, + state, + launched_at, + num_players, + max_players, + title, + host, + mapname, + map_file_path, + teams, + featured_mod, + sim_mods, + password_protected, + visibility, + **kwargs, + ): + + ModelItem.__init__(self) self._playerset = playerset self.uid = uid - self.state = None - self.launched_at = None - self.num_players = None - self.max_players = None - self.title = None - self.host = None - self.mapname = None - self.map_file_path = None - self.teams = None - self.featured_mod = None - self.featured_mod_versions = None - self.sim_mods = None - self.password_protected = None - self.visibility = None + self.add_field("state", state) + self.add_field("launched_at", launched_at) + self.add_field("num_players", num_players) + self.add_field("max_players", max_players) + self.add_field("title", title) + self.add_field("host", host) + self.add_field("mapname", mapname) + self.add_field("map_file_path", map_file_path) + self.add_field("teams", teams) + self.add_field("featured_mod", featured_mod) + self.add_field("sim_mods", sim_mods) + self.add_field("password_protected", password_protected) + self.add_field("visibility", visibility) self._aborted = False self._live_replay_timer = QTimer() @@ -89,159 +91,83 @@ def __init__(self, self._live_replay_timer.setInterval(self.LIVE_REPLAY_DELAY_SECS * 1000) self._live_replay_timer.timeout.connect(self._emit_live_replay) self.has_live_replay = False + self._check_live_replay_timer() - self._update(state, launched_at, num_players, max_players, title, - host, mapname, map_file_path, teams, featured_mod, - featured_mod_versions, sim_mods, password_protected, - visibility) + @property + def id_key(self): + return self.uid def copy(self): - s = self - return Game(s._playerset, s.uid, s.state, s.launched_at, s.num_players, - s.max_players, s.title, s.host, s.mapname, s.map_file_path, - s.teams, s.featured_mod, s.featured_mod_versions, - s.sim_mods, s.password_protected, s.visibility) + old = Game(self._playerset, self.uid, **self.field_dict) + old._aborted = self._aborted + old.has_live_replay = self.has_live_replay + return old - def update(self, *args, **kwargs): + @transactional + def update(self, **kwargs): if self._aborted: return - old = self.copy() - self._update(*args, **kwargs) - self.gameUpdated.emit(self, old) - - def _update(self, - state=SENTINEL, - launched_at=SENTINEL, - num_players=SENTINEL, - max_players=SENTINEL, - title=SENTINEL, - host=SENTINEL, - mapname=SENTINEL, - map_file_path=SENTINEL, - teams=SENTINEL, - featured_mod=SENTINEL, - featured_mod_versions=SENTINEL, - sim_mods=SENTINEL, - password_protected=SENTINEL, - visibility=SENTINEL, - uid=SENTINEL, # For convenience - ): - - def changed(item): - return item is not self.SENTINEL - - if changed(launched_at): - self.launched_at = launched_at - if changed(state): - self.state = state - if changed(num_players): - self.num_players = num_players - if changed(max_players): - self.max_players = max_players - if changed(title): - self.title = title - if changed(host): - self.host = host - if changed(mapname): - self.mapname = mapname - if changed(map_file_path): - self.map_file_path = map_file_path - - # Dict of : [list of player names] - if changed(teams): - self.teams = teams - - # Actually a game mode like faf, coop, ladder etc. - if changed(featured_mod): - self.featured_mod = featured_mod - - # Featured mod versions for this game used to update FA before joining - # TODO - investigate if this is actually necessary - if changed(featured_mod_versions): - self.featured_mod_versions = featured_mod_versions - - # Dict of mod uid: mod version for each mod used by the game - if changed(sim_mods): - self.sim_mods = sim_mods - if changed(password_protected): - self.password_protected = password_protected - if changed(visibility): - self.visibility = visibility + _transaction = kwargs.pop("_transaction") + old = self.copy() + ModelItem.update(self, **kwargs) self._check_live_replay_timer() - - def _check_live_replay_timer(self): - if (self.state != GameState.PLAYING or - self._live_replay_timer.isActive() or - self.launched_at is None): + self.emit_update(old, _transaction) + + def _check_live_replay_timer(self) -> None: + if ( + self.state != GameState.PLAYING + or self._live_replay_timer.isActive() + or self.launched_at is None + ): return if self.has_live_replay: return - time_elapsed = time.time() - self.launched_at + time_elapsed = round(time.time() - self.launched_at, 0) time_to_replay = max(self.LIVE_REPLAY_DELAY_SECS - time_elapsed, 0) - self._live_replay_timer.start(time_to_replay * 1000) + self._live_replay_timer.start(int(time_to_replay * 1000)) - def _emit_live_replay(self): + @transactional + def _emit_live_replay(self, _transaction=None): if self.state != GameState.PLAYING: return self.has_live_replay = True - self.liveReplayAvailable.emit(self) + _transaction.emit(self.liveReplayAvailable, self) + self.before_replay_available.emit(self, _transaction) def closed(self): return self.state == GameState.CLOSED or self._aborted # Used when the server confuses us whether the game is valid anymore. - def abort_game(self): + @transactional + def abort_game(self, _transaction=None): if self.closed(): return old = self.copy() self.state = GameState.CLOSED self._aborted = True - self.gameUpdated.emit(self, old) + self.emit_update(old, _transaction) def to_dict(self): - return { - "uid": self.uid, - "state": self.state.name, - "launched_at": self.launched_at, - "num_players": self.num_players, - "max_players": self.max_players, - "title": self.title, - "host": self.host, - "mapname": self.mapname, - "map_file_path": self.map_file_path, - "teams": self.teams, - "featured_mod": self.featured_mod, - "featured_mod_versions": self.featured_mod_versions, - "sim_mods": self.sim_mods, - "password_protected": self.password_protected, - "visibility": self.visibility.name, - "command": "game_info" # For compatibility - } + data = self.field_dict + data["uid"] = self.uid + data["state"] = data["state"].name + data["visibility"] = data["visibility"].name + data["command"] = "game_info" # For compatibility + return data def url(self, player_id): if self.state == GameState.CLOSED: return None - - url = QUrl() - url.setHost("lobby.faforever.com") - query = QUrlQuery() - query.addQueryItem("map", self.mapname) - query.addQueryItem("mod", self.featured_mod) - if self.state == GameState.OPEN: - url.setScheme("fafgame") - url.setPath("/" + str(player_id)) - query.addQueryItem("uid", str(self.uid)) + gtype = GameUrlType.OPEN_GAME else: - url.setScheme("faflive") - url.setPath("/" + str(self.uid) + "/" + str(player_id) + ".SCFAreplay") + gtype = GameUrlType.LIVE_REPLAY - url.setQuery(query) - return url + return GameUrl(gtype, self.mapname, self.featured_mod, self.uid, player_id, self.sim_mods) # Utility functions start here. @@ -249,9 +175,11 @@ def is_connected(self, name): return name in self._playerset def is_ingame(self, name): - return (not self.closed() - and self.is_connected(name) - and self._playerset[name].currentGame == self) + return ( + not self.closed() + and self.is_connected(name) + and self._playerset[name].currentGame == self + ) def to_player(self, name): if not self.is_connected(name): @@ -268,16 +196,22 @@ def players(self): def observers(self): if self.teams is None: return [] - return [name for tname, team in self.teams.items() - if tname in self.OBSERVER_TEAMS - for name in team] + return [ + name + for tname, team in self.teams.items() + if tname in self.OBSERVER_TEAMS + for name in team + ] @property def playing_teams(self): if self.teams is None: return {} - return {n: t for n, t in self.teams.items() - if n not in self.OBSERVER_TEAMS} + return { + n: t + for n, t in self.teams.items() + if n not in self.OBSERVER_TEAMS + } @property def playing_players(self): @@ -290,16 +224,30 @@ def host_player(self): except KeyError: return None + @transactional + def ingame_player_added(self, player, _transaction=None): + _transaction.emit(self.ingamePlayerAdded, self, player) + + @transactional + def ingame_player_removed(self, player, _transaction=None): + _transaction.emit(self.ingamePlayerRemoved, self, player) + @property def average_rating(self): - players = [name for team in self.playing_teams.values() - for name in team] - players = [self.to_player(name) for name in players - if self.is_connected(name)] + players = [ + name + for team in self.playing_teams.values() + for name in team + ] + players = [ + self.to_player(name) + for name in players + if self.is_connected(name) + ] if not players: return 0 else: - return sum([p.rating_estimate() for p in players]) / len(players) + return sum([p.global_estimate for p in players]) / len(players) @property def mapdisplayname(self): @@ -325,6 +273,8 @@ def message_to_game_args(m): try: m['state'] = GameState(m['state']) m['visibility'] = GameVisibility(m['visibility']) + # Server sends HTML-escaped names, which is needlessly confusing + m['title'] = html.unescape(m['title']) except (KeyError, ValueError): return False diff --git a/src/model/gameset.py b/src/model/gameset.py index c62a52c29..acdfc85ff 100644 --- a/src/model/gameset.py +++ b/src/model/gameset.py @@ -1,11 +1,13 @@ -from PyQt5.QtCore import QObject, pyqtSignal -from decorators import with_logger +from PyQt6.QtCore import pyqtSignal +from decorators import with_logger from model import game +from model.modelitemset import ModelItemSet +from model.transaction import transactional @with_logger -class Gameset(QObject): +class Gameset(ModelItemSet): """ Keeps track of currently active games. Removes games that closed. Reports creation and state change of games. Gives access to active games. @@ -14,148 +16,138 @@ class Gameset(QObject): send a game state for a uid, send a state that closes it, then send a state with the same uid again, and it will be reported as a new game. """ - newGame = pyqtSignal(object) - newLobby = pyqtSignal(object) newLiveGame = pyqtSignal(object) newClosedGame = pyqtSignal(object) newLiveReplay = pyqtSignal(object) def __init__(self, playerset): - QObject.__init__(self) - self.games = {} + ModelItemSet.__init__(self) self._playerset = playerset - self._idx = PlayerGameIndex(playerset) - - def __getitem__(self, uid): - return self.games[uid] - - def __contains__(self, uid): - return uid in self.games - - def __iter__(self): - return iter(self.games) - - def keys(self): - return self.games.keys() - - def values(self): - return self.games.values() - def items(self): - return self.games.items() - - def get(self, item, default=None): - try: - return self[item] - except KeyError: - return default - - def __setitem__(self, key, value): + @transactional + def set_item(self, key, value, _transaction=None): if not isinstance(key, int) or not isinstance(value, game.Game): raise TypeError - - if key in self or value.closed(): + if value.closed(): raise ValueError - if key != value.uid: - raise ValueError - - self.games[key] = value - # We should be the first ones to connect to the signal - value.gameUpdated.connect(self._at_game_update) - value.liveReplayAvailable.connect(self._at_live_replay) - self._at_game_update(value, None) - self.newGame.emit(value) - self._logger.debug("Added game, uid {}".format(value.uid)) - - def clear(self): + ModelItemSet.set_item(self, key, value, _transaction) + value.before_updated.connect(self._at_game_update) + value.before_replay_available.connect(self._at_live_replay) + self._at_game_update(value, None, _transaction) + self._logger.debug("Added game, uid {}".format(value.id_key)) + self.emit_added(value, _transaction) + + @transactional + def del_item(self, key, _transaction=None): + g = ModelItemSet.del_item(self, key, _transaction) + if g is None: + return + + g.before_updated.disconnect(self._at_game_update) + g.before_replay_available.disconnect(self._at_live_replay) + self._logger.debug("Removed game, uid {}".format(g.id_key)) + self.emit_removed(g, _transaction) + + @transactional + def clear(self, _transaction=None): # Abort_game removes g from dict, so 'for g in values()' complains - for g in list(self.games.values()): - g.abort_game() + for g in list(self._items.values()): + g.abort_game(_transaction) - def _at_game_update(self, new, old): + def _at_game_update(self, new, old, _transaction=None): if new.closed(): - self._remove_game(new) - self._idx.at_game_update(new, old) + self.del_item(new.id_key, _transaction) if old is None or new.state != old.state: - self._new_state(new) + self._new_state(new, _transaction) - def _new_state(self, g): - self._logger.debug("New game state {}, uid {}".format(g.state, g.uid)) + def _new_state(self, g, _transaction=None): + self._logger.debug( + "New game state {}, uid {}".format(g.state, g.id_key), + ) if g.state == game.GameState.OPEN: - self.newLobby.emit(g) + _transaction.emit(self.newLobby, g) elif g.state == game.GameState.PLAYING: - self.newLiveGame.emit(g) + _transaction.emit(self.newLiveGame, g) elif g.state == game.GameState.CLOSED: - self.newClosedGame.emit(g) - - def _at_live_replay(self, game): - self.newLiveReplay.emit(game) + _transaction.emit(self.newClosedGame, g) - def _remove_game(self, g): - try: - g = self.games[g.uid] - g.gameUpdated.disconnect(self._at_game_update) - g.liveReplayAvailable.disconnect(self._at_live_replay) - del self.games[g.uid] - self._logger.debug("Removed game, uid {}".format(g.uid)) - except KeyError: - pass + def _at_live_replay(self, game, _transaction=None): + _transaction.emit(self.newLiveReplay, game) class PlayerGameIndex: # Helper class that keeps track of player / game relationship and helps # assign games to players that reconnected. - def __init__(self, playerset): + def __init__(self, gameset, playerset): self._playerset = playerset + self._gameset = gameset + self._playerset.before_added.connect(self._on_player_added) + self._playerset.before_removed.connect(self._on_player_removed) + self._gameset.before_added.connect(self._on_game_added) + self._gameset.before_removed.connect(self._on_game_removed) + self._idx = {} - self._playerset.playerAdded.connect(self._on_player_added) - self._playerset.playerRemoved.connect(self._on_player_removed) - # Called by gameset - def at_game_update(self, new, old): - old_closed = old is None or old.closed() + def player_game(self, pname): + return self._idx.get(pname) + + def _on_game_added(self, game, _transaction=None): + game.before_updated.connect(self._at_game_update) + for p in game.players: + self._set_relation(p, game, _transaction) - news = set() if new.closed() else set(new.players) - olds = set() if old_closed else set(old.players) + def _on_game_removed(self, game, _transaction=None): + game.before_updated.disconnect(self._at_game_update) + for p in game.players: + self._remove_relation(p, game, _transaction) - removed = [p for p in olds - news - if p in self._idx and self._idx[p] == new] + def _at_game_update(self, new, old, _transaction=None): + news = set() if new.closed() else set(new.players) + olds = set() if old.closed() else set(old.players) + removed = olds - news added = news - olds - - # Player games are part of state, so update all first before signals - signals = [] for p in removed: - signals.append(self._set_player_game_defer_signal(p, None)) + self._remove_relation(p, new, _transaction) for p in added: - signals.append(self._set_player_game_defer_signal(p, new)) + self._set_relation(p, new, _transaction) - for s in signals: - s() + def _remove_relation(self, pname, game, _transaction=None): + if pname not in self._idx: + return + if self.player_game(pname) != game: + return - def _set_player_game_defer_signal(self, pname, game): - oldgame = self._idx.get(pname) - if not self._should_update_player_game(game, oldgame): - return lambda: None + player = self._playerset.get(pname) + del self._idx[pname] - if game is None: - if pname in self._idx: - del self._idx[pname] - else: - self._idx[pname] = game + if player is not None: + player.set_currentGame(None, _transaction) + game.ingame_player_removed(player, _transaction) - if pname in self._playerset: - player = self._playerset[pname] - return player.set_current_game_defer_signal(game) - else: - return lambda: None + def _set_relation(self, pname, game, _transaction=None): + oldgame = self.player_game(pname) + if not self._player_did_change_game(game, oldgame): + return - def _should_update_player_game(self, new, old): + player = self._playerset.get(pname) + self._idx[pname] = game + + if player is not None: + player.set_currentGame(game, _transaction) + if oldgame is not None: + oldgame.ingame_player_removed(player, _transaction) + game.ingame_player_added(player, _transaction) + + def _player_did_change_game(self, new, old): # Removing or setting new game should always happen if new is None or old is None: return True + if new.id_key == old.id_key: + return False + # Games should be not closed now # Lobbies always take precedence - if there are 2 at once, tough luck if new.state == game.GameState.OPEN: @@ -170,16 +162,13 @@ def _should_update_player_game(self, new, old): return True return new.launched_at > old.launched_at - def player_game(self, pname): - return self._idx.get(pname) - - def _on_player_added(self, player): + def _on_player_added(self, player, _transaction=None): pgame = self.player_game(player.login) if pgame is not None: - player.currentGame = pgame - pgame.connectedPlayerAdded.emit(pgame, player) + player.set_currentGame(pgame, _transaction) + pgame.ingame_player_added(player, _transaction) - def _on_player_removed(self, player): + def _on_player_removed(self, player, _transaction=None): pgame = self.player_game(player.login) if pgame is not None: - pgame.connectedPlayerRemoved.emit(pgame, player) + pgame.ingame_player_removed(player, _transaction) diff --git a/src/model/ircuser.py b/src/model/ircuser.py index 7991675e4..e18ef4289 100644 --- a/src/model/ircuser.py +++ b/src/model/ircuser.py @@ -1,54 +1,62 @@ -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import pyqtSignal +from model.modelitem import ModelItem +from model.transaction import transactional -class IrcUser(QObject): - updated = pyqtSignal(object, object) + +class IrcUser(ModelItem): newPlayer = pyqtSignal(object, object, object) def __init__(self, name, hostname): - QObject.__init__(self) - - self.name = name + ModelItem.__init__(self) self.elevation = {} - self.hostname = hostname + self.add_field("name", name) + self.add_field("hostname", hostname) self._player = None + @property + def id_key(self): + return self.name + def copy(self): - old = IrcUser(self.name, self.hostname) + old = IrcUser(**self.field_dict) for channel in self.elevation: old.set_elevation(channel, self.elevation[channel]) return old - def update(self, name=None, hostname=None): + @transactional + def update(self, **kwargs): + _transaction = kwargs.pop("_transaction") olduser = self.copy() + ModelItem.update(self, **kwargs) + self.emit_update(olduser, _transaction) - if name is not None: - self.name = name - if hostname is not None: - self.hostname = hostname - - self.updated.emit(self, olduser) - - def set_elevation(self, channel, elevation): + @transactional + def set_elevation(self, channel, elevation, _transaction=None): olduser = self.copy() if elevation is None: if channel in self.elevation: del self.elevation[channel] else: self.elevation[channel] = elevation - self.updated.emit(self, olduser) + self.emit_update(olduser, _transaction) @property def player(self): return self._player - @player.setter - def player(self, val): + @transactional + def set_player(self, val, _transaction=None): oldplayer = self._player self._player = val - self.newPlayer.emit(self, val, oldplayer) + _transaction.emit(self.newPlayer, self, val, oldplayer) + + @player.setter + def player(self, val): + # CAVEAT: this will emit signals immediately! + self.set_player(val) def is_mod(self, channel): if channel not in self.elevation: diff --git a/src/model/ircuserset.py b/src/model/ircuserset.py index 22161b643..c39417539 100644 --- a/src/model/ircuserset.py +++ b/src/model/ircuserset.py @@ -1,101 +1,53 @@ -from PyQt5.QtCore import QObject, pyqtSignal +from model.ircuser import IrcUser +from model.modelitemset import ModelItemSet +from model.transaction import transactional -class IrcUserset(QObject): - userAdded = pyqtSignal(object) - userRemoved = pyqtSignal(object) - +class IrcUserset(ModelItemSet): def __init__(self, playerset): - QObject.__init__(self) - self._users = {} + ModelItemSet.__init__(self) self._playerset = playerset - playerset.playerAdded.connect(self._at_player_added) - playerset.playerRemoved.connect(self._at_player_removed) - - def __getitem__(self, item): - return self._users[item] - - def __len__(self): - return len(self._users) - - def __iter__(self): - return iter(self._users) - - # We need to define the below things - QObject - # doesn't allow for Mapping mixin - def keys(self): - return self._users.keys() - - def values(self): - return self._users.values() - - def items(self): - return self._users.items() - - def get(self, item, default=None): - try: - return self[item] - except KeyError: - return default - - def __contains__(self, item): - try: - self[item] - return True - except KeyError: - return False - - def __setitem__(self, key, value): - if key in self: # disallow overwriting existing chatters - raise ValueError - - if key != value.name: - raise ValueError - - self._users[key] = value - - if value.name in self._playerset: - value.player = self._playerset[value.name] - - # We're first to connect, so first to get called - value.updated.connect(self._at_user_updated) - - self.userAdded.emit(value) - - def __delitem__(self, item): - try: - user = self[item] - except KeyError: + playerset.before_added.connect(self._at_player_added) + playerset.before_removed.connect(self._at_player_removed) + + @transactional + def set_item(self, key, value, _transaction=None): + if not isinstance(key, str) or not isinstance(value, IrcUser): + raise TypeError + ModelItemSet.set_item(self, key, value, _transaction) + if value.id_key in self._playerset: + value.player = self._playerset[value.id_key] + value.before_updated.connect(self._at_user_updated) + self.emit_added(value, _transaction) + + @transactional + def del_item(self, key, _transaction=None): + user = ModelItemSet.del_item(self, key, _transaction) + if user is None: return - del self._users[user.name] - user.updated.disconnect(self._at_user_updated) - self.userRemoved.emit(user) - - def clear(self): - oldusers = list(self.keys()) - for user in oldusers: - del self[user] + user.before_updated.disconnect(self._at_user_updated) + self.emit_removed(user, _transaction) - def _at_player_added(self, player): + def _at_player_added(self, player, _transaction=None): if player.login in self: - self[player.login].player = player + self[player.login].set_player(player, _transaction) - def _at_player_removed(self, player): + def _at_player_removed(self, player, _transaction=None): if player.login in self: - self[player.login].player = None + self[player.login].set_player(None, _transaction) - def _at_user_updated(self, user, olduser): + def _at_user_updated(self, user, olduser, _transaction=None): if user.name != olduser.name: - self._handle_rename(user, olduser) + self._handle_rename(user, olduser, _transaction) - def _handle_rename(self, user, olduser): + def _handle_rename(self, user, olduser, _transaction=None): # We should never rename to an existing user, but let's handle it if user.name in self: - del self[user.name] + self.del_item(user.name, _transaction) - if olduser.name in self._users: - del self._users[olduser.name] - self._users[user.name] = user + if olduser.name in self._items: + del self._items[olduser.name] + self._items[user.name] = user newplayer = self._playerset.get(user.name) - user.player = newplayer + user.set_player(newplayer, _transaction) diff --git a/src/model/modelitem.py b/src/model/modelitem.py new file mode 100644 index 000000000..d15eec6bd --- /dev/null +++ b/src/model/modelitem.py @@ -0,0 +1,47 @@ +from PyQt6.QtCore import pyqtSignal + +from model.qobjectmapping import QObject +from model.transaction import transactional + + +class ModelItem(QObject): + updated = pyqtSignal(object, object) + before_updated = pyqtSignal(object, object, object) + + def __init__(self): + QObject.__init__(self) + self._data_fields = [] + + def add_field(self, name, default): + self._data_fields.append(name) + setattr(self, name, default) + + @property + def field_dict(self): + return {v: getattr(self, v) for v in self._data_fields} + + def copy(self): + raise NotImplementedError + + def update(self, **kwargs): + # Ignore unknown fields for convenience + for f in self._data_fields: + if f in kwargs: + setattr(self, f, kwargs[f]) + + @transactional + def emit_update(self, old, _transaction=None): + _transaction.emit(self.updated, self, old) + self.before_updated.emit(self, old, _transaction) + + @property + def id_key(self): + raise NotImplementedError + + def __hash__(self): + return hash(self.id_key) + + def __eq__(self, other): + if not isinstance(self, type(other)): + return False + return self.id_key == other.id_key diff --git a/src/model/modelitemset.py b/src/model/modelitemset.py new file mode 100644 index 000000000..3aa6fef21 --- /dev/null +++ b/src/model/modelitemset.py @@ -0,0 +1,64 @@ +from PyQt6.QtCore import pyqtSignal + +from model.qobjectmapping import QObjectMapping +from model.transaction import transactional + + +class ModelItemSet(QObjectMapping): + added = pyqtSignal(object) + removed = pyqtSignal(object) + before_added = pyqtSignal(object, object) + before_removed = pyqtSignal(object, object) + + def __init__(self): + QObjectMapping.__init__(self) + + self._items = {} + + def __getitem__(self, item): + return self._items[item] + + def __len__(self): + return len(self._items) + + def __iter__(self): + return iter(self._items) + + def emit_added(self, value, _transaction=None): + _transaction.emit(self.added, value) + self.before_added.emit(value, _transaction) + + def emit_removed(self, value, _transaction=None): + _transaction.emit(self.removed, value) + self.before_removed.emit(value, _transaction) + + @transactional + def set_item(self, key, value, _transaction=None): + if key in self: + raise ValueError + if key != value.id_key: + raise ValueError + self._items[key] = value + + def __setitem__(self, key, value): + # CAVEAT: use only as an entry point for model changes. + self.set_item(key, value) + + @transactional + def del_item(self, item, _transaction=None): + try: + value = self[item] + except KeyError: + return None + del self._items[value.id_key] + return value + + def __delitem__(self, item): + # CAVEAT: use only as an entry point for model changes. + self.del_item(item) + + @transactional + def clear(self, _transaction=None): + items = list(self.keys()) + for item in items: + self.del_item(item, _transaction) diff --git a/src/model/player.py b/src/model/player.py index 5b3e5334f..407edc29d 100644 --- a/src/model/player.py +++ b/src/model/player.py @@ -1,165 +1,152 @@ -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import pyqtSignal +from model.modelitem import ModelItem +from model.rating import RatingType +from model.transaction import transactional -class Player(QObject): - updated = pyqtSignal(object, object) + +class Player(ModelItem): newCurrentGame = pyqtSignal(object, object, object) """ Represents a player the client knows about. """ - def __init__(self, - id_, - login, - global_rating=(1500, 500), - ladder_rating=(1500, 500), - number_of_games=0, - avatar=None, - country=None, - clan=None, - league=None): - QObject.__init__(self) + + def __init__( + self, + id_, + login, + ratings={}, + avatar=None, + country=None, + clan=None, + league=None, + **kwargs + ): + ModelItem.__init__(self) """ Initialize a Player """ # Required fields + # Login should be mutable, but we look up things by login right now self.id = int(id_) self.login = login - self.global_rating = global_rating - self.ladder_rating = ladder_rating - self.number_of_games = number_of_games - self.avatar = avatar - self.country = country - self.clan = clan - self.league = league + self.add_field("avatar", avatar) + self.add_field("country", country) + self.add_field("clan", clan) + self.add_field("league", league) + self.add_field("ratings", ratings) # The game the player is currently playing self._currentGame = None + @property + def id_key(self): + return self.id + def copy(self): - s = self - p = Player(s.id, s.login, s.global_rating, s.ladder_rating, - s.number_of_games, s.avatar, s.country, s.clan, s.league) - p.currentGame = self._currentGame + p = Player(self.id, self.login, **self.field_dict) + p.currentGame = self.currentGame return p - def update(self, - id_=None, - login=None, - global_rating=None, - ladder_rating=None, - number_of_games=None, - avatar=None, - country=None, - clan=None, - league=None): + @transactional + def update(self, **kwargs): + _transaction = kwargs.pop("_transaction") old_data = self.copy() - # Ignore id and login (they are be immutable) - # Login should be mutable, but we look up things by login right now - if global_rating is not None: - self.global_rating = global_rating - if ladder_rating is not None: - self.ladder_rating = ladder_rating - if number_of_games is not None: - self.number_of_games = number_of_games - if avatar is not None: - self.avatar = avatar - if country is not None: - self.country = country - if clan is not None: - self.clan = clan - if league is not None: - self.league = league - - self.updated.emit(self, old_data) - - def __hash__(self): - """ - Index by id - """ - return self.id.__hash__() + ModelItem.update(self, **kwargs) + self.emit_update(old_data, _transaction) def __index__(self): return self.id - def __eq__(self, other): - """ - Equality by id - - :param other: player object to compare with - """ - if not isinstance(other, Player): - return False - return other.id == self.id - - def rounded_rating_estimate(self): - """ - Get the conservative estimate of the players global trueskill rating, - rounded to nearest 100 - """ - return round((self.rating_estimate()/100))*100 - - def rating_estimate(self): - """ - Get the conservative estimate of the players global trueskill rating - """ - return int(max(0, (self.global_rating[0] - 3 * self.global_rating[1]))) + @property + def global_estimate(self): + return self.rating_estimate() + @property def ladder_estimate(self): - """ - Get the conservative estimate of the players ladder trueskill rating - """ - return int(max(0, (self.ladder_rating[0] - 3 * self.ladder_rating[1]))) + return self.rating_estimate(RatingType.LADDER.value) @property - def rating_mean(self): - return self.global_rating[0] + def global_rating_mean(self): + return self.rating_mean() @property - def rating_deviation(self): - return self.global_rating[1] + def global_rating_deviation(self): + return self.rating_deviation() @property def ladder_rating_mean(self): - return self.ladder_rating[0] + return self.rating_mean(RatingType.LADDER.value) @property def ladder_rating_deviation(self): - return self.ladder_rating[1] + return self.rating_deviation(RatingType.LADDER.value) + + @property + def number_of_games(self): + count = 0 + for rating_type in self.ratings: + count += self.ratings[rating_type].get("number_of_games", 0) + return count + + def rating_estimate(self, rating_type=RatingType.GLOBAL.value): + """ + Get the conservative estimate of the player's trueskill rating + """ + try: + mean = self.ratings[rating_type]["rating"][0] + deviation = self.ratings[rating_type]["rating"][1] + return int(max(0, (mean - 3 * deviation))) + except (KeyError, IndexError): + return 0 + + def rating_mean(self, rating_type=RatingType.GLOBAL.value): + try: + return round(self.ratings[rating_type]["rating"][0]) + except (KeyError, IndexError): + return 1500 + + def rating_deviation(self, rating_type=RatingType.GLOBAL.value): + try: + return round(self.ratings[rating_type]["rating"][1]) + except (KeyError, IndexError): + return 500 + + def game_count(self, rating_type=RatingType.GLOBAL.value): + try: + return int(self.ratings[rating_type]["number_of_games"]) + except KeyError: + return 0 def __repr__(self): return self.__str__() def __str__(self): - return ("Player(id={}, login={}, global_rating={}, " - "ladder_rating={})").format( + return ( + "Player(id={}, login={}, global_rating={}, ladder_rating={})" + ).format( self.id, self.login, - self.global_rating, - self.ladder_rating + (self.global_rating_mean, self.global_rating_deviation), + (self.ladder_rating_mean, self.ladder_rating_deviation), ) @property def currentGame(self): return self._currentGame - @currentGame.setter - def currentGame(self, game): - self.set_current_game_defer_signal(game)() - - def set_current_game_defer_signal(self, game): + @transactional + def set_currentGame(self, game, _transaction=None): if self.currentGame == game: - return lambda: None - + return old = self._currentGame self._currentGame = game - return lambda: self._emit_game_change(game, old) - - def _emit_game_change(self, game, old): - self.newCurrentGame.emit(self, game, old) - if old is not None: - old.ingamePlayerRemoved.emit(old, self) - if game is not None: - game.ingamePlayerAdded.emit(game, self) + _transaction.emit(self.newCurrentGame, self, game, old) + + @currentGame.setter + def currentGame(self, val): + # CAVEAT: this will emit signals immediately! + self.set_currentGame(val) diff --git a/src/model/playerset.py b/src/model/playerset.py index e7ecdb254..96663aadf 100644 --- a/src/model/playerset.py +++ b/src/model/playerset.py @@ -1,91 +1,43 @@ -from PyQt5.QtCore import QObject, pyqtSignal - +from model.modelitemset import ModelItemSet from model.player import Player +from model.transaction import transactional -class Playerset(QObject): - """ - Wrapper for an id->Player map - - Used to lookup players either by id or by login. - """ - playerAdded = pyqtSignal(object) - playerRemoved = pyqtSignal(object) - +class Playerset(ModelItemSet): def __init__(self): - QObject.__init__(self) - - # UID -> Player map - self._players = {} + ModelItemSet.__init__(self) # Login -> Player map self._logins = {} def __getitem__(self, item): if isinstance(item, int): - return self._players[item] + return ModelItemSet.__getitem__(self, item) if isinstance(item, str): return self._logins[item] raise TypeError - def __len__(self): - return len(self._players) - - def __iter__(self): - return iter(self._players) - - # We need to define the below things - QObject - # doesn't allow for Mapping mixin - def keys(self): - return self._players.keys() - - def values(self): - return self._players.values() - - def items(self): - return self._players.items() - - def get(self, item, default=None): - try: - return self[item] - except KeyError: - return default - - def __contains__(self, item): - try: - self[item] - return True - except KeyError: - return False - def getID(self, name): if name in self: return self[name].id return -1 - def __setitem__(self, key, value): + @transactional + def set_item(self, key, value, _transaction=None): if not isinstance(key, int) or not isinstance(value, Player): raise TypeError - if key in self: # disallow overwriting existing players - raise ValueError - - if key != value.id: - raise ValueError - - self._players[key] = value + ModelItemSet.set_item(self, key, value, _transaction) self._logins[value.login] = value - self.playerAdded.emit(value) + self.emit_added(value, _transaction) - def __delitem__(self, item): - try: - player = self[item] - except KeyError: + @transactional + def del_item(self, key, _transaction=None): + player = ModelItemSet.del_item(self, key, _transaction) + if player is None: return - del self._players[player.id] del self._logins[player.login] - self.playerRemoved.emit(player) + self.emit_removed(player, _transaction) - def clear(self): - oldplayers = list(self.keys()) - for player in oldplayers: - del self[player] + def __delitem__(self, item): + # CAVEAT: use only as an entry point for model changes. + self.del_item(item) diff --git a/src/model/qobjectmapping.py b/src/model/qobjectmapping.py new file mode 100644 index 000000000..7e15a6141 --- /dev/null +++ b/src/model/qobjectmapping.py @@ -0,0 +1,67 @@ +from collections.abc import ItemsView +from collections.abc import KeysView +from collections.abc import ValuesView + +from PyQt6.QtCore import QObject + + +class QObjectMapping(QObject): + """ + ABC similar to collections.abc.MutableMapping. + Used since we can't mixin the above with QObject. + """ + + def __init__(self): + QObject.__init__(self) + + def __len__(self): + return 0 + + def __iter__(self): + while False: + yield None + + def __getitem__(self, key): + raise KeyError + + def __setitem__(self, key, value): + raise KeyError + + def __delitem__(self, key): + raise KeyError + + __marker = object() + + def pop(self, key, default=__marker): + try: + value = self[key] + except KeyError: + if default is self.__marker: + raise + return default + else: + del self[key] + return value + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def __contains__(self, key): + try: + self[key] + except KeyError: + return False + else: + return True + + def keys(self): + return KeysView(self) + + def values(self): + return ValuesView(self) + + def items(self): + return ItemsView(self) diff --git a/src/model/rating.py b/src/model/rating.py new file mode 100644 index 000000000..83fea2913 --- /dev/null +++ b/src/model/rating.py @@ -0,0 +1,41 @@ +# TODO: fetch this from API + +from enum import Enum + +# copied from the server code according to which +# this will need be fixed when the database +# gets migrated + + +class RatingType(Enum): + GLOBAL = "global" + LADDER = "ladder_1v1" + TMM_2v2 = "tmm_2v2" + TMM_3v3 = "tmm_3v3" + TMM_4v4 = "tmm_4v4" + + @staticmethod + def fromMatchmakerQueue(matchmakerQueueName): + for ratingType in list(RatingType): + if ratingType.value.replace("_", "") == matchmakerQueueName: + return ratingType.value + return RatingType.GLOBAL.value + + +# this is not from the server code. but it is weird +# that rating types and leaderboard names differ +# from matchmaker queue names + + +class MatchmakerQueueType(Enum): + LADDER = "ladder1v1" + TMM_2v2 = "tmm2v2" + TMM_3v3 = "tmm3v3" + TMM_4v4 = "tmm4v4" + + @staticmethod + def fromRatingType(ratingTypeName): + for matchmakerQueue in list(MatchmakerQueueType): + if ratingTypeName.replace("_", "") == matchmakerQueue.value: + return matchmakerQueue.value + return MatchmakerQueueType.LADDER.value diff --git a/src/model/transaction.py b/src/model/transaction.py new file mode 100644 index 000000000..81a4db21c --- /dev/null +++ b/src/model/transaction.py @@ -0,0 +1,45 @@ +class ModelTransaction: + """ + Allows model classes to postpone side effects of a model update (such as + emitting signals) until after the model is in a consistent state. + """ + + def __init__(self): + self._signals = [] + + def emit(self, *args): + self._signals.append(args) + + def finalize(self): + for s in self._signals: + s[0].emit(*s[1:]) + self._signals = [] + + +# An easy way for a function to create a transaction if it's called without one +# and finalize it once it's done, and otherwise use a supplied transaction. +# +# In order to use it, a function has to define a _transaction argument as its +# last, and should not accept another transaction instance. The transaction +# argument will be added to kwargs if any were defined and _transaction was not +# among them, or if there are no kwargs and the last arg is not a transaction. + +def transactional(fn): + def trans_fn(*args, **kwargs): + top_transaction = None + + # _transaction is last, so if kwargs are non-empty, it's in them + if kwargs: + if "_transaction" not in kwargs: + top_transaction = ModelTransaction() + kwargs["_transaction"] = top_transaction + else: + if not args or not isinstance(args[-1], ModelTransaction): + top_transaction = ModelTransaction() + args = args + (top_transaction,) + + ret = fn(*args, **kwargs) + if top_transaction is not None: + top_transaction.finalize() + return ret + return trans_fn diff --git a/src/modvault/__init__.py b/src/modvault/__init__.py deleted file mode 100644 index 4749f5c30..000000000 --- a/src/modvault/__init__.py +++ /dev/null @@ -1,428 +0,0 @@ -""" -Modvault database documentation: -command = "modvault" -possible commands (value for the 'type' key): - start: - given when the tab is opened. Signals that the server should send the possible mods. - addcomment: moduid=, comment={"or","uid","date","text"} - addbugreport: moduid=, comment={"author","uid","date","text"} - like: uid- - -Can also send a UPLOAD_MOD command directly using writeToServer -"UPLOAD_MOD","modname.zip",{mod info}, qfile - -modInfo function is called when the client recieves a modvault_info command. -It should have a message dict with the following keys: -uid - Unique identifier for a mod. Also needed ingame. -name - Name of the mod. Also the name of the folder the mod will be located in. -description - A general description of the mod. As seen ingame -author - The FAF username of the person that uploaded the mod. -downloads - An integer containing the amount of downloads of this mod -likes - An integer containing the amount of likes the mod has recieved. #TODO: Actually implement an inteface for this. -comments - A python list containing dictionaries containing the keys as described above. -bugreports - A python list containing dictionaries containing the keys as described above. -date - A string describing the date the mod was uploaded. Format: "%Y-%m-%d %H:%M:%S" eg: 2012-10-28 16:50:28 -ui - A boolean describing if it is a ui mod yay or nay. -link - Direct link to the zip file containing the mod. -thumbnail - A direct link to the thumbnail file. Should be something suitable for util.THEME.icon(). Not yet tested if this works correctly - -Additional stuff: -fa.exe now has a CheckMods method, which is used in fa.exe.check -check has a new argument 'additional_mods' for this. -In client._clientwindow joinGameFromURL is changed. The url should have a -queryItemValue called 'mods' which with json can be translated in a list of modnames -so that it can be checked with checkMods. -handle_game_launch should have a new key in the form of mods, which is a list of modnames -to be checked with checkMods. - -Stuff to be removed: -In _gameswidget.py in hostGameCLicked setActiveMods is called. -This should be done in the faf.exe.check function or in the lobby code. -It is here because the server doesn't yet send the mods info. - -The tempAddMods function should be removed after the server can return mods in the modvault. -""" - -import os - -import zipfile - -from PyQt5 import QtCore, QtWidgets, QtGui - -from modvault.utils import * -from .modwidget import ModWidget -from .uploadwidget import UploadModWidget -from .uimodwidget import UIModWidget -from ui.busy_widget import BusyWidget - -import util -import logging -import time -logger = logging.getLogger(__name__) -import urllib.request, urllib.error, urllib.parse - -from util import datetostr, now -d = datetostr(now()) - -from downloadManager import PreviewDownloadRequest - -""" -tempmod1 = dict(uid=1,name='Mod1', comments=[],bugreports=[], date = d, - ui=True, downloads=0, likes=0, - thumbnail='',author='johnie102', - description='Lorem ipsum dolor sit amet, consectetur adipiscing elit. ',) -""" - -FormClass, BaseClass = util.THEME.loadUiType("modvault/modvault.ui") - - -class ModVault(FormClass, BaseClass, BusyWidget): - def __init__(self, client, *args, **kwargs): - QtCore.QObject.__init__(self, *args, **kwargs) - - self.setupUi(self) - - self.client = client - - logger.debug("Mod Vault tab instantiating") - self.loaded = False - - self.modList.setItemDelegate(ModItemDelegate(self)) - self.modList.itemDoubleClicked.connect(self.modClicked) - self.searchButton.clicked.connect(self.search) - self.searchInput.returnPressed.connect(self.search) - self.uploadButton.clicked.connect(self.openUploadForm) - self.UIButton.clicked.connect(self.openUIModForm) - - self.SortType.setCurrentIndex(2) - self.SortType.currentIndexChanged.connect(self.sortChanged) - self.ShowType.currentIndexChanged.connect(self.showChanged) - - self.client.lobby_info.modVaultInfo.connect(self.modInfo) - - self.sortType = "rating" - self.showType = "all" - self.searchString = "" - - self.mods = {} - self.uids = [mod.uid for mod in getInstalledMods()] - - @QtCore.pyqtSlot(dict) - def modInfo(self, message): # this is called when the database has send a mod to us - """ - See above for the keys neccessary in message. - """ - uid = message["uid"] - if not uid in self.mods: - mod = ModItem(self, uid) - self.mods[uid] = mod - self.modList.addItem(mod) - else: - mod = self.mods[uid] - mod.update(message) - self.modList.sortItems(1) - - @QtCore.pyqtSlot(int) - def sortChanged(self, index): - if index == -1 or index == 0: - self.sortType = "alphabetical" - elif index == 1: - self.sortType = "date" - elif index == 2: - self.sortType = "rating" - elif index == 3: - self.sortType = "downloads" - self.updateVisibilities() - - @QtCore.pyqtSlot(int) - def showChanged(self, index): - if index == -1 or index == 0: - self.showType = "all" - elif index == 1: - self.showType = "ui" - elif index == 2: - self.showType = "sim" - elif index == 5: - self.showType = "yours" - elif index == 6: - self.showType = "installed" - self.updateVisibilities() - - @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) - def modClicked(self, item): - widget = ModWidget(self, item) - widget.exec_() - - def search(self): - """ Sending search to mod server""" - - self.searchString = self.searchInput.text().lower() - index = self.ShowType.currentIndex() - typemod = 2 - - if index == 1: - typemod = 1 - elif index == 2: - typemod = 0 - - self.client.statsServer.send(dict(command="modvault_search", typemod=typemod, search=self.searchString)) - - self.updateVisibilities() - - @QtCore.pyqtSlot() - def openUIModForm(self): - dialog = UIModWidget(self) - dialog.exec_() - - @QtCore.pyqtSlot() - def openUploadForm(self): - modDir = QtWidgets.QFileDialog.getExistingDirectory(self.client, "Select the mod directory to upload", - MODFOLDER, QtWidgets.QFileDialog.ShowDirsOnly) - logger.debug("Uploading mod from: " + modDir) - if modDir != "": - if isModFolderValid(modDir): - # os.chmod(modDir, S_IWRITE) Don't need this at the moment - modinfofile, modinfo = parseModInfo(modDir) - if modinfofile.error: - logger.debug("There were " + str(modinfofile.errors) + " errors and " + str(modinfofile.warnings) + - " warnings.") - logger.debug(modinfofile.errorMsg) - QtWidgets.QMessageBox.critical(self.client, "Lua parsing error", modinfofile.errorMsg + - "\nMod uploading cancelled.") - else: - if modinfofile.warning: - uploadmod = QtWidgets.QMessageBox.question(self.client, "Lua parsing warning", - modinfofile.errorMsg + - "\nDo you want to upload the mod?", - QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No) - else: - uploadmod = QtWidgets.QMessageBox.Yes - if uploadmod == QtWidgets.QMessageBox.Yes: - modinfo = ModInfo(**modinfo) - modinfo.setFolder(os.path.split(modDir)[1]) - modinfo.update() - dialog = UploadModWidget(self, modDir, modinfo) - dialog.exec_() - else: - QtWidgets.QMessageBox.information(self.client, "Mod selection", - "This folder doesn't contain a mod_info.lua file") - - @QtCore.pyqtSlot() - def busy_entered(self): - self.client.lobby_connection.send(dict(command="modvault", type="start")) - - def updateVisibilities(self): - logger.debug("Updating visibilities with sort '%s' and visibility '%s'" % (self.sortType, self.showType)) - for mod in self.mods: - self.mods[mod].updateVisibility() - self.modList.sortItems(1) - - def downloadMod(self, mod): - if downloadMod(mod): - self.client.lobby_connection.send(dict(command="modvault", type="download", uid=mod.uid)) - self.uids = [mod.uid for mod in getInstalledMods()] - self.updateVisibilities() - return True - else: - return False - - def removeMod(self, mod): - if removeMod(mod): - self.uids = [m.uid for m in installedMods] - mod.updateVisibility() - - -# the drawing helper function for the modlist -class ModItemDelegate(QtWidgets.QStyledItemDelegate): - - def __init__(self, *args, **kwargs): - QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) - - def paint(self, painter, option, index, *args, **kwargs): - self.initStyleOption(option, index) - - painter.save() - - html = QtGui.QTextDocument() - html.setHtml(option.text) - - icon = QtGui.QIcon(option.icon) - iconsize = icon.actualSize(option.rect.size()) - - # clear icon and text before letting the control draw itself because we're rendering these parts ourselves - option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) - - # Shadow - painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1, iconsize.width(), iconsize.height(), QtGui.QColor("#202020")) - - # Icon - icon.paint(painter, option.rect.adjusted(5-2, -2, 0, 0), QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) - - # Frame around the icon - pen = QtGui.QPen() - pen.setWidth(1) - pen.setBrush(QtGui.QColor("#303030")) # FIXME: This needs to come from theme. - pen.setCapStyle(QtCore.Qt.RoundCap) - painter.setPen(pen) - painter.drawRect(option.rect.left()+5-2, option.rect.top()+3, iconsize.width(), iconsize.height()) - - # Description - painter.translate(option.rect.left() + iconsize.width() + 10, option.rect.top()+4) - clip = QtCore.QRectF(0, 0, option.rect.width()-iconsize.width() - 10 - 5, option.rect.height()) - html.drawContents(painter, clip) - - painter.restore() - - def sizeHint(self, option, index, *args, **kwargs): - self.initStyleOption(option, index) - - html = QtGui.QTextDocument() - html.setHtml(option.text) - html.setTextWidth(ModItem.TEXTWIDTH) - return QtCore.QSize(ModItem.ICONSIZE + ModItem.TEXTWIDTH + ModItem.PADDING, ModItem.ICONSIZE + ModItem.PADDING) - - -class ModItem(QtWidgets.QListWidgetItem): - TEXTWIDTH = 230 - ICONSIZE = 100 - PADDING = 10 - - WIDTH = ICONSIZE + TEXTWIDTH - #DATA_PLAYERS = 32 - - FORMATTER_MOD = str(util.THEME.readfile("modvault/modinfo.qthtml")) - FORMATTER_MOD_UI = str(util.THEME.readfile("modvault/modinfoui.qthtml")) - - def __init__(self, parent, uid, *args, **kwargs): - QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) - - self.parent = parent - self.uid = uid - self.name = "" - self.description = "" - self.author = "" - self.version = 0 - self.downloads = 0 - self.likes = 0 - self.played = 0 - self.comments = [] # every element is a dictionary with a - self.bugreports = [] # text, author and date key - self.date = None - self.isuidmod = False - self.uploadedbyuser = False - - self.thumbnail = None - self.link = "" - self.loadThread = None - self.setHidden(True) - - self._map_dl_request = PreviewDownloadRequest() - self._map_dl_request.done.connect(self._on_mod_downloaded) - - def update(self, dic): - self.name = dic["name"] - self.played = dic["played"] - self.description = dic["description"] - self.version = dic["version"] - self.author = dic["author"] - self.downloads = dic["downloads"] - self.likes = dic["likes"] - self.comments = dic["comments"] - self.bugreports = dic["bugreports"] - self.date = QtCore.QDateTime.fromTime_t(dic['date']).toString("yyyy-MM-dd") - self.isuimod = dic["ui"] - self.link = dic["link"] # Direct link to the zip file. - self.thumbstr = dic["thumbnail"] # direct url to the thumbnail file. - self.uploadedbyuser = (self.author == self.parent.client.login) - - self.thumbnail = None - if self.thumbstr == "": - self.setIcon(util.THEME.icon("games/unknown_map.png")) - else: - name = os.path.basename(urllib.parse.unquote(self.thumbstr)) - img = getIcon(name) - if img: - self.setIcon(util.THEME.icon(img, False)) - else: - self.parent.client.mod_downloader.download_preview(name, self._map_dl_request, self.thumbstr) - self.updateVisibility() - - def _on_mod_downloaded(self, modname, result): - path, is_local = result - icon = util.THEME.icon(path, is_local) - self.setIcon(icon) - - def updateIcon(self): - self.setIcon(self.thumbnail) - - def shouldBeVisible(self): - p = self.parent - if p.searchString != "": - if not (self.author.lower().find(p.searchString) != -1 or self.name.lower().find(p.searchString) != -1 or - self.description.lower().find(" " + p.searchString + " ") != -1): - return False - if p.showType == "all": - return True - elif p.showType == "ui": - return self.isuimod - elif p.showType == "sim": - return not self.isuimod - elif p.showType == "yours": - return self.uploadedbyuser - elif p.showType == "installed": - return self.uid in self.parent.uids - else: # shouldn't happen - return True - - def updateVisibility(self): - self.setHidden(not self.shouldBeVisible()) - if len(self.description) < 200: - descr = self.description - else: - descr = self.description[:197] + "..." - - modtype = "" - if self.isuimod: - modtype = "UI mod" - if self.uid in self.parent.uids: - color = "green" - else: - color = "white" - - if self.isuimod: - self.setText(self.FORMATTER_MOD_UI.format(color=color, version=str(self.version), title=self.name, - description=descr, author=self.author, - downloads=str(self.downloads), likes=str(self.likes), - date=str(self.date), modtype=modtype)) - else: - self.setText(self.FORMATTER_MOD.format(color=color, version=str(self.version), title=self.name, - description=descr, author=self.author, downloads=str(self.downloads), - likes=str(self.likes), date=str(self.date), modtype=modtype, - played=str(self.played))) - - self.setToolTip('

%s

' % self.description) - - def __ge__(self, other): - return not self.__lt__(self, other) - - def __lt__(self, other): - if self.parent.sortType == "alphabetical": - if self.name.lower() == other.name.lower(): - return self.uid < other.uid - return self.name.lower() > other.name.lower() - elif self.parent.sortType == "rating": - if self.likes == other.likes: - return self.downloads < other.downloads - return self.likes < other.likes - elif self.parent.sortType == "downloads": - if self.downloads == other.downloads: - return self.date < other.date - return self.downloads < other.downloads - elif self.parent.sortType == "date": - # guard - if self.date is None: - return other.date is not None - if self.date == other.date: - return self.name.lower() < other.name.lower() - return self.date < other.date diff --git a/src/modvault/modwidget.py b/src/modvault/modwidget.py deleted file mode 100644 index 55cb914e2..000000000 --- a/src/modvault/modwidget.py +++ /dev/null @@ -1,168 +0,0 @@ - -import urllib.request, urllib.error, urllib.parse - -from PyQt5 import QtCore, QtWidgets, QtGui - -from util import strtodate, datetostr, now -import util - -FormClass, BaseClass = util.THEME.loadUiType("modvault/mod.ui") - - -class ModWidget(FormClass, BaseClass): - def __init__(self, parent, mod, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - - self.setupUi(self) - self.parent = parent - - self.setStyleSheet(self.parent.client.styleSheet()) - - self.setWindowTitle(mod.name) - - self.mod = mod - - self.Title.setText(mod.name) - self.Description.setText(mod.description) - modtext = "" - if mod.isuimod: modtext = "UI mod\n" - self.Info.setText(modtext + "By %s\nUploaded %s" % (mod.author, str(mod.date))) - if mod.thumbnail is None: - self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png")) - else: - self.Picture.setPixmap(mod.thumbnail.pixmap(100, 100)) - - #self.Comments.setItemDelegate(CommentItemDelegate(self)) - #self.BugReports.setItemDelegate(CommentItemDelegate(self)) - - self.tabWidget.setEnabled(False) - - if self.mod.uid in self.parent.uids: - self.DownloadButton.setText("Remove Mod") - self.DownloadButton.clicked.connect(self.download) - - #self.likeButton.clicked.connect(self.like) - #self.LineComment.returnPressed.connect(self.addComment) - #self.LineBugReport.returnPressed.connect(self.addBugReport) - - #for item in mod.comments: - # comment = CommentItem(self,item["uid"]) - # comment.update(item) - # self.Comments.addItem(comment) - #for item in mod.bugreports: - # comment = CommentItem(self,item["uid"]) - # comment.update(item) - # self.BugReports.addItem(comment) - - self.likeButton.setEnabled(False) - self.LineComment.setEnabled(False) - self.LineBugReport.setEnabled(False) - - @QtCore.pyqtSlot() - def download(self): - if self.mod.uid not in self.parent.uids: - self.parent.downloadMod(self.mod) - self.done(1) - else: - show = QtWidgets.QMessageBox.question(self.parent.client, "Delete Mod", - "Are you sure you want to delete this mod?", - QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No) - if show == QtWidgets.QMessageBox.Yes: - self.parent.removeMod(self.mod) - self.done(1) - - @QtCore.pyqtSlot() - def addComment(self): - if self.LineComment.text() == "": - return - comment = {"author": self.parent.client.login, "text": self.LineComment.text(), - "date": datetostr(now()), "uid": "%s-%s" % (self.mod.uid, str(len(self.mod.bugreports) + - len(self.mod.comments)).zfill(3))} - - self.parent.client.lobby_connection.send(dict(command="modvault", type="addcomment", moduid=self.mod.uid, - comment=comment)) - c = CommentItem(self, comment["uid"]) - c.update(comment) - self.Comments.addItem(c) - self.mod.comments.append(comment) - self.LineComment.setText("") - - @QtCore.pyqtSlot() - def addBugReport(self): - if self.LineBugReport.text() == "": - return - bugreport = {"author": self.parent.client.login, "text": self.LineBugReport.text(), - "date": datetostr(now()), "uid": "%s-%s" % (self.mod.uid, str(len(self.mod.bugreports) + - len(self.mod.comments)).zfill(3))} - - self.parent.client.lobby_connection.send(dict(command="modvault", type="addbugreport", moduid=self.mod.uid, - bugreport=bugreport)) - c = CommentItem(self, bugreport["uid"]) - c.update(bugreport) - self.BugReports.addItem(c) - self.mod.bugreports.append(bugreport) - self.LineBugReport.setText("") - - @QtCore.pyqtSlot() - def like(self): # the server should determine if the user hasn't already clicked the like button for this mod. - self.parent.client.lobby_connection.send(dict(command="modvault", type="like", uid=self.mod.uid)) - self.likeButton.setEnabled(False) - - -class CommentItemDelegate(QtWidgets.QStyledItemDelegate): - TEXTWIDTH = 350 - TEXTHEIGHT = 60 - - def __init__(self, *args, **kwargs): - QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) - - def paint(self, painter, option, index, *args, **kwargs): - self.initStyleOption(option, index) - - painter.save() - - html = QtGui.QTextDocument() - html.setHtml(option.text) - - option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) - - # Description - painter.translate(option.rect.left() + 10, option.rect.top()+10) - clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height()) - html.drawContents(painter, clip) - - painter.restore() - - def sizeHint(self, option, index, *args, **kwargs): - self.initStyleOption(option, index) - - html = QtGui.QTextDocument() - html.setHtml(option.text) - html.setTextWidth(self.TEXTWIDTH) - return QtCore.QSize(self.TEXTWIDTH, self.TEXTHEIGHT) - - -class CommentItem(QtWidgets.QListWidgetItem): - FORMATTER_COMMENT = str(util.THEME.readfile("modvault/comment.qthtml")) - - def __init__(self, parent, uid, *args, **kwargs): - QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) - - self.parent = parent - self.uid = uid - self.text = "" - self.author = "" - self.date = None - - def update(self, dic): - self.text = dic["text"] - self.author = dic["author"] - self.date = strtodate(dic["date"]) - self.setText(self.FORMATTER_COMMENT.format(text=self.text, author=self.author, date=str(self.date))) - - def __ge__(self, other): - return self.date > other.date - - def __lt__(self, other): - return self.date <= other.date diff --git a/src/modvault/uimodwidget.py b/src/modvault/uimodwidget.py deleted file mode 100644 index 06c3f1f45..000000000 --- a/src/modvault/uimodwidget.py +++ /dev/null @@ -1,55 +0,0 @@ - -import urllib.request, urllib.error, urllib.parse - -from PyQt5 import QtCore, QtWidgets - -import modvault -import util - -FormClass, BaseClass = util.THEME.loadUiType("modvault/uimod.ui") - - -class UIModWidget(FormClass, BaseClass): - FORMATTER_UIMOD = str(util.THEME.readfile("modvault/uimod.qthtml")) - - def __init__(self, parent, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - - self.setupUi(self) - self.parent = parent - - self.setStyleSheet(self.parent.client.styleSheet()) - - self.setWindowTitle("Ui Mod Manager") - - self.doneButton.clicked.connect(self.doneClicked) - self.modList.itemEntered.connect(self.hoverOver) - allmods = modvault.getInstalledMods() - self.uimods = {} - for mod in allmods: - if mod.ui_only: - self.uimods[mod.totalname] = mod - self.modList.addItem(mod.totalname) - - names = [mod.totalname for mod in modvault.getActiveMods(uimods=True)] - for name in names: - l = self.modList.findItems(name, QtCore.Qt.MatchExactly) - if l: - l[0].setSelected(True) - - if len(self.uimods) != 0: - self.hoverOver(self.modList.item(0)) - - @QtCore.pyqtSlot() - def doneClicked(self): - selected_mods = [self.uimods[str(item.text())] for item in self.modList.selectedItems()] - succes = modvault.setActiveMods(selected_mods, False) - if not succes: - QtWidgets.QMessageBox.information(None, "Error", "Could not set the active UI mods. Maybe something is " - "wrong with your game.prefs file. Please send your log.") - self.done(1) - - @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) - def hoverOver(self, item): - mod = self.uimods[str(item.text())] - self.modInfo.setText(self.FORMATTER_UIMOD.format(name=mod.totalname, description=mod.description)) diff --git a/src/modvault/uploadwidget.py b/src/modvault/uploadwidget.py deleted file mode 100644 index 4f9952901..000000000 --- a/src/modvault/uploadwidget.py +++ /dev/null @@ -1,116 +0,0 @@ -import urllib.request, urllib.error, urllib.parse -import tempfile -import zipfile -import os - -from PyQt5 import QtCore, QtWidgets - -import modvault -import util - -FormClass, BaseClass = util.THEME.loadUiType("modvault/upload.ui") - - -class UploadModWidget(FormClass, BaseClass): - def __init__(self, parent, modDir, modinfo, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - - self.setupUi(self) - self.parent = parent - self.client = self.parent.client - self.modinfo = modinfo - self.modDir = modDir - - self.setStyleSheet(self.parent.client.styleSheet()) - - self.setWindowTitle("Uploading Mod") - - self.Name.setText(modinfo.name) - self.Version.setText(str(modinfo.version)) - if modinfo.ui_only: - self.isUILabel.setText("is UI Only") - else: - self.isUILabel.setText("not UI Only") - self.UID.setText(modinfo.uid) - self.Description.setPlainText(modinfo.description) - if modinfo.icon != "": - self.IconURI.setText(modvault.iconPathToFull(modinfo.icon)) - self.updateThumbnail() - else: - self.Thumbnail.setPixmap(util.THEME.pixmap("games/unknown_map.png")) - self.UploadButton.pressed.connect(self.upload) - - @QtCore.pyqtSlot() - def upload(self): - n = self.Name.text() - if any([(i in n) for i in '"<*>|?/\\:']): - QtWidgets.QMessageBox.information(self.client, "Invalid Name", - "The mod name contains invalid characters: /\\<>|?:\"") - return - - iconpath = modvault.iconPathToFull(self.modinfo.icon) - infolder = False - if iconpath != "" and os.path.commonprefix([os.path.normcase(self.modDir), os.path.normcase(iconpath)]) == \ - os.path.normcase(self.modDir): # the icon is in the game folder - localpath = modvault.fullPathToIcon(iconpath) - infolder = True - if iconpath != "" and not infolder: - QtWidgets.QMessageBox.information(self.client, "Invalid Icon File", - "The file %s is not located inside the modfolder. Copy the icon file to " - "your modfolder and change the mod_info.lua accordingly" % iconpath) - return - - try: - temp = tempfile.NamedTemporaryFile(mode='w+b', suffix=".zip", delete=False) - zipped = zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED) - zipdir(self.modDir, zipped, os.path.basename(self.modDir)) - zipped.close() - temp.flush() - except: - QtWidgets.QMessageBox.critical(self.client, "Mod uploading error", "Something went wrong zipping the mod files.") - return - qfile = QtCore.QFile(temp.name) - - # The server should check again if there is already a mod with this name or UID. - self.client.lobby_connection.writeToServer("UPLOAD_MOD", "%s.v%04d.zip" % (self.modinfo.name, self.modinfo.version), self.modinfo.to_dict(), qfile) - - @QtCore.pyqtSlot() - def updateThumbnail(self): - iconfilename = modvault.iconPathToFull(self.modinfo.icon) - if iconfilename == "": - return False - if os.path.splitext(iconfilename)[1].lower() == ".dds": - old = iconfilename - iconfilename = os.path.join(self.modDir, os.path.splitext(os.path.basename(iconfilename))[0] + ".png") - succes = modvault.generateThumbnail(old, iconfilename) - if not succes: - QtWidgets.QMessageBox.information(self.client, "Invalid Icon File", - "Because FAF can't read DDS files, it tried to convert it to a png. " - "This failed. Try something else") - return False - try: - self.Thumbnail.setPixmap(util.THEME.pixmap(iconfilename, False)) - except: - QtWidgets.QMessageBox.information(self.client, "Invalid Icon File", - "This was not a valid icon file. Please pick a png or jpeg") - return False - self.modinfo.thumbnail = modvault.fullPathToIcon(iconfilename) - self.IconURI.setText(iconfilename) - return True - - -# from http://stackoverflow.com/questions/1855095/how-to-create-a-zip-archive-of-a-directory-in-python -def zipdir(path, zipf, fname): - # zips the entire directory path to zipf. Every file in the zipfile starts with fname. - # So if path is "/foo/bar/hello" and fname is "test" then every file in zipf is of the form "/test/*.*" - path = os.path.normcase(path) - if path[-1] in r'\/': - path = path[:-1] - short = os.path.split(path)[0] - for root, dirs, files in os.walk(path): - for f in files: - name = os.path.join(os.path.normcase(root), f) - n = name[len(os.path.commonprefix([name, path])):] - if n[0] == "\\": - n = n[1:] - zipf.write(name, os.path.join(fname, n)) diff --git a/src/news/__init__.py b/src/news/__init__.py index 97c382b5d..fbc0740e2 100644 --- a/src/news/__init__.py +++ b/src/news/__init__.py @@ -1,7 +1,11 @@ -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply -from PyQt5 import QtWidgets, QtCore - from ._newswidget import NewsWidget from .newsitem import NewsItem from .newsmanager import NewsManager from .wpapi import WPAPI + +__all__ = ( + "NewsWidget", + "NewsItem", + "NewsManager", + "WPAPI", +) diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py index c5a2642bc..d873952bf 100644 --- a/src/news/_newswidget.py +++ b/src/news/_newswidget.py @@ -1,86 +1,160 @@ -from PyQt5 import QtCore, QtWidgets -from PyQt5.QtCore import Qt - -import webbrowser -import util -import re -from .newsitem import NewsItem, NewsItemDelegate -from .newsmanager import NewsManager - -from util.qt import ExternalLinkPage - -import base64 - import logging +import os.path -logger = logging.getLogger(__name__) - - -class Hider(QtCore.QObject): - """ - Hides a widget by blocking its paint event. This is useful if a - widget is in a layout that you do not want to change when the - widget is hidden. - """ - def __init__(self, parent=None): - super(Hider, self).__init__(parent) +from PyQt6 import QtWidgets +from PyQt6.QtCore import QPoint +from PyQt6.QtCore import QSize +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QImage +from PyQt6.QtGui import QTextDocument +from PyQt6.QtNetwork import QNetworkAccessManager - def eventFilter(self, obj, ev): - return ev.type() == QtCore.QEvent.Paint +import util +from config import Settings +from downloadManager import Downloader +from downloadManager import DownloadRequest - def hide(self, widget): - widget.installEventFilter(self) - widget.update() +from .newsitem import NewsItem +from .newsitem import NewsItemDelegate +from .newsmanager import NewsManager - def unhide(self, widget): - widget.removeEventFilter(self) - widget.update() +logger = logging.getLogger(__name__) - def hideWidget(self, sender): - if sender.isWidgetType(): - self.hide(sender) FormClass, BaseClass = util.THEME.loadUiType("news/news.ui") class NewsWidget(FormClass, BaseClass): - CSS = util.THEME.readstylesheet('news/news_webview.css') + CSS = util.THEME.readstylesheet('news/news_style.css') - HTML = str(util.THEME.readfile('news/news_webview_frame.html')) + HTML = util.THEME.readfile('news/news_page.html') - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: BaseClass.__init__(self, *args, **kwargs) self.setupUi(self) + self.nam = QNetworkAccessManager() + self._downloader = Downloader(util.NEWS_CACHE_DIR) + self._images_dl_request = DownloadRequest() + self._images_dl_request.done.connect(self.item_image_downloaded) + self.newsManager = NewsManager(self) + self.newsItems = [] # open all links in external browser - self.newsWebView.setPage(ExternalLinkPage(self)) + self.newsTextBrowser.setOpenExternalLinks(True) - # hide webview until loaded to avoid FOUC - self.hider = Hider() - self.hider.hide(self.newsWebView) - self.newsWebView.loadFinished.connect(self.loadFinished) + self.settingsFrame.hide() + self.hideNewsEdit.setText(Settings.get('news/hideWords', "")) - self.newsList.setIconSize(QtCore.QSize(0, 0)) + self.newsList.setIconSize(QSize(0, 0)) self.newsList.setItemDelegate(NewsItemDelegate(self)) self.newsList.currentItemChanged.connect(self.itemChanged) + self.newsSettings.pressed.connect(self.showSettings) + self.showAllButton.pressed.connect(self.showAll) + self.hideNewsEdit.textEdited.connect(self.updateNewsFilter) + self.hideNewsEdit.cursorPositionChanged.connect(self.showEditToolTip) def addNews(self, newsPost): newsItem = NewsItem(newsPost, self.newsList) - - # QtWebEngine has no user CSS support yet, so let's just prepend it to the HTML - def _injectCSS(self, body): - return ''.format(self.CSS) + body - - def itemChanged(self, current, previous): - self.newsWebView.page().setHtml(self.HTML.format(title=current.newsPost['title'], - content=self._injectCSS(current.newsPost['body']),)) - - def linkClicked(self, url): - webbrowser.open(url.toString()) - - def loadFinished(self, ok): - self.hider.unhide(self.newsWebView) - self.newsWebView.loadFinished.disconnect(self.loadFinished) + self.newsItems.append(newsItem) + + def download_image(self, img_url: str) -> None: + name = os.path.basename(img_url) + self._downloader.download(name, self._images_dl_request, img_url) + + def add_image_resource(self, image_name: str, image_path: str) -> None: + doc = self.newsTextBrowser.document() + if doc.resource(QTextDocument.ResourceType.ImageResource, QUrl(image_name)): + return + img = QImage(image_path) + scaled = img.scaled(QSize(900, 500)) + doc.addResource(QTextDocument.ResourceType.ImageResource, QUrl(image_name), scaled) + + def item_image_downloaded(self, image_name: str, result: tuple[str, bool]) -> None: + image_path, download_failed = result + if not download_failed: + self.add_image_resource(image_name, image_path) + self.show_newspage() + + def itemChanged(self, current: NewsItem | None, previous: NewsItem | None) -> None: + if current is None: + return + + url = current.newsPost["img_url"] + image_name = os.path.basename(url) + image_path = os.path.join(util.NEWS_CACHE_DIR, image_name) + if os.path.isfile(image_path): + self.add_image_resource(image_name, image_path) + self.show_newspage() + else: + self._downloader.download(image_name, self._images_dl_request, url) + + def show_newspage(self) -> None: + current = self.newsList.currentItem() + + if current.newsPost['external_link'] == '': + external_link = current.newsPost['link'] + else: + external_link = current.newsPost['external_link'] + + image_name = os.path.basename(current.newsPost["img_url"]) + content = current.newsPost["excerpt"].strip().removeprefix("

").removesuffix("

") + html = self.HTML.format( + style=self.CSS, + title=current.newsPost['title'], + content=content, + img_source=image_name, + external_link=external_link, + ) + self.newsTextBrowser.setHtml(html) + + def showAll(self): + for item in self.newsItems: + item.setHidden(False) + self.updateLabel(0) + + def showEditToolTip(self) -> None: + """ + Default tooltips are too slow and disappear when user starts typing + """ + widget = self.hideNewsEdit + position = widget.mapToGlobal( + QPoint(0 + widget.width(), 0 - widget.height() / 2), + ) + QtWidgets.QToolTip.showText( + position, + "To separate multiple words use commas: nomads,server,dev", + ) + + def showSettings(self): + if self.settingsFrame.isHidden(): + self.settingsFrame.show() + else: + self.settingsFrame.hide() + + def updateNewsFilter(self, text=False): + if text is not False: + Settings.set('news/hideWords', text) + + filterList = Settings.get('news/hideWords', "").lower().split(",") + newsHidden = 0 + + if filterList[0]: + for item in self.newsItems: + for word in filterList: + if word in item.text().lower(): + item.setHidden(True) + newsHidden += 1 + break + else: + item.setHidden(False) + else: + for item in self.newsItems: + item.setHidden(False) + + self.updateLabel(newsHidden) + + def updateLabel(self, number): + self.totalHidden.setText("NEWS HIDDEN: " + str(number)) diff --git a/src/news/newsitem.py b/src/news/newsitem.py index 81ac4daa1..b3c27e240 100644 --- a/src/news/newsitem.py +++ b/src/news/newsitem.py @@ -1,19 +1,21 @@ -from PyQt5 import QtCore, QtGui, QtWidgets +import logging + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util -import client -import logging logger = logging.getLogger(__name__) class NewsItemDelegate(QtWidgets.QStyledItemDelegate): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) html = QtGui.QTextDocument() to = QtGui.QTextOption() - to.setWrapMode(QtGui.QTextOption.WordWrap) + to.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap) html.setDefaultTextOption(to) html.setTextWidth(NewsItem.TEXTWIDTH) @@ -26,30 +28,40 @@ def paint(self, painter, option, index, *args, **kwargs): self.html.setHtml(option.text) - icon = QtGui.QIcon(option.icon) - - # clear icon and text before letting the control draw itself because we're rendering these parts ourselves + # clear icon and text before letting the control draw itself because + # we're rendering these parts ourselves option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) + option.text = "" + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) # Shadow (100x100 shifted 8 right and 8 down) -# painter.fillRect(option.rect.left()+8, option.rect.top()+8, 100, 100, QtGui.QColor("#202020")) + # painter.fillRect(option.rect.left()+8, option.rect.top()+8, + # 100, 100, QtGui.QColor("#202020")) -# # Icon (110x110 adjusted: shifts top,left 3 and bottom,right -7 -> makes/clips it to 100x100) -# icon.paint(painter, option.rect.adjusted(3, 3, -7, -7), QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + # Icon (110x110 adjusted: shifts top,left 3 and bottom,right -7 -> + # makes/clips it to 100x100) + # icon.paint(painter, option.rect.adjusted(3, 3, -7, -7), + # QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # Frame around the icon (100x100 shifted 3 right and 3 down) -# pen = QtWidgets.QPen() -# pen.setWidth(1) -# pen.setBrush(QtGui.QColor("#303030")) # FIXME: This needs to come from theme. -# pen.setCapStyle(QtCore.Qt.RoundCap) -# painter.setPen(pen) -# painter.drawRect(option.rect.left() + 3, option.rect.top() + 3, 100, 100) - - # Description (text right of map icon(100), shifted 10 more right and 10 down) - painter.translate(option.rect.left() + 10, option.rect.top()+10) - clip = QtCore.QRectF(0, 0, option.rect.width() - 10 - 5, option.rect.height()) + # pen = QtWidgets.QPen() + # pen.setWidth(1) + # FIXME: This needs to come from theme. + # pen.setBrush(QtGui.QColor("#303030")) + + # pen.setCapStyle(QtCore.Qt.RoundCap) + # painter.setPen(pen) + # painter.drawRect(option.rect.left() + 3, option.rect.top() + 3, + # 100, 100) + + # Description (text right of map icon(100), shifted 10 more right and + # 10 down) + painter.translate(option.rect.left() + 10, option.rect.top() + 10) + clip = QtCore.QRectF( + 0, 0, option.rect.width() - 10 - 5, option.rect.height(), + ) self.html.drawContents(painter, clip) painter.restore() @@ -59,7 +71,9 @@ def sizeHint(self, option, index, *args, **kwargs): self.html.setHtml(option.text) - return QtCore.QSize(NewsItem.TEXTWIDTH + NewsItem.PADDING, NewsItem.TEXTHEIGHT) + return QtCore.QSize( + NewsItem.TEXTWIDTH + NewsItem.PADDING, NewsItem.TEXTHEIGHT, + ) class NewsItem(QtWidgets.QListWidgetItem): @@ -74,11 +88,13 @@ def __init__(self, newsPost, *args, **kwargs): self.newsPost = newsPost - self.setText(self.FORMATTER.format( - author=newsPost['author'][0]['name'], - date=newsPost['date'], - title=newsPost['title'] - )) + self.setText( + self.FORMATTER.format( + author=newsPost['author'][0]['name'], + date=newsPost['date'], + title=newsPost['title'], + ), + ) def __ge__(self, other): """ Comparison operator used for item list sorting """ diff --git a/src/news/newsmanager.py b/src/news/newsmanager.py index 23a87fa4b..b2707933d 100644 --- a/src/news/newsmanager.py +++ b/src/news/newsmanager.py @@ -1,13 +1,13 @@ -from PyQt5 import QtCore -from PyQt5.QtCore import QObject, Qt +import logging -from .newsitem import NewsItem -from .wpapi import WPAPI +from PyQt6 import QtCore +from PyQt6.QtCore import QObject +from PyQt6.QtCore import Qt import client -import math -import logging +from .wpapi import WPAPI + logger = logging.getLogger(__name__) @@ -17,20 +17,20 @@ class NewsManager(QObject): def __init__(self, client): QObject.__init__(self) self.widget = client -# self.newsContent = [] -# self.newsFrames = [] -# self.selectedFrame = None -# self.page = 0 -# -# for i in range(self.FRAMES): -# frame = NewsFrame() -# self.newsFrames.append(frame) -# client.newsAreaLayout.addWidget(frame) -# frame.clicked.connect(self.frameClicked) -# -# client.nextPageButton.clicked.connect(self.nextPage) -# client.prevPageButton.clicked.connect(self.prevPage) -# client.pageBox.currentIndexChanged.connect(self.selectPage) + # self.newsContent = [] + # self.newsFrames = [] + # self.selectedFrame = None + # self.page = 0 + + # for i in range(self.FRAMES): + # frame = NewsFrame() + # self.newsFrames.append(frame) + # client.newsAreaLayout.addWidget(frame) + # frame.clicked.connect(self.frameClicked) + + # client.nextPageButton.clicked.connect(self.nextPage) + # client.prevPageButton.clicked.connect(self.prevPage) + # client.pageBox.currentIndexChanged.connect(self.selectPage) self.WpApi = WPAPI(client) self.WpApi.newsDone.connect(self.on_wpapi_done) @@ -39,7 +39,8 @@ def __init__(self, client): @QtCore.pyqtSlot(list) def on_wpapi_done(self, items): """ - Reinitialize the whole news conglomerate after downloading the news from the api. + Reinitialize the whole news conglomerate after downloading the news + from the api. items is a list of (title, content) tuples. @@ -47,17 +48,30 @@ def on_wpapi_done(self, items): """ for item in items: self.widget.addNews(item) - self.widget.newsList.setCurrentItem(self.widget.newsList.item(0)) -# self.newsContent = self.newsContent + items -# -# self.npages = int(math.ceil(len(self.newsContent) / self.FRAMES)) -# -## origpage = self.page -# -# pb = client.instance.pageBox -# pb.insertItems(pb.count(), ['Page {: >2}'.format(x + 1) for x in range(pb.count(), self.npages)]) -# -# self.selectPage(self.page) + + self.widget.updateNewsFilter() + for i in range(0, 10): + if not self.widget.newsList.item(i).isHidden(): + self.widget.newsList.setCurrentItem( + self.widget.newsList.item(i), + ) + break + # self.newsContent = self.newsContent + items + + # self.npages = int(math.ceil(len(self.newsContent) / self.FRAMES)) + + # origpage = self.page + + # pb = client.instance.pageBox + # pb.insertItems( + # pb.count(), + # [ + # 'Page {: >2}'.format(x + 1) + # for x in range(pb.count(), self.npages) + # ], + # ) + + # self.selectPage(self.page) @QtCore.pyqtSlot() def frameClicked(self): @@ -80,7 +94,7 @@ def expandFrame(self, selectedFrame): for frame in self.newsFrames: frame.collapse() - selectedFrame.expand(Qt.ScrollBarAsNeeded, set_filter=False) + selectedFrame.expand(Qt.ScrollBarPolicy.ScrollBarAsNeeded, set_filter=False) self.selectedFrame = selectedFrame @@ -88,7 +102,7 @@ def resetFrames(self): logger.info('resetFrames') self.selectedFrame = None for frame in self.newsFrames: - frame.expand(Qt.ScrollBarAlwaysOff, set_filter=True) + frame.expand(Qt.ScrollBarPolicy.ScrollBarAlwaysOff, set_filter=True) def nextPage(self): pb = client.instance.pageBox @@ -111,7 +125,7 @@ def selectPage(self, idx): elif idx == self.npages - 1: client.instance.nextPageButton.setEnabled(False) # download next page - self.WpApi.download(page=self.npages+1, perpage=self.FRAMES) + self.WpApi.download(page=self.npages + 1, perpage=self.FRAMES) firstNewsIdx = idx * self.FRAMES diff --git a/src/news/wpapi.py b/src/news/wpapi.py index 82c703eaa..15a6ad780 100644 --- a/src/news/wpapi.py +++ b/src/news/wpapi.py @@ -1,14 +1,19 @@ -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply -from PyQt5 import QtCore - import json import logging -import sys + +from PyQt6 import QtCore +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + +from config import Settings logger = logging.getLogger(__name__) # FIXME: Make setting -WPAPI_ROOT = 'http://direct.faforever.com/wp-json/wp/v2/posts?per_page={perpage}&page={page}&_embed=1' +WPAPI_ROOT = ( + '{host}/wp-json/wp/v2/posts?per_page={perpage}&page={page}&_embed=1' +) class WPAPI(QtCore.QObject): @@ -41,15 +46,26 @@ def finishedDownload(self, reply): 'body': post.get('content', {}).get('rendered'), 'date': post.get('date'), 'excerpt': post.get('excerpt', {}).get('rendered'), - 'author': post.get('_embedded', {}).get('author') + 'author': post.get('_embedded', {}).get('author'), + 'link': post.get('link'), + 'external_link': post.get('newshub_externalLinkUrl'), + 'img_url': ( + post.get('_embedded', {}) + .get('wp:featuredmedia', [{}])[0] + .get('source_url', "") + ), } posts.append(content) self.newsDone.emit(posts) - except: + except BaseException: logger.exception('Error handling wp data') def download(self, page=1, perpage=10): - url = QtCore.QUrl(WPAPI_ROOT.format(page=page, perpage=perpage)) + url = QtCore.QUrl( + WPAPI_ROOT.format( + host=Settings.get('news/host'), page=page, perpage=perpage, + ), + ) request = QNetworkRequest(url) self.nam.get(request) diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py index 13c072d76..f5a4b74db 100644 --- a/src/notifications/__init__.py +++ b/src/notifications/__init__.py @@ -1,20 +1,23 @@ -from PyQt5 import QtCore - -import util -from fa import maps -from notifications.ns_dialog import NotificationDialog -from notifications.ns_settings import NsSettingsDialog, IngameNotification - """ The Notification Systems reacts on events and displays a popup. Each event_type has a NsHook to customize it. """ +from PyQt6 import QtCore + +import util +from config import Settings +from fa import maps +from notifications.ns_dialog import NotificationDialog +from notifications.ns_settings import IngameNotification +from notifications.ns_settings import NsSettingsDialog class Notifications: USER_ONLINE = 'user_online' NEW_GAME = 'new_game' GAME_FULL = 'game_full' + UNOFFICIAL_CLIENT = 'unofficial_client' + PARTY_INVITE = 'party_invite' def __init__(self, client, gameset, playerset, me): self.client = client @@ -25,24 +28,35 @@ def __init__(self, client, gameset, playerset, me): self.events = [] self.disabledStartup = True self.game_running = False + self.unofficialClientDate = Settings.get( + 'notifications/unofficialClientDate', 0, type=int, + ) - client.gameEnter.connect(self.gameEnter) - client.gameExit.connect(self.gameExit) - client.gameFull.connect(self._gamefull) + client.game_enter.connect(self.gameEnter) + client.game_exit.connect(self.gameExit) + client.game_full.connect(self._gamefull) + client.unofficial_client.connect(self.unofficialClient) + client.party_invite.connect(self.partyInvite) gameset.newLobby.connect(self._newLobby) - playerset.playerAdded.connect(self._newPlayer) + playerset.added.connect(self._newPlayer) self.user = util.THEME.icon("client/user.png", pix=True) def _newPlayer(self, player): - if self.isDisabled() or not self.settings.popupEnabled(self.USER_ONLINE): + if ( + self.isDisabled() + or not self.settings.popupEnabled(self.USER_ONLINE) + ): return if self.me.player is not None and self.me.player == player: return notify_mode = self.settings.getCustomSetting(self.USER_ONLINE, 'mode') - if notify_mode != 'all' and not self.me.isFriend(player.id): + if ( + notify_mode != 'all' + and not self.me.relations.model.is_friend(player.id) + ): return self.events.append((self.USER_ONLINE, player.copy())) @@ -55,7 +69,7 @@ def _newLobby(self, game): host = game.host_player notify_mode = self.settings.getCustomSetting(self.NEW_GAME, 'mode') if notify_mode != 'all': - if host is None or not self.me.isFriend(host): + if host is None or not self.me.relations.model.is_friend(host): return self.events.append((self.NEW_GAME, game.copy())) @@ -64,7 +78,30 @@ def _newLobby(self, game): def _gamefull(self): if self.isDisabled() or not self.settings.popupEnabled(self.GAME_FULL): return - self.events.append((self.GAME_FULL, None)) + if (self.GAME_FULL, None) not in self.events: + self.events.append((self.GAME_FULL, None)) + self.checkEvent() + + def unofficialClient(self, msg): + date = QtCore.QDate.currentDate().dayOfYear() + if date == self.unofficialClientDate: # Show once per day + return + + self.unofficialClientDate = date + Settings.set( + 'notifications/unofficialClientDate', self.unofficialClientDate, + ) + self.events.append((self.UNOFFICIAL_CLIENT, msg)) + self.checkEvent() + + def partyInvite(self, message): + notify_mode = self.settings.getCustomSetting(self.PARTY_INVITE, 'mode') + if ( + notify_mode != 'all' + and not self.me.relations.model.is_friend(message["sender"]) + ): + return + self.events.append((self.PARTY_INVITE, message)) self.checkEvent() def gameEnter(self): @@ -79,7 +116,13 @@ def gameExit(self): def isDisabled(self): return ( self.disabledStartup - or self.game_running and self.settings.ingame_notifications == IngameNotification.DISABLE + or ( + self.game_running + and ( + self.settings.ingame_notifications + == IngameNotification.DISABLE + ) + ) or not self.settings.enabled ) @@ -89,7 +132,9 @@ def setNotificationEnabled(self, enabled): @QtCore.pyqtSlot() def on_showSettings(self): - """ Shows a Settings Dialg with all registered notifications modules """ + """ + Shows a Settings Dialg with all registered notifications modules + """ self.settings.show() def showEvent(self): @@ -97,7 +142,8 @@ def showEvent(self): Display the next event in the queue as popup Pops event from queue and checks if it is showable as per settings - If event is showable, process event data and then feed it into notification dialog + If event is showable, process event data and then feed it into + notification dialog Returns True if showable event found, False otherwise """ @@ -111,8 +157,10 @@ def showEvent(self): if eventType == self.USER_ONLINE: player = data pixmap = self.user - text = '%s
is online' % \ - (player.login) + text = ( + '{}
is online' + ''.format(player.login) + ) elif eventType == self.NEW_GAME: game = data preview = maps.preview(game.mapname, pixmap=True) @@ -134,13 +182,55 @@ def showEvent(self): if len(modstr) > 20: modstr = modstr[:15] + "..." - modhtml = '' if (modstr == '') else '
mods %s' % modstr - text = '%s
on %s%s' % \ - (game.title, maps.getDisplayName(game.mapname), modhtml) + if modstr == '': + modhtml = '' + else: + modhtml = ( + '
mods ' + '{}'.format(modstr) + ) + text = ( + '{}
on ' + '{}{}'.format( + game.title, + maps.getDisplayName(game.mapname), + modhtml, + ) + ) elif eventType == self.GAME_FULL: pixmap = self.user - text = '
Game is full.' - self.dialog.newEvent(pixmap, text, self.settings.popup_lifetime, self.settings.soundEnabled(eventType)) + text = ( + '
Game is full.' + '' + ) + elif eventType == self.UNOFFICIAL_CLIENT: + pixmap = self.user + text = ( + '
{}' + .format(data) + ) + self.dialog.newEvent(pixmap, text, 10, False, 200) + return + elif eventType == self.PARTY_INVITE: + pixmap = self.user + + text = ( + '{}
invites you to' + ' their party' + .format(str(self.client.players[data["sender"]].login)) + ) + self.dialog.newEvent( + pixmap, text, 15, + self.settings.soundEnabled(eventType), + hide_accept_button=False, + sender_id=data["sender"], + ) + return + + self.dialog.newEvent( + pixmap, text, self.settings.popup_lifetime, + self.settings.soundEnabled(eventType), + ) def checkEvent(self): """ @@ -148,10 +238,20 @@ def checkEvent(self): This means: * There need to be events pending - * There must be no notification showing right now (i.e. notification dialog hidden) + * There must be no notification showing right now + (i.e. notification dialog hidden) * Game isn't running, or ingame notifications are enabled """ - if (len(self.events) > 0 and self.dialog.isHidden() and - (not self.game_running or self.settings.ingame_notifications == IngameNotification.ENABLE)): - self.showEvent() \ No newline at end of file + if ( + len(self.events) > 0 + and self.dialog.isHidden() + and ( + not self.game_running + or ( + self.settings.ingame_notifications + == IngameNotification.ENABLE + ) + ) + ): + self.showEvent() diff --git a/src/notifications/hook_gamefull.py b/src/notifications/hook_gamefull.py index d910f2872..845f7a194 100644 --- a/src/notifications/hook_gamefull.py +++ b/src/notifications/hook_gamefull.py @@ -1,15 +1,10 @@ -from PyQt5 import QtCore -import util -import config -from config import Settings -from notifications.ns_hook import NsHook -import notifications as ns - """ Settings for notifications: If a game is full """ +import notifications as ns +from notifications.ns_hook import NsHook class NsHookGameFull(NsHook): def __init__(self): - NsHook.__init__(self, ns.Notifications.GAME_FULL) \ No newline at end of file + NsHook.__init__(self, ns.Notifications.GAME_FULL) diff --git a/src/notifications/hook_newgame.py b/src/notifications/hook_newgame.py index 839b694cd..2abbba7b9 100644 --- a/src/notifications/hook_newgame.py +++ b/src/notifications/hook_newgame.py @@ -1,13 +1,13 @@ -from PyQt5 import QtCore -import util -import config -from config import Settings -from notifications.ns_hook import NsHook -import notifications as ns - """ Settings for notifications: if a new game is hosted. """ +from PyQt6 import QtCore + +import config +import notifications as ns +import util +from config import Settings +from notifications.ns_hook import NsHook class NsHookNewGame(NsHook): @@ -17,6 +17,7 @@ def __init__(self): self.dialog = NewGameDialog(self, self.eventType) self.button.clicked.connect(self.dialog.show) + FormClass, BaseClass = util.THEME.loadUiType("notification_system/new_game.ui") @@ -29,22 +30,30 @@ def __init__(self, parent, eventType): self.setupUi(self) # remove help button - self.setWindowFlags(self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint)) + self.setWindowFlags( + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), + ) self.loadSettings() def loadSettings(self): - self.mode = Settings.get(self._settings_key+'/mode', 'friends') + self.mode = Settings.get(self._settings_key + '/mode', 'friends') - self.checkBoxFriends.setCheckState(QtCore.Qt.Checked if self.mode == 'friends' else QtCore.Qt.Unchecked) + if self.mode == 'friends': + self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Checked) + else: + self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Unchecked) self.parent.mode = self.mode def saveSettings(self): - config.Settings.set(self._settings_key+'/mode', self.mode) + config.Settings.set(self._settings_key + '/mode', self.mode) self.parent.mode = self.mode @QtCore.pyqtSlot() def on_btnSave_clicked(self): - self.mode = 'friends' if self.checkBoxFriends.checkState() == QtCore.Qt.Checked else 'all' + if self.checkBoxFriends.checkState() == QtCore.Qt.CheckState.Checked: + self.mode = 'friends' + else: + self.mode = 'all' self.saveSettings() self.hide() diff --git a/src/notifications/hook_partyinvite.py b/src/notifications/hook_partyinvite.py new file mode 100644 index 000000000..a2c0c5fb8 --- /dev/null +++ b/src/notifications/hook_partyinvite.py @@ -0,0 +1,57 @@ +""" +Settings for notifications: if a player comes online +""" +from PyQt6 import QtCore + +import notifications as ns +import util +from config import Settings +from notifications.ns_hook import NsHook + + +class NsHookPartyInvite(NsHook): + def __init__(self): + NsHook.__init__(self, ns.Notifications.PARTY_INVITE) + self.button.setEnabled(True) + self.dialog = PartyInviteDialog(self, self.eventType) + self.button.clicked.connect(self.dialog.show) + + +FormClass, BaseClass = util.THEME.loadUiType( + "notification_system/party_invite.ui", +) + + +class PartyInviteDialog(FormClass, BaseClass): + def __init__(self, parent, eventType): + BaseClass.__init__(self) + self.parent = parent + self.eventType = eventType + self._settings_key = 'notifications/{}'.format(eventType) + self.setupUi(self) + + # remove help button + self.setWindowFlags( + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), + ) + + self.loadSettings() + + def loadSettings(self): + self.mode = Settings.get(self._settings_key + '/mode', 'friends') + + if self.mode == 'friends': + self.radioButtonFriends.setChecked(True) + else: + self.radioButtonAll.setChecked(True) + self.parent.mode = self.mode + + def saveSettings(self): + Settings.set(self._settings_key + '/mode', self.mode) + self.parent.mode = self.mode + + @QtCore.pyqtSlot() + def on_btnSave_clicked(self): + self.mode = 'friends' if self.radioButtonFriends.isChecked() else 'all' + self.saveSettings() + self.hide() diff --git a/src/notifications/hook_useronline.py b/src/notifications/hook_useronline.py index bd29571f3..d949bb4c2 100644 --- a/src/notifications/hook_useronline.py +++ b/src/notifications/hook_useronline.py @@ -1,13 +1,12 @@ -from PyQt5 import QtCore -import util -import config -from config import Settings -from notifications.ns_hook import NsHook -import notifications as ns - """ Settings for notifications: if a player comes online """ +from PyQt6 import QtCore + +import notifications as ns +import util +from config import Settings +from notifications.ns_hook import NsHook class NsHookUserOnline(NsHook): @@ -17,7 +16,10 @@ def __init__(self): self.dialog = UserOnlineDialog(self, self.eventType) self.button.clicked.connect(self.dialog.show) -FormClass, BaseClass = util.THEME.loadUiType("notification_system/user_online.ui") + +FormClass, BaseClass = util.THEME.loadUiType( + "notification_system/user_online.ui", +) class UserOnlineDialog(FormClass, BaseClass): @@ -29,12 +31,14 @@ def __init__(self, parent, eventType): self.setupUi(self) # remove help button - self.setWindowFlags(self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint)) + self.setWindowFlags( + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), + ) self.loadSettings() def loadSettings(self): - self.mode = Settings.get(self._settings_key+'/mode', 'friends') + self.mode = Settings.get(self._settings_key + '/mode', 'friends') if self.mode == 'friends': self.radioButtonFriends.setChecked(True) @@ -43,7 +47,7 @@ def loadSettings(self): self.parent.mode = self.mode def saveSettings(self): - Settings.set(self._settings_key+'/mode', self.mode) + Settings.set(self._settings_key + '/mode', self.mode) self.parent.mode = self.mode @QtCore.pyqtSlot() diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py index c681bc9d0..2426b2e14 100644 --- a/src/notifications/ns_dialog.py +++ b/src/notifications/ns_dialog.py @@ -1,11 +1,15 @@ -from PyQt5 import QtCore, QtWidgets -import util -import time -from .ns_settings import NotificationPosition - """ The UI popup of the notification system """ +import time + +from PyQt6 import QtCore +from PyQt6.QtMultimedia import QSoundEffect + +import util + +from .ns_settings import NotificationPosition + FormClass, BaseClass = util.THEME.loadUiType("notification_system/dialog.ui") @@ -17,20 +21,43 @@ def __init__(self, client, settings, *args, **kwargs): self.setupUi(self) self.client = client - self.labelIcon.setPixmap(util.THEME.icon("client/tray_icon.png", pix=True).scaled(32, 32)) + self.labelIcon.setPixmap( + util.THEME.icon("client/tray_icon.png", pix=True).scaled(32, 32), + ) self.standardIcon = util.THEME.icon("client/comment.png", pix=True) self.settings = settings self.updatePosition() # Frameless, always on top, steal no focus & no entry at the taskbar - self.setWindowFlags(QtCore.Qt.ToolTip) + self.setWindowFlags(QtCore.Qt.WindowType.ToolTip) + self.labelEvent.setOpenExternalLinks(True) + + self.baseHeight = 165 + self.baseWidth = 375 + + self.sender_id = None + self.acceptButton.clicked.connect( + lambda: self.acceptPartyInvite(sender_id=self.sender_id), + ) + self.sound_effect = QSoundEffect() + self.sound_effect.setSource(util.THEME.sound("chat/sfx/query.wav")) # TODO: integrate into client.css # self.setStyleSheet(self.client.styleSheet()) @QtCore.pyqtSlot() - def newEvent(self, pixmap, text, lifetime, sound): + def newEvent( + self, + pixmap, + text, + lifetime, + sound, + height=None, + width=None, + hide_accept_button=True, + sender_id=None, + ): """ Called to display a new popup Keyword arguments: pixmap -- Icon for the event (displayed left) @@ -43,10 +70,18 @@ def newEvent(self, pixmap, text, lifetime, sound): pixmap = self.standardIcon self.labelImage.setPixmap(pixmap) - self.labelTime.setText(time.strftime("%H:%M:%S", time.gmtime())) + self.labelTime.setText(time.strftime("%H:%M:%S", time.localtime())) QtCore.QTimer.singleShot(lifetime * 1000, self.hide) if sound: - util.THEME.sound("chat/sfx/query.wav") + self.sound_effect.play() + self.setFixedHeight(height or self.baseHeight) + self.setFixedWidth(width or self.baseWidth) + + if hide_accept_button: + self.acceptButton.hide() + else: + self.sender_id = sender_id + self.acceptButton.show() self.updatePosition() self.show() @@ -59,19 +94,28 @@ def hide(self): # mouseReleaseEvent sometimes not fired def mousePressEvent(self, event): - if event.button() == QtCore.Qt.RightButton: + if event.button() == QtCore.Qt.MouseButton.RightButton: self.hide() - def updatePosition(self): - screen = QtWidgets.QDesktopWidget().screenGeometry() + def updatePosition(self) -> None: + screen_size = self.screen().availableGeometry() dialog_size = self.geometry() - position = self.settings.popup_position # self.client.notificationSystem.settings.popup_position + # self.client.notificationSystem.settings.popup_position + position = self.settings.popup_position if position == NotificationPosition.TOP_LEFT: self.move(0, 0) elif position == NotificationPosition.TOP_RIGHT: - self.move(screen.width() - dialog_size.width(), 0) + self.move(screen_size.width() - dialog_size.width(), 0) elif position == NotificationPosition.BOTTOM_LEFT: - self.move(0, screen.height() - dialog_size.height()) + self.move(0, screen_size.height() - dialog_size.height()) else: - self.move(screen.width() - dialog_size.width(), screen.height() - dialog_size.height()) + self.move( + screen_size.width() - dialog_size.width(), + screen_size.height() - dialog_size.height(), + ) + + @QtCore.pyqtSlot() + def acceptPartyInvite(self, sender_id): + self.client.games.accept_party_invite(sender_id) + self.hide() diff --git a/src/notifications/ns_hook.py b/src/notifications/ns_hook.py index 3c68b5de3..00cb15a57 100644 --- a/src/notifications/ns_hook.py +++ b/src/notifications/ns_hook.py @@ -1,7 +1,3 @@ -from PyQt5 import QtWidgets -import util -from config import Settings - """ Setting Model class. All Event Types (Notifications) are customizable. @@ -11,6 +7,9 @@ self.button.clicked.connect(self.dialog.show) """ +from PyQt6 import QtWidgets + +from config import Settings class NsHook(): @@ -22,12 +21,16 @@ def __init__(self, eventType): self.button.setEnabled(False) def loadSettings(self): - self.popup = Settings.get(self._settings_key + '/popup', True, type=bool) - self.sound = Settings.get(self._settings_key + '/sound', True, type=bool) + self.popup = Settings.get( + self._settings_key + '/popup', True, type=bool, + ) + self.sound = Settings.get( + self._settings_key + '/sound', True, type=bool, + ) def saveSettings(self): - Settings.set(self._settings_key+'/popup', self.popup) - Settings.set(self._settings_key+'/sound', self.sound) + Settings.set(self._settings_key + '/popup', self.popup) + Settings.set(self._settings_key + '/sound', self.sound) def getEventDisplayName(self): return self.eventType diff --git a/src/notifications/ns_settings.py b/src/notifications/ns_settings.py index c794c6798..8b7b976ec 100644 --- a/src/notifications/ns_settings.py +++ b/src/notifications/ns_settings.py @@ -1,16 +1,19 @@ -from PyQt5 import QtCore, QtWidgets -from enum import Enum -from config import Settings -import util -import notifications as ns -from notifications.hook_useronline import NsHookUserOnline -from notifications.hook_newgame import NsHookNewGame -from notifications.hook_gamefull import NsHookGameFull - """ The UI of the Notification System Settings Frame. Each module/hook for the notification system must be registered here. """ +from enum import Enum + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import notifications as ns +import util +from config import Settings +from notifications.hook_gamefull import NsHookGameFull +from notifications.hook_newgame import NsHookNewGame +from notifications.hook_partyinvite import NsHookPartyInvite +from notifications.hook_useronline import NsHookUserOnline class IngameNotification(Enum): @@ -37,43 +40,63 @@ def getLabel(self): # TODO: how to register hooks? -FormClass2, BaseClass2 = util.THEME.loadUiType("notification_system/ns_settings.ui") +FormClass2, BaseClass2 = util.THEME.loadUiType( + "notification_system/ns_settings.ui", +) class NsSettingsDialog(FormClass2, BaseClass2): def __init__(self, client): BaseClass2.__init__(self) - #BaseClass2.__init__(self, client) + # BaseClass2.__init__(self, client) self.setupUi(self) self.client = client # remove help button - self.setWindowFlags(self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint)) + self.setWindowFlags( + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), + ) # init hooks self.hooks = {} self.hooks[ns.Notifications.USER_ONLINE] = NsHookUserOnline() self.hooks[ns.Notifications.NEW_GAME] = NsHookNewGame() self.hooks[ns.Notifications.GAME_FULL] = NsHookGameFull() + self.hooks[ns.Notifications.PARTY_INVITE] = NsHookPartyInvite() model = NotificationHooks(self, list(self.hooks.values())) self.tableView.setModel(model) # stretch first column - self.tableView.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + self.tableView.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch, + ) for row in range(0, model.rowCount(None)): - self.tableView.setIndexWidget(model.createIndex(row, 3), model.getHook(row).settings()) + self.tableView.setIndexWidget( + model.createIndex(row, 3), + model.getHook(row).settings(), + ) self.loadSettings() def loadSettings(self): self.enabled = Settings.get('notifications/enabled', True, type=bool) - self.popup_lifetime = Settings.get('notifications/popup_lifetime', 5, type=int) - self.popup_position = NotificationPosition(Settings.get('notifications/popup_position', - NotificationPosition.BOTTOM_RIGHT.value, type=int)) - self.ingame_notifications = IngameNotification(Settings.get('notifications/ingame', - IngameNotification.ENABLE, type=int)) + self.popup_lifetime = Settings.get( + 'notifications/popup_lifetime', 5, type=int, + ) + self.popup_position = NotificationPosition( + Settings.get( + 'notifications/popup_position', + NotificationPosition.BOTTOM_RIGHT.value, + type=int, + ), + ) + self.ingame_notifications = IngameNotification( + Settings.get( + 'notifications/ingame', IngameNotification.ENABLE, type=int, + ), + ) self.nsEnabled.setChecked(self.enabled) self.nsPopLifetime.setValue(self.popup_lifetime) @@ -92,8 +115,12 @@ def saveSettings(self): def on_btnSave_clicked(self): self.enabled = self.nsEnabled.isChecked() self.popup_lifetime = self.nsPopLifetime.value() - self.popup_position = NotificationPosition(self.nsPositionComboBox.currentIndex()) - self.ingame_notifications = IngameNotification(self.nsIngameComboBox.currentIndex()) + self.popup_position = NotificationPosition( + self.nsPositionComboBox.currentIndex(), + ) + self.ingame_notifications = IngameNotification( + self.nsIngameComboBox.currentIndex(), + ) self.saveSettings() self.hide() @@ -119,13 +146,13 @@ def getCustomSetting(self, eventType, key): return getattr(self.hooks[eventType], key) return None -""" -Model Class for notification type table. -Needs an NsHook. -""" - class NotificationHooks(QtCore.QAbstractTableModel): + """ + Model Class for notification type table. + Needs an NsHook. + """ + POPUP = 1 SOUND = 2 SETTINGS = 3 @@ -139,9 +166,9 @@ def __init__(self, parent, hooks, *args): def flags(self, index): flags = super(QtCore.QAbstractTableModel, self).flags(index) if index.column() == self.POPUP or index.column() == self.SOUND: - return flags | QtCore.Qt.ItemIsUserCheckable + return flags | QtCore.Qt.ItemFlag.ItemIsUserCheckable if index.column() == self.SETTINGS: - return flags | QtCore.Qt.ItemIsEditable + return flags | QtCore.Qt.ItemFlag.ItemIsEditable return flags def rowCount(self, parent): @@ -153,21 +180,25 @@ def columnCount(self, parent): def getHook(self, row): return self.hooks[row] - def data(self, index, role = QtCore.Qt.EditRole): + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole): if not index.isValid(): return None - # if role == QtCore.Qt.TextAlignmentRole and index.column() != 0: - # return QtCore.Qt.AlignHCenter + # if role == QtCore.Qt.ItemDataRole.TextAlignmentRole and index.column() != 0: + # return QtCore.Qt.AlignmentFlag.AlignHCenter - if role == QtCore.Qt.CheckStateRole: + if role == QtCore.Qt.ItemDataRole.CheckStateRole: if index.column() == self.POPUP: - return self.returnChecked(self.hooks[index.row()].popupEnabled()) + return self.returnChecked( + self.hooks[index.row()].popupEnabled(), + ) if index.column() == self.SOUND: - return self.returnChecked(self.hooks[index.row()].soundEnabled()) + return self.returnChecked( + self.hooks[index.row()].soundEnabled(), + ) return None - if role != QtCore.Qt.DisplayRole: + if role != QtCore.Qt.ItemDataRole.DisplayRole: return None if index.column() == 0: @@ -175,9 +206,9 @@ def data(self, index, role = QtCore.Qt.EditRole): return '' def returnChecked(self, state): - return QtCore.Qt.Checked if state else QtCore.Qt.Unchecked + return QtCore.Qt.CheckState.Checked if state else QtCore.Qt.CheckState.Unchecked - def setData(self, index, value, role = QtCore.Qt.EditRole): + def setData(self, index, value, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole): if index.column() == self.POPUP: self.hooks[index.row()].switchPopup() self.dataChanged.emit(index, index) @@ -189,6 +220,9 @@ def setData(self, index, value, role = QtCore.Qt.EditRole): return False def headerData(self, col, orientation, role): - if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: + if ( + orientation == QtCore.Qt.Orientation.Horizontal + and role == QtCore.Qt.ItemDataRole.DisplayRole + ): return self.headerdata[col] return None diff --git a/src/oauth/__init__.py b/src/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/oauth/oauth_flow.py b/src/oauth/oauth_flow.py new file mode 100644 index 000000000..9710e990f --- /dev/null +++ b/src/oauth/oauth_flow.py @@ -0,0 +1,99 @@ +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetworkAuth import QOAuth2AuthorizationCodeFlow +from PyQt6.QtNetworkAuth import QOAuthHttpServerReplyHandler + +from config import Settings +from decorators import with_logger + + +class OAuthReplyHandler(QOAuthHttpServerReplyHandler): + def callback(self) -> str: + with_trailing_slash = super().callback() + # remove trailing slash because server does not accept it + return with_trailing_slash.removesuffix("/") + + +@with_logger +class OAuth2Flow(QOAuth2AuthorizationCodeFlow): + def __init__( + self, + manager: QNetworkAccessManager | None = None, + parent: QObject | None = None, + ) -> None: + super().__init__(manager, parent) + + if manager is None: + self.setNetworkAccessManager(QNetworkAccessManager()) + + self.setup_credentials() + reply_handler = OAuthReplyHandler(self) + self.setReplyHandler(reply_handler) + + self.authorizeWithBrowser.connect(QDesktopServices.openUrl) + self.requestFailed.connect(self.on_request_failed) + self.granted.connect(self.on_granted) + self.tokenChanged.connect(self.on_token_changed) + self.expirationAtChanged.connect(self.on_expiration_at_changed) + + self._check_timer = QTimer(self) + self._check_timer.timeout.connect(self.check_token) + self._check_interval = 5000 + self._expires_in = None + + def stop_checking_expiration(self) -> None: + self._check_timer.stop() + self._expires_in = None + + def start_checking_expiration(self) -> None: + self._check_timer.start(self._check_interval) + + def check_token(self) -> None: + if self._expires_in is None: + return + + self._expires_in -= self._check_interval + if self._expires_in <= 60_000: + self.refreshAccessToken() + + def on_expiration_at_changed(self, expiration_at: QDateTime) -> None: + self._logger.debug(f"Token expiration at changed to: {expiration_at}") + self._expires_in = QDateTime.currentDateTime().msecsTo(expiration_at) + + def on_token_changed(self, new_token: str) -> None: + self._logger.debug("Token changed") + + def on_granted(self) -> None: + self._logger.debug("Token granted successfuly!") + self.start_checking_expiration() + + def on_request_failed(self, error: QOAuth2AuthorizationCodeFlow.Error) -> None: + self._logger.debug(f"Request failed with an error: {error}") + self.stop_checking_expiration() + + def setup_credentials(self) -> None: + """ + Set client's credentials, scopes and OAuth endpoints + """ + client_id = Settings.get("oauth/client_id") + scopes = Settings.get("oauth/scope") + + oauth_host = QUrl(Settings.get("oauth/host")) + auth_endpoint = QUrl(Settings.get("oauth/auth_endpoint")) + token_endpoint = QUrl(Settings.get("oauth/token_endpoint")) + + authorization_url = oauth_host.resolved(auth_endpoint) + token_url = oauth_host.resolved(token_endpoint) + + self.setUserAgent("FAF Client") + self.setAuthorizationUrl(authorization_url) + self.setClientIdentifier(client_id) + self.setAccessTokenUrl(token_url) + self.setScope(" ".join(scopes)) + + +OAuth2FlowInstance = OAuth2Flow() diff --git a/src/power/__init__.py b/src/power/__init__.py new file mode 100644 index 000000000..58ad93557 --- /dev/null +++ b/src/power/__init__.py @@ -0,0 +1,15 @@ +from power.actions import PowerActions +from power.view import PowerView + + +class PowerTools: + def __init__(self, actions, view): + self.power = 0 + self.actions = actions + self.view = view + + @classmethod + def build(cls, **kwargs): + actions = PowerActions.build(**kwargs) + view = PowerView.build(mod_actions=actions, **kwargs) + return cls(actions, view) diff --git a/src/power/actions.py b/src/power/actions.py new file mode 100644 index 000000000..3f4004cb5 --- /dev/null +++ b/src/power/actions.py @@ -0,0 +1,73 @@ +import logging +from enum import Enum + +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices + +logger = logging.getLogger(__name__) + + +class BanPeriod(Enum): + HOUR = 'HOUR' + DAY = 'DAY' + WEEK = 'WEEK' + MONTH = 'MONTH' + YEAR = 'YEAR' + + +class PowerActions: + def __init__(self, lobby_connection, playerset, settings): + self._lobby_connection = lobby_connection + self._playerset = playerset + self._settings = settings + + @classmethod + def build(cls, lobby_connection, playerset, settings, **kwargs): + return cls(lobby_connection, playerset, settings) + + def close_fa(self, username): + player = self._playerset.get(username, None) + if player is None: + return False + logger.info('Closing FA for {}'.format(player.login)) + self._lobby_connection.send({ + "command": "admin", + "action": "closeFA", + "user_id": player.id, + }) + return True + + def kick_player(self, username): + player = self._playerset.get(username, None) + if player is None: + return False + logger.info('Closing lobby for {}'.format(player.login)) + self._lobby_connection.send({ + "command": "admin", + "action": "closelobby", + "user_id": player.id, + }) + return True + + def ban_player(self, username, reason, duration, period): + player = self._playerset.get(username, None) + if player is None: + return False + message = { + "command": "admin", + "action": "closelobby", + "ban": { + "reason": reason, + "duration": duration, + "period": period, + }, + "user_id": player.id, + } + self._lobby_connection.send(message) + return True + + def send_the_orcs(self, username): + player = self._playerset.get(username, None) + target = username if player is None else player.id + route = self._settings.get('mordor/host') + QDesktopServices.openUrl(QUrl("{}/users/{}".format(route, target))) diff --git a/src/power/view.py b/src/power/view.py new file mode 100644 index 000000000..442e7f895 --- /dev/null +++ b/src/power/view.py @@ -0,0 +1,102 @@ +from PyQt6.QtCore import QObject +from PyQt6.QtWidgets import QMessageBox + +from power.actions import BanPeriod +from util.select_player_dialog import PlayerCompleter +from util.select_player_dialog import SelectPlayerDialog + + +class CloseGameDialog(SelectPlayerDialog): + def __init__(self, mod_actions, playerset, parent_widget): + SelectPlayerDialog.__init__(self, playerset, parent_widget) + self._mod_actions = mod_actions + + @classmethod + def build(cls, mod_actions, playerset, parent_widget, **kwargs): + return cls(mod_actions, playerset, parent_widget) + + def show(self, username=""): + self.show_dialog("Closing player's game", "Player name:", username) + + def _at_value(self, name): + if not self._mod_actions.close_fa(name): + msg = QMessageBox(self._parent_widget) + msg.setWindowTitle("Player not found!") + msg.setText("The specified player was not found.") + msg.show() + + +class KickDialog(QObject): + def __init__(self, username, mod_actions, playerset, theme, parent_widget): + QObject.__init__(self, parent_widget) + self._mod_actions = mod_actions + self._playerset = playerset + self.set_theme(theme, parent_widget) + self.form.leUsername.setText(username) + self.base.show() + + @classmethod + def builder(cls, mod_actions, playerset, theme, parent_widget, **kwargs): + def make(username=""): + return cls(username, mod_actions, playerset, theme, parent_widget) + return make + + def set_theme(self, theme, parent_widget): + formc, basec = theme.loadUiType("client/kick.ui") + self.form = formc() + self.base = basec(parent_widget) + self.form.setupUi(self.base) + + self.form.cbBan.stateChanged.connect(self.banChanged) + self.base.accepted.connect(self.accepted) + self.base.rejected.connect(self.rejected) + + completer = PlayerCompleter(self._playerset, self.base) + self.form.leUsername.setCompleter(completer) + + def banChanged(self, newState): + checked = self.form.cbBan.isChecked() + self.form.cbReason.setEnabled(checked) + self.form.sbDuration.setEnabled(checked) + self.form.cbPeriod.setEnabled(checked) + + def _warning(self, title, text): + msg = QMessageBox( + QMessageBox.Warning, title, text, + parent=self._parent_widget, + ) + msg.show() + + def accepted(self): + username = self.form.leUsername.text() + if not self.form.cbBan.isChecked(): + result = self._mod_actions.kick_player(username) + else: + reason = self.form.cbReason.currentText() + duration = self.form.sbDuration.value() + period = [e for e in BanPeriod][self.form.cbPeriod.currentIndex()] + result = self._mod_actions.ban_player( + username, reason, duration, period, + ) + + if not result: + self._warning( + "Player not found", + 'Player "{}" was not found.'.format(username), + ) + self.setParent(None) # Let ourselves get GC'd + + def rejected(self): + self.setParent(None) # Let ourselves get GC'd + + +class PowerView: + def __init__(self, close_game_dialog, kick_dialog): + self.close_game_dialog = close_game_dialog + self.kick_dialog = kick_dialog + + @classmethod + def build(cls, **kwargs): + close_game_dialog = CloseGameDialog.build(**kwargs) + kick_dialog = KickDialog.builder(**kwargs) + return cls(close_game_dialog, kick_dialog) diff --git a/src/qt/itemviews/tableheaderview.py b/src/qt/itemviews/tableheaderview.py new file mode 100644 index 000000000..07b995e08 --- /dev/null +++ b/src/qt/itemviews/tableheaderview.py @@ -0,0 +1,71 @@ +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import QRect +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QHoverEvent +from PyQt6.QtGui import QMouseEvent +from PyQt6.QtGui import QPainter +from PyQt6.QtGui import QWheelEvent +from PyQt6.QtWidgets import QHeaderView +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QStyleOptionHeader + + +class VerticalHeaderView(QHeaderView): + def __init__(self, *args, **kwargs) -> None: + super().__init__(Qt.Orientation.Vertical, *args, **kwargs) + self.setHighlightSections(True) + self.setSectionResizeMode(self.ResizeMode.Fixed) + self.setVisible(True) + self.setSectionsClickable(True) + self.setAlternatingRowColors(True) + self.setObjectName("VerticalHeader") + + self.hover = -1 + + def paintSection(self, painter: QPainter, rect: QRect, index: QModelIndex) -> None: + opt = QStyleOptionHeader() + self.initStyleOption(opt) + opt.rect = rect + opt.section = index + + data = self.model().headerData(index, self.orientation(), Qt.ItemDataRole.DisplayRole) + opt.text = str(data) + + opt.textAlignment = Qt.AlignmentFlag.AlignCenter + + state = QStyle.StateFlag.State_None + + if self.highlightSections(): + if self.selectionModel().rowIntersectsSelection(index, QModelIndex()): + state |= QStyle.StateFlag.State_On + elif index == self.hover: + state |= QStyle.StateFlag.State_MouseOver + + opt.state |= state + + self.style().drawControl(QStyle.ControlElement.CE_Header, opt, painter, self) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + QHeaderView.mouseMoveEvent(self, event) + self.parent().update_hover_row(event) + self.update_hover_section(event) + + def wheelEvent(self, event: QWheelEvent) -> None: + QHeaderView.wheelEvent(self, event) + self.parent().update_hover_row(event) + self.update_hover_section(event) + + def mousePressEvent(self, event: QMouseEvent) -> None: + QHeaderView.mousePressEvent(self, event) + self.parent().update_hover_row(event) + self.update_hover_section(event) + + def update_hover_section(self, event: QHoverEvent) -> None: + index = self.logicalIndexAt(event.position().toPoint()) + old_hover, self.hover = self.hover, index + + if self.hover != old_hover: + if old_hover != -1: + self.updateSection(old_hover) + if self.hover != -1: + self.updateSection(self.hover) diff --git a/src/qt/itemviews/tableitemdelegte.py b/src/qt/itemviews/tableitemdelegte.py new file mode 100644 index 000000000..b5c7ded05 --- /dev/null +++ b/src/qt/itemviews/tableitemdelegte.py @@ -0,0 +1,61 @@ +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QStyledItemDelegate +from PyQt6.QtWidgets import QStyleOptionViewItem +from PyQt6.QtWidgets import QTableView + +from util.qt import qpainter + + +class TableItemDelegate(QStyledItemDelegate): + """ + Highlights the entire row on mouse hover when table's + SelectionBehavior is set to SelectRows + Requires TableView to have method hover_index() defined + """ + + def _customize_style_option( + self, + option: QStyleOptionViewItem, + index: QModelIndex, + ) -> QStyleOptionViewItem: + opt = QStyleOptionViewItem(option) + opt.state &= ~QStyle.StateFlag.State_HasFocus + opt.state &= ~QStyle.StateFlag.State_MouseOver + + view = opt.styleObject + behavior = view.selectionBehavior() + hover_index = view.hover_index() + + if ( + not (option.state & QStyle.StateFlag.State_Selected) + and behavior != QTableView.SelectionBehavior.SelectItems + ): + if ( + behavior == QTableView.SelectionBehavior.SelectRows + and hover_index.row() == index.row() + ): + opt.state |= QStyle.StateFlag.State_MouseOver + + self.initStyleOption(opt, index) + return opt + + def _draw_clear_option(self, painter: QPainter, option: QStyleOptionViewItem) -> None: + option.text = "" + control_element = QStyle.ControlElement.CE_ItemViewItem + option.widget.style().drawControl(control_element, option, painter, option.widget) + + def _set_pen(self, painter: QPainter, option: QStyleOptionViewItem) -> None: + if option.state & QStyle.StateFlag.State_Selected: + painter.setPen(Qt.GlobalColor.white) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: + opt = self._customize_style_option(option, index) + text = opt.text + + with qpainter(painter): + self._draw_clear_option(painter, opt) + self._set_pen(painter, opt) + painter.drawText(opt.rect, Qt.AlignmentFlag.AlignCenter, text) diff --git a/src/qt/itemviews/tableview.py b/src/qt/itemviews/tableview.py new file mode 100644 index 000000000..0816e1a24 --- /dev/null +++ b/src/qt/itemviews/tableview.py @@ -0,0 +1,56 @@ +from PyQt6.QtCore import QModelIndex +from PyQt6.QtGui import QHoverEvent +from PyQt6.QtGui import QMouseEvent +from PyQt6.QtGui import QWheelEvent +from PyQt6.QtWidgets import QTableView + +from qt.itemviews.tableheaderview import VerticalHeaderView + + +class TableView(QTableView): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setMouseTracking(True) + self.setSelectionBehavior(self.SelectionBehavior.SelectRows) + self.setSelectionMode(self.SelectionMode.SingleSelection) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + + self.setVerticalHeader(VerticalHeaderView()) + self.m_hover_row = -1 + self.m_hover_column = -1 + + def hover_index(self) -> QModelIndex: + return QModelIndex(self.model().index(self.m_hover_row, self.m_hover_column)) + + def update_hover_row(self, event: QHoverEvent) -> None: + index = self.indexAt(event.position().toPoint()) + old_hover_row = self.m_hover_row + self.m_hover_row = index.row() + self.m_hover_column = index.column() + + if ( + self.selectionBehavior() is self.SelectionBehavior.SelectRows + and old_hover_row != self.m_hover_row + ): + if old_hover_row != -1: + for i in range(self.model().columnCount()): + self.update(self.model().index(old_hover_row, i)) + if self.m_hover_row != -1: + for i in range(self.model().columnCount()): + self.update(self.model().index(self.m_hover_row, i)) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + QTableView.mouseMoveEvent(self, event) + self.update_hover_row(event) + self.verticalHeader().update_hover_section(event) + + def wheelEvent(self, event: QWheelEvent) -> None: + QTableView.wheelEvent(self, event) + self.update_hover_row(event) + self.verticalHeader().update_hover_section(event) + + def mousePressEvent(self, event: QMouseEvent) -> None: + QTableView.mousePressEvent(self, event) + self.update_hover_row(event) + self.verticalHeader().update_hover_section(event) diff --git a/src/replays/__init__.py b/src/replays/__init__.py index b475521df..a9a26303b 100644 --- a/src/replays/__init__.py +++ b/src/replays/__init__.py @@ -1,5 +1,10 @@ import logging -logger = logging.getLogger(__name__) from ._replayswidget import ReplaysWidget + +__all__ = ( + "ReplaysWidget", +) + +logger = logging.getLogger(__name__) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index ccc3739bf..8f48b4d65 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -1,21 +1,31 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply -from fa.replay import replay -from config import Settings -import util +import json +import logging import os -import fa import time -import client -import json -import jsonschema -from replays.replayitem import ReplayItem, ReplayItemDelegate +from pydantic import ValidationError +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + +import client +import fa +import util +from api.replaysapi import ReplaysApiConnector +from config import Settings +from downloadManager import DownloadRequest +from fa.replay import replay from model.game import GameState -from replays.connection import ReplaysConnection -from downloadManager import PreviewDownloadRequest +from replays.models import MetadataModel +from replays.replayitem import ReplayItem +from replays.replayitem import ReplayItemDelegate +from replays.replayToolbox import ReplayToolboxHandler +from util.gameurl import GameUrl +from util.gameurl import GameUrlType -import logging logger = logging.getLogger(__name__) # Replays uses the new Inheritance Based UI creation pattern @@ -34,10 +44,10 @@ def __init__(self, game): self.launch_time = game.launched_at else: self.launch_time = time.time() - self._map_dl_request = PreviewDownloadRequest() + self._map_dl_request = DownloadRequest() self._map_dl_request.done.connect(self._map_preview_downloaded) - self._game.gameUpdated.connect(self._update_game) + self._game.updated.connect(self._update_game) self._set_show_delay() self._update_game(self._game) @@ -47,7 +57,7 @@ def _set_show_delay(self): # Wait until the replayserver makes the replay available elapsed_time = time.time() - self.launch_time delay_time = self.LIVEREPLAY_DELAY - elapsed_time - QtCore.QTimer.singleShot(1000 * delay_time, self._show_item) + QtCore.QTimer.singleShot(int(1000 * delay_time), self._show_item) def _show_item(self): self.setHidden(False) @@ -74,7 +84,7 @@ def _set_debug_tooltip(self, game): info = game.to_dict() tip = "" for key in list(info.keys()): - tip += "'" + str(key) + "' : '" + str(info[key]) + "'
" + tip += "'{}' : '{}'
".format(key, info[key]) self.setToolTip(1, tip) def _set_game_map_icon(self, game): @@ -83,7 +93,7 @@ def _set_game_map_icon(self, game): else: icon = fa.maps.preview(game.mapname) if not icon: - dler = client.instance.map_downloader + dler = client.instance.map_preview_downloader dler.download_preview(game.mapname, self._map_dl_request) icon = util.THEME.icon("games/unknown_map.png") self.setIcon(0, icon) @@ -96,21 +106,21 @@ def _set_misc_formatting(self, game): self.setText(0, launch_time) colors = client.instance.player_colors - self.setForeground(0, QtGui.QColor(colors.getColor("default"))) + self.setForeground(0, QtGui.QColor(colors.get_color("default"))) if game.featured_mod == "ladder1v1": self.setText(1, game.title) else: self.setText(1, game.title + " - [host: " + game.host + "]") - self.setForeground(1, QtGui.QColor(colors.getColor("player"))) + self.setForeground(1, QtGui.QColor(colors.get_color("player"))) self.setText(2, game.featured_mod) - self.setTextAlignment(2, QtCore.Qt.AlignCenter) + self.setTextAlignment(2, QtCore.Qt.AlignmentFlag.AlignCenter) def _is_me(self, name): return client.instance.login == name def _is_friend(self, name): playerid = client.instance.players.getID(name) - return client.instance.me.isFriend(playerid) + return client.instance.me.relations.model.is_friend(playerid) def _is_online(self, name): return name in client.instance.players @@ -125,7 +135,7 @@ def _set_color(self, game): else: my_color = "player" colors = client.instance.player_colors - self.setForeground(1, QtGui.QColor(colors.getColor(my_color))) + self.setForeground(1, QtGui.QColor(colors.get_color(my_color))) def _generate_player_subitems(self, game): if not game.teams: @@ -148,26 +158,21 @@ def _create_playeritem(self, game, name): else: player_color = "default" colors = client.instance.player_colors - item.setForeground(0, QtGui.QColor(colors.getColor(player_color))) + item.setForeground(0, QtGui.QColor(colors.get_color(player_color))) if self._is_online(name): - item.url = self._generate_livereplay_link(game, name) - item.setToolTip(0, item.url.toString()) + item.gurl = self._generate_livereplay_link(game, name) + item.setToolTip(0, item.gurl.to_url().toString()) item.setIcon(0, util.THEME.icon("replays/replay.png")) else: item.setDisabled(True) return item def _generate_livereplay_link(self, game, name): - url = QtCore.QUrl() - url.setScheme("faflive") - url.setHost("lobby.faforever.com") - url.setPath("/" + str(game.uid) + "/" + name + ".SCFAreplay") - query = QtCore.QUrlQuery() - query.addQueryItem("map", game.mapname) - query.addQueryItem("mod", game.featured_mod) - url.setQuery(query) - return url + return GameUrl( + GameUrlType.LIVE_REPLAY, game.mapname, + game.featured_mod, game.uid, name, + ) def __lt__(self, other): return self.launch_time < other.launch_time @@ -187,9 +192,15 @@ def __init__(self, liveTree, client, gameset): self.liveTree = liveTree self.liveTree.itemDoubleClicked.connect(self.liveTreeDoubleClicked) self.liveTree.itemPressed.connect(self.liveTreePressed) - self.liveTree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) - self.liveTree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) - self.liveTree.header().setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) + self.liveTree.header().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) + self.liveTree.header().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.Stretch, + ) + self.liveTree.header().setSectionResizeMode( + 2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) self.client = client self.gameset = gameset @@ -199,7 +210,7 @@ def __init__(self, liveTree, client, gameset): self.games = {} def liveTreePressed(self, item): - if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton: + if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton: return if self.liveTree.indexOfTopLevelItem(item) != -1: @@ -217,8 +228,14 @@ def liveTreePressed(self, item): menu.addAction(actionLink) # Triggers - actionReplay.triggered.connect(lambda: self.liveTreeDoubleClicked(item)) - actionLink.triggered.connect(lambda: QtWidgets.QApplication.clipboard().setText(item.toolTip(0))) + actionReplay.triggered.connect( + lambda: self.liveTreeDoubleClicked(item), + ) + actionLink.triggered.connect( + lambda: QtWidgets.QApplication.clipboard().setText( + item.toolTip(0), + ), + ) # Adding to menu menu.addAction(actionReplay) @@ -228,15 +245,24 @@ def liveTreePressed(self, item): menu.popup(QtGui.QCursor.pos()) def liveTreeDoubleClicked(self, item): - """ This slot launches a live replay from eligible items in liveTree """ + """ + This slot launches a live replay from eligible items in liveTree + """ if item.isDisabled(): return + if ( + self.client.games.party + and self.client.games.party.memberCount > 1 + ): + if not self.client.games.leave_party(): + return + if self.liveTree.indexOfTopLevelItem(item) == -1: # Notify other modules that we're watching a replay - self.client.viewingReplay.emit(item.url) - replay(item.url) + self.client.viewing_replay.emit(item.gurl) + replay(item.gurl) def _addExistingGames(self, gameset): for game in gameset.values(): @@ -247,80 +273,47 @@ def _newGame(self, game): item = LiveReplayItem(game) self.games[game] = item self.liveTree.insertTopLevelItem(0, item) - game.gameUpdated.connect(self._check_game_closed) + game.updated.connect(self._check_game_closed) def _check_game_closed(self, game): if game.state == GameState.CLOSED: - game.gameUpdated.disconnect(self._check_game_closed) + game.updated.disconnect(self._check_game_closed) self._removeGame(game) def _removeGame(self, game): - self.liveTree.takeTopLevelItem(self.liveTree.indexOfTopLevelItem(self.games[game])) + self.liveTree.takeTopLevelItem( + self.liveTree.indexOfTopLevelItem(self.games[game]), + ) del self.games[game] class ReplayMetadata: - def __init__(self, data): + def __init__(self, data: str) -> None: self.raw_data = data - self.data = None self.is_broken = False - self.is_incomplete = False + self.model: MetadataModel | None = None try: - self.data = json.loads(data) + json_data = json.loads(data) except json.decoder.JSONDecodeError: self.is_broken = True return - self._validate_data() - - # FIXME - this is what the widget uses so far, we should define this - # schema precisely in the future - def _validate_data(self): - if not isinstance(self.data, dict): - self.is_broken = True - return - if not self.data.get('complete', False): - self.is_incomplete = True - return - - replay_schema = { - "type": "object", - "properties": { - "num_players": {"type": "number"}, - "launched_at": {"type": "number"}, - "game_time": { - "type": "number", - "minimum": 0 - }, - "mapname": {"type": "string"}, - "title": {"type": "string"}, - "teams": { - "type": "object", - "patternProperties": { - ".*": { - "type": "array", - "items": {"type": "string"} - } - } - }, - "featured_mod": {"type": "string"} - }, - "required": ["num_players", "mapname", "title", "teams", - "featured_mod"] - } try: - jsonschema.validate(self.data, replay_schema) - except jsonschema.ValidationError: + self.model = MetadataModel(**json_data) + except ValidationError: self.is_broken = True - def launch_time(self): - if 'launched_at' in self.data: - return self.data['launched_at'] - elif 'game_time' in self.data: - return self.data['game_time'] - else: - return time.time() # FIXME + @property + def is_incomplete(self) -> bool: + if self.model is None: + return True + return not self.model.complete + + def launch_time(self) -> float: + if self.model.launched_at > 0: + return self.model.launched_at + return self.model.game_time class LocalReplayItem(QtWidgets.QTreeWidgetItem): @@ -328,7 +321,7 @@ def __init__(self, replay_file, metadata=None): QtWidgets.QTreeWidgetItem.__init__(self) self._replay_file = replay_file self._metadata = metadata - self._map_dl_request = PreviewDownloadRequest() + self._map_dl_request = DownloadRequest() self._map_dl_request.done.connect(self._map_preview_downloaded) self._setup_appearance() @@ -349,51 +342,57 @@ def _setup_no_metadata_appearance(self): self.setText(1, self._replay_file) self.setIcon(0, util.THEME.icon("replays/replay.png")) colors = client.instance.player_colors - self.setForeground(0, QtGui.QColor(colors.getColor("default"))) + self.setForeground(0, QtGui.QColor(colors.get_color("default"))) def _setup_broken_appearance(self): self.setIcon(0, util.THEME.icon("replays/broken.png")) self.setText(1, self._replay_file) - self.setForeground(1, QtGui.QColor("red")) # FIXME: Needs to come from theme + # FIXME: Needs to come from theme + self.setForeground(1, QtGui.QColor("red")) + self.setForeground(2, QtGui.QColor("gray")) + self.setText(2, "(replay parse error)") - self.setForeground(2, QtGui.QColor("gray")) # FIXME: Needs to come from theme def _setup_incomplete_appearance(self): self.setIcon(0, util.THEME.icon("replays/replay.png")) self.setText(1, self._replay_file) self.setText(2, "(replay doesn't have complete metadata)") - self.setForeground(1, QtGui.QColor("yellow")) # FIXME: Needs to come from theme + # FIXME: Needs to come from theme + self.setForeground(1, QtGui.QColor("yellow")) - def _setup_complete_appearance(self): - data = self._metadata.data + def _setup_complete_appearance(self) -> None: + data = self._metadata.model launch_time = time.localtime(self._metadata.launch_time()) try: game_time = time.strftime("%H:%M", launch_time) except ValueError: game_time = "Unknown" - icon = fa.maps.preview(data['mapname']) + icon = fa.maps.preview(data.mapname) if icon: self.setIcon(0, icon) else: - dler = client.instance.map_downloader - dler.download_preview(data['mapname'], self._map_dl_request) + dler = client.instance.map_preview_downloader + dler.download_preview(data.mapname, self._map_dl_request) self.setIcon(0, util.THEME.icon("games/unknown_map.png")) - self.setToolTip(0, fa.maps.getDisplayName(data['mapname'])) + self.setToolTip(0, fa.maps.getDisplayName(data.mapname)) self.setText(0, game_time) - self.setForeground(0, QtGui.QColor(client.instance.player_colors.getColor("default"))) - self.setText(1, data['title']) + self.setForeground( + 0, + QtGui.QColor(client.instance.player_colors.get_color("default")), + ) + self.setText(1, data.title) self.setToolTip(1, self._replay_file) playerlist = [] - for players in list(data['teams'].values()): + for players in data.teams.values(): playerlist.extend(players) self.setText(2, ", ".join(playerlist)) self.setToolTip(2, ", ".join(playerlist)) - self.setText(3, data['featured_mod']) - self.setTextAlignment(3, QtCore.Qt.AlignCenter) + self.setText(3, data.featured_mod) + self.setTextAlignment(3, QtCore.Qt.AlignmentFlag.AlignCenter) def replay_bucket(self): if self._metadata is None: @@ -430,28 +429,50 @@ def _setup_appearance(self, kind, children): self.setIcon(0, util.THEME.icon("replays/bucket.png")) self.setText(0, kind) self.setText(3, "{} replays".format(len(children))) - self.setForeground(3, QtGui.QColor(client.instance.player_colors.getColor("default"))) + self.setForeground( + 3, + QtGui.QColor(client.instance.player_colors.get_color("default")), + ) for item in children: self.addChild(item) def _setup_broken_appearance(self): - self.setForeground(0, QtGui.QColor("red")) # FIXME: Needs to come from theme + # FIXME: Needs to come from theme + self.setForeground(0, QtGui.QColor("red")) + self.setText(1, "(not watchable)") - self.setForeground(1, QtGui.QColor(client.instance.player_colors.getColor("default"))) + self.setForeground( + 1, + QtGui.QColor(client.instance.player_colors.get_color("default")), + ) def _setup_incomplete_appearance(self): - self.setForeground(0, QtGui.QColor("yellow")) # FIXME: Needs to come from theme + # FIXME: Needs to come from theme + self.setForeground(0, QtGui.QColor("yellow")) + self.setText(1, "(watchable)") - self.setForeground(1, QtGui.QColor(client.instance.player_colors.getColor("default"))) + self.setForeground( + 1, + QtGui.QColor(client.instance.player_colors.get_color("default")), + ) def _setup_legacy_appearance(self): - self.setForeground(0, QtGui.QColor(client.instance.player_colors.getColor("default"))) - self.setForeground(1, QtGui.QColor(client.instance.player_colors.getColor("default"))) + self.setForeground( + 0, + QtGui.QColor(client.instance.player_colors.get_color("default")), + ) + self.setForeground( + 1, + QtGui.QColor(client.instance.player_colors.get_color("default")), + ) self.setText(1, "(old replay system)") def _setup_date_appearance(self): - self.setForeground(0, QtGui.QColor(client.instance.player_colors.getColor("player"))) + self.setForeground( + 0, + QtGui.QColor(client.instance.player_colors.get_color("player")), + ) class LocalReplaysWidgetHandler(object): @@ -459,18 +480,27 @@ def __init__(self, myTree): self.myTree = myTree self.myTree.itemDoubleClicked.connect(self.myTreeDoubleClicked) self.myTree.itemPressed.connect(self.myTreePressed) - self.myTree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) - self.myTree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) - self.myTree.header().setSectionResizeMode(2, QtWidgets.QHeaderView.Stretch) - self.myTree.header().setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) + self.myTree.header().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) + self.myTree.header().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) + self.myTree.header().setSectionResizeMode( + 2, QtWidgets.QHeaderView.ResizeMode.Stretch, + ) + self.myTree.header().setSectionResizeMode( + 3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) self.myTree.modification_time = 0 replay_cache = os.path.join(util.CACHE_DIR, "local_replays_metadata") - self.replay_files = LocalReplayMetadataCache(util.REPLAY_DIR, - replay_cache) + self.replay_files = LocalReplayMetadataCache( + util.REPLAY_DIR, replay_cache, + ) def myTreePressed(self, item): - if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton: + if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton: return if item.isDisabled(): @@ -491,7 +521,9 @@ def myTreePressed(self, item): # Triggers actionReplay.triggered.connect(lambda: self.myTreeDoubleClicked(item)) - actionExplorer.triggered.connect(lambda: util.showFileInFileBrowser(item.replay_path())) + actionExplorer.triggered.connect( + lambda: util.showFileInFileBrowser(item.replay_path()), + ) # Adding to menu menu.addAction(actionReplay) @@ -509,12 +541,13 @@ def myTreeDoubleClicked(self, item): def updatemyTree(self): modification_time = os.path.getmtime(util.REPLAY_DIR) - if self.myTree.modification_time == modification_time: # anything changed? + if self.myTree.modification_time == modification_time: return # nothing changed -> don't redo self.myTree.modification_time = modification_time self.myTree.clear() - # We put the replays into buckets by day first, then we add them to the treewidget. + # We put the replays into buckets by day first, then we add them to the + # treewidget. buckets = {} if not self.replay_files.cache_loaded: @@ -534,7 +567,8 @@ def updatemyTree(self): buckets[bucket].append(item) self.replay_files.save_cache() - # Now, create a top level treeWidgetItem for every bucket, and put the bucket's contents into them + # Now, create a top level treeWidgetItem for every bucket, and put the + # bucket's contents into them for bucket, items in buckets.items(): bucket_item = LocalReplayBucketItem(bucket, items) self.myTree.addTopLevelItem(bucket_item) @@ -589,12 +623,20 @@ def __getitem__(self, filename): class ReplayVaultWidgetHandler(object): - HOST = "lobby.faforever.com" - PORT = 11002 - - # connect to save/restore persistence settings for checkboxes & search parameters - automatic = Settings.persisted_property("replay/automatic", default_value=False, type=bool) - spoiler_free = Settings.persisted_property("replay/spoilerFree", default_value=True, type=bool) + # connect to save/restore persistence settings for checkboxes & search + # parameters + automatic = Settings.persisted_property( + "replay/automatic", default_value=False, type=bool, + ) + spoiler_free = Settings.persisted_property( + "replay/spoilerFree", default_value=True, type=bool, + ) + hide_unranked = Settings.persisted_property( + "replay/hideUnranked", default_value=False, type=bool, + ) + match_username = Settings.persisted_property( + "replay/matchUsername", default_value=True, type=bool, + ) def __init__(self, widget, dispatcher, client, gameset, playerset): self._w = widget @@ -605,13 +647,26 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): self.onlineReplays = {} self.selectedReplay = None - self.vault_connection = ReplaysConnection(self._dispatcher, self.HOST, self.PORT) - self.client.lobby_info.replayVault.connect(self.replayVault) + self.apiConnector = ReplaysApiConnector() + self.apiConnector.data_ready.connect(self.process_replays_data) self.replayDownload = QNetworkAccessManager() - self.replayDownload.finished.connect(self.finishRequest) + self.replayDownload.finished.connect(self.onDownloadFinished) + self.toolboxHandler = ReplayToolboxHandler( + self, widget, dispatcher, client, gameset, playerset, + ) + self.showLatest = True self.searching = False self.searchInfo = "Searching..." + self.defaultSearchParams = { + "page[number]": 1, + "page[size]": 100, + "sort": "-startTime", + "include": ( + "featuredMod,mapVersion,mapVersion.map,playerStats," + "playerStats.player" + ), + } _w = self._w _w.onlineTree.setItemDelegate(ReplayItemDelegate(_w)) @@ -622,55 +677,207 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): _w.playerName.returnPressed.connect(self.searchVault) _w.mapName.returnPressed.connect(self.searchVault) _w.automaticCheckbox.stateChanged.connect(self.automaticCheckboxchange) + _w.matchUsernameCheckbox.stateChanged.connect( + self.matchUsernameCheckboxChange, + ) + _w.showLatestCheckbox.stateChanged.connect( + self.showLatestCheckboxchange, + ) _w.spoilerCheckbox.stateChanged.connect(self.spoilerCheckboxchange) + _w.hideUnrCheckbox.stateChanged.connect(self.hideUnrCheckboxchange) _w.RefreshResetButton.pressed.connect(self.resetRefreshPressed) # restore persistent checkbox settings + _w.matchUsernameCheckbox.setChecked(self.match_username) _w.automaticCheckbox.setChecked(self.automatic) _w.spoilerCheckbox.setChecked(self.spoiler_free) + _w.hideUnrCheckbox.setChecked(self.hide_unranked) + + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self.stopSearchVault) + + def showToolTip(self, widget, msg): + """ + Default tooltips are too slow and disappear when user starts typing + """ - def searchVault(self, minRating=None, mapName=None, playerName=None, modListIndex=None): + position = widget.mapToGlobal( + QtCore.QPoint(0 + widget.width(), 0 - widget.height() / 2), + ) + QtWidgets.QToolTip.showText(position, msg) + + def stopSearchVault(self): + self.searching = False + self._w.searchInfoLabel.clear() + self._w.advSearchInfoLabel.clear() + self.timer.stop() + + def searchVault( + self, + minRating=None, + mapName=None, + playerName=None, + leaderboardId=None, + modListIndex=None, + quantity=None, + reset=None, + exactPlayerName=None, + ): w = self._w - if minRating is not None: - w.minRating.setValue(minRating) - if mapName is not None: - w.mapName.setText(mapName) - if playerName is not None: - w.playerName.setText(playerName) - if modListIndex is not None: - w.modList.setCurrentIndex(modListIndex) - - # Map Search helper - the secondary server has a problem with blanks (fix until change to api) - map_name = w.mapName.text().replace(" ", "*") - - """ search for some replays """ + timePeriod = None + + if self.searching: + QtWidgets.QMessageBox.critical( + None, + "Replay vault", + "Please, wait for previous search to finish.", + ) + return + + if reset: + w.minRating.setValue(0) + w.mapName.setText("") + w.playerName.setText("") + w.leaderboardList.setCurrentIndex(0) + w.modList.setCurrentIndex(0) + w.quantity.setValue(100) + w.showLatestCheckbox.setChecked(True) + else: + if minRating is not None: + w.minRating.setValue(minRating) + if mapName is not None: + w.mapName.setText(mapName) + if playerName is not None: + w.playerName.setText(playerName) + if leaderboardId is not None: + w.leaderboardList.setCurrentIndex(leaderboardId) + if modListIndex is not None: + w.modList.setCurrentIndex(modListIndex) + if quantity is not None: + w.quantity.setValue(quantity) + if not self.showLatest: + timePeriod = [] + timePeriod.append( + w.dateStart.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate), + ) + timePeriod.append( + w.dateEnd.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate), + ) + + filters = self.prepareFilters( + w.minRating.value(), + w.mapName.text(), + w.playerName.text(), + w.leaderboardList.currentIndex(), + w.modList.currentText(), + timePeriod, + exactPlayerName, + ) + + # """ search for some replays """ + self._w.onlineTree.clear() self._w.searchInfoLabel.setText(self.searchInfo) + self._w.searchInfoLabel.setVisible(True) + self._w.advSearchInfoLabel.setVisible(False) self.searching = True - self.vault_connection.connect() - self.vault_connection.send(dict(command="search", - rating=w.minRating.value(), - map=map_name, - player=w.playerName.text(), - mod=w.modList.currentText())) - self._w.onlineTree.clear() + + parameters = self.defaultSearchParams.copy() + parameters["page[size]"] = w.quantity.value() + + if filters: + parameters["filter"] = filters + + self.apiConnector.requestData(parameters) + self.timer.start(90000) + + def prepareFilters( + self, + minRating, + mapName, + playerName, + leaderboardId, + modListIndex, + timePeriod=None, + exactPlayerName=None, + ): + ''' + Making filter string here + some logic to exclude "heavy" requests + which may overload database (>30 sec searches). It might looks weak + (and probably it is), but hey, it works! =) + ''' + + filters = [] + + if self.hide_unranked: + filters.append('validity=="VALID"') + + if leaderboardId: + filters.append( + 'playerStats.ratingChanges.leaderboard.id=="{}"' + .format(leaderboardId), + ) + + if minRating and minRating > 0: + filters.append( + 'playerStats.ratingChanges.meanBefore=ge="{}"' + .format(minRating + 300), + ) + + if mapName: + filters.append( + 'mapVersion.map.displayName=="*{}*"'.format(mapName), + ) + + if playerName: + if self.match_username or exactPlayerName: + filters.append( + 'playerStats.player.login=="{}"'.format(playerName), + ) + else: + filters.append( + 'playerStats.player.login=="*{}*"'.format(playerName), + ) + + if modListIndex and modListIndex != "All": + filters.append( + 'featuredMod.technicalName=="{}"'.format(modListIndex), + ) + + if timePeriod: + filters.append('startTime=ge="{}"'.format(timePeriod[0])) + filters.append('startTime=le="{}"'.format(timePeriod[1])) + elif len(filters) > 0: + months = 3 + if playerName: + months = 6 + + startTime = ( + QtCore.QDateTime.currentDateTimeUtc() + .addMonths(-months) + .toString(QtCore.Qt.DateFormat.ISODate) + ) + filters.append('startTime=ge="{}"'.format(startTime)) + + if len(filters) > 0: + return "({})".format(";".join(filters)) + + return None def reloadView(self): - if not self.searching: # something else is already in the pipe from SearchVault - if self.automatic or self.onlineReplays == {}: # refresh on Tab change or only the first time - self._w.searchInfoLabel.setText(self.searchInfo) - self.vault_connection.connect() - self.vault_connection.send(dict(command="list")) + if not self.searching: + # refresh on Tab change or only the first time + if self.automatic or self.onlineReplays == {}: + self.searchVault(reset=True) def onlineTreeClicked(self, item): - if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.RightButton: - if type(item.parent) == ReplaysWidget: # FIXME - hack + if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.MouseButton.RightButton: + if isinstance(item.parent, ReplaysWidget): # FIXME - hack item.pressed(item) else: self.selectedReplay = item if hasattr(item, "moreInfo"): if item.moreInfo is False: - self.vault_connection.connect() - self.vault_connection.send(dict(command="info_replay", uid=item.uid)) + item.infoPlayers() elif item.spoiled != self._w.spoilerCheckbox.isChecked(): self._w.replayInfos.clear() self._w.replayInfos.setHtml(item.replayInfo) @@ -678,13 +885,23 @@ def onlineTreeClicked(self, item): else: self._w.replayInfos.clear() item.generateInfoPlayersHtml() + if self.toolboxHandler.mapPreview: + self.toolboxHandler.updateMapPreview() def onlineTreeDoubleClicked(self, item): + if ( + self.client.games.party + and self.client.games.party.memberCount > 1 + ): + if not self.client.games.leave_party(): + return + if hasattr(item, "duration"): # it's a game not a date separator if "playing" in item.duration: # live game will not be in vault - # search result isn't updated automatically - so game status might have changed - if item.uid in self._gameset.games: # game still running - game = self._gameset.games[item.uid] + # search result isn't updated automatically - so game status + # might have changed + if item.uid in self._gameset: # game still running + game = self._gameset[item.uid] if not game.launched_at: # we frown upon those return if game.has_live_replay: # live game over 5min @@ -693,21 +910,34 @@ def onlineTreeDoubleClicked(self, item): self._startReplay(name) break else: - wait_str = time.strftime('%M Min %S Sec', time.gmtime(game.LIVE_REPLAY_DELAY_SECS - - (time.time() - game.launched_at))) - QtWidgets.QMessageBox.information(client.instance, "5 Minute Live Game Delay", - "It is too early to join the Game.\n" - "You have to wait " + wait_str + " to join.") + delta = time.gmtime( + game.LIVE_REPLAY_DELAY_SECS + - (time.time() - game.launched_at), + ) + wait_str = time.strftime('%M Min %S Sec', delta) + QtWidgets.QMessageBox.information( + client.instance, + "5 Minute Live Game Delay", + ( + "It is too early to join the Game.\n" + "You have to wait {} to join.".format(wait_str) + ), + ) else: # game ended - ask to start replay - if QtWidgets.QMessageBox.question(client.instance, "Live Game ended", - "Would you like to watch the replay from the vault?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.Yes: - self.replayDownload.get(QNetworkRequest(QtCore.QUrl(item.url))) + if QtWidgets.QMessageBox.question( + client.instance, + "Live Game ended", + "Would you like to watch the replay from the vault?", + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) == QtWidgets.QMessageBox.StandardButton.Yes: + req = QNetworkRequest(QtCore.QUrl(item.url)) + self.replayDownload.get(req) else: # start replay if hasattr(item, "url"): - self.replayDownload.get(QNetworkRequest(QtCore.QUrl(item.url))) + req = QNetworkRequest(QtCore.QUrl(item.url)) + self.replayDownload.get(req) def _startReplay(self, name): if name is None or name not in self._playerset: @@ -718,78 +948,82 @@ def _startReplay(self, name): return replay(player.currentGame.url(player.id)) + def matchUsernameCheckboxChange(self, state): + self.match_username = state + def automaticCheckboxchange(self, state): self.automatic = state def spoilerCheckboxchange(self, state): self.spoiler_free = state - if self.selectedReplay: # if something is selected in the tree to the left - if type(self.selectedReplay) == ReplayItem: # and if it is a game - self.selectedReplay.generateInfoPlayersHtml() # then we redo it - - def resetRefreshPressed(self): # reset search parameter and reload recent Replays List - self._w.searchInfoLabel.setText(self.searchInfo) - self.vault_connection.connect() - self.vault_connection.send(dict(command="list")) - self._w.minRating.setValue(0) - self._w.mapName.setText("") - self._w.playerName.setText("") - self._w.modList.setCurrentIndex(0) # "All" - - def finishRequest(self, reply): - if reply.error() != QNetworkReply.NoError: - QtWidgets.QMessageBox.warning(self._w, "Network Error", reply.errorString()) + # if something is selected in the tree to the left + if self.selectedReplay: + # and if it is a game + if isinstance(self.selectedReplay, ReplayItem): + # then we redo it + self.selectedReplay.generateInfoPlayersHtml() + + def showLatestCheckboxchange(self, state): + self.showLatest = state + if state: # disable date edit fields if True + self._w.dateStart.setEnabled(False) + self._w.dateEnd.setEnabled(False) + else: # enable date edit and set current date + self._w.dateStart.setEnabled(True) + self._w.dateEnd.setEnabled(True) + + date = QtCore.QDate.currentDate() + self._w.dateStart.setDate(date) + self._w.dateEnd.setDate(date) + + def hideUnrCheckboxchange(self, state): + self.hide_unranked = state + + def resetRefreshPressed(self): + # reset search parameter and reload recent Replays List + if not self.searching: + self.searchVault(reset=True) + + def onDownloadFinished(self, reply): + if reply.error() != QNetworkReply.NetworkError.NoError: + QtWidgets.QMessageBox.warning( + self._w, "Network Error", reply.errorString(), + ) else: - faf_replay = QtCore.QFile(os.path.join(util.CACHE_DIR, "temp.fafreplay")) - faf_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate) + faf_replay = QtCore.QFile( + os.path.join(util.CACHE_DIR, "temp.fafreplay"), + ) + faf_replay.open( + QtCore.QIODevice.OpenModeFlag.WriteOnly + | QtCore.QIODevice.OpenModeFlag.Truncate, + ) faf_replay.write(reply.readAll()) faf_replay.flush() faf_replay.close() replay(os.path.join(util.CACHE_DIR, "temp.fafreplay")) - def replayVault(self, message): - action = message["action"] - self._w.searchInfoLabel.clear() - if action == "list_recents": - self.onlineReplays = {} - replays = message["replays"] - for replay in replays: - uid = replay["id"] - - if uid not in self.onlineReplays: - self.onlineReplays[uid] = ReplayItem(uid, self._w) - self.onlineReplays[uid].update(replay, self.client) - else: - self.onlineReplays[uid].update(replay, self.client) - - self.updateOnlineTree() - self._w.replayInfos.clear() - self._w.RefreshResetButton.setText("Refresh Recent List") - - elif action == "info_replay": - uid = message["uid"] - if uid in self.onlineReplays: - self.onlineReplays[uid].infoPlayers(message["players"]) - - elif action == "search_result": - self.searching = False - self.onlineReplays = {} - replays = message["replays"] - for replay in replays: - uid = replay["id"] - - if uid not in self.onlineReplays: - self.onlineReplays[uid] = ReplayItem(uid, self._w) - self.onlineReplays[uid].update(replay, self.client) - else: - self.onlineReplays[uid].update(replay, self.client) - - self.updateOnlineTree() - self._w.replayInfos.clear() - self._w.RefreshResetButton.setText("Reset Search to Recent") + def process_replays_data(self, message: dict) -> None: + self.stopSearchVault() + self._w.replayInfos.clear() + self.onlineReplays = {} + replays = message["data"] + for replay_item in replays: + uid = int(replay_item["id"]) + if uid not in self.onlineReplays: + self.onlineReplays[uid] = ReplayItem(uid, self._w) + self.onlineReplays[uid].update(replay_item, self.client) + self.updateOnlineTree() + + if len(message["data"]) == 0: + self._w.searchInfoLabel.setText( + "No replays found", + ) + self._w.advSearchInfoLabel.setText( + "No replays found", + ) def updateOnlineTree(self): - self.selectedReplay = None # clear because won't be part of the new tree + self.selectedReplay = None # clear, it won't be part of the new tree self._w.replayInfos.clear() self._w.onlineTree.clear() buckets = {} @@ -802,13 +1036,18 @@ def updateOnlineTree(self): self._w.onlineTree.addTopLevelItem(bucket_item) bucket_item.setIcon(0, util.THEME.icon("replays/bucket.png")) - bucket_item.setText(0, "" + bucket+"") - bucket_item.setText(1, "" + str(len(buckets[bucket])) + " replays") - - for replay in buckets[bucket]: - bucket_item.addChild(replay) - replay.setFirstColumnSpanned(True) - replay.setIcon(0, replay.icon) + bucket_item.setText( + 0, "{}".format(bucket), + ) + bucket_len = len(buckets[bucket]) + bucket_item.setText( + 1, "{} replays".format(bucket_len), + ) + + for replay_item in buckets[bucket]: + bucket_item.addChild(replay_item) + replay_item.setFirstColumnSpanned(True) + replay_item.setIcon(0, replay_item.icon) bucket_item.setExpanded(True) @@ -819,15 +1058,27 @@ def __init__(self, client, dispatcher, gameset, playerset): self.setupUi(self) - self.liveManager = LiveReplaysWidgetHandler(self.liveTree, client, gameset) + self.liveManager = LiveReplaysWidgetHandler( + self.liveTree, client, gameset, + ) self.localManager = LocalReplaysWidgetHandler(self.myTree) - self.vaultManager = ReplayVaultWidgetHandler(self, dispatcher, client, gameset, playerset) + self.vaultManager = ReplayVaultWidgetHandler( + self, dispatcher, client, gameset, playerset, + ) logger.info("Replays Widget instantiated.") - def set_player(self, name): + def set_player(self, name, leaderboardName=None): self.setCurrentIndex(2) # focus on Online Fault - self.vaultManager.searchVault(-1400, "", name, 0) + if leaderboardName is not None: + leaderboardId = self.leaderboardList.findText(leaderboardName) + self.vaultManager.searchVault( + 0, "", name, leaderboardId, 0, 100, exactPlayerName=True, + ) + else: + self.vaultManager.searchVault( + 0, "", name, 0, 0, 100, exactPlayerName=True, + ) def focusEvent(self, event): self.localManager.updatemyTree() diff --git a/src/replays/connection.py b/src/replays/connection.py deleted file mode 100644 index 81cbbe153..000000000 --- a/src/replays/connection.py +++ /dev/null @@ -1,105 +0,0 @@ -from PyQt5 import QtCore, QtNetwork -import json - -import logging -logger = logging.getLogger(__name__) - -# Connection to the replay vault. Given how this works, it will one day -# be replaced with FAF API. - - -class ReplaysConnection(QtCore.QObject): - def __init__(self, dispatch, host, port): - QtCore.QObject.__init__(self) - - self.dispatch = dispatch - self.blockSize = 0 - self.host = host - self.port = port - - self.replayVaultSocket = QtNetwork.QTcpSocket() - self.replayVaultSocket.readyRead.connect(self._readDataFromServer) - self.replayVaultSocket.error.connect(self._handleServerError) - self.replayVaultSocket.disconnected.connect(self._disconnected) - - def connect(self): - """ connect to the replay vault server """ - state = self.replayVaultSocket.state() - states = QtNetwork.QAbstractSocket - if state != states.ConnectedState and state != states.ConnectingState: - self.replayVaultSocket.connectToHost(self.host, self.port) - - def receiveJSON(self, data_string, stream): - """ A fairly pythonic way to process received strings as JSON messages. """ - - try: - message = json.loads(data_string) - self.dispatch.dispatch(message) - except ValueError as e: - logger.error("Error decoding json ") - logger.error(e) - self.replayVaultSocket.disconnectFromHost() - - @QtCore.pyqtSlot() - def _readDataFromServer(self): - ins = QtCore.QDataStream(self.replayVaultSocket) - ins.setVersion(QtCore.QDataStream.Qt_4_2) - - while not ins.atEnd(): - if self.blockSize == 0: - if self.replayVaultSocket.bytesAvailable() < 4: - return - self.blockSize = ins.readUInt32() - if self.replayVaultSocket.bytesAvailable() < self.blockSize: - return - - action = ins.readQString() - logger.debug("Replay Vault Server: " + action) - self.receiveJSON(action, ins) - self.blockSize = 0 - - def send(self, message): - data = json.dumps(message) - logger.debug("Outgoing JSON Message: " + data) - self._writeToServer(data) - - def _writeToServer(self, action, *args, **kw): - logger.debug(("writeToServer(" + action + ", [" + ', '.join(args) + "])")) - - block = QtCore.QByteArray() - out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite) - out.setVersion(QtCore.QDataStream.Qt_4_2) - out.writeUInt32(0) - out.writeQString(action) - - for arg in args: - if type(arg) is int: - out.writeInt(arg) - elif isinstance(arg, str): - out.writeQString(arg) - elif type(arg) is float: - out.writeFloat(arg) - elif type(arg) is list: - out.writeQVariantList(arg) - else: - logger.warning("Uninterpreted Data Type: " + str(type(arg)) + " of value: " + str(arg)) - out.writeQString(str(arg)) - - out.device().seek(0) - out.writeUInt32(block.size() - 4) - self.replayVaultSocket.write(block) - - def _handleServerError(self, socketError): - if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError: - logger.info("Replay Server down: The server is down for maintenance, please try later.") - - elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError: - logger.info("Connection to Host lost. Please check the host name and port settings.") - - elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError: - logger.info("The connection was refused by the peer.") - else: - logger.info("The following error occurred: %s." % self.replayVaultSocket.errorString()) - - def _disconnected(self): - logger.debug("Disconnected from server") diff --git a/src/replays/models.py b/src/replays/models.py new file mode 100644 index 000000000..21700a128 --- /dev/null +++ b/src/replays/models.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from pydantic import Field + + +# FIXME - this is what the widget uses so far, we should define this +# schema precisely in the future +class MetadataModel(BaseModel): + complete: bool = Field(False) + featured_mod: str | None + launched_at: float + mapname: str + num_players: int + teams: dict[str, list[str]] + title: str + game_time: float = Field(0.0) diff --git a/src/replays/replayToolbox.py b/src/replays/replayToolbox.py new file mode 100644 index 000000000..97d0a7b67 --- /dev/null +++ b/src/replays/replayToolbox.py @@ -0,0 +1,368 @@ +import logging +import os + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + +from config import Settings +from downloadManager import DownloadRequest +from downloadManager import MapLargePreviewDownloader +from util import MAP_PREVIEW_LARGE_DIR + +logger = logging.getLogger(__name__) + +filtersSettings = { + "Player name": dict( + filterString="playerStats.player.login", + operators=["contains", "is", "is not"], + ), + "One of global ratings": dict( + filterString="playerStats.player.globalRating.rating", + operators=[">", "<"], + ), + "One of ladder ratings": dict( + filterString="playerStats.player.ladder1v1Rating.rating", + operators=[">", "<"], + ), + "One of ratings": dict( + filterString="playerStats.ratingChanges.meanBefore", + operators=[">", "<"], + ), + "Game mod name": dict( + filterString="featuredMod.technicalName", + operators=["contains", "is", "is not"], + ), + "Leaderboard name": dict( + filterString="playerStats.ratingChanges.leaderboard.technicalName", + operators=["contains", "is", "is not"], + ), + "Map name": dict( + filterString="mapVersion.map.displayName", + operators=["contains", "is", "is not"], + ), + "Player faction": dict( + filterString="playerStats.faction", + operators=["is", "is not"], + values=["AEON", "CYBRAN", "UEF", "SERAPHIM", "NOMAD", "CIVILIAN"], + ), + "Player start position": dict( + filterString="playerStats.startSpot", + operators=["is", "is not"], + ), + "Max players (map)": dict( + filterString="mapVersion.maxPlayers", + operators=["is", "is not", ">", "<"], + ), + "Replay ID": dict( + filterString="id", + operators=["is"], + ), + "Title": dict( + filterString="name", + operators=["contains", "is", "is not"], + ), + "Start time": dict( + filterString="startTime", + operators=[">", "<"], + ), + "Validity": dict( + filterString="validity", + operators=["is"], + values=[ + "VALID", "TOO_MANY_DESYNCS", "WRONG_VICTORY_CONDITION", + "NO_FOG_OF_WAR", "CHEATS_ENABLED", "PREBUILT_ENABLED", + "NORUSH_ENABLED", "BAD_UNIT_RESTRICTIONS", "BAD_MAP", "TOO_SHORT", + ], + ), + "Victory condition": dict( + filterString="victoryCondition", + operators=["is", "is not"], + values=[ + "DEMORALIZATION", "DOMINATION", "ERADICATION", + "SANDBOX", "UNKNOWN", + ], + ), +} + +operators = { + 'is': '=="{}"', + 'is not': '!="{}"', + 'contains': '=="*{}*"', + '>': '=gt="{}"', + '<': '=lt="{}"', +} + + +class ReplayToolboxHandler(object): + activePage = Settings.get('replay/activeTboxPage', "Hide all", str) + + def __init__( + self, + wigetHandler, + widget, + dispatcher, + client, + gameset, + playerset, + ): + self._w = widget + self._dispatcher = dispatcher + self.client = client + self._gameset = gameset + self._playerset = playerset + self.widgetHandler = wigetHandler + + self._map_preview_dler = MapLargePreviewDownloader(MAP_PREVIEW_LARGE_DIR) + self._map_dl_request = DownloadRequest() + self._map_dl_request.done.connect(self._on_map_preview_downloaded) + + w = self._w + + self.hidden = False + self.pageChanged = False + self.mapPreview = False + self.numOfFiltersLines = 6 + self.filtersList = [] + self.numOfPages = w.replayToolBox.count() + self.hideAllIndex = self.numOfPages - 1 + self.tboxMinHeight = w.replayToolBox.minimumHeight() + self.widgetMinHeight = w.widget_3.minimumHeight() + + w.replayToolBox.currentChanged.connect(self.tboxChanged) + w.advSearchButton.pressed.connect(self.advancedSearch) + w.advResetButton.pressed.connect(self.resetAll) + w.mapPreviewLabel.currentMap = None + + self.setupTboxPages() + self.setupComboBoxes() + + def setupTboxPages(self): + ''' + A hack to imitate 'collapse all' function + + some style tweaks that can't be done via css or QtDesigner. + Ideally, we should rewrite QToolBox and make our own :) + ''' + w = self._w + children = w.replayToolBox.children() + + for widget in children: + if isinstance(widget, QtWidgets.QAbstractButton): + widget.clicked.connect(self.tboxTitleClicked) + widget.setStyleSheet("font-size:9pt") + + # make our empty page invisible + children[-1].setStyleSheet( + "background-color: transparent; border-width: 0px", + ) + children[-2].setStyleSheet("max-height: 0px") + + for n in range(self.numOfPages): + if w.replayToolBox.itemText(n) == self.activePage: + w.replayToolBox.setCurrentIndex(n) + break + + if self.activePage == "Hide all": + self.adjustTboxSize(hide=True) + elif self.activePage == "Map Preview": + self.mapPreview = True + + def adjustTboxSize(self, hide=None): + ''' a part of "collapse all" hack''' + if hide: + self.hidden = True + height = 35 * self.numOfPages + self._w.widget_3.setMaximumHeight(height) + self._w.widget_3.setMinimumHeight(0) + + self._w.replayToolBox.setMaximumHeight(height) + self._w.replayToolBox.setMinimumHeight(0) + else: + self.hidden = False + self._w.widget_3.setMaximumHeight(1000) + self._w.widget_3.setMinimumHeight(self.widgetMinHeight) + + self._w.replayToolBox.setMaximumHeight(1000) + self._w.replayToolBox.setMinimumHeight(self.tboxMinHeight) + + def tboxChanged(self, index): + page = self._w.replayToolBox.itemText(index) + if page == "Map Preview": + self.mapPreview = True + else: + self.mapPreview = False + + Settings.set('replay/activeTboxPage', page) + self.pageChanged = True + + def tboxTitleClicked(self, arg): + if not self.pageChanged: + self.adjustTboxSize(hide=True) + self._w.replayToolBox.setCurrentIndex(self.hideAllIndex) + elif self.hidden: + self.adjustTboxSize(hide=False) + + self.pageChanged = False + + # Advanced search section + + def setupComboBoxes(self): + for n in range(1, self.numOfFiltersLines + 1): + filterComboBox = getattr(self._w, "filter{}".format(n)) + filterComboBox.operatorBox = getattr( + self._w, "operator{}".format(n), + ) + filterComboBox.valueBox = getattr(self._w, "value{}".format(n)) + filterComboBox.layout = getattr( + self._w, "filterHorizontal{}".format(n), + ) + filterComboBox.dateEdit = None + filterComboBox.dateIsActive = False + + filterComboBox.currentIndexChanged.connect(self.filterChanged) + filterComboBox.addItem("") + + for key, v in filtersSettings.items(): + filterComboBox.addItem(key) + self.filtersList.append(filterComboBox) + + self._w.filter1.setCurrentIndex(1) + + def filterChanged(self): + '''Setup operator and value comboBoxes according to selected filter''' + filterWidget = self._w.sender() + filterName = filterWidget.currentText() + operatorBox = filterWidget.operatorBox + valueBox = filterWidget.valueBox + + operatorBox.clear() + valueBox.clear() + + if filterName: + if filterName == "Start time": # show date edit and hide valueBox + filterWidget.valueBox.hide() + if not filterWidget.dateEdit: + self.createDateEdit(filterWidget, valueBox) + else: + filterWidget.dateEdit.show() + filterWidget.dateIsActive = True + elif filterWidget.dateIsActive: + filterWidget.dateEdit.hide() + filterWidget.valueBox.show() + filterWidget.dateIsActive = False + + for operator in filtersSettings[filterName]['operators']: + operatorBox.addItem(operator) + + if 'values' in filtersSettings[filterName]: + for val in filtersSettings[filterName]['values']: + valueBox.addItem(val) + elif filterWidget.dateIsActive: + # Switch from "Start time" filter to empty + filterWidget.dateEdit.hide() + filterWidget.dateIsActive = False + filterWidget.valueBox.show() + + def createDateEdit(self, filterWidget, valueBox): + filterWidget.dateEdit = QtWidgets.QDateEdit( + QtCore.QDate.currentDate(), valueBox, + ) + filterWidget.dateEdit.setCalendarPopup(True) + filterWidget.layout.addWidget(filterWidget.dateEdit) + + def advancedSearch(self): + if self.widgetHandler.searching: + QtWidgets.QMessageBox.critical( + None, + "Replay vault", + "Please, wait for previous search to finish.", + ) + return + + self._w.advSearchInfoLabel.setText(self.widgetHandler.searchInfo) + self._w.advSearchInfoLabel.setVisible(True) + self._w.searchInfoLabel.setVisible(False) + self.widgetHandler.searching = True + + parameters = self.widgetHandler.defaultSearchParams.copy() + parameters["page[size]"] = self._w.advQuantity.value() + + filters = self.prepareFilters() + + if filters: + parameters["filter"] = filters + + self.widgetHandler.apiConnector.requestData(parameters) + self.widgetHandler.timer.start(90000) + + def prepareFilters(self): + finalFilters = [] + + for filterBox in self.filtersList: + filterName = filterBox.currentText() + opName = filterBox.operatorBox.currentText() + value = filterBox.valueBox.currentText() + + if filterName: + filterString = filtersSettings[filterName]["filterString"] + + if filterName == "Start time": + startDate = filterBox.dateEdit.dateTime().toUTC().toString( + QtCore.Qt.DateFormat.ISODate, + ) + if opName == ">": + finalFilters.append( + filterString + operators[opName].format(startDate), + ) + else: + finalFilters.append( + filterString + operators[opName].format(startDate), + ) + elif filterName == "One of ratings": + finalFilters.append( + filterString + operators[opName].format( + int(value) + 300, + ), + ) + elif value: + finalFilters.append( + filterString + operators[opName].format(value), + ) + + if len(finalFilters) > 0: + return "({})".format(";".join(finalFilters)) + + return None + + def resetAll(self): + for filterWidget in self.filtersList: + filterWidget.setCurrentIndex(0) + filterWidget.valueBox.setEditText("") + + # Map preview section + + def updateMapPreview(self): + selectedReplay = self.widgetHandler.selectedReplay + if selectedReplay and hasattr(selectedReplay, "mapname"): + preview = self._w.mapPreviewLabel + if ( + selectedReplay.mapname.lower() != "unknown" + and selectedReplay.mapname != preview.currentMap + ): + imgPath = os.path.join( + MAP_PREVIEW_LARGE_DIR, selectedReplay.mapname + ".png", + ) + + if os.path.isfile(imgPath): + pix = QtGui.QPixmap(imgPath) + preview.setPixmap(pix) + preview.currentMap = selectedReplay.mapname + else: + self._map_preview_dler.download_preview( + selectedReplay.mapname, + self._map_dl_request, + ) + + def _on_map_preview_downloaded(self, mapname, result): + if mapname == self.widgetHandler.selectedReplay.mapname: + self.updateMapPreview() diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 4297efbcf..c3ee5aef3 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -1,59 +1,80 @@ import os import time +from datetime import datetime +from datetime import timezone -import util -from PyQt5 import QtCore, QtWidgets, QtGui +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +import util from config import Settings +from downloadManager import DownloadRequest from fa import maps from games.moditem import mods -from downloadManager import PreviewDownloadRequest class ReplayItemDelegate(QtWidgets.QStyledItemDelegate): - + def __init__(self, *args, **kwargs): QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) - + def paint(self, painter, option, index, *args, **kwargs): self.initStyleOption(option, index) - + painter.save() - + html = QtGui.QTextDocument() html.setHtml(option.text) - + icon = QtGui.QIcon(option.icon) iconsize = icon.actualSize(option.rect.size()) - - # clear icon and text before letting the control draw itself because we're rendering these parts ourselves + + # clear icon and text before letting the control draw itself because + # we're rendering these parts ourselves option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) - + option.text = "" + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) + # Shadow - # painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1, iconsize.width(), iconsize.height(), QtGui.QColor("#202020")) + # painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1, + # iconsize.width(), iconsize.height(), + # QtGui.QColor("#202020")) # Icon - icon.paint(painter, option.rect.adjusted(5-2, -2, 0, 0), QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) - + icon.paint( + painter, option.rect.adjusted(3, -2, 0, 0), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + # Frame around the icon -# pen = QtWidgets.QPen() -# pen.setWidth(1) -# pen.setBrush(QtGui.QColor("#303030")) #FIXME: This needs to come from theme. -# pen.setCapStyle(QtCore.Qt.RoundCap) -# painter.setPen(pen) -# painter.drawRect(option.rect.left()+5-2, option.rect.top()+5-2, iconsize.width(), iconsize.height()) + # pen = QtWidgets.QPen() + # pen.setWidth(1) + # FIXME: This needs to come from theme. + # pen.setBrush(QtGui.QColor("#303030")) + + # pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + # painter.setPen(pen) + # painter.drawRect(option.rect.left()+5-2, option.rect.top()+5-2, + # iconsize.width(), iconsize.height()) # Description - painter.translate(option.rect.left() + iconsize.width() + 10, option.rect.top() + 10) - clip = QtCore.QRectF(0, 0, option.rect.width()-iconsize.width() - 10 - 5, option.rect.height()) + painter.translate( + option.rect.left() + iconsize.width() + 10, + option.rect.top() + 10, + ) + clip = QtCore.QRectF( + 0, 0, option.rect.width() - iconsize.width() - 15, + option.rect.height(), + ) html.drawContents(painter, clip) - + painter.restore() def sizeHint(self, option, index, *args, **kwargs): - clip = index.model().data(index, QtCore.Qt.UserRole) + clip = index.model().data(index, QtCore.Qt.ItemDataRole.UserRole) self.initStyleOption(option, index) html = QtGui.QTextDocument() html.setHtml(option.text) @@ -66,122 +87,192 @@ def sizeHint(self, option, index, *args, **kwargs): class ReplayItem(QtWidgets.QTreeWidgetItem): # list element - FORMATTER_REPLAY = str(util.THEME.readfile("replays/formatters/replay.qthtml")) + FORMATTER_REPLAY = str( + util.THEME.readfile( + "replays/formatters/replay.qthtml", + ), + ) # replay-info elements - FORMATTER_REPLAY_INFORMATION = "

Replay UID : {uid}

{teams}
" - FORMATTER_REPLAY_TEAM_SPOILED = "{title}{players}" - FORMATTER_REPLAY_FFA_SPOILED = "Win{winner}Lose{players}" - FORMATTER_REPLAY_TEAM2_SPOILED = "{players}
{title}
" - FORMATTER_REPLAY_TEAM2 = "{players}
" - FORMATTER_REPLAY_PLAYER_SCORE = "{player_score}" - FORMATTER_REPLAY_PLAYER_ICON = "" - FORMATTER_REPLAY_PLAYER_LABEL = "{player_name} ({player_rating})" + FORMATTER_REPLAY_INFORMATION = ( + "

Replay UID : {uid}

{teams}" + "
" + ) + FORMATTER_REPLAY_TEAM_SPOILED = ( + "" + "{title}{players}" + ) + FORMATTER_REPLAY_FFA_SPOILED = ( + "" + "Win{winner}Lose{players}" + ) + FORMATTER_REPLAY_TEAM2_SPOILED = ( + "{players}" + "
{title}
" + ) + FORMATTER_REPLAY_TEAM2 = "{players}
" + FORMATTER_REPLAY_PLAYER_SCORE = ( + "{player_score}" + ) + FORMATTER_REPLAY_PLAYER_ICON = ( + "" + "" + ) + FORMATTER_REPLAY_PLAYER_LABEL = ( + "{player_name} " + "({player_rating})" + ) def __init__(self, uid, parent, *args, **kwargs): QtWidgets.QTreeWidgetItem.__init__(self, *args, **kwargs) - self.uid = uid - self.parent = parent - self.height = 70 - self.viewtext = None + self.uid = uid + self.parent = parent + self.height = 70 + self.viewtext = None self.viewtextPlayer = None - self.mapname = None + self.mapname = None self.mapdisplayname = None - self.client = None - self.title = None - self.host = None - - self.startDate = None - self.duration = None - self.live_delay = False - - self.moreInfo = False - self.replayInfo = False - self.spoiled = False - self.url = "{}/faf/vault/replay_vault/replay.php?id={}".format(Settings.get('content/host'), self.uid) - - self.teams = {} - self.access = None - self.mod = None + self.client = None + self.title = None + self.host = None + + self.startDate = None + self.duration = None + self.live_delay = False + + self.moreInfo = False + self.replayInfo = False + self.spoiled = False + self.url = "{}/{}".format(Settings.get('replay_vault/host'), self.uid) + + self.teams = {} + self.access = None + self.mod = None self.moddisplayname = None - self.options = [] - self.players = [] - self.numberplayers = 0 - self.biggestTeam = 0 - self.winner = None - self.teamWin = None + self.options = [] + self.players = [] + self.numberplayers = 0 + self.biggestTeam = 0 + self.winner = None + self.teamWin = None self.setHidden(True) - self.extraInfoWidth = 0 # panel with more information + self.extraInfoWidth = 0 # panel with more information self.extraInfoHeight = 0 # panel with more information - self._map_dl_request = PreviewDownloadRequest() + self._map_dl_request = DownloadRequest() self._map_dl_request.done.connect(self._on_map_preview_downloaded) - def update(self, message, client): + def update(self, replay, client): """ Updates this item from the message dictionary supplied """ - + self.replay = replay + self.client = client - - self.name = message["name"] - self.mapname = message["map"] - if message['end'] == 4294967295: # = FFFF FFFF (year 2106) aka still playing - seconds = time.time()-message['start'] + self.name = replay["name"] + + if "id" in replay["mapVersion"]: + self.mapid = replay["mapVersion"]["id"] + self.mapname = replay["mapVersion"]["folderName"] + self.previewUrlLarge = replay["mapVersion"]["thumbnailUrlLarge"] + else: + self.mapname = "unknown" + + startDt = datetime.strptime(replay["startTime"], '%Y-%m-%dT%H:%M:%SZ') + # local time + startDt = startDt.replace(tzinfo=timezone.utc).astimezone(tz=None) + + if replay["endTime"] is None: + seconds = time.time() - startDt.timestamp() if seconds > 86400: # more than 24 hours - self.duration = "end time
 missing
" + self.duration = ( + "end time
 missing
" + ) elif seconds > 7200: # more than 2 hours - self.duration = time.strftime('%H:%M:%S', time.gmtime(seconds)) + "
?playing?" + self.duration = ( + time.strftime('%H:%M:%S', time.gmtime(seconds)) + + "
?playing?" + ) elif seconds < 300: # less than 5 minutes - self.duration = time.strftime('%H:%M:%S', time.gmtime(seconds)) + "
 playing" + self.duration = ( + time.strftime('%H:%M:%S', time.gmtime(seconds)) + + "
 playing" + ) self.live_delay = True else: - self.duration = time.strftime('%H:%M:%S', time.gmtime(seconds)) + "
 playing" + self.duration = ( + time.strftime('%H:%M:%S', time.gmtime(seconds)) + + "
 playing" + ) else: - self.duration = time.strftime('%H:%M:%S', time.gmtime(message["duration"])) - self.startHour = time.strftime("%H:%M", time.localtime(message['start'])) - self.startDate = time.strftime("%Y-%m-%d", time.localtime(message['start'])) - self.mod = message["mod"] + endDt = datetime.strptime(replay["endTime"], '%Y-%m-%dT%H:%M:%SZ') + # local time + endDt = endDt.replace(tzinfo=timezone.utc).astimezone(tz=None) + self.duration = time.strftime( + '%H:%M:%S', + time.gmtime((endDt - startDt).total_seconds()), + ) + + self.startHour = startDt.strftime("%H:%M") + self.startDate = startDt.strftime("%Y-%m-%d") + + self.modid = replay["featuredMod"]["id"] + self.mod = replay["featuredMod"]["technicalName"] # Map preview code self.mapdisplayname = maps.getDisplayName(self.mapname) - + self.icon = maps.preview(self.mapname) if not self.icon: - self.client.map_downloader.download_preview(self.mapname, self._map_dl_request) self.icon = util.THEME.icon("games/unknown_map.png") + if self.mapname != "unknown": + self.client.map_preview_downloader.download_preview( + self.mapname, self._map_dl_request, + ) if self.mod in mods: - self.moddisplayname = mods[self.mod].name + self.moddisplayname = mods[self.mod].name else: self.moddisplayname = self.mod - self.viewtext = self.FORMATTER_REPLAY.format(time=self.startHour, name=self.name, map=self.mapdisplayname, - duration=self.duration, mod=self.moddisplayname) + self.viewtext = self.FORMATTER_REPLAY.format( + time=self.startHour, name=self.name, map=self.mapdisplayname, + duration=self.duration, mod=self.moddisplayname, + ) def _on_map_preview_downloaded(self, mapname, result): path, is_local = result self.icon = util.THEME.icon(path, is_local) self.setIcon(0, self.icon) - def infoPlayers(self, players): - """ processes information from the server about a replay into readable extra information for the user, - also calls method to show the information """ + def infoPlayers(self): + """ + processes information from the server about a replay into readable + extra information for the user, also calls method to show the + information + """ self.moreInfo = True - self.numberplayers = len(players) + playersList = self.replay['playerStats'] + self.numberplayers = len(playersList) + mvpscore = 0 mvp = None scores = {} - for player in players: # player highscore + for player in playersList: # player highscore if "score" in player: if player["score"] > mvpscore: mvp = player mvpscore = player["score"] - for player in players: # player -> teams & playerscore -> teamscore - if self.mod == "phantomx" or self.mod == "murderparty": # get ffa like into one team + # player -> teams & playerscore -> teamscore + for player in playersList: + # get ffa like into one team + if self.mod == "phantomx" or self.mod == "murderparty": team = 1 else: team = int(player["team"]) @@ -197,16 +288,16 @@ def infoPlayers(self, players): self.teams[team].append(player) if self.numberplayers == len(self.teams): # some kind of FFA - self.teams ={} + self.teams = {} scores = {} team = 1 - for player in players: # player -> team (1) + for player in playersList: # player -> team (1) if team not in self.teams: self.teams[team] = [player] else: self.teams[team].append(player) - if len(self.teams) == 1 or len(self.teams) == len(players): # it's FFA + if len(self.teams) == 1 or len(self.teams) == len(playersList): self.winner = mvp elif len(scores) > 0: # team highscore mvt = 0 @@ -218,8 +309,10 @@ def infoPlayers(self, players): self.generateInfoPlayersHtml() def generateInfoPlayersHtml(self): - """ Creates the ui and extra information about a replay, - Either teamWin or winner must be set if the replay is to be spoiled """ + """ + Creates the ui and extra information about a replay, + Either teamWin or winner must be set if the replay is to be spoiled + """ teams = "" winnerHTML = "" @@ -231,23 +324,49 @@ def generateInfoPlayersHtml(self): if team != -1: i += 1 - if len(self.teams[team]) > self.biggestTeam: # for height of Infobox + if len(self.teams[team]) > self.biggestTeam: self.biggestTeam = len(self.teams[team]) players = "" for player in self.teams[team]: - alignment, playerIcon, playerLabel, playerScore = self.generatePlayerHTML(i, player) - - if self.winner is not None and player["score"] == self.winner["score"] and self.spoiled: - winnerHTML += "%s%s%s" % (playerScore, playerIcon, playerLabel) + alignment, playerIcon, playerLabel, playerScore = ( + self.generatePlayerHTML(i, player) + ) + + if ( + self.winner is not None + and player["score"] == self.winner["score"] + and self.spoiled + ): + winnerHTML += ( + "{}{}{}".format( + playerScore, + playerIcon, + playerLabel, + ) + ) elif alignment == "left": - players += "%s%s%s" % (playerScore, playerIcon, playerLabel) + players += ( + "{}{}{}".format( + playerScore, + playerIcon, + playerLabel, + ) + ) else: # alignment == "right" - players += "%s%s%s" % (playerLabel, playerIcon, playerScore) + players += ( + "{}{}{}".format( + playerLabel, + playerIcon, + playerScore, + ) + ) if self.spoiled: - if self.winner is not None: # FFA in rows: Win ... Lose .... - teams += self.FORMATTER_REPLAY_FFA_SPOILED.format(winner=winnerHTML, players=players) + if self.winner is not None: # FFA in rows: Win... Lose... + teams += self.FORMATTER_REPLAY_FFA_SPOILED.format( + winner=winnerHTML, players=players, + ) else: if "playing" in self.duration: teamTitle = "Playing" @@ -257,22 +376,37 @@ def generateInfoPlayersHtml(self): teamTitle = "Lose" if len(self.teams) == 2: # pack team in - teams += self.FORMATTER_REPLAY_TEAM2_SPOILED.format(title=teamTitle, players=players) + teams += ( + self.FORMATTER_REPLAY_TEAM2_SPOILED.format( + title=teamTitle, players=players, + ) + ) else: # just row on - teams += self.FORMATTER_REPLAY_TEAM_SPOILED.format(title=teamTitle, players=players) + teams += self.FORMATTER_REPLAY_TEAM_SPOILED.format( + title=teamTitle, players=players, + ) else: if len(self.teams) == 2: # pack team in
- teams += self.FORMATTER_REPLAY_TEAM2.format(players=players) + teams += self.FORMATTER_REPLAY_TEAM2.format( + players=players, + ) else: # just row on teams += players if len(self.teams) == 2 and i == 1: # add the 'vs' - teams += "" + teams += ( + "" + ) - if len(self.teams) == 2: # prepare the package to 'fit in' with its %s" % teams + # prepare the package to 'fit in' with its {}".format(teams) - self.replayInfo = self.FORMATTER_REPLAY_INFORMATION.format(uid=self.uid, teams=teams) + self.replayInfo = self.FORMATTER_REPLAY_INFORMATION.format( + uid=self.uid, teams=teams, + ) if self.isSelected(): self.parent.replayInfos.clear() @@ -285,17 +419,39 @@ def generatePlayerHTML(self, i, player): else: alignment = "left" - playerLabel = self.FORMATTER_REPLAY_PLAYER_LABEL.format(player_name=player["name"], - player_rating=player["rating"], alignment=alignment) - - iconUrl = os.path.join(util.COMMON_DIR, "replays/%s.png" % self.retrieveIconFaction(player, self.mod)) - - playerIcon = self.FORMATTER_REPLAY_PLAYER_ICON.format(faction_icon_uri=iconUrl) + if "login" not in player["player"]: + player["player"]["login"] = "No data" + + playerRating = int( + round((player["beforeMean"] - player["beforeDeviation"] * 3) / 100) + * 100, + ) + playerLabel = self.FORMATTER_REPLAY_PLAYER_LABEL.format( + player_name=player["player"]["login"], + player_rating=playerRating, + alignment=alignment, + ) + + iconPath = os.path.join( + util.COMMON_DIR, + "replays/{}.png".format( + self.retrieveIconFaction(player, self.mod), + ), + ) + iconUrl = QtCore.QUrl.fromLocalFile(iconPath).url() + + playerIcon = self.FORMATTER_REPLAY_PLAYER_ICON.format( + faction_icon_uri=iconUrl, + ) if self.spoiled and not self.mod == "ladder1v1": - playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format(player_score=player["score"]) + playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format( + player_score=player["score"], + ) else: # no score for ladder - playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format(player_score=" ") + playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format( + player_score=" ", + ) return alignment, playerIcon, playerLabel, playerScore @@ -331,13 +487,16 @@ def resize(self): if self.extraInfoWidth == 0 or self.extraInfoHeight == 0: if len(self.teams) == 1: # ladder, FFA self.extraInfoWidth = 275 - self.extraInfoHeight = 75 + (self.numberplayers + 1) * 25 # + 1 -> second title + # + 1 -> second title + self.extraInfoHeight = 75 + (self.numberplayers + 1) * 25 elif len(self.teams) == 2: # Team vs Team self.extraInfoWidth = 500 self.extraInfoHeight = 75 + self.biggestTeam * 22 else: # FAF self.extraInfoWidth = 275 - self.extraInfoHeight = 75 + (self.numberplayers + len(self.teams)) * 25 + self.extraInfoHeight = ( + 75 + (self.numberplayers + len(self.teams)) * 25 + ) self.parent.replayInfos.setMinimumWidth(self.extraInfoWidth) self.parent.replayInfos.setMaximumWidth(600) @@ -362,19 +521,19 @@ def display(self, column): return self.viewtext def data(self, column, role): - if role == QtCore.Qt.DisplayRole: + if role == QtCore.Qt.ItemDataRole.DisplayRole: return self.display(column) - elif role == QtCore.Qt.UserRole: + elif role == QtCore.Qt.ItemDataRole.UserRole: return self return super(ReplayItem, self).data(column, role) def permutations(self, items): """ Yields all permutations of the items. """ - if items is []: + if items == []: yield [] else: for i in range(len(items)): - for j in self.permutations(items[:i] + items[i+1:]): + for j in self.permutations(items[:i] + items[i + 1:]): yield [items[i]] + j def __ge__(self, other): @@ -383,7 +542,9 @@ def __ge__(self, other): def __lt__(self, other): """ Comparison operator used for item list sorting """ - if not self.client: return True # If not initialized... - if not other.client: return False + if not self.client: + return True # If not initialized... + if not other.client: + return False # Default: uid return self.uid < other.uid diff --git a/src/secondaryServer/__init__.py b/src/secondaryServer/__init__.py index 108b24a31..3996427ad 100644 --- a/src/secondaryServer/__init__.py +++ b/src/secondaryServer/__init__.py @@ -1 +1,5 @@ from .secondaryserver import SecondaryServer + +__all__ = ( + "SecondaryServer", +) diff --git a/src/secondaryServer/secondaryserver.py b/src/secondaryServer/secondaryserver.py index f54030ed6..49da8bd26 100644 --- a/src/secondaryServer/secondaryserver.py +++ b/src/secondaryServer/secondaryserver.py @@ -1,9 +1,10 @@ -from PyQt5 import QtCore, QtNetwork -import time import json import logging -from config import Settings +from PyQt6 import QtCore +from PyQt6 import QtNetwork + +from config import Settings logger = logging.getLogger(__name__) @@ -12,7 +13,8 @@ def log(string): logger.debug(string) -# A set of exceptions we use to see what goes wrong during asynchronous data transfer waits +# A set of exceptions we use to see what goes wrong during asynchronous data +# transfer waits class Cancellation(Exception): pass @@ -47,7 +49,7 @@ def __init__(self, name, socket, dispatcher, *args, **kwargs): self.name = name - logger = logging.getLogger("faf.secondaryServer.%s" % self.name) + logger = logging.getLogger("faf.secondaryServer.{}".format(self.name)) logger.info("Instantiating secondary server.") self.logger = logger @@ -60,7 +62,7 @@ def __init__(self, name, socket, dispatcher, *args, **kwargs): self.blockSize = 0 self.serverSocket = QtNetwork.QTcpSocket() - self.serverSocket.error.connect(self.handleServerError) + self.serverSocket.errorOccurred.connect(self.handleServerError) self.serverSocket.readyRead.connect(self.readDataFromServer) self.serverSocket.connected.connect(self.send_pending) self.invisible = False @@ -71,10 +73,21 @@ def setInvisible(self): def send(self, command, *args, **kwargs): """ actually do the settings """ - self._requests += [{'command': command, 'args': args, 'kwargs': kwargs}] + self._requests.extend([{ + 'command': command, + 'args': args, + 'kwargs': kwargs, + }]) self.logger.info("Pending requests: {}".format(len(self._requests))) - if not self.serverSocket.state() == QtNetwork.QAbstractSocket.ConnectedState: - self.logger.info("Connecting to {} {}:{}".format(self.name, self.HOST, self.socketPort)) + if not ( + self.serverSocket.state() + == QtNetwork.QAbstractSocket.SocketState.ConnectedState + ): + self.logger.info( + "Connecting to {} {}:{}".format( + self.name, self.HOST, self.socketPort, + ), + ) self.serverSocket.connectToHost(self.HOST, self.socketPort) else: self.send_pending() @@ -111,19 +124,19 @@ def readDataFromServer(self): def writeToServer(self, action, *args, **kw): block = QtCore.QByteArray() - out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite) + out = QtCore.QDataStream(block, QtCore.QIODevice.OpenModeFlag.ReadWrite) out.setVersion(QtCore.QDataStream.Qt_4_2) out.writeUInt32(0) out.writeQString(action) for arg in args: - if type(arg) is int: + if isinstance(arg, int): out.writeInt(arg) elif isinstance(arg, str): out.writeQString(arg) - elif type(arg) is float: + elif isinstance(arg, float): out.writeFloat(arg) - elif type(arg) is list: + elif isinstance(arg, list): out.writeQVariantList(arg) else: out.writeQString(str(arg)) @@ -148,15 +161,26 @@ def receiveJSON(self, data_string, stream): @QtCore.pyqtSlot('QAbstractSocket::SocketError') def handleServerError(self, socketError): """ - Simple error handler that flags the whole operation as failed, not very graceful but what can you do... + Simple error handler that flags the whole operation as failed, not + very graceful but what can you do... """ - if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError: - log("FA Server down: The server is down for maintenance, please try later.") - - elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError: - log("Connection to Host lost. Please check the host name and port settings.") - - elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError: + if socketError == QtNetwork.QAbstractSocket.SocketError.RemoteHostClosedError: + log( + "FA Server down: The server is down for maintenance, please " + "try later.", + ) + + elif socketError == QtNetwork.QAbstractSocket.SocketError.HostNotFoundError: + log( + "Connection to Host lost. Please check the host name and port " + "settings.", + ) + + elif socketError == QtNetwork.QAbstractSocket.SocketError.ConnectionRefusedError: log("The connection was refused by the peer.") else: - log("The following error occurred: %s." % self.serverSocket.errorString()) + log( + "The following error occurred: {}.".format( + self.serverSocket.errorString(), + ), + ) diff --git a/src/stats/__init__.py b/src/stats/__init__.py index 2fde42e57..d20f230c7 100644 --- a/src/stats/__init__.py +++ b/src/stats/__init__.py @@ -1,9 +1,15 @@ -from PyQt5 import QtCore import logging -import urllib.request, urllib.parse, urllib.error -import util -logger = logging.getLogger(__name__) +from stats.itemviews.leaderboardtableview import LeaderboardTableView +from stats.leaderboardlineedit import LeaderboardLineEdit from ._statswidget import StatsWidget + +__all__ = ( + "LeaderboardTableView", + "LeaderboardLineEdit", + "StatsWidget", +) + +logger = logging.getLogger(__name__) diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py index 19a84c8f4..a13bef632 100644 --- a/src/stats/_statswidget.py +++ b/src/stats/_statswidget.py @@ -1,14 +1,15 @@ -from PyQt5 import QtCore, QtWidgets, QtWebEngineWidgets -import util -from stats import mapstat -from config import Settings -import client -from util.qt import injectWebviewCSS +import logging import time +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import util +from api.stats_api import LeaderboardApiConnector from ui.busy_widget import BusyWidget -import logging +from .leaderboard_widget import LeaderboardWidget + logger = logging.getLogger(__name__) ANTIFLOOD = 0.1 @@ -20,197 +21,243 @@ class StatsWidget(BaseClass, FormClass, BusyWidget): # signals laddermaplist = QtCore.pyqtSignal(dict) - laddermapstat = QtCore.pyqtSignal(dict) def __init__(self, client): super(BaseClass, self).__init__() self.setupUi(self) - self.client = client - - self.client.lobby_info.statsInfo.connect(self.processStatsInfos) - self.client = client - self.webview = QtWebEngineWidgets.QWebEngineView() - - self.LadderRatings.layout().addWidget(self.webview) - self.selected_player = None self.selected_player_loaded = False - self.webview.loadFinished.connect(self.webview.show) - self.webview.loadFinished.connect(self._injectCSS) self.leagues.currentChanged.connect(self.leagueUpdate) + self.currentChanged.connect(self.busy_entered) self.pagesDivisions = {} self.pagesDivisionsResults = {} self.pagesAllLeagues = {} - + self.floodtimer = time.time() - + self.currentLeague = 0 self.currentDivision = 0 - - self.FORMATTER_LADDER = str(util.THEME.readfile("stats/formatters/ladder.qthtml")) - self.FORMATTER_LADDER_HEADER = str(util.THEME.readfile("stats/formatters/ladder_header.qthtml")) - util.THEME.setStyleSheet(self.leagues, "stats/formatters/style.css") - + self.FORMATTER_LADDER = str( + util.THEME.readfile("stats/formatters/ladder.qthtml"), + ) + self.FORMATTER_LADDER_HEADER = str( + util.THEME.readfile("stats/formatters/ladder_header.qthtml"), + ) + + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() + # setup other tabs - self.mapstat = mapstat.LadderMapStat(self.client, self) + + self.apiConnector = LeaderboardApiConnector() + self.apiConnector.data_ready.connect(self.process_leaderboards_info) + self.apiConnector.requestData({"sort": "id"}) + + # hiding some non-functional tabs + self.removeTab(self.indexOf(self.ladderTab)) + self.removeTab(self.indexOf(self.laddermapTab)) + + self.leaderboardNames = [] + self.client.authorized.connect(self.onAuthorized) + + def onAuthorized(self): + if not self.leaderboardNames: + self.refreshLeaderboards() + + def refreshLeaderboards(self): + while self.client.replays.leaderboardList.count() != 1: + self.client.replays.leaderboardList.removeItem(1) + self.leaderboards.blockSignals(True) + while self.leaderboards.widget(0) is not None: + self.leaderboards.widget(0).deleteLater() + self.leaderboards.removeTab(0) + self.apiConnector.requestData(dict(sort="id")) + self.leaderboards.blockSignals(False) + + def load_stylesheet(self): + self.setStyleSheet( + util.THEME.readstylesheet("stats/formatters/style.css"), + ) @QtCore.pyqtSlot(int) def leagueUpdate(self, index): self.currentLeague = index + 1 - leagueTab = self.leagues.widget(index).findChild(QtWidgets.QTabWidget,"league"+str(index)) + leagueTab = self.leagues.widget(index).findChild( + QtWidgets.QTabWidget, "league" + str(index), + ) if leagueTab: if leagueTab.currentIndex() == 0: if time.time() - self.floodtimer > ANTIFLOOD: - self.floodtimer = time.time() - self.client.statsServer.send(dict(command="stats", type="league_table", league=self.currentLeague)) + self.floodtimer = time.time() + self.client.statsServer.send( + dict( + command="stats", + type="league_table", + league=self.currentLeague, + ), + ) @QtCore.pyqtSlot(int) def divisionsUpdate(self, index): if index == 0: if time.time() - self.floodtimer > ANTIFLOOD: self.floodtimer = time.time() - self.client.statsServer.send(dict(command="stats", type="league_table", league=self.currentLeague)) - + self.client.statsServer.send( + dict( + command="stats", + type="league_table", + league=self.currentLeague, + ), + ) + elif index == 1: tab = self.currentLeague - 1 if tab not in self.pagesDivisions: - self.client.statsServer.send(dict(command="stats", type="divisions", league=self.currentLeague)) - + self.client.statsServer.send( + dict( + command="stats", + type="divisions", + league=self.currentLeague, + ), + ) + @QtCore.pyqtSlot(int) def divisionUpdate(self, index): if time.time() - self.floodtimer > ANTIFLOOD: self.floodtimer = time.time() - self.client.statsServer.send(dict(command="stats", type="division_table", - league=self.currentLeague, division=index)) - + self.client.statsServer.send( + dict( + command="stats", + type="division_table", + league=self.currentLeague, + division=index, + ), + ) + def createDivisionsTabs(self, divisions): userDivision = "" me = self.client.me.player if me.league is not None: # was me.division, but no there there userDivision = me.league[1] # ? [0]=league and [1]=division - + pages = QtWidgets.QTabWidget() foundDivision = False - + for division in divisions: name = division["division"] index = division["number"] league = division["league"] widget = QtWidgets.QTextBrowser() - + if league not in self.pagesDivisionsResults: self.pagesDivisionsResults[league] = {} - - self.pagesDivisionsResults[league][index] = widget - + + self.pagesDivisionsResults[league][index] = widget + pages.insertTab(index, widget, name) - + if name == userDivision: foundDivision = True pages.setCurrentIndex(index) - self.client.statsServer.send(dict(command="stats", type="division_table", league=league, division=index)) - + self.client.statsServer.send( + dict( + command="stats", + type="division_table", + league=league, + division=index, + ), + ) + if not foundDivision: - self.client.statsServer.send(dict(command="stats", type="division_table", league=league, division=0)) - + self.client.statsServer.send( + dict( + command="stats", + type="division_table", + league=league, + division=0, + ), + ) + pages.currentChanged.connect(self.divisionUpdate) return pages def createResults(self, values, table): - + formatter = self.FORMATTER_LADDER formatter_header = self.FORMATTER_LADDER_HEADER glist = [] append = glist.append - append("
VS" + "VSs - teams = "
s + if len(self.teams) == 2: + teams = "
") - append(formatter_header.format(rank="rank", name="name", score="score", color="#92C1E4")) + append( + "
", + ) + append( + formatter_header.format( + rank="rank", name="name", score="score", color="#92C1E4", + ), + ) for val in values: rank = val["rank"] name = val["name"] score = str(val["score"]) if self.client.login == name: - append(formatter.format(rank=str(rank), name=name, score=score, color="#6CF")) + append( + formatter.format( + rank=str(rank), name=name, score=score, color="#6CF", + ), + ) elif rank % 2 == 0: - append(formatter.format(rank=str(rank), name=name, score=str(val["score"]), color="#F1F1F1")) + append( + formatter.format( + rank=str(rank), name=name, + score=str(val["score"]), color="#F1F1F1", + ), + ) else: - append(formatter.format(rank=str(rank), name=name, score=str(val["score"]), color="#D8D8D8")) + append( + formatter.format( + rank=str(rank), name=name, + score=str(val["score"]), color="#D8D8D8", + ), + ) append("
") html = "".join(glist) table.setHtml(html) - + table.verticalScrollBar().setValue(table.verticalScrollBar().minimum()) return table - @QtCore.pyqtSlot(dict) - def processStatsInfos(self, message): - - typeStat = message["type"] - if typeStat == "divisions": - self.currentLeague = message["league"] - tab = self.currentLeague - 1 - - if tab not in self.pagesDivisions: - self.pagesDivisions[tab] = self.createDivisionsTabs(message["values"]) - leagueTab = self.leagues.widget(tab).findChild(QtWidgets.QTabWidget,"league"+str(tab)) - leagueTab.widget(1).layout().addWidget(self.pagesDivisions[tab]) - - elif typeStat == "division_table": - self.currentLeague = message["league"] - self.currentDivision = message["division"] - - if self.currentLeague in self.pagesDivisionsResults: - if self.currentDivision in self.pagesDivisionsResults[self.currentLeague]: - self.createResults(message["values"], self.pagesDivisionsResults[self.currentLeague][self.currentDivision]) - - elif typeStat == "league_table": - self.currentLeague = message["league"] - tab = self.currentLeague - 1 - if tab not in self.pagesAllLeagues: - table = QtWidgets.QTextBrowser() - self.pagesAllLeagues[tab] = self.createResults(message["values"], table) - leagueTab = self.leagues.widget(tab).findChild(QtWidgets.QTabWidget,"league"+str(tab)) - leagueTab.currentChanged.connect(self.divisionsUpdate) - leagueTab.widget(0).layout().addWidget(self.pagesAllLeagues[tab]) - - elif typeStat == "ladder_map_stat": - self.laddermapstat.emit(message) - - def _injectCSS(self): - if util.THEME.themeurl("ladder/style.css"): - injectWebviewCSS(self.webview.page(), util.THEME.readstylesheet("ladder/style.css")) - - def set_player(self, player): - if self.selected_player != player: - self.selected_player = player - self.selected_player_loaded = False + @QtCore.pyqtSlot(int) + def leaderboardsTabChanged(self, curr): + if self.leaderboards.widget(curr) is not None: + self.leaderboards.widget(curr).entered() + + def process_leaderboards_info(self, message: dict) -> None: + self.leaderboardNames.clear() + for value in message["data"]: + self.leaderboardNames.append(value["technicalName"]) + for index, name in enumerate(self.leaderboardNames): + self.leaderboards.insertTab( + index, + LeaderboardWidget(self.client, self, name), + name.capitalize().replace("_", " "), + ) + self.client.replays.leaderboardList.addItem(name) + self.leaderboards.setCurrentIndex(1) + self.leaderboards.currentChanged.connect(self.leaderboardsTabChanged) @QtCore.pyqtSlot() def busy_entered(self): - # Don't display things when we're not logged in - # FIXME - one day we'll have more obvious points of entry - if self.client.state != client.ClientState.LOGGED_IN: - return - - if self.selected_player is None: - self.selected_player = self.client.players[self.client.login] - if self.selected_player.league is not None: - self.leagues.setCurrentIndex(self.selected_player.league - 1) - else: - self.leagues.setCurrentIndex(5) # -> 5 = direct to Ladder Ratings - - if self.selected_player_loaded: - return - - self.webview.setVisible(False) - self.webview.setUrl(QtCore.QUrl("{}/faf/leaderboards/read-leader.php?board=1v1&username={}". - format(Settings.get('content/host'), self.selected_player.login))) - self.selected_player_loaded = True + if self.currentIndex() == self.indexOf(self.leaderboardsTab): + self.leaderboards.currentChanged.emit( + self.leaderboards.currentIndex(), + ) diff --git a/src/stats/itemviews/__init__.py b/src/stats/itemviews/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/stats/itemviews/leaderboarditemdelegate.py b/src/stats/itemviews/leaderboarditemdelegate.py new file mode 100644 index 000000000..3142963ed --- /dev/null +++ b/src/stats/itemviews/leaderboarditemdelegate.py @@ -0,0 +1,30 @@ +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import QRect +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter +from PyQt6.QtWidgets import QStyleOptionViewItem + +from qt.itemviews.tableitemdelegte import TableItemDelegate +from util.qt import qpainter + + +class LeaderboardItemDelegate(TableItemDelegate): + def paint( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex, + ) -> None: + opt = self._customize_style_option(option, index) + text = opt.text + + with qpainter(painter): + self._draw_clear_option(painter, opt) + self._set_pen(painter, opt) + if index.column() == 0: + rect = QRect(opt.rect) + rect.setLeft(int(opt.rect.left() + opt.rect.width() // 2.125)) + alignment_flags = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + painter.drawText(rect, alignment_flags, text) + else: + painter.drawText(opt.rect, Qt.AlignmentFlag.AlignCenter, text) diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py new file mode 100644 index 000000000..50c14287f --- /dev/null +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -0,0 +1,109 @@ +from enum import Enum + +from PyQt6 import QtWidgets +from PyQt6.QtGui import QAction + + +class LeaderboardTableMenuItems(Enum): + VIEW_ALIASES = "View aliases" + VIEW_REPLAYS = "View Replays in Vault" + ADD_FRIEND = "Add friend" + ADD_FOE = "Add foe" + REMOVE_FRIEND = "Remove friend" + REMOVE_FOE = "Remove foe" + COPY_USERNAME = "Copy username" + + +class LeaderboardTableMenu: + def __init__(self, parent, client, leaderboardName): + self.parent = parent + self.client = client + self.leaderboardName = leaderboardName + + @classmethod + def build(cls, parent, client, leaderboardName): + return cls(parent, client, leaderboardName) + + def actions(self, name, uid): + yield list(self.usernameActions()) + yield list(self.playerActions()) + + if self.client.me.player is None: + return + + is_me = self.client.me.id == uid + yield list(self.friendActions(name, uid, is_me)) + + def usernameActions(self): + yield LeaderboardTableMenuItems.COPY_USERNAME + yield LeaderboardTableMenuItems.VIEW_ALIASES + + def playerActions(self): + yield LeaderboardTableMenuItems.VIEW_REPLAYS + + def friendActions(self, name, uid, is_me): + if is_me: + return + + if self.client.me.relations.model.is_friend(uid, name): + yield LeaderboardTableMenuItems.REMOVE_FRIEND + elif self.client.me.relations.model.is_foe(uid, name): + yield LeaderboardTableMenuItems.REMOVE_FOE + else: + yield LeaderboardTableMenuItems.ADD_FRIEND + yield LeaderboardTableMenuItems.ADD_FOE + + def getMenu(self, name: str, uid: int) -> QtWidgets.QMenu: + menu = QtWidgets.QMenu(self.parent) + + def addEntry(item): + action = QAction(item.value, menu) + action.triggered.connect(self.handler(name, uid, item)) + menu.addAction(action) + + first = True + for category in self.actions(name, uid): + if not category: + continue + if not first: + menu.addSeparator() + for item in category: + addEntry(item) + first = False + return menu + + def handler(self, name, uid, kind): + Items = LeaderboardTableMenuItems + if kind == Items.COPY_USERNAME: + return lambda: self.copyUsername(name) + elif kind == Items.VIEW_ALIASES: + return lambda: self.viewAliases(name) + elif kind == Items.VIEW_REPLAYS: + return lambda: self.viewReplays(name) + elif kind in [ + Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, + Items.REMOVE_FOE, + ]: + return lambda: self.handleFriends(uid, kind) + + def copyUsername(self, name): + QtWidgets.QApplication.clipboard().setText(name) + + def viewAliases(self, name): + self.client._alias_viewer.view_aliases(name) + + def viewReplays(self, name): + self.client.view_replays(name, self.leaderboardName) + + def handleFriends(self, uid, kind): + ctl = self.client.me.relations.controller.faf + + Items = LeaderboardTableMenuItems + if kind == Items.ADD_FRIEND: + ctl.friends.add(uid) + elif kind == Items.REMOVE_FRIEND: + ctl.friends.remove(uid) + if kind == Items.ADD_FOE: + ctl.foes.add(uid) + elif kind == Items.REMOVE_FOE: + ctl.foes.remove(uid) diff --git a/src/stats/itemviews/leaderboardtableview.py b/src/stats/itemviews/leaderboardtableview.py new file mode 100644 index 000000000..69d78bd63 --- /dev/null +++ b/src/stats/itemviews/leaderboardtableview.py @@ -0,0 +1,30 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QCursor +from PyQt6.QtGui import QMouseEvent + +from qt.itemviews.tableview import TableView +from stats.itemviews.leaderboardtablemenu import LeaderboardTableMenu + + +class LeaderboardTableView(TableView): + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.RightButton: + row = self.indexAt(event.pos()).row() + if row != -1: + name_index = self.model().index(row, 0) + id_index = self.model().index(row, 8) + name = self.model().data(name_index) + uid = int(self.model().data(id_index)) + self.selectRow(row) + self.context_menu(name, uid) + self.update_hover_row(event) + self.verticalHeader().update_hover_section(event) + else: + TableView.mousePressEvent(self, event) + + def context_menu(self, name: str, uid: int) -> None: + client = self.parent().parent().client + leaderboardName = self.parent().parent().leaderboardName + menuHandler = LeaderboardTableMenu.build(self, client, leaderboardName) + menu = menuHandler.getMenu(name, uid) + menu.popup(QCursor.pos()) diff --git a/src/stats/leaderboard_widget.py b/src/stats/leaderboard_widget.py new file mode 100644 index 000000000..a10dc476d --- /dev/null +++ b/src/stats/leaderboard_widget.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import util +from api.player_api import PlayerApiConnector +from api.stats_api import LeaderboardRatingApiConnector +from config import Settings + +from .itemviews.leaderboarditemdelegate import LeaderboardItemDelegate +from .models.leaderboardfiltermodel import LeaderboardFilterModel +from .models.leaderboardtablemodel import LeaderboardTableModel + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + +FormClass, BaseClass = util.THEME.loadUiType("stats/leaderboard.ui") + +DATE_FORMAT = QtCore.Qt.DateFormat.ISODate + + +class LeaderboardWidget(BaseClass, FormClass): + + def __init__( + self, + client: ClientWindow, + parent: QtWidgets.QWidget, + leaderboardName: str, + *args, + **kwargs, + ) -> None: + super(BaseClass, self).__init__() + + self.setupUi(self) + + self.model = None + + self.client = client + self.parent = parent + self.leaderboardName = leaderboardName + self.apiConnector = LeaderboardRatingApiConnector(self.leaderboardName) + self.apiConnector.data_ready.connect(self.process_rating_info) + self.playerApiConnector = PlayerApiConnector() + self.onlyActive = True + self.pageNumber = 1 + self.totalPages = 1 + self.pageSize = 1000 + self.query = dict( + include="player,leaderboard", + sort="-rating", + filter=self.prepareFilters(), + ) + + self.onlyActiveCheckBox.stateChanged.connect( + self.onlyActiveCheckBoxChange, + ) + self.onlyActiveCheckBox.setChecked(True) + + self.nextButton.clicked.connect( + lambda: self.getPage(self.pageNumber + 1), + ) + self.previousButton.clicked.connect( + lambda: self.getPage(self.pageNumber - 1), + ) + self.lastButton.clicked.connect(lambda: self.getPage(self.totalPages)) + self.firstButton.clicked.connect(lambda: self.getPage(1)) + self.goToPageButton.clicked.connect( + lambda: self.getPage(self.pageBox.value()), + ) + self.pageBox.setValue(self.pageNumber) + self.pageBox.valueChanged.connect(self.checkTotalPages) + self.refreshButton.clicked.connect(self.refreshLeaderboard) + + self.findInPageLine.textChanged.connect(self.findEntry) + self.findInPageLine.returnPressed.connect( + lambda: self.findEntry(self.findInPageLine.text()), + ) + + self.searchPlayerLine.textEdited.connect(self.searchPlayer) + self.searchPlayerLine.returnPressed.connect( + self.searchPlayerInLeaderboard, + ) + self.searchPlayerButton.clicked.connect(self.searchPlayerInLeaderboard) + + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self.resetLoading) + + self.loading = False + + self.showColumnCheckBoxes = [ + self.showName, + self.showRating, + self.showMean, + self.showDeviation, + self.showGames, + self.showWon, + self.showWinRate, + self.showUpdated, + ] + + self.shownColumns = Settings.get( + "leaderboards/shownColumns", + default=[True for i in range(len(self.showColumnCheckBoxes))], + type=bool, + ) + + self.showAllColumns = Settings.get( + "leaderboards/showAllColumns", + default=True, + type=bool, + ) + + self.showAllCheckBox.setChecked(self.showAllColumns) + self.showAllCheckBox.stateChanged.connect(self.showAllCheckBoxChange) + + for index, checkbox in enumerate(self.showColumnCheckBoxes): + if self.showAllColumns: + checkbox.setChecked(False) + checkbox.setEnabled(False) + else: + checkbox.setChecked(self.shownColumns[index]) + checkbox.setEnabled(True) + checkbox.stateChanged.connect(self.setShownColumns) + + self.tableView.horizontalHeader().setSectionResizeMode( + QtWidgets.QHeaderView.ResizeMode.Stretch, + ) + self.tableView.horizontalHeader().setFixedHeight(30) + self.tableView.horizontalHeader().setHighlightSections(False) + self.tableView.horizontalHeader().setSortIndicatorShown(True) + self.tableView.horizontalHeader().setSectionsMovable(True) + + def showAllCheckBoxChange(self, state): + self.showAllColumns = True if state else False + Settings.set("leaderboards/showAllColumns", self.showAllColumns) + self.showColumns() + + def setShownColumns(self): + for i in range(len(self.showColumnCheckBoxes)): + self.shownColumns[i] = self.showColumnCheckBoxes[i].isChecked() + Settings.set("leaderboards/shownColumns", self.shownColumns) + self.showColumns() + + def showColumns(self): + self.tableView.setColumnHidden(8, True) + + self.showAllCheckBox.blockSignals(True) + self.showAllCheckBox.setChecked(self.showAllColumns) + self.showAllCheckBox.blockSignals(False) + + for index, isShown in enumerate(self.shownColumns): + self.showColumnCheckBoxes[index].blockSignals(True) + + if self.showAllColumns: + self.tableView.setColumnHidden(index, False) + self.showColumnCheckBoxes[index].setChecked(False) + self.showColumnCheckBoxes[index].setEnabled(False) + else: + self.tableView.setColumnHidden(index, not isShown) + self.showColumnCheckBoxes[index].setEnabled(True) + self.showColumnCheckBoxes[index].setChecked(isShown) + + self.showColumnCheckBoxes[index].blockSignals(False) + + def process_rating_info(self, message: dict) -> None: + if message["leaderboard"] == self.leaderboardName: + self.createLeaderboard(message) + self.processMeta(message["meta"]) + self.resetLoading() + self.timer.stop() + + def createLeaderboard(self, data): + self.model = LeaderboardTableModel(data) + self.findInPageLine.set_completion_list(self.model.logins) + proxyModel = LeaderboardFilterModel(self.model) + proxyModel.setSourceModel(self.model) + self.tableView.verticalHeader().setModel(proxyModel) + self.tableView.setModel(proxyModel) + self.tableView.setItemDelegate(LeaderboardItemDelegate(self)) + + completer = QtWidgets.QCompleter( + sorted(self.model.logins, key=lambda login: login.lower()), + ) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + completer.popup().setStyleSheet( + "background: rgb(32, 32, 37); color: orange;", + ) + self.findInPageLine.setCompleter(completer) + + self.showColumns() + + def processMeta(self, meta): + totalPages = meta["page"]["totalPages"] + self.totalPages = totalPages if totalPages > 0 else 1 + self.labelTotalPages.setText(str(self.totalPages)) + + pageNumber = meta["page"]["number"] + self.pageNumber = pageNumber if pageNumber > 0 else 1 + self.pageBox.setValue(self.pageNumber) + + def resetLoading(self): + self.loading = False + self.labelLoading.clear() + + def findEntry(self, text): + if self.model is not None: + for row in self.model.logins: + if row.lower() == text.lower(): + self.tableView.selectRow(self.model.logins.index(row)) + break + + def searchPlayer(self) -> None: + query = { + "filter": 'login=="{}*"'.format(self.searchPlayerLine.text()), + "page[size]": 10, + } + self.playerApiConnector.get_by_query(query, self.createPlayerCompleter) + + def createPlayerCompleter(self, message: dict) -> None: + logins = [player["login"] for player in message["data"]] + self.searchPlayerLine.set_completion_list(logins) + completer = QtWidgets.QCompleter( + sorted(logins, key=lambda login: login.lower()), + ) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + completer.popup().setStyleSheet( + "background: rgb(32, 32, 37); color: orange;", + ) + self.searchPlayerLine.setCompleter(completer) + completer.complete() + + def onlyActiveCheckBoxChange(self, state): + self.onlyActive = state + if state: + self.dateEditStart.setEnabled(False) + self.dateEditEnd.setEnabled(False) + else: + self.dateEditStart.setEnabled(True) + self.dateEditEnd.setEnabled(True) + + date = QtCore.QDate.currentDate() + self.dateEditStart.setDate(date) + self.dateEditEnd.setDate(date) + + def prepareFilters(self): + filters = [ + 'leaderboard.technicalName=="{}"'.format(self.leaderboardName), + ] + + if self.onlyActive: + filters.append( + 'updateTime=ge="{}"'.format( + QtCore.QDateTime + .currentDateTimeUtc() + .addMonths(-1) + .toString(DATE_FORMAT), + ), + ) + else: + filters.append( + 'updateTime=ge="{}"'.format( + self.dateEditStart + .dateTime() + .toUTC() + .toString(DATE_FORMAT), + ), + ) + filters.append( + 'updateTime=le="{}"'.format( + self.dateEditEnd + .dateTime() + .toUTC() + .toString(DATE_FORMAT), + ), + ) + + return "({})".format(";".join(filters)) + + def refreshLeaderboard(self): + self.findInPageLine.clear() + self.searchPlayerLine.clear() + self.pageSize = self.quantityBox.value() + self.query["filter"] = self.prepareFilters() + self.getPage(1) + + def searchPlayerInLeaderboard(self, player=None): + filters = [ + 'leaderboard.technicalName=="{}"'.format(self.leaderboardName), + ] + if player: + self.searchPlayerLine.setText(player.login) + if self.searchPlayerLine.text() != "": + filters.append( + 'player.login=="{}"'.format(self.searchPlayerLine.text()), + ) + self.query["filter"] = "({})".format(";".join(filters)) + self.getPage(1) + + def checkTotalPages(self): + if self.pageBox.value() > self.totalPages: + self.pageBox.setValue(self.totalPages) + + def getPage(self, number): + if self.loading: + QtWidgets.QMessageBox.critical( + self.client, + "Leaderboards", + "Please, wait for previous request to finish.", + ) + return + + if 1 <= number <= self.totalPages: + self.query["page[size]"] = self.pageSize + self.query["page[number]"] = number + self.query["page[totals]"] = "yes" + + self.apiConnector.requestData(self.query) + self.labelLoading.setText("Loading...") + self.loading = True + self.timer.start(40000) + + def entered(self): + if self.model is None and not self.loading: + self.getPage(1) + + self.shownColumns = Settings.get( + "leaderboards/shownColumns", + default=[True for i in range(len(self.showColumnCheckBoxes))], + type=bool, + ) + self.showAllColumns = Settings.get( + "leaderboards/showAllColumns", + default=True, + type=bool, + ) + + self.showColumns() diff --git a/src/stats/leaderboardlineedit.py b/src/stats/leaderboardlineedit.py new file mode 100644 index 000000000..bc0d2b63e --- /dev/null +++ b/src/stats/leaderboardlineedit.py @@ -0,0 +1,63 @@ +from PyQt6 import QtCore +from PyQt6 import QtWidgets + + +# TODO: probably create a common ancestor of ChatLineEdit and this +class LeaderboardLineEdit(QtWidgets.QLineEdit): + def __init__(self, parent): + super().__init__(parent) + self.completionStarted = False + self.currenLocalChatter = None + self.LocalNameList = [] + self.completionList = None + + def set_completion_list(self, list_): + self.completionList = list_ + + def event(self, event): + if event.type() == QtCore.QEvent.Type.KeyPress: + # Swallow a selection of keypresses that we want for our history + # support. + if event.key() == QtCore.Qt.Key.Key_Tab: + self.try_completion() + return True + else: + self.cancel_completion() + return QtWidgets.QLineEdit.event(self, event) + + # All other events (non-keypress) + return QtWidgets.QLineEdit.event(self, event) + + def try_completion(self): + if not self.completionStarted: + # no completion on empty line + if self.text() == "": + return + # no completion if last character is a space + if self.text().rfind(" ") == (len(self.text()) - 1): + return + + self.completionStarted = True + self.LocalNameList = [] + + # make a copy of users because the list might change frequently + # giving all kind of problems + if self.completionList is not None: + for name in self.completionList: + if name.lower().startswith(self.text().lower()): + self.LocalNameList.append(name) + + if len(self.LocalNameList) > 0: + self.LocalNameList.sort(key=lambda login: login.lower()) + self.currenLoginIndex = 0 + self.setText(self.LocalNameList[self.currenLoginIndex]) + else: + self.currenLoginIndex = None + else: + if self.currenLoginIndex is not None: + self.currenLoginIndex += 1 + self.currenLoginIndex %= len(self.LocalNameList) + self.setText(self.LocalNameList[self.currenLoginIndex]) + + def cancel_completion(self): + self.completionStarted = False diff --git a/src/stats/mapstat.py b/src/stats/mapstat.py deleted file mode 100644 index fece6242e..000000000 --- a/src/stats/mapstat.py +++ /dev/null @@ -1,186 +0,0 @@ - -import util -from PyQt5 import QtWidgets, QtCore, QtGui -import json -import datetime -from fa import maps - -FormClass, BaseClass = util.THEME.loadUiType("stats/mapstat.ui") - - -class LadderMapStat(FormClass, BaseClass): - """ - This class list all the maps given by the server, and ask for more details when selected. - """ - def __init__(self, client, parent, *args, **kwargs): - BaseClass.__init__(self, client, *args, **kwargs) - - self.setupUi(self) - - self.parent = parent - self.client = client - - self.mapid = 0 - - # adding ourself to the stat tab - - self.parent.laddermapTab.layout().addWidget(self) - - self.parent.laddermaplist.connect(self.updatemaps) - self.parent.laddermapstat.connect(self.updatemapstat) - - self.maplist.itemClicked.connect(self.mapselected) - - def getSeasonDate(self): - now = datetime.date.today() - - if (now.month == 3 and now.day < 21) or now.month < 3: - previous = datetime.datetime(now.year-1, 12, 21) - - elif (now.month == 6 and now.day < 21) or now.month < 6: - - previous = datetime.datetime(now.year, 0o3, 21) - - elif (now.month == 9 and now.day < 21) or now.month < 9: - - previous = datetime.datetime(now.year, 0o6, 21) - - else: - - previous = datetime.datetime(now.year, 12, 21) - - return previous.strftime('%d %B %Y') - - @QtCore.pyqtSlot(dict) - def updatemapstat(self, message): - """ fill all the data for that map """ - - if message["idmap"] != self.mapid: - return - - values = message["values"] - - draws = values["draws"] - - uef_total = values["uef_total"] - uef_win = values["uef_win"] - uef_ignore = values["uef_ignore"] - - cybran_total = values["cybran_total"] - cybran_win = values["cybran_win"] - cybran_ignore = values["cybran_ignore"] - - aeon_total = values["aeon_total"] - aeon_win = values["aeon_win"] - aeon_ignore = values["aeon_ignore"] - - sera_total = values["sera_total"] - sera_win = values["sera_win"] - sera_ignore = values["sera_ignore"] - - duration_max = values["duration_max"] - duration_avg = values["duration_avg"] - - avgm, avgs = divmod(duration_avg, 60) - averagetime = "%02d minutes %02d seconds" % (avgm, avgs) - - maxm, maxs = divmod(duration_max, 60) - maxtime = "%02d minutes %02d seconds" % (maxm, maxs) - - game_played = values["game_played"] - - if game_played == 0: - game_played = 1 - - self.mapstats.insertHtml("
" + str(game_played) + " games played on this map
") - - self.mapstats.insertHtml("
" + str(round(float(draws)/float(game_played), 2)) + - "% of the games end with a draw ("+str(draws) + " games)
") - - self.mapstats.insertHtml("
Average time for a game : " + averagetime + "
") - self.mapstats.insertHtml("
Maximum time for a game : " + maxtime + "
") - - totalFaction = float(uef_total + cybran_total + aeon_total + sera_total) - if totalFaction == 0: - totalFaction = 1 - - percentUef = round((uef_total / totalFaction) * 100.0, 2) - percentAeon = round((aeon_total / totalFaction) * 100.0, 2) - percentCybran = round((cybran_total / totalFaction) * 100.0, 2) - percentSera = round((sera_total / totalFaction) * 100.0, 2) - - self.mapstats.insertHtml("
" + str(percentUef) + " % UEF ("+str(uef_total) + " occurrences) ") - self.mapstats.insertHtml("
" + str(percentCybran) + " % Cybran ("+str(cybran_total) + " occurrences) ") - self.mapstats.insertHtml("
" + str(percentAeon) + " % Aeon ("+str(aeon_total) + " occurrences) ") - self.mapstats.insertHtml("
" + str(percentSera) + " % Seraphim ("+str(sera_total) + " occurrences)
") - - # if a win was ignored, it's because of a mirror matchup. - # No win count, but we have to remove 2 times the occurences. - # once for each player.. - - uefnomirror = (float(uef_total) - float(uef_ignore) * 2) - cybrannomirror = (float(cybran_total) - float(cybran_ignore) * 2) - aeonnomirror = (float(aeon_total) - float(aeon_ignore) * 2) - seranomirror = (float(sera_total) - float(sera_ignore) * 2) - - if uefnomirror == 0: - uefnomirror = 1 - - if cybrannomirror == 0: - cybrannomirror = 1 - - if aeonnomirror == 0: - aeonnomirror = 1 - - if seranomirror == 0: - seranomirror = 1 - - percentwinUef = round((uef_win / uefnomirror) * 100.0, 2) - percentwinCybran = round((cybran_win / cybrannomirror) * 100.0, 2) - percentwinAeon = round((aeon_win / aeonnomirror) * 100.0, 2) - percentwinSera = round((sera_win / seranomirror) * 100.0, 2) - - self.mapstats.insertHtml("
Win ratios : ") - self.mapstats.insertHtml("
UEF : " + str(percentwinUef) + " % ("+str(uef_win) + - " games won in "+str(int(uefnomirror)) + " no mirror matchup games)") - self.mapstats.insertHtml("
Cybran : " + str(percentwinCybran) + " % ("+str(cybran_win) + - " games won in " + str(int(cybrannomirror)) + " no mirror matchup games)") - self.mapstats.insertHtml("
Aeon : " + str(percentwinAeon) + " % ("+str(aeon_win) + - " games won in " + str(int(aeonnomirror)) + " no mirror matchup games)") - self.mapstats.insertHtml("
Seraphim : " + str(percentwinSera) + " % ("+str(sera_win) + - " games won in " + str(int(seranomirror)) + " no mirror matchup games)") - - def mapselected(self, item): - """ user has selected a map, we send the request to the server """ - - self.mapstats.clear() - self.mapid = item.data(32)[0] - realmap = item.data(32)[1].split("/")[1][:-4] - - self.mapstats.document().addResource(QtGui.QTextDocument.ImageResource, QtCore.QUrl("map.png"), - maps.preview(realmap, True, force=True)) - - self.mapstats.insertHtml("
" + item.text() + "

") - self.client.statsServer.send(dict(command="stats", type="ladder_map_stat", mapid=self.mapid)) - - @QtCore.pyqtSlot(dict) - def updatemaps(self, message): - - self.maps = message["values"] - - self.mapstats.insertHtml("Stats since : %s" % self.getSeasonDate()) - self.mapstats.insertHtml("
Number of game played :
%i " % message["gamesplayed"]) - - # clearing current map list - self.maplist.clear() - - for mp in self.maps: - mapid = mp["idmap"] - name = mp["mapname"] - realname = mp["maprealname"] - - item = QtWidgets.QListWidgetItem(name) - item.setData(32, (mapid, realname)) - self.maplist.addItem(item) - - self.maplist.sortItems(0) diff --git a/src/stats/models/__init__.py b/src/stats/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/stats/models/leaderboardfiltermodel.py b/src/stats/models/leaderboardfiltermodel.py new file mode 100644 index 000000000..487751b66 --- /dev/null +++ b/src/stats/models/leaderboardfiltermodel.py @@ -0,0 +1,32 @@ +import re + +from PyQt6 import QtCore + + +class LeaderboardFilterModel(QtCore.QSortFilterProxyModel): + def lessThan(self, leftIndex, rightIndex): + column = leftIndex.column() + leftData = self.sourceModel().data(leftIndex) + rightData = self.sourceModel().data(rightIndex) + + if column == 0: # Name + return leftData < rightData + elif column == 1: # Rating + return int(leftData) < int(rightData) + elif column == 2: # Mean + return float(leftData) < float(rightData) + elif column == 3: # Deviation + return float(leftData) < float(rightData) + elif column == 4: # Total Games + return int(leftData) < int(rightData) + elif column == 5: # Won Games + return int(leftData) < int(rightData) + elif column == 6: # Win rate + percentageLeft = float(re.sub(r"[^\d.]", "", str(leftData))) + percentageRight = float(re.sub(r"[^\d.]", "", str(rightData))) + return percentageLeft < percentageRight + elif column == 7: # Updated + return leftData < rightData + + def headerData(self, section, orientation, role): + return self.sourceModel().headerData(section, orientation, role) diff --git a/src/stats/models/leaderboardtablemodel.py b/src/stats/models/leaderboardtablemodel.py new file mode 100644 index 000000000..c01c95d06 --- /dev/null +++ b/src/stats/models/leaderboardtablemodel.py @@ -0,0 +1,79 @@ +from PyQt6.QtCore import QAbstractTableModel +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt + + +class LeaderboardTableModel(QAbstractTableModel): + def __init__(self, data=None): + QAbstractTableModel.__init__(self) + self.load_data(data) + + def load_data(self, data: dict) -> None: + self.values = data["data"] + self.meta = data["meta"] + self.logins = [] + for value in self.values: + self.logins.append(value["player"]["login"]) + self.column_count = 9 + self.row_count = len(data["data"]) + + def rowCount(self, parent=QModelIndex()): + return self.row_count + + def columnCount(self, parent=QModelIndex()): + return self.column_count + + def headerData(self, section, orientation, role): + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return ( + "Name", "Rating", "Mean", "Deviation", "Games", "Won", + "Win rate", "Updated", "Player Id", + )[section] + else: + return "{}".format( + int(self.meta["page"]["number"] - 1) + * int(self.meta["page"]["limit"]) + + section + + 1, + ) + elif role == Qt.ItemDataRole.TextAlignmentRole: + return Qt.AlignmentFlag.AlignCenter + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + column = index.column() + row = index.row() + + if role == Qt.ItemDataRole.DisplayRole: + if column == 0: + return "{}".format(self.values[row]["player"]["login"]) + elif column == 1: + return "{}".format(int(self.values[row]["rating"])) + elif column == 2: + return "{:.2f}".format(round(self.values[row]["mean"], 2)) + elif column == 3: + return "{:.2f}".format(round(self.values[row]["deviation"], 2)) + elif column == 4: + return "{}".format(self.values[row]["totalGames"]) + elif column == 5: + return "{}".format(self.values[row]["wonGames"]) + elif column == 6: + if self.values[row]["totalGames"] == 0: + return "{:.2f}%".format(0) + else: + return "{:.2f}%".format( + 100 + * self.values[row]["wonGames"] + / self.values[row]["totalGames"], + ) + elif column == 7: + dateUTC = QDateTime.fromString( + self.values[row]["updateTime"], Qt.DateFormat.ISODate, + ) + dateLocal = dateUTC.toLocalTime().toString("yyyy-MM-dd") + return "{}".format(dateLocal) + elif column == 8: + return "{}".format(self.values[row]["player"]["id"]) + + return None diff --git a/src/tourneys/__init__.py b/src/tourneys/__init__.py index 313298325..6ed377d0b 100644 --- a/src/tourneys/__init__.py +++ b/src/tourneys/__init__.py @@ -1,6 +1,9 @@ -from PyQt5 import QtCore import logging -logger = logging.getLogger(__name__) - from ._tournamentswidget import TournamentsWidget + +__all__ = ( + "TournamentsWidget", +) + +logger = logging.getLogger(__name__) diff --git a/src/tourneys/_tournamentswidget.py b/src/tourneys/_tournamentswidget.py index ba20d8aa1..96718dcca 100644 --- a/src/tourneys/_tournamentswidget.py +++ b/src/tourneys/_tournamentswidget.py @@ -1,43 +1,52 @@ -from PyQt5 import QtCore, QtWidgets -import util -import secondaryServer +from PyQt6 import QtCore +from PyQt6 import QtWidgets -from tourneys.tourneyitem import TourneyItem, TourneyItemDelegate +import secondaryServer +import util +from tourneys.tourneyitem import TourneyItem +from tourneys.tourneyitem import TourneyItemDelegate FormClass, BaseClass = util.THEME.loadUiType("tournaments/tournaments.ui") class TournamentsWidget(FormClass, BaseClass): """ list and manage the main tournament lister """ - + def __init__(self, client, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - + BaseClass.__init__(self, *args, **kwargs) + self.setupUi(self) self.client = client - + # tournament server - self.tourneyServer = secondaryServer.SecondaryServer("Tournament", 11001, self) + self.tourneyServer = secondaryServer.SecondaryServer( + "Tournament", 11001, self, + ) self.tourneyServer.setInvisible() # Dictionary containing our actual tournaments. self.tourneys = {} - + self.tourneyList.setItemDelegate(TourneyItemDelegate(self)) - + self.tourneyList.itemDoubleClicked.connect(self.tourneyDoubleClicked) - + self.tourneysTab = {} - # Special stylesheet - util.THEME.setStyleSheet(self, "tournaments/formatters/style.css") + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() self.updateTimer = QtCore.QTimer(self) self.updateTimer.timeout.connect(self.updateTournaments) self.updateTimer.start(600000) + def load_stylesheet(self): + self.setStyleSheet( + util.THEME.readstylesheet("tournaments/formatters/style.css"), + ) + def showEvent(self, event): self.updateTournaments() return BaseClass.showEvent(self, event) @@ -51,24 +60,42 @@ def tourneyDoubleClicked(self, item): Slot that attempts to join or leave a tournament. """ if self.client.login not in item.playersname: - reply = QtWidgets.QMessageBox.question(self.client, "Register", - "Do you want to register to this tournament ?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) - if reply == QtWidgets.QMessageBox.Yes: - self.tourneyServer.send(dict(command="add_participant", uid=item.uid, login=self.client.login)) + reply = QtWidgets.QMessageBox.question( + self.client, + "Register", + "Do you want to register to this tournament ?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + ) + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.tourneyServer.send( + dict( + command="add_participant", + uid=item.uid, + login=self.client.login, + ), + ) else: - reply = QtWidgets.QMessageBox.question(self.client, "Register", - "Do you want to leave this tournament ?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) - if reply == QtWidgets.QMessageBox.Yes: - self.tourneyServer.send(dict(command="remove_participant", uid=item.uid, login=self.client.login)) + reply = QtWidgets.QMessageBox.question( + self.client, + "Register", + "Do you want to leave this tournament ?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + ) + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.tourneyServer.send( + dict( + command="remove_participant", + uid=item.uid, + login=self.client.login, + ), + ) def handle_tournaments_info(self, message): - #self.tourneyList.clear() + # self.tourneyList.clear() tournaments = message["data"] - for uid in tournaments : - if not uid in self.tourneys : + for uid in tournaments: + if uid not in self.tourneys: self.tourneys[uid] = TourneyItem(self, uid) self.tourneyList.addItem(self.tourneys[uid]) self.tourneys[uid].update(tournaments[uid], self.client) diff --git a/src/tourneys/tourneyitem.py b/src/tourneys/tourneyitem.py index 1ca92737e..abeb4b552 100644 --- a/src/tourneys/tourneyitem.py +++ b/src/tourneys/tourneyitem.py @@ -1,68 +1,70 @@ -from PyQt5 import QtCore, QtWidgets, QtGui, QtWebEngineWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + import util class TourneyItemDelegate(QtWidgets.QStyledItemDelegate): - #colors = json.loads(util.THEME.readfile("client/colors.json")) - + # colors = json.loads(util.THEME.readfile("client/colors.json")) + def __init__(self, *args, **kwargs): QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) self.height = 125 - + def paint(self, painter, option, index, *args, **kwargs): self.initStyleOption(option, index) - + painter.save() - + html = QtGui.QTextDocument() html.setHtml(option.text) if self.height < html.size().height(): self.height = html.size().height() - + option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) - + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) + # Description painter.translate(option.rect.left(), option.rect.top()) - #painter.fillRect(QtCore.QRect(0, 0, option.rect.width(), option.rect.height()), QtGui.QColor(36, 61, 75, 150)) + # painter.fillRect(QtCore.QRect(0, 0, option.rect.width(), + # option.rect.height()), QtGui.QColor(36, 61, 75, 150)) clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height()) html.drawContents(painter, clip) - + painter.restore() def sizeHint(self, option, index, *args, **kwargs): self.initStyleOption(option, index) html = QtGui.QTextDocument() html.setHtml(option.text) - return QtCore.QSize(int(html.size().width()), int(html.size().height())) - - -class QWebPageChrome(QtWebEngineWidgets.QWebEnginePage): - def __init__(self, *args, **kwargs): - QtWebEngineWidgets.QWebEnginePage.__init__(self, *args, **kwargs) - - def userAgentForUrl(self, url): - return "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.2 (KHTML, like Gecko) Chrome/15.0.874.121 Safari/535.2" + return QtCore.QSize( + int(html.size().width()), int(html.size().height()), + ) class TourneyItem(QtWidgets.QListWidgetItem): - FORMATTER_SWISS_OPEN = str(util.THEME.readfile("tournaments/formatters/open.qthtml")) - + FORMATTER_SWISS_OPEN = str( + util.THEME.readfile("tournaments/formatters/open.qthtml"), + ) + def __init__(self, parent, uid, *args, **kwargs): QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) self.uid = int(uid) self.parent = parent - - self.type = None + + self.type = None self.client = None self.title = None self.description = None self.state = None self.players = [] self.playersname = [] - + self.viewtext = "" self.height = 40 self.setHidden(True) @@ -74,7 +76,7 @@ def update(self, message, client): self.client = client old_state = self.state self.state = message.get('state', "close") - + """ handling the listing of the tournament """ self.title = message['name'] self.type = message['type'] @@ -83,35 +85,44 @@ def update(self, message, client): self.players = message.get('participants', []) if old_state != self.state and self.state == "started": - widget = QtWebEngineWidgets.QWebEngineView() - webPage = QWebPageChrome() - widget.setPage(webPage) - widget.setUrl(QtCore.QUrl(self.url)) - self.parent.topTabs.addTab(widget, self.title) + # create a widget and add it to the parent's tabs + # anyway, this tournaments feature most likely won't return + ... self.playersname = [] for player in self.players: self.playersname.append(player["name"]) - if old_state != self.state and self.state == "started" and player["name"] == self.client.login: + if ( + old_state != self.state + and self.state == "started" + and player["name"] == self.client.login + ): channel = "#" + self.title.replace(" ", "_") - self.client.autoJoin.emit([channel]) - QtWidgets.QMessageBox.information(self.client, "Tournament started !", - "Your tournament has started !\n" - "You have automatically joined the tournament channel.") + self.client.auto_join.emit([channel]) + QtWidgets.QMessageBox.information( + self.client, + "Tournament started !", + ( + "Your tournament has started !\n" + "You have automatically joined the tournament channel." + ), + ) playerstring = "
".join(self.playersname) - self.viewtext = self.FORMATTER_SWISS_OPEN.format(title=self.title, description=self.description, - numreg=str(len(self.players)), playerstring=playerstring) + self.viewtext = self.FORMATTER_SWISS_OPEN.format( + title=self.title, description=self.description, + numreg=str(len(self.players)), playerstring=playerstring, + ) self.setText(self.viewtext) def display(self): return self.viewtext def data(self, role): - if role == QtCore.Qt.DisplayRole: - return self.display() - elif role == QtCore.Qt.UserRole: + if role == QtCore.Qt.ItemDataRole.DisplayRole: + return self.display() + elif role == QtCore.Qt.ItemDataRole.UserRole: return self return super(TourneyItem, self).data(role) diff --git a/src/turn_client.py b/src/turn_client.py index 1519c8194..c38d423f4 100644 --- a/src/turn_client.py +++ b/src/turn_client.py @@ -1,5 +1,7 @@ import signal -from PyQt5.QtCore import QCoreApplication, QTimer + +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtCore import QTimer from .connectivity import QTurnSocket @@ -17,4 +19,4 @@ def sigint_handler(*args): timer.timeout.connect(lambda: None) c = QTurnSocket() c.run() - app.exec_() + app.exec() diff --git a/src/tutorials/__init__.py b/src/tutorials/__init__.py index f89a86d78..d494ff256 100644 --- a/src/tutorials/__init__.py +++ b/src/tutorials/__init__.py @@ -1,7 +1,10 @@ -from PyQt5 import QtCore import logging -logger = logging.getLogger(__name__) - from ._tutorialswidget import TutorialsWidget + +__all__ = ( + "TutorialsWidget", +) + +logger = logging.getLogger(__name__) diff --git a/src/tutorials/_tutorialswidget.py b/src/tutorials/_tutorialswidget.py index 9f4446ec7..f32f94b99 100644 --- a/src/tutorials/_tutorialswidget.py +++ b/src/tutorials/_tutorialswidget.py @@ -1,12 +1,17 @@ -from PyQt5 import QtCore, QtWidgets -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply -from fa.replay import replay -import util +import logging import os + +from PyQt6 import QtCore +from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + import fa -from tutorials.tutorialitem import TutorialItem, TutorialItemDelegate +import util +from tutorials.tutorialitem import TutorialItem +from tutorials.tutorialitem import TutorialItemDelegate -import logging logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("tutorials/tutorials.ui") @@ -14,7 +19,7 @@ class TutorialsWidget(FormClass, BaseClass): def __init__(self, client, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) + BaseClass.__init__(self, *args, **kwargs) self.setupUi(self) @@ -28,12 +33,14 @@ def __init__(self, client, *args, **kwargs): logger.info("Tutorials instantiated.") def finishReplay(self, reply): - if reply.error() != QNetworkReply.NoError: - QtWidgets.QMessageBox.warning(self, "Network Error", reply.errorString()) + if reply.error() != QNetworkReply.NetworkError.NoError: + QtWidgets.QMessageBox.warning( + self, "Network Error", reply.errorString(), + ) else: filename = os.path.join(util.CACHE_DIR, str("tutorial.fafreplay")) replay = QtCore.QFile(filename) - replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text) + replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.Text) replay.write(reply.readAll()) replay.close() @@ -43,7 +50,7 @@ def tutorialClicked(self, item): self.nam = QNetworkAccessManager() self.nam.finished.connect(self.finishReplay) - self.nam.get(QNetworkRequest(QtCore.QUrl(item.url))) + self.nam.get(QNetworkRequest(QtCore.QUrl(item.url))) def processTutorialInfo(self, message): """ @@ -58,7 +65,7 @@ def processTutorialInfo(self, message): desc = message["description"] area = util.THEME.loadUi("tutorials/tutorialarea.ui") - tabIndex = self.addTab(area, section) + tabIndex = self.addTab(area, section) self.setTabToolTip(tabIndex, desc) # Set up the List that contains the tutorial items diff --git a/src/tutorials/tutorialitem.py b/src/tutorials/tutorialitem.py index 802eabcaa..9e6063275 100644 --- a/src/tutorials/tutorialitem.py +++ b/src/tutorials/tutorialitem.py @@ -1,61 +1,86 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from fa import maps +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + import util from config import Settings -from downloadManager import PreviewDownloadRequest +from downloadManager import DownloadRequest +from fa import maps class TutorialItemDelegate(QtWidgets.QStyledItemDelegate): - + def __init__(self, *args, **kwargs): QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) - + def paint(self, painter, option, index, *args, **kwargs): self.initStyleOption(option, index) - + painter.save() - + html = QtGui.QTextDocument() html.setHtml(option.text) - + icon = QtGui.QIcon(option.icon) iconsize = icon.actualSize(option.rect.size()) - - # clear icon and text before letting the control draw itself because we're rendering these parts ourselves + + # clear icon and text before letting the control draw itself because + # we're rendering these parts ourselves option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget) - + option.text = "" + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) + # Shadow - painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1, iconsize.width(), iconsize.height(), - QtGui.QColor("#202020")) + painter.fillRect( + option.rect.left() + 7, option.rect.top() + 7, + iconsize.width(), iconsize.height(), QtGui.QColor("#202020"), + ) # Icon - icon.paint(painter, option.rect.adjusted(5-2, -2, 0, 0), QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) - + icon.paint( + painter, option.rect.adjusted(3, -2, 0, 0), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + # Frame around the icon pen = QtGui.QPen() pen.setWidth(1) - pen.setBrush(QtGui.QColor("#303030")) # FIXME: This needs to come from theme. - pen.setCapStyle(QtCore.Qt.RoundCap) + # FIXME: This needs to come from theme. + pen.setBrush(QtGui.QColor("#303030")) + + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) painter.setPen(pen) - painter.drawRect(option.rect.left()+5-2, option.rect.top()+5-2, iconsize.width(), iconsize.height()) + painter.drawRect( + option.rect.left() + 3, option.rect.top() + 3, + iconsize.width(), iconsize.height(), + ) # Description - painter.translate(option.rect.left() + iconsize.width() + 10, option.rect.top()+10) - clip = QtCore.QRectF(0, 0, option.rect.width()-iconsize.width() - 10 - 5, option.rect.height()) + painter.translate( + option.rect.left() + iconsize.width() + 10, option.rect.top() + 10, + ) + clip = QtCore.QRectF( + 0, 0, option.rect.width() - iconsize.width() - 15, + option.rect.height(), + ) html.drawContents(painter, clip) - + painter.restore() def sizeHint(self, option, index, *args, **kwargs): self.initStyleOption(option, index) - + html = QtGui.QTextDocument() html.setHtml(option.text) html.setTextWidth(TutorialItem.TEXTWIDTH) - return QtCore.QSize(TutorialItem.ICONSIZE + TutorialItem.TEXTWIDTH + TutorialItem.PADDING, TutorialItem.ICONSIZE) + return QtCore.QSize( + TutorialItem.ICONSIZE + + TutorialItem.TEXTWIDTH + + TutorialItem.PADDING, TutorialItem.ICONSIZE, + ) class TutorialItem(QtWidgets.QListWidgetItem): @@ -64,19 +89,21 @@ class TutorialItem(QtWidgets.QListWidgetItem): PADDING = 10 WIDTH = ICONSIZE + TEXTWIDTH - #DATA_PLAYERS = 32 + # DATA_PLAYERS = 32 - FORMATTER_TUTORIAL = str(util.THEME.readfile("tutorials/formatters/tutorials.qthtml")) + FORMATTER_TUTORIAL = str( + util.THEME.readfile("tutorials/formatters/tutorials.qthtml"), + ) def __init__(self, uid, *args, **kwargs): QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) self.mapname = None - self.mapdisplayname = None + self.mapdisplayname = None self.client = None self.title = None - - self._map_dl_request = PreviewDownloadRequest() + + self._map_dl_request = DownloadRequest() self._map_dl_request.done.connect(self._on_map_preview_downloaded) def update(self, message, client): @@ -87,7 +114,9 @@ def update(self, message, client): self.client = client self.tutorial = message['tutorial'] self.description = message['description'] - self.url = "{}/faf/tutorials/{}".format(Settings.get('content/host'), message['url']) + self.url = "{}/faf/tutorials/{}".format( + Settings.get('content/host'), message['url'], + ) # Map preview code if self.mapname != message['mapname']: @@ -97,12 +126,19 @@ def update(self, message, client): icon = maps.preview(self.mapname) if not icon: icon = util.THEME.icon("games/unknown_map.png") - self.client.map_downloader.download_preview(self.mapname, self._map_dl_request) + self.client.map_preview_downloader.download_preview( + self.mapname, self._map_dl_request, + ) self.setIcon(icon) - self.setText(self.FORMATTER_TUTORIAL.format(mapdisplayname=self.mapdisplayname, - title=self.tutorial, description=self.description)) + self.setText( + self.FORMATTER_TUTORIAL.format( + mapdisplayname=self.mapdisplayname, + title=self.tutorial, + description=self.description, + ), + ) def _on_map_preview_downloaded(self, mapname, result): path, is_local = result @@ -114,7 +150,7 @@ def permutations(self, items): yield [] else: for i in range(len(items)): - for j in self.permutations(items[:i] + items[i+1:]): + for j in self.permutations(items[:i] + items[i + 1:]): yield [items[i]] + j def __ge__(self, other): diff --git a/src/ui/__init__.py b/src/ui/__init__.py index 8b1378917..e69de29bb 100644 --- a/src/ui/__init__.py +++ b/src/ui/__init__.py @@ -1 +0,0 @@ - diff --git a/src/ui/busy_widget.py b/src/ui/busy_widget.py index 6a899f31f..f0940c389 100644 --- a/src/ui/busy_widget.py +++ b/src/ui/busy_widget.py @@ -3,6 +3,7 @@ class BusyWidget(object): Represents a widget that has to do some heavier lifting while running / shown (like some main tabs). """ + def __init__(self): pass diff --git a/src/ui/status_logo.py b/src/ui/status_logo.py index c3ac4829b..79ad0075d 100644 --- a/src/ui/status_logo.py +++ b/src/ui/status_logo.py @@ -1,27 +1,36 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QLabel, QAction, QMenu +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QLabel +from PyQt6.QtWidgets import QMenu + import util -from client import ClientState +from client.clientstate import ClientState class StatusLogo(QLabel): disconnect_requested = pyqtSignal() reconnect_requested = pyqtSignal() + chat_reconnect_requested = pyqtSignal() about_dialog_requested = pyqtSignal() connectivity_dialog_requested = pyqtSignal() - def __init__(self, client, logo_file='window_icon.png'): + def __init__(self, client, chat_model, logo_file='window_icon.png'): QLabel.__init__(self) + self._chat_model = chat_model self.state = client.state self.setScaledContents(True) self.setMargin(3) - normal, yellow, red = list(map(util.THEME.pixmap, [ - 'window_icon.png', - 'window_icon_yellow.png', - 'window_icon_red.png' - ])) + normal, yellow, red = list( + map( + util.THEME.pixmap, [ + 'window_icon.png', + 'window_icon_yellow.png', + 'window_icon_red.png', + ], + ), + ) self._pixmaps = { ClientState.SHUTDOWN: red, @@ -49,23 +58,33 @@ def contextMenuEvent(self, event): dc = QAction('Disconnect', None) rc = QAction('Reconnect', None) + crc = QAction('Reconnect with chat', None) + conn = QAction('Connectivity', None) about = QAction('About', None) if self.state != ClientState.DISCONNECTED: menu.addAction(dc) + if not self._chat_model.connected: + menu.addAction(crc) if self.state not in [ ClientState.CONNECTING, ClientState.CONNECTED, - ClientState.LOGGED_IN]: + ClientState.LOGGED_IN, + ]: menu.addAction(rc) + menu.addAction(conn) menu.addAction(about) - action = menu.exec_(self.mapToGlobal(event.pos())) + action = menu.exec(self.mapToGlobal(event.pos())) if action == dc: self.disconnect_requested.emit() elif action == rc: self.reconnect_requested.emit() + elif action == crc: + self.chat_reconnect_requested.emit() + elif action == conn: + self.connectivity_dialog_requested.emit() elif action == about: self.about_dialog_requested.emit() diff --git a/src/unitdb/__init__.py b/src/unitdb/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/unitdb/unitdbtab.py b/src/unitdb/unitdbtab.py new file mode 100644 index 000000000..580c86580 --- /dev/null +++ b/src/unitdb/unitdbtab.py @@ -0,0 +1,29 @@ +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices + +import util +from config import Settings + +FormClass, BaseClass = util.THEME.loadUiType("unitdb/unitdb.ui") + + +class UnitDbView(FormClass, BaseClass): + def __init__(self) -> None: + super(BaseClass, self).__init__() + self.setupUi(self) + + +class UnitDBTab: + def __init__(self) -> None: + self.db_widget = UnitDbView() + self._db_url = QUrl(Settings.get("UNITDB_URL")) + self._db_url_alt = QUrl(Settings.get("UNITDB_SPOOKY_URL")) + + self.db_widget.fafDbButton.pressed.connect(self.open_default_tab) + self.db_widget.spookyDbButton.pressed.connect(self.open_alternative_tab) + + def open_default_tab(self) -> None: + QDesktopServices.openUrl(self._db_url) + + def open_alternative_tab(self) -> None: + QDesktopServices.openUrl(self._db_url_alt) diff --git a/src/updater/__init__.py b/src/updater/__init__.py new file mode 100644 index 000000000..29714d5d3 --- /dev/null +++ b/src/updater/__init__.py @@ -0,0 +1,62 @@ +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QDialog +from PyQt6.QtWidgets import QMessageBox +from semantic_version import Version + +from updater.base import Releases +from updater.base import UpdateChecker +from updater.base import UpdateNotifier +from updater.base import UpdateSettings +from updater.widgets import UpdateDialog +from updater.widgets import UpdateSettingsDialog + + +class ClientUpdateTools(QObject): + mandatory_update_aborted = pyqtSignal() + + def __init__( + self, update_settings, checker, notifier, dialog, parent_widget, + ): + QObject.__init__(self) + self.update_settings = update_settings + self.checker = checker + self.notifier = notifier + self.dialog = dialog + self.parent_widget = parent_widget + self.notifier.update.connect(self._handle_update) + + @classmethod + def build(cls, current_version, parent_widget, network_manager): + current_version = Version(current_version) + update_settings = UpdateSettings() + checker = UpdateChecker.build( + current_version=current_version, + settings=update_settings, + network_manager=network_manager, + ) + notifier = UpdateNotifier(update_settings, checker) + dialog = UpdateDialog.build( + update_settings, parent_widget, current_version, + network_manager=network_manager, + ) + return cls(update_settings, checker, notifier, dialog, parent_widget) + + def _handle_update(self, releases: Releases, mandatory: bool) -> None: + branch = self.update_settings.updater_branch.to_reltype() + versions = releases.versions( + branch, self.update_settings.updater_downgrade, + ) + if not versions: + QMessageBox.information( + self.parent_widget, "No updates found", + "No client updates were found.", + ) + return + self.dialog.setup(releases) + result = self.dialog.exec() + if result is QDialog.DialogCode.Rejected and mandatory: + self.mandatory_update_aborted.emit() + + def settings_dialog(self): + return UpdateSettingsDialog(self.parent_widget, self.update_settings) diff --git a/src/updater/base.py b/src/updater/base.py new file mode 100644 index 000000000..51752a33f --- /dev/null +++ b/src/updater/base.py @@ -0,0 +1,275 @@ +import json +from enum import Enum + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest +from semantic_version import Version + +from config import Settings +from decorators import with_logger + + +class UpdateChannel(Enum): + Stable = 0 + Prerelease = 1 + Unstable = 2 + + def to_reltype(self): + d = { + UpdateChannel.Stable: ReleaseType.STABLE, + UpdateChannel.Prerelease: ReleaseType.PRERELEASE, + UpdateChannel.Unstable: ReleaseType.UNSTABLE, + } + return d[self] + + +class ReleaseType(Enum): + STABLE = "stable" + PRERELEASE = "pre" + UNSTABLE = "beta" + MINIMUM = "server" + + @classmethod + def get(cls, version): + if version < Version("0.18.0"): + return cls.legacy_versioning(version) + else: + return cls.new_versioning(version) + + @classmethod + def legacy_versioning(cls, version): + if version.minor % 2 == 1: + return cls.UNSTABLE + else: + if version.prerelease == (): + return cls.STABLE + else: + return cls.PRERELEASE + + @classmethod + def new_versioning(cls, version): + for tuple_value in version.prerelease: + if any(p in tuple_value for p in ['alpha', 'beta']): + return cls.UNSTABLE + if any(p in tuple_value for p in ['pre', 'rc']): + return cls.PRERELEASE + return cls.STABLE + + def included_channels(self): + order = [ + ReleaseType.MINIMUM, + ReleaseType.STABLE, + ReleaseType.PRERELEASE, + ReleaseType.UNSTABLE, + ] + for item in order: + yield item + if item == self: + break + + +class Release: + def __init__(self, version, installer, branch=None): + self.version = version + self.installer = installer + self._branch = branch + + @property + def branch(self): + if self._branch is not None: + return self._branch + return ReleaseType.get(self.version) + + def __lt__(self, other): + return self.version < other.version + + +class Releases: + def __init__(self, release_list, current_version): + self._current_version = current_version + self.branches = {} + for branch in ReleaseType: + b_releases = [r for r in release_list if r.branch == branch] + b_releases.sort(reverse=True) + self.branches[branch] = b_releases + + def newest(self, channel): + try: + return max(self.versions(channel)) + except ValueError: + return None + + def versions(self, channel, show_older=False): + current = self._current_version + eligible_channels = channel.included_channels() + versions = [v for c in eligible_channels for v in self.branches[c]] + if not show_older: + versions = filter(lambda r: r.version > current, versions) + else: + versions = filter( + lambda r: r.version > current or r.version < current, + versions, + ) + versions = list(versions) + versions.sort(reverse=True) + return versions + + def mandatory_update(self): + return self.optional_update(ReleaseType.MINIMUM) + + def optional_update(self, channel): + newest = self.newest(channel) + return newest is not None and newest.version > self._current_version + + +class UpdateSettings: + _updater_branch = Settings.persisted_property( + 'updater/branch', + type=str, + default_value=UpdateChannel.Prerelease.name, + ) + updater_downgrade = Settings.persisted_property( + 'updater/downgrade', type=bool, default_value=False, + ) + gh_releases_url = Settings.persisted_property( + 'updater/gh_release_url', + type=str, + default_value=( + 'https://api.github.com/repos/FAForever/' + 'client/releases?per_page=20' + ), + ) + changelog_url = Settings.persisted_property( + 'updater/changelog_url', + type=str, + default_value='https://github.com/FAForever/client/releases/tag', + ) + + def __init__(self): + pass + + @property + def updater_branch(self): + try: + return UpdateChannel[self._updater_branch] + except ValueError: + return UpdateChannel.Prerelease + + @updater_branch.setter + def updater_branch(self, value): + self._updater_branch = value.name + + +class GithubUpdateChecker(QObject): + finished = pyqtSignal() + + def __init__(self, settings, network_manager): + QObject.__init__(self) + self._settings = settings + self._network_manager = network_manager + self._rep = None + self.releases = None + self.done = False + + @classmethod + def builder(cls, settings, network_manager, **kwargs): + def build(): + return cls(settings, network_manager) + return build + + def start(self): + gh_url = QUrl(self._settings.gh_releases_url) + self._rep = self._network_manager.get(QNetworkRequest(gh_url)) + self._rep.finished.connect(self._req_done) + + def _req_done(self): + self.done = True + self.releases = self._process_response(self._rep) + self.finished.emit() + + def _process_response(self, rep): + if rep.error() != QNetworkReply.NetworkError.NoError: + return None + release_data = bytes(self._rep.readAll()) + try: + releases = json.loads(release_data.decode('utf-8')) + except (UnicodeError, json.JSONDecodeError): + self._logger.exception( + "Error parsing network reply: {}".format(repr(release_data)), + ) + return None + return list(self._parse_releases(releases)) + + def _parse_releases(self, releases): + if not isinstance(releases, list): + releases = [releases] + for release_dict in releases: + for asset in release_dict['assets']: + if '.msi' in asset['browser_download_url']: + download_url = asset['browser_download_url'] + version = Version(release_dict['tag_name']) + yield Release(version, download_url) + + +@with_logger +class UpdateChecker(QObject): + finished = pyqtSignal(object, bool) + + def __init__(self, current_version, github_check_builder): + QObject.__init__(self) + self._current_version = current_version + self._github_check_builder = github_check_builder + self._github_checker = None + self.releases = None + self._always_notify = False + + @classmethod + def build(cls, current_version, **kwargs): + github_check_builder = GithubUpdateChecker.builder(**kwargs) + return cls(current_version, github_check_builder) + + def check(self, always_notify=False): + self._always_notify = always_notify + self._start_github_check() + + def _start_github_check(self): + self._github_checker = self._github_check_builder() + self._github_checker.finished.connect(self._check_all_checks_finished) + self._github_checker.start() + + def _check_all_checks_finished(self): + if self._github_checker is None: + return + if not self._github_checker.done: + return + self._set_releases() + self.finished.emit(self.releases, self._always_notify) + + def _set_releases(self): + releases = [] + if self._github_checker.releases is not None: + releases.extend(self._github_checker.releases) + self.releases = Releases(releases, self._current_version) + + +class UpdateNotifier(QObject): + update = pyqtSignal(object, bool) + + def __init__(self, settings, update_checker): + QObject.__init__(self) + self._settings = settings + self._update_checker = update_checker + self._update_checker.finished.connect(self._notify_if_needed) + + def _notify_if_needed(self, releases, force=False): + if force: + self.update.emit(releases, False) + elif releases.mandatory_update(): + self.update.emit(releases, True) + elif releases.optional_update( + self._settings.updater_branch.to_reltype(), + ): + self.update.emit(releases, False) diff --git a/src/updater/process.py b/src/updater/process.py new file mode 100644 index 000000000..a5f4d01f8 --- /dev/null +++ b/src/updater/process.py @@ -0,0 +1,114 @@ +import os +import subprocess +import tempfile + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest + +import client +from decorators import with_logger + + +@with_logger +class ClientUpdater(QObject): + + finished = pyqtSignal() + + def __init__(self, parent, progress_bar, cancel_btn, network_manager): + QObject.__init__(self, parent) + self._progress = None + self._network_manager = network_manager + self._progress_bar = progress_bar + self._cancel_btn = cancel_btn + self._tmp = None + self._req = None + self._rep = None + + @classmethod + def builder(cls, network_manager, **kwargs): + def build(parent, progress_bar, cancel_btn): + return cls(parent, progress_bar, cancel_btn, network_manager) + return build + + def exec_(self, url): + self._logger.info('Downloading {}'.format(url)) + self._setup_progress() + self._prepare_download(url) + + def _prepare_download(self, url): + self._logger.debug('_prepare_download') + self._tmp = tempfile.NamedTemporaryFile( + mode='w+b', + suffix=".msi", + delete=False, + ) + self._req = QNetworkRequest(QUrl(url)) + self._rep = self._network_manager.get(self._req) + self._rep.setReadBufferSize(0) + self._rep.downloadProgress.connect(self._on_progress) + self._rep.finished.connect(self._on_finished) + self._rep.errorOccurred.connect(self.error) + self._rep.readyRead.connect(self._buffer) + self._rep.sslErrors.connect(self.ssl_error) + + def ssl_error(self, errors): + estrings = [e.errorString() for e in errors] + self._logger.error('ssl errors: {}'.format(estrings)) + self._rep.ignoreSslErrors() + + def error(self, code): + self._logger.error(self._rep.errorString()) + + def _buffer(self): + self._tmp.write(self._rep.read(self._rep.bytesAvailable())) + + def _on_finished(self): + self._logger.debug('_on_finished') + assert self._tmp + assert self._rep.atEnd() + if self._rep.error() != QNetworkReply.NetworkError.NoError: + self._logger.error(self._rep.errorString()) + return # FIXME - handle + + self._tmp.close() + + redirected = self._rep.attribute( + QNetworkRequest.RedirectionTargetAttribute, + ) + if redirected is not None: + self._logger.debug('redirected to {}'.format(redirected)) + os.remove(self._tmp.name) + if redirected.isRelative(): + url = self._rep.url().resolved(redirected) + else: + url = redirected + self._prepare_download(url) + else: + self._run_installer() + + def _run_installer(self): + command = 'msiexec /i "{msiname}" & del "{msiname}"'.format( + msiname=self._tmp.name, + ) + self._logger.debug(r'Running msi installation command: ' + command) + subprocess.Popen(command, shell=True) + client.instance.close() + + def _on_progress(self, bytesReceived, bytesTotal): + # only show for "real" download, i.e. bytesTotal > 5MB + if (bytesTotal > 5 * 1024**2): + self._progress_bar.setMaximum(bytesTotal) + self._progress_bar.setValue(bytesReceived) + + def cancel(self): + self._rep.abort() + self.finished.emit() + + def _setup_progress(self): + self._cancel_btn.show() + self._progress_bar.show() + self._progress_bar.setValue(0) + self._cancel_btn.clicked.connect(self.cancel) diff --git a/src/updater/widgets.py b/src/updater/widgets.py new file mode 100644 index 000000000..f4a1961fd --- /dev/null +++ b/src/updater/widgets.py @@ -0,0 +1,135 @@ +from PyQt6.QtWidgets import QLayout + +import util +from decorators import with_logger +from updater.base import ReleaseType +from updater.base import UpdateChannel +from updater.process import ClientUpdater + +FormClass, BaseClass = util.THEME.loadUiType("client/update.ui") + + +@with_logger +class UpdateDialog(FormClass, BaseClass): + def __init__( + self, settings, parent_widget, current_version, updater_builder, + ): + BaseClass.__init__(self, parent_widget) + self._settings = settings + self._current_version = current_version + self._updater_builder = updater_builder + self.setModal(True) + self.setupUi(self) + self.btnStart.clicked.connect(self.startUpdate) + self.btnAbort.clicked.connect(self.abort) + self.btnSettings.clicked.connect(self.showSettings) + self.cbReleases.currentIndexChanged.connect(self.indexChanged) + self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + + @classmethod + def build(cls, settings, parent_widget, current_version, **kwargs): + updater_builder = ClientUpdater.builder(**kwargs) + return cls(settings, parent_widget, current_version, updater_builder) + + def setup(self, releases): + self._releases = releases + self.reset_controls() + + def reset_controls(self): + self.pbDownload.hide() + self.btnCancel.hide() + self.btnAbort.setEnabled(True) + + branch = self._settings.updater_branch.to_reltype() + if self._releases.mandatory_update(): + text = 'Your client version is outdated - you must update to play.' + elif self._releases.optional_update(branch): + text = 'Client updates were found.' + else: + text = 'Client releases were found.' + self.lblUpdatesFound.setText(text) + + versions = self._releases.versions( + branch, self._settings.updater_downgrade, + ) + newest_version = self._releases.newest(branch) + + labels = { + ReleaseType.MINIMUM: 'Server Version', + ReleaseType.STABLE: 'Stable Version', + ReleaseType.PRERELEASE: 'Stable Prerelease', + ReleaseType.UNSTABLE: 'Unstable', + } + + self.cbReleases.blockSignals(True) + self.cbReleases.clear() + for rel in versions: + new = ' [New!]' if rel.version > self._current_version else '' + name = '{} {}{}'.format(labels[rel.branch], rel.version, new) + self.cbReleases.addItem(name, rel) + preferred_idx = self.cbReleases.findData(newest_version) + if preferred_idx != -1: + self.cbReleases.setCurrentIndex(preferred_idx) + self.indexChanged(preferred_idx) + self.cbReleases.blockSignals(False) + + if len(versions) > 0: + self.btnStart.setEnabled(True) + + def indexChanged(self, index): + release = self.cbReleases.itemData(index) + self.lblInfo.setText(self._format_changelog(release.version)) + + def _format_changelog(self, version): + if version is not None: + return "Release Info".format( + self._settings.changelog_url, version, + ) + else: + return 'Not available' + + def startUpdate(self): + release = self.cbReleases.itemData(self.cbReleases.currentIndex()) + url = release.installer + self.btnStart.setEnabled(False) + self.btnAbort.setEnabled(False) + client_updater = self._updater_builder( + parent=self, + progress_bar=self.pbDownload, + cancel_btn=self.btnCancel, + ) + client_updater.finished.connect(self.finishUpdate) + client_updater.exec_(url) + + def finishUpdate(self): + self.reset_controls() + + def abort(self): + self.close() + + def showSettings(self): + dialog = UpdateSettingsDialog(self, self._settings) + dialog.finished.connect(self.reset_controls) + dialog.show() + + +FormClass, BaseClass = util.THEME.loadUiType("client/update_settings.ui") + + +@with_logger +class UpdateSettingsDialog(FormClass, BaseClass): + def __init__(self, parent_widget, settings): + BaseClass.__init__(self, parent_widget) + self._settings = settings + self.setModal(True) + self.setupUi(self) + self.cbChannel.setCurrentIndex(self._settings.updater_branch.value) + self.cbDowngrade.setChecked(self._settings.updater_downgrade) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(lambda: self.close()) + + def accept(self): + branch = UpdateChannel(self.cbChannel.currentIndex()) + self._settings.updater_branch = branch + self._settings.updater_downgrade = self.cbDowngrade.isChecked() + super().accept() diff --git a/src/util/__init__.py b/src/util/__init__.py index f9475f21e..c00d702a3 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -1,38 +1,41 @@ -import sys -import os +import datetime import getpass -import codecs - -from PyQt5.QtWidgets import QMessageBox -from PyQt5.QtGui import QIcon, QPixmap, QDesktopServices -from PyQt5.QtCore import QUrl -from PyQt5.QtMultimedia import QSound +import hashlib +import logging +import os +import re +import shutil import subprocess +import sys -from semantic_version import Version -from util.theme import Theme, ThemeSet +from PyQt6 import QtWidgets +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QStandardPaths +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import QMessageBox +import fafpath +from config import VERSION as VERSION_STRING from config import Settings -from PyQt5.QtCore import QStandardPaths +from config import _settings # Stolen from Config because reasons +from mapGenerator import mapgenUtils +from util.theme import Theme +from util.theme import ThemeSet + if sys.platform == 'win32': - import win32serviceutil import win32service + import win32serviceutil - -# Developer mode flag -def developer(): - return sys.executable.endswith("python.exe") - -from config import VERSION as VERSION_STRING - -import logging logger = logging.getLogger(__name__) LOGFILE_MAX_SIZE = 256 * 1024 # 256kb should be enough for anyone -UNITS_PREVIEW_ROOT = "{}/faf/unitsDB/icons/big/".format(Settings.get('content/host')) +UNITS_PREVIEW_ROOT = ( + "{}/faf/unitsDB/icons/big/".format(Settings.get('content/host')) +) -import fafpath COMMON_DIR = fafpath.get_resdir() stylesheets = {} # map [qt obj] -> filename of stylesheet @@ -45,12 +48,21 @@ def developer(): # This contains the themes THEME_DIR = os.path.join(APPDATA_DIR, "themes") -# This contains cached data downloaded while communicating with the lobby - at the moment, mostly map preview pngs. +# This contains cached data downloaded while communicating with the lobby CACHE_DIR = os.path.join(APPDATA_DIR, "cache") -MAP_PREVIEW_DIR = os.path.join(CACHE_DIR, "map_previews") +# Use one cache with Java client (maps/small and maps/large) +MAP_PREVIEW_SMALL_DIR = os.path.join(CACHE_DIR, "maps", "small") +MAP_PREVIEW_LARGE_DIR = os.path.join(CACHE_DIR, "maps", "large") + MOD_PREVIEW_DIR = os.path.join(CACHE_DIR, "mod_previews") +# Cache for news images +NEWS_CACHE_DIR = os.path.join(CACHE_DIR, "news") + +# This contains cached game files +GAME_CACHE_DIR = os.path.join(CACHE_DIR, "featured_mod") + # This contains cached data downloaded for FA extras EXTRA_DIR = os.path.join(APPDATA_DIR, "extra") @@ -60,22 +72,24 @@ def developer(): # This contains all Lobby, Chat and Game logs LOG_DIR = os.path.join(APPDATA_DIR, "logs") LOG_FILE_FAF = os.path.join(LOG_DIR, 'forever.log') +LOG_FILE_MAPGEN = os.path.join(LOG_DIR, 'map_generator.log') LOG_FILE_GAME_PREFIX = os.path.join(LOG_DIR, 'game') LOG_FILE_GAME = LOG_FILE_GAME_PREFIX + ".log" LOG_FILE_GAME_INFIX = ".uid." LOG_FILE_REPLAY = os.path.join(LOG_DIR, 'replay.log') -# This contains the game binaries (old binFAF folder) and the game mods (.faf files) +# This contains the game binaries (old binFAF folder) and the game mods +# (.faf files) BIN_DIR = os.path.join(APPDATA_DIR, "bin") GAMEDATA_DIR = os.path.join(APPDATA_DIR, "gamedata") REPO_DIR = os.path.join(APPDATA_DIR, "repo") +# This contains java executables of map generators +MAPGEN_DIR = os.path.join(APPDATA_DIR, "map_generator") + if not os.path.exists(REPO_DIR): os.makedirs(REPO_DIR) -# Public settings object -# Stolen from Config because reasons -from config import _settings settings = _settings # initialize wine settings for non Windows platforms @@ -87,14 +101,25 @@ def developer(): else: wine_prefix = os.path.join(os.path.expanduser("~"), ".wine") -LOCALFOLDER = os.path.join(os.path.expandvars("%LOCALAPPDATA%"), "Gas Powered Games", - "Supreme Commander Forged Alliance") +LOCALFOLDER = os.path.join( + os.path.expandvars("%LOCALAPPDATA%"), + "Gas Powered Games", + "Supreme Commander Forged Alliance", +) if not os.path.exists(LOCALFOLDER): - LOCALFOLDER = os.path.join(os.path.expandvars("%USERPROFILE%"), "Local Settings", "Application Data", - "Gas Powered Games", "Supreme Commander Forged Alliance") + LOCALFOLDER = os.path.join( + os.path.expandvars("%USERPROFILE%"), + "Local Settings", "Application Data", + "Gas Powered Games", + "Supreme Commander Forged Alliance", + ) if not os.path.exists(LOCALFOLDER) and sys.platform != 'win32': - LOCALFOLDER = os.path.join(wine_prefix, "drive_c", "users", getpass.getuser(), "Local Settings", "Application Data", - "Gas Powered Games", "Supreme Commander Forged Alliance") + LOCALFOLDER = os.path.join( + wine_prefix, "drive_c", "users", + getpass.getuser(), "Local Settings", + "Application Data", "Gas Powered Games", + "Supreme Commander Forged Alliance", + ) PREFSFILENAME = os.path.join(LOCALFOLDER, "game.prefs") if not os.path.exists(PREFSFILENAME): @@ -103,33 +128,51 @@ def developer(): DOWNLOADED_RES_PIX = {} DOWNLOADING_RES_PIX = {} -PERSONAL_DIR = str(QStandardPaths.standardLocations(QStandardPaths.DocumentsLocation)[0]) -logger.info('PERSONAL_DIR initial: ' + PERSONAL_DIR) -try: - PERSONAL_DIR.encode("ascii") - if not os.path.isdir(PERSONAL_DIR): - raise Exception('No documents location. Will use APPDATA instead.') -except: - logger.exception('PERSONAL_DIR not ok, falling back.') - PERSONAL_DIR = os.path.join(APPDATA_DIR, "user") +def getPersonalDir(): + fallback = Settings.get('vault/fallback', type=bool, default=False) + if fallback: + dir_ = os.path.join(APPDATA_DIR, "user") + else: + dir_ = str( + QStandardPaths.standardLocations( + QStandardPaths.StandardLocation.DocumentsLocation, + )[0], + ) + try: + dir_.encode("ascii") + + if not os.path.isdir(dir_): + raise Exception( + 'No documents location. Will use APPDATA instead.', + ) + except BaseException: + logger.exception('PERSONAL_DIR not ok, falling back.') + dir_ = os.path.join(APPDATA_DIR, "user") + return dir_ + + +def setPersonalDir(): + global PERSONAL_DIR + PERSONAL_DIR = getPersonalDir() + logger.info('PERSONAL_DIR set to: ' + PERSONAL_DIR) + + +PERSONAL_DIR = getPersonalDir() logger.info('PERSONAL_DIR final: ' + PERSONAL_DIR) # Ensure Application data directories exist -for data_dir in [APPDATA_DIR, PERSONAL_DIR, LUA_DIR, CACHE_DIR, - MAP_PREVIEW_DIR, MOD_PREVIEW_DIR, THEME_DIR, REPLAY_DIR, - LOG_DIR, EXTRA_DIR]: +for data_dir in [ + APPDATA_DIR, PERSONAL_DIR, LUA_DIR, CACHE_DIR, + MAP_PREVIEW_SMALL_DIR, MAP_PREVIEW_LARGE_DIR, MOD_PREVIEW_DIR, + THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR, NEWS_CACHE_DIR, + GAME_CACHE_DIR, GAMEDATA_DIR, BIN_DIR, REPLAY_DIR, +]: if not os.path.isdir(data_dir): os.makedirs(data_dir) -from PyQt5 import QtWidgets -from PyQt5.uic import * -import shutil -import hashlib -import re - def get_files_by_mod_date(location): files = os.listdir(location) @@ -160,22 +203,78 @@ def remove_obsolete_logs(location, pattern, max_number): if os.path.isfile(LOG_FILE_GAME): if os.path.getsize(LOG_FILE_GAME) > LOGFILE_MAX_SIZE: os.remove(LOG_FILE_GAME) + if os.path.isfile(LOG_FILE_MAPGEN): + if os.path.getsize(LOG_FILE_MAPGEN) > LOGFILE_MAX_SIZE: + os.remove(LOG_FILE_MAPGEN) remove_obsolete_logs(LOG_DIR, LOG_FILE_GAME_INFIX, 30) -except: +except BaseException: pass +# Ensure that access time is modified (needed for cache system) +def setAccessTime(file): + if os.path.exists(file): + curr_time = datetime.datetime.timestamp(datetime.datetime.now()) + mtime = os.stat(file).st_mtime + os.utime(file, times=(curr_time, mtime)) + + +# Get rid of cached files that are stored for too long +def clearGameCache(): + fmod_dir = os.path.join(CACHE_DIR, 'featured_mod') + if os.path.exists(fmod_dir): + curr_time = datetime.datetime.now() + max_storage_time = Settings.get( + 'cache/number_of_days', type=int, default=0, + ) + if max_storage_time > -1: # -1 stands for keeping files forever + for _dir in ['bin', 'gamedata']: + dir_to_check = os.path.join(fmod_dir, _dir) + if os.path.exists(dir_to_check): + files_to_check = [] + for dir, _, files in os.walk(dir_to_check): + files_to_check = files + for _file in files_to_check: + access_time = os.path.getatime( + os.path.join(dir_to_check, _file), + ) + access_time = datetime.datetime.fromtimestamp( + access_time, + ) + if (curr_time - access_time).days >= max_storage_time: + os.remove(os.path.join(dir_to_check, _file)) + + +# Get rid of generated maps +def clearGeneratedMaps(): + map_dir = os.path.join( + PERSONAL_DIR, "My Games", "Gas Powered Games", + "Supreme Commander Forged Alliance", "Maps", + ) + if os.path.exists(map_dir): + for entry in os.scandir(map_dir): + if re.match(mapgenUtils.generatedMapPattern, entry.name): + if entry.is_dir(): + shutil.rmtree(os.path.join(map_dir, entry.name)) + + def clearDirectory(directory, confirm=True): if os.path.isdir(directory): if (confirm): - result = QtWidgets.QMessageBox.question(None, "Clear Directory", "Are you sure you wish to clear the " - "following directory:
  " - + directory + "", - QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No) + result = QtWidgets.QMessageBox.question( + None, + "Clear Directory", + ( + "Are you sure you wish to clear the following directory:" + "
  {}".format(directory) + ), + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) else: - result = QtWidgets.QMessageBox.Yes + result = QtWidgets.QMessageBox.StandardButton.Yes - if result == QtWidgets.QMessageBox.Yes: + if result == QtWidgets.QMessageBox.StandardButton.Yes: shutil.rmtree(directory) return True else: @@ -189,7 +288,6 @@ def clearDirectory(directory, confirm=True): def _setup_theme(): global THEME - global VERSION_STRING default = Theme(COMMON_DIR, None) themes = [] @@ -200,58 +298,8 @@ def _setup_theme(): themes.append(Theme(theme_path, infile)) THEME = ThemeSet(themes, default, Settings, VERSION_STRING) -_setup_theme() - -# Public settings object -# Stolen from Config because reasons -from config import _settings -settings = _settings - - -def clean_slate(path): - if os.path.exists(path): - logger.info("Wiping " + path) - shutil.rmtree(path) - os.makedirs(path) - - -def curDownloadAvatar(url): - if url in DOWNLOADING_RES_PIX: - return DOWNLOADING_RES_PIX[url] - return None - - -def delDownloadAvatar(url): - try: - del DOWNLOADING_RES_PIX[url] - except KeyError: - pass - -def removeCurrentDownloadAvatar(url, caller, item): - if url in DOWNLOADING_RES_PIX: - DOWNLOADING_RES_PIX[url].remove(caller) - - -def addcurDownloadAvatar(url, caller): - if url in DOWNLOADING_RES_PIX: - if caller not in DOWNLOADING_RES_PIX[url]: - DOWNLOADING_RES_PIX[url].append(caller) - return False - else: - DOWNLOADING_RES_PIX[url] = [] - DOWNLOADING_RES_PIX[url].append(caller) - return True - - -def addrespix(url, pixmap): - DOWNLOADED_RES_PIX[url] = pixmap - - -def respix(url): - if url in DOWNLOADED_RES_PIX: - return DOWNLOADED_RES_PIX[url] - return None +_setup_theme() def __downloadPreviewFromWeb(unitname): @@ -259,8 +307,11 @@ def __downloadPreviewFromWeb(unitname): Downloads a preview image from the web for the given unit name """ # This is done so generated previews always have a lower case name. - # This doesn't solve the underlying problem (case folding Windows vs. Unix vs. FAF) - import urllib.request, urllib.error, urllib.parse + # This doesn't solve the underlying problem + # (case folding Windows vs. Unix vs. FAF) + import urllib.error + import urllib.parse + import urllib.request unitname = unitname.lower() logger.debug("Searching web preview for: " + unitname) @@ -272,58 +323,49 @@ def __downloadPreviewFromWeb(unitname): with open(img, 'wb') as fp: shutil.copyfileobj(req, fp) fp.flush() - os.fsync(fp.fileno()) # probably works fine without the flush and fsync + os.fsync(fp.fileno()) # probably works without the flush and fsync fp.close() return img -def iconUnit(unitname): - # Try to load directly from cache - - img = os.path.join(CACHE_DIR, unitname) - if os.path.isfile(img): - logger.log(5, "Using cached preview image for: " + unitname) - return THEME.icon(img, False) - # Try to download from web - img = __downloadPreviewFromWeb(unitname) - if img and os.path.isfile(img): - logger.debug("Using web preview image for: " + unitname) - return THEME.icon(img, False) - - -def wait(until): - """ - Super-simple wait function that takes a callable and waits until the callable returns true or the user aborts. - """ - progress = QtWidgets.QProgressDialog() - progress.show() - - while not until() and progress.isVisible(): - QtWidgets.QApplication.processEvents() - - progress.close() - - return not progress.wasCanceled() +def wrongPathNotice(): + msgBox = QtWidgets.QMessageBox() + msgBox.setWindowTitle("Location not found") + msgBox.setIcon(QtWidgets.QMessageBox.Information) + msgBox.setText("Folder or file does not exist") + msgBox.exec() def showDirInFileBrowser(location): - QDesktopServices.openUrl(QUrl.fromLocalFile(location)) + if not QDesktopServices.openUrl(QUrl.fromLocalFile(location)): + wrongPathNotice() def showFileInFileBrowser(location): if sys.platform == 'win32': - # Open the directory and highlight the picked file - subprocess.Popen('explorer /select,"{}"'.format(location)) + # Ensure that the path is in Windows format + location = os.path.normpath(location) + + if os.path.exists(location): + # Open the directory and highlight the picked file + subprocess.Popen('explorer /select,"{}"'.format(location)) + else: + wrongPathNotice() else: # No highlighting on cross-platform, sorry! showDirInFileBrowser(os.path.dirname(location)) + +def showConfigFile(): + showFileInFileBrowser(Settings.fileName()) + + html_escape_table = { "&": "&", '"': """, "'": "'", ">": ">", - "<": "<" + "<": "<", } @@ -332,31 +374,48 @@ def html_escape(text): return "".join(html_escape_table.get(c, c) for c in text) -def irc_escape(text, a_style=""): +def irc_escape(text): # first, strip any and all html text = html_escape(text) # taken from django and adapted url_re = re.compile( r'^((https?|faflive|fafgame|fafmap|ftp|ts3server)://)?' # protocols - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+' # domain name, then TLDs - r'(?:ac|ad|ae|aero|af|ag|ai|al|am|an|ao|aq|ar|arpa|as|asia|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|biz|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cat|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|com|coop|cr|cu|cv|cw|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|info|int|io|iq|ir|is|it|je|jm|jo|jobs|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mo|mobi|mp|mq|mr|ms|mt|mu|museum|mv|mw|mx|my|mz|na|name|nc|ne|net|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|pro|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sx|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|travel|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xxx|ye|yt|za|zm|zw)' + # domain name, then TLDs + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+' + r'(?:ac|ad|ae|aero|af|ag|ai|al|am|an|ao|aq|ar|arpa|as|asia|at|au|aw|' + r'ax|az|ba|bb|bd|be|bf|bg|bh|bi|biz|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|' + r'ca|cat|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|com|coop|cr|cu|cv|cw|cx|cy|' + r'cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|' + r'gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|' + r'hr|ht|hu|id|ie|il|im|in|info|int|io|iq|ir|is|it|je|jm|jo|jobs|jp|' + r'ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|' + r'ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mo|mobi|mp|mq|mr|ms|mt|mu|museum|' + r'mv|mw|mx|my|mz|na|name|nc|ne|net|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|' + r'pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|pro|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|' + r'sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sx|sy|sz|tc|td|' + r'tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|travel|tt|tv|tw|tz|ua|ug|uk|' + r'us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xxx|ye|yt|za|zm|zw)' r'|localhost' # localhost... r'|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip r'(?::\d+)?' # optional port - r'(?:/?|[/?]\S+)$', re.IGNORECASE) + r'(?:/?|[/?]\S+)$', re.IGNORECASE, + ) # Tired of bothering with end-of-word cases in this regex - # I'm splitting the whole string and matching each fragment start-to-end as a whole - strings = text.split() + # I'm splitting the whole string and matching each fragment start-to-end + # as a whole + strings = text.split(" ") result = [] for fragment in strings: match = url_re.match(fragment) if match: - if "://" in fragment: # slight hack to get those protocol-less URLs on board. Better: With groups! - rpl = '{0}'.format(fragment, a_style) + # slight hack to get those protocol-less URLs on board. + # Better: With groups! + if "://" in fragment: + rpl = '{0}'.format(fragment) else: - rpl = '{0}'.format(fragment, a_style) + rpl = '{0}'.format(fragment) fragment = fragment.replace(match.group(0), rpl) @@ -386,36 +445,49 @@ def md5(file_name): with open(file_name, "rb") as fd: while True: content = fd.read(1024 * 1024) - if not content: break + if not content: + break m.update(content) return m.hexdigest() -def uniqueID(user, session): - """ This is used to uniquely identify a user's machine to prevent smurfing. """ +def uniqueID(session): + """ + This is used to uniquely identify a user's machine to prevent smurfing. + """ # the UID check needs the WMI service running on Windows if sys.platform == 'win32': try: - _, wmi_state, _, _, _, _, _ = win32serviceutil.QueryServiceStatus('Winmgmt') + wmi_state = win32serviceutil.QueryServiceStatus('Winmgmt')[1] if wmi_state != win32service.SERVICE_RUNNING: - QMessageBox.critical(None, "WMI service not running", "FAF requires the 'Windows Management " - "Instrumentation' service for smurf protection " - "to be running. Please run 'service.msc', open " - "the 'Windows Management Instrumentation' " - "service, set the startup type to automatic and " - "restart FAF.") - except Exception as e: - QMessageBox.critical(None, "WMI service missing", "FAF requires the 'Windows Management Instrumentation' " - "service for smurf protection. This service could not " - "be found.") + QMessageBox.critical( + None, + "WMI service not running", + "FAF requires the 'Windows Management Instrumentation' " + "service for smurf protection to be running. Please run " + "'service.msc', open the 'Windows Management " + "Instrumentation' service, set the startup type to " + "automatic and restart FAF.", + ) + except BaseException: + QMessageBox.critical( + None, + "WMI service missing", + "FAF requires the 'Windows Management Instrumentation' service" + " for smurf protection. This service could not be found.", + ) if sys.platform == 'win32': exe_path = os.path.join(fafpath.get_libdir(), "faf-uid.exe") else: # Expect it to be in PATH already exe_path = "faf-uid" try: - uid_p = subprocess.Popen([exe_path, session], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + uid_p = subprocess.Popen( + [exe_path, session], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) out, err = uid_p.communicate() if uid_p.returncode != 0: logger.error("UniqueID executable error:") @@ -429,26 +501,20 @@ def uniqueID(user, session): return None -def userNameAction(parent, caption, action): - """ Get a username and execute action with it""" - username, success = QtWidgets.QInputDialog.getText(parent, 'Input Username', caption) - if success and username != '': - action(username) - -import datetime - -_dateDummy = datetime.datetime(2013, 5, 27) - +def strtodate(s: str) -> QDateTime: + return QDateTime.fromString(s, Qt.DateFormat.ISODate).toLocalTime() -def strtodate(s): - return _dateDummy.strptime(s, "%Y-%m-%d %H:%M:%S") +def datetostr(d: QDateTime) -> str: + return d.toString("yyyy-mm-dd HH:MM:ss") -def datetostr(d): - return str(d)[:-7] +def utctolocal(s: str) -> str: + return datetostr(strtodate(s)) -def now(): - return _dateDummy.now() -from .crash import CrashDialog, runtime_info +def capitalize(string: str) -> str: + """ + Capitalize the first letter only, leave the rest as it is + """ + return f"{string[0].upper()}{string[1:]}" diff --git a/src/util/crash.py b/src/util/crash.py index acd368536..ac8c73d76 100644 --- a/src/util/crash.py +++ b/src/util/crash.py @@ -1,19 +1,22 @@ # Bug Reporting -import config -import traceback -import util -from config import Settings import platform +import traceback -from . import APPDATA_DIR, PERSONAL_DIR, VERSION_STRING +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtCore import QUrl +import config +import util +from config import Settings +from util import APPDATA_DIR +from util import PERSONAL_DIR +from util import VERSION_STRING CRASH_REPORT_USER = "pre-login" FormClass, BaseClass = util.THEME.loadUiType("client/crash.ui") + def runtime_info(): try: desc = [] @@ -21,20 +24,23 @@ def runtime_info(): desc.append(("FAF Version", VERSION_STRING)) desc.append(("FAF Environment", config.environment)) desc.append(("FAF Directory", APPDATA_DIR)) - fa_path = util.settings.value("ForgedAlliance/app/path", - "Unknown", - type=str) + fa_path = util.settings.value( + "ForgedAlliance/app/path", + "Unknown", + type=str, + ) desc.append(("FA Path: ", fa_path)) desc.append(("Home Directory", PERSONAL_DIR)) desc.append(("Platform", platform.platform())) desc.append(("Uname", str(platform.uname()))) desc = "".join(["{}: {}\n".format(n, d) for n, d in desc]) - except Exception: + except BaseException: desc = "(Exception raised while writing runtime info)\n" return desc + class CrashDialog(FormClass, BaseClass): def __init__(self, exc_info, *args, **kwargs): BaseClass.__init__(self, *args, **kwargs) @@ -49,6 +55,21 @@ def __init__(self, exc_info, *args, **kwargs): self.helpButton.clicked.connect(self.tech_support) self.continueButton.clicked.connect(self.accept) self.quitButton.clicked.connect(self.reject) + self.add_theme_caveat() def tech_support(self): QDesktopServices().openUrl(QUrl(Settings.get("SUPPORT_URL"))) + + def add_theme_caveat(self): + text = self.infoBlurb.text() + try: + config_loc = "(located at {}) ".format(util.Settings.fileName()) + except BaseException: + config_loc = "" + text += ( + "

If you're seeing this message after overiding " + "an obsolete theme, go to the client config file {}and " + "remove the [theme_version_override] section." + .format(config_loc) + ) + self.infoBlurb.setText(text) diff --git a/src/util/gameurl.py b/src/util/gameurl.py new file mode 100644 index 000000000..0e653ae1a --- /dev/null +++ b/src/util/gameurl.py @@ -0,0 +1,84 @@ +from enum import Enum + +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import QUrlQuery + + +class GameUrlType(Enum): + LIVE_REPLAY = "faflive" + OPEN_GAME = "fafgame" + + +class GameUrl: + LOBBY_URL = "lobby.faforever.com" + REPLAY_SUFFIX = ".SCFAreplay" + + def __init__(self, game_type, map_, mod, uid, player, mods=None): + self.game_type = game_type + self.map = map_ + self.mod = mod + self.mods = mods + self.uid = uid + self.player = player # Can be both name and uid + + def to_url(self): + url = QUrl() + url.setHost(self.LOBBY_URL) + url.setScheme(self.game_type.value) + query = QUrlQuery() + query.addQueryItem("map", self.map) + query.addQueryItem("mod", self.mod) + if self.mods: + query.addQueryItem("mods", ";".join(self.mods)) + + if self.game_type == GameUrlType.OPEN_GAME: + url.setPath("/{}".format(self.player)) + query.addQueryItem("uid", str(self.uid)) + else: + url.setPath( + "/{}/{}{}".format(self.uid, self.player, self.REPLAY_SUFFIX), + ) + + url.setQuery(query) + return url + + @classmethod + def from_url(cls, url): + try: + url = QUrl(url) + query = QUrlQuery(url) + map_ = cls._get_query_item(query, "map") + mod = cls._get_query_item(query, "mod") + game_type = GameUrlType(url.scheme()) + + path = url.path().split("/") + + if game_type == GameUrlType.OPEN_GAME: + uid = cls._get_query_item(query, "uid", int) + player = path[1] + else: + uid = int(path[1]) + player = path[2] + if not player.endswith(cls.REPLAY_SUFFIX): + raise ValueError + player = player[:-len(cls.REPLAY_SUFFIX)] + + except (ValueError, TypeError, IndexError): + raise ValueError + + try: + mods = cls._get_query_item(query, "mods") + except ValueError: + mods = None + return cls(game_type, map_, mod, uid, player, mods) + + @classmethod + def _get_query_item(cls, query, name, type_=str): + str_value = query.queryItemValue(name) + if str_value == "": + raise ValueError + return type_(str_value) + + @classmethod + def is_game_url(cls, url): + return QUrl(url).scheme() in [e.value for e in GameUrlType] diff --git a/src/util/lang.py b/src/util/lang.py new file mode 100644 index 000000000..9951ba729 --- /dev/null +++ b/src/util/lang.py @@ -0,0 +1,12 @@ +# TODO: Python has no "reasonably guess language from country" +# package, so use this rough guesstimate +COUNTRY_TO_LANGUAGE = { + "ru": ["ru", "kz", "kg"], + "by": ["by"], + "de": ["de", "au"], +} +COUNTRY_TO_LANGUAGE = { + country: lang + for lang, countries in COUNTRY_TO_LANGUAGE.items() + for country in countries +} diff --git a/src/util/magic_dict.py b/src/util/magic_dict.py new file mode 100644 index 000000000..697ec275b --- /dev/null +++ b/src/util/magic_dict.py @@ -0,0 +1,34 @@ +class MagicDict: + def __init__(self, value=None): + super().__setattr__('_dict', {}) + super().__setattr__('_value', value) + + def __getattr__(self, attr): + return self._dict.get(attr, _magic_none) + + def __setattr__(self, attr, val): + self._dict[attr] = MagicDict(val) + + def put(self, attr): + self.__setattr__(attr, None) + return self.__getattr__(attr) + + def __bool__(self): + return True + + def __call__(self): + return self._value + + +class MagicNone: + def __getattr__(self, attr): + return self + + def __call__(self): + return None + + def __bool__(self): + return False + + +_magic_none = MagicNone() diff --git a/src/util/qt.py b/src/util/qt.py index b2721aa1c..92ce9bd49 100644 --- a/src/util/qt.py +++ b/src/util/qt.py @@ -1,27 +1,33 @@ -from PyQt5.QtWebEngineWidgets import QWebEnginePage -from PyQt5.QtGui import QDesktopServices - - -class ExternalLinkPage(QWebEnginePage): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def acceptNavigationRequest(self, url, navtype, isMainFrame): - if navtype == QWebEnginePage.NavigationTypeLinkClicked: - QDesktopServices.openUrl(url) - return False - return True - - -def injectWebviewCSS(page, css): - # Hacky way to inject CSS into QWebEnginePage, since QtWebengine doesn't - # have a way to inject user CSS yet - # We should eventually remove all QtWebEngine uses anyway - js = """ - var css = document.createElement("style"); - css.type = "text/css"; - css.innerHTML = `{}`; - document.head.appendChild(css); - """ - js = js.format(css) - page.runJavaScript(js) +import types +from contextlib import contextmanager +from typing import Generator + +from PyQt6.QtCore import QFile +from PyQt6.QtGui import QPainter + + +def monkeypatch_method(obj, name, fn): + old_fn = getattr(obj, name) + + def wrapper(self, *args, **kwargs): + return fn(self, old_fn, *args, **kwargs) + setattr(obj, name, types.MethodType(wrapper, obj)) + + +@contextmanager +def qopen(path: str, flags: QFile.OpenModeFlag) -> Generator[QFile, None, None]: + try: + file = QFile(path) + file.open(flags) + yield file + finally: + file.close() + + +@contextmanager +def qpainter(painter: QPainter) -> Generator[QPainter, None, None]: + try: + painter.save() + yield painter + finally: + painter.restore() diff --git a/src/util/qt_list_model.py b/src/util/qt_list_model.py new file mode 100644 index 000000000..e12742441 --- /dev/null +++ b/src/util/qt_list_model.py @@ -0,0 +1,57 @@ +from PyQt6.QtCore import QAbstractListModel +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt + + +class QtListModel(QAbstractListModel): + def __init__(self, item_builder): + QAbstractListModel.__init__(self) + self._items = {} + self._itemlist = [] # For queries + self._item_builder = item_builder + + def rowCount(self, parent): + if parent.isValid(): + return 0 + return len(self._itemlist) + + def data(self, index, role): + if not index.isValid() or index.row() >= len(self._itemlist): + return None + if role != Qt.ItemDataRole.DisplayRole: + return None + return self._itemlist[index.row()] + + # TODO - insertion and removal are O(n). + def _add_item(self, data, id_): + assert id_ not in self._items + next_index = len(self._itemlist) + self.beginInsertRows(QModelIndex(), next_index, next_index) + item = self._item_builder(data) + item.updated.connect(self._at_item_updated) + self._items[id_] = item + self._itemlist.append(item) + self.endInsertRows() + + def _remove_item(self, id_): + assert id_ in self._items + item = self._items[id_] + item_index = self._itemlist.index(item) + self.beginRemoveRows(QModelIndex(), item_index, item_index) + item.updated.disconnect(self._at_item_updated) + del self._items[id_] + self._itemlist.pop(item_index) + self.endRemoveRows() + + def _clear_items(self): + self.beginRemoveRows(QModelIndex(), 0, len(self._items) - 1) + for item in self._items.values(): + item.updated.disconnect(self._at_item_updated) + self._items.clear() + self._itemlist.clear() + self.endRemoveRows() + + def _at_item_updated(self, item): + item_index = self._itemlist.index(item) + index = self.index(item_index, 0) + self.dataChanged.emit(index, index) diff --git a/src/util/select_player_dialog.py b/src/util/select_player_dialog.py new file mode 100644 index 000000000..66b528c0b --- /dev/null +++ b/src/util/select_player_dialog.py @@ -0,0 +1,30 @@ +from PyQt6.QtWidgets import QCompleter +from PyQt6.QtWidgets import QInputDialog +from PyQt6.QtWidgets import QLineEdit + + +class SelectPlayerDialog: + def __init__(self, playerset, parent_widget): + self._playerset = playerset + self._parent_widget = parent_widget + + def show_dialog(self, title, label, name): + dialog = QInputDialog(self._parent_widget) + dialog.setInputMode(QInputDialog.TextInput) + dialog.setWindowTitle(title) + dialog.setLabelText(label) + dialog.textValueSelected.connect(self._at_value) + dialog.setTextValue(name) + dialog.show() + + completer = PlayerCompleter(self._playerset, dialog) + dialog.findChild(QLineEdit).setCompleter(completer) + + def _at_value(self, value): + pass + + +class PlayerCompleter(QCompleter): + def __init__(self, playerset, parent_widget): + online_players = [p.login for p in playerset.values()] + QCompleter.__init__(self, online_players, parent_widget) diff --git a/src/util/theme.py b/src/util/theme.py index 7c5eb6acd..563c6c3e0 100644 --- a/src/util/theme.py +++ b/src/util/theme.py @@ -1,8 +1,12 @@ -from PyQt5 import QtGui, QtWidgets, QtCore, QtMultimedia, uic -from semantic_version import Version +import logging import os -import logging +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6 import uic +from semantic_version import Version + logger = logging.getLogger(__name__) @@ -10,6 +14,7 @@ class Theme(): """ Represents a single FAF client theme. """ + def __init__(self, themedir, name): """ A 'None' themedir represents no theming (no dir prepended to filename) @@ -51,7 +56,8 @@ def version(self): def pixmap(self, filename): """ This function loads a pixmap from a themed directory, or anywhere. - It also stores them in a cache dictionary (may or may not be necessary depending on how Qt works under the hood) + It also stores them in a cache dictionary (may or may not be necessary + depending on how Qt works under the hood) """ try: return self._pixmapcache[filename] @@ -71,7 +77,8 @@ def loadUi(self, filename): @_noneIfNoFile def loadUiType(self, filename): - # Loads and compiles a Qt Ui file via uic, and returns the Type and Basetype as a tuple + # Loads and compiles a Qt Ui file via uic, and returns the Type and + # Basetype as a tuple return uic.loadUiType(self._themepath(filename)) @_noneIfNoFile @@ -85,14 +92,19 @@ def readlines(self, filename): def readstylesheet(self, filename): with open(self._themepath(filename)) as f: logger.info(u"Read themed stylesheet: " + filename) - return f.read().replace("%THEMEPATH%", self._themedir.replace("\\", "/")) + return f.read().replace( + "%THEMEPATH%", self._themedir.replace("\\", "/"), + ) @_noneIfNoFile def themeurl(self, filename): """ - This creates an url to use for a local stylesheet. It's a bit of a hack because Qt has a bug identifying proper localfile QUrls + This creates an url to use for a local stylesheet. It's a bit of a + hack because Qt has a bug identifying proper localfile QUrls """ - return QtCore.QUrl("file://" + self._themepath(filename).replace("\\", "/")) + return QtCore.QUrl( + "file://" + self._themepath(filename).replace("\\", "/"), + ) @_noneIfNoFile def readfile(self, filename): @@ -107,13 +119,17 @@ def sound(self, filename): return self._themepath(filename) -class ThemeSet: +class ThemeSet(QtCore.QObject): """ Represent a collection of themes to choose from, with a default theme and an unthemed directory. """ - def __init__(self, themeset, default_theme, settings, - client_version, unthemed = None): + stylesheets_reloaded = QtCore.pyqtSignal() + + def __init__( + self, themeset, default_theme, settings, client_version, unthemed=None, + ): + QtCore.QObject.__init__(self) self._default_theme = default_theme self._themeset = themeset self._theme = default_theme @@ -121,9 +137,6 @@ def __init__(self, themeset, default_theme, settings, self._settings = settings self._client_version = client_version - # For refreshing stylesheets - self._stylesheets = {} - @property def theme(self): return self._theme @@ -131,7 +144,9 @@ def theme(self): def _getThemeByName(self, name): if name is None: return self._default_theme - matching_themes = [theme for theme in self._themeset if theme.name == name] + matching_themes = [ + theme for theme in self._themeset if theme.name == name + ] if not matching_themes: return None return matching_themes[0] @@ -144,7 +159,7 @@ def loadTheme(self): def listThemes(self): return [None] + [theme.name for theme in self._themeset] - def setTheme(self, name, restart = True): + def setTheme(self, name, restart=True): theme = self._getThemeByName(name) if theme is None: return @@ -155,7 +170,9 @@ def setTheme(self, name, restart = True): self._settings.sync() if set_theme and restart: - QtWidgets.QMessageBox.information(None, "Restart Needed", "FAF will quit now.") + QtWidgets.QMessageBox.information( + None, "Restart Needed", "FAF will quit now.", + ) QtWidgets.QApplication.quit() def _checkThemeVersion(self, theme): @@ -175,13 +192,18 @@ def _checkThemeVersion(self, theme): override_version = Version(override_version_str) except ValueError: # Did someone manually mess with the override config? - logger.warning("Malformed theme version override setting: " + override_version_str) + logger.warning( + "Malformed theme version override setting: {}" + .format(override_version_str), + ) self._settings.remove(override_config) return version if version >= override_version: - logger.info("New version " + str(version) + " of theme " + theme + - ", removing override " + override_version_str) + logger.info( + "New version {} of theme {}, removing override {}" + .format(str(version), theme, override_version_str), + ) self._settings.remove(override_config) return version else: @@ -193,7 +215,9 @@ def _checkThemeOutdated(self, theme_version): def _do_setTheme(self, new_theme): old_theme = self._theme - theme_changed = lambda: old_theme != self._theme + + def theme_changed(): + return old_theme != self._theme if new_theme == self._theme: return theme_changed() @@ -206,52 +230,81 @@ def _do_setTheme(self, new_theme): theme_version = self._checkThemeVersion(new_theme) if theme_version is None: QtWidgets.QMessageBox.information( - QtWidgets.QApplication.activeWindow(), - "Invalid Theme", - "Failed to read the version of the following theme:
" + - str(new_theme) + - "
Contact the maker of the theme for a fix!") - logger.error("Error reading theme version: " + str(new_theme) + - " in directory " + new_theme.themedir) + QtWidgets.QApplication.activeWindow(), + "Invalid Theme", + ( + "Failed to read the version of the following theme:" + "
{}
Contact the maker of the theme for" + " a fix!".format(str(new_theme)) + ), + ) + logger.error( + "Error reading theme version: {} in directory {}" + .format(str(new_theme), new_theme.themedir), + ) return theme_changed() outdated = self._checkThemeOutdated(theme_version) if not outdated: - logger.info("Using theme: " + str(new_theme) + - " in directory " + new_theme.themedir) + logger.info( + "Using theme: {} in directory {}" + .format(new_theme, new_theme.themedir), + ) self._theme = new_theme else: box = QtWidgets.QMessageBox(QtWidgets.QApplication.activeWindow()) box.setWindowTitle("Incompatible Theme") box.setText( - "The selected theme reports compatibility with a lower version of the FA client:
" + - str(new_theme) + - "
Contact the maker of the theme for an update!
" + - "Do you want to try to apply the theme anyway?") - b_yes = box.addButton("Apply this once", QtWidgets.QMessageBox.YesRole) - b_always = box.addButton("Always apply for this FA version", QtWidgets.QMessageBox.YesRole) - b_default = box.addButton("Use default theme", QtWidgets.QMessageBox.NoRole) - b_no = box.addButton("Abort", QtWidgets.QMessageBox.NoRole) - box.exec_() + "The selected theme reports compatibility with a lower " + "version of the FA client:
{}
Contact " + "the maker of the theme for an update!
Do you " + "want to try to apply the theme anyway?" + .format(str(new_theme)), + ) + b_yes = box.addButton( + "Apply this once", + QtWidgets.QMessageBox.ButtonRole.YesRole, + ) + b_always = box.addButton( + "Always apply for this FA version", + QtWidgets.QMessageBox.ButtonRole.YesRole, + ) + b_default = box.addButton( + "Use default theme", + QtWidgets.QMessageBox.ButtonRole.NoRole, + ) + b_no = box.addButton("Abort", QtWidgets.QMessageBox.ButtonRole.NoRole) + box.exec() result = box.clickedButton() if result == b_always: QtWidgets.QMessageBox.information( - QtWidgets.QApplication.activeWindow(), - "Notice", - "If the applied theme causes crashes, clear the '[theme_version_override]'
" + - "section of your FA client config file.") - logger.info("Overriding version of theme " + str(new_theme) + "with " + self._client_version) + QtWidgets.QApplication.activeWindow(), + "Notice", + ( + "If the applied theme causes crashes, clear the " + "'[theme_version_override]'
section of your FA " + "client config file." + ), + ) + logger.info( + "Overriding version of theme {} with {}" + .format(str(new_theme), self._client_version), + ) override_config = "theme_version_override/" + str(new_theme) self._settings.set(override_config, self._client_version) if result == b_always or result == b_yes: - logger.info("Using theme: " + str(new_theme) + - " in directory " + new_theme.themedir) + logger.info( + "Using theme: {} in directory {}" + .format(new_theme, new_theme.themedir), + ) self._theme = new_theme elif result == b_default: self._theme = self._default_theme + elif result == b_no: + pass else: pass return theme_changed() @@ -272,7 +325,10 @@ def _warn_resource_null(fn): def _nullcheck(self, filename, themed=True): ret = fn(self, filename, themed) if ret is None: - logger.warning("Failed to load resource '" + filename + "' in theme." + fn.__name__) + logger.warning( + "Failed to load resource '{}' in theme. {}" + .format(filename, fn.__name__), + ) return ret return _nullcheck @@ -304,8 +360,9 @@ def readfile(self, filename, themed=True): return self._theme_callchain("readfile", filename, themed) @_warn_resource_null - def _sound(self, filename, themed=True): - return self._theme_callchain("sound", filename, themed) + def sound(self, filename: str, themed: bool = True) -> QtCore.QUrl: + filepath = self._theme_callchain("sound", filename, themed) + return QtCore.QUrl.fromLocalFile(filepath) def pixmap(self, filename, themed=True): # If we receive None, return the default pixmap @@ -314,16 +371,8 @@ def pixmap(self, filename, themed=True): return QtGui.QPixmap() return ret - def sound(self, filename, themed=True): - QtMultimedia.QSound.play(self._sound(filename, themed)) - - def setStyleSheet(self, obj, filename): - self._stylesheets[obj] = filename - obj.setStyleSheet(self.readstylesheet(filename)) - def reloadStyleSheets(self): - for obj, filename in self._stylesheets.items(): - obj.setStyleSheet(self.readstylesheet(filename)) + self.stylesheets_reloaded.emit() def icon(self, filename, themed=True, pix=False): """ @@ -334,18 +383,30 @@ def icon(self, filename, themed=True, pix=False): return self.pixmap(filename, themed) else: icon = QtGui.QIcon() - icon.addPixmap(self.pixmap(filename, themed), QtGui.QIcon.Normal) + icon.addPixmap(self.pixmap(filename, themed), QtGui.QIcon.Mode.Normal) splitExt = os.path.splitext(filename) if len(splitExt) == 2: - pixDisabled = self.pixmap(splitExt[0] + "_disabled" + splitExt[1], themed) + pixDisabled = self.pixmap( + splitExt[0] + "_disabled" + splitExt[1], themed, + ) if pixDisabled is not None: - icon.addPixmap(pixDisabled, QtGui.QIcon.Disabled, QtGui.QIcon.On) + icon.addPixmap( + pixDisabled, QtGui.QIcon.Mode.Disabled, QtGui.QIcon.State.On, + ) - pixActive = self.pixmap(splitExt[0] + "_active" + splitExt[1], themed) + pixActive = self.pixmap( + splitExt[0] + "_active" + splitExt[1], themed, + ) if pixActive is not None: - icon.addPixmap(pixActive, QtGui.QIcon.Active, QtGui.QIcon.On) + icon.addPixmap( + pixActive, QtGui.QIcon.Mode.Active, QtGui.QIcon.State.On, + ) - pixSelected = self.pixmap(splitExt[0] + "_selected" + splitExt[1], themed) + pixSelected = self.pixmap( + splitExt[0] + "_selected" + splitExt[1], themed, + ) if pixSelected is not None: - icon.addPixmap(pixSelected, QtGui.QIcon.Selected, QtGui.QIcon.On) + icon.addPixmap( + pixSelected, QtGui.QIcon.Mode.Selected, QtGui.QIcon.State.On, + ) return icon diff --git a/src/vault/__init__.py b/src/vault/__init__.py deleted file mode 100644 index cc06c547d..000000000 --- a/src/vault/__init__.py +++ /dev/null @@ -1,208 +0,0 @@ -from PyQt5 import QtCore, QtWidgets, QtWebChannel, QtWebEngineWidgets -from stat import * -import util -from util.qt import injectWebviewCSS -import urllib.request, urllib.parse, urllib.error -import logging -import os -from fa import maps -from vault import luaparser -import urllib.request, urllib.error, urllib.parse -import re -from config import Settings - -from ui.busy_widget import BusyWidget - -logger = logging.getLogger(__name__) - - -class FAFPage(QtWebEngineWidgets.QWebEnginePage): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def userAgentForUrl(self, url): - return "FAForever" - - -class MapVault(QtCore.QObject, BusyWidget): - def __init__(self, client, *args, **kwargs): - QtCore.QObject.__init__(self, *args, **kwargs) - self.client = client - logger.debug("Map Vault tab instantiating") - - self._webChannel = QtWebChannel.QWebChannel(self) - self._webChannel.registerObject("webVault", self) - self._page = FAFPage(self) - self._page.setWebChannel(self._webChannel) - - self.ui = QtWebEngineWidgets.QWebEngineView() - - self.ui.setPage(self._page) - - self.loaded = False - self.ui.loadFinished.connect(self.ui.show) - self.reloadView() - - def busy_entered(self): - self.reloadView() - - def reloadView(self): - if self.loaded: - return - self.loaded = True - - self.ui.setVisible(False) - -# If a local theme CSS exists, skin the WebView with it - if util.THEME.themeurl("vault/style.css"): - injectWebviewCSS(self.ui.page(), - util.THEME.readstylesheet("vault/style.css")) - - ROOT = Settings.get('content/host') - - url = QtCore.QUrl(ROOT) - url.setPath("/faf/vault/maps_qt5.php") - query = QtCore.QUrlQuery(url.query()) - query.addQueryItem('username', self.client.login) - query.addQueryItem('pwdhash', self.client.password) - url.setQuery(query) - - self.ui.setUrl(url) - - def __preparePositions(self, positions, map_size): - img_size = [256, 256] - size = [int(map_size['0']), int(map_size['1'])] - off_x = 0 - off_y = 0 - - if size[1] > size[0]: - img_size[0] = img_size[0]/2 - off_x = img_size[0]/2 - elif size[0] > size[1]: - img_size[1] = img_size[1]/2 - off_y = img_size[1]/2 - - cf_x = size[0]/img_size[0] - cf_y = size[1]/img_size[1] - - regexp = re.compile(" \\d+\\.\\d*| \\d+") - - for postype in positions: - for pos in positions[postype]: - values = regexp.findall(positions[postype][pos]) - x = off_x + float(values[0].strip())/cf_x - y = off_y + float(values[2].strip())/cf_y - positions[postype][pos] = [int(x), int(y)] - - @QtCore.pyqtSlot() - def uploadMap(self): - mapDir = QtWidgets.QFileDialog.getExistingDirectory( - self.client, - "Select the map directory to upload", - maps.getUserMapsFolder(), - QtWidgets.QFileDialog.ShowDirsOnly) - logger.debug("Uploading map from: " + mapDir) - if mapDir != "": - if maps.isMapFolderValid(mapDir): - os.chmod(mapDir, S_IWRITE) - mapName = os.path.basename(mapDir) - zipName = mapName.lower()+".zip" - - scenariolua = luaparser.luaParser(os.path.join( - mapDir, - maps.getScenarioFile(mapDir))) - scenarioInfos = scenariolua.parse({ - 'scenarioinfo>name':'name', 'size':'map_size', - 'description':'description', - 'count:armies':'max_players', - 'map_version':'version', - 'type':'map_type', - 'teams>0>name':'battle_type' - }, {'version':'1'}) - - if scenariolua.error: - logger.debug("There were {} errors and {} warnings".format( - scenariolua.errors, - scenariolua.warnings - )) - logger.debug(scenariolua.errorMsg) - QtWidgets.QMessageBox.critical( - self.client, - "Lua parsing error", - "{}\nMap uploading cancelled.".format( - scenariolua.errorMsg)) - else: - if scenariolua.warning: - uploadmap = QtWidgets.QMessageBox.question( - self.client, - "Lua parsing warning", - "{}\nDo you want to upload the map?".format( - scenariolua.errorMsg), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No) - else: - uploadmap = QtWidgets.QMessageBox.Yes - if uploadmap == QtWidgets.QMessageBox.Yes: - savelua = luaparser.luaParser(os.path.join( - mapDir, - maps.getSaveFile(mapDir) - )) - saveInfos = savelua.parse({ - 'markers>mass*>position':'mass:__parent__', - 'markers>hydro*>position':'hydro:__parent__', - 'markers>army*>position':'army:__parent__'}) - if savelua.error or savelua.warning: - logger.debug("There were {} errors and {} warnings".format( - scenariolua.errors, - scenariolua.warnings - )) - logger.debug(scenariolua.errorMsg) - - self.__preparePositions( - saveInfos, - scenarioInfos["map_size"]) - - tmpFile = maps.processMapFolderForUpload( - mapDir, - saveInfos) - if not tmpFile: - QtWidgets.QMessageBox.critical( - self.client, - "Map uploading error", - "Couldn't make previews for {}\n" - "Map uploading cancelled.".format(mapName)) - return None - - qfile = QtCore.QFile(tmpFile.name) - self.client.lobby_connection.writeToServer("UPLOAD_MAP", zipName, scenarioInfos, qfile) - - #removing temporary files - qfile.remove() - else: - QtWidgets.QMessageBox.information( - self.client, - "Map selection", - "This folder doesn't contain valid map data.") - - @QtCore.pyqtSlot(str) - def downloadMap(self, link): - link = urllib.parse.unquote(link) - name = maps.link2name(link) - alt_name = name.replace(" ", "_") - avail_name = None - if maps.isMapAvailable(name): - avail_name = name - elif maps.isMapAvailable(alt_name): - avail_name = alt_name - if avail_name is None: - maps.downloadMap(name) - maps.existMaps(True) - else: - show = QtWidgets.QMessageBox.question( - self.client, - "Already got the Map", - "Seems like you already have that map!
Would you like to see it?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No) - if show == QtWidgets.QMessageBox.Yes: - util.showDirInFileBrowser(maps.folderForMap(avail_name)) diff --git a/src/vault/dialogs.py b/src/vault/dialogs.py deleted file mode 100644 index 185d5264f..000000000 --- a/src/vault/dialogs.py +++ /dev/null @@ -1,135 +0,0 @@ -from downloadManager import FileDownload -from PyQt5 import QtCore, QtNetwork, QtWidgets -import zipfile -import os -import io - -import logging -logger = logging.getLogger(__name__) - - -class VaultDownloadDialog(object): - # Result codes - SUCCESS = 0 - CANCELED = 1 - DL_ERROR = 2 - UNKNOWN_ERROR = 3 - - def __init__(self, dler, title, label, silent = False): - self._silent = silent - self._result = None - - self._dler = dler - self._dler.cb_start = self._start - self._dler.cb_progress = self._cont - self._dler.cb_finished = self._finished - self._dler.blocksize = 8192 - - self._progress = QtWidgets.QProgressDialog() - self._progress.setWindowTitle(title) - self._progress.setLabelText(label) - if not self._silent: - self._progress.setCancelButtonText("Cancel") - else: - self._progress.setCancelButton(None) - self._progress.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint) - self._progress.setAutoReset(False) - self._progress.setModal(1) - self._progress.canceled.connect(self._dler.cancel) - - def run(self): - self._progress.show() - self._dler.run() - self._dler.waitForCompletion() - return self._result - - def _start(self, dler): - self._progress.setMinimum(0) - self._progress.setMaximum(dler.bytes_total) - - def _cont(self, dler): - self._progress.setValue(dler.bytes_progress) - self._progress.setMaximum(dler.bytes_total) - QtWidgets.QApplication.processEvents() - - def _finished(self, dler): - self._progress.reset() - - if not dler.succeeded(): - if dler.canceled: - self._result = self.CANCELED - return - - elif dler.error: - self._result = self.DL_ERROR - return - else: - print("Unknown error") - self._result = self.UNKNOWN_ERROR - return - - self._result = self.SUCCESS - return - -# FIXME - one day we'll do it properly -_global_nam = QtNetwork.QNetworkAccessManager() - - -def downloadVaultAssetNoMsg(url, target_dir, exist_handler, name, category, - silent): - """ - Download and unpack a zip from the vault, interacting with the user and - logging things. - """ - global _global_nam - msg = None - output = io.BytesIO() - capitCat = category[0].upper() + category[1:] - - dler = FileDownload(_global_nam, url, output) - ddialog = VaultDownloadDialog(dler, "Downloading {}".format(category), name, silent) - result = ddialog.run() - - if result == VaultDownloadDialog.CANCELED: - logger.warning("{} Download canceled for: {}".format(capitCat, url)) - if result in [VaultDownloadDialog.DL_ERROR, VaultDownloadDialog.UNKNOWN_ERROR]: - logger.warning("Vault download failed, {} probably not in vault (or broken).".format(category)) - msg = lambda: QtWidgets.QMessageBox.information( - None, - "{} not downloadable".format(capitCat), - ("This {} was not found in the vault (or is broken).
" - "You need to get it from somewhere else in order to use it.") - .format(category)) - if result != VaultDownloadDialog.SUCCESS: - return False, msg - - try: - zfile = zipfile.ZipFile(output) - # FIXME - nothing in python 2.7 that can do that - dirname = zfile.namelist()[0].split(os.path.sep, 1)[0] - - if os.path.exists(os.path.join(target_dir, dirname)): - proceed = exist_handler(target_dir, dirname) - if not proceed: - return False - zfile.extractall(target_dir) - logger.debug("Successfully downloaded and extracted {} from: {}".format(category, url)) - return True, msg - - except: - logger.error("Extract error") - msg = lambda: QtWidgets.QMessageBox.information( - None, - "{} installation failed".format(capitCat), - "This {} could not be installed (please report this {} or bug)." - .format(category, category)) - return False, msg - - -def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent): - ret, dialog = downloadVaultAssetNoMsg(url, target_dir, exist_handler, - name, category, silent) - if dialog is not None: - dialog() - - return ret diff --git a/src/vaults/__init__.py b/src/vaults/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py new file mode 100644 index 000000000..b892759d6 --- /dev/null +++ b/src/vaults/dialogs.py @@ -0,0 +1,244 @@ +import logging +import os +from enum import Enum +from enum import auto +from typing import Callable + +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6 import QtWidgets + +from downloadManager import FileDownload +from downloadManager import ZipDownloadExtract +from util import capitalize + +logger = logging.getLogger(__name__) + + +class VaultDownloadResult(Enum): + SUCCESS = auto() + CANCELED = auto() + DL_ERROR = auto() + UNKNOWN_ERROR = auto() + + +class VaultDownloadDialog(object): + + def __init__( + self, + dler: FileDownload | ZipDownloadExtract, + title: str, + label: str, + silent: bool = False, + ) -> None: + self._silent = silent + self._result = None + + self._dler = dler + self._dler.start.connect(self._start) + self._dler.progress.connect(self._cont) + self._dler.finished.connect(self._finished) + self._dler.blocksize = 8192 + + self._progress = QtWidgets.QProgressDialog() + self._progress.setWindowTitle(title) + self.label = label + self._progress.setLabelText(self.label) + if not self._silent: + self._progress.setCancelButtonText("Cancel") + else: + self._progress.setCancelButton(None) + self._progress.setWindowFlags( + QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, + ) + self._progress.setAutoReset(False) + self._progress.setModal(1) + self._progress.canceled.connect(self._dler.cancel) + + progressBar = QtWidgets.QProgressBar(self._progress) + progressBar.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._progress.setBar(progressBar) + + self.progress_measure_interaval = 250 + self.timer = QtCore.QTimer() + self.timer.setInterval(self.progress_measure_interaval) + self.timer.timeout.connect(self.updateLabel) + self.bytes_prev = 0 + + def updateLabel(self) -> None: + label_text = f"{self.label}\n\n{self.get_download_progress_mb()}" + speed_text = f"({self.get_download_speed()} MB/s)" + if self._dler.bytes_total > 0: + label_text = f"{label_text}/{self.get_download_size_mb()} MB\n\n{speed_text}" + else: + label_text = f"{label_text} MB {speed_text}" + self._progress.setLabelText(label_text) + + def get_download_speed(self) -> float: + bytes_diff = self._dler.bytes_progress - self.bytes_prev + self.bytes_prev = self._dler.bytes_progress + return round(bytes_diff * (1000 / self.progress_measure_interaval) / 1024 / 1024, 2) + + def get_download_progress_mb(self) -> float: + return round(self._dler.bytes_progress / 1024 / 1024, 2) + + def get_download_size_mb(self) -> float: + return round(self._dler.bytes_total / 1024 / 1024, 2) + + def run(self): + self.updateLabel() + self.timer.start() + self._progress.show() + self._dler.run() + self._dler.waitForCompletion() + return self._result + + def _start(self, dler): + self._progress.setMinimum(0) + if dler.bytes_total > 0: + self._progress.setMaximum(dler.bytes_total) + else: + self._progress.setMaximum(0) + + def _cont(self, dler): + if dler.bytes_total > 0: + self._progress.setValue(dler.bytes_progress) + self._progress.setMaximum(dler.bytes_total) + + QtWidgets.QApplication.processEvents() + + def _finished(self, dler: FileDownload | ZipDownloadExtract) -> None: + self.timer.stop() + self._progress.reset() + self._set_result(dler) + + def _set_result(self, dler: FileDownload | ZipDownloadExtract) -> None: + if dler.failed(): + if dler.canceled: + self._result = VaultDownloadResult.CANCELED + return + elif dler.error: + self._result = VaultDownloadResult.DL_ERROR + return + else: + logger.error('Unknown download error') + self._result = VaultDownloadResult.UNKNOWN_ERROR + return + + self._result = VaultDownloadResult.SUCCESS + return + + +# FIXME - one day we'll do it properly +_global_nam = QtNetwork.QNetworkAccessManager() + + +def _download_asset( + dler: FileDownload | ZipDownloadExtract, + category: str, + silent: bool, + label: str = "", +) -> VaultDownloadResult: + ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label, silent) + result = ddialog.run() + + if result == VaultDownloadResult.CANCELED: + logger.warning(f"{category} Download canceled for: {dler.addr}") + if result in [ + VaultDownloadResult.DL_ERROR, + VaultDownloadResult.UNKNOWN_ERROR, + ]: + logger.warning(f"Download failed. {dler.addr}") + return result + + +def downloadVaultAssetNoMsg( + url: str, + target_dir: str, + exist_handler: Callable[[str, str], bool], + name: str, + category: str, + silent: bool, + request_params: dict | None = None, + label: str = "", +) -> tuple[bool, Callable[[], None] | None]: + """ + Download and unpack a zip from the vault, interacting with the user and + logging things. + """ + global _global_nam + + msg = None + capit_cat = capitalize(category) + + if os.path.exists(os.path.join(target_dir, name)): + proceed = exist_handler(target_dir, name) + if not proceed: + return False, msg + + os.makedirs(target_dir, exist_ok=True) + dler = ZipDownloadExtract(target_dir, _global_nam, url, request_params) + result = _download_asset(dler, capit_cat, silent, label or name) + + if result in [ + VaultDownloadResult.DL_ERROR, + VaultDownloadResult.UNKNOWN_ERROR, + ]: + logger.warning(f"Vault download failed, {category} is probably not in vault (or broken).") + msg_title = "{} not downloadable".format(capit_cat) + msg_text = ( + f"This {category} was not found in the vault (or is broken)." + f"
You need to get it from somewhere else in order to " + f"use it." + ) + + def msg(): + QtWidgets.QMessageBox.information(None, msg_title, msg_text) + + return result == VaultDownloadResult.SUCCESS, msg + + +def downloadVaultAsset( + url: str, + target_dir: str, + exist_handler: Callable[[str, str], bool], + name: str, + category: str, + silent: bool, +) -> bool: + ret, dialog = downloadVaultAssetNoMsg(url, target_dir, exist_handler, name, category, silent) + if dialog is not None: + dialog() + return ret + + +def download_file( + url: str, + target_dir: str, + name: str, + category: str, + silent: bool, + request_params: dict | None = None, + label: str = "", +) -> bool: + """ + Basically a copy of downloadVaultAssetNoMsg without zip + """ + capit_cat = capitalize(category) + + os.makedirs(target_dir, exist_ok=True) + target_path = os.path.join(target_dir, name) + + dler = FileDownload(target_path, _global_nam, url, request_params) + result = _download_asset(dler, capit_cat, silent, label or name) + + if result in [ + VaultDownloadResult.DL_ERROR, + VaultDownloadResult.UNKNOWN_ERROR, + ]: + QtWidgets.QMessageBox.information( + None, + f"{capit_cat} not downloadable", + f"Failed to download {category} from
{url}", + ) + return result == VaultDownloadResult.SUCCESS diff --git a/src/vault/luaparser.py b/src/vaults/luaparser.py similarity index 83% rename from src/vault/luaparser.py rename to src/vaults/luaparser.py index 9f3089756..a0f130c75 100644 --- a/src/vault/luaparser.py +++ b/src/vaults/luaparser.py @@ -12,41 +12,45 @@ *** IMPORTANT *** All the parsed lua item names are lower case (not the values) -If there were more than one occurrences found, only the last matched is returned -If there is default value for a search entry, error will not be generated. Default value will be returned instead +If there were more than one occurrences found, only the last matched is +returned +If there is default value for a search entry, error will not be generated. +Default value will be returned instead *** IMPORTANT *** 'parent>name:command' you can use * character here parent - item parent - you can specify multiple parents (parent1>parent2>...>name) - you should explicitly specify parents (you cannot drop in-between parents and expect it will find desired items) - you can drop parents at all + * you can specify multiple parents (parent1>parent2>...>name) + * you should explicitly specify parents (you cannot drop in-between + parents and expect it will find desired items) + * you can drop parents at all name - name of desired parameter in lua in lower case special characters, like ' [ ] " are pulled down command - an instruction to the parser (only 1 supported command so far) - count - counts all matched elements if they are strings, and size of lists or dicts otherwise - + count - counts all matched elements if they are strings, and size of + lists or dicts otherwise + 'destination:alias' alias - a name under which matched item will be returned you can use any of following patterns: __self__ - returns item name as it is in lua __parent__ - returns item parent - destination - you can specify a dictionary for matched items in the resulting array + destination - you can specify a dictionary for matched items in the + resulting array """ -import re -import zipfile import os +import re class luaParser: - + def __init__(self, luaPath): self.iszip = False self.zip = None self.__path = luaPath - self.__keyFilter = re.compile("[\[\],'\"]") - self.__valFilter = re.compile("[\[\]]") + self.__keyFilter = re.compile(r"[\[\],'\"]") + self.__valFilter = re.compile(r"[\[\]]") self.__searchResult = dict() self.__searchPattern = dict() self.__foundItemsCount = dict() @@ -63,7 +67,7 @@ def __init__(self, luaPath): self.warning = False self.errorMsg = "" self.loweringKeys = True - + def __checkUninterruptibleStr(self, char): if char == "\"" or char == "'": if not self.__inString: @@ -74,21 +78,21 @@ def __checkUninterruptibleStr(self, char): elif not self.__inString and char == "(": self.__inString = True self.__stringChar = ")" - + def __processLine(self, parent=""): - # initialize item counter counter = 0 - # initialize empty array lua = dict() - # initialize value value = "" + key = "" + prevkey = "" # start cycle while len(self.__stream): # get a line from the list or read next line from the file if len(self.__lines) == 0: line = self.__stream.pop(0) - # cut commentary section (either start whit '#' or is a '--[[ ]]--' section - comment = re.compile("((#.*))$|((--\[\[).*(\]\]--)$)") + # cut commentary section (either start whit '#' or is a + # '--[[ ]]--' section + comment = re.compile(r"((#.*))$|((--\[\[).*(\]\]--)$)") line = comment.sub("", line) # process line to see if it one command or a stack of them newLine = 0 @@ -124,8 +128,9 @@ def __processLine(self, parent=""): line = line.strip() # if the string is not empty, proceed if line != "": - # split it by '=' - lineArray = line.split("=") + # split it by '=' -- actually, by ' = ', because + # there can be values with '=' inside, e.g. '=============', + lineArray = line.split(" = ") # if result is one element list if len(lineArray) == 1: # this element is value @@ -145,7 +150,6 @@ def __processLine(self, parent=""): # if value is '}' - which is end of lua array, stop parsing if value == "}": break - unfinished = False if len(value) != 0: # remove finishing comma if there is one if value[-1] == ",": @@ -159,9 +163,9 @@ def __processLine(self, parent=""): # add new item into the array: recursive function call if self.__prevUnfinished: self.__prevUnfinished = False - lua[prevkey] = self.__processLine(parent+">"+prevkey) + lua[prevkey] = self.__processLine(parent + ">" + prevkey) else: - lua[key] = self.__processLine(parent+">"+key) + lua[key] = self.__processLine(parent + ">" + key) else: # add new item into the array: value itself if value[0] == "\"" or value[0] == "'": @@ -169,17 +173,17 @@ def __processLine(self, parent=""): if value[-1] == "\"" or value[-1] == "'": value = value[:-1] lua[key] = value - elif len(value) != 0: - # add new item into the array: value itself - lua[key] = value elif len(key) != 0: self.__prevUnfinished = True prevkey = key # checking line if it suits searchPattern, and adding if so for searchKey in self.__searchPattern: - # regkey = re.compile(".*("+searchKey.split(":")[-1].replace("*", ".*")+")$") + # regkey = re.compile( + # ".*("+searchKey.split(":")[-1].replace("*", ".*")+")$" + # ) # if regkey.match(parent+">"+key): - if re.match(".*>("+searchKey.split(":")[-1].replace("*", ".*")+")$", parent+">"+key): + matchKey = searchKey.split(":")[-1].replace("*", ".*") + if re.match(".*>(" + matchKey + ")$", parent + ">" + key): # get command from key valcmd = searchKey.split(":") valcmd = valcmd[0] if len(valcmd) == 2 else "none" @@ -200,7 +204,9 @@ def __processLine(self, parent=""): else: resultVal = lua[key] resultKey = resultKey.replace("__self__", key) - resultKey = resultKey.replace("__parent__", parent.split(">")[-1]) + resultKey = resultKey.replace( + "__parent__", parent.split(">")[-1], + ) keycmd = resultKey.split(":") # unpack command from search key if len(keycmd) == 2: @@ -219,9 +225,9 @@ def __processLine(self, parent=""): self.__searchResult[keydst] = dict() self.__searchResult[keydst][resultKey] = resultVal if isinstance(resultVal, int): - self.__foundItemsCount[searchKey] = self.__foundItemsCount[searchKey] + resultVal + self.__foundItemsCount[searchKey] += resultVal else: - self.__foundItemsCount[searchKey] = self.__foundItemsCount[searchKey] + 1 + self.__foundItemsCount[searchKey] += 1 # increase counter counter = counter + 1 # return resulting array @@ -233,7 +239,7 @@ def __parseLua(self): f = open(self.__path, "r") else: if self.zip.testzip() is None: - for member in self.zip.namelist() : + for member in self.zip.namelist(): filename = os.path.basename(member) if not filename: continue @@ -245,8 +251,9 @@ def __parseLua(self): self.__stream = f.readlines() if self.__stream[-1][-1] != "\n": # file doesn't end in a newline - # needed to prevent a bug happening when a file doesn't end with a newline. - self.__stream[-1] += "\n" + # needed to prevent a bug happening when a file doesn't end with a + # newline. + self.__stream[-1] += "\n" f.close() # call recursive function result = self.__processLine() @@ -261,25 +268,25 @@ def __checkErrors(self): self.__searchResult[resultKey] = self.__defaultValues[resultKey] else: self.error = True - self.errors = self.errors + 1 - self.errorMsg = self.errorMsg + "Error: no matches for '" + key + "' were found\n" + self.errors += 1 + self.errorMsg += f"Error: no matches for {key!r} were found\n" else: if self.__foundItemsCount[key] == 0: if resultKey in self.__defaultValues: self.__searchResult[resultKey] = self.__defaultValues[resultKey] else: self.error = True - self.errors = self.errors + 1 - self.errorMsg = self.errorMsg + "Error: no matches for '" + key + "' were found\n" + self.errors += 1 + self.errorMsg += f"Error: no matches for {key!r} were found\n" elif self.__foundItemsCount[key] > 1: self.warning = True - self.warnings = self.warnings + 1 - self.errorMsg = self.errorMsg + "Warning: there were duplicate occurrences for '" + key + "'\n" + self.warnings += 1 + self.errorMsg += f"Warning: there were duplicate occurrences for {key!r}\n" def parse(self, luaSearch, defValues=dict()): self.__searchPattern.update(luaSearch) self.__defaultValues.update(defValues) - self.__foundItemsCount = {}.fromkeys(list(self.__searchPattern.keys()), 0) + self.__foundItemsCount = {}.fromkeys(self.__searchPattern, 0) self.__parsedData = self.__parseLua() self.__checkErrors() return self.__searchResult diff --git a/src/vaults/mapvault/__init__.py b/src/vaults/mapvault/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py new file mode 100644 index 000000000..ed4481d2e --- /dev/null +++ b/src/vaults/mapvault/mapitem.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import util +from api.models.Map import Map +from fa import maps +from mapGenerator import mapgenUtils +from vaults.vaultitem import VaultListItem + +if TYPE_CHECKING: + from vaults.mapvault.mapvault import MapVault + + +class MapListItem(VaultListItem): + def __init__(self, parent: MapVault, item_info: Map, *args, **kwargs) -> None: + super().__init__(parent, item_info, *args, **kwargs) + self.html = str(util.THEME.readfile("vaults/mapvault/mapinfo.qthtml")) + self._preview_dler.set_target_dir(util.MAP_PREVIEW_SMALL_DIR) + self.update() + + def update(self) -> None: + if thumbnail := maps.preview(self.item_version.folder_name): + self.setIcon(thumbnail) + else: + if self.item_version.thumbnail_url_small == "": + if mapgenUtils.isGeneratedMap(self.item_version.folder_name): + self.setItemIcon("games/generated_map.png") + else: + self.setItemIcon("games/unknown_map.png") + else: + self._preview_dler.download( + f"{self.item_version.folder_name}.png", + self._item_dl_request, + self.item_version.thumbnail_url_small, + ) + VaultListItem.update(self) + + def should_be_visible(self) -> bool: + p = self.parent + if p.showType == "all": + return True + elif p.showType == "unranked": + return not self.item_version.ranked + elif p.showType == "ranked": + return self.item_version.ranked + elif p.showType == "installed": + return maps.isMapAvailable(self.item_version.folder_name) + else: + return True + + def update_visibility(self): + if maps.isMapAvailable(self.item_version.folder_name): + color = "green" + else: + color = "white" + + maptype = "" if self.item_version.ranked else "Unranked map" + if self.item_info.reviews_summary is None: + score = reviews = "-" + else: + score = round(self.item_info.reviews_summary.average_score, 1) + reviews = self.item_info.reviews_summary.num_reviews + + self.setText( + self.html.format( + color=color, + version=self.item_version.version, + title=self.item_info.display_name, + description=self.item_version.description, + rating=score, + reviews=reviews, + date=util.utctolocal(self.item_version.create_time), + modtype=maptype, + height=self.item_version.size.height_km, + width=self.item_version.size.width_km, + ), + ) + super().update_visibility() + + def __lt__(self, other: MapListItem) -> bool: + if self.parent.sortType == "size": + return self._lt_size(other) + return super().__lt__(other) + + def _lt_size(self, other: MapListItem) -> bool: + if self.item_version.size == other.item_version.size: + return self._lt_alphabetical(other) + return self.item_version.size < other.item_version.size diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py new file mode 100644 index 000000000..1cdc7450e --- /dev/null +++ b/src/vaults/mapvault/mapvault.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import logging +import os +import shutil +import urllib.error +import urllib.parse +import urllib.request +from stat import S_IWRITE +from typing import TYPE_CHECKING + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import util +from api.models.Map import Map +from api.vaults_api import MapApiConnector +from api.vaults_api import MapPoolApiConnector +from fa import maps +from vaults import luaparser +from vaults.mapvault.mapitem import MapListItem +from vaults.vault import Vault + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + +from .mapwidget import MapWidget + +logger = logging.getLogger(__name__) + + +class MapVault(Vault): + def __init__(self, client: ClientWindow, *args, **kwargs) -> None: + QtCore.QObject.__init__(self, *args, **kwargs) + Vault.__init__(self, client, *args, **kwargs) + + logger.debug("Map Vault tab instantiating") + + self.itemList.itemDoubleClicked.connect(self.itemClicked) + self.client.authorized.connect(self.busy_entered) + + self.installed_maps = maps.getUserMaps() + + for type_ in ["Size"]: + self.SortTypeList.addItem(type_) + for type_ in ["Unranked Only", "Ranked Only", "Installed"]: + self.ShowTypeList.addItem(type_) + + self.mapApiConnector = MapApiConnector() + self.mapPoolApiConnector = MapPoolApiConnector() + self.mapApiConnector.data_ready.connect(self.mapInfo) + self.mapPoolApiConnector.data_ready.connect(self.mapInfo) + + self.apiConnector = self.mapApiConnector + + self.busy_entered() + self.UIButton.hide() + self.uploadButton.hide() + + def create_item(self, item: Map) -> MapListItem: + return MapListItem(self, item) + + @QtCore.pyqtSlot(dict) + def mapInfo(self, message: dict) -> None: + super().items_info(message) + + @QtCore.pyqtSlot(int) + def sortChanged(self, index): + if index == -1 or index == 0: + self.sortType = "alphabetical" + elif index == 1: + self.sortType = "date" + elif index == 2: + self.sortType = "rating" + elif index == 3: + self.sortType = "size" + self.update_visibilities() + + @QtCore.pyqtSlot(int) + def showChanged(self, index): + if index == -1 or index == 0: + self.showType = "all" + elif index == 1: + self.showType = "unranked" + elif index == 2: + self.showType = "ranked" + elif index == 3: + self.showType = "installed" + self.update_visibilities() + + @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) + def itemClicked(self, item: MapListItem) -> None: + widget = MapWidget(self, item) + widget.exec() + + def requestMapPool(self, queueName, minRating): + self.apiConnector = self.mapPoolApiConnector + self.searchQuery = { + "filter": ";".join(( + f"mapPool.matchmakerQueueMapPool.matchmakerQueue.technicalName=={queueName}", + ( + f"(mapPool.matchmakerQueueMapPool.minRating=le={minRating!r}," + "mapPool.matchmakerQueueMapPool.minRating=isnull='true')" + ), + )), + } + self.goToPage(1) + self.apiConnector = self.mapApiConnector + + @QtCore.pyqtSlot() + def uploadMap(self): + mapDir = QtWidgets.QFileDialog.getExistingDirectory( + self.client, + "Select the map directory to upload", + maps.getUserMapsFolder(), + QtWidgets.QFileDialog.ShowDirsOnly, + ) + logger.debug("Uploading map from: " + mapDir) + if mapDir != "": + if maps.isMapFolderValid(mapDir): + os.chmod(mapDir, S_IWRITE) + mapName = os.path.basename(mapDir) + # zipName = mapName.lower() + ".zip" + + scenariolua = luaparser.luaParser( + os.path.join(mapDir, maps.getScenarioFile(mapDir)), + ) + scenarioInfos = scenariolua.parse( + { + 'scenarioinfo>name': 'name', + 'size': 'map_size', + 'description': 'description', + 'count:armies': 'max_players', + 'map_version': 'version', + 'type': 'map_type', + 'teams>0>name': 'battle_type', + }, + {'version': '1'}, + ) + + if scenariolua.error: + logger.debug( + "There were {} errors and {} warnings".format( + scenariolua.errors, + scenariolua.warnings, + ), + ) + logger.debug(scenariolua.errorMsg) + QtWidgets.QMessageBox.critical( + self.client, + "Lua parsing error", + ( + "{}\nMap uploading cancelled." + .format(scenariolua.errorMsg) + ), + ) + else: + if scenariolua.warning: + uploadmap = QtWidgets.QMessageBox.question( + self.client, + "Lua parsing warning", + ( + "{}\nDo you want to upload the map?" + .format(scenariolua.errorMsg) + ), + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + else: + uploadmap = QtWidgets.QMessageBox.StandardButton.Yes + if uploadmap == QtWidgets.QMessageBox.StandardButton.Yes: + savelua = luaparser.luaParser( + os.path.join(mapDir, maps.getSaveFile(mapDir)), + ) + saveInfos = savelua.parse({ + 'markers>mass*>position': 'mass:__parent__', + 'markers>hydro*>position': 'hydro:__parent__', + 'markers>army*>position': 'army:__parent__', + }) + if savelua.error or savelua.warning: + logger.debug( + "There were {} errors and {} warnings" + .format( + scenariolua.errors, + scenariolua.warnings, + ), + ) + logger.debug(scenariolua.errorMsg) + + self.__preparePositions( + saveInfos, + scenarioInfos["map_size"], + ) + + tmpFile = maps.processMapFolderForUpload( + mapDir, + saveInfos, + ) + if not tmpFile: + QtWidgets.QMessageBox.critical( + self.client, + "Map uploading error", + ( + "Couldn't make previews for {}\n" + "Map uploading cancelled.".format(mapName) + ), + ) + return None + + qfile = QtCore.QFile(tmpFile.name) + + # TODO: implement uploading via API + ... + # removing temporary files + qfile.remove() + else: + QtWidgets.QMessageBox.information( + self.client, + "Map selection", + "This folder doesn't contain valid map data.", + ) + + @QtCore.pyqtSlot(str) + def downloadMap(self, link): + link = urllib.parse.unquote(link) + name = maps.link2name(link) + alt_name = name.replace(" ", "_") + avail_name = None + if maps.isMapAvailable(name): + avail_name = name + elif maps.isMapAvailable(alt_name): + avail_name = alt_name + if avail_name is None: + maps.downloadMap(name) + self.installed_maps.append(name) + self.update_visibilities() + else: + show = QtWidgets.QMessageBox.question( + self.client, + "Already got the Map", + ( + "Seems like you already have that map!
Would you " + "like to see it?" + ), + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + if show == QtWidgets.QMessageBox.StandardButton.Yes: + util.showDirInFileBrowser(maps.folderForMap(avail_name)) + + @QtCore.pyqtSlot(str) + def removeMap(self, folder): + maps_folder = os.path.join(maps.getUserMapsFolder(), folder) + if os.path.exists(maps_folder): + shutil.rmtree(maps_folder) + self.installed_maps.remove(folder) + self.update_visibilities() diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py new file mode 100644 index 000000000..a9178b2f5 --- /dev/null +++ b/src/vaults/mapvault/mapwidget.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + +import util +from downloadManager import DownloadRequest +from downloadManager import MapLargePreviewDownloader +from fa import maps +from mapGenerator import mapgenUtils +from vaults.mapvault.mapitem import MapListItem + +if TYPE_CHECKING: + from vaults.mapvault.mapvault import MapVault + +FormClass, BaseClass = util.THEME.loadUiType("vaults/mapvault/map.ui") + + +class MapWidget(FormClass, BaseClass): + ICONSIZE = QtCore.QSize(256, 256) + + def __init__(self, parent: MapVault, list_item: MapListItem, *args, **kwargs) -> None: + BaseClass.__init__(self, *args, **kwargs) + + self.setupUi(self) + self.parent = parent + + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() + + self._map = list_item.item_info + self.map_version = list_item.item_version + self.setWindowTitle(self._map.display_name) + + self.Title.setText(self._map.display_name) + self.Description.setText(self.map_version.description) + maptext = "" + if not self.map_version.ranked: + maptext = "Unranked map\n" + self.Info.setText(f"{maptext} Uploaded {self.map_version.create_time}") + self.Players.setText(f"Maximum players: {self.map_version.max_players}") + self.Size.setText(f"Size: {self.map_version.size}") + self._preview_dler = MapLargePreviewDownloader(util.MAP_PREVIEW_LARGE_DIR) + self._map_dl_request = DownloadRequest() + self._map_dl_request.done.connect(self._on_preview_downloaded) + + # Ensure that pixmap is set + self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png")) + self.update_preview() + + if maps.isBase(self.map_version.folder_name): + self.DownloadButton.setText("This is a base map") + self.DownloadButton.setEnabled(False) + elif mapgenUtils.isGeneratedMap(self.map_version.folder_name): + self.DownloadButton.setEnabled(False) + elif maps.isMapAvailable(self.map_version.folder_name): + self.DownloadButton.setText("Remove Map") + + self.DownloadButton.clicked.connect(self.download) + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + @QtCore.pyqtSlot() + def download(self) -> None: + if not maps.isMapAvailable(self.map_version.folder_name): + self.parent.downloadMap(self.map_version.download_url) + self.done(1) + else: + show = QtWidgets.QMessageBox.question( + self.parent.client, + "Delete Map", + "Are you sure you want to delete this map?", + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + if show == QtWidgets.QMessageBox.StandardButton.Yes: + self.parent.removeMap(self.map_version.folder_name) + self.done(1) + + def update_preview(self) -> None: + imgPath = os.path.join(util.MAP_PREVIEW_LARGE_DIR, f"{self.map_version.folder_name}.png") + if os.path.isfile(imgPath): + pix = QtGui.QPixmap(imgPath).scaled(self.ICONSIZE) + self.Picture.setPixmap(pix) + elif mapgenUtils.isGeneratedMap(self.map_version.folder_name): + self.Picture.setPixmap(util.THEME.pixmap("games/generated_map.png")) + else: + self._preview_dler.download_preview( + self.map_version.folder_name, + self._map_dl_request, + ) + + def _on_preview_downloaded(self, mapname, result: tuple[str, bool]) -> None: + filename, themed = result + pixmap = util.THEME.pixmap(filename, themed) + if themed: + self.Picture.setPixmap(pixmap) + else: + self.Picture.setPixmap(pixmap.scaled(self.ICONSIZE)) diff --git a/src/vaults/modvault/__init__.py b/src/vaults/modvault/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py new file mode 100644 index 000000000..b510b63a9 --- /dev/null +++ b/src/vaults/modvault/moditem.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +import urllib +from typing import TYPE_CHECKING + +import util +from api.models.Mod import Mod +from api.models.ModVersion import ModType +from vaults.modvault import utils +from vaults.vaultitem import VaultListItem + +if TYPE_CHECKING: + from vaults.modvault.modvault import ModVault + + +class ModListItem(VaultListItem): + def __init__(self, parent: ModVault, item_info: Mod, *args, **kwargs) -> None: + super().__init__(parent, item_info, *args, **kwargs) + self.html = str(util.THEME.readfile("vaults/modvault/modinfo.qthtml")) + self._preview_dler.set_target_dir(util.MOD_PREVIEW_DIR) + self.update() + + def should_be_visible(self) -> bool: + p = self.parent + if p.showType == "all": + return True + elif p.showType == "ui": + return self.item_version.modtype == ModType.UI + elif p.showType == "sim": + return self.item_version.modtype == ModType.SIM + elif p.showType == "yours": + return self.item_info.author == self.parent.client.login + elif p.showType == "installed": + return self.item_version.uid in self.parent.uids + else: + return True + + def update(self) -> None: + if thumbstr := self.item_version.thumbnail_url: + name = os.path.basename(urllib.parse.unquote(thumbstr)) + img = utils.getIcon(name) + if img: + self.set_item_icon(img, False) + else: + self._preview_dler.download(name, self._item_dl_request, thumbstr) + else: + self.set_item_icon("games/unknown_map.png") + super().update() + + def update_visibility(self) -> None: + if self.item_version.modtype == ModType.UI: + modtype = "UI mod" + else: + modtype = "" + + if self.item_version.uid in self.parent.uids: + color = "green" + else: + color = "white" + + if self.item_info.reviews_summary is None: + score = reviews = "-" + else: + score = round(self.item_info.reviews_summary.average_score, 1) + reviews = self.item_info.reviews_summary.num_reviews + + self.setText( + self.html.format( + color=color, + version=self.item_version.version, + title=self.item_info.display_name, + description=self.item_version.description, + rating=score, + reviews=reviews, + date=util.utctolocal(self.item_version.create_time), + modtype=modtype, + author=self.item_info.author, + ), + ) + super().update_visibility() diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py new file mode 100644 index 000000000..5b0683add --- /dev/null +++ b/src/vaults/modvault/modvault.py @@ -0,0 +1,186 @@ +""" +modInfo function is called when the client recieves a modvault_info command. +It should have a message dict with the following keys: +uid - Unique identifier for a mod. Also needed ingame. +name - Name of the mod. Also the name of the folder the mod will be + located in. +description - A general description of the mod. As seen ingame +author - The FAF username of the person that uploaded the mod. +downloads - An integer containing the amount of downloads of this mod +likes - An integer containing the amount of likes the mod has recieved. + (TODO: Actually implement an inteface for this.) +comments - A python list containing dictionaries containing the keys as + described above. +bugreports - A python list containing dictionaries containing the keys as + described above. +date - A string describing the date the mod was uploaded. + Format: "%Y-%m-%d %H:%M:%S" eg: 2012-10-28 16:50:28 +ui - A boolean describing if it is a ui mod yay or nay. +link - Direct link to the zip file containing the mod. +thumbnail - A direct link to the thumbnail file. Should be something suitable + for util.THEME.icon(). Not yet tested if this works correctly + +Additional stuff: +fa.exe now has a CheckMods method, which is used in fa.exe.check +check has a new argument 'additional_mods' for this. +In client._clientwindow joinGameFromURL is changed. The url should have a +queryItemValue called 'mods' which with json can be translated in a list of +modnames so that it can be checked with checkMods. +handle_game_launch should have a new key in the form of mods, which is a list +of modnames to be checked with checkMods. + +Stuff to be removed: +In _gameswidget.py in hostGameCLicked setActiveMods is called. +This should be done in the faf.exe.check function or in the lobby code. +It is here because the server doesn't yet send the mods info. + +The tempAddMods function should be removed after the server can return mods in +the modvault. +""" + +import logging +import os + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +from api.vaults_api import ModApiConnector +from vaults.modvault import utils +from vaults.modvault.moditem import ModListItem +from vaults.modvault.utils import ModInfo +from vaults.vault import Vault + +from .modwidget import ModWidget +from .uimodwidget import UIModWidget +from .uploadwidget import UploadModWidget + +logger = logging.getLogger(__name__) + + +class ModVault(Vault): + def __init__(self, client, *args, **kwargs): + QtCore.QObject.__init__(self, *args, **kwargs) + Vault.__init__(self, client, *args, **kwargs) + + logger.debug("Mod Vault tab instantiating") + + self.itemList.itemDoubleClicked.connect(self.modClicked) + self.UIButton.clicked.connect(self.openUIModForm) + + self.uids = [mod.uid for mod in utils.getInstalledMods()] + + for type_ in ["UI Only", "Sim Only", "Uploaded by You", "Installed"]: + self.ShowTypeList.addItem(type_) + + self.apiConnector = ModApiConnector() + self.apiConnector.data_ready.connect(self.modInfo) + + self.uploadButton.hide() + + def create_item(self, item_key: str) -> ModListItem: + return ModListItem(self, item_key) + + @QtCore.pyqtSlot(dict) + def modInfo(self, message: dict) -> None: + super().items_info(message) + + @QtCore.pyqtSlot(int) + def sortChanged(self, index): + if index == -1 or index == 0: + self.sortType = "alphabetical" + elif index == 1: + self.sortType = "date" + elif index == 2: + self.sortType = "rating" + self.update_visibilities() + + @QtCore.pyqtSlot(int) + def showChanged(self, index): + if index == -1 or index == 0: + self.showType = "all" + elif index == 1: + self.showType = "ui" + elif index == 2: + self.showType = "sim" + elif index == 3: + self.showType = "yours" + elif index == 4: + self.showType = "installed" + self.update_visibilities() + + @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) + def modClicked(self, item): + widget = ModWidget(self, item) + widget.exec() + + @QtCore.pyqtSlot() + def openUIModForm(self): + dialog = UIModWidget(self) + dialog.exec() + + @QtCore.pyqtSlot() + def openUploadForm(self): + modDir = QtWidgets.QFileDialog.getExistingDirectory( + self.client, + "Select the mod directory to upload", + utils.MODFOLDER, + QtWidgets.QFileDialog.ShowDirsOnly, + ) + logger.debug("Uploading mod from: " + modDir) + if modDir != "": + if utils.isModFolderValid(modDir): + # os.chmod(modDir, S_IWRITE) Don't need this at the moment + modinfofile, modinfo = utils.parseModInfo(modDir) + if modinfofile.error: + logger.debug( + "There were {} errors and {} warnings.".format( + modinfofile.error, + modinfofile.warnings, + ), + ) + logger.debug(modinfofile.errorMsg) + QtWidgets.QMessageBox.critical( + self.client, + "Lua parsing error", + modinfofile.errorMsg + "\nMod uploading cancelled.", + ) + else: + if modinfofile.warning: + uploadmod = QtWidgets.QMessageBox.question( + self.client, + "Lua parsing warning", + ( + modinfofile.errorMsg + + "\nDo you want to upload the mod?" + ), + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + else: + uploadmod = QtWidgets.QMessageBox.StandardButton.Yes + if uploadmod == QtWidgets.QMessageBox.StandardButton.Yes: + modinfo = utils.ModInfo(**modinfo) + modinfo.setFolder(os.path.split(modDir)[1]) + modinfo.update() + dialog = UploadModWidget(self, modDir, modinfo) + dialog.exec() + else: + QtWidgets.QMessageBox.information( + self.client, + "Mod selection", + "This folder doesn't contain a mod_info.lua file", + ) + + def downloadMod(self, link: str, name: str) -> bool: + if utils.downloadMod(link, name): + self.uids = [mod.uid for mod in utils.getInstalledMods()] + self.update_visibilities() + return True + else: + return False + + def removeMod(self, name: str, uid: str) -> None: + mod = ModInfo(name=name, uid=uid) + if utils.removeMod(mod): + self.uids = [m.uid for m in utils.installedMods] + self.update_visibilities() diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py new file mode 100644 index 000000000..c7dcc8b1b --- /dev/null +++ b/src/vaults/modvault/modwidget.py @@ -0,0 +1,185 @@ + +import os +import urllib.parse + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets + +import util +from api.models.ModType import ModType +from util import strtodate +from vaults.modvault.moditem import ModListItem + +from .modvault import utils + +FormClass, BaseClass = util.THEME.loadUiType("vaults/modvault/mod.ui") + + +class ModWidget(FormClass, BaseClass): + ICONSIZE = QtCore.QSize(100, 100) + + def __init__(self, parent: QtWidgets.QWidget, mod_item: ModListItem, *args, **kwargs) -> None: + BaseClass.__init__(self, *args, **kwargs) + + self.setupUi(self) + self.parent = parent + + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() + + self.mod = mod_item.item_info + self.mod_version = mod_item.item_version + + self.setWindowTitle(self.mod.display_name) + + self.Title.setText(self.mod.display_name) + self.Description.setText(self.mod_version.description) + modtext = "" + if self.mod_version.modtype == ModType.UI: + modtext = "UI mod" + self.Info.setText( + f"{modtext}\nBy {self.mod.author}\nUploaded {self.mod_version.create_time}", + ) + thumbnail = utils.getIcon( + os.path.basename(urllib.parse.unquote(self.mod_version.thumbnail_url)), + ) + if thumbnail is None: + self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png")) + else: + pixmap = util.THEME.pixmap(thumbnail, False) + self.Picture.setPixmap(pixmap.scaled(self.ICONSIZE)) + + # ensure that pixmap is set + if self.Picture.pixmap() is None or self.Picture.pixmap().isNull(): + self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png")) + + # self.Comments.setItemDelegate(CommentItemDelegate(self)) + # self.BugReports.setItemDelegate(CommentItemDelegate(self)) + + self.tabWidget.setEnabled(False) + + if self.mod_version.uid in self.parent.uids: + self.DownloadButton.setText("Remove Mod") + self.DownloadButton.clicked.connect(self.download) + + # self.likeButton.clicked.connect(self.like) + # self.LineComment.returnPressed.connect(self.addComment) + # self.LineBugReport.returnPressed.connect(self.addBugReport) + + # for item in mod.comments: + # comment = CommentItem(self,item["uid"]) + # comment.update(item) + # self.Comments.addItem(comment) + # for item in mod.bugreports: + # comment = CommentItem(self,item["uid"]) + # comment.update(item) + # self.BugReports.addItem(comment) + + self.likeButton.setEnabled(False) + self.LineComment.setEnabled(False) + self.LineBugReport.setEnabled(False) + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + @QtCore.pyqtSlot() + def download(self) -> None: + if self.mod_version.uid not in self.parent.uids: + self.parent.downloadMod(self.mod_version.download_url, self.mod.display_name) + self.done(1) + else: + show = QtWidgets.QMessageBox.question( + self.parent.client, + "Delete Mod", + "Are you sure you want to delete this mod?", + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + if show == QtWidgets.QMessageBox.StandardButton.Yes: + self.parent.removeMod(self.mod.display_name, self.mod_version.uid) + self.done(1) + + @QtCore.pyqtSlot() + def addComment(self): + # TODO: implement this with the use of API + ... + + @QtCore.pyqtSlot() + def addBugReport(self): + # TODO: implement this with the use of API (if possible) + ... + + @QtCore.pyqtSlot() + def like(self): + # TODO: implement this with the use of API + ... + + +class CommentItemDelegate(QtWidgets.QStyledItemDelegate): + TEXTWIDTH = 350 + TEXTHEIGHT = 60 + + def __init__(self, *args, **kwargs): + QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) + + def paint(self, painter, option, index, *args, **kwargs): + self.initStyleOption(option, index) + + painter.save() + + html = QtGui.QTextDocument() + html.setHtml(option.text) + + option.text = "" + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, + ) + + # Description + painter.translate(option.rect.left() + 10, option.rect.top() + 10) + clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height()) + html.drawContents(painter, clip) + + painter.restore() + + def sizeHint(self, option, index, *args, **kwargs): + self.initStyleOption(option, index) + + html = QtGui.QTextDocument() + html.setHtml(option.text) + html.setTextWidth(self.TEXTWIDTH) + return QtCore.QSize(self.TEXTWIDTH, self.TEXTHEIGHT) + + +class CommentItem(QtWidgets.QListWidgetItem): + FORMATTER_COMMENT = str( + util.THEME.readfile("vaults/modvault/comment.qthtml"), + ) + + def __init__(self, parent, uid, *args, **kwargs): + QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) + + self.parent = parent + self.uid = uid + self.text = "" + self.author = "" + self.date = None + + def update(self, dic): + self.text = dic["text"] + self.author = dic["author"] + self.date = strtodate(dic["date"]) + self.setText( + self.FORMATTER_COMMENT.format( + text=self.text, + author=self.author, + date=str(self.date), + ), + ) + + def __ge__(self, other): + return self.date > other.date + + def __lt__(self, other): + return self.date <= other.date diff --git a/src/vaults/modvault/uimodwidget.py b/src/vaults/modvault/uimodwidget.py new file mode 100644 index 000000000..ad3da49b6 --- /dev/null +++ b/src/vaults/modvault/uimodwidget.py @@ -0,0 +1,74 @@ + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import util +from vaults.modvault import utils + +FormClass, BaseClass = util.THEME.loadUiType("vaults/modvault/uimod.ui") + + +class UIModWidget(FormClass, BaseClass): + FORMATTER_UIMOD = str(util.THEME.readfile("vaults/modvault/uimod.qthtml")) + + def __init__(self, parent, *args, **kwargs): + BaseClass.__init__(self, *args, **kwargs) + + self.setupUi(self) + self.parent = parent + + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() + + self.setWindowTitle("Ui Mod Manager") + + self.doneButton.clicked.connect(self.doneClicked) + self.modList.itemEntered.connect(self.hoverOver) + allmods = utils.getInstalledMods() + self.uimods = {} + for mod in allmods: + if mod.ui_only: + self.uimods[mod.totalname] = mod + self.modList.addItem(mod.totalname) + + names = [mod.totalname for mod in utils.getActiveMods(uimods=True)] + for name in names: + activeModList = self.modList.findItems( + name, QtCore.Qt.MatchFlag.MatchExactly, + ) + if activeModList: + activeModList[0].setSelected(True) + + if len(self.uimods) != 0: + self.hoverOver(self.modList.item(0)) + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + @QtCore.pyqtSlot() + def doneClicked(self): + selected_mods = [ + self.uimods[str(item.text())] + for item in self.modList.selectedItems() + ] + succes = utils.setActiveMods(selected_mods, False) + if not succes: + QtWidgets.QMessageBox.information( + None, + "Error", + ( + "Could not set the active UI mods. Maybe something is " + "wrong with your game.prefs file. Please send your log." + ), + ) + self.done(1) + + @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) + def hoverOver(self, item): + mod = self.uimods[str(item.text())] + self.modInfo.setText( + self.FORMATTER_UIMOD.format( + name=mod.totalname, + description=mod.description, + ), + ) diff --git a/src/vaults/modvault/uploadwidget.py b/src/vaults/modvault/uploadwidget.py new file mode 100644 index 000000000..e35757d83 --- /dev/null +++ b/src/vaults/modvault/uploadwidget.py @@ -0,0 +1,154 @@ +import os +import tempfile +import zipfile + +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +import util +from vaults.modvault import utils + +FormClass, BaseClass = util.THEME.loadUiType("vaults/modvault/upload.ui") + + +class UploadModWidget(FormClass, BaseClass): + def __init__(self, parent, modDir, modinfo, *args, **kwargs): + BaseClass.__init__(self, *args, **kwargs) + + self.setupUi(self) + self.parent = parent + self.client = self.parent.client # type - ClientWindow + self.modinfo = modinfo + self.modDir = modDir + + util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() + + self.setWindowTitle("Uploading Mod") + + self.Name.setText(modinfo.name) + self.Version.setText(str(modinfo.version)) + if modinfo.ui_only: + self.isUILabel.setText("is UI Only") + else: + self.isUILabel.setText("not UI Only") + self.UID.setText(modinfo.uid) + self.Description.setPlainText(modinfo.description) + if modinfo.icon != "": + self.IconURI.setText(utils.iconPathToFull(modinfo.icon)) + self.updateThumbnail() + else: + self.Thumbnail.setPixmap( + util.THEME.pixmap("games/unknown_map.png"), + ) + self.UploadButton.pressed.connect(self.upload) + + def load_stylesheet(self): + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + @QtCore.pyqtSlot() + def upload(self): + n = self.Name.text() + if any([(i in n) for i in '"<*>|?/\\:']): + QtWidgets.QMessageBox.information( + self.client, + "Invalid Name", + "The mod name contains invalid characters: /\\<>|?:\"", + ) + return + + iconpath = utils.iconPathToFull(self.modinfo.icon) + infolder = False + if ( + iconpath != "" + and ( + os.path.commonprefix([ + os.path.normcase(self.modDir), + os.path.normcase(iconpath), + ]) + == os.path.normcase(self.modDir) + ) + ): # the icon is in the game folder + # localpath = utils.fullPathToIcon(iconpath) + infolder = True + if iconpath != "" and not infolder: + QtWidgets.QMessageBox.information( + self.client, + "Invalid Icon File", + ( + "The file {} is not located inside the modfolder. Copy the" + " icon file to your modfolder and change the mod_info.lua " + "accordingly".format(iconpath) + ), + ) + return + + try: + temp = tempfile.NamedTemporaryFile( + mode='w+b', suffix=".zip", delete=False, + ) + zipped = zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED) + zipdir(self.modDir, zipped, os.path.basename(self.modDir)) + zipped.close() + temp.flush() + except BaseException: + QtWidgets.QMessageBox.critical( + self.client, + "Mod uploading error", + "Something went wrong zipping the mod files.", + ) + return + # qfile = QtCore.QFile(temp.name) + # TODO: implement uploading via API + ... + + @QtCore.pyqtSlot() + def updateThumbnail(self): + iconfilename = utils.iconPathToFull(self.modinfo.icon) + if iconfilename == "": + return False + if os.path.splitext(iconfilename)[1].lower() == ".dds": + old = iconfilename + iconfilename = os.path.join( + self.modDir, + os.path.splitext(os.path.basename(iconfilename))[0] + ".png", + ) + succes = utils.generateThumbnail(old, iconfilename) + if not succes: + QtWidgets.QMessageBox.information( + self.client, + "Invalid Icon File", + ( + "Because FAF can't read DDS files, it tried to convert" + " it to a png. This failed. Try something else" + ), + ) + return False + try: + self.Thumbnail.setPixmap(util.THEME.pixmap(iconfilename, False)) + except BaseException: + QtWidgets.QMessageBox.information( + self.client, + "Invalid Icon File", + "This was not a valid icon file. Please pick a png or jpeg", + ) + return False + self.modinfo.thumbnail = utils.fullPathToIcon(iconfilename) + self.IconURI.setText(iconfilename) + return True + + +def zipdir(path, zipf, fname): + # zips the entire directory path to zipf. Every file in the zipfile starts + # with fname. So if path is "/foo/bar/hello" and fname is "test" then every + # file in zipf is of the form "/test/*.*" + path = os.path.normcase(path) + if path[-1] in r'\/': + path = path[:-1] + for root, dirs, files in os.walk(path): + for f in files: + name = os.path.join(os.path.normcase(root), f) + n = name[len(os.path.commonprefix([name, path])):] + if n[0] == "\\": + n = n[1:] + zipf.write(name, os.path.join(fname, n)) diff --git a/src/modvault/utils.py b/src/vaults/modvault/utils.py similarity index 52% rename from src/modvault/utils.py rename to src/vaults/modvault/utils.py index 21888f0ae..294741c91 100644 --- a/src/modvault/utils.py +++ b/src/vaults/modvault/utils.py @@ -1,32 +1,42 @@ +import logging import os -import sys -import urllib.request, urllib.error, urllib.parse import re import shutil +import zipfile -from PyQt5 import QtCore, QtWidgets, QtGui +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets -from util import PREFSFILENAME import util -import logging -from vault import luaparser -import warnings - -import io -import zipfile from config import Settings -from downloadManager import FileDownload -from vault.dialogs import VaultDownloadDialog, downloadVaultAsset +from util import PREFSFILENAME +from vaults import luaparser +from vaults.dialogs import downloadVaultAsset logger = logging.getLogger(__name__) -MODFOLDER = os.path.join(util.PERSONAL_DIR, "My Games", "Gas Powered Games", "Supreme Commander Forged Alliance", "Mods") + +def getModFolder(): + return os.path.join( + util.PERSONAL_DIR, "My Games", "Gas Powered Games", + "Supreme Commander Forged Alliance", "Mods", + ) + + +def setModFolder(): + global MODFOLDER + MODFOLDER = getModFolder() + + +setModFolder() MODVAULT_DOWNLOAD_ROOT = "{}/faf/vault/".format(Settings.get('content/host')) installedMods = [] # This is a global list that should be kept intact. # So it should be cleared using installedMods[:] = [] -# mods selected by user, are not overwritten by temporary mods selected when joining game +# mods selected by user, are not overwritten by temporary mods selected when +# joining game selectedMods = Settings.get('play/mods', default=[]) @@ -45,10 +55,10 @@ def setFolder(self, localfolder): def update(self): self.setFolder(self.localfolder) if isinstance(self.version, int): - self.totalname = "%s v%d" % (self.name, self.version) + self.totalname = "{} v{}".format(self.name, self.version) elif isinstance(self.version, float): s = str(self.version).rstrip("0") - self.totalname = "%s v%s" % (self.name, s) + self.totalname = "{} v{}".format(self.name, s) else: raise TypeError("version is not an int or float") @@ -60,14 +70,14 @@ def to_dict(self): return out def __str__(self): - return '%s in "%s"' % (self.totalname, self.localfolder) + return '{} in "{}"'.format(self.totalname, self.localfolder) def getAllModFolders(): # returns a list of names of installed mods - mods = [] - if os.path.isdir(MODFOLDER): - mods = os.listdir(MODFOLDER) - return mods + mods = [] + if os.path.isdir(MODFOLDER): + mods = os.listdir(MODFOLDER) + return mods def getInstalledMods(): @@ -77,16 +87,16 @@ def getInstalledMods(): if os.path.isdir(os.path.join(MODFOLDER, f)): try: m = getModInfoFromFolder(f) - except: + except BaseException: continue else: try: m = getModInfoFromZip(f) - except: + except BaseException: continue if m: installedMods.append(m) - logger.debug("getting installed mods. Count: %d" % len(installedMods)) + logger.debug("Getting installed mods. Count:{}".format(len(installedMods))) return installedMods @@ -100,56 +110,78 @@ def isModFolderValid(folder): def iconPathToFull(path): """ - Converts a path supplied in the icon field of mod_info with an absolute path to that file. - So "/mods/modname/data/icons/icon.dds" becomes - "C:\\Users\\user\Documents\My Games\Gas Powered Games\Supreme Commander Forged Alliance\Mods\modname\data\icons\icon.dds" + Converts a path supplied in the icon field of mod_info with an absolute + path to that file. So "/mods/modname/data/icons/icon.dds" becomes + "C:\\Users\\user\\Documents\\My Games\\Gas Powered Games\\Supreme Commander + ...Forged Alliance\\Mods\\modname\\data\\icons\\icon.dds" """ if not (path.startswith("/mods") or path.startswith("mods")): - logger.info("Something went wrong parsing the path %s" % path) + logger.info("Something went wrong parsing the path {}".format(path)) return "" - return os.path.join(MODFOLDER, os.path.normpath(path[5+int(path[0] == "/"):])) # yay for dirty hacks + + # yay for dirty hacks + return os.path.join( + MODFOLDER, os.path.normpath(path[5 + int(path[0] == "/"):]), + ) def fullPathToIcon(path): p = os.path.normpath(os.path.abspath(path)) - return p[len(MODFOLDER)-5:].replace('\\', '/') + return p[len(MODFOLDER) - 5:].replace('\\', '/') def getIcon(name): img = os.path.join(util.MOD_PREVIEW_DIR, name) if os.path.isfile(img): - logger.log(5, "Using cached preview image for: " + name) + logger.log(5, "Using cached preview image for: {}".format(name)) return img return None def getModInfo(modinfofile): - modinfo = modinfofile.parse({"name": "name", "uid": "uid", "version": "version", "author": "author", - "description": "description", "ui_only": "ui_only", - "icon": "icon"}, - {"version": "1", "ui_only": "false", "description": "", "icon": "", "author": ""}) + modinfo = modinfofile.parse( + { + "name": "name", + "uid": "uid", + "version": "version", + "author": "author", + "description": "description", + "ui_only": "ui_only", + "icon": "icon", + }, + { + "version": "1", + "ui_only": "false", + "description": "", + "icon": "", + "author": "", + }, + ) modinfo["ui_only"] = (modinfo["ui_only"] == 'true') if "uid" not in modinfo: - logger.warning("Couldn't find uid for mod %s" % modinfo["name"]) + logger.warning("Couldn't find uid for mod {}".format(modinfo["name"])) return None - #modinfo["uid"] = modinfo["uid"].lower() + # modinfo["uid"] = modinfo["uid"].lower() try: modinfo["version"] = int(modinfo["version"]) - except: + except BaseException: try: modinfo["version"] = float(modinfo["version"]) - except: + except BaseException: modinfo["version"] = 0 - logger.warning("Couldn't find version for mod %s" % modinfo["name"]) + logger.warning( + "Couldn't find version for mod {}".format(modinfo["name"]), + ) return modinfofile, modinfo def parseModInfo(folder): if not isModFolderValid(folder): return None - modinfofile = luaparser.luaParser(os.path.join(folder,"mod_info.lua")) + modinfofile = luaparser.luaParser(os.path.join(folder, "mod_info.lua")) return getModInfo(modinfofile) + modCache = {} @@ -160,23 +192,25 @@ def getModInfoFromZip(zfile): r = None if zipfile.is_zipfile(os.path.join(MODFOLDER, zfile)): - zip = zipfile.ZipFile(os.path.join(MODFOLDER,zfile), "r", zipfile.ZIP_DEFLATED) - if zip.testzip() is None: - for member in zip.namelist(): + zip_ = zipfile.ZipFile( + os.path.join(MODFOLDER, zfile), "r", zipfile.ZIP_DEFLATED, + ) + if zip_.testzip() is None: + for member in zip_.namelist(): filename = os.path.basename(member) if not filename: continue if filename == "mod_info.lua": modinfofile = luaparser.luaParser("mod_info.lua") modinfofile.iszip = True - modinfofile.zip = zip + modinfofile.zip = zip_ r = getModInfo(modinfofile) if r is None: - logger.debug("mod_info.lua not found in zip file %s" % zfile) + logger.debug("mod_info.lua not found in zip file {}".format(zfile)) return None f, info = r if f.error: - logger.debug("Error in parsing mod_info.lua in %s" % zfile) + logger.debug("Error in parsing mod_info.lua in {}".format(zfile)) return None m = ModInfo(**info) m.setFolder(zfile) @@ -191,11 +225,11 @@ def getModInfoFromFolder(modfolder): # modfolder must be local to MODFOLDER r = parseModInfo(os.path.join(MODFOLDER, modfolder)) if r is None: - logger.debug("mod_info.lua not found in %s folder" % modfolder) + logger.debug("mod_info.lua not found in {} folder".format(modfolder)) return None f, info = r if f.error: - logger.debug("Error in parsing %s/mod_info.lua" % modfolder) + logger.debug("Error in parsing {}/mod_info.lua".format(modfolder)) return None m = ModInfo(**info) m.setFolder(modfolder) @@ -204,7 +238,8 @@ def getModInfoFromFolder(modfolder): # modfolder must be local to MODFOLDER return m -def getActiveMods(uimods=None, temporary=True): # returns a list of ModInfo's containing information of the mods +# returns a list of ModInfo's containing information of the mods +def getActiveMods(uimods=None, temporary=True): """uimods: None - return all active mods True - only return active UI Mods @@ -218,47 +253,67 @@ def getActiveMods(uimods=None, temporary=True): # returns a list of ModInfo's c logger.info("No game.prefs file found") return [] if temporary: - l = luaparser.luaParser(PREFSFILENAME) - l.loweringKeys = False - modlist = l.parse({"active_mods":"active_mods"}, {"active_mods": {}})["active_mods"] - if l.error: + parser = luaparser.luaParser(PREFSFILENAME) + parser.loweringKeys = False + parsedlist = parser.parse( + {"active_mods": "active_mods"}, + {"active_mods": {}}, + ) + modlist = parsedlist["active_mods"] + if parser.error: logger.info("Error in reading the game.prefs file") return [] - uids = [uid for uid,b in list(modlist.items()) if b == 'true'] - #logger.debug("Active mods detected: %s" % str(uids)) + uids = [uid for uid, b in list(modlist.items()) if b == 'true'] + # logger.debug("Active mods detected: {}".format(str(uids))) else: uids = selectedMods[:] allmods = [] for m in installedMods: - if (uimods and m.ui_only) or (not uimods and not m.ui_only) or uimods is None: + if ( + (uimods and m.ui_only) + or (not uimods and not m.ui_only) + or uimods is None + ): allmods.append(m) active_mods = [m for m in allmods if m.uid in uids] - #logger.debug("Allmods uids: %s\n\nActive mods uids: %s\n" % (", ".join([mod.uid for mod in allmods]), ", ".join([mod.uid for mod in allmods]))) + # logger.debug( + # "All mods uids: {}\n\nActive mods uids: {}\n" + # .format(", ".join([mod.uid for mod in allmods]), + # ", ".join([mod.uid for mod in allmods])) + # ) return active_mods - except: + except BaseException: return [] -def setActiveMods(mods, keepuimods=True, temporary=True): # uimods works the same as in getActiveMods +# uimods works the same as in getActiveMods +def setActiveMods(mods, keepuimods=True, temporary=True): """ keepuimods: None: Replace all active mods with 'mods' True: Keep the UI mods already activated activated False: Keep only the non-UI mods that were activated activated - So set it True if you want to set gameplay mods, and False if you want to set UI mods. + So set it True if you want to set gameplay mods, and False if you want + to set UI mods. temporary: Set this when mods are activated due to joining a game. """ if keepuimods is not None: - keepTheseMods = getActiveMods(keepuimods) # returns the active UI mods if True, the active non-ui mods if False + # returns the active UI mods if True, the active non-ui mods if False + keepTheseMods = getActiveMods(keepuimods) else: keepTheseMods = [] allmods = keepTheseMods + mods - logger.debug('setting active Mods: {}'.format([mod.uid for mod in allmods])) + logger.debug( + 'Setting active Mods: {}'.format([ + mod.uid + for mod in allmods + ]), + ) s = "active_mods = {\n" for mod in allmods: - s += "['%s'] = true,\n" % str(mod.uid) + s += "['{}'] = true,\n".format(str(mod.uid)) s += "}" if not temporary: @@ -267,30 +322,28 @@ def setActiveMods(mods, keepuimods=True, temporary=True): # uimods works the sa selectedMods = [str(mod.uid) for mod in allmods] logger.debug('Writing selectedMods: {}'.format(selectedMods)) Settings.set('play/mods', selectedMods) - logger.debug('selectedMods written: {}'.format(Settings.get('play/mods'))) + logger.debug( + 'selectedMods written: {}'.format(Settings.get('play/mods')), + ) try: - f = open(PREFSFILENAME, 'r') - data = f.read() - except: + with open(PREFSFILENAME, 'r') as f: + data = f.read() + except BaseException: logger.info("Couldn't read the game.prefs file") return False - else: - f.close() - if re.search("active_mods\s*=\s*{.*?}", data, re.S): - data = re.sub("active_mods\s*=\s*{.*?}", s, data, 1, re.S) + if re.search(r"active_mods\s*=\s*{.*?}", data, re.S): + data = re.sub(r"active_mods\s*=\s*{.*?}", s, data, 1, re.S) else: data += "\n" + s try: - f = open(PREFSFILENAME, 'w') - f.write(data) - except: + with open(PREFSFILENAME, 'w') as f: + f.write(data) + except BaseException: logger.info("Cound't write to the game.prefs file") return False - else: - f.close() return True @@ -298,59 +351,65 @@ def setActiveMods(mods, keepuimods=True, temporary=True): # uimods works the sa def updateModInfo(mod, info): # should probably not be used. """ Updates a mod_info.lua file with new data. - Because those files can be random lua this function can fail if the file is complicated enough - If every value however is on a seperate line, this should work. + Because those files can be random lua this function can fail if the file is + complicated enough. If every value however is on a seperate line, this + should work. """ logger.warning("updateModInfo called. Probably not a good idea") fname = mod.mod_info try: - f = open(fname, 'r') - data = f.read() - except: - logger.info("Something went wrong reading %s" % fname) + with open(fname, 'r') as f: + data = f.read() + except BaseException: + logger.info("Something went wrong reading {}".format(fname)) return False - else: - f.close() for k, v in list(info.items()): if type(v) in (bool, int): val = str(v).lower() if type(v) in (str, str): val = '"' + v.replace('"', '\\"') + '"' - if re.search(r'^\s*'+k, data, re.M): - data = re.sub(r'^\s*' + k + r'\s*=.*$', "%s = %s" % (k, val), data, 1, re.M) + if re.search(r'^\s*' + k, data, re.M): + data = re.sub( + r'^\s*' + k + r'\s*=.*$', "{} = {}".format(k, val), + data, 1, re.M, + ) else: if data[-1] != '\n': data += '\n' - data += "%s = %s" % (k, val) + data += "{} = {}".format(k, val) try: - f = open(fname, 'w') - f.write(data) - except: - logger.info("Something went wrong writing to %s" % fname) + with open(fname, 'w') as f: + f.write(data) + except BaseException: + logger.info("Something went wrong writing to {}".format(fname)) return False - else: - f.close() return True def generateThumbnail(sourcename, destname): - """Given a dds file, generates a png file (or whatever the extension of dest is""" - logger.debug("Creating png thumnail for %s to %s" % (sourcename, destname)) + """ + Given a dds file, generates a png file (or whatever the extension + of dest is + """ + logger.debug( + "Creating png thumnail for {} to {}".format(sourcename, destname), + ) try: img = bytearray() buf = bytearray(16) - file = open(sourcename, "rb") - file.seek(128) # skip header - while file.readinto(buf): - img += buf[:3] + buf[4:7] + buf[8:11] + buf[12:15] - file.close() - - size = int((len(img)/3) ** (1.0/2)) - imageFile = QtGui.QImage(img, size, size, QtGui.QImage.Format_RGB888).rgbSwapped().\ - scaled(100, 100, transformMode=QtCore.Qt.SmoothTransformation) + with open(sourcename, "rb") as f: + f.seek(128) # skip header + while f.readinto(buf): + img += buf[:3] + buf[4:7] + buf[8:11] + buf[12:15] + + size = int((len(img) / 3) ** (1.0 / 2)) + image = QtGui.QImage(img, size, size, QtGui.QImage.Format_RGB888) + imageFile = image.rgbSwapped().scaled( + 100, 100, transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, + ) imageFile.save(destname) except IOError: return False @@ -361,33 +420,33 @@ def generateThumbnail(sourcename, destname): return False -def downloadMod(item): - if isinstance(item, str): - link = MODVAULT_DOWNLOAD_ROOT + urllib.parse.quote(item) - logger.debug("Getting mod from: " + link) - else: - link = item.link - logger.debug("Getting mod from: " + link) - link = urllib.parse.quote(link, "http://") +def downloadMod(link: str, name: str) -> bool: + logger.debug(f"Getting mod from: {link}") - def handle_exist(path, modname): + def handle_exist(path: str, modname: str) -> bool: modpath = os.path.join(path, modname) oldmod = getModInfoFromFolder(modpath) - result = QtWidgets.QMessageBox.question(None, "Modfolder already exists", - ("The mod is to be downloaded to the folder '{}'. This folder already " - "exists and contains {}. Do you want to overwrite this mod?") - .format(modpath, oldmod.totalname), QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No) - if result == QtWidgets.QMessageBox.No: + result = QtWidgets.QMessageBox.question( + None, + "Modfolder already exists", + ( + "The mod is to be downloaded to the folder '{}'. This folder " + "already exists and contains {}. Do you want to " + "overwrite this mod?" + ).format(modpath, oldmod.totalname), + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) + if result == QtWidgets.QMessageBox.StandardButton.No: return False removeMod(oldmod) return True - return downloadVaultAsset(link, MODFOLDER, handle_exist, link, "mod", False) + return downloadVaultAsset(link, MODFOLDER, handle_exist, name, "mod", silent=False) def removeMod(mod): - logger.debug("removing mod %s" % mod.name) + logger.debug("removing mod {}".format(mod.name)) real = None for m in getInstalledMods(): if m.uid == mod.uid: diff --git a/src/vaults/vault.py b/src/vaults/vault.py new file mode 100644 index 000000000..87d69fb28 --- /dev/null +++ b/src/vaults/vault.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from PyQt6 import QtCore + +import util +from ui.busy_widget import BusyWidget +from vaults.vaultitem import VaultItemDelegate +from vaults.vaultitem import VaultListItem + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + +logger = logging.getLogger(__name__) + + +FormClass, BaseClass = util.THEME.loadUiType("vaults/vault.ui") + + +class Vault(FormClass, BaseClass, BusyWidget): + def __init__(self, client: ClientWindow, *args, **kwargs) -> None: + QtCore.QObject.__init__(self, *args, **kwargs) + self.setupUi(self) + self.client = client + + self.itemList.setItemDelegate(VaultItemDelegate(self)) + + self.searchButton.clicked.connect(self.search) + self.searchInput.returnPressed.connect(self.search) + + self.SortTypeList.setCurrentIndex(0) + self.SortTypeList.currentIndexChanged.connect(self.sortChanged) + self.ShowTypeList.currentIndexChanged.connect(self.showChanged) + + self.sortType = "alphabetical" + self.showType = "all" + self.searchString = "" + self.searchQuery = {} + self.apiConnector = None + + self.pageSize = self.quantityBox.value() + self.pageNumber = 1 + + self.goToPageButton.clicked.connect( + lambda: self.goToPage(self.pageBox.value()), + ) + self.pageBox.setValue(self.pageNumber) + self.pageBox.valueChanged.connect(self.checkTotalPages) + self.totalPages = None + self.totalRecords = None + self.quantityBox.valueChanged.connect(self.checkPageSize) + self.nextButton.clicked.connect( + lambda: self.goToPage(self.pageBox.value() + 1), + ) + self.previousButton.clicked.connect( + lambda: self.goToPage(self.pageBox.value() - 1), + ) + self.firstButton.clicked.connect(lambda: self.goToPage(1)) + self.lastButton.clicked.connect(lambda: self.goToPage(self.totalPages)) + self.resetButton.clicked.connect(self.resetSearch) + + self._items = {} + self._installed_items = {} + + for type_ in ["Upload Date", "Rating"]: + self.SortTypeList.addItem(type_) + + @QtCore.pyqtSlot(int) + def checkPageSize(self): + self.pageSize = self.quantityBox.value() + + @QtCore.pyqtSlot(int) + def checkTotalPages(self): + if self.pageBox.value() > self.totalPages: + self.pageBox.setValue(self.totalPages) + + def updateQuery(self, pageNumber): + self.searchQuery['page[size]'] = self.pageSize + self.searchQuery['page[number]'] = pageNumber + self.searchQuery['page[totals]'] = None + + @QtCore.pyqtSlot(bool) + def goToPage(self, page: int) -> None: + if self.apiConnector is None: + return + + self._items.clear() + self.itemList.clear() + self.pageBox.setValue(page) + self.pageNumber = self.pageBox.value() + self.updateQuery(self.pageNumber) + self.apiConnector.request_data(self.searchQuery) + self.update_visibilities() + + def create_item(self, item_key: str) -> VaultListItem: + return VaultListItem(self, item_key) + + @QtCore.pyqtSlot(dict) + def items_info(self, message: dict) -> None: + for value in message["values"]: + item_key = value.xd + if item_key in self._items: + item = self._items[item_key] + else: + item = self.create_item(value) + self._items[item_key] = item + self.itemList.addItem(item) + self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder) + self.processMeta(message["meta"]) + + def processMeta(self, message: dict) -> None: + self.totalPages = message['page']['totalPages'] + self.totalRecords = message['page']['totalRecords'] + if self.totalPages < 1: + self.totalPages = 1 + self.labelTotalPages.setText(str(self.totalPages)) + + @QtCore.pyqtSlot(bool) + def resetSearch(self): + self.searchString = '' + self.searchInput.clear() + self.searchQuery.clear() + self.goToPage(1) + + def search(self): + self.searchString = self.searchInput.text().lower() + if self.searchString == '' or self.searchString.replace(' ', '') == '': + self.resetSearch() + else: + self.searchString = self.searchString.strip() + self.searchQuery = {"filter": f"displayName=='*{self.searchString}*'"} + self.goToPage(1) + + @QtCore.pyqtSlot() + def busy_entered(self): + if not self._items: + self.goToPage(self.pageNumber) + + def update_visibilities(self) -> None: + logger.debug( + f"Updating visibilities with sort {self.sortType!r} and visibility {self.showType!r}", + ) + for item in self._items.values(): + item.update_visibility() + self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder) diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py new file mode 100644 index 000000000..4e5cbcf00 --- /dev/null +++ b/src/vaults/vaultitem.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6.QtWidgets import QListWidgetItem +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QStyledItemDelegate + +import util +from api.models.Map import Map +from api.models.Mod import Mod +from downloadManager import Downloader +from downloadManager import DownloadRequest + +if TYPE_CHECKING: + from vaults.vault import Vault + + +class VaultListItem(QListWidgetItem): + TEXTWIDTH = 230 + ICONSIZE = 100 + PADDING = 10 + + def __init__(self, parent: Vault, item_info: Mod | Map, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.parent = parent + self.setHidden(True) + + self.item_info = item_info + self.item_version = item_info.version + + self._preview_dler = Downloader(util.CACHE_DIR) + self._item_dl_request = DownloadRequest() + self._item_dl_request.done.connect(self._on_item_downloaded) + + def update(self): + self.ensure_icon() + self.update_visibility() + + def set_item_icon(self, filename: str, themed: bool = True) -> None: + icon = util.THEME.icon(filename) + if not themed: + pixmap = QtGui.QPixmap(filename) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaled(QtCore.QSize(self.ICONSIZE, self.ICONSIZE)) + icon.addPixmap(scaled_pixmap) + self.setIcon(icon) + + def ensure_icon(self): + if self.icon() is None or self.icon().isNull(): + self.set_item_icon("games/unknown_map.png") + + def _on_item_downloaded(self, mapname: str, result: tuple[str, bool]) -> None: + filename, themed = result + self.set_item_icon(filename, themed) + self.ensure_icon() + + def should_be_hidden(self) -> bool: + return not self.should_be_visible() + + def should_be_visible(self) -> bool: + return True + + def update_visibility(self) -> None: + self.setHidden(self.should_be_hidden()) + if len(self.item_version.description) < 200: + trimmed_description = self.item_version.description + else: + trimmed_description = f"{self.item_version.description[:197]}..." + self.setToolTip('

{}

'.format(trimmed_description)) + + def __ge__(self, other: VaultListItem) -> bool: + return not self.__lt__(self, other) + + def __lt__(self, other: VaultListItem) -> bool: + if self.parent.sortType == "alphabetical": + return self._lt_alphabetical(other) + elif self.parent.sortType == "rating": + return self._lt_rating(other) + elif self.parent.sortType == "date": + return self._lt_date(other) + return True + + def _lt_date(self, other: VaultListItem) -> bool: + if self.item_version.create_time == other.item_version.create_time: + if self.item_version.update_time == other.item_version.update_time: + return self._lt_alphabetical(other) + return self.item_version.update_time < other.item_version.update_time + return self.item_version.create_time < other.item_version.create_time + + def _lt_alphabetical(self, other: VaultListItem) -> bool: + return self.item_info.display_name.lower() > other.item_info.display_name.lower() + + def _lt_rating(self, other: VaultListItem) -> bool: + review = self.item_info.reviews_summary + other_review = other.item_info.reviews_summary + + if review is None: + return other_review is not None + if other_review is None: + return review is None + + if review.average_score == other_review.average_score: + if review.num_reviews == other_review.num_reviews: + return self._lt_alphabetical(other) + return review.num_reviews < other_review.num_reviews + + return review.average_score < other_review.average_score + + +class VaultItemDelegate(QStyledItemDelegate): + def paint(self, painter, option, index, *args, **kwargs): + self.initStyleOption(option, index) + + painter.save() + + html = QtGui.QTextDocument() + html.setHtml(option.text) + + icon = QtGui.QIcon(option.icon) + iconsize = QtCore.QSize(VaultListItem.ICONSIZE, VaultListItem.ICONSIZE) + + # clear icon and text before letting the control draw itself because + # we're rendering these parts ourselves + option.icon = QtGui.QIcon() + option.text = "" + control_element = QStyle.ControlElement.CE_ItemViewItem + option.widget.style().drawControl(control_element, option, painter, option.widget) + + # Shadow + painter.fillRect( + option.rect.left() + 7, + option.rect.top() + 7, + iconsize.width(), + iconsize.height(), + QtGui.QColor("#202020"), + ) + + iconrect = QtCore.QRect(option.rect.adjusted(3, 3, 0, 0)) + iconrect.setSize(iconsize) + # Icon + alignment = QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + icon.paint(painter, iconrect, alignment) + + # Frame around the icon + pen = QtGui.QPen() + pen.setWidth(1) + # FIXME: This needs to come from theme. + pen.setBrush(QtGui.QColor("#303030")) + + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawRect(iconrect) + + # Description + painter.translate( + option.rect.left() + iconsize.width() + 10, option.rect.top() + 4, + ) + clip = QtCore.QRectF( + 0, 0, option.rect.width() - iconsize.width() - 15, + option.rect.height(), + ) + html.drawContents(painter, clip) + + painter.restore() + + def sizeHint(self, option, index, *args, **kwargs): + self.initStyleOption(option, index) + + html = QtGui.QTextDocument() + html.setHtml(option.text) + html.setTextWidth(VaultListItem.TEXTWIDTH) + return QtCore.QSize( + ( + VaultListItem.ICONSIZE + + VaultListItem.TEXTWIDTH + + VaultListItem.PADDING + ), + VaultListItem.ICONSIZE + VaultListItem.PADDING, + ) diff --git a/tests/conftest.py b/tests/conftest.py index d1fb13e25..335177a67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,12 @@ from model.playerset import Playerset +@pytest.fixture +def client_instance(): + from client import instance + return instance + + @pytest.fixture def player(mocker): return mocker.MagicMock(spec=Player) @@ -24,3 +30,9 @@ def playerset(mocker): @pytest.fixture def gameset(mocker): return mocker.MagicMock(spec=Gameset) + + +@pytest.fixture +def mouse_position(client_instance): + from client.mouse_position import MousePosition + return MousePosition(client_instance) diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py deleted file mode 100644 index 4c9449411..000000000 --- a/tests/fa/test_featured.py +++ /dev/null @@ -1,92 +0,0 @@ -__author__ = 'Thygrrr' - -from fa import updater -from PyQt5 import QtWidgets, QtCore -import pytest -import collections - -class _TestObjectWithoutIsFinished(QtCore.QObject): - finished = QtCore.pyqtSignal() - - -class _TestThreadNoOp(QtCore.QThread): - def run(self): - self.yieldCurrentThread() - - -def test_updater_is_a_dialog(application): - assert isinstance(updater.UpdaterProgressDialog(None), QtWidgets.QDialog) - - -def test_updater_has_progress_bar_game_progress(application): - assert isinstance(updater.UpdaterProgressDialog(None).gameProgress, QtWidgets.QProgressBar) - - -def test_updater_has_progress_bar_map_progress(application): - assert isinstance(updater.UpdaterProgressDialog(None).mapProgress, QtWidgets.QProgressBar) - - -def test_updater_has_progress_bar_mod_progress(application): - assert isinstance(updater.UpdaterProgressDialog(None).mapProgress, QtWidgets.QProgressBar) - - -def test_updater_has_method_append_log(application): - assert isinstance(updater.UpdaterProgressDialog(None).appendLog, collections.Callable) - - -def test_updater_append_log_accepts_string(application): - updater.UpdaterProgressDialog(None).appendLog("Hello Test") - - -def test_updater_has_method_add_watch(application): - assert isinstance(updater.UpdaterProgressDialog(None).addWatch, collections.Callable) - - -def test_updater_append_log_accepts_qobject_with_signals_finished(application): - updater.UpdaterProgressDialog(None).addWatch(QtCore.QThread()) - - -def test_updater_add_watch_raises_error_on_watch_without_signal_finished(application): - with pytest.raises(AttributeError): - updater.UpdaterProgressDialog(None).addWatch(QtCore.QObject()) - - -def test_updater_watch_finished_raises_error_on_watch_without_method_is_finished(application): - u = updater.UpdaterProgressDialog(None) - u.addWatch(_TestObjectWithoutIsFinished()) - with pytest.raises(AttributeError): - u.watchFinished() - - -def test_updater_hides_and_accepts_if_all_watches_are_finished(application): - u = updater.UpdaterProgressDialog(None) - t = _TestThreadNoOp() - - u.addWatch(t) - u.show() - t.start() - - while not t.isFinished(): - pass - - application.processEvents() - assert not u.isVisible() - assert u.result() == QtWidgets.QDialog.Accepted - - -def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(application): - u = updater.UpdaterProgressDialog(None) - t = _TestThreadNoOp() - t_not_finished = QtCore.QThread() - - u.addWatch(t) - u.addWatch(t_not_finished) - u.show() - t.start() - - while not t.isFinished(): - pass - - application.processEvents() - assert u.isVisible() - assert not u.result() == QtWidgets.QDialog.Accepted diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py index 53b5f3521..0e9b0757e 100644 --- a/tests/fa/test_updater.py +++ b/tests/fa/test_updater.py @@ -1,9 +1,13 @@ __author__ = 'Thygrrr' -from fa import updater -from PyQt5 import QtWidgets, QtCore +from typing import Callable + import pytest -import collections +from PyQt6 import QtCore +from PyQt6 import QtWidgets + +from fa.game_updater.updater import UpdaterProgressDialog + class NoIsFinished(QtCore.QObject): finished = QtCore.pyqtSignal() @@ -15,54 +19,66 @@ def run(self): def test_updater_is_a_dialog(application): - assert isinstance(updater.UpdaterProgressDialog(None), QtWidgets.QDialog) + assert isinstance(UpdaterProgressDialog(None), QtWidgets.QDialog) def test_updater_has_progress_bar_game_progress(application): - assert isinstance(updater.UpdaterProgressDialog(None).gameProgress, QtWidgets.QProgressBar) - - -def test_updater_has_progress_bar_map_progress(application): - assert isinstance(updater.UpdaterProgressDialog(None).mapProgress, QtWidgets.QProgressBar) + assert isinstance( + UpdaterProgressDialog(None).gameProgress, + QtWidgets.QProgressBar, + ) def test_updater_has_progress_bar_mod_progress(application): - assert isinstance(updater.UpdaterProgressDialog(None).mapProgress, QtWidgets.QProgressBar) + assert isinstance( + UpdaterProgressDialog(None).modProgress, + QtWidgets.QProgressBar, + ) def test_updater_has_method_append_log(application): - assert isinstance(updater.UpdaterProgressDialog(None).appendLog, collections.Callable) + assert isinstance( + UpdaterProgressDialog(None).append_log, + Callable, + ) def test_updater_append_log_accepts_string(application): - updater.UpdaterProgressDialog(None).appendLog("Hello Test") + UpdaterProgressDialog(None).append_log("Hello Test") def test_updater_has_method_add_watch(application): - assert isinstance(updater.UpdaterProgressDialog(None).addWatch, collections.Callable) + assert isinstance( + UpdaterProgressDialog(None).add_watch, + Callable, + ) def test_updater_append_log_accepts_qobject_with_signals_finished(application): - updater.UpdaterProgressDialog(None).addWatch(QtCore.QThread()) + UpdaterProgressDialog(None).add_watch(QtCore.QThread()) -def test_updater_add_watch_raises_error_on_watch_without_signal_finished(application): +def test_updater_add_watch_raises_error_on_watch_without_signal_finished( + application, +): with pytest.raises(AttributeError): - updater.UpdaterProgressDialog(None).addWatch(QtCore.QObject()) + UpdaterProgressDialog(None).add_watch(QtCore.QObject()) -def test_updater_watch_finished_raises_error_on_watch_without_method_is_finished(application): - u = updater.UpdaterProgressDialog(None) - u.addWatch(NoIsFinished()) +def test_updater_watch_finished_raises_error_on_watch_without_method_finished( + application, +): + u = UpdaterProgressDialog(None) + u.add_watch(NoIsFinished()) with pytest.raises(AttributeError): u.watchFinished() def test_updater_hides_and_accepts_if_all_watches_are_finished(application): - u = updater.UpdaterProgressDialog(None) + u = UpdaterProgressDialog(None) t = NoOpThread() - u.addWatch(t) + u.add_watch(t) u.show() t.start() @@ -71,16 +87,18 @@ def test_updater_hides_and_accepts_if_all_watches_are_finished(application): application.processEvents() assert not u.isVisible() - assert u.result() == QtWidgets.QDialog.Accepted + assert u.result() == QtWidgets.QDialog.DialogCode.Accepted -def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(application): - u = updater.UpdaterProgressDialog(None) +def test_updater_does_not_hide_and_accept_before_all_watches_are_finished( + application, +): + u = UpdaterProgressDialog(None) t = NoOpThread() t_not_finished = QtCore.QThread() - u.addWatch(t) - u.addWatch(t_not_finished) + u.add_watch(t) + u.add_watch(t_not_finished) u.show() t.start() @@ -89,5 +107,4 @@ def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(applic application.processEvents() assert u.isVisible() - assert not u.result() == QtWidgets.QDialog.Accepted - + assert not u.result() == QtWidgets.QDialog.DialogCode.Accepted diff --git a/tests/integration_tests/model/test_player_game.py b/tests/integration_tests/model/test_player_game.py index 2f52508a8..2f1206ac8 100644 --- a/tests/integration_tests/model/test_player_game.py +++ b/tests/integration_tests/model/test_player_game.py @@ -1,26 +1,25 @@ import copy from model.game import Game, GameState, GameVisibility -from model.gameset import Gameset -from model.playerset import Playerset +from model.gameset import Gameset, PlayerGameIndex from model.player import Player +from model.playerset import Playerset GAME_DICT = { - "uid": 1, + "uid": 1, "state": GameState.OPEN, "launched_at": 10000, "num_players": 3, "max_players": 8, "title": "Sentons sucks", - "host": "Guy", + "host": "Guy", "mapname": "Sentons Ultimate 6v6", "map_file_path": "xrca_co_000001.scfamap", "teams": { 1: ["Guy", "TableNoob"], - 2: ["Kraut"] - }, + 2: ["Kraut"], + }, "featured_mod": "faf", - "featured_mod_versions": {}, "sim_mods": {}, "password_protected": False, "visibility": GameVisibility.PUBLIC, @@ -35,7 +34,7 @@ def check_relation(game, player, exists): def setup(): ps = Playerset() gs = Gameset(ps) - + pgr = PlayerGameIndex(gs, ps) p = Player(**{"id_": 1, "login": "Guy"}) ps[p.id] = p p = Player(**{"id_": 2, "login": "TableNoob"}) @@ -45,11 +44,11 @@ def setup(): g = Game(playerset=ps, **GAME_DICT) gs[g.uid] = g - return ps, gs + return ps, gs, pgr def test_setup(): - ps, gs = setup() + ps, gs, pgr = setup() for i in range(1, 3): assert ps[i].currentGame is gs[1] @@ -67,6 +66,7 @@ def test_setup(): def test_player_at_game_change(mocker): ps = Playerset() gs = Gameset(ps) + pgr = PlayerGameIndex(gs, ps) # noqa: F841 p = Player(**{"id_": 1, "login": "Guy"}) ps[p.id] = p @@ -96,7 +96,7 @@ def test_player_at_game_change(mocker): def test_player_at_another_game(mocker): - ps, gs = setup() + ps, gs, pgr = setup() data = copy.deepcopy(GAME_DICT) @@ -126,6 +126,7 @@ def test_player_at_another_game(mocker): def test_game_at_missing_player(mocker): ps = Playerset() gs = Gameset(ps) + pgr = PlayerGameIndex(gs, ps) # noqa: F841 p = Player(**{"id_": 1, "login": "Guy"}) ps[p.id] = p @@ -134,8 +135,6 @@ def test_game_at_missing_player(mocker): data = copy.deepcopy(GAME_DICT) g1 = Game(playerset=ps, **data) - pAdd = mocker.Mock() - g1.connectedPlayerAdded.connect(pAdd) gs[1] = g1 assert len(g1.players) == 3 @@ -144,10 +143,8 @@ def test_game_at_missing_player(mocker): assert ps[1] in gps assert ps[2] in gps - assert not pAdd.called p = Player(**{"id_": 3, "login": "Kraut"}) ps[p.id] = p - pAdd.assert_called_with(g1, ps[3]) gps = [g1.to_player(n) for n in g1.players if g1.is_connected(n)] assert len(gps) == 3 @@ -157,29 +154,16 @@ def test_game_at_missing_player(mocker): def test_remove_add_player(mocker): - ps, gs = setup() - - pAdd = mocker.Mock() - gs[1].connectedPlayerAdded.connect(pAdd) - pRem = mocker.Mock() - gs[1].connectedPlayerRemoved.connect(pRem) - + ps, gs, pgr = setup() p3 = ps[3] del ps[3] - pRem.assert_called_with(gs[1], p3) - assert not pAdd.called assert not gs[1].is_connected(p3.login) - - pRem.reset_mock() - ps[3] = p3 - pAdd.assert_called_with(gs[1], p3) - assert not pRem.called assert gs[1].is_connected(p3.login) def test_game_at_another_game(mocker): - ps, gs = setup() + ps, gs, pgr = setup() data = copy.deepcopy(GAME_DICT) @@ -201,8 +185,20 @@ def test_game_at_another_game(mocker): check_relation(gs[1], ps[1], False) +def test_no_player_change_does_not_resend_game_set_signals(mocker): + ps, gs, pgr = setup() + + gUpd = mocker.Mock() + ps[1].newCurrentGame.connect(gUpd) + + data = copy.deepcopy(GAME_DICT) + data["state"] = GameState.PLAYING + gs[1].update(**data) + assert not gUpd.called + + def test_game_abort_removes_relation(mocker): - ps, gs = setup() + ps, gs, pgr = setup() gUpd = mocker.Mock() ps[1].newCurrentGame.connect(gUpd) @@ -215,7 +211,7 @@ def test_game_abort_removes_relation(mocker): def test_game_closed_removes_relation(mocker): - ps, gs = setup() + ps, gs, pgr = setup() gUpd = mocker.Mock() ps[1].newCurrentGame.connect(gUpd) @@ -232,7 +228,7 @@ def test_game_closed_removes_relation(mocker): def test_game_closed_removes_only_own(mocker): - ps, gs = setup() + ps, gs, pgr = setup() data = copy.deepcopy(GAME_DICT) data["uid"] = 2 @@ -250,7 +246,7 @@ def test_game_closed_removes_only_own(mocker): def override_tests(g1_dict, g2_dict, should): - ps, gs = setup() + ps, gs, pgr = setup() data = copy.deepcopy(GAME_DICT) data.update(g1_dict) @@ -279,32 +275,48 @@ def test_game_doesnt_override_lobby(): def test_later_game_overrides_earlier_game(): - g1 = {"state": GameState.PLAYING, - "launched_at": 1000} - g2 = {"state": GameState.PLAYING, - "launched_at": 1200} + g1 = { + "state": GameState.PLAYING, + "launched_at": 1000, + } + g2 = { + "state": GameState.PLAYING, + "launched_at": 1200, + } override_tests(g1, g2, True) def test_earlier_game_doesnt_override_later_game(): - g1 = {"state": GameState.PLAYING, - "launched_at": 1200} - g2 = {"state": GameState.PLAYING, - "launched_at": 1000} + g1 = { + "state": GameState.PLAYING, + "launched_at": 1200, + } + g2 = { + "state": GameState.PLAYING, + "launched_at": 1000, + } override_tests(g1, g2, False) def test_launchtime_overrides_no_launchtime(): - g1 = {"state": GameState.PLAYING, - "launched_at": None} - g2 = {"state": GameState.PLAYING, - "launched_at": 1000} + g1 = { + "state": GameState.PLAYING, + "launched_at": None, + } + g2 = { + "state": GameState.PLAYING, + "launched_at": 1000, + } override_tests(g1, g2, True) def test_no_launchtime_doesnt_override_launchtime(): - g1 = {"state": GameState.PLAYING, - "launched_at": 1000} - g2 = {"state": GameState.PLAYING, - "launched_at": None} + g1 = { + "state": GameState.PLAYING, + "launched_at": 1000, + } + g2 = { + "state": GameState.PLAYING, + "launched_at": None, + } override_tests(g1, g2, False) diff --git a/tests/integration_tests/model/test_player_ircuser.py b/tests/integration_tests/model/test_player_ircuser.py index 2bc7933d1..222495747 100644 --- a/tests/integration_tests/model/test_player_ircuser.py +++ b/tests/integration_tests/model/test_player_ircuser.py @@ -2,8 +2,8 @@ from model.ircuser import IrcUser from model.ircuserset import IrcUserset -from model.playerset import Playerset from model.player import Player +from model.playerset import Playerset def test_player_change(mocker): diff --git a/tests/unit_tests/client/test_gameurl.py b/tests/unit_tests/client/test_gameurl.py new file mode 100644 index 000000000..5df77e797 --- /dev/null +++ b/tests/unit_tests/client/test_gameurl.py @@ -0,0 +1,180 @@ +import pytest + +from util.gameurl import GameUrl, GameUrlType + +# NOTE - any time url format gets updated, fix below URLs to be correct +# in everything except what we test for! +# Since people can post any URL in chat they please, we need to test explicit +# url format. +# Example live replay URL: +# 'faflive://lobby.faforever.com/9876?map=Sedongs&mod=coop&uid=123456' +# Example open game URL: +# 'fafgame://lobby.faforever.com/342423/3453.SCFAreplay?map=Sedongs&mod=faf' + + +def test_example_format_passes(): + live_url = ( + 'faflive://lobby.faforever.com/342423/3453.SCFAreplay?map=Canis River' + '&mod=faf' + ) + open_url = ( + 'fafgame://lobby.faforever.com/9876?map=Sedongs&mod=coop&uid=123456' + ) + + gurl = GameUrl.from_url(live_url) + assert gurl.game_type == GameUrlType.LIVE_REPLAY + assert gurl.map == "Canis River" + assert gurl.mod == "faf" + assert gurl.uid == 342423 + assert gurl.player == "3453" + assert gurl.mods is None + + gurl = GameUrl.from_url(open_url) + assert gurl.game_type == GameUrlType.OPEN_GAME + assert gurl.map == "Sedongs" + assert gurl.mod == "coop" + assert gurl.uid == 123456 + assert gurl.player == "9876" + assert gurl.mods is None + + +def test_to_url_and_back_works(): + def test_values(gtype, map_, mod, uid, pid, mods): + gurl = GameUrl(gtype, map_, mod, uid, pid, mods) + url = gurl.to_url() + print(url) + gurl2 = GameUrl.from_url(url) + assert gurl2.game_type == gtype + assert gurl2.map == map_ + assert gurl2.mod == mod + assert gurl2.uid == uid + assert gurl2.player == pid + test_values( + GameUrlType.LIVE_REPLAY, + "Canis River", + "faf", + 342423, + "3453", + "[]", + ) + test_values( + GameUrlType.OPEN_GAME, + "Sedongs", + "coop", + 123456, + "Wesmania", + "[]", + ) + + +def test_playername_accepts_both_uid_and_name(): + live_url = ( + 'faflive://lobby.faforever.com/342423/Wesmania.SCFAreplay?map=Canis ' + 'River&mod=faf' + ) + live_url2 = ( + 'faflive://lobby.faforever.com/342423/12346.SCFAreplay?map=Canis ' + 'River&mod=faf' + ) + open_url = ( + 'fafgame://lobby.faforever.com/Wesmania?map=Sedongs&mod=coop' + '&uid=123456' + ) + open_url2 = ( + 'fafgame://lobby.faforever.com/123456?map=Sedongs&mod=coop&uid=123456' + ) + for u in [live_url, live_url2, open_url, open_url2]: + GameUrl.from_url(u) + + +def test_mods_parameter_is_optional(): + live_url = ( + 'faflive://lobby.faforever.com/342423/3453.SCFAreplay?map=Canis River' + '&mod=faf' + ) + gurl = GameUrl.from_url(live_url) + assert gurl.mods is None + + live_url = ( + 'faflive://lobby.faforever.com/342423/3453.SCFAreplay?map=Canis River' + '&mod=faf&mods=[]' + ) + gurl = GameUrl.from_url(live_url) + assert gurl.mods == '[]' + + +def test_invalid_scheme_throws_value_error(): + live_url = ( + 'http://lobby.faforever.com/342423/3453.SCFAreplay?map=Canis River' + '&mod=faf' + ) + open_url = ( + 'https://lobby.faforever.com/9876?map=Sedongs&mod=coop&uid=123456' + ) + for u in [live_url, open_url]: + with pytest.raises(ValueError): + GameUrl.from_url(u) + + +def test_missing_map_throws_value_error(): + live_url = 'faflive://lobby.faforever.com/342423/3453.SCFAreplay?mod=faf' + open_url = 'fafgame://lobby.faforever.com/9876?mod=coop&uid=123456' + for u in [live_url, open_url]: + with pytest.raises(ValueError): + GameUrl.from_url(u) + + +def test_missing_mod_throws_value_error(): + live_url = ( + 'faflive://lobby.faforever.com/342423/3453.SCFAreplay' + '?map=Canis River' + ) + open_url = 'fafgame://lobby.faforever.com/9876?map=Sedongs&uid=123456' + for u in [live_url, open_url]: + with pytest.raises(ValueError): + GameUrl.from_url(u) + + +def test_missing_uid_throws_value_error(): + live_url = ( + 'faflive://lobby.faforever.com/3453.SCFAreplay?map=Canis River&mod=faf' + ) + open_url = 'fafgame://lobby.faforever.com/9876?map=Sedongs&mod=coop' + for u in [live_url, open_url]: + with pytest.raises(ValueError): + GameUrl.from_url(u) + + +def test_bad_replay_suffix_throws_value_error(): + live_url = ( + 'faflive://lobby.faforever.com/342423/3453.blahblah?map=Canis River' + '&mod=faf' + ) + with pytest.raises(ValueError): + GameUrl.from_url(live_url) + + +def test_too_short_path_throws_value_error(): + live_url = ( + 'faflive://lobby.faforever.com/3453.SCFAreplay?map=Canis River&mod=faf' + ) + open_url = 'fafgame://lobby.faforever.com?map=Sedongs&mod=coop&uid=123456' + for u in [live_url, open_url]: + with pytest.raises(ValueError): + GameUrl.from_url(u) + + +def test_malformed_url_throws_value_error(): + for u in ["This is not a URL at all", 42, None]: + with pytest.raises(ValueError): + GameUrl.from_url(u) + + +def test_schema_determines_if_url_is_game(): + game1 = 'faflive://' + game2 = 'fafgame://' + notgame = 'http://' + + for u in [game1, game2]: + assert GameUrl.is_game_url(u) + assert not GameUrl.is_game_url(notgame) diff --git a/tests/unit_tests/client/test_mouse_position.py b/tests/unit_tests/client/test_mouse_position.py new file mode 100644 index 000000000..862874e9f --- /dev/null +++ b/tests/unit_tests/client/test_mouse_position.py @@ -0,0 +1,42 @@ +import pytest +from PyQt6.QtCore import QPoint + + +@pytest.mark.parametrize("x,y", [(0, 0)]) +def test_mouse_position(mouse_position, x: int, y: int): + point = QPoint() + point.setX(x) + point.setY(y) + + assert not mouse_position.on_left_edge + assert not mouse_position.on_right_edge + assert not mouse_position.on_top_edge + assert not mouse_position.on_bottom_edge + assert not mouse_position.on_top_left_edge + assert not mouse_position.on_bottom_left_edge + assert not mouse_position.on_top_right_edge + assert not mouse_position.on_bottom_right_edge + assert not mouse_position.is_on_edge() + + mouse_position.update_mouse_position(point) + assert mouse_position.on_left_edge + assert not mouse_position.on_right_edge + assert mouse_position.on_top_edge + assert not mouse_position.on_bottom_edge + assert mouse_position.on_top_left_edge + assert not mouse_position.on_bottom_left_edge + assert not mouse_position.on_top_right_edge + assert not mouse_position.on_bottom_right_edge + assert mouse_position.is_on_edge() + + mouse_position.reset_to_false() + + assert not mouse_position.on_left_edge + assert not mouse_position.on_right_edge + assert not mouse_position.on_top_edge + assert not mouse_position.on_bottom_edge + assert not mouse_position.on_top_left_edge + assert not mouse_position.on_bottom_left_edge + assert not mouse_position.on_top_right_edge + assert not mouse_position.on_bottom_right_edge + assert not mouse_position.is_on_edge() diff --git a/tests/unit_tests/client/test_updating.py b/tests/unit_tests/client/test_updating.py index ffb27f479..a0c97e64c 100644 --- a/tests/unit_tests/client/test_updating.py +++ b/tests/unit_tests/client/test_updating.py @@ -16,10 +16,16 @@ def test_client_sends_current_version(qtbot, mocker): assert args[0]['version'] == config.VERSION -@pytest.mark.skipif(True, reason="Run this manually to test client update downloading") +# TODO: bad test, should be rewritten +@pytest.mark.skipif( + True, + reason="Run this manually to test client update downloading", +) def test_client_updater(qtbot): from client.updater import ClientUpdater - updater = ClientUpdater("http://content.faforever.com/FAForever-0.10.125.msi") + updater = ClientUpdater( + "http://content.faforever.com/FAForever-0.10.125.msi", + ) updater.exec_() qtbot.stop() diff --git a/tests/unit_tests/model/chat/test_channel.py b/tests/unit_tests/model/chat/test_channel.py new file mode 100644 index 000000000..150c6cf20 --- /dev/null +++ b/tests/unit_tests/model/chat/test_channel.py @@ -0,0 +1,24 @@ +import pytest + +from model.chat.channel import Channel, ChannelID, ChannelType + + +@pytest.fixture +def lines(mocker): + return mocker.Mock(spec_set=[]) + + +def test_setting_topic(lines, mocker): + old_topic = "" + new_topic = "The gloves are comming off." + + def check_update(new, old): + assert new.topic == new_topic + assert old.topic == old_topic + + cid = ChannelID(ChannelType.PUBLIC, "aeolus") + channel = Channel(cid, lines, old_topic) + topic_call = mocker.Mock() + channel.updated.connect(topic_call) + channel.updated.connect(check_update) + channel.set_topic(new_topic) diff --git a/tests/unit_tests/model/chat/test_channelchatter.py b/tests/unit_tests/model/chat/test_channelchatter.py new file mode 100644 index 000000000..2223b2002 --- /dev/null +++ b/tests/unit_tests/model/chat/test_channelchatter.py @@ -0,0 +1,39 @@ +import pytest + +from model.chat.channelchatter import ChannelChatter + + +@pytest.fixture +def channel(mocker): + return mocker.Mock(spec_set=["id_key"]) + + +@pytest.fixture +def chatter(mocker): + return mocker.Mock(spec_set=["id_key"]) + + +def test_id_is_tuple_of_channel_chatter_ids(channel, chatter): + channel.id_key = "channel" + chatter.id_key = "chatter" + + cc = ChannelChatter(channel, chatter, "") + assert cc.id_key == (channel.id_key, chatter.id_key) + + +def test_setting_elevation(channel, chatter, mocker): + old_el = "" + new_el = "~" + + def check_update(new, old): + assert new.elevation == new_el + assert old.elevation == old_el + + cc = ChannelChatter(channel, chatter, old_el) + + call = mocker.Mock() + cc.updated.connect(call) + cc.updated.connect(check_update) + cc.set_elevation(new_el) + + assert call.called diff --git a/tests/unit_tests/model/chat/test_channelset.py b/tests/unit_tests/model/chat/test_channelset.py new file mode 100644 index 000000000..56122c8be --- /dev/null +++ b/tests/unit_tests/model/chat/test_channelset.py @@ -0,0 +1,76 @@ +import pytest + +from model.chat.channel import ChannelID, ChannelType +from model.chat.channelset import Channelset + + +class MockChannels(): + def __init__(self, mock): + self._mock = mock + + def get(self, cid): + mock_channel = self._mock.Mock(spec_set=["id_key", "is_base"]) + mock_channel.id_key = cid + return mock_channel + + +@pytest.fixture +def channels(mocker): + return MockChannels(mocker) + + +def test_adding_channel(channels, mocker): + added = mocker.Mock() + channelset = Channelset([]) + channelset.added.connect(added) + + cid = ChannelID(ChannelType.PUBLIC, "aeolus") + new_channel = channels.get(cid) + channelset[cid] = new_channel + + assert channelset[cid] is new_channel + added.assert_called_with(new_channel) + assert len(channelset) == 1 + assert [cid for cid in channelset] == [cid] + + +def test_removing_channel(channels, mocker): + removed = mocker.Mock() + channelset = Channelset([]) + channelset.removed.connect(removed) + + cid = ChannelID(ChannelType.PUBLIC, "aeolus") + new_channel = channels.get(cid) + channelset[cid] = new_channel + assert not removed.called + + del channelset[cid] + assert cid not in channelset + removed.assert_called_with(new_channel) + assert len(channelset) == 0 + assert [cid for cid in channelset] == [] + + +def test_adding_mismatched_cid_is_value_error(channels): + channelset = Channelset([]) + cid = ChannelID(ChannelType.PUBLIC, "aeolus") + cid2 = ChannelID(ChannelType.PUBLIC, "odysseus") + cid3 = ChannelID(ChannelType.PRIVATE, "aeolus") + new_channel = channels.get(cid) + + with pytest.raises(ValueError): + channelset[cid2] = new_channel + with pytest.raises(ValueError): + channelset[cid3] = new_channel + + +def test_adding_same_cid_twice_is_value_error(channels): + channelset = Channelset([]) + cid1 = ChannelID(ChannelType.PUBLIC, "aeolus") + cid2 = ChannelID(ChannelType.PUBLIC, "aeolus") + new_channel1 = channels.get(cid1) + new_channel2 = channels.get(cid2) + + channelset[cid1] = new_channel1 + with pytest.raises(ValueError): + channelset[cid2] = new_channel2 diff --git a/tests/unit_tests/model/chat/test_lines.py b/tests/unit_tests/model/chat/test_lines.py new file mode 100644 index 000000000..78a6e70f5 --- /dev/null +++ b/tests/unit_tests/model/chat/test_lines.py @@ -0,0 +1,97 @@ +import pytest + +from model.chat.channel import Lines + + +def test_lines_dont_care_about_line_internals(): + o = object() + + for item in [1, "a", o]: + lines = Lines() + lines.add_line(item) + assert [i for i in lines] == [item] + + +def test_lines_add_latest_last(): + lines = Lines() + for item in range(5): + lines.add_line(item) + + assert [i for i in lines] == list(range(5)) + + +def test_lines_emit_add_remove_signals(mocker): + lines = Lines() + added = mocker.Mock() + removed = mocker.Mock() + lines.added.connect(added) + lines.removed.connect(removed) + + lines.add_line("a") + assert added.called + assert not removed.called + added.reset_mock() + + lines.remove_lines(1) + removed.assert_called_with(1) + assert not added.called + + +def test_lines_remove_acts_like_queue(): + lines = Lines() + for item in range(5): + lines.add_line(item) + + lines.remove_lines(2) + assert [i for i in lines] == [2, 3, 4] + + +def test_lines_len(): + lines = Lines() + for item in range(5): + lines.add_line(item) + lines.remove_lines(2) + assert len(lines) == 3 + + +def test_remove_more_lines_than_len_removes_len_lines(mocker): + lines = Lines() + removed = mocker.Mock() + lines.removed.connect(removed) + + for item in range(5): + lines.add_line(item) + + lines.remove_lines(15) + removed.assert_called_with(5) + assert len(lines) == 0 + assert [i for i in lines] == [] + + +def test_lines_zero_remove_does_nothing(mocker): + lines = Lines() + removed = mocker.Mock() + lines.removed.connect(removed) + + for item in range(5): + lines.add_line(item) + + lines.remove_lines(0) + assert not removed.called + assert [i for i in lines] == list(range(5)) + + +def test_remove_on_empty_list_does_nothing(mocker): + lines = Lines() + removed = mocker.Mock() + lines.removed.connect(removed) + + lines.remove_lines(15) + assert not removed.called + assert [i for i in lines] == [] + + +def test_negative_remove_number_is_value_error(): + lines = Lines() + with pytest.raises(ValueError): + lines.remove_lines(-5) diff --git a/tests/unit_tests/model/test_game.py b/tests/unit_tests/model/test_game.py index 0c191ef01..e13d23742 100644 --- a/tests/unit_tests/model/test_game.py +++ b/tests/unit_tests/model/test_game.py @@ -1,24 +1,24 @@ -import pytest import copy +import pytest + from model import game DEFAULT_DICT = { - "uid": 1, + "uid": 1, "state": game.GameState.OPEN, "launched_at": 10000, "num_players": 3, "max_players": 8, "title": "Sentons sucks", - "host": "IllIIIlIlIIIlI", + "host": "IllIIIlIlIIIlI", "mapname": "Sentons Ultimate 6v6", "map_file_path": "xrca_co_000001.scfamap", "teams": { 1: ["IllIIIlIlIIIlI", "TableNoob"], - 2: ["Kraut"] - }, + 2: ["Kraut"], + }, "featured_mod": "faf", - "featured_mod_versions": {}, "sim_mods": {}, "password_protected": False, "visibility": game.GameVisibility.PUBLIC, @@ -45,8 +45,8 @@ def check_signal(new, old): assert old.host == "IllIIIlIlIIIlI" assert new.host == "OtherName" - g.gameUpdated.connect(updated) - g.gameUpdated.connect(check_signal) + g.updated.connect(updated) + g.updated.connect(check_signal) data["host"] = "OtherName" g.update(**data) assert updated.called diff --git a/tests/unit_tests/model/test_gameset.py b/tests/unit_tests/model/test_gameset.py index e180da7f0..19f18be43 100644 --- a/tests/unit_tests/model/test_gameset.py +++ b/tests/unit_tests/model/test_gameset.py @@ -1,24 +1,24 @@ -import pytest import copy -from model import gameset, game +import pytest + +from model import game, gameset DEFAULT_DICT = { - "uid": 1, + "uid": 1, "state": game.GameState.OPEN, "launched_at": 10000, "num_players": 3, "max_players": 8, "title": "Sentons sucks", - "host": "IllIIIlIlIIIlI", + "host": "IllIIIlIlIIIlI", "mapname": "Sentons Ultimate 6v6", "map_file_path": "xrca_co_000001.scfamap", "teams": { 1: ["IllIIIlIlIIIlI", "TableNoob"], - 2: ["Kraut"] - }, + 2: ["Kraut"], + }, "featured_mod": "faf", - "featured_mod_versions": {}, "sim_mods": {}, "password_protected": False, "visibility": game.GameVisibility.PUBLIC, @@ -34,7 +34,7 @@ def test_add_update(mocker, playerset): data = copy.deepcopy(DEFAULT_DICT) s = gameset.Gameset(playerset=playerset) newgame = mocker.Mock() - s.newGame.connect(newgame) + s.added.connect(newgame) s[1] = game.Game(playerset=playerset, **data) assert 1 in s diff --git a/tests/unit_tests/model/test_ircuserset.py b/tests/unit_tests/model/test_ircuserset.py index f9722475f..e0953bd9d 100644 --- a/tests/unit_tests/model/test_ircuserset.py +++ b/tests/unit_tests/model/test_ircuserset.py @@ -18,10 +18,10 @@ def test_user_signal(sp): newuser = mocker.Mock() goneuser = mocker.Mock() - ps.userAdded.connect(newuser) - ps.userAdded.connect(test_user_signal) - ps.userRemoved.connect(goneuser) - ps.userRemoved.connect(test_user_signal) + ps.added.connect(newuser) + ps.added.connect(test_user_signal) + ps.removed.connect(goneuser) + ps.removed.connect(test_user_signal) ps[p.name] = p assert newuser.called diff --git a/tests/unit_tests/model/test_player.py b/tests/unit_tests/model/test_player.py index 19ae95611..8480dd4ab 100644 --- a/tests/unit_tests/model/test_player.py +++ b/tests/unit_tests/model/test_player.py @@ -1,19 +1,32 @@ from model.player import Player +from model.rating import RatingType DEFAULT_DICT = { - "id_": 17, + "id_": 17, "login": "TesterNoob", - "global_rating": (1455, 160), - "ladder_rating": (1192, 216), - "number_of_games": 374, + "ratings": { + "global": { + "rating": (1455, 160), + "number_of_games": 374, + }, + "ladder_1v1": { + "rating": (1192, 216), + "number_of_games": 374, + }, + "tmm_2v2": { + "rating": (888, 88), + "number_of_games": 88, + }, + }, "avatar": { 'url': 'http://content.faforever.com/faf/avatars/GW_Cybran.png', - 'tooltip': 'Liberate !'}, - "country": "PL", + 'tooltip': 'Liberate !', + }, + "country": "PL", } NONOPTIONAL_DICT = { - "id_": 17, + "id_": 17, "login": "TesterNoob", } @@ -31,12 +44,33 @@ def test_update_signal(mocker): updated = mocker.Mock() def check_signal(new, old): - assert old.number_of_games == 374 - assert new.number_of_games == 375 + assert old.global_estimate == (1455 - 3 * 160) + assert new.global_estimate == (1456 - 3 * 140) + assert old.game_count() == 374 + assert new.game_count() == 375 + assert old.number_of_games == 374 + 374 + 88 + assert new.number_of_games == 375 + 374 + 88 + assert old.ladder_estimate == (1192 - 3 * 216) + assert new.ladder_estimate == (1192 - 3 * 216) p.updated.connect(updated) p.updated.connect(check_signal) - p.update(number_of_games=375) + p.update( + ratings={ + "global": { + "rating": (1456, 140), + "number_of_games": 375, + }, + "ladder_1v1": { + "rating": (1192, 216), + "number_of_games": 374, + }, + "tmm_2v2": { + "rating": (888, 88), + "number_of_games": 88, + }, + }, + ) assert updated.called @@ -54,3 +88,45 @@ def test_player_equality(): def test_player_indexing(): p = Player(id_=1, login='x') assert {1: p}[1] == {p: p}[p] + + +def test_player_repr(): + p = Player(**DEFAULT_DICT) + assert str(p) == ( + "Player(id=17, login=TesterNoob, global_rating=(1455, 160), " + "ladder_rating=(1192, 216))" + ) + + +def test_player_fields(): + p = Player(**DEFAULT_DICT) + assert p.login == "TesterNoob" + assert p.id == 17 + assert p.ratings is not None + assert isinstance(p.ratings, dict) is True + assert p.country == "PL" + assert p.clan is None + assert p.league is None + assert p.avatar == { + 'url': 'http://content.faforever.com/faf/avatars/GW_Cybran.png', + 'tooltip': 'Liberate !', + } + + +def test_missing_ratings(): + p = Player(**DEFAULT_DICT) + p.update(ratings={}) + assert p.number_of_games == 0 + assert p.ladder_estimate == 0 + assert p.global_estimate == 0 + for rating_type in list(RatingType): + assert p.rating_mean(rating_type.value) == 1500 + assert p.rating_deviation(rating_type.value) == 500 + assert p.rating_estimate(rating_type.value) == 0 + assert p.game_count(rating_type.value) == 0 + + +def test_missing_number_of_games(): + p = Player(**DEFAULT_DICT) + p.update(ratings={"global": {"rating": (1500, 500)}}) + assert p.number_of_games == 0 diff --git a/tests/unit_tests/model/test_playerset.py b/tests/unit_tests/model/test_playerset.py index 2331a7c66..000c19840 100644 --- a/tests/unit_tests/model/test_playerset.py +++ b/tests/unit_tests/model/test_playerset.py @@ -4,15 +4,27 @@ from model.playerset import Playerset DEFAULT_DICT = { - "id_": 17, + "id_": 17, "login": "TesterNoob", - "global_rating": (1455, 160), - "ladder_rating": (1192, 216), - "number_of_games": 374, + "ratings": { + "global": { + "rating": (1455, 160), + "number_of_games": 374, + }, + "ladder_1v1": { + "rating": (1192, 216), + "number_of_games": 374, + }, + "tmm_2v2": { + "rating": (888, 88), + "number_of_games": 88, + }, + }, "avatar": { 'url': 'http://content.faforever.com/faf/avatars/GW_Cybran.png', - 'tooltip': 'Liberate !'}, - "country": "PL", + 'tooltip': 'Liberate !', + }, + "country": "PL", } @@ -25,10 +37,10 @@ def test_player_signal(sp): newplayer = mocker.Mock() goneplayer = mocker.Mock() - ps.playerAdded.connect(newplayer) - ps.playerAdded.connect(test_player_signal) - ps.playerRemoved.connect(goneplayer) - ps.playerRemoved.connect(test_player_signal) + ps.added.connect(newplayer) + ps.added.connect(test_player_signal) + ps.removed.connect(goneplayer) + ps.removed.connect(test_player_signal) ps[p.id] = p assert newplayer.called diff --git a/tests/unit_tests/themes/test_theme.py b/tests/unit_tests/themes/test_theme.py index 9093b8613..0988cdb06 100644 --- a/tests/unit_tests/themes/test_theme.py +++ b/tests/unit_tests/themes/test_theme.py @@ -1,18 +1,17 @@ -import pytest +from semantic_version import Version from util import Theme -from semantic_version import Version THEME_FILE_FUNS = [ - "pixmap", - "loadUi", - "loadUiType", - "readlines", - "readstylesheet", - "themeurl", - "readfile", - "sound", - ] + "pixmap", + "loadUi", + "loadUiType", + "readlines", + "readstylesheet", + "themeurl", + "readfile", + "sound", +] def test_theme_with_empty_dir_keeps_filename(tmpdir): @@ -35,30 +34,30 @@ def test_missing_files_return_none(tmpdir): theme = Theme(str(themedir), "") for fun in THEME_FILE_FUNS: - assert getattr(theme, fun)("file") == None + assert getattr(theme, fun)("file") is None theme = Theme(None, "") for fun in THEME_FILE_FUNS: - assert getattr(theme, fun)(str(themedir.join("file"))) == None + assert getattr(theme, fun)(str(themedir.join("file"))) is None def test_missing_version_returns_none(tmpdir): themedir = tmpdir.mkdir("theme") theme = Theme(str(themedir), "") - assert theme.version() == None + assert theme.version() is None def test_empty_dir_theme_version_returns_none(tmpdir): - themedir = tmpdir.mkdir("theme") + tmpdir.mkdir("theme") theme = Theme(None, "") - assert theme.version() == None + assert theme.version() is None def test_malformed_version_returns_none(tmpdir): themedir = tmpdir.mkdir("theme") themedir.join("version").write("1.0blergh") theme = Theme(str(themedir), "") - assert theme.version() == None + assert theme.version() is None def test_version_correctly_read(tmpdir): @@ -71,18 +70,18 @@ def test_version_correctly_read(tmpdir): def test_pixmap_cache_caches(tmpdir, mocker): - with mocker.patch('PyQt5.QtGui.QPixmap', side_effect = [1, 2]) as pixmock: - themedir = tmpdir.mkdir("theme") - themedir.join("file").write("content") - themedir.join("second_file").write("content") - theme = Theme(str(themedir), "") - - first = theme.pixmap("file") - still_first = theme.pixmap("file") - second = theme.pixmap("second_file") - - assert first is not None and second is not None - assert first is still_first - assert first is not second + mocker.patch('PyQt6.QtGui.QPixmap', side_effect=[1, 2]) + themedir = tmpdir.mkdir("theme") + themedir.join("file").write("content") + themedir.join("second_file").write("content") + theme = Theme(str(themedir), "") + + first = theme.pixmap("file") + still_first = theme.pixmap("file") + second = theme.pixmap("second_file") + + assert first is not None and second is not None + assert first is still_first + assert first is not second # TODO - tests for specific results of functions diff --git a/tests/unit_tests/themes/test_themeset.py b/tests/unit_tests/themes/test_themeset.py index dbe970768..c761dcc03 100644 --- a/tests/unit_tests/themes/test_themeset.py +++ b/tests/unit_tests/themes/test_themeset.py @@ -1,38 +1,49 @@ -import pytest +from semantic_version import Version from util import ThemeSet -from semantic_version import Version THEME_FILE_FUNS = [ - "pixmap", - "loadUi", - "loadUiType", - "readlines", - "readstylesheet", - "themeurl", - "readfile", - "sound", - ] + "pixmap", + "loadUi", + "loadUiType", + "readlines", + "readstylesheet", + "themeurl", + "readfile", + "sound", +] def test_basic_init(mocker): theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) + setting_mock.configure_mock(get=lambda x, y=None: None) - ts = ThemeSet([], theme_mock, setting_mock, "1.0.0") + ThemeSet([], theme_mock, setting_mock, "1.0.0") def test_list_themes(mocker): def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") + def_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="default", + themedir="", + ) theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) + setting_mock.configure_mock(get=lambda x, y=None: None) ts = ThemeSet([theme_mock], def_mock, setting_mock, "1.0.0") @@ -45,14 +56,26 @@ def test_list_themes(mocker): def test_set_theme(mocker): def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") + def_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="default", + themedir="", + ) theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) other_t_mock = mocker.Mock() - other_t_mock.configure_mock(version = lambda: Version("1.0.0"), name = "other", themedir = "") + other_t_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="other", + themedir="", + ) setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) + setting_mock.configure_mock(get=lambda x, y=None: None) ts = ThemeSet([theme_mock, other_t_mock], def_mock, setting_mock, "1.0.0") @@ -71,12 +94,20 @@ def test_set_theme(mocker): def test_wrong_set_theme(mocker): def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") + def_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="default", + themedir="", + ) theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) + setting_mock.configure_mock(get=lambda x, y=None: None) ts = ThemeSet([theme_mock], def_mock, setting_mock, "1.0.0") ts.setTheme("wrong", False) @@ -86,15 +117,27 @@ def test_wrong_set_theme(mocker): def test_loadTheme(mocker): def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") + def_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="default", + themedir="", + ) theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) other_t_mock = mocker.Mock() - other_t_mock.configure_mock(version = lambda: Version("1.0.0"), name = "other", themedir = "") + other_t_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="other", + themedir="", + ) setting_mock = mocker.Mock() # Don't track option name, but make sure something is read - setting_mock.configure_mock(get = (lambda x, y = None: "theme")) + setting_mock.configure_mock(get=(lambda x, y=None: "theme")) ts = ThemeSet([theme_mock], def_mock, setting_mock, "1.0.0") theme = ts.theme @@ -104,27 +147,40 @@ def test_loadTheme(mocker): theme = ts.theme assert theme == theme_mock - setting_mock.configure_mock(get = (lambda x, y = None: None)) + setting_mock.configure_mock(get=(lambda x, y=None: None)) ts.loadTheme() theme = ts.theme assert theme == def_mock + def test_returns_when_not_found(mocker): - mocker.patch("PyQt5.QtMultimedia.QSound") - mocker.patch("PyQt5.QtGui.QPixmap") + mocker.patch("PyQt6.QtMultimedia.QSoundEffect") + mocker.patch("PyQt6.QtGui.QPixmap") setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) + setting_mock.configure_mock(get=lambda x, y=None: None) def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") + def_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="default", + themedir="", + ) theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) unthemed = mocker.Mock() - unthemed.configure_mock(version = lambda: Version("1.0.0"), name = "", themedir = "") + unthemed.configure_mock( + version=lambda: Version("1.0.0"), + name="", + themedir="", + ) ts = ThemeSet([theme_mock], def_mock, setting_mock, "1.0.0", unthemed) - ts.setTheme("theme", restart = False) + ts.setTheme("theme", restart=False) # Make mocks return None for theme in [def_mock, theme_mock, unthemed]: @@ -133,32 +189,43 @@ def test_returns_when_not_found(mocker): for fn in THEME_FILE_FUNS: getattr(theme, fn).return_value = None - # Don't return none in pixmap even if we don't find one assert ts.pixmap("name") is not None - assert ts.pixmap("name", themed = True) is not None - assert ts.pixmap("name", themed = False) is not None + assert ts.pixmap("name", themed=True) is not None + assert ts.pixmap("name", themed=False) is not None # All others should return None if they don't find the result for fn in [f for f in THEME_FILE_FUNS if f not in ["pixmap", "sound"]]: assert (getattr(ts, fn)("name")) is None - assert (getattr(ts, fn)("name", themed = True)) is None - assert (getattr(ts, fn)("name", themed = False)) is None + assert (getattr(ts, fn)("name", themed=True)) is None + assert (getattr(ts, fn)("name", themed=False)) is None def test_theme_call_order(mocker): - mocker.patch("PyQt5.QtMultimedia.QSound") - mocker.patch("PyQt5.QtGui.QPixmap") + mocker.patch("PyQt6.QtCore.QUrl.fromLocalFile") + mocker.patch("PyQt6.QtGui.QPixmap") setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) + setting_mock.configure_mock(get=lambda x, y=None: None) def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") + def_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="default", + themedir="", + ) theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") + theme_mock.configure_mock( + version=lambda: Version("1.0.0"), + name="theme", + themedir="", + ) unthemed = mocker.Mock() - unthemed.configure_mock(version = lambda: Version("1.0.0"), name = "", themedir = "") + unthemed.configure_mock( + version=lambda: Version("1.0.0"), + name="", + themedir="", + ) all_mocks = [def_mock, theme_mock, unthemed] @@ -170,9 +237,10 @@ def test_theme_call_order(mocker): # Tests if all THEME_FILE_FUNS functions call themes and only # themes from should_call in their specified order. - def test_run(should_call, themed = None): + def test_run(should_call, themed=None): for fn in THEME_FILE_FUNS: - theme_ids = dict((e, "e" + str(i)) for (i, e) in enumerate(all_mocks)) + theme_ids = dict((e, "e" + str(i)) + for (i, e) in enumerate(all_mocks)) manager = mocker.Mock() for theme in all_mocks: manager.attach_mock(theme, theme_ids[theme]) @@ -185,44 +253,22 @@ def test_run(should_call, themed = None): getattr(ts, fn)("mock", themed) assert [c[0] for c in manager.mock_calls] == call_names - test_run([def_mock]) # Default theme returns something - test_run([def_mock], True) # Same if we explicitly theme - test_run([unthemed], False) # Unthemed returns something + test_run([def_mock]) # Default theme returns something + test_run([def_mock], True) # Same if we explicitly theme + test_run([unthemed], False) # Unthemed returns something - ts.setTheme("theme", restart = False) - test_run([theme_mock]) # Find things in set theme - test_run([theme_mock], True) # Same if we explicitly theme - test_run([unthemed], False) # Use unthemed + ts.setTheme("theme", restart=False) + test_run([theme_mock]) # Find things in set theme + test_run([theme_mock], True) # Same if we explicitly theme + test_run([unthemed], False) # Use unthemed for fn in THEME_FILE_FUNS: getattr(theme_mock, fn).return_value = None - test_run([theme_mock, def_mock]) # Try mock first, then fallback to default - test_run([theme_mock, def_mock], True) # Same if we explicitly theme - test_run([unthemed], False) # Use unthemed - - -def test_stylesheets(mocker): - def_mock = mocker.Mock() - def_mock.configure_mock(version = lambda: Version("1.0.0"), name = "default", themedir = "") - theme_mock = mocker.Mock() - theme_mock.configure_mock(version = lambda: Version("1.0.0"), name = "theme", themedir = "") - - setting_mock = mocker.Mock() - setting_mock.configure_mock(get = lambda x, y = None: None) - - style_mock = mocker.Mock() - sheet_mock = mocker.Mock() - style_mock.attach_mock(sheet_mock, "setStyleSheet") - - ts = ThemeSet([theme_mock], def_mock, setting_mock, "1.0.0") - - ts.setStyleSheet(style_mock, "filename") - assert sheet_mock.called - sheet_mock.reset_mock() - - ts.reloadStyleSheets() - assert sheet_mock.called + # Try mock first, then fallback to default + test_run([theme_mock, def_mock]) + test_run([theme_mock, def_mock], True) # Same if we explicitly theme + test_run([unthemed], False) # Use unthemed # TODO - add more setTheme tests once we remove using qt dialogs from themeset