From b53df0fbe0cf080916b92ffc44ef2277ffeb15b7 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 25 Dec 2022 17:26:28 +0200 Subject: [PATCH 001/123] Use streaming API to decompress zstd replay files sometimes replay files don't have content size embedded within and the plain decompress doesn't work --- src/fa/replay.py | 310 +++++++++++++++++++++++------------------------ 1 file changed, 155 insertions(+), 155 deletions(-) diff --git a/src/fa/replay.py b/src/fa/replay.py index 6f6c9ac5a..3ee5825eb 100644 --- a/src/fa/replay.py +++ b/src/fa/replay.py @@ -16,6 +16,17 @@ __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. @@ -23,180 +34,169 @@ def replay(source, detach=False): """ 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 - 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()) - compression_type = info.get("compression") - if compression_type == "zstd": - def decompress(data): - decompressor = zstandard.ZstdDecompressor() - return QtCore.QByteArray( - decompressor.decompress(data), - ) - else: - def decompress(data): - return QtCore.qUncompress( - QtCore.QByteArray.fromBase64(data), - ) - try: - binary = decompress(replay.read()) - except BaseException: - 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.", - ) - return False - - scfa_replay = QtCore.QFile( - os.path.join(util.CACHE_DIR, "temp.scfareplay"), + 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()) + 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()), ) - scfa_replay.open( - QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate, - ) - 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: - # TODO: maybe offer a list of mods for the user. - mod = "faf" - logger.warning( - "no mod could be guessed, using " - "fallback ('faf') ", + + if binary.size() == 0: + logger.info("Invalid replay") + QtWidgets.QMessageBox.critical( + None, + "FA Forever Replay", + "Sorry, this replay is corrupted.", ) + return False - mapname = None - arg_string = source - parser = replayParser(arg_string) - version = parser.getVersion() + 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') + 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:
{}".format(source) - ), + # TODO: maybe offer a list of mods for the user. + mod = "faf" + logger.warning( + "no mod could be guessed, using " + "fallback ('faf') ", ) - 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 + '"' - - else: - # 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() + mapname = None + arg_string = source + parser = replayParser(arg_string) + version = parser.getVersion() else: QtWidgets.QMessageBox.critical( None, "FA Forever Replay", ( - "App doesn't know how to play replays from " - "that scheme:
{}".format(url.scheme()) + "Sorry, FAF has no idea how to replay " + "this file:
{}".format(source) ), ) - return False - # We couldn't construct a decent argument format to tell - # ForgedAlliance for this replay - if not arg_string: + 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 + '"' + + else: + # 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 " - "source:
{}".format(source) + "App doesn't know how to play replays from " + "that scheme:
{}".format(url.scheme()) ), ) 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): - 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), - ) - 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 From 6e6d3180b0ab55275c25a5ee2be79083f2b12a05 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 17 Feb 2023 12:00:30 +0200 Subject: [PATCH 002/123] pre-commit autoupdate --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 749053004..310fca1bf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,23 +1,23 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-docstring-first - repo: https://github.com/asottile/add-trailing-comma - rev: v2.2.2 + rev: v2.4.0 hooks: - id: add-trailing-comma - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 6.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v1.6.0 + rev: v2.0.1 hooks: - id: autopep8 - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort From b88257df043cd93d20ce7c1fb12c22021e095d9c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 Mar 2023 22:59:09 +0200 Subject: [PATCH 003/123] Downgrade isort to version 5.10.1 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 310fca1bf..656ffbafd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,6 @@ repos: hooks: - id: autopep8 - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.10.1 hooks: - id: isort From 9fc2e0fec40d5ebf435122d4a28209343275b7df Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 Mar 2023 23:03:26 +0200 Subject: [PATCH 004/123] API: send meta in a result dict, not separately --- src/api/ApiBase.py | 15 ++++++--------- src/api/featured_mod_api.py | 2 +- src/api/featured_mod_updater.py | 4 ++-- src/api/matchmaker_queue_api.py | 5 +++-- src/api/player_api.py | 11 ++++++----- src/api/replaysapi.py | 4 ++-- src/api/stats_api.py | 15 ++++++++------- src/api/vaults_api.py | 33 +++++++++------------------------ 8 files changed, 37 insertions(+), 52 deletions(-) diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index 820f77f80..4b14f9dc6 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -59,12 +59,10 @@ def onRequestFinished(self, reply): message_bytes = reply.readAll().data() message = json.loads(message_bytes.decode('utf-8')) included = self.parseIncluded(message) - meta = self.parseMeta(message) - result = self.parseData(message, included) - if len(meta) > 0: - self.handlers[reply](result, meta) - else: - self.handlers[reply](result) + result = {} + result["data"] = self.parseData(message, included) + result["meta"] = self.parseMeta(message) + self.handlers[reply](result) self.handlers.pop(reply) reply.deleteLater() @@ -127,10 +125,9 @@ def parseSingleData(self, data, included): return result def parseMeta(self, message): - result = {} if "meta" in message: - result["meta"] = message["meta"] - return result + return message["meta"] + return {} def waitForCompletion(self): waitFlag = QtCore.QEventLoop.WaitForMoreEvents diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py index 55ad43957..5af136677 100644 --- a/src/api/featured_mod_api.py +++ b/src/api/featured_mod_api.py @@ -18,7 +18,7 @@ def handleData(self, message): "command": "mod_info_api", "values": [], } - for mod in message: + for mod in message["data"]: preparedMod = { "command": "mod_info_api", "name": mod["technicalName"], diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py index 1674ab437..c4a69be30 100644 --- a/src/api/featured_mod_updater.py +++ b/src/api/featured_mod_updater.py @@ -18,7 +18,7 @@ def requestData(self): self.request({}, self.handleData) def handleData(self, message): - self.featuredModFiles = message + self.featuredModFiles = message["data"] def getFiles(self): self.requestData() @@ -35,7 +35,7 @@ def requestData(self, queryDict={}): self.request(queryDict, self.handleData) def handleFeaturedModId(self, message): - self.featuredModId = message[0]['id'] + self.featuredModId = message['data'][0]['id'] def requestFeaturedModIdByName(self, technicalName): queryDict = dict(filter='technicalName=={}'.format(technicalName)) diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py index 3f5bbb94a..edbab4697 100644 --- a/src/api/matchmaker_queue_api.py +++ b/src/api/matchmaker_queue_api.py @@ -13,12 +13,13 @@ def __init__(self, dispatch): def requestData(self, queryDict={}): self.request(queryDict, self.handleData) - def handleData(self, message): + def handleData(self, message: dict) -> None: preparedData = { "command": "matchmaker_queue_info", "values": [], + "meta": message["meta"], } - for queue in message: + for queue in message["data"]: preparedQueue = { "technicalName": queue["technicalName"], "ratingType": queue["leaderboard"]["technicalName"], diff --git a/src/api/player_api.py b/src/api/player_api.py index a69b12447..41bc8887e 100644 --- a/src/api/player_api.py +++ b/src/api/player_api.py @@ -14,13 +14,13 @@ def requestDataForLeaderboard(self, leaderboardName, queryDict={}): self.leaderboardName = leaderboardName self.request(queryDict, self.handleDataForLeaderboard) - def handleDataForLeaderboard(self, message, meta): + def handleDataForLeaderboard(self, message: dict) -> None: preparedData = dict( command='stats', type='player', leaderboardName=self.leaderboardName, - values=message, - meta=meta['meta'], + values=message['data'], + meta=message['meta'], ) self.dispatch.dispatch(preparedData) @@ -35,9 +35,10 @@ def requestDataForAliasViewer(self, nameToFind): } self.request(queryDict, self.handleDataForAliasViewer) - def handleDataForAliasViewer(self, message, meta=None): + def handleDataForAliasViewer(self, message: dict) -> None: preparedData = dict( command='alias_info', - values=message, + values=message['data'], + meta=message['meta'], ) self.dispatch.dispatch(preparedData) diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py index fbf6a7978..445bf8dea 100644 --- a/src/api/replaysapi.py +++ b/src/api/replaysapi.py @@ -13,7 +13,7 @@ def __init__(self, dispatch): def requestData(self, args): self.request(args, self.handleData) - def handleData(self, message, meta): + def handleData(self, message): preparedData = dict( command="replay_vault", action="search_result", @@ -24,6 +24,6 @@ def handleData(self, message, meta): playerStats={}, ) - preparedData['replays'] = message + preparedData["replays"] = message["data"] self.dispatch.dispatch(preparedData) diff --git a/src/api/stats_api.py b/src/api/stats_api.py index c7971b0e4..6c9c76532 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -1,6 +1,6 @@ import logging -from .ApiBase import ApiBase +from api.ApiBase import ApiBase logger = logging.getLogger(__name__) @@ -14,29 +14,30 @@ def __init__(self, dispatch, leaderboardName): def requestData(self, queryDict={}): self.request(queryDict, self.handleData) - def handleData(self, message, meta): + def handleData(self, message: dict) -> None: preparedData = dict( command='stats', type='leaderboardRating', leaderboardName=self.leadeboardName, - values=message, - meta=meta['meta'], + values=message["data"], + meta=message["meta"], ) self.dispatch.dispatch(preparedData) class LeaderboardApiConnector(ApiBase): - def __init__(self, dispatch): + def __init__(self, dispatch=None): ApiBase.__init__(self, '/data/leaderboard') self.dispatch = dispatch def requestData(self, queryDict={}): self.request(queryDict, self.handleData) - def handleData(self, message): + def handleData(self, message: dict) -> None: preparedData = dict( command='stats', type='leaderboard', - values=message, + values=message["data"], + meta=message["meta"], ) self.dispatch.dispatch(preparedData) diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py index 1737923b5..bb736de04 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -13,18 +13,13 @@ def __init__(self, dispatch): def requestData(self, query={}): self.request(query, self.handleData) - def handleData(self, message, meta): - if len(meta) > 0: - data = dict( - command='vault_meta', - page=meta['meta']['page'], - ) - self.dispatch.dispatch(data) + def handleData(self, message: dict) -> None: preparedData = dict( command='modvault_info', values=[], + meta=message['meta'], ) - for mod in message: + for mod in message['data']: preparedMod = dict( name=mod['displayName'], uid=mod['latestVersion']['uid'], @@ -58,18 +53,13 @@ def __init__(self, dispatch): def requestData(self, query={}): self.request(query, self.handleData) - def handleData(self, message, meta): - if len(meta) > 0: - data = dict( - command='vault_meta', - page=meta['meta']['page'], - ) - self.dispatch.dispatch(data) + def handleData(self, message: dict) -> None: preparedData = dict( command='mapvault_info', values=[], + meta=message['meta'], ) - for _map in message: + for _map in message['data']: preparedMap = dict( name=_map['displayName'], folderName=_map['latestVersion']['folderName'], @@ -106,18 +96,13 @@ def __init__(self, dispatch): def requestData(self, query={}): self.request(query, self.handleData) - def handleData(self, message, meta): - if len(meta) > 0: - data = dict( - command='vault_meta', - page=meta['meta']['page'], - ) - self.dispatch.dispatch(data) + def handleData(self, message: dict) -> None: preparedData = dict( command='mapvault_info', values=[], + meta=message['meta'], ) - for data in message: + for data in message['data']: if len(data['mapVersion']) > 0: _map = data['mapVersion'] preparedMap = dict( From 3fad81b22017d90308910c076472316a92111eba Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 Mar 2023 23:10:55 +0200 Subject: [PATCH 005/123] Parse meta correctly in vaults, remove vaultMeta signal --- src/client/connection.py | 2 - src/vaults/mapvault/mapitem.py | 93 ++++++++++++++ src/vaults/mapvault/mapvault.py | 111 ++--------------- src/vaults/modvault/moditem.py | 87 +++++++++++++ src/vaults/modvault/modvault.py | 106 ++-------------- src/vaults/vault.py | 208 +++++--------------------------- src/vaults/vaultitem.py | 166 +++++++++++++++++++++++++ 7 files changed, 395 insertions(+), 378 deletions(-) create mode 100644 src/vaults/mapvault/mapitem.py create mode 100644 src/vaults/modvault/moditem.py create mode 100644 src/vaults/vaultitem.py diff --git a/src/client/connection.py b/src/client/connection.py index 06b5502af..6674969bf 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -379,7 +379,6 @@ class LobbyInfo(QtCore.QObject): social = QtCore.pyqtSignal(dict) serverSession = QtCore.pyqtSignal(dict) mapVaultInfo = QtCore.pyqtSignal(dict) - vaultMeta = QtCore.pyqtSignal(dict) aliasInfo = QtCore.pyqtSignal(dict) matchmakerQueueInfo = QtCore.pyqtSignal(dict) @@ -397,7 +396,6 @@ def __init__(self, dispatcher, gameset, playerset): self._dispatcher["admin"] = self.handle_admin self._dispatcher["social"] = self._simple_emit(self.social) self._dispatcher["session"] = self._simple_emit(self.serverSession) - self._dispatcher["vault_meta"] = self._simple_emit(self.vaultMeta) self._dispatcher["alias_info"] = self._simple_emit(self.aliasInfo) self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info self._dispatcher["modvault_info"] = self._simple_emit( diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py new file mode 100644 index 000000000..7468a0d69 --- /dev/null +++ b/src/vaults/mapvault/mapitem.py @@ -0,0 +1,93 @@ +import util +from fa import maps +from mapGenerator import mapgenUtils +from vaults.vaultitem import VaultItem + + +class MapItem(VaultItem): + def __init__(self, parent, folderName, *args, **kwargs): + VaultItem.__init__(self, parent, *args, **kwargs) + + self.formatterItem = str( + util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"), + ) + + self.height = 0 + self.width = 0 + self.maxPlayers = 0 + self.thumbnail = None + self.unranked = False + self.folderName = folderName + self.thumbstrSmall = "" + self.thumbnailLarge = "" + + def update(self, item_dict): + self.name = maps.getDisplayName(item_dict["folderName"]) + self.description = item_dict["description"] + self.version = item_dict["version"] + self.rating = item_dict["rating"] + self.reviews = item_dict["reviews"] + + self.maxPlayers = item_dict["maxPlayers"] + self.height = int(item_dict["height"] / 51.2) + self.width = int(item_dict["width"] / 51.2) + + self.folderName = item_dict["folderName"] + self.date = item_dict['date'][:10] + self.unranked = not item_dict["ranked"] + self.link = item_dict["link"] + self.thumbstrSmall = item_dict["thumbnailSmall"] + self.thumbnailLarge = item_dict["thumbnailLarge"] + + self.thumbnail = maps.preview(self.folderName) + if self.thumbnail: + self.setIcon(self.thumbnail) + else: + if self.thumbstrSmall == "": + if mapgenUtils.isGeneratedMap(self.folderName): + self.setItemIcon("games/generated_map.png") + else: + self.setItemIcon("games/unknown_map.png") + else: + self.parent.client.map_downloader.download_preview( + self.folderName, self._item_dl_request, self.thumbstrSmall, + ) + VaultItem.update(self) + + def shouldBeVisible(self): + p = self.parent + if p.showType == "all": + return True + elif p.showType == "unranked": + return self.unranked + elif p.showType == "ranked": + return not self.unranked + elif p.showType == "installed": + return maps.isMapAvailable(self.folderName) + else: + return True + + def updateVisibility(self): + if self.unranked: + self.itemType_ = "Unranked map" + if maps.isMapAvailable(self.folderName): + self.color = "green" + else: + self.color = "white" + + self.setText( + self.formatterItem.format( + color=self.color, + version=self.version, + title=self.name, + description=self.trimmedDescription, + rating=self.rating, + reviews=self.reviews, + date=self.date, + modtype=self.itemType_, + height=self.height, + width=self.width, + ), + ) + + VaultItem.updateVisibility(self) diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py index c80df7ae4..6c536cf76 100644 --- a/src/vaults/mapvault/mapvault.py +++ b/src/vaults/mapvault/mapvault.py @@ -11,9 +11,9 @@ import util from api.vaults_api import MapApiConnector, MapPoolApiConnector from fa import maps -from mapGenerator import mapgenUtils from vaults import luaparser -from vaults.vault import Vault, VaultItem +from vaults.mapvault.mapitem import MapItem +from vaults.vault import Vault from .mapwidget import MapWidget @@ -44,22 +44,18 @@ def __init__(self, client, *args, **kwargs): ) self.apiConnector = self.mapApiConnector + self.items_uid = "folderName" + self.busy_entered() self.UIButton.hide() self.uploadButton.hide() + def createItem(self, item_key: str) -> MapItem: + return MapItem(self, item_key) + @QtCore.pyqtSlot(dict) - def mapInfo(self, message): - for value in message["values"]: - folderName = value["folderName"] - if folderName not in self._items: - _map = MapItem(self, folderName) - self._items[folderName] = _map - self.itemList.addItem(_map) - else: - _map = self._items[folderName] - _map.update(value) - self.itemList.sortItems(1) + def mapInfo(self, message: dict) -> None: + super().itemsInfo(message) @QtCore.pyqtSlot(int) def sortChanged(self, index): @@ -256,92 +252,3 @@ def removeMap(self, folder): shutil.rmtree(maps_folder) self.installed_maps.remove(folder) self.updateVisibilities() - - -class MapItem(VaultItem): - def __init__(self, parent, folderName, *args, **kwargs): - VaultItem.__init__(self, parent, *args, **kwargs) - - self.formatterItem = str( - util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"), - ) - - self.height = 0 - self.width = 0 - self.maxPlayers = 0 - self.thumbnail = None - self.unranked = False - self.folderName = folderName - self.thumbstrSmall = "" - self.thumbnailLarge = "" - - def update(self, item_dict): - self.name = maps.getDisplayName(item_dict["folderName"]) - self.description = item_dict["description"] - self.version = item_dict["version"] - self.rating = item_dict["rating"] - self.reviews = item_dict["reviews"] - - self.maxPlayers = item_dict["maxPlayers"] - self.height = int(item_dict["height"] / 51.2) - self.width = int(item_dict["width"] / 51.2) - - self.folderName = item_dict["folderName"] - self.date = item_dict['date'][:10] - self.unranked = not item_dict["ranked"] - self.link = item_dict["link"] - self.thumbstrSmall = item_dict["thumbnailSmall"] - self.thumbnailLarge = item_dict["thumbnailLarge"] - - self.thumbnail = maps.preview(self.folderName) - if self.thumbnail: - self.setIcon(self.thumbnail) - else: - if self.thumbstrSmall == "": - if mapgenUtils.isGeneratedMap(self.folderName): - self.setItemIcon("games/generated_map.png") - else: - self.setItemIcon("games/unknown_map.png") - else: - self.parent.client.map_downloader.download_preview( - self.folderName, self._item_dl_request, self.thumbstrSmall, - ) - VaultItem.update(self) - - def shouldBeVisible(self): - p = self.parent - if p.showType == "all": - return True - elif p.showType == "unranked": - return self.unranked - elif p.showType == "ranked": - return not self.unranked - elif p.showType == "installed": - return maps.isMapAvailable(self.folderName) - else: - return True - - def updateVisibility(self): - if self.unranked: - self.itemType_ = "Unranked map" - if maps.isMapAvailable(self.folderName): - self.color = "green" - else: - self.color = "white" - - self.setText( - self.formatterItem.format( - color=self.color, - version=self.version, - title=self.name, - description=self.trimmedDescription, - rating=self.rating, - reviews=self.reviews, - date=self.date, - modtype=self.itemType_, - height=self.height, - width=self.width, - ), - ) - - VaultItem.updateVisibility(self) diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py new file mode 100644 index 000000000..d5dc5517b --- /dev/null +++ b/src/vaults/modvault/moditem.py @@ -0,0 +1,87 @@ +import os +import urllib + +import util +from vaults.modvault import utils +from vaults.vaultitem import VaultItem + + +class ModItem(VaultItem): + def __init__(self, parent, uid, *args, **kwargs): + VaultItem.__init__(self, parent, *args, **kwargs) + + self.formatterItem = str( + util.THEME.readfile("vaults/modvault/modinfo.qthtml"), + ) + + self.uid = uid + self.author = "" + self.thumbstr = "" + self.isuidmod = False + self.uploadedbyuser = False + + def shouldBeVisible(self): + p = self.parent + 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: + return True + + def update(self, item_dict): + self.name = item_dict["name"] + self.description = item_dict["description"] + self.version = item_dict["version"] + self.author = item_dict["author"] + self.rating = item_dict["rating"] + self.reviews = item_dict["reviews"] + self.date = item_dict['date'][:10] + self.isuimod = item_dict["ui"] + self.link = item_dict["link"] + self.thumbstr = item_dict["thumbnail"] + self.uploadedbyuser = (self.author == self.parent.client.login) + + if self.thumbstr == "": + self.setItemIcon("games/unknown_map.png") + else: + name = os.path.basename(urllib.parse.unquote(self.thumbstr)) + img = utils.getIcon(name) + if img: + self.setItemIcon(img, False) + else: + self.parent.client.mod_downloader.download_preview( + name[:-4], self._item_dl_request, self.thumbstr, + ) + + VaultItem.update(self) + + def updateVisibility(self): + if self.isuimod: + self.itemType_ = "UI mod" + if self.uid in self.parent.uids: + self.color = "green" + else: + self.color = "white" + + self.setText( + self.formatterItem.format( + color=self.color, + version=self.version, + title=self.name, + description=self.trimmedDescription, + rating=self.rating, + reviews=self.reviews, + date=self.date, + modtype=self.itemType_, + author=self.author, + ), + ) + + VaultItem.updateVisibility(self) diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 537ada936..8642b9d23 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -40,16 +40,13 @@ import logging import os -import urllib.error -import urllib.parse -import urllib.request from PyQt5 import QtCore, QtWidgets -import util from api.vaults_api import ModApiConnector from vaults.modvault import utils -from vaults.vault import Vault, VaultItem +from vaults.modvault.moditem import ModItem +from vaults.vault import Vault from .modwidget import ModWidget from .uimodwidget import UIModWidget @@ -76,20 +73,16 @@ def __init__(self, client, *args, **kwargs): self.apiConnector = ModApiConnector(self.client.lobby_dispatch) + self.items_uid = "uid" + self.uploadButton.hide() + def createItem(self, item_key: str) -> ModItem: + return ModItem(self, item_key) + @QtCore.pyqtSlot(dict) - def modInfo(self, message): - for value in message["values"]: - uid = value["uid"] - if uid not in self._items: - mod = ModItem(self, uid) - self._items[uid] = mod - self.itemList.addItem(mod) - else: - mod = self._items[uid] - mod.update(value) - self.itemList.sortItems(1) + def modInfo(self, message: dict) -> None: + super().itemsInfo(message) @QtCore.pyqtSlot(int) def sortChanged(self, index): @@ -190,84 +183,3 @@ def removeMod(self, mod): if utils.removeMod(mod): self.uids = [m.uid for m in utils.installedMods] mod.updateVisibility() - - -class ModItem(VaultItem): - def __init__(self, parent, uid, *args, **kwargs): - VaultItem.__init__(self, parent, *args, **kwargs) - - self.formatterItem = str( - util.THEME.readfile("vaults/modvault/modinfo.qthtml"), - ) - - self.uid = uid - self.author = "" - self.thumbstr = "" - self.isuidmod = False - self.uploadedbyuser = False - - def shouldBeVisible(self): - p = self.parent - 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: - return True - - def update(self, item_dict): - self.name = item_dict["name"] - self.description = item_dict["description"] - self.version = item_dict["version"] - self.author = item_dict["author"] - self.rating = item_dict["rating"] - self.reviews = item_dict["reviews"] - self.date = item_dict['date'][:10] - self.isuimod = item_dict["ui"] - self.link = item_dict["link"] - self.thumbstr = item_dict["thumbnail"] - self.uploadedbyuser = (self.author == self.parent.client.login) - - if self.thumbstr == "": - self.setItemIcon("games/unknown_map.png") - else: - name = os.path.basename(urllib.parse.unquote(self.thumbstr)) - img = utils.getIcon(name) - if img: - self.setItemIcon(img, False) - else: - self.parent.client.mod_downloader.download_preview( - name[:-4], self._item_dl_request, self.thumbstr, - ) - - VaultItem.update(self) - - def updateVisibility(self): - if self.isuimod: - self.itemType_ = "UI mod" - if self.uid in self.parent.uids: - self.color = "green" - else: - self.color = "white" - - self.setText( - self.formatterItem.format( - color=self.color, - version=self.version, - title=self.name, - description=self.trimmedDescription, - rating=self.rating, - reviews=self.reviews, - date=self.date, - modtype=self.itemType_, - author=self.author, - ), - ) - - VaultItem.updateVisibility(self) diff --git a/src/vaults/vault.py b/src/vaults/vault.py index 8af31ae32..0354c691f 100644 --- a/src/vaults/vault.py +++ b/src/vaults/vault.py @@ -1,10 +1,10 @@ import logging -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5 import QtCore import util -from downloadManager import DownloadRequest from ui.busy_widget import BusyWidget +from vaults.vaultitem import VaultItem, VaultItemDelegate logger = logging.getLogger(__name__) @@ -27,8 +27,6 @@ def __init__(self, client, *args, **kwargs): self.SortTypeList.currentIndexChanged.connect(self.sortChanged) self.ShowTypeList.currentIndexChanged.connect(self.showChanged) - self.client.lobby_info.vaultMeta.connect(self.metaInfo) - self.sortType = "alphabetical" self.showType = "all" self.searchString = "" @@ -56,6 +54,7 @@ def __init__(self, client, *args, **kwargs): self.lastButton.clicked.connect(lambda: self.goToPage(self.totalPages)) self.resetButton.clicked.connect(self.resetSearch) + self.items_uid = "" self._items = {} self._installed_items = {} @@ -77,19 +76,36 @@ def updateQuery(self, pageNumber): self.searchQuery['page[totals]'] = None @QtCore.pyqtSlot(bool) - def goToPage(self, page): - if self.apiConnector is not None: - self._items.clear() - self.itemList.clear() - self.pageBox.setValue(page) - self.pageNumber = self.pageBox.value() - self.pageBox.setValue(self.pageNumber) - self.updateQuery(self.pageNumber) - self.apiConnector.requestData(self.searchQuery) - self.updateVisibilities() + 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.requestData(self.searchQuery) + self.updateVisibilities() + + def createItem(self, item_key: str) -> VaultItem: + return VaultItem(self, item_key) @QtCore.pyqtSlot(dict) - def metaInfo(self, message): + def itemsInfo(self, message: dict) -> None: + for value in message["values"]: + item_key = value[self.items_uid] + if item_key in self._items: + item = self._items[item_key] + else: + item = self.createItem(item_key) + self._items[item_key] = item + self.itemList.addItem(item) + item.update(value) + self.itemList.sortItems(1) + self.processMeta(message["meta"]) + + def processMeta(self, message: dict) -> None: self.totalPages = message['page']['totalPages'] self.totalRecords = message['page']['totalRecords'] if self.totalPages < 1: @@ -128,165 +144,3 @@ def updateVisibilities(self): for _item in self._items: self._items[_item].updateVisibility() self.itemList.sortItems(1) - - -class VaultItemDelegate(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 = QtCore.QSize(VaultItem.ICONSIZE, VaultItem.ICONSIZE) - - # 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() + 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 - icon.paint( - painter, iconrect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, - ) - - # 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.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(VaultItem.TEXTWIDTH) - return QtCore.QSize( - ( - VaultItem.ICONSIZE - + VaultItem.TEXTWIDTH - + VaultItem.PADDING - ), - VaultItem.ICONSIZE + VaultItem.PADDING, - ) - - -class VaultItem(QtWidgets.QListWidgetItem): - TEXTWIDTH = 230 - ICONSIZE = 100 - PADDING = 10 - - def __init__(self, parent, *args, **kwargs): - QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) - self.parent = parent - - self.name = "" - self.description = "" - self.trimmedDescription = "" - self.version = 0 - self.rating = 0 - self.reviews = 0 - self.date = None - - self.itemType_ = "" - self.color = "white" - - self.link = "" - self.setHidden(True) - - self._item_dl_request = DownloadRequest() - self._item_dl_request.done.connect(self._on_item_downloaded) - - def update(self): - self.ensureIcon() - self.updateVisibility() - - def setItemIcon(self, filename, themed=True): - icon = util.THEME.icon(filename) - if not themed: - pixmap = QtGui.QPixmap(filename) - if not pixmap.isNull(): - icon.addPixmap( - pixmap.scaled( - QtCore.QSize(self.ICONSIZE, self.ICONSIZE), - ), - ) - self.setIcon(icon) - - def ensureIcon(self): - if self.icon() is None or self.icon().isNull(): - self.setItemIcon("games/unknown_map.png") - - def _on_item_downloaded(self, mapname, result): - filename, themed = result - self.setItemIcon(filename, themed) - self.ensureIcon() - - def updateVisibility(self): - self.setHidden(not self.shouldBeVisible()) - if len(self.description) < 200: - self.trimmedDescription = self.description - else: - self.trimmedDescription = self.description[:197] + "..." - - self.setToolTip('

{}

'.format(self.description)) - - def __ge__(self, other): - return not self.__lt__(self, other) - - def __lt__(self, other): - if self.parent.sortType == "alphabetical": - return self.name.lower() > other.name.lower() - elif self.parent.sortType == "rating": - if self.rating == other.rating: - if self.reviews == other.reviews: - return self.name.lower() > other.name.lower() - return self.reviews < other.reviews - return self.rating < other.rating - elif self.parent.sortType == "size": - if self.height * self.width == other.height * other.width: - return self.name.lower() > other.name.lower() - return self.height * self.width < other.height * other.width - elif self.parent.sortType == "date": - 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/vaults/vaultitem.py b/src/vaults/vaultitem.py new file mode 100644 index 000000000..85d023803 --- /dev/null +++ b/src/vaults/vaultitem.py @@ -0,0 +1,166 @@ +from PyQt5 import QtCore, QtGui, QtWidgets + +import util +from downloadManager import DownloadRequest + + +class VaultItem(QtWidgets.QListWidgetItem): + TEXTWIDTH = 230 + ICONSIZE = 100 + PADDING = 10 + + def __init__(self, parent, *args, **kwargs): + QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) + self.parent = parent + + self.name = "" + self.description = "" + self.trimmedDescription = "" + self.version = 0 + self.rating = 0 + self.reviews = 0 + self.date = None + + self.itemType_ = "" + self.color = "white" + + self.link = "" + self.setHidden(True) + + self._item_dl_request = DownloadRequest() + self._item_dl_request.done.connect(self._on_item_downloaded) + + def update(self): + self.ensureIcon() + self.updateVisibility() + + def setItemIcon(self, filename, themed=True): + icon = util.THEME.icon(filename) + if not themed: + pixmap = QtGui.QPixmap(filename) + if not pixmap.isNull(): + icon.addPixmap( + pixmap.scaled( + QtCore.QSize(self.ICONSIZE, self.ICONSIZE), + ), + ) + self.setIcon(icon) + + def ensureIcon(self): + if self.icon() is None or self.icon().isNull(): + self.setItemIcon("games/unknown_map.png") + + def _on_item_downloaded(self, mapname, result): + filename, themed = result + self.setItemIcon(filename, themed) + self.ensureIcon() + + def updateVisibility(self): + self.setHidden(not self.shouldBeVisible()) + if len(self.description) < 200: + self.trimmedDescription = self.description + else: + self.trimmedDescription = self.description[:197] + "..." + + self.setToolTip('

{}

'.format(self.description)) + + def __ge__(self, other): + return not self.__lt__(self, other) + + def __lt__(self, other): + if self.parent.sortType == "alphabetical": + return self.name.lower() > other.name.lower() + elif self.parent.sortType == "rating": + if self.rating == other.rating: + if self.reviews == other.reviews: + return self.name.lower() > other.name.lower() + return self.reviews < other.reviews + return self.rating < other.rating + elif self.parent.sortType == "size": + if self.height * self.width == other.height * other.width: + return self.name.lower() > other.name.lower() + return self.height * self.width < other.height * other.width + elif self.parent.sortType == "date": + 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 + + +class VaultItemDelegate(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 = QtCore.QSize(VaultItem.ICONSIZE, VaultItem.ICONSIZE) + + # 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() + 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 + icon.paint( + painter, iconrect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + ) + + # 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.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(VaultItem.TEXTWIDTH) + return QtCore.QSize( + ( + VaultItem.ICONSIZE + + VaultItem.TEXTWIDTH + + VaultItem.PADDING + ), + VaultItem.ICONSIZE + VaultItem.PADDING, + ) From af582e24f628335c13bd24f3f6d9cad0c40352ea Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 Mar 2023 23:13:54 +0200 Subject: [PATCH 006/123] Force single line for imports --- setup.cfg | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 218b455de..e1d35b6d8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,2 @@ [isort] -multi_line_output=3 -include_trailing_comma=True +force_single_line=True From 262dffb0c56dc8f43828efebdd12f6bb2a020055 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:59:53 +0200 Subject: [PATCH 007/123] Make .ui files compatible with PyQt6 --- res/client/client.ui | 3 --- res/client/oauth.ui | 3 --- 2 files changed, 6 deletions(-) diff --git a/res/client/client.ui b/res/client/client.ui index b4f2a915f..477e67e8b 100644 --- a/res/client/client.ui +++ b/res/client/client.ui @@ -31,9 +31,6 @@ Qt::ToolButtonIconOnly - - QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks - false diff --git a/res/client/oauth.ui b/res/client/oauth.ui index 729bfce98..852c030c8 100644 --- a/res/client/oauth.ui +++ b/res/client/oauth.ui @@ -35,9 +35,6 @@ 0 - - - From 3cb7808681246ff520a80e3c020f0c5fcf3a02e1 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 14 Mar 2024 20:01:03 +0200 Subject: [PATCH 008/123] pre-commit autoupdate --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 656ffbafd..e0eb7f6ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,23 +1,23 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-docstring-first - repo: https://github.com/asottile/add-trailing-comma - rev: v2.4.0 + rev: v3.1.0 hooks: - id: add-trailing-comma - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 7.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v2.0.1 + rev: v2.0.4 hooks: - id: autopep8 - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.13.2 hooks: - id: isort From 1fbd5ac75c67107ee132899fd491c11197093d12 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 14 Mar 2024 21:18:11 +0200 Subject: [PATCH 009/123] Increase line length to 100 --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index e1d35b6d8..105ad2804 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,5 @@ [isort] force_single_line=True + +[flake8] +max_line_length=100 From ecc27a169649e5b1b3b2ea6d22a4adf3b2651a60 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 14 Mar 2024 22:30:44 +0200 Subject: [PATCH 010/123] Partially upgrade to PyQt6 use compatible namespaces and remove/replace deprecated stuff --- conftest.py | 2 +- requirements.txt | 11 +- setup.py | 7 +- src/__main__.py | 42 +++-- src/api/ApiBase.py | 10 +- src/chat/_avatarWidget.py | 10 +- src/chat/channel_tab.py | 2 +- src/chat/channel_view.py | 21 ++- src/chat/channel_widget.py | 20 ++- src/chat/chat_controller.py | 11 +- src/chat/chat_widget.py | 11 +- src/chat/chatlineedit.py | 15 +- src/chat/chatter_menu.py | 4 +- src/chat/chatter_model.py | 72 +++++--- src/chat/chatter_model_item.py | 3 +- src/chat/chatterlistview.py | 4 +- src/chat/gameinfo.py | 2 +- src/chat/ircconnection.py | 11 +- src/chat/language_channel_config.py | 19 ++- src/client/_clientwindow.py | 154 +++++++++--------- src/client/aliasviewer.py | 8 +- src/client/chat_config.py | 2 +- src/client/connection.py | 24 +-- src/client/gameannouncer.py | 4 +- src/client/login.py | 3 +- src/client/oauth_dialog.py | 9 +- src/client/playercolors.py | 3 +- src/client/theme_menu.py | 2 +- src/client/user.py | 5 +- src/config/__init__.py | 28 ++-- src/connectivity/ConnectivityDialog.py | 20 ++- src/connectivity/IceAdapterClient.py | 2 +- src/connectivity/IceAdapterProcess.py | 18 +- src/connectivity/IceServersPoller.py | 6 +- src/connectivity/JsonRpcTcpClient.py | 15 +- src/coop/_coopwidget.py | 17 +- src/coop/coopmapitem.py | 24 ++- src/downloadManager/__init__.py | 20 ++- src/fa/check.py | 39 +++-- src/fa/game_connection.py | 7 +- src/fa/game_process.py | 9 +- src/fa/game_session.py | 4 +- src/fa/maps.py | 11 +- src/fa/mods.py | 4 +- src/fa/replay.py | 12 +- src/fa/replayserver.py | 10 +- src/fa/updater.py | 14 +- src/fa/wizards.py | 35 ++-- src/games/_gameswidget.py | 16 +- src/games/automatchframe.py | 25 ++- src/games/gameitem.py | 13 +- src/games/gamemodel.py | 7 +- src/games/gamemodelitem.py | 3 +- src/games/hostgamewidget.py | 12 +- src/games/mapgenoptionsdialog.py | 7 +- src/games/moditem.py | 3 +- src/mapGenerator/mapgenManager.py | 8 +- src/mapGenerator/mapgenProcess.py | 18 +- src/model/chat/channel.py | 3 +- src/model/chat/chat.py | 9 +- src/model/chat/chatter.py | 2 +- src/model/game.py | 14 +- src/model/gameset.py | 2 +- src/model/ircuser.py | 2 +- src/model/modelitem.py | 2 +- src/model/modelitemset.py | 2 +- src/model/player.py | 2 +- src/model/qobjectmapping.py | 6 +- src/news/_newswidget.py | 8 +- src/news/newsitem.py | 10 +- src/news/newsmanager.py | 9 +- src/news/wpapi.py | 11 +- src/notifications/__init__.py | 5 +- src/notifications/hook_newgame.py | 10 +- src/notifications/hook_partyinvite.py | 4 +- src/notifications/hook_useronline.py | 4 +- src/notifications/ns_dialog.py | 17 +- src/notifications/ns_hook.py | 2 +- src/notifications/ns_settings.py | 29 ++-- src/power/actions.py | 4 +- src/power/view.py | 7 +- src/replays/_replayswidget.py | 62 ++++--- src/replays/replayToolbox.py | 6 +- src/replays/replayitem.py | 19 ++- src/secondaryServer/secondaryserver.py | 9 +- src/stats/_statswidget.py | 7 +- src/stats/itemviews/leaderboardheaderview.py | 44 ++--- .../itemviews/leaderboarditemdelegate.py | 42 +++-- src/stats/itemviews/leaderboardtablemenu.py | 7 +- src/stats/itemviews/leaderboardtableview.py | 20 ++- src/stats/leaderboard_widget.py | 27 ++- src/stats/leaderboardlineedit.py | 7 +- src/stats/models/leaderboardfiltermodel.py | 2 +- src/stats/models/leaderboardtablemodel.py | 19 ++- src/tourneys/_tournamentswidget.py | 6 +- src/tourneys/tourneyitem.py | 16 +- src/turn_client.py | 5 +- src/tutorials/_tutorialswidget.py | 18 +- src/tutorials/tutorialitem.py | 10 +- src/ui/status_logo.py | 8 +- src/unitdb/unitdbtab.py | 9 +- src/updater/__init__.py | 15 +- src/updater/base.py | 9 +- src/updater/process.py | 11 +- src/updater/widgets.py | 7 +- src/util/__init__.py | 16 +- src/util/crash.py | 9 +- src/util/gameurl.py | 3 +- src/util/qt.py | 6 +- src/util/qt_list_model.py | 6 +- src/util/select_player_dialog.py | 4 +- src/util/theme.py | 16 +- src/vaults/dialogs.py | 8 +- src/vaults/mapvault/mapvault.py | 8 +- src/vaults/mapvault/mapwidget.py | 4 +- src/vaults/modvault/modvault.py | 9 +- src/vaults/modvault/modwidget.py | 6 +- src/vaults/modvault/uimodwidget.py | 5 +- src/vaults/modvault/uploadwidget.py | 3 +- src/vaults/modvault/utils.py | 6 +- src/vaults/vault.py | 9 +- src/vaults/vaultitem.py | 13 +- tests/fa/test_featured.py | 3 +- tests/fa/test_updater.py | 7 +- .../unit_tests/client/test_mouse_position.py | 2 +- tests/unit_tests/client/test_updating.py | 2 +- 126 files changed, 926 insertions(+), 666 deletions(-) diff --git a/conftest.py b/conftest.py index efb18d0f8..01227e12e 100644 --- a/conftest.py +++ b/conftest.py @@ -22,7 +22,7 @@ def application(qapp, request): @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): diff --git a/requirements.txt b/requirements.txt index f0ebc9c6f..f3b75e0b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ -cx_Freeze==5.0.2 +cx_Freeze ipaddress pathlib -pyqt5 +pyqt6 pytest pytest-cov pytest-mock -pytest-qt==3.3.0 -semantic_version==2.8.5 +pytest-qt +pywin32 +semantic_version idna -jsonschema==2.6.0 +jsonschema jinja2 zstandard irc diff --git a/setup.py b/setup.py index 2ac8dbe72..ea8c3dfb4 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import sys from pathlib import Path -import PyQt5.uic +import PyQt6.uic import sip sip.setapi('QString', 2) @@ -13,7 +13,8 @@ sip.setapi('QProcess', 2) if sys.platform == 'win32': - from cx_Freeze import Executable, setup + from cx_Freeze import Executable + from cx_Freeze import setup else: from distutils.core import setup @@ -36,7 +37,7 @@ # 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 + silly_file = Path(PyQt6.__path__[0]) / "uic" / "port_v2" / module print("Removing {}".format(silly_file)) silly_file.unlink() except OSError: diff --git a/src/__main__.py b/src/__main__.py index 412c47d7c..5481613cc 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -7,12 +7,16 @@ import os import sys +from types import TracebackType # According to PyQt5 docs we need to import QtWebEngineWidgets before we create # QApplication -from PyQt5 import QtWebEngineWidgets # noqa: F401 -from PyQt5 import QtWidgets, uic -from PyQt5.QtCore import Qt +from PyQt6 import QtWebEngineWidgets # noqa: F401 +from PyQt6 import uic +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QDialog +from PyQt6.QtWidgets import QMessageBox import util import util.crash @@ -55,14 +59,18 @@ os.environ.setdefault('QT_ANGLE_PLATFORM', 'd3d9') -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) # 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. @@ -80,30 +88,28 @@ def excepthook(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_() + answer = dialog.exec() - if answer == QtWidgets.QDialog.Rejected: - QtWidgets.QApplication.exit(1) + if answer == QDialog.DialogCode.Rejected: + QApplication.exit(1) sys.excepthook = excepthook -def admin_user_error_dialog(): +def admin_user_error_dialog() -> None: from config import Settings ignore_admin = Settings.get("client/ignore_admin", False, bool) if not ignore_admin: - box = QtWidgets.QMessageBox() + 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( - QtWidgets.QMessageBox.Ignore | QtWidgets.QMessageBox.Close, - ) - box.setIcon(QtWidgets.QMessageBox.Critical) + 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) @@ -120,7 +126,7 @@ def run_faf(): faf_client.try_to_auto_login() # Main update loop - QtWidgets.QApplication.exec_() + QApplication.exec() if __name__ == '__main__': @@ -128,8 +134,8 @@ def run_faf(): 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) if sys.platform == 'win32': import ctypes diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index 4b14f9dc6..0800fbecc 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -2,14 +2,16 @@ import logging import time -from PyQt5 import QtCore, QtNetwork, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6 import QtWidgets from config import Settings logger = logging.getLogger(__name__) DO_NOT_ENCODE = QtCore.QByteArray() -DO_NOT_ENCODE.append(":/?&=.,") +DO_NOT_ENCODE.append(b":/?&=.,") class ApiBase(QtCore.QObject): @@ -29,7 +31,7 @@ def request(self, queryDict, responseHandler): query = QtCore.QUrlQuery() for key, value in queryDict.items(): query.addQueryItem(key, str(value)) - stringQuery = query.toString(QtCore.QUrl.FullyDecoded) + stringQuery = query.toString(QtCore.QUrl.ComponentFormattingOption.FullyDecoded) percentEncodedByteArrayQuery = QtCore.QUrl.toPercentEncoding( stringQuery, exclude=DO_NOT_ENCODE, @@ -53,7 +55,7 @@ def request(self, queryDict, responseHandler): def onRequestFinished(self, reply): self._running = False - if reply.error() != QtNetwork.QNetworkReply.NoError: + if reply.error() != QtNetwork.QNetworkReply.NetworkError.NoError: logger.error("API request error: {}".format(reply.error())) else: message_bytes = reply.readAll().data() diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py index 0b2cc4c28..9e9e61dd1 100644 --- a/src/chat/_avatarWidget.py +++ b/src/chat/_avatarWidget.py @@ -1,6 +1,8 @@ -from PyQt5.QtCore import QObject, QSize -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QListWidgetItem, QPushButton +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 @@ -28,7 +30,7 @@ def __init__( @classmethod def builder( cls, parent_widget, lobby_connection, lobby_info, avatar_dler, - theme, **kwargs + theme, **kwargs, ): return lambda: cls( parent_widget, lobby_connection, lobby_info, avatar_dler, theme, diff --git a/src/chat/channel_tab.py b/src/chat/channel_tab.py index 52a4826d9..1a1338ecb 100644 --- a/src/chat/channel_tab.py +++ b/src/chat/channel_tab.py @@ -1,6 +1,6 @@ from enum import IntEnum -from PyQt5.QtCore import QTimer +from PyQt6.QtCore import QTimer from chat.chat_widget import TabIcon diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py index 72c0252dc..366bdeefe 100644 --- a/src/chat/channel_view.py +++ b/src/chat/channel_view.py @@ -2,21 +2,20 @@ import time import jinja2 -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtGui import QDesktopServices +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, - ChatterFormat, - ChatterItemDelegate, - ChatterLayout, - ChatterLayoutElements, - ChatterModel, - ChatterSortFilterModel, -) +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 diff --git a/src/chat/channel_widget.py b/src/chat/channel_widget.py index 77990cacf..a4a0af1fc 100644 --- a/src/chat/channel_widget.py +++ b/src/chat/channel_widget.py @@ -1,8 +1,12 @@ import logging import re -from PyQt5.QtCore import QObject, Qt, QUrl, pyqtSignal -from PyQt5.QtGui import QTextCursor, QTextDocument +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 @@ -101,8 +105,8 @@ def clear_chat(self): def add_avatar_resource(self, url, pix): doc = self.chat_area.document() link = QUrl(url) - if not doc.resource(QTextDocument.ImageResource, link): - doc.addResource(QTextDocument.ImageResource, link, pix) + 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) @@ -127,7 +131,7 @@ def append_line(self, text): self._sticky_scroll.save_scroll() cursor = self.chat_area.textCursor() - cursor.movePosition(QTextCursor.End) + cursor.movePosition(QTextCursor.MoveOperation.End) self.chat_area.setTextCursor(cursor) self.chat_area.insertHtml(text) @@ -135,8 +139,8 @@ def append_line(self, text): def remove_lines(self, number): cursor = self.chat_area.textCursor() - cursor.movePosition(QTextCursor.Start) - cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor, number) + cursor.movePosition(QTextCursor.MoveOperation.Start) + cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor, number) cursor.removeSelectedText() def set_chatter_delegate(self, delegate): @@ -144,7 +148,7 @@ def set_chatter_delegate(self, delegate): def set_chatter_model(self, model): self.nick_list.setModel(model) - model.setFilterCaseSensitivity(Qt.CaseInsensitive) + model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) def set_chatter_event_filter(self, event_filter): self.nick_list.viewport().installEventFilter(event_filter) diff --git a/src/chat/chat_controller.py b/src/chat/chat_controller.py index 94a75d1c5..0b97ec36f 100644 --- a/src/chat/chat_controller.py +++ b/src/chat/chat_controller.py @@ -1,10 +1,15 @@ from enum import Enum -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal -from model.chat.channel import Channel, ChannelID, ChannelType, Lines +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, ChatLineType +from model.chat.chatline import ChatLine +from model.chat.chatline import ChatLineType from model.chat.chatter import Chatter diff --git a/src/chat/chat_widget.py b/src/chat/chat_widget.py index 59ce83798..308b08ded 100644 --- a/src/chat/chat_widget.py +++ b/src/chat/chat_widget.py @@ -1,9 +1,12 @@ from enum import Enum -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtWidgets import QApplication, QTabBar +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, ChannelType +from model.chat.channel import PARTY_CHANNEL_SUFFIX +from model.chat.channel import ChannelType class TabIcon(Enum): @@ -37,7 +40,7 @@ def set_theme(self): self.remove_server_tab_close_button() def remove_server_tab_close_button(self): - self.base.tabBar().setTabButton(0, QTabBar.RightSide, None) + self.base.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None) def add_channel(self, widget, key, index=None): if key in self._channels: diff --git a/src/chat/chatlineedit.py b/src/chat/chatlineedit.py index dd6bd43da..0370531e0 100644 --- a/src/chat/chatlineedit.py +++ b/src/chat/chatlineedit.py @@ -4,7 +4,8 @@ @author: thygrrr """ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets class ChatLineEdit(QtWidgets.QLineEdit): @@ -29,20 +30,20 @@ def set_channel(self, channel): self.channel = channel def event(self, event): - if event.type() == QtCore.QEvent.KeyPress: + 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_Tab: + 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 @@ -59,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): diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index caeb22bb7..b1b304cb7 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -1,7 +1,9 @@ import logging from enum import Enum -from PyQt5.QtWidgets import QAction, QApplication, QMenu +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QMenu from model.game import GameState diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index a59e130c0..88262a380 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -1,11 +1,21 @@ -from enum import Enum, IntEnum - -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import QObject, QRectF, QSortFilterProxyModel, Qt, pyqtSignal -from PyQt5.QtGui import QColor, QIcon +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 @@ -72,8 +82,8 @@ def build(cls, model, me, user_relations, chat_config, **kwargs): def lessThan(self, leftIndex, rightIndex): source = self.sourceModel() - left = source.data(leftIndex, Qt.DisplayRole) - right = source.data(rightIndex, Qt.DisplayRole) + 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: @@ -119,14 +129,14 @@ def _get_user_rank(self, item): return ChatterRank.USER return ChatterRank.NONPLAYER - def filterAcceptsRow(self, row, parent): + 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.DisplayRole) + data = source.data(index, Qt.ItemDataRole.DisplayRole) displayed_name = ChatterFormat.chatter_name(data.chatter) - return self.filterRegExp().indexIn(displayed_name) != -1 + return self.filterRegularExpression().match(displayed_name).hasMatch() def _check_sort_changed(self, option): if option == "friendsontop": @@ -369,17 +379,21 @@ 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, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) - def _handle_highlight(self, painter, option): - if option.state & QtWidgets.QStyle.State_Selected: + 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, data): + def _draw_nick(self, painter: QtGui.QPainter, data: str) -> None: text = self._formatter.chatter_name(data) color = QColor(self._formatter.chatter_color(data)) - clip = QRectF(self.layout.sizes[ChatterLayoutElements.NICK]) + clip = QRect(self.layout.sizes[ChatterLayoutElements.NICK]) text = self._get_elided_text(painter, text, clip.width()) painter.save() @@ -387,13 +401,13 @@ def _draw_nick(self, painter, data): pen.setColor(color) painter.setPen(pen) - painter.drawText(clip, Qt.AlignLeft | Qt.AlignVCenter, text) + painter.drawText(clip, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text) painter.restore() - def _get_elided_text(self, painter, text, width): + def _get_elided_text(self, painter: QtGui.QPainter, text: str, width: int) -> str: metrics = painter.fontMetrics() - return metrics.elidedText(text, Qt.ElideRight, width) + return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width) def _draw_status(self, painter, data): status = self._formatter.chatter_status(data) @@ -427,7 +441,7 @@ def _draw_country(self, painter, data): def _draw_icon(self, painter, icon, element): rect = self.layout.sizes[element] - icon.paint(painter, rect, QtCore.Qt.AlignCenter) + icon.paint(painter, rect, QtCore.Qt.AlignmentFlag.AlignCenter) def sizeHint(self, option, index): return self.layout.size @@ -551,18 +565,22 @@ 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.ToolTip: + if event.type() == QtCore.QEvent.Type.ToolTip: return self._handle_tooltip(obj, event) - elif event.type() == QtCore.QEvent.MouseButtonRelease: - if event.button() == QtCore.Qt.RightButton: + 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.MouseButtonDblClick: - if event.button() == QtCore.Qt.LeftButton: + 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, event): - view = widget.parent() + 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 @@ -571,7 +589,7 @@ def _get_data_and_elem(self, widget, event): elem = self._chatter_layout.element_at_point(point) return idx.data(), elem - def _handle_tooltip(self, widget, event): + 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 diff --git a/src/chat/chatter_model_item.py b/src/chat/chatter_model_item.py index 951ba59cb..1cbd44ddf 100644 --- a/src/chat/chatter_model_item.py +++ b/src/chat/chatter_model_item.py @@ -1,6 +1,7 @@ from urllib import parse -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal from downloadManager import DownloadRequest from fa import maps diff --git a/src/chat/chatterlistview.py b/src/chat/chatterlistview.py index 3e39bc186..02723356d 100644 --- a/src/chat/chatterlistview.py +++ b/src/chat/chatterlistview.py @@ -1,5 +1,5 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QListView +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QListView class ChatterListView(QListView): diff --git a/src/chat/gameinfo.py b/src/chat/gameinfo.py index aea021a19..857525c64 100644 --- a/src/chat/gameinfo.py +++ b/src/chat/gameinfo.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import QObject +from PyQt6.QtCore import QObject from model.game import GameState diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py index d501c2a75..63dc6bfe8 100644 --- a/src/chat/ircconnection.py +++ b/src/chat/ircconnection.py @@ -5,12 +5,17 @@ import irc import irc.client -from PyQt5.QtCore import QObject, QSocketNotifier, QTimer, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QSocketNotifier +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import pyqtSignal import config import util -from model.chat.channel import ChannelID, ChannelType -from model.chat.chatline import ChatLine, ChatLineType +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__) PONG_INTERVAL = 60000 # milliseconds between pongs diff --git a/src/chat/language_channel_config.py b/src/chat/language_channel_config.py index 6e4b4637f..6f2588152 100644 --- a/src/chat/language_channel_config.py +++ b/src/chat/language_channel_config.py @@ -1,4 +1,5 @@ -from PyQt5.QtCore import QAbstractListModel, Qt +from PyQt6.QtCore import QAbstractListModel +from PyQt6.QtCore import Qt from chat.lang import LANGUAGE_CHANNELS @@ -83,25 +84,25 @@ def rowCount(self, parent): return 0 return len(self._items) - def data(self, index, role=Qt.DisplayRole): + def data(self, index, role=Qt.ItemDataRole.DisplayRole): item = self._index_item(index) if item is None: return None - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: return item.name - if role == Qt.DecorationRole: + if role == Qt.ItemDataRole.DecorationRole: return item.icon - if role == Qt.CheckStateRole: + if role == Qt.ItemDataRole.CheckStateRole: return item.checked return None - def setData(self, index, value, role=Qt.EditRole): + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): item = self._index_item(index) if item is None: return False - if role == Qt.CheckStateRole: + if role == Qt.ItemDataRole.CheckStateRole: item.checked = value - self.dataChanged.emit(index, index, [Qt.CheckStateRole]) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole]) return True return False @@ -120,7 +121,7 @@ def load_data(self, entries): def flags(self, index): if index.isValid(): - return Qt.ItemIsUserCheckable | Qt.ItemIsEnabled + return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled return 0 def checked_channels(self): diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index e72695d51..5f45ab991 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -4,8 +4,10 @@ from functools import partial from oauthlib.oauth2 import WebApplicationClient -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtNetwork import QNetworkAccessManager +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkAccessManager from requests_oauthlib import OAuth2Session import config @@ -24,34 +26,29 @@ from chat.ircconnection import IrcConnection from chat.language_channel_config import LanguageChannelConfig from chat.line_restorer import ChatLineRestorer -from client.aliasviewer import AliasSearchWindow, AliasWindow +from client.aliasviewer import AliasSearchWindow +from client.aliasviewer import AliasWindow from client.chat_config import ChatConfig from client.clientstate import ClientState -from client.connection import ( - ConnectionState, - Dispatcher, - LobbyInfo, - ServerConnection, - ServerReconnecter, -) +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.login import LoginWidget from client.playercolors import PlayerColors from client.theme_menu import ThemeMenu -from client.user import ( - User, - UserRelationController, - UserRelationModel, - UserRelations, - UserRelationTrackers, -) +from client.user import User +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 ( - MAP_PREVIEW_ROOT, - AvatarDownloader, - PreviewDownloader, -) +from downloadManager import MAP_PREVIEW_ROOT +from downloadManager import AvatarDownloader +from downloadManager import PreviewDownloader from fa.factions import Factions from fa.game_runner import GameRunner from fa.game_session import GameSession @@ -61,13 +58,16 @@ from games.gamemodel import GameModel from games.hostgamewidget import build_launcher from mapGenerator.mapgenManager import MapGeneratorManager -from model.chat.channel import ChannelID, ChannelType +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, PlayerGameIndex +from model.gameset import Gameset +from model.gameset import PlayerGameIndex from model.player import Player from model.playerset import Playerset -from model.rating import MatchmakerQueueType, RatingType +from model.rating import MatchmakerQueueType +from model.rating import RatingType from news import NewsWidget from power import PowerTools from replays import ReplaysWidget @@ -79,7 +79,8 @@ from updater import ClientUpdateTools from vaults.mapvault.mapvault import MapVault from vaults.modvault.modvault import ModVault -from vaults.modvault.utils import getModFolder, setModFolder +from vaults.modvault.utils import getModFolder +from vaults.modvault.utils import setModFolder from .mouse_position import MousePosition from .oauth_dialog import OAuthWidget @@ -283,7 +284,7 @@ def __init__(self, *args, **kwargs): # Process used to run Forged Alliance (managed in module fa) fa.instance.started.connect(self.started_fa) fa.instance.finished.connect(self.finished_fa) - fa.instance.error.connect(self.error_fa) + fa.instance.errorOccurred.connect(self.error_fa) self.gameset.added.connect(fa.instance.newServerGame) # Local Replay Server @@ -306,13 +307,13 @@ def __init__(self, *args, **kwargs): # Frameless self.setWindowFlags( - QtCore.Qt.FramelessWindowHint - | QtCore.Qt.WindowSystemMenuHint - | QtCore.Qt.WindowMinimizeButtonHint, + QtCore.Qt.WindowType.FramelessWindowHint + | QtCore.Qt.WindowType.WindowSystemMenuHint + | QtCore.Qt.WindowType.WindowMinimizeButtonHint, ) self.rubber_band = QtWidgets.QRubberBand( - QtWidgets.QRubberBand.Rectangle, + QtWidgets.QRubberBand.Shape.Rectangle, ) self.mouse_position = MousePosition(self) @@ -352,7 +353,7 @@ def __init__(self, *args, **kwargs): self.topLayout.addWidget(close) self.topLayout.setSpacing(0) self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed, ) self.is_window_maximized = False @@ -491,17 +492,17 @@ def on_disconnected(self): self.games.stopSearch() def appStateChanged(self, state): - if state == QtCore.Qt.ApplicationInactive: + if state == QtCore.Qt.ApplicationState.ApplicationInactive: self._lastDeactivateTime = time.time() def eventFilter(self, obj, event): - if event.type() == QtCore.QEvent.HoverMove: + if event.type() == QtCore.QEvent.Type.HoverMove: self.dragging_hover = self.dragging if self.dragging: - self.resize_widget(self.mapToGlobal(event.pos())) + self.resize_widget(self.mapToGlobal(event.position())) else: if not self.is_window_maximized: - self.mouse_position.update_mouse_position(event.pos()) + self.mouse_position.update_mouse_position(event.position()) else: self.mouse_position.reset_to_false() self.update_cursor_shape() @@ -514,32 +515,35 @@ def update_cursor_shape(self): or self.mouse_position.on_bottom_right_edge ): self.mouse_position.cursor_shape_change = True - self.setCursor(QtCore.Qt.SizeFDiagCursor) + 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.SizeBDiagCursor) + 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.SizeHorCursor) + 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.SizeVerCursor) + self.setCursor(QtCore.Qt.CursorShape.SizeVerCursor) self.mouse_position.cursor_shape_change = True else: if self.mouse_position.cursor_shape_change: self.unsetCursor() self.mouse_position.cursor_shape_change = False - def handle_tray_icon_activation(self, reason): - if reason == QtWidgets.QSystemTrayIcon.Trigger: + def handle_tray_icon_activation( + self, + reason: QtWidgets.QSystemTrayIcon.ActivationReason, + ) -> None: + if reason is QtWidgets.QSystemTrayIcon.ActivationReason.Trigger: inactiveTime = time.time() - self._lastDeactivateTime if ( self.isMinimized() @@ -548,7 +552,7 @@ def handle_tray_icon_activation(self, reason): self.show_normal() else: self.showMinimized() - elif reason == QtWidgets.QSystemTrayIcon.Context: + elif reason is QtWidgets.QSystemTrayIcon.ActivationReason.Context: position = QtGui.QCursor.pos() position.setY(position.y() - self.tray.contextMenu().height()) self.tray.contextMenu().popup(position) @@ -567,7 +571,7 @@ def show_max_restore(self): self.is_window_maximized = True self.current_geometry = self.geometry() self.setGeometry( - QtWidgets.QDesktopWidget().availableGeometry(self), + QtWidgets.QApplication.primaryScreen().availableGeometry(), ) def mouseDoubleClickEvent(self, event): @@ -584,7 +588,7 @@ def mouseReleaseEvent(self, event): # self.show_max_restore() def mousePressEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: + if event.button() == QtCore.Qt.MouseButton.LeftButton: if ( self.mouse_position.is_on_edge() and not self.is_window_maximized @@ -595,22 +599,23 @@ def mousePressEvent(self, event): self.dragging = False self.moving = True - self.offset = event.pos() + self.offset = event.position() def mouseMoveEvent(self, event): if self.dragging and not self.dragging_hover: - self.resize_widget(event.globalPos()) + self.resize_widget(event.globalPosition()) elif self.moving and self.offset is not None: - desktop = QtWidgets.QDesktopWidget().availableGeometry(self) - if event.globalPos().y() == 0: + desktop = QtWidgets.QApplication.primaryScreen().availableGeometry() + # desktop = QtWidgets.QDesktopWidget().availableGeometry(self) + if event.globalPosition().y() == 0: self.rubber_band.setGeometry(desktop) self.rubber_band.show() - elif event.globalPos().x() == 0: + elif event.globalPosition().x() == 0: desktop.setRight(desktop.right() / 2.0) self.rubber_band.setGeometry(desktop) self.rubber_band.show() - elif event.globalPos().x() == desktop.right(): + elif event.globalPosition().x() == desktop.right(): desktop.setRight(desktop.right() / 2.0) desktop.moveLeft(desktop.right()) self.rubber_band.setGeometry(desktop) @@ -621,10 +626,12 @@ def mouseMoveEvent(self, event): if self.is_window_maximized: self.show_max_restore() - self.move(event.globalPos() - self.offset) + point_f = event.globalPosition() - self.offset + self.move(point_f.toPoint()) - def resize_widget(self, mouse_position): - if mouse_position.y() == 0: + def resize_widget(self, mouse_position: QtCore.QRectF) -> None: + mouse_point = mouse_position.toPoint() + if mouse_point.y() == 0: self.rubber_band.setGeometry( QtWidgets.QDesktopWidget().availableGeometry(self), ) @@ -638,26 +645,25 @@ def resize_widget(self, mouse_position): min_width = self.minimumWidth() min_height = self.minimumHeight() if self.mouse_position.on_top_left_edge: - left = mouse_position.x() - top = mouse_position.y() - + left = mouse_point.x() + top = mouse_point.y() elif self.mouse_position.on_bottom_left_edge: - left = mouse_position.x() - bottom = mouse_position.y() + left = mouse_point.x() + bottom = mouse_point.y() elif self.mouse_position.on_top_right_edge: - right = mouse_position.x() - top = mouse_position.y() + right = mouse_point.x() + top = mouse_point.y() elif self.mouse_position.on_bottom_right_edge: - right = mouse_position.x() - bottom = mouse_position.y() + right = mouse_point.x() + bottom = mouse_point.y() elif self.mouse_position.on_left_edge: - left = mouse_position.x() + left = mouse_point.x() elif self.mouse_position.on_right_edge: - right = mouse_position.x() + right = mouse_point.x() elif self.mouse_position.on_top_edge: - top = mouse_position.y() + top = mouse_point.y() elif self.mouse_position.on_bottom_edge: - bottom = mouse_position.y() + bottom = mouse_point.y() new_rect = QtCore.QRect( QtCore.QPoint(left, top), @@ -844,8 +850,8 @@ def setup(self): "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) + 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) @@ -1266,7 +1272,7 @@ def set_ice_adapter_window_launch_delay(self): @QtCore.pyqtSlot() def switchPath(self): - fa.wizards.Wizard(self).exec_() + fa.wizards.Wizard(self).exec() @QtCore.pyqtSlot() def clearSettings(self): @@ -1337,7 +1343,7 @@ def connectivityDialog(self): 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): @@ -1577,10 +1583,10 @@ def show_login_widget(self): 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, QtCore.Qt.QueuedConnection, + 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, api_changed): self.lobby_connection.setHostFromConfig() @@ -1605,7 +1611,7 @@ def on_widget_login_data(self, api_changed): ) oauth_widget.finished.connect(self.oauth_finished) oauth_widget.rejected.connect(self.on_widget_no_login) - oauth_widget.exec_() + oauth_widget.exec() def oauth_finished(self, state, code, error): token_url = config.Settings.get("oauth/host") + OAUTH_TOKEN_PATH diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py index ebbbb2a8a..58a2d1355 100644 --- a/src/client/aliasviewer.py +++ b/src/client/aliasviewer.py @@ -1,7 +1,9 @@ import logging -from PyQt5 import QtWidgets -from PyQt5.QtCore import QDateTime, Qt, QTimer +from PyQt6 import QtWidgets +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QTimer from api.player_api import PlayerApiConnector @@ -79,7 +81,7 @@ def nick_times(self, name_records): ] for record in past_records: - isoTime = QDateTime.fromString(record["changeTime"], Qt.ISODate) + isoTime = QDateTime.fromString(record["changeTime"], Qt.DateFormat.ISODate) record["changeTime"] = isoTime.toLocalTime() past_records.sort(key=lambda record: record["changeTime"]) diff --git a/src/client/chat_config.py b/src/client/chat_config.py index fc794e8c7..ab153652c 100644 --- a/src/client/chat_config.py +++ b/src/client/chat_config.py @@ -1,4 +1,4 @@ -from PyQt5 import QtCore +from PyQt6 import QtCore from chat.chatter_model import ChatterLayoutElements from client.user import SignallingSet # TODO - move to util diff --git a/src/client/connection.py b/src/client/connection.py index 6674969bf..f74441125 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -3,11 +3,13 @@ import sys from enum import IntEnum -from PyQt5 import QtCore, QtNetwork +from PyQt6 import QtCore +from PyQt6 import QtNetwork import fa from config import Settings -from model.game import Game, message_to_game_args +from model.game import Game +from model.game import message_to_game_args logger = logging.getLogger(__name__) @@ -154,8 +156,8 @@ 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.errorOccurred.connect(self.socketError) + self.socket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1) self.socket.stateChanged.connect(self.on_socket_state_change) self._host = host @@ -167,7 +169,7 @@ def __init__(self, host, port, dispatch): self._dispatch = dispatch 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 @@ -233,7 +235,7 @@ 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() @@ -279,7 +281,7 @@ def writeToServer(self, action, *args, **kw): 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.ConnectedState: + if self.socket.state() == QtNetwork.QAbstractSocket.SocketState.ConnectedState: self.socket.write(message) def send(self, message): @@ -309,10 +311,10 @@ def on_disconnect(self): @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 + 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()), diff --git a/src/client/gameannouncer.py b/src/client/gameannouncer.py index 11cdd128f..5c84f7d85 100644 --- a/src/client/gameannouncer.py +++ b/src/client/gameannouncer.py @@ -1,4 +1,6 @@ -from PyQt5.QtCore import QObject, QTimer, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import pyqtSignal from fa import maps from model.game import GameState diff --git a/src/client/login.py b/src/client/login.py index 1f2a742e3..f6b50a28c 100644 --- a/src/client/login.py +++ b/src/client/login.py @@ -1,6 +1,7 @@ import logging -from PyQt5 import QtCore, QtGui +from PyQt6 import QtCore +from PyQt6 import QtGui import config import util diff --git a/src/client/oauth_dialog.py b/src/client/oauth_dialog.py index 24a3abf19..02fbe793c 100644 --- a/src/client/oauth_dialog.py +++ b/src/client/oauth_dialog.py @@ -1,6 +1,9 @@ import logging -from PyQt5 import QtCore, QtGui, QtWebEngineWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWebEngineCore +from PyQt6 import QtWebEngineWidgets import util @@ -44,11 +47,11 @@ def navigationRequestAccepted(self, url): self.finished.emit("", "", error) -class OAuthWebPage(QtWebEngineWidgets.QWebEnginePage): +class OAuthWebPage(QtWebEngineCore.QWebEnginePage): navigationRequestAccepted = QtCore.pyqtSignal(QtCore.QUrl) def __init__(self): - QtWebEngineWidgets.QWebEnginePage.__init__(self) + QtWebEngineCore.QWebEnginePage.__init__(self) def acceptNavigationRequest(self, url, type_, isMainFrame): if "oauth" in url.url() or "localhost" in url.url(): diff --git a/src/client/playercolors.py b/src/client/playercolors.py index 8c27a17fb..f5960b895 100644 --- a/src/client/playercolors.py +++ b/src/client/playercolors.py @@ -2,7 +2,8 @@ import random from enum import Enum -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal class PlayerAffiliation(Enum): diff --git a/src/client/theme_menu.py b/src/client/theme_menu.py index 9551c71d9..2f1c0f422 100644 --- a/src/client/theme_menu.py +++ b/src/client/theme_menu.py @@ -1,4 +1,4 @@ -from PyQt5 import QtCore +from PyQt6 import QtCore import util diff --git a/src/client/user.py b/src/client/user.py index 9f3e404dc..ca0221061 100644 --- a/src/client/user.py +++ b/src/client/user.py @@ -1,7 +1,8 @@ from collections.abc import MutableSet -from PyQt5 import QtCore -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6 import QtCore +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal class User(QtCore.QObject): diff --git a/src/config/__init__.py b/src/config/__init__.py index ba805fc99..c4e653daa 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -4,16 +4,16 @@ import os import sys import traceback -from logging.handlers import MemoryHandler, RotatingFileHandler +from logging.handlers import MemoryHandler +from logging.handlers import RotatingFileHandler -from PyQt5 import QtCore +from PyQt6 import QtCore import fafpath - -from . import version -from .develop import default_values as develop_defaults -from .production import default_values as production_defaults -from .testing import default_values as testing_defaults +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 @@ -25,8 +25,8 @@ from . import admin _settings = QtCore.QSettings( - QtCore.QSettings.IniFormat, - QtCore.QSettings.UserScope, + QtCore.QSettings.Format.IniFormat, + QtCore.QSettings.Scope.UserScope, "ForgedAllianceForever", "FA Lobby", ) @@ -306,15 +306,15 @@ def setup_file_handler(filename): def qt_log_handler(type_, context, text): loglvl = None - if type_ == QtCore.QtDebugMsg: + if type_ == QtCore.QtMsgType.QtDebugMsg: loglvl = logging.DEBUG - elif type_ == QtCore.QtInfoMsg: + elif type_ == QtCore.QtMsgType.QtInfoMsg: loglvl = logging.INFO - elif type_ == QtCore.QtWarningMsg: + elif type_ == QtCore.QtMsgType.QtWarningMsg: loglvl = logging.WARNING - elif type_ == QtCore.QtCriticalMsg: + elif type_ == QtCore.QtMsgType.QtCriticalMsg: loglvl = logging.ERROR - elif type_ == QtCore.QtFatalMsg: + elif type_ == QtCore.QtMsgType.QtFatalMsg: loglvl = logging.CRITICAL if loglvl is None: return diff --git a/src/connectivity/ConnectivityDialog.py b/src/connectivity/ConnectivityDialog.py index 8d5f5c74a..2c3aad6fa 100644 --- a/src/connectivity/ConnectivityDialog.py +++ b/src/connectivity/ConnectivityDialog.py @@ -1,9 +1,13 @@ import pprint -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtWidgets import QHeaderView, QInputDialog, QTableWidgetItem +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 @@ -21,7 +25,7 @@ class ConnectivityDialog(object): columnCount = 8 - def __init__(self, ice_adapter_client): + 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) @@ -29,7 +33,7 @@ def __init__(self, ice_adapter_client): 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.Dialog) + self.dialog.setParent(clientwindow.instance, Qt.WindowType.Dialog) # the table header needs theming, # and using "QHeaderView::section { background-color: green; }" @@ -43,12 +47,10 @@ def __init__(self, ice_adapter_client): """ self.dialog.table_relays.horizontalHeader().setStyleSheet(stylesheet) self.dialog.table_relays.horizontalHeader().setSectionResizeMode( - QHeaderView.Stretch, + QHeaderView.ResizeMode.Stretch, ) self.dialog.table_relays.horizontalHeader().setFixedHeight(30) - self.dialog.table_relays.verticalHeader().setSectionResizeMode( - QHeaderView.Fixed, - ) + self.dialog.table_relays.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) self.dialog.table_relays.verticalHeader().hide() self.dialog.finished.connect(self.close) @@ -157,5 +159,5 @@ def onStatus(self, status): def tableItem(self, data): item = QTableWidgetItem(str(data)) - item.setTextAlignment(Qt.AlignCenter) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) return item diff --git a/src/connectivity/IceAdapterClient.py b/src/connectivity/IceAdapterClient.py index da450433e..b51e6b4e0 100644 --- a/src/connectivity/IceAdapterClient.py +++ b/src/connectivity/IceAdapterClient.py @@ -1,6 +1,6 @@ import json -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal import client from client.connection import ConnectionState diff --git a/src/connectivity/IceAdapterProcess.py b/src/connectivity/IceAdapterProcess.py index 0b2caac51..e9d8e7c1d 100644 --- a/src/connectivity/IceAdapterProcess.py +++ b/src/connectivity/IceAdapterProcess.py @@ -1,9 +1,11 @@ import os import sys -from PyQt5.QtCore import QProcess, QProcessEnvironment -from PyQt5.QtNetwork import QHostAddress, QTcpServer -from PyQt5.QtWidgets import QMessageBox +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 @@ -17,7 +19,7 @@ def __init__(self, player_id, player_login): # determine free listen port for the RPC server inside the ice adapter # process s = QTcpServer() - s.listen(QHostAddress.LocalHost, 0) + s.listen(QHostAddress.SpecialAddress.LocalHost, 0) self._rpc_server_port = s.serverPort() s.close() @@ -93,8 +95,8 @@ def on_error_ready(self): for line in standard_error.splitlines(): self._logger.debug("ICEERROR: " + line) - def on_exit(self, code, status): - if status == QProcess.CrashExit: + 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", @@ -119,10 +121,10 @@ def rpc_port(self): return self._rpc_server_port def close(self): - if self.ice_adapter_process.state() == QProcess.Running: + 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.Running: + 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): diff --git a/src/connectivity/IceServersPoller.py b/src/connectivity/IceServersPoller.py index 4783aa289..a05ff3d68 100644 --- a/src/connectivity/IceServersPoller.py +++ b/src/connectivity/IceServersPoller.py @@ -1,6 +1,8 @@ -from datetime import datetime, timedelta +from datetime import datetime +from datetime import timedelta -from PyQt5.QtCore import QObject, QTimer +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QTimer from decorators import with_logger diff --git a/src/connectivity/JsonRpcTcpClient.py b/src/connectivity/JsonRpcTcpClient.py index 307e68ed4..bf4f86858 100644 --- a/src/connectivity/JsonRpcTcpClient.py +++ b/src/connectivity/JsonRpcTcpClient.py @@ -1,8 +1,9 @@ import json -from PyQt5 import QtCore -from PyQt5.QtCore import QObject -from PyQt5.QtNetwork import QAbstractSocket, QTcpSocket +from PyQt6 import QtCore +from PyQt6.QtCore import QObject +from PyQt6.QtNetwork import QAbstractSocket +from PyQt6.QtNetwork import QTcpSocket from decorators import with_logger @@ -14,7 +15,7 @@ def __init__(self, request_handler_instance): self.socket = QTcpSocket(self) self.connectionAttempts = 1 self.socket.readyRead.connect(self.onData) - self.socket.error.connect(self.onSocketError) + self.socket.errorOccurred.connect(self.onSocketError) self.request_handler_instance = request_handler_instance self.nextid = 1 self.callbacks_result = {} @@ -29,11 +30,11 @@ def connect_(self, host, port, blocking=False): self.socket.waitForConnected(5000) def isConnected(self): - return self.socket.state() == QAbstractSocket.ConnectedState + return self.socket.state() == QAbstractSocket.SocketState.ConnectedState @QtCore.pyqtSlot(QAbstractSocket.SocketError) def onSocketError(self, error): - if (error == QAbstractSocket.ConnectionRefusedError): + if (error == QAbstractSocket.SocketError.ConnectionRefusedError): self.socket.connectToHost(self.host, self.port) self.connectionAttempts += 1 # self._logger.info("Reconnecting to JSONRPC server {}" @@ -167,7 +168,7 @@ def call( callback_error=None, blocking=False, ): - if self.socket.state() != QAbstractSocket.ConnectedState: + if self.socket.state() != QAbstractSocket.SocketState.ConnectedState: raise RuntimeError("Not connected to the JSONRPC server.") rpcObject = { "method": method, diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 4e2915970..035cc8620 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -1,12 +1,16 @@ import logging import os -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkRequest import fa import util -from coop.coopmapitem import CoopMapItem, CoopMapItemDelegate +from coop.coopmapitem import CoopMapItem +from coop.coopmapitem import CoopMapItemDelegate from coop.coopmodel import CoopGameFilterModel from fa.replay import replay from ui.busy_widget import BusyWidget @@ -43,7 +47,7 @@ def __init__( self.client.lobby_info.coopInfo.connect(self.processCoopInfo) self.coopList.header().setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeToContents, + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, ) self.coopList.setItemDelegate(CoopMapItemDelegate(self)) @@ -100,7 +104,8 @@ def finishRequest(self, reply): "temp.fafreplay", ), ) - faf_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate) + open_mode = QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.OpenModeFlag.Truncate + faf_replay.open(open_mode) faf_replay.write(reply.readAll()) faf_replay.flush() faf_replay.close() @@ -144,7 +149,7 @@ def processLeaderBoardInfos(self, message): formatter = self.FORMATTER_LADDER formatter_header = self.FORMATTER_LADDER_HEADER cursor = w.textCursor() - cursor.movePosition(QtGui.QTextCursor.End) + cursor.movePosition(QtGui.QTextCursor.MoveOperation.End) w.setTextCursor(cursor) color = "lime" line = formatter_header.format( diff --git a/src/coop/coopmapitem.py b/src/coop/coopmapitem.py index d7f3aa464..5b95c6331 100644 --- a/src/coop/coopmapitem.py +++ b/src/coop/coopmapitem.py @@ -1,11 +1,13 @@ -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util 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): @@ -15,7 +17,7 @@ def paint(self, painter, option, index, *args, **kwargs): 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()) @@ -25,7 +27,7 @@ def paint(self, painter, option, index, *args, **kwargs): # rendering these parts ourselves option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Description painter.translate(option.rect.left(), option.rect.top()) @@ -34,11 +36,17 @@ def paint(self, painter, option, index, *args, **kwargs): 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) @@ -88,9 +96,9 @@ 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(CoopMapItem, self).data(column, role) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 0de65e0c8..4e6334ec0 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -4,9 +4,14 @@ import urllib.parse import urllib.request -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import QObject, QUrl, pyqtSignal -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkRequest from config import Settings @@ -58,7 +63,7 @@ def cancel(self): def _finish(self): # check status code statusCode = self._dfile.attribute( - QNetworkRequest.HttpStatusCodeAttribute, + QNetworkRequest.Attribute.HttpStatusCodeAttribute, ) if statusCode != 200: logger.debug( @@ -71,13 +76,12 @@ def run(self): self._running = True req = QNetworkRequest(QUrl(self.addr)) req.setRawHeader(b'User-Agent', b"FAF Client") - req.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) req.setMaximumRedirectsAllowed(3) self.start.emit(self) self._dfile = self._nam.get(req) - self._dfile.error.connect(self._error) + 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) @@ -118,7 +122,7 @@ def succeeded(self): return not self.error and not self.canceled def waitForCompletion(self): - waitFlag = QtCore.QEventLoop.WaitForMoreEvents + waitFlag = QtCore.QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: QtWidgets.QApplication.processEvents(waitFlag) @@ -159,7 +163,7 @@ def _prepare_dl(self): def _get_cachefile(self, name): imgpath = os.path.join(self._target_dir, name) img = QtCore.QFile(imgpath) - img.open(QtCore.QIODevice.WriteOnly) + img.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) return img, imgpath def remove_request(self, req): diff --git a/src/fa/check.py b/src/fa/check.py index edbd23798..166f5eec5 100644 --- a/src/fa/check.py +++ b/src/fa/check.py @@ -1,22 +1,29 @@ +from __future__ import annotations + import binascii import logging import os import zipfile +from typing import TYPE_CHECKING -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets import config import fa import util from fa.mods import checkMods -from fa.path import validatePath, writeFAPathLua +from fa.path import validatePath +from fa.path import writeFAPathLua from fa.wizards import Wizard 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. """ @@ -28,10 +35,12 @@ def map_(mapname, force=False, silent=False): if isGeneratedMap(mapname): import client - return client.instance.map_generator.generateMap(mapname) + + # 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: @@ -46,17 +55,17 @@ def map_(mapname, force=False, silent=False): "downloaded automatically in the future", ) msgbox.setStandardButtons( - QtWidgets.QMessageBox.Yes - | QtWidgets.QMessageBox.YesToAll - | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.YesToAll + | QtWidgets.QMessageBox.StandardButton.No, ) - result = msgbox.exec_() - if result == QtWidgets.QMessageBox.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): @@ -67,7 +76,7 @@ def sim_mod(sim_mod, version): pass -def path(parent): +def path(parent: ClientWindow) -> bool: while not validatePath( util.settings.value( "ForgedAlliance/app/path", "", @@ -80,8 +89,8 @@ def path(parent): ), ) 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.") diff --git a/src/fa/game_connection.py b/src/fa/game_connection.py index 8922ef61d..57a68a294 100644 --- a/src/fa/game_connection.py +++ b/src/fa/game_connection.py @@ -1,6 +1,9 @@ -from struct import pack, unpack +from struct import pack +from struct import unpack -from PyQt5.QtCore import QDataStream, QObject, pyqtSignal +from PyQt6.QtCore import QDataStream +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal from decorators import with_logger diff --git a/src/fa/game_process.py b/src/fa/game_process.py index e61176586..db1bb0285 100644 --- a/src/fa/game_process.py +++ b/src/fa/game_process.py @@ -3,7 +3,8 @@ import re import sys -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import config import util @@ -106,7 +107,7 @@ def run(self, info, arguments, detach=False, init_file=None): self.setWorkingDirectory(os.path.dirname(executable)) if not detach: - self.start(command) + self.startCommand(command) else: # Remove the wrapping " at the start and end of some # arguments as QT will double wrap when launching @@ -127,7 +128,7 @@ def run(self, info, arguments, detach=False, init_file=None): return False def running(self): - return self.state() == QtCore.QProcess.Running + return self.state() == QtCore.QProcess.ProcessState.Running def available(self): if self.running(): @@ -147,7 +148,7 @@ def close(self): progress = QtWidgets.QProgressDialog() progress.setCancelButtonText("Terminate") progress.setWindowFlags( - QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint, + QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, ) progress.setAutoClose(False) progress.setAutoReset(False) diff --git a/src/fa/game_session.py b/src/fa/game_session.py index 9a1a1dec2..30e9ab78e 100644 --- a/src/fa/game_session.py +++ b/src/fa/game_session.py @@ -1,7 +1,9 @@ import logging from enum import IntEnum -from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal import client from config import setup_file_handler diff --git a/src/fa/maps.py b/src/fa/maps.py index ccea949d4..1f23cc200 100644 --- a/src/fa/maps.py +++ b/src/fa/maps.py @@ -12,7 +12,8 @@ import urllib.request import zipfile -from PyQt5 import QtCore, QtGui +from PyQt6 import QtCore +from PyQt6 import QtGui # module imports import util @@ -184,7 +185,7 @@ def getUserMapsFolder(): ) -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 """ @@ -203,18 +204,18 @@ def genPrevFromDDS(sourcename, destname, small=False): img, size, size, - QtGui.QImage.Format_RGB888, + QtGui.QImage.Format.Format_RGB888, ).rgbSwapped().scaled( 100, 100, - transformMode=QtCore.Qt.SmoothTransformation, + transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, ) else: imageFile = QtGui.QImage( img, size, size, - QtGui.QImage.Format_RGB888, + QtGui.QImage.Format.Format_RGB888, ).rgbSwapped() imageFile.save(destname) except IOError: diff --git a/src/fa/mods.py b/src/fa/mods.py index 65f569768..f531849c6 100644 --- a/src/fa/mods.py +++ b/src/fa/mods.py @@ -1,6 +1,6 @@ import logging -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets import config import fa @@ -40,7 +40,7 @@ def checkMods(mods): # mods is a dictionary of uid-name pairs | QtWidgets.QMessageBox.YesToAll | QtWidgets.QMessageBox.No, ) - result = msgbox.exec_() + result = msgbox.exec() if result == QtWidgets.QMessageBox.No: return False elif result == QtWidgets.QMessageBox.YesToAll: diff --git a/src/fa/replay.py b/src/fa/replay.py index 3ee5825eb..359eba922 100644 --- a/src/fa/replay.py +++ b/src/fa/replay.py @@ -3,13 +3,15 @@ import os import zstandard -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import fa import util from fa.check import check from fa.replayparser import replayParser -from util.gameurl import GameUrl, GameUrlType +from util.gameurl import GameUrl +from util.gameurl import GameUrlType logger = logging.getLogger(__name__) @@ -71,9 +73,11 @@ def replay(source, detach=False): scfa_replay = QtCore.QFile( os.path.join(util.CACHE_DIR, "temp.scfareplay"), ) - scfa_replay.open( - QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate, + 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() diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py index 8099d3bcb..4bfbc9411 100644 --- a/src/fa/replayserver.py +++ b/src/fa/replayserver.py @@ -4,7 +4,9 @@ import os import time -from PyQt5 import QtCore, QtNetwork, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6 import QtWidgets import fa import util @@ -166,7 +168,7 @@ def writeReplayFile(self): ) replay = QtCore.QFile(filename) - replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text) + replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.Text) replay.write(json.dumps(self.replayInfo).encode('utf-8')) replay.write(b'\n') replay.write(QtCore.qCompress(self.replayData).toBase64()) @@ -188,9 +190,9 @@ def __init__(self, client, *args, **kwargs): self.__logger.debug("initializing...") self.newConnection.connect(self.acceptConnection) - def doListen(self): + def doListen(self) -> bool: while not self.isListening(): - self.listen(QtNetwork.QHostAddress.LocalHost, 0) + self.listen(QtNetwork.QHostAddress.SpecialAddress.LocalHost, 0) if self.isListening(): self.__logger.info( "listening on address {}:{}".format( diff --git a/src/fa/updater.py b/src/fa/updater.py index 37761eb3a..eda68ab16 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -14,11 +14,13 @@ import stat import time -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import config import util -from api.featured_mod_updater import FeaturedModFiles, FeaturedModId +from api.featured_mod_updater import FeaturedModFiles +from api.featured_mod_updater import FeaturedModId from api.sim_mod_updater import SimModFiles from config import Settings from vaults.dialogs import downloadFile @@ -51,12 +53,12 @@ def addWatch(self, watch): watch.finished.connect(self.watchFinished) @QtCore.pyqtSlot() - def watchFinished(self): + def watchFinished(self) -> None: for watch in self.watches: if not watch.isFinished(): return # equivalent to self.accept(), but clearer - self.done(QtWidgets.QDialog.Accepted) + self.done(QtWidgets.QDialog.DialogCode.Accepted) def clearLog(): @@ -115,7 +117,7 @@ def __init__( sim=False, silent=False, *args, - **kwargs + **kwargs, ): """ Constructor @@ -154,7 +156,7 @@ def __init__( else: self.progress.setCancelButtonText("Cancel") self.progress.setWindowFlags( - QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint, + QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, ) self.progress.setAutoClose(False) self.progress.setAutoReset(False) diff --git a/src/fa/wizards.py b/src/fa/wizards.py index e2cc9cf79..2a68b328a 100644 --- a/src/fa/wizards.py +++ b/src/fa/wizards.py @@ -1,20 +1,29 @@ +from __future__ import annotations + import os +from typing import TYPE_CHECKING -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util -from fa.path import typicalForgedAlliancePaths, validatePath +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, + QtWidgets.QWizard.WizardPixmap.WatermarkPixmap, util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"), ) @@ -59,14 +68,14 @@ def comboChanged(self): self.completeChanged.emit() @QtCore.pyqtSlot() - def showChooser(self): + def showChooser(self) -> None: path = QtWidgets.QFileDialog.getExistingDirectory( self, "Select Forged Alliance folder", self.comboBox.currentText(), ( - QtWidgets.QFileDialog.DontResolveSymlinks - | QtWidgets.QFileDialog.ShowDirsOnly + QtWidgets.QFileDialog.Option.DontResolveSymlinks + | QtWidgets.QFileDialog.Option.ShowDirsOnly ), ) if path: @@ -86,20 +95,20 @@ 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 # 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, + 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( @@ -108,11 +117,11 @@ def accept(self): 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: + if combobox.findText(path, QtCore.Qt.MatchFlag.MatchFixedString) == -1: combobox.addItem(path) diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index ab2a78dea..12f996216 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -1,8 +1,11 @@ import logging -from PyQt5 import QtWidgets -from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QColor, QCursor +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 @@ -10,7 +13,8 @@ from config import Settings from games.automatchframe import MatchmakerQueue from games.gamemodel import CustomGameFilterModel -from games.moditem import ModItem, mod_invisible +from games.moditem import ModItem +from games.moditem import mod_invisible from model.chat.channel import PARTY_CHANNEL_SUFFIX logger = logging.getLogger(__name__) @@ -265,12 +269,12 @@ def sortGamesComboChanged(self, index): self._game_model.sort_type = CustomGameFilterModel.SortType(index) def teamListItemClicked(self, item): - if QtWidgets.QApplication.mouseButtons() == Qt.LeftButton: + 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.RightButton + QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.RightButton and self.party.owner_id == self._me.id ): self.teamList.setCurrentItem(item) diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py index 88032eedb..2b8f6cec5 100644 --- a/src/games/automatchframe.py +++ b/src/games/automatchframe.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import logging from functools import partial +from typing import TYPE_CHECKING -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import fa import util @@ -13,10 +18,20 @@ 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, client, queueName, teamSize): + def __init__( + self, + games: GamesWidget, + client: ClientWindow, + queueName: str, + teamSize: int, + ) -> None: BaseClass.__init__(self, games) self.setupUi(self) @@ -60,10 +75,10 @@ def __init__(self, games, client, queueName, teamSize): self.setFactionIcons(self.subFactions) keys = ( - QtCore.Qt.Key_1, QtCore.Qt.Key_2, QtCore.Qt.Key_3, QtCore.Qt.Key_4, + QtCore.Qt.Key.Key_1, QtCore.Qt.Key.Key_2, QtCore.Qt.Key.Key_3, QtCore.Qt.Key.Key_4, ) - self.shortcut = QtWidgets.QShortcut( - QtGui.QKeySequence(QtCore.Qt.CTRL + keys[self.teamSize - 1]), + self.shortcut = QtGui.QShortcut( + QtGui.QKeySequence(QtCore.Qt.Key.Key_Control + keys[self.teamSize - 1]), self.client, self.startSearchRanked, ) diff --git a/src/games/gameitem.py b/src/games/gameitem.py index b75e4ee29..0d89a848b 100644 --- a/src/games/gameitem.py +++ b/src/games/gameitem.py @@ -2,7 +2,9 @@ import os import jinja2 -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util from fa import maps @@ -75,7 +77,7 @@ 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, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) def _draw_icon_shadow(self, painter, option): @@ -94,13 +96,14 @@ def _draw_icon(self, painter, option, icon): self.ICON_CLIP_BOTTOM_RIGHT, self.ICON_CLIP_BOTTOM_RIGHT, ) - icon.paint(painter, rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + 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, @@ -141,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) diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py index c87c3db51..69c07471c 100644 --- a/src/games/gamemodel.py +++ b/src/games/gamemodel.py @@ -1,6 +1,7 @@ from enum import Enum -from PyQt5.QtCore import QSortFilterProxyModel, Qt +from PyQt6.QtCore import QSortFilterProxyModel +from PyQt6.QtCore import Qt from games.moditem import mod_invisible from model.game import GameState @@ -47,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] diff --git a/src/games/gamemodelitem.py b/src/games/gamemodelitem.py index b8e9f506b..5eab67fa3 100644 --- a/src/games/gamemodelitem.py +++ b/src/games/gamemodelitem.py @@ -1,4 +1,5 @@ -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal from downloadManager import DownloadRequest from fa import maps diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py index ff54d4fb5..99c1dabf0 100644 --- a/src/games/hostgamewidget.py +++ b/src/games/hostgamewidget.py @@ -1,6 +1,6 @@ import logging -from PyQt5 import QtCore +from PyQt6 import QtCore import fa.check import games.mapgenoptionsdialog as MapGenDialog @@ -8,7 +8,9 @@ import vaults.modvault.utils from fa import maps from games.gamemodel import GameModel -from model.game import Game, GameState, GameVisibility +from model.game import Game +from model.game import GameState +from model.game import GameVisibility logger = logging.getLogger(__name__) @@ -65,7 +67,7 @@ def host_game(self, title, main_mod, mapname=None): if mapname is not None: self._game_widget.set_map(mapname) - return self._game_widget.exec_() + 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 @@ -155,7 +157,7 @@ def setup(self, title, game): ] logger.debug("Active Mods detected: {}".format(str(names))) for name in names: - ml = self.modList.findItems(name, QtCore.Qt.MatchExactly) + ml = self.modList.findItems(name, QtCore.Qt.MatchFlag.MatchExactly.MatchExactly) logger.debug("found item: {}".format(ml[0].text())) if ml: ml[0].setSelected(True) @@ -252,7 +254,7 @@ def save_last_hosted_settings(self, password): @QtCore.pyqtSlot() def generateMap(self): dialog = MapGenDialog.MapGenDialog(self) - dialog.exec_() + dialog.exec() def build_launcher(playerset, me, client, view_builder, map_preview_dler): diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index cd8bfd913..faac64ab3 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -1,6 +1,7 @@ from enum import Enum -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import config import util @@ -127,8 +128,8 @@ def load_stylesheet(self): def keyPressEvent(self, event): if ( - event.key() == QtCore.Qt.Key_Enter - or event.key() == QtCore.Qt.Key_Return + event.key() == QtCore.Qt.Key.Key_Enter + or event.key() == QtCore.Qt.Key.Key_Return ): return QtWidgets.QDialog.keyPressEvent(self, event) diff --git a/src/games/moditem.py b/src/games/moditem.py index de3bf81c8..93fd64680 100644 --- a/src/games/moditem.py +++ b/src/games/moditem.py @@ -1,6 +1,7 @@ import os -from PyQt5 import QtGui, QtWidgets +from PyQt6 import QtGui +from PyQt6 import QtWidgets import client import util diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py index c53e77f68..752fa0f72 100644 --- a/src/mapGenerator/mapgenManager.py +++ b/src/mapGenerator/mapgenManager.py @@ -3,7 +3,9 @@ import os import random -from PyQt5 import QtCore, QtNetwork, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6 import QtWidgets import util # local imports @@ -77,7 +79,7 @@ def generateMap(self, mapname=None, args=None): | QtWidgets.QMessageBox.YesToAll | QtWidgets.QMessageBox.No, ) - result = msgbox.exec_() + result = msgbox.exec() if result == QtWidgets.QMessageBox.No: return False elif result == QtWidgets.QMessageBox.YesToAll: @@ -162,7 +164,7 @@ def checkUpdates(self): progress = QtWidgets.QProgressDialog() progress.setCancelButtonText("Cancel") progress.setWindowFlags( - QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint, + QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, ) progress.setAutoClose(False) progress.setAutoReset(False) diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py index f6f8569e4..8eb56db90 100644 --- a/src/mapGenerator/mapgenProcess.py +++ b/src/mapGenerator/mapgenProcess.py @@ -1,8 +1,12 @@ import logging import re -from PyQt5.QtCore import QEventLoop, QProcess, Qt -from PyQt5.QtWidgets import QApplication, QMessageBox, QProgressDialog +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 @@ -22,7 +26,7 @@ def __init__(self, gen_path, out_path, args): self._progress.setWindowTitle("Generating map, please wait...") self._progress.setCancelButtonText("Cancel") self._progress.setWindowFlags( - Qt.CustomizeWindowHint | Qt.WindowTitleHint, + Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, ) self._progress.setAutoReset(False) self._progress.setModal(1) @@ -104,18 +108,18 @@ def on_exit(self, code, status): generatorLogger.info("<<< --------------------- MapGenerator Shutdown") def close(self): - if self.map_generator_process.state() == QProcess.Running: + 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.Running: + 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): + def waitForCompletion(self) -> None: '''Copied from downloadManager. I hope it's ok :)''' - waitFlag = QEventLoop.WaitForMoreEvents + waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: QApplication.processEvents(waitFlag) diff --git a/src/model/chat/channel.py b/src/model/chat/channel.py index 3fb219516..25c4d0942 100644 --- a/src/model/chat/channel.py +++ b/src/model/chat/channel.py @@ -1,6 +1,7 @@ from enum import Enum -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal from model.modelitem import ModelItem from model.transaction import transactional diff --git a/src/model/chat/chat.py b/src/model/chat/chat.py index cb839c1db..7af356e8a 100644 --- a/src/model/chat/chat.py +++ b/src/model/chat/chat.py @@ -1,9 +1,8 @@ -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSignal -from model.chat.channelchatterset import ( - ChannelChatterRelation, - ChannelChatterset, -) +from model.chat.channelchatterset import ChannelChatterRelation +from model.chat.channelchatterset import ChannelChatterset from model.chat.channelset import Channelset from model.chat.chatterset import Chatterset diff --git a/src/model/chat/chatter.py b/src/model/chat/chatter.py index 395143b37..7c191108c 100644 --- a/src/model/chat/chatter.py +++ b/src/model/chat/chatter.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal from model.modelitem import ModelItem from model.transaction import transactional diff --git a/src/model/game.py b/src/model/game.py index 35290e631..98c560a2e 100644 --- a/src/model/game.py +++ b/src/model/game.py @@ -3,12 +3,14 @@ import time from enum import Enum -from PyQt5.QtCore import QTimer, pyqtSignal +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, GameUrlType +from util.gameurl import GameUrl +from util.gameurl import GameUrlType class GameState(Enum): @@ -61,7 +63,7 @@ def __init__( sim_mods, password_protected, visibility, - **kwargs + **kwargs, ): ModelItem.__init__(self) @@ -112,7 +114,7 @@ def update(self, **kwargs): self._check_live_replay_timer() self.emit_update(old, _transaction) - def _check_live_replay_timer(self): + def _check_live_replay_timer(self) -> None: if ( self.state != GameState.PLAYING or self._live_replay_timer.isActive() @@ -123,9 +125,9 @@ def _check_live_replay_timer(self): 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)) @transactional def _emit_live_replay(self, _transaction=None): diff --git a/src/model/gameset.py b/src/model/gameset.py index 117402083..acdfc85ff 100644 --- a/src/model/gameset.py +++ b/src/model/gameset.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal from decorators import with_logger from model import game diff --git a/src/model/ircuser.py b/src/model/ircuser.py index cf6f746ad..e18ef4289 100644 --- a/src/model/ircuser.py +++ b/src/model/ircuser.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal from model.modelitem import ModelItem from model.transaction import transactional diff --git a/src/model/modelitem.py b/src/model/modelitem.py index 5feedebb2..d15eec6bd 100644 --- a/src/model/modelitem.py +++ b/src/model/modelitem.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal from model.qobjectmapping import QObject from model.transaction import transactional diff --git a/src/model/modelitemset.py b/src/model/modelitemset.py index b6c646496..3aa6fef21 100644 --- a/src/model/modelitemset.py +++ b/src/model/modelitemset.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal from model.qobjectmapping import QObjectMapping from model.transaction import transactional diff --git a/src/model/player.py b/src/model/player.py index a10af9d09..407edc29d 100644 --- a/src/model/player.py +++ b/src/model/player.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import pyqtSignal +from PyQt6.QtCore import pyqtSignal from model.modelitem import ModelItem from model.rating import RatingType diff --git a/src/model/qobjectmapping.py b/src/model/qobjectmapping.py index 14c00d65c..7e15a6141 100644 --- a/src/model/qobjectmapping.py +++ b/src/model/qobjectmapping.py @@ -1,6 +1,8 @@ -from collections.abc import ItemsView, KeysView, ValuesView +from collections.abc import ItemsView +from collections.abc import KeysView +from collections.abc import ValuesView -from PyQt5.QtCore import QObject +from PyQt6.QtCore import QObject class QObjectMapping(QObject): diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py index 2ee63eef6..ca15cc249 100644 --- a/src/news/_newswidget.py +++ b/src/news/_newswidget.py @@ -1,13 +1,15 @@ import logging import webbrowser -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util from config import Settings from util.qt import ExternalLinkPage -from .newsitem import NewsItem, NewsItemDelegate +from .newsitem import NewsItem +from .newsitem import NewsItemDelegate from .newsmanager import NewsManager logger = logging.getLogger(__name__) @@ -24,7 +26,7 @@ def __init__(self, parent=None): super(Hider, self).__init__(parent) def eventFilter(self, obj, ev): - return ev.type() == QtCore.QEvent.Paint + return ev.type() == QtCore.QEvent.Type.Paint def hide(self, widget): widget.installEventFilter(self) diff --git a/src/news/newsitem.py b/src/news/newsitem.py index 03b24067c..b3c27e240 100644 --- a/src/news/newsitem.py +++ b/src/news/newsitem.py @@ -1,6 +1,8 @@ import logging -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util @@ -8,12 +10,12 @@ 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) @@ -31,7 +33,7 @@ def paint(self, painter, option, index, *args, **kwargs): option.icon = QtGui.QIcon() option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Shadow (100x100 shifted 8 right and 8 down) diff --git a/src/news/newsmanager.py b/src/news/newsmanager.py index 70e44c408..b2707933d 100644 --- a/src/news/newsmanager.py +++ b/src/news/newsmanager.py @@ -1,7 +1,8 @@ import logging -from PyQt5 import QtCore -from PyQt5.QtCore import QObject, Qt +from PyQt6 import QtCore +from PyQt6.QtCore import QObject +from PyQt6.QtCore import Qt import client @@ -93,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 @@ -101,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 diff --git a/src/news/wpapi.py b/src/news/wpapi.py index b05c3aa2a..15a6ad780 100644 --- a/src/news/wpapi.py +++ b/src/news/wpapi.py @@ -1,12 +1,10 @@ import json import logging -from PyQt5 import QtCore -from PyQt5.QtNetwork import ( - QNetworkAccessManager, - QNetworkReply, - QNetworkRequest, -) +from PyQt6 import QtCore +from PyQt6.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest from config import Settings @@ -70,5 +68,4 @@ def download(self, page=1, perpage=10): ), ) request = QNetworkRequest(url) - request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) self.nam.get(request) diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py index 718ff90f6..f5a4b74db 100644 --- a/src/notifications/__init__.py +++ b/src/notifications/__init__.py @@ -2,13 +2,14 @@ The Notification Systems reacts on events and displays a popup. Each event_type has a NsHook to customize it. """ -from PyQt5 import QtCore +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, NsSettingsDialog +from notifications.ns_settings import IngameNotification +from notifications.ns_settings import NsSettingsDialog class Notifications: diff --git a/src/notifications/hook_newgame.py b/src/notifications/hook_newgame.py index 8452f10eb..2abbba7b9 100644 --- a/src/notifications/hook_newgame.py +++ b/src/notifications/hook_newgame.py @@ -1,7 +1,7 @@ """ Settings for notifications: if a new game is hosted. """ -from PyQt5 import QtCore +from PyQt6 import QtCore import config import notifications as ns @@ -31,7 +31,7 @@ def __init__(self, parent, eventType): # remove help button self.setWindowFlags( - self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint), + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), ) self.loadSettings() @@ -40,9 +40,9 @@ def loadSettings(self): self.mode = Settings.get(self._settings_key + '/mode', 'friends') if self.mode == 'friends': - self.checkBoxFriends.setCheckState(QtCore.Qt.Checked) + self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Checked) else: - self.checkBoxFriends.setCheckState(QtCore.Qt.Unchecked) + self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Unchecked) self.parent.mode = self.mode def saveSettings(self): @@ -51,7 +51,7 @@ def saveSettings(self): @QtCore.pyqtSlot() def on_btnSave_clicked(self): - if self.checkBoxFriends.checkState() == QtCore.Qt.Checked: + if self.checkBoxFriends.checkState() == QtCore.Qt.CheckState.Checked: self.mode = 'friends' else: self.mode = 'all' diff --git a/src/notifications/hook_partyinvite.py b/src/notifications/hook_partyinvite.py index e0d115261..a2c0c5fb8 100644 --- a/src/notifications/hook_partyinvite.py +++ b/src/notifications/hook_partyinvite.py @@ -1,7 +1,7 @@ """ Settings for notifications: if a player comes online """ -from PyQt5 import QtCore +from PyQt6 import QtCore import notifications as ns import util @@ -32,7 +32,7 @@ def __init__(self, parent, eventType): # remove help button self.setWindowFlags( - self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint), + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), ) self.loadSettings() diff --git a/src/notifications/hook_useronline.py b/src/notifications/hook_useronline.py index 41eddea6a..d949bb4c2 100644 --- a/src/notifications/hook_useronline.py +++ b/src/notifications/hook_useronline.py @@ -1,7 +1,7 @@ """ Settings for notifications: if a player comes online """ -from PyQt5 import QtCore +from PyQt6 import QtCore import notifications as ns import util @@ -32,7 +32,7 @@ def __init__(self, parent, eventType): # remove help button self.setWindowFlags( - self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint), + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), ) self.loadSettings() diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py index b25bcae9b..8aebba951 100644 --- a/src/notifications/ns_dialog.py +++ b/src/notifications/ns_dialog.py @@ -3,7 +3,8 @@ """ import time -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util @@ -29,7 +30,7 @@ def __init__(self, client, settings, *args, **kwargs): 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 @@ -91,11 +92,11 @@ 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() + screen_size = QtWidgets.QApplication.primaryScreen().geometry() dialog_size = self.geometry() # self.client.notificationSystem.settings.popup_position position = self.settings.popup_position @@ -103,13 +104,13 @@ def updatePosition(self): 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(), + screen_size.width() - dialog_size.width(), + screen_size.height() - dialog_size.height(), ) @QtCore.pyqtSlot() diff --git a/src/notifications/ns_hook.py b/src/notifications/ns_hook.py index 3bca343f4..00cb15a57 100644 --- a/src/notifications/ns_hook.py +++ b/src/notifications/ns_hook.py @@ -7,7 +7,7 @@ self.button.clicked.connect(self.dialog.show) """ -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets from config import Settings diff --git a/src/notifications/ns_settings.py b/src/notifications/ns_settings.py index d0683cd22..8b7b976ec 100644 --- a/src/notifications/ns_settings.py +++ b/src/notifications/ns_settings.py @@ -4,7 +4,8 @@ """ from enum import Enum -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import notifications as ns import util @@ -54,7 +55,7 @@ def __init__(self, client): # remove help button self.setWindowFlags( - self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint), + self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint), ) # init hooks @@ -68,7 +69,7 @@ def __init__(self, client): self.tableView.setModel(model) # stretch first column self.tableView.horizontalHeader().setSectionResizeMode( - 0, QtWidgets.QHeaderView.Stretch, + 0, QtWidgets.QHeaderView.ResizeMode.Stretch, ) for row in range(0, model.rowCount(None)): @@ -165,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): @@ -179,14 +180,14 @@ 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(), @@ -197,7 +198,7 @@ def data(self, index, role=QtCore.Qt.EditRole): ) return None - if role != QtCore.Qt.DisplayRole: + if role != QtCore.Qt.ItemDataRole.DisplayRole: return None if index.column() == 0: @@ -205,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) @@ -220,8 +221,8 @@ def setData(self, index, value, role=QtCore.Qt.EditRole): def headerData(self, col, orientation, role): if ( - orientation == QtCore.Qt.Horizontal - and role == QtCore.Qt.DisplayRole + orientation == QtCore.Qt.Orientation.Horizontal + and role == QtCore.Qt.ItemDataRole.DisplayRole ): return self.headerdata[col] return None diff --git a/src/power/actions.py b/src/power/actions.py index 706c67e39..3f4004cb5 100644 --- a/src/power/actions.py +++ b/src/power/actions.py @@ -1,8 +1,8 @@ import logging from enum import Enum -from PyQt5.QtCore import QUrl -from PyQt5.QtGui import QDesktopServices +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices logger = logging.getLogger(__name__) diff --git a/src/power/view.py b/src/power/view.py index d637d83c8..442e7f895 100644 --- a/src/power/view.py +++ b/src/power/view.py @@ -1,8 +1,9 @@ -from PyQt5.QtCore import QObject -from PyQt5.QtWidgets import QMessageBox +from PyQt6.QtCore import QObject +from PyQt6.QtWidgets import QMessageBox from power.actions import BanPeriod -from util.select_player_dialog import PlayerCompleter, SelectPlayerDialog +from util.select_player_dialog import PlayerCompleter +from util.select_player_dialog import SelectPlayerDialog class CloseGameDialog(SelectPlayerDialog): diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index e09788a51..2d2be09ae 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -4,12 +4,12 @@ import time import jsonschema -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtNetwork import ( - QNetworkAccessManager, - QNetworkReply, - QNetworkRequest, -) +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 @@ -19,9 +19,11 @@ from downloadManager import DownloadRequest from fa.replay import replay from model.game import GameState -from replays.replayitem import ReplayItem, ReplayItemDelegate +from replays.replayitem import ReplayItem +from replays.replayitem import ReplayItemDelegate from replays.replayToolbox import ReplayToolboxHandler -from util.gameurl import GameUrl, GameUrlType +from util.gameurl import GameUrl +from util.gameurl import GameUrlType logger = logging.getLogger(__name__) @@ -54,7 +56,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) @@ -110,7 +112,7 @@ def _set_misc_formatting(self, game): self.setText(1, game.title + " - [host: " + game.host + "]") 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 @@ -190,13 +192,13 @@ def __init__(self, liveTree, client, gameset): self.liveTree.itemDoubleClicked.connect(self.liveTreeDoubleClicked) self.liveTree.itemPressed.connect(self.liveTreePressed) self.liveTree.header().setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeToContents, + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, ) self.liveTree.header().setSectionResizeMode( - 1, QtWidgets.QHeaderView.Stretch, + 1, QtWidgets.QHeaderView.ResizeMode.Stretch, ) self.liveTree.header().setSectionResizeMode( - 2, QtWidgets.QHeaderView.ResizeToContents, + 2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, ) self.client = client @@ -207,7 +209,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: @@ -426,7 +428,7 @@ def _setup_complete_appearance(self): self.setToolTip(2, ", ".join(playerlist)) self.setText(3, data['featured_mod']) - self.setTextAlignment(3, QtCore.Qt.AlignCenter) + self.setTextAlignment(3, QtCore.Qt.AlignmentFlag.AlignCenter) def replay_bucket(self): if self._metadata is None: @@ -515,16 +517,16 @@ def __init__(self, myTree): self.myTree.itemDoubleClicked.connect(self.myTreeDoubleClicked) self.myTree.itemPressed.connect(self.myTreePressed) self.myTree.header().setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeToContents, + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, ) self.myTree.header().setSectionResizeMode( - 1, QtWidgets.QHeaderView.ResizeToContents, + 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, ) self.myTree.header().setSectionResizeMode( - 2, QtWidgets.QHeaderView.Stretch, + 2, QtWidgets.QHeaderView.ResizeMode.Stretch, ) self.myTree.header().setSectionResizeMode( - 3, QtWidgets.QHeaderView.ResizeToContents, + 3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents, ) self.myTree.modification_time = 0 @@ -534,7 +536,7 @@ def __init__(self, myTree): ) def myTreePressed(self, item): - if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton: + if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton: return if item.isDisabled(): @@ -792,10 +794,10 @@ def searchVault( if not self.showLatest: timePeriod = [] timePeriod.append( - w.dateStart.dateTime().toUTC().toString(QtCore.Qt.ISODate), + w.dateStart.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate), ) timePeriod.append( - w.dateEnd.dateTime().toUTC().toString(QtCore.Qt.ISODate), + w.dateEnd.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate), ) filters = self.prepareFilters( @@ -911,7 +913,7 @@ def prepareFilters( startTime = ( QtCore.QDateTime.currentDateTimeUtc() .addMonths(-months) - .toString(QtCore.Qt.ISODate) + .toString(QtCore.Qt.DateFormat.ISODate) ) filters.append('startTime=ge="{}"'.format(startTime)) @@ -927,7 +929,7 @@ def reloadView(self): self.searchVault(reset=True) def onlineTreeClicked(self, item): - if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.RightButton: + if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.MouseButton.RightButton: if isinstance(item.parent, ReplaysWidget): # FIXME - hack item.pressed(item) else: @@ -989,17 +991,11 @@ def onlineTreeDoubleClicked(self, item): QtWidgets.QMessageBox.No, ) == QtWidgets.QMessageBox.Yes: req = QNetworkRequest(QtCore.QUrl(item.url)) - req.setAttribute( - QNetworkRequest.FollowRedirectsAttribute, True, - ) self.replayDownload.get(req) else: # start replay if hasattr(item, "url"): req = QNetworkRequest(QtCore.QUrl(item.url)) - req.setAttribute( - QNetworkRequest.FollowRedirectsAttribute, True, - ) self.replayDownload.get(req) def _startReplay(self, name): @@ -1048,7 +1044,7 @@ def resetRefreshPressed(self): self.searchVault(reset=True) def onDownloadFinished(self, reply): - if reply.error() != QNetworkReply.NoError: + if reply.error() != QNetworkReply.NetworkError.NoError: QtWidgets.QMessageBox.warning( self._w, "Network Error", reply.errorString(), ) @@ -1057,8 +1053,8 @@ def onDownloadFinished(self, reply): os.path.join(util.CACHE_DIR, "temp.fafreplay"), ) faf_replay.open( - QtCore.QIODevice.WriteOnly - | QtCore.QIODevice.Truncate, + QtCore.QIODevice.OpenModeFlag.WriteOnly + | QtCore.QIODevice.OpenModeFlag.Truncate, ) faf_replay.write(reply.readAll()) faf_replay.flush() diff --git a/src/replays/replayToolbox.py b/src/replays/replayToolbox.py index 7740ee308..5eca2b94c 100644 --- a/src/replays/replayToolbox.py +++ b/src/replays/replayToolbox.py @@ -1,7 +1,9 @@ import logging import os -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets from config import Settings from downloadManager import DownloadRequest @@ -304,7 +306,7 @@ def prepareFilters(self): if filterName == "Start time": startDate = filterBox.dateEdit.dateTime().toUTC().toString( - QtCore.Qt.ISODate, + QtCore.Qt.DateFormat.ISODate, ) if opName == ">": finalFilters.append( diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 48b0f8a4c..f3d614ba6 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -1,8 +1,11 @@ import os import time -from datetime import datetime, timezone +from datetime import datetime +from datetime import timezone -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util from config import Settings @@ -32,7 +35,7 @@ def paint(self, painter, option, index, *args, **kwargs): option.icon = QtGui.QIcon() option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Shadow @@ -43,7 +46,7 @@ def paint(self, painter, option, index, *args, **kwargs): # Icon icon.paint( painter, option.rect.adjusted(3, -2, 0, 0), - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, ) # Frame around the icon @@ -52,7 +55,7 @@ def paint(self, painter, option, index, *args, **kwargs): # FIXME: This needs to come from theme. # pen.setBrush(QtGui.QColor("#303030")) - # pen.setCapStyle(QtCore.Qt.RoundCap) + # 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()) @@ -71,7 +74,7 @@ def paint(self, painter, option, index, *args, **kwargs): 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) @@ -518,9 +521,9 @@ 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) diff --git a/src/secondaryServer/secondaryserver.py b/src/secondaryServer/secondaryserver.py index 78f4c7a2f..d51557a2f 100644 --- a/src/secondaryServer/secondaryserver.py +++ b/src/secondaryServer/secondaryserver.py @@ -1,7 +1,8 @@ import json import logging -from PyQt5 import QtCore, QtNetwork +from PyQt6 import QtCore +from PyQt6 import QtNetwork from config import Settings @@ -61,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 @@ -80,7 +81,7 @@ def send(self, command, *args, **kwargs): self.logger.info("Pending requests: {}".format(len(self._requests))) if not ( self.serverSocket.state() - == QtNetwork.QAbstractSocket.ConnectedState + == QtNetwork.QAbstractSocket.SocketState.ConnectedState ): self.logger.info( "Connecting to {} {}:{}".format( @@ -123,7 +124,7 @@ 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) diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py index 91b8b3e80..570b0de04 100644 --- a/src/stats/_statswidget.py +++ b/src/stats/_statswidget.py @@ -1,7 +1,10 @@ import logging import time -from PyQt5 import QtCore, QtWebEngineWidgets, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWebEngineCore +from PyQt6 import QtWebEngineWidgets +from PyQt6 import QtWidgets import util from api.stats_api import LeaderboardApiConnector @@ -341,7 +344,7 @@ def busy_entered(self): ) -class WebEnginePage(QtWebEngineWidgets.QWebEnginePage): +class WebEnginePage(QtWebEngineCore.QWebEnginePage): def acceptNavigationRequest(self, url, type, isMainFrame): if ( url.url().startswith("https://faforever.com/competitive/") diff --git a/src/stats/itemviews/leaderboardheaderview.py b/src/stats/itemviews/leaderboardheaderview.py index 0c747002b..1c8249b22 100644 --- a/src/stats/itemviews/leaderboardheaderview.py +++ b/src/stats/itemviews/leaderboardheaderview.py @@ -1,11 +1,16 @@ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtWidgets +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import QRect +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QHoverEvent +from PyQt6.QtGui import QPainter class VerticalHeaderView(QtWidgets.QHeaderView): - def __init__(self, *args, **kwargs): - super().__init__(QtCore.Qt.Vertical, *args, **kwargs) + def __init__(self, *args, **kwargs) -> None: + super().__init__(Qt.Orientation.Vertical, *args, **kwargs) self.setHighlightSections(True) - self.setSectionResizeMode(self.Fixed) + self.setSectionResizeMode(self.ResizeMode.Fixed) self.setVisible(True) self.setSectionsClickable(True) self.setAlternatingRowColors(True) @@ -13,33 +18,28 @@ def __init__(self, *args, **kwargs): self.hover = -1 - def paintSection(self, painter, rect, index): + def paintSection(self, painter: QPainter, rect: QRect, index: QModelIndex) -> None: opt = QtWidgets.QStyleOptionHeader() self.initStyleOption(opt) opt.rect = rect opt.section = index - opt.text = str( - self.model().headerData( - index, self.orientation(), QtCore.Qt.DisplayRole, - ), - ) - opt.textAlignment = QtCore.Qt.AlignCenter - state = QtWidgets.QStyle.State_None + data = self.model().headerData(index, self.orientation(), Qt.ItemDataRole.DisplayRole) + opt.text = str(data) + + opt.textAlignment = Qt.AlignmentFlag.AlignCenter + + state = QtWidgets.QStyle.StateFlag.State_None if self.highlightSections(): - if self.selectionModel().rowIntersectsSelection( - index, QtCore.QModelIndex(), - ): - state |= QtWidgets.QStyle.State_On + if self.selectionModel().rowIntersectsSelection(index, QModelIndex()): + state |= QtWidgets.QStyle.StateFlag.State_On elif index == self.hover: - state |= QtWidgets.QStyle.State_MouseOver + state |= QtWidgets.QStyle.StateFlag.State_MouseOver opt.state |= state - self.style().drawControl( - QtWidgets.QStyle.CE_Header, opt, painter, self, - ) + self.style().drawControl(QtWidgets.QStyle.ControlElement.CE_Header, opt, painter, self) def mouseMoveEvent(self, event): QtWidgets.QHeaderView.mouseMoveEvent(self, event) @@ -56,8 +56,8 @@ def mousePressEvent(self, event): self.parent().updateHoverRow(event) self.updateHoverSection(event) - def updateHoverSection(self, event): - index = self.logicalIndexAt(event.pos()) + def updateHoverSection(self, event: QHoverEvent) -> None: + index = self.logicalIndexAt(event.position().toPoint()) oldHover = self.hover self.hover = index diff --git a/src/stats/itemviews/leaderboarditemdelegate.py b/src/stats/itemviews/leaderboarditemdelegate.py index 952e6209a..dc62c89e7 100644 --- a/src/stats/itemviews/leaderboarditemdelegate.py +++ b/src/stats/itemviews/leaderboarditemdelegate.py @@ -1,41 +1,49 @@ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets +from PyQt6.QtGui import QPainter class LeaderboardItemDelegate(QtWidgets.QStyledItemDelegate): - def paint(self, painter, option, index): + def paint( + self, + painter: QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: opt = QtWidgets.QStyleOptionViewItem(option) - opt.state &= ~QtWidgets.QStyle.State_HasFocus - opt.state &= ~QtWidgets.QStyle.State_MouseOver + opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus + opt.state &= ~QtWidgets.QStyle.StateFlag.State_MouseOver view = opt.styleObject behavior = view.selectionBehavior() hoverIndex = view.hoverIndex() if ( - not (option.state & QtWidgets.QStyle.State_Selected) - and behavior != QtWidgets.QTableView.SelectItems + not (option.state & QtWidgets.QStyle.StateFlag.State_Selected) + and behavior is not QtWidgets.QTableView.SelectionBehavior.SelectItems ): if ( - behavior == QtWidgets.QTableView.SelectRows + behavior is QtWidgets.QTableView.SelectionBehavior.SelectRows and hoverIndex.row() == index.row() ): - opt.state |= QtWidgets.QStyle.State_MouseOver + opt.state |= QtWidgets.QStyle.StateFlag.State_MouseOver self.initStyleOption(opt, index) painter.save() text = opt.text opt.text = "" - opt.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, opt, painter, opt.widget, - ) - if opt.state & QtWidgets.QStyle.State_Selected: - painter.setPen(QtCore.Qt.white) + control_element = QtWidgets.QStyle.ControlElement.CE_ItemViewItem + opt.widget.style().drawControl(control_element, opt, painter, opt.widget) + if opt.state & QtWidgets.QStyle.StateFlag.State_Selected: + painter.setPen(QtCore.Qt.GlobalColor.white) if index.column() == 0: rect = QtCore.QRect(opt.rect) - rect.setLeft(opt.rect.left() + opt.rect.width() // 2.125) - painter.drawText( - rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, text, + rect.setLeft(int(opt.rect.left() + opt.rect.width() // 2.125)) + alignment_flags = ( + QtCore.Qt.AlignmentFlag.AlignLeft + | QtCore.Qt.AlignmentFlag.AlignVCenter ) + painter.drawText(rect, alignment_flags, text) else: - painter.drawText(opt.rect, QtCore.Qt.AlignCenter, text) + painter.drawText(opt.rect, QtCore.Qt.AlignmentFlag.AlignCenter, text) painter.restore() diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 3b502aaa4..50c14287f 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -1,6 +1,7 @@ from enum import Enum -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets +from PyQt6.QtGui import QAction class LeaderboardTableMenuItems(Enum): @@ -52,11 +53,11 @@ def friendActions(self, name, uid, is_me): yield LeaderboardTableMenuItems.ADD_FRIEND yield LeaderboardTableMenuItems.ADD_FOE - def getMenu(self, name, uid): + def getMenu(self, name: str, uid: int) -> QtWidgets.QMenu: menu = QtWidgets.QMenu(self.parent) def addEntry(item): - action = QtWidgets.QAction(item.value, menu) + action = QAction(item.value, menu) action.triggered.connect(self.handler(name, uid, item)) menu.addAction(action) diff --git a/src/stats/itemviews/leaderboardtableview.py b/src/stats/itemviews/leaderboardtableview.py index 25772ac28..9157c0f47 100644 --- a/src/stats/itemviews/leaderboardtableview.py +++ b/src/stats/itemviews/leaderboardtableview.py @@ -1,15 +1,17 @@ -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets from .leaderboardheaderview import VerticalHeaderView from .leaderboardtablemenu import LeaderboardTableMenu class LeaderboardTableView(QtWidgets.QTableView): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.setMouseTracking(True) - self.setSelectionBehavior(self.SelectRows) - self.setSelectionMode(self.SingleSelection) + self.setSelectionBehavior(self.SelectionBehavior.SelectRows) + self.setSelectionMode(self.SelectionMode.SingleSelection) self.setAlternatingRowColors(True) self.setSortingEnabled(True) @@ -19,13 +21,13 @@ def __init__(self, *args, **kwargs): def hoverIndex(self): return QtCore.QModelIndex(self.model().index(self.mHoverRow, 0)) - def updateHoverRow(self, event): - index = self.indexAt(event.pos()) + def updateHoverRow(self, event: QtGui.QHoverEvent) -> None: + index = self.indexAt(event.position().toPoint()) oldHoverRow = self.mHoverRow self.mHoverRow = index.row() if ( - self.selectionBehavior() == self.SelectRows + self.selectionBehavior() is self.SelectionBehavior.SelectRows and oldHoverRow != self.mHoverRow ): if oldHoverRow != -1: @@ -45,8 +47,8 @@ def wheelEvent(self, event): self.updateHoverRow(event) self.verticalHeader().updateHoverSection(event) - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.RightButton: + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + if event.button() is QtCore.Qt.MouseButton.RightButton: row = self.indexAt(event.pos()).row() if row != -1: name_index = self.model().index(row, 0) diff --git a/src/stats/leaderboard_widget.py b/src/stats/leaderboard_widget.py index f2e203ce7..a8a8d1636 100644 --- a/src/stats/leaderboard_widget.py +++ b/src/stats/leaderboard_widget.py @@ -1,4 +1,9 @@ -from PyQt5 import QtCore, QtWidgets +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 @@ -9,14 +14,24 @@ 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.ISODate +DATE_FORMAT = QtCore.Qt.DateFormat.ISODate class LeaderboardWidget(BaseClass, FormClass): - def __init__(self, client, parent, leaderboardName, *args, **kwargs): + def __init__( + self, + client: ClientWindow, + parent: QtWidgets.QWidget, + leaderboardName: str, + *args, + **kwargs, + ) -> None: super(BaseClass, self).__init__() self.setupUi(self) @@ -115,7 +130,7 @@ def __init__(self, client, parent, leaderboardName, *args, **kwargs): checkbox.stateChanged.connect(self.setShownColumns) self.tableView.horizontalHeader().setSectionResizeMode( - QtWidgets.QHeaderView.Stretch, + QtWidgets.QHeaderView.ResizeMode.Stretch, ) self.tableView.horizontalHeader().setFixedHeight(30) self.tableView.horizontalHeader().setHighlightSections(False) @@ -177,7 +192,7 @@ def createLeaderboard(self, data): completer = QtWidgets.QCompleter( sorted(self.model.logins, key=lambda login: login.lower()), ) - completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) completer.popup().setStyleSheet( "background: rgb(32, 32, 37); color: orange;", ) @@ -223,7 +238,7 @@ def createPlayerCompleter(self, message): completer = QtWidgets.QCompleter( sorted(logins, key=lambda login: login.lower()), ) - completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) completer.popup().setStyleSheet( "background: rgb(32, 32, 37); color: orange;", ) diff --git a/src/stats/leaderboardlineedit.py b/src/stats/leaderboardlineedit.py index 94cf57112..bc0d2b63e 100644 --- a/src/stats/leaderboardlineedit.py +++ b/src/stats/leaderboardlineedit.py @@ -1,4 +1,5 @@ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets # TODO: probably create a common ancestor of ChatLineEdit and this @@ -14,10 +15,10 @@ def set_completion_list(self, list_): self.completionList = list_ def event(self, event): - if event.type() == QtCore.QEvent.KeyPress: + 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_Tab: + if event.key() == QtCore.Qt.Key.Key_Tab: self.try_completion() return True else: diff --git a/src/stats/models/leaderboardfiltermodel.py b/src/stats/models/leaderboardfiltermodel.py index a4adda7f6..487751b66 100644 --- a/src/stats/models/leaderboardfiltermodel.py +++ b/src/stats/models/leaderboardfiltermodel.py @@ -1,6 +1,6 @@ import re -from PyQt5 import QtCore +from PyQt6 import QtCore class LeaderboardFilterModel(QtCore.QSortFilterProxyModel): diff --git a/src/stats/models/leaderboardtablemodel.py b/src/stats/models/leaderboardtablemodel.py index bd52be38f..18b04dd69 100644 --- a/src/stats/models/leaderboardtablemodel.py +++ b/src/stats/models/leaderboardtablemodel.py @@ -1,4 +1,7 @@ -from PyQt5.QtCore import QAbstractTableModel, QDateTime, QModelIndex, Qt +from PyQt6.QtCore import QAbstractTableModel +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt class LeaderboardTableModel(QAbstractTableModel): @@ -22,8 +25,8 @@ def columnCount(self, parent=QModelIndex()): return self.column_count def headerData(self, section, orientation, role): - if role == Qt.DisplayRole: - if orientation == Qt.Horizontal: + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: return ( "Name", "Rating", "Mean", "Deviation", "Games", "Won", "Win rate", "Updated", "Player Id", @@ -35,14 +38,14 @@ def headerData(self, section, orientation, role): + section + 1, ) - elif role == Qt.TextAlignmentRole: - return Qt.AlignCenter + elif role == Qt.ItemDataRole.TextAlignmentRole: + return Qt.AlignmentFlag.AlignCenter - def data(self, index, role=Qt.DisplayRole): + def data(self, index, role=Qt.ItemDataRole.DisplayRole): column = index.column() row = index.row() - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: if column == 0: return "{}".format(self.values[row]["player"]["login"]) elif column == 1: @@ -66,7 +69,7 @@ def data(self, index, role=Qt.DisplayRole): ) elif column == 7: dateUTC = QDateTime.fromString( - self.values[row]["updateTime"], Qt.ISODate, + self.values[row]["updateTime"], Qt.DateFormat.ISODate, ) dateLocal = dateUTC.toLocalTime().toString("yyyy-MM-dd") return "{}".format(dateLocal) diff --git a/src/tourneys/_tournamentswidget.py b/src/tourneys/_tournamentswidget.py index e2ced6ebf..1f653d1b6 100644 --- a/src/tourneys/_tournamentswidget.py +++ b/src/tourneys/_tournamentswidget.py @@ -1,9 +1,11 @@ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import secondaryServer import util -from tourneys.tourneyitem import TourneyItem, TourneyItemDelegate +from tourneys.tourneyitem import TourneyItem +from tourneys.tourneyitem import TourneyItemDelegate FormClass, BaseClass = util.THEME.loadUiType("tournaments/tournaments.ui") diff --git a/src/tourneys/tourneyitem.py b/src/tourneys/tourneyitem.py index 6f8a182a8..497823740 100644 --- a/src/tourneys/tourneyitem.py +++ b/src/tourneys/tourneyitem.py @@ -1,4 +1,8 @@ -from PyQt5 import QtCore, QtGui, QtWebEngineWidgets, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWebEngineCore +from PyQt6 import QtWebEngineWidgets +from PyQt6 import QtWidgets import util @@ -22,7 +26,7 @@ def paint(self, painter, option, index, *args, **kwargs): option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Description @@ -43,9 +47,9 @@ def sizeHint(self, option, index, *args, **kwargs): ) -class QWebPageChrome(QtWebEngineWidgets.QWebEnginePage): +class QWebPageChrome(QtWebEngineCore.QWebEnginePage): def __init__(self, *args, **kwargs): - QtWebEngineWidgets.QWebEnginePage.__init__(self, *args, **kwargs) + QtWebEngineCore.QWebEnginePage.__init__(self, *args, **kwargs) def userAgentForUrl(self, url): return ( @@ -131,9 +135,9 @@ def display(self): return self.viewtext def data(self, role): - if role == QtCore.Qt.DisplayRole: + if role == QtCore.Qt.ItemDataRole.DisplayRole: return self.display() - elif role == QtCore.Qt.UserRole: + 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 48f43b239..c38d423f4 100644 --- a/src/turn_client.py +++ b/src/turn_client.py @@ -1,6 +1,7 @@ import signal -from PyQt5.QtCore import QCoreApplication, QTimer +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtCore import QTimer from .connectivity import QTurnSocket @@ -18,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/_tutorialswidget.py b/src/tutorials/_tutorialswidget.py index 166779875..f32f94b99 100644 --- a/src/tutorials/_tutorialswidget.py +++ b/src/tutorials/_tutorialswidget.py @@ -1,16 +1,16 @@ import logging import os -from PyQt5 import QtCore, QtWidgets -from PyQt5.QtNetwork import ( - QNetworkAccessManager, - QNetworkReply, - QNetworkRequest, -) +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 import util -from tutorials.tutorialitem import TutorialItem, TutorialItemDelegate +from tutorials.tutorialitem import TutorialItem +from tutorials.tutorialitem import TutorialItemDelegate logger = logging.getLogger(__name__) @@ -33,14 +33,14 @@ def __init__(self, client, *args, **kwargs): logger.info("Tutorials instantiated.") def finishReplay(self, reply): - if reply.error() != QNetworkReply.NoError: + 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() diff --git a/src/tutorials/tutorialitem.py b/src/tutorials/tutorialitem.py index d4b5c9516..853d76a80 100644 --- a/src/tutorials/tutorialitem.py +++ b/src/tutorials/tutorialitem.py @@ -1,5 +1,7 @@ -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util from config import Settings @@ -28,7 +30,7 @@ def paint(self, painter, option, index, *args, **kwargs): option.icon = QtGui.QIcon() option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Shadow @@ -40,7 +42,7 @@ def paint(self, painter, option, index, *args, **kwargs): # Icon icon.paint( painter, option.rect.adjusted(3, -2, 0, 0), - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, ) # Frame around the icon @@ -49,7 +51,7 @@ def paint(self, painter, option, index, *args, **kwargs): # FIXME: This needs to come from theme. pen.setBrush(QtGui.QColor("#303030")) - pen.setCapStyle(QtCore.Qt.RoundCap) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.drawRect( option.rect.left() + 3, option.rect.top() + 3, diff --git a/src/ui/status_logo.py b/src/ui/status_logo.py index 91c3e87dd..79ad0075d 100644 --- a/src/ui/status_logo.py +++ b/src/ui/status_logo.py @@ -1,5 +1,7 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QAction, QLabel, 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.clientstate import ClientState @@ -74,7 +76,7 @@ def contextMenuEvent(self, event): 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: diff --git a/src/unitdb/unitdbtab.py b/src/unitdb/unitdbtab.py index 6b5486a2b..d684a27cd 100644 --- a/src/unitdb/unitdbtab.py +++ b/src/unitdb/unitdbtab.py @@ -1,8 +1,11 @@ import logging -from PyQt5.QtCore import QObject, QUrl, pyqtSignal -from PyQt5.QtNetwork import QNetworkCookie -from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineProfile +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkCookie +from PyQt6.QtWebEngineCore import QWebEnginePage +from PyQt6.QtWebEngineCore import QWebEngineProfile import util from config import Settings diff --git a/src/updater/__init__.py b/src/updater/__init__.py index 535bc6245..372bfcc38 100644 --- a/src/updater/__init__.py +++ b/src/updater/__init__.py @@ -1,9 +1,14 @@ -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtWidgets import QDialog, QMessageBox +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 UpdateChecker, UpdateNotifier, UpdateSettings -from updater.widgets import UpdateDialog, UpdateSettingsDialog +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): @@ -48,7 +53,7 @@ def _handle_update(self, releases, mandatory): ) return self.dialog.setup(releases) - result = self.dialog.exec_() + result = self.dialog.exec() if result == QDialog.Rejected and mandatory: self.mandatory_update_aborted.emit() diff --git a/src/updater/base.py b/src/updater/base.py index 1bb0e6f04..51752a33f 100644 --- a/src/updater/base.py +++ b/src/updater/base.py @@ -1,8 +1,11 @@ import json from enum import Enum -from PyQt5.QtCore import QObject, QUrl, pyqtSignal -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest +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 @@ -188,7 +191,7 @@ def _req_done(self): self.finished.emit() def _process_response(self, rep): - if rep.error() != QNetworkReply.NoError: + if rep.error() != QNetworkReply.NetworkError.NoError: return None release_data = bytes(self._rep.readAll()) try: diff --git a/src/updater/process.py b/src/updater/process.py index 6756770ad..a5f4d01f8 100644 --- a/src/updater/process.py +++ b/src/updater/process.py @@ -2,8 +2,11 @@ import subprocess import tempfile -from PyQt5.QtCore import QObject, QUrl, pyqtSignal -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest +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 @@ -47,7 +50,7 @@ def _prepare_download(self, url): 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.errorOccurred.connect(self.error) self._rep.readyRead.connect(self._buffer) self._rep.sslErrors.connect(self.ssl_error) @@ -66,7 +69,7 @@ def _on_finished(self): self._logger.debug('_on_finished') assert self._tmp assert self._rep.atEnd() - if self._rep.error() != QNetworkReply.NoError: + if self._rep.error() != QNetworkReply.NetworkError.NoError: self._logger.error(self._rep.errorString()) return # FIXME - handle diff --git a/src/updater/widgets.py b/src/updater/widgets.py index 54cafe3ee..f4a1961fd 100644 --- a/src/updater/widgets.py +++ b/src/updater/widgets.py @@ -1,8 +1,9 @@ -from PyQt5.QtWidgets import QLayout +from PyQt6.QtWidgets import QLayout import util from decorators import with_logger -from updater.base import ReleaseType, UpdateChannel +from updater.base import ReleaseType +from updater.base import UpdateChannel from updater.process import ClientUpdater FormClass, BaseClass = util.THEME.loadUiType("client/update.ui") @@ -23,7 +24,7 @@ def __init__( self.btnAbort.clicked.connect(self.abort) self.btnSettings.clicked.connect(self.showSettings) self.cbReleases.currentIndexChanged.connect(self.indexChanged) - self.layout().setSizeConstraint(QLayout.SetFixedSize) + self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) @classmethod def build(cls, settings, parent_widget, current_version, **kwargs): diff --git a/src/util/__init__.py b/src/util/__init__.py index 5cd10fd67..139199884 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -8,17 +8,19 @@ import subprocess import sys -from PyQt5 import QtWidgets -from PyQt5.QtCore import QStandardPaths, QUrl -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtWidgets import QMessageBox +from PyQt6 import QtWidgets +from PyQt6.QtCore import QStandardPaths +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 # Stolen from Config because reasons from config import Settings +from config import _settings # Stolen from Config because reasons from mapGenerator import mapgenUtils -from util.theme import Theme, ThemeSet +from util.theme import Theme +from util.theme import ThemeSet if sys.platform == 'win32': import win32service @@ -126,7 +128,7 @@ def getPersonalDir(): else: dir_ = str( QStandardPaths.standardLocations( - QStandardPaths.DocumentsLocation, + QStandardPaths.StandardLocation.DocumentsLocation, )[0], ) try: diff --git a/src/util/crash.py b/src/util/crash.py index 9cfff45b5..ac8c73d76 100644 --- a/src/util/crash.py +++ b/src/util/crash.py @@ -2,14 +2,15 @@ import platform import traceback -from PyQt5.QtCore import QUrl -from PyQt5.QtGui import QDesktopServices +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices import config import util from config import Settings - -from . import APPDATA_DIR, PERSONAL_DIR, VERSION_STRING +from util import APPDATA_DIR +from util import PERSONAL_DIR +from util import VERSION_STRING CRASH_REPORT_USER = "pre-login" diff --git a/src/util/gameurl.py b/src/util/gameurl.py index 40208fc0e..a7ed29cd5 100644 --- a/src/util/gameurl.py +++ b/src/util/gameurl.py @@ -1,6 +1,7 @@ from enum import Enum -from PyQt5.QtCore import QUrl, QUrlQuery +from PyQt6.QtCore import QUrl +from PyQt6.QtCore import QUrlQuery class GameUrlType(Enum): diff --git a/src/util/qt.py b/src/util/qt.py index d503c7f9d..51ce981ec 100644 --- a/src/util/qt.py +++ b/src/util/qt.py @@ -1,7 +1,7 @@ import types -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtWebEngineWidgets import QWebEnginePage +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWebEngineCore import QWebEnginePage class ExternalLinkPage(QWebEnginePage): @@ -12,7 +12,7 @@ def __init__(self, *args, **kwargs): self.linkUnderCursor = "" def acceptNavigationRequest(self, url, navtype, isMainFrame): - if navtype == QWebEnginePage.NavigationTypeLinkClicked: + if navtype == QWebEnginePage.NavigationType.NavigationTypeLinkClicked: if url.toString() == self.linkUnderCursor: QDesktopServices.openUrl(url) return False diff --git a/src/util/qt_list_model.py b/src/util/qt_list_model.py index a32ab29e0..e12742441 100644 --- a/src/util/qt_list_model.py +++ b/src/util/qt_list_model.py @@ -1,4 +1,6 @@ -from PyQt5.QtCore import QAbstractListModel, QModelIndex, Qt +from PyQt6.QtCore import QAbstractListModel +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt class QtListModel(QAbstractListModel): @@ -16,7 +18,7 @@ def rowCount(self, parent): def data(self, index, role): if not index.isValid() or index.row() >= len(self._itemlist): return None - if role != Qt.DisplayRole: + if role != Qt.ItemDataRole.DisplayRole: return None return self._itemlist[index.row()] diff --git a/src/util/select_player_dialog.py b/src/util/select_player_dialog.py index aea34a463..66b528c0b 100644 --- a/src/util/select_player_dialog.py +++ b/src/util/select_player_dialog.py @@ -1,4 +1,6 @@ -from PyQt5.QtWidgets import QCompleter, QInputDialog, QLineEdit +from PyQt6.QtWidgets import QCompleter +from PyQt6.QtWidgets import QInputDialog +from PyQt6.QtWidgets import QLineEdit class SelectPlayerDialog: diff --git a/src/util/theme.py b/src/util/theme.py index 00705d097..5c53dc8cb 100644 --- a/src/util/theme.py +++ b/src/util/theme.py @@ -1,7 +1,11 @@ import logging import os -from PyQt5 import QtCore, QtGui, QtMultimedia, QtWidgets, uic +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtMultimedia +from PyQt6 import QtWidgets +from PyQt6 import uic from semantic_version import Version logger = logging.getLogger(__name__) @@ -272,7 +276,7 @@ def theme_changed(): QtWidgets.QMessageBox.NoRole, ) b_no = box.addButton("Abort", QtWidgets.QMessageBox.NoRole) - box.exec_() + box.exec() result = box.clickedButton() if result == b_always: @@ -382,7 +386,7 @@ 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( @@ -390,7 +394,7 @@ def icon(self, filename, themed=True, pix=False): ) if pixDisabled is not None: icon.addPixmap( - pixDisabled, QtGui.QIcon.Disabled, QtGui.QIcon.On, + pixDisabled, QtGui.QIcon.Mode.Disabled, QtGui.QIcon.State.On, ) pixActive = self.pixmap( @@ -398,7 +402,7 @@ def icon(self, filename, themed=True, pix=False): ) if pixActive is not None: icon.addPixmap( - pixActive, QtGui.QIcon.Active, QtGui.QIcon.On, + pixActive, QtGui.QIcon.Mode.Active, QtGui.QIcon.State.On, ) pixSelected = self.pixmap( @@ -406,6 +410,6 @@ def icon(self, filename, themed=True, pix=False): ) if pixSelected is not None: icon.addPixmap( - pixSelected, QtGui.QIcon.Selected, QtGui.QIcon.On, + pixSelected, QtGui.QIcon.Mode.Selected, QtGui.QIcon.State.On, ) return icon diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py index 13ace928a..142a7afad 100644 --- a/src/vaults/dialogs.py +++ b/src/vaults/dialogs.py @@ -3,7 +3,9 @@ import os import zipfile -from PyQt5 import QtCore, QtNetwork, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtNetwork +from PyQt6 import QtWidgets from downloadManager import FileDownload @@ -36,14 +38,14 @@ def __init__(self, dler, title, label, silent=False): else: self._progress.setCancelButton(None) self._progress.setWindowFlags( - QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint, + 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.AlignCenter) + progressBar.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self._progress.setBar(progressBar) self.timer = QtCore.QTimer() diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py index 6c536cf76..db8dd12a6 100644 --- a/src/vaults/mapvault/mapvault.py +++ b/src/vaults/mapvault/mapvault.py @@ -6,10 +6,12 @@ import urllib.request from stat import S_IWRITE -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util -from api.vaults_api import MapApiConnector, MapPoolApiConnector +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 MapItem @@ -84,7 +86,7 @@ def showChanged(self, index): @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) def itemClicked(self, item): widget = MapWidget(self, item) - widget.exec_() + widget.exec() def requestMapPool(self, queueName, minRating): self.apiConnector = self.mapPoolApiConnector diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py index 8d0fce11f..b92f11536 100644 --- a/src/vaults/mapvault/mapwidget.py +++ b/src/vaults/mapvault/mapwidget.py @@ -1,7 +1,9 @@ import os -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import downloadManager import util diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 8642b9d23..620d9069d 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -41,7 +41,8 @@ import logging import os -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets from api.vaults_api import ModApiConnector from vaults.modvault import utils @@ -111,12 +112,12 @@ def showChanged(self, index): @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) def modClicked(self, item): widget = ModWidget(self, item) - widget.exec_() + widget.exec() @QtCore.pyqtSlot() def openUIModForm(self): dialog = UIModWidget(self) - dialog.exec_() + dialog.exec() @QtCore.pyqtSlot() def openUploadForm(self): @@ -163,7 +164,7 @@ def openUploadForm(self): modinfo.setFolder(os.path.split(modDir)[1]) modinfo.update() dialog = UploadModWidget(self, modDir, modinfo) - dialog.exec_() + dialog.exec() else: QtWidgets.QMessageBox.information( self.client, diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py index 414edd12c..6ad0d2dd3 100644 --- a/src/vaults/modvault/modwidget.py +++ b/src/vaults/modvault/modwidget.py @@ -2,7 +2,9 @@ import os import urllib.parse -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util from util import strtodate @@ -128,7 +130,7 @@ def paint(self, painter, option, index, *args, **kwargs): option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Description diff --git a/src/vaults/modvault/uimodwidget.py b/src/vaults/modvault/uimodwidget.py index cbed39aaa..ad3da49b6 100644 --- a/src/vaults/modvault/uimodwidget.py +++ b/src/vaults/modvault/uimodwidget.py @@ -1,5 +1,6 @@ -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util from vaults.modvault import utils @@ -33,7 +34,7 @@ def __init__(self, parent, *args, **kwargs): names = [mod.totalname for mod in utils.getActiveMods(uimods=True)] for name in names: activeModList = self.modList.findItems( - name, QtCore.Qt.MatchExactly, + name, QtCore.Qt.MatchFlag.MatchExactly, ) if activeModList: activeModList[0].setSelected(True) diff --git a/src/vaults/modvault/uploadwidget.py b/src/vaults/modvault/uploadwidget.py index 964625e16..e35757d83 100644 --- a/src/vaults/modvault/uploadwidget.py +++ b/src/vaults/modvault/uploadwidget.py @@ -2,7 +2,8 @@ import tempfile import zipfile -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets import util from vaults.modvault import utils diff --git a/src/vaults/modvault/utils.py b/src/vaults/modvault/utils.py index ab127aa5a..15c8e37c9 100644 --- a/src/vaults/modvault/utils.py +++ b/src/vaults/modvault/utils.py @@ -4,7 +4,9 @@ import shutil import zipfile -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util from config import Settings @@ -406,7 +408,7 @@ def generateThumbnail(sourcename, destname): 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.SmoothTransformation, + 100, 100, transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, ) imageFile.save(destname) except IOError: diff --git a/src/vaults/vault.py b/src/vaults/vault.py index 0354c691f..f7c5a577b 100644 --- a/src/vaults/vault.py +++ b/src/vaults/vault.py @@ -1,10 +1,11 @@ import logging -from PyQt5 import QtCore +from PyQt6 import QtCore import util from ui.busy_widget import BusyWidget -from vaults.vaultitem import VaultItem, VaultItemDelegate +from vaults.vaultitem import VaultItem +from vaults.vaultitem import VaultItemDelegate logger = logging.getLogger(__name__) @@ -102,7 +103,7 @@ def itemsInfo(self, message: dict) -> None: self._items[item_key] = item self.itemList.addItem(item) item.update(value) - self.itemList.sortItems(1) + self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder) self.processMeta(message["meta"]) def processMeta(self, message: dict) -> None: @@ -143,4 +144,4 @@ def updateVisibilities(self): ) for _item in self._items: self._items[_item].updateVisibility() - self.itemList.sortItems(1) + self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder) diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py index 85d023803..2405d5103 100644 --- a/src/vaults/vaultitem.py +++ b/src/vaults/vaultitem.py @@ -1,4 +1,6 @@ -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtGui +from PyQt6 import QtWidgets import util from downloadManager import DownloadRequest @@ -109,7 +111,7 @@ def paint(self, painter, option, index, *args, **kwargs): option.icon = QtGui.QIcon() option.text = "" option.widget.style().drawControl( - QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget, + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, ) # Shadow @@ -124,9 +126,8 @@ def paint(self, painter, option, index, *args, **kwargs): iconrect = QtCore.QRect(option.rect.adjusted(3, 3, 0, 0)) iconrect.setSize(iconsize) # Icon - icon.paint( - painter, iconrect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, - ) + alignment = QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + icon.paint(painter, iconrect, alignment) # Frame around the icon pen = QtGui.QPen() @@ -134,7 +135,7 @@ def paint(self, painter, option, index, *args, **kwargs): # FIXME: This needs to come from theme. pen.setBrush(QtGui.QColor("#303030")) - pen.setCapStyle(QtCore.Qt.RoundCap) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.drawRect(iconrect) diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py index 5bb166e25..c6017b87c 100644 --- a/tests/fa/test_featured.py +++ b/tests/fa/test_featured.py @@ -3,7 +3,8 @@ import collections import pytest -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets from fa import updater diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py index 7c6822dd8..7bd0e61ed 100644 --- a/tests/fa/test_updater.py +++ b/tests/fa/test_updater.py @@ -3,7 +3,8 @@ import collections import pytest -from PyQt5 import QtCore, QtWidgets +from PyQt6 import QtCore +from PyQt6 import QtWidgets from fa import updater @@ -93,7 +94,7 @@ 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( @@ -113,4 +114,4 @@ def test_updater_does_not_hide_and_accept_before_all_watches_are_finished( application.processEvents() assert u.isVisible() - assert not u.result() == QtWidgets.QDialog.Accepted + assert not u.result() == QtWidgets.QDialog.DialogCode.Accepted diff --git a/tests/unit_tests/client/test_mouse_position.py b/tests/unit_tests/client/test_mouse_position.py index e79902b02..862874e9f 100644 --- a/tests/unit_tests/client/test_mouse_position.py +++ b/tests/unit_tests/client/test_mouse_position.py @@ -1,5 +1,5 @@ import pytest -from PyQt5.QtCore import QPoint +from PyQt6.QtCore import QPoint @pytest.mark.parametrize("x,y", [(0, 0)]) diff --git a/tests/unit_tests/client/test_updating.py b/tests/unit_tests/client/test_updating.py index 166a40f9b..1e3eff129 100644 --- a/tests/unit_tests/client/test_updating.py +++ b/tests/unit_tests/client/test_updating.py @@ -1,5 +1,5 @@ import pytest -from PyQt5 import QtWebEngineWidgets # noqa: F401 +from PyQt6 import QtWebEngineWidgets # noqa: F401 import config From 074299254229dc08b3e441e3cb64a9e2b90128a1 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 3 Apr 2024 23:28:20 +0300 Subject: [PATCH 011/123] Disallow using HTTP2 in api requests see https://bugreports.qt.io/browse/QTBUG-123891 also, split some ApiBase's methods into smaller ones --- src/api/ApiBase.py | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index 0800fbecc..c73b3c7a2 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -1,46 +1,59 @@ import json import logging import time +from typing import Any +from typing import Callable -from PyQt6 import QtCore -from PyQt6 import QtNetwork 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 logger = logging.getLogger(__name__) -DO_NOT_ENCODE = QtCore.QByteArray() +DO_NOT_ENCODE = QByteArray() DO_NOT_ENCODE.append(b":/?&=.,") -class ApiBase(QtCore.QObject): - def __init__(self, route): - QtCore.QObject.__init__(self) - +class ApiBase(QObject): + def __init__(self, route: str = "") -> None: + QObject.__init__(self) self.route = route - self.manager = QtNetwork.QNetworkAccessManager() + self.manager = QNetworkAccessManager() self.manager.finished.connect(self.onRequestFinished) self._running = False - - self.handlers = {} + self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {} # query arguments like filter=login==Rhyza - def request(self, queryDict, responseHandler): + def request(self, queryDict: dict, responseHandler: Callable[[dict], Any]) -> None: self._running = True - query = QtCore.QUrlQuery() - for key, value in queryDict.items(): + url = self.build_query_url(queryDict) + self.get(url, responseHandler) + + 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(QtCore.QUrl.ComponentFormattingOption.FullyDecoded) - percentEncodedByteArrayQuery = QtCore.QUrl.toPercentEncoding( + stringQuery = query.toString(QUrl.ComponentFormattingOption.FullyDecoded) + percentEncodedByteArrayQuery = QUrl.toPercentEncoding( stringQuery, exclude=DO_NOT_ENCODE, ) percentEncodedStrQuery = percentEncodedByteArrayQuery.data().decode() - url = QtCore.QUrl(Settings.get('api') + self.route) + url = url = QUrl(Settings.get('api') + self.route) url.setQuery(percentEncodedStrQuery) - request = QtNetwork.QNetworkRequest(url) + return url + @staticmethod + def prepare_request(url: QUrl | None) -> QNetworkRequest: + request = QNetworkRequest(url) if url else QNetworkRequest() api_token = Settings.get('oauth/token', None) if api_token is not None and api_token.get('expires_at') > time.time(): access_token = api_token.get('access_token') @@ -49,13 +62,19 @@ def request(self, queryDict, responseHandler): request.setRawHeader(b'User-Agent', b"FAF Client") request.setRawHeader(b'Content-Type', b'application/vnd.api+json') + # 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(request) - self.handlers[reply] = responseHandler + reply = self.manager.get(self.prepare_request(url)) + self.handlers[reply] = response_handler - def onRequestFinished(self, reply): + def onRequestFinished(self, reply: QNetworkReply) -> None: self._running = False - if reply.error() != QtNetwork.QNetworkReply.NetworkError.NoError: + if reply.error() != QNetworkReply.NetworkError.NoError: logger.error("API request error: {}".format(reply.error())) else: message_bytes = reply.readAll().data() @@ -132,6 +151,6 @@ def parseMeta(self, message): return {} def waitForCompletion(self): - waitFlag = QtCore.QEventLoop.WaitForMoreEvents + waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: QtWidgets.QApplication.processEvents(waitFlag) From 4e0e5ab60b91a8ed28d32e31d6b67942e15446aa Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 4 Apr 2024 20:48:19 +0300 Subject: [PATCH 012/123] Use Qt's built-in OAuth2 flow instead of oauthlib and requests_oauthlib --- requirements.txt | 2 - res/client/oauth.ui | 42 ------------ src/api/ApiBase.py | 22 +++---- src/client/_clientwindow.py | 125 +++++++----------------------------- src/client/oauth_dialog.py | 64 ------------------ src/config/production.py | 2 + src/oauth/__init__.py | 0 src/oauth/oauth_flow.py | 97 ++++++++++++++++++++++++++++ 8 files changed, 130 insertions(+), 224 deletions(-) delete mode 100644 res/client/oauth.ui delete mode 100644 src/client/oauth_dialog.py create mode 100644 src/oauth/__init__.py create mode 100644 src/oauth/oauth_flow.py diff --git a/requirements.txt b/requirements.txt index f3b75e0b2..7f4deedcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,3 @@ jsonschema jinja2 zstandard irc -oauthlib -requests_oauthlib diff --git a/res/client/oauth.ui b/res/client/oauth.ui deleted file mode 100644 index 852c030c8..000000000 --- a/res/client/oauth.ui +++ /dev/null @@ -1,42 +0,0 @@ - - - OAuthDialog - - - - 0 - 0 - 640 - 700 - - - - - 640 - 600 - - - - OAuth Login - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index c73b3c7a2..5162e1332 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -1,6 +1,5 @@ import json import logging -import time from typing import Any from typing import Callable @@ -15,6 +14,7 @@ from PyQt6.QtNetwork import QNetworkRequest from config import Settings +from oauth.oauth_flow import OAuth2Flow logger = logging.getLogger(__name__) @@ -23,6 +23,8 @@ class ApiBase(QObject): + oauth: OAuth2Flow = OAuth2Flow() + def __init__(self, route: str = "") -> None: QObject.__init__(self) self.route = route @@ -31,11 +33,9 @@ def __init__(self, route: str = "") -> None: self._running = False self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {} - # query arguments like filter=login==Rhyza - def request(self, queryDict: dict, responseHandler: Callable[[dict], Any]) -> None: - self._running = True - url = self.build_query_url(queryDict) - self.get(url, responseHandler) + @classmethod + def set_oauth(cls, oauth: OAuth2Flow) -> None: + cls.oauth = oauth def build_query_url(self, query_dict: dict) -> QUrl: query = QUrlQuery() @@ -54,14 +54,8 @@ def build_query_url(self, query_dict: dict) -> QUrl: @staticmethod def prepare_request(url: QUrl | None) -> QNetworkRequest: request = QNetworkRequest(url) if url else QNetworkRequest() - api_token = Settings.get('oauth/token', None) - if api_token is not None and api_token.get('expires_at') > time.time(): - access_token = api_token.get('access_token') - bearer = 'Bearer {}'.format(access_token).encode('utf-8') - request.setRawHeader(b'Authorization', bearer) - - request.setRawHeader(b'User-Agent', b"FAF Client") - request.setRawHeader(b'Content-Type', b'application/vnd.api+json') + # 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 diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 5f45ab991..2fa0b68b7 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1,20 +1,18 @@ import logging -import sys import time from functools import partial -from oauthlib.oauth2 import WebApplicationClient from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets from PyQt6.QtNetwork import QNetworkAccessManager -from requests_oauthlib import OAuth2Session import config import fa import notifications as ns import util import util.crash +from api.ApiBase import ApiBase from chat import ChatMVC from chat._avatarWidget import AvatarWidget from chat.channel_autojoiner import ChannelAutojoiner @@ -69,6 +67,7 @@ from model.rating import MatchmakerQueueType from model.rating import RatingType from news import NewsWidget +from oauth.oauth_flow import OAuth2Flow from power import PowerTools from replays import ReplaysWidget from secondaryServer import SecondaryServer @@ -83,13 +82,9 @@ from vaults.modvault.utils import setModFolder from .mouse_position import MousePosition -from .oauth_dialog import OAuthWidget logger = logging.getLogger(__name__) -OAUTH_TOKEN_PATH = "/oauth2/token" -OAUTH_AUTH_PATH = "/oauth2/auth" - FormClass, BaseClass = util.THEME.loadUiType("client/client.ui") @@ -148,9 +143,11 @@ def __init__(self, *args, **kwargs): ) self._network_access_manager = QNetworkAccessManager(self) - self.OAuthSession = None - self.tokenTimer = QtCore.QTimer() - self.tokenTimer.timeout.connect(self.checkOAuthToken) + self.oauth_flow = OAuth2Flow(parent=self) + ApiBase.set_oauth(self.oauth_flow) + 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.unique_id = None self._chat_config = ChatConfig(util.settings) @@ -917,7 +914,7 @@ def disconnect_(self): self.lobby_connection.disconnect_() self._chatMVC.connection.disconnect_() self.games.onLogOut() - self.tokenTimer.stop() + self.oauth_flow.stop_checking_expiration() config.Settings.set("oauth/token", None, persist=False) def chat_reconnect(self): @@ -1504,7 +1501,13 @@ def load_chat(self): except BaseException: pass - def do_connect(self): + 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 @@ -1516,68 +1519,24 @@ def set_remember(self, remember): # FIXME - option updating is silly self.actionSetAutoLogin.setChecked(self.remember) - def try_to_auto_login(self): + def try_to_auto_login(self) -> None: if ( self._auto_relogin and self.refresh_token - and self.refreshOAuthToken() ): - self.do_connect() + self.oauth_flow.setRefreshToken(self.refresh_token) + self.oauth_flow.refreshAccessToken() else: self.show_login_widget() - def get_creds_and_login(self): - if self.OAuthSession.token and self.checkOAuthToken(): - if self.send_token(self.OAuthSession.token.get("access_token")): - return + 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 createOAuthSession(self): - client_id = config.Settings.get("oauth/client_id") - refresh_kwargs = dict(client_id=client_id) - redirect_uri = config.Settings.get("oauth/redirect_uri") - scope = config.Settings.get("oauth/scope") - app_client = WebApplicationClient(client_id=client_id) - OAuth = OAuth2Session( - client=app_client, - redirect_uri=redirect_uri, - scope=scope, - auto_refresh_kwargs=refresh_kwargs, - ) - return OAuth - - def checkOAuthToken(self): - if self.OAuthSession.token.get("expires_at", 0) < time.time() + 5: - self.tokenTimer.stop() - logger.info("Token expired, going to refresh") - return self.refreshOAuthToken() - return True - - def refreshOAuthToken(self): - token_url = config.Settings.get('oauth/host') + OAUTH_TOKEN_PATH - if not self.OAuthSession: - self.OAuthSession = self.createOAuthSession() - try: - logger.debug("Refreshing OAuth token") - token = self.OAuthSession.refresh_token( - token_url, - refresh_token=self.refresh_token, - verify=False, - ) - self.saveOAuthToken(token) - return True - except BaseException: - logger.error("Error during refreshing token") - return False - - def saveOAuthToken(self, token): - config.Settings.set("oauth/token", token, persist=False) - self.refresh_token = token.get("refresh_token") - self.tokenTimer.start(1 * 1000) - def show_login_widget(self): login_widget = LoginWidget(self.remember) login_widget.finished.connect(self.on_widget_login_data) @@ -1599,46 +1558,8 @@ def on_widget_login_data(self, api_changed): self.news.updateNews() self.games.refreshMods() - oauth_host = config.Settings.get("oauth/host") - authorization_endpoint = oauth_host + OAUTH_AUTH_PATH - self.OAuthSession = self.createOAuthSession() - authorization_url, oauth_state = self.OAuthSession.authorization_url( - authorization_endpoint, - ) - oauth_widget = OAuthWidget( - oauth_state=oauth_state, - url=authorization_url, - ) - oauth_widget.finished.connect(self.oauth_finished) - oauth_widget.rejected.connect(self.on_widget_no_login) - oauth_widget.exec() - - def oauth_finished(self, state, code, error): - token_url = config.Settings.get("oauth/host") + OAUTH_TOKEN_PATH - if state: - try: - logger.debug("Fetching OAuth token") - token = self.OAuthSession.fetch_token( - token_url, - code=code, - include_client_id=True, - verify=False, - ) - self.saveOAuthToken(token) - self.do_connect() - return - except BaseException: - logger.error( - "Fetching token failed: ", - exc_info=sys.exc_info(), - ) - elif error: - logger.error("Error during logging in: {}".format(error)) - - QtWidgets.QMessageBox.warning( - self, "Log In", "Error occured, please retry", - ) - self.on_widget_no_login() + self.oauth_flow.setup_credentials() + self.oauth_flow.grant() def on_widget_no_login(self): self.state = ClientState.DISCONNECTED diff --git a/src/client/oauth_dialog.py b/src/client/oauth_dialog.py deleted file mode 100644 index 02fbe793c..000000000 --- a/src/client/oauth_dialog.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging - -from PyQt6 import QtCore -from PyQt6 import QtGui -from PyQt6 import QtWebEngineCore -from PyQt6 import QtWebEngineWidgets - -import util - -logger = logging.getLogger(__name__) - -FormClass, BaseClass = util.THEME.loadUiType("client/oauth.ui") - - -class OAuthWidget(FormClass, BaseClass): - finished = QtCore.pyqtSignal(str, str, str) - request_quit = QtCore.pyqtSignal() - - def __init__(self, oauth_state=None, url=None, *args, **kwargs): - BaseClass.__init__(self, *args, **kwargs) - self.setupUi(self) - self.webview = QtWebEngineWidgets.QWebEngineView() - self.layout().addWidget(self.webview) - self.url = QtCore.QUrl(url) - self.oauth_state = oauth_state - self.webpage = OAuthWebPage() - self.webpage.setUrl(self.url) - self.webview.setPage(self.webpage) - - self.webpage.navigationRequestAccepted.connect( - self.navigationRequestAccepted, - ) - - def navigationRequestAccepted(self, url): - query = QtCore.QUrlQuery(url) - code = query.queryItemValue("code") - state = query.queryItemValue("state") - error = query.queryItemValue("error") - if state and code: - self.accept() - if self.oauth_state == state: - self.finished.emit(state, code, error) - else: - self.finished.emit("", "", "") - elif error: - self.reject() - self.finished.emit("", "", error) - - -class OAuthWebPage(QtWebEngineCore.QWebEnginePage): - navigationRequestAccepted = QtCore.pyqtSignal(QtCore.QUrl) - - def __init__(self): - QtWebEngineCore.QWebEnginePage.__init__(self) - - def acceptNavigationRequest(self, url, type_, isMainFrame): - if "oauth" in url.url() or "localhost" in url.url(): - self.navigationRequestAccepted.emit(url) - return True - elif type_ == 1: - return False - else: - QtGui.QDesktopServices.openUrl(url) - return False diff --git a/src/config/production.py b/src/config/production.py index 4cc473da0..7734871f4 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -42,6 +42,8 @@ '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, 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..ad691c01a --- /dev/null +++ b/src/oauth/oauth_flow.py @@ -0,0 +1,97 @@ +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(f"Token changed to: {new_token}") + + 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") + client_id = "faf-java-client" # FIXME: ask to configure ports for python client + 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)) From 3a0d8d9b59750411f15ebe919cad3b1f5c8778ef Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 5 Apr 2024 22:05:32 +0300 Subject: [PATCH 013/123] Factor out ApiAcessor classes from ApiBase class create user api class, which will be needed for WebSocket connection --- src/api/ApiAccessors.py | 89 +++++++++++++++++++++++++++++++++ src/api/ApiBase.py | 86 +++++++------------------------ src/api/featured_mod_api.py | 15 +++--- src/api/featured_mod_updater.py | 31 ++++++------ src/api/matchmaker_queue_api.py | 14 +++--- src/api/player_api.py | 22 +++++--- src/api/replaysapi.py | 13 ++--- src/api/sim_mod_updater.py | 16 +++--- src/api/stats_api.py | 25 +++++---- src/api/vaults_api.py | 36 +++++++------ src/config/production.py | 1 + 11 files changed, 201 insertions(+), 147 deletions(-) create mode 100644 src/api/ApiAccessors.py diff --git a/src/api/ApiAccessors.py b/src/api/ApiAccessors.py new file mode 100644 index 000000000..874a02cb4 --- /dev/null +++ b/src/api/ApiAccessors.py @@ -0,0 +1,89 @@ +import logging + +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): + 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 {} diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index 5162e1332..630a033b7 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -28,6 +28,7 @@ class ApiBase(QObject): 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 @@ -47,10 +48,22 @@ def build_query_url(self, query_dict: dict) -> QUrl: exclude=DO_NOT_ENCODE, ) percentEncodedStrQuery = percentEncodedByteArrayQuery.data().decode() - url = url = QUrl(Settings.get('api') + self.route) + 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() @@ -66,6 +79,9 @@ def get(self, url: QUrl, response_handler: Callable[[dict], Any]) -> None: 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: @@ -73,77 +89,11 @@ def onRequestFinished(self, reply: QNetworkReply) -> None: else: message_bytes = reply.readAll().data() message = json.loads(message_bytes.decode('utf-8')) - included = self.parseIncluded(message) - result = {} - result["data"] = self.parseData(message, included) - result["meta"] = self.parseMeta(message) + result = self.parse_message(message) self.handlers[reply](result) self.handlers.pop(reply) reply.deleteLater() - def parseIncluded(self, message): - result = {} - 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, included): - 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, included): - 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 BaseException: - logger.error("error parsing ", data) - return result - - def parseMeta(self, message): - if "meta" in message: - return message["meta"] - return {} - def waitForCompletion(self): waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py index 5af136677..203aad324 100644 --- a/src/api/featured_mod_api.py +++ b/src/api/featured_mod_api.py @@ -1,19 +1,20 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor +from client.connection import Dispatcher logger = logging.getLogger(__name__) -class FeaturedModApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/featuredMod') +class FeaturedModApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/featuredMod') self.dispatch = dispatch - def requestData(self): - self.request({}, self.handleData) + def requestData(self) -> None: + self.get_by_query({}, self.handleData) - def handleData(self, message): + def handleData(self, message: dict) -> None: preparedData = { "command": "mod_info_api", "values": [], diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py index c4a69be30..e1d2cbd6e 100644 --- a/src/api/featured_mod_updater.py +++ b/src/api/featured_mod_updater.py @@ -1,21 +1,17 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor logger = logging.getLogger(__name__) -class FeaturedModFiles(ApiBase): - - def __init__(self, mod_id, version): - ApiBase.__init__( - self, - '/featuredMods/{}/files/{}'.format(mod_id, version), - ) +class FeaturedModFiles(DataApiAccessor): + def __init__(self, mod_id: int, version: str) -> None: + super().__init__('/featuredMods/{}/files/{}'.format(mod_id, version)) self.featuredModFiles = [] - def requestData(self): - self.request({}, self.handleData) + def requestData(self) -> None: + self.get_by_query({}, self.handleData) def handleData(self, message): self.featuredModFiles = message["data"] @@ -26,20 +22,21 @@ def getFiles(self): return self.featuredModFiles -class FeaturedModId(ApiBase): - def __init__(self): - ApiBase.__init__(self, '/data/featuredMod') +class FeaturedModId(DataApiAccessor): + def __init__(self) -> None: + super().__init__('/data/featuredMod') self.featuredModId = 0 - def requestData(self, queryDict={}): - self.request(queryDict, self.handleData) + def requestData(self, queryDict: dict | None = None) -> None: + queryDict = queryDict or {} + self.get_by_query(queryDict, self.handleData) def handleFeaturedModId(self, message): self.featuredModId = message['data'][0]['id'] - def requestFeaturedModIdByName(self, technicalName): + def requestFeaturedModIdByName(self, technicalName: str) -> None: queryDict = dict(filter='technicalName=={}'.format(technicalName)) - self.request(queryDict, self.handleFeaturedModId) + self.get_by_query(queryDict, self.handleFeaturedModId) def requestAndGetFeaturedModIdByName(self, technicalName): self.requestFeaturedModIdByName(technicalName) diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py index edbab4697..e4ca62d43 100644 --- a/src/api/matchmaker_queue_api.py +++ b/src/api/matchmaker_queue_api.py @@ -1,17 +1,19 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor +from client.connection import Dispatcher logger = logging.getLogger(__name__) -class matchmakerQueueApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/matchmakerQueue') +class matchmakerQueueApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/matchmakerQueue') self.dispatch = dispatch - def requestData(self, queryDict={}): - self.request(queryDict, self.handleData) + def requestData(self, queryDict: dict | None = None) -> None: + queryDict = queryDict or {} + self.get_by_query(queryDict, self.handleData) def handleData(self, message: dict) -> None: preparedData = { diff --git a/src/api/player_api.py b/src/api/player_api.py index 41bc8887e..235a22638 100644 --- a/src/api/player_api.py +++ b/src/api/player_api.py @@ -1,18 +1,24 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor +from client.connection import Dispatcher logger = logging.getLogger(__name__) -class PlayerApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/player') +class PlayerApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/player') self.dispatch = dispatch - def requestDataForLeaderboard(self, leaderboardName, queryDict={}): + def requestDataForLeaderboard( + self, + leaderboardName: str, + queryDict: dict | None = None, + ) -> None: + queryDict = queryDict or {} self.leaderboardName = leaderboardName - self.request(queryDict, self.handleDataForLeaderboard) + self.get_by_query(queryDict, self.handleDataForLeaderboard) def handleDataForLeaderboard(self, message: dict) -> None: preparedData = dict( @@ -24,7 +30,7 @@ def handleDataForLeaderboard(self, message: dict) -> None: ) self.dispatch.dispatch(preparedData) - def requestDataForAliasViewer(self, nameToFind): + def requestDataForAliasViewer(self, nameToFind: str) -> None: queryDict = { 'include': 'names', 'filter': '(login=="{name}",names.name=="{name}")'.format( @@ -33,7 +39,7 @@ def requestDataForAliasViewer(self, nameToFind): 'fields[player]': 'login,names', 'fields[nameRecord]': 'name,changeTime,player', } - self.request(queryDict, self.handleDataForAliasViewer) + self.get_by_query(queryDict, self.handleDataForAliasViewer) def handleDataForAliasViewer(self, message: dict) -> None: preparedData = dict( diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py index 445bf8dea..f76ebd17e 100644 --- a/src/api/replaysapi.py +++ b/src/api/replaysapi.py @@ -1,17 +1,18 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor +from client.connection import Dispatcher logger = logging.getLogger(__name__) -class ReplaysApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/game') +class ReplaysApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/game') self.dispatch = dispatch - def requestData(self, args): - self.request(args, self.handleData) + def requestData(self, params: dict) -> None: + self.get_by_query(params, self.handleData) def handleData(self, message): preparedData = dict( diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py index 75a706381..e8cae3158 100644 --- a/src/api/sim_mod_updater.py +++ b/src/api/sim_mod_updater.py @@ -1,23 +1,23 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor logger = logging.getLogger(__name__) -class SimModFiles(ApiBase): - def __init__(self): - ApiBase.__init__(self, '/data/modVersion') +class SimModFiles(DataApiAccessor): + def __init__(self) -> None: + super().__init__('/data/modVersion') self.simModUrl = '' - def requestData(self, queryDict): - self.request(queryDict, self.handleData) + def requestData(self, queryDict: dict) -> None: + self.get_by_query(queryDict, self.handleData) def getUrlFromMessage(self, message): self.simModUrl = message[0]['downloadUrl'] - def requestAndGetSimModUrlByUid(self, uid): + def requestAndGetSimModUrlByUid(self, uid: int) -> str: queryDict = dict(filter='uid=={}'.format(uid)) - self.request(queryDict, self.getUrlFromMessage) + self.get_by_query(queryDict, self.getUrlFromMessage) self.waitForCompletion() return self.simModUrl diff --git a/src/api/stats_api.py b/src/api/stats_api.py index 6c9c76532..ad8f1fc0c 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -1,18 +1,20 @@ import logging -from api.ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor +from client.connection import Dispatcher logger = logging.getLogger(__name__) -class LeaderboardRatingApiConnector(ApiBase): - def __init__(self, dispatch, leaderboardName): - ApiBase.__init__(self, '/data/leaderboardRating') +class LeaderboardRatingApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher, leaderboardName: str) -> None: + super().__init__('/data/leaderboardRating') self.dispatch = dispatch self.leadeboardName = leaderboardName - def requestData(self, queryDict={}): - self.request(queryDict, self.handleData) + def requestData(self, queryDict: dict | None = None) -> None: + queryDict = queryDict or {} + self.get_by_query(queryDict, self.handleData) def handleData(self, message: dict) -> None: preparedData = dict( @@ -25,13 +27,14 @@ def handleData(self, message: dict) -> None: self.dispatch.dispatch(preparedData) -class LeaderboardApiConnector(ApiBase): - def __init__(self, dispatch=None): - ApiBase.__init__(self, '/data/leaderboard') +class LeaderboardApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher | None = None) -> None: + super().__init__('/data/leaderboard') self.dispatch = dispatch - def requestData(self, queryDict={}): - self.request(queryDict, self.handleData) + def requestData(self, queryDict: dict | None = None) -> None: + queryDict = queryDict or {} + self.get_by_query(queryDict, self.handleData) def handleData(self, message: dict) -> None: preparedData = dict( diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py index bb736de04..ab8af345d 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -1,17 +1,19 @@ import logging -from .ApiBase import ApiBase +from api.ApiAccessors import DataApiAccessor +from client.connection import Dispatcher logger = logging.getLogger(__name__) -class ModApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/mod') +class ModApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/mod') self.dispatch = dispatch - def requestData(self, query={}): - self.request(query, self.handleData) + def requestData(self, params: dict | None = None) -> None: + params = params or {} + self.get_by_query(params, self.handleData) def handleData(self, message: dict) -> None: preparedData = dict( @@ -45,13 +47,14 @@ def handleData(self, message: dict) -> None: self.dispatch.dispatch(preparedData) -class MapApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/map') +class MapApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/map') self.dispatch = dispatch - def requestData(self, query={}): - self.request(query, self.handleData) + def requestData(self, params: dict | None = None) -> None: + params = params or {} + self.get_by_query(params, self.handleData) def handleData(self, message: dict) -> None: preparedData = dict( @@ -88,13 +91,14 @@ def handleData(self, message: dict) -> None: self.dispatch.dispatch(preparedData) -class MapPoolApiConnector(ApiBase): - def __init__(self, dispatch): - ApiBase.__init__(self, '/data/mapPoolAssignment') +class MapPoolApiConnector(DataApiAccessor): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__('/data/mapPoolAssignment') self.dispatch = dispatch - def requestData(self, query={}): - self.request(query, self.handleData) + def requestData(self, params: dict | None) -> None: + params = params or {} + self.get_by_query(params, self.handleData) def handleData(self, message: dict) -> None: preparedData = dict( diff --git a/src/config/production.py b/src/config/production.py index 7734871f4..bdd5808bd 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -12,6 +12,7 @@ default_values = { 'display_name': 'Main Server (recommended)', 'api': 'https://api.{host}', + 'user_api': 'https://user.{host}', 'chat/host': 'irc.{host}', 'chat/port': 6697, 'client/data_path': APPDATA_DIR, From 6ca9da89249475c0295d54001db3329e6c0e0f2c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 6 Apr 2024 10:03:42 +0300 Subject: [PATCH 014/123] Switch to WebSocket connection --- src/client/connection.py | 48 +++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/client/connection.py b/src/client/connection.py index f74441125..f8928169b 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -5,8 +5,12 @@ 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 @@ -151,13 +155,13 @@ class ServerConnection(QtCore.QObject): connected = QtCore.pyqtSignal() disconnected = QtCore.pyqtSignal() received_pong = 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 = QWebSocket() + self.socket.binaryMessageReceived.connect(self.on_binary_message_received) self.socket.errorOccurred.connect(self.socketError) - self.socket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1) self.socket.stateChanged.connect(self.on_socket_state_change) self._host = host @@ -168,6 +172,9 @@ def __init__(self, host, port, dispatch): 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.SocketState my_state = None @@ -225,7 +232,22 @@ def setPortFromConfig(self): 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("com?", "com/?") + 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 @@ -238,7 +260,7 @@ def socket_connected(self): return self.socket.state() == QtNetwork.QTcpSocket.SocketState.ConnectedState def disconnect_(self): - self.socket.disconnectFromHost() + self.socket.close() def set_upnp(self, port): fa.upnp.createPortMapping( @@ -265,15 +287,11 @@ def processDataFromServer(self, data): exc_info=sys.exc_info(), ) - @QtCore.pyqtSlot() - def readFromServer(self): - while not self.socket.atEnd(): - if self.socket.bytesAvailable() == 0: - return - - data = self.socket.readAll().data().decode() - logger.debug("Server: '{}'".format(data)) - self._data += data + @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) @@ -282,7 +300,7 @@ def writeToServer(self, action, *args, **kw): # 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.write(message) + self.socket.sendBinaryMessage(message) def send(self, message): data = json.dumps(message) From 0b97942bbbf09ebf1cf29b14a82afdbdd4a8bbc4 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 6 Apr 2024 14:32:28 +0300 Subject: [PATCH 015/123] Fix QSoundEffect usage Qt6 doesn't allow to play sounds immediately from filepath so create QSoundEffect objects inside classes that use them and play whenever needed --- src/chat/channel_tab.py | 8 ++++++-- src/notifications/ns_dialog.py | 5 ++++- src/util/theme.py | 9 +++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/chat/channel_tab.py b/src/chat/channel_tab.py index 1a1338ecb..1208c4cf0 100644 --- a/src/chat/channel_tab.py +++ b/src/chat/channel_tab.py @@ -1,6 +1,7 @@ from enum import IntEnum from PyQt6.QtCore import QTimer +from PyQt6.QtMultimedia import QSoundEffect from chat.chat_widget import TabIcon @@ -29,6 +30,9 @@ def __init__(self, cid, widget, theme, chat_config): 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": @@ -69,14 +73,14 @@ def _start_blinking(self): self._timer.start() self._ping() - def _ping(self): + 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._theme.sound("chat/sfx/query.wav") + self._ping_sound.play() def _stop_blinking(self): self._timer.stop() diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py index 8aebba951..5c56e9fcb 100644 --- a/src/notifications/ns_dialog.py +++ b/src/notifications/ns_dialog.py @@ -5,6 +5,7 @@ from PyQt6 import QtCore from PyQt6 import QtWidgets +from PyQt6.QtMultimedia import QSoundEffect import util @@ -41,6 +42,8 @@ def __init__(self, client, settings, *args, **kwargs): 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()) @@ -71,7 +74,7 @@ def newEvent( 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) diff --git a/src/util/theme.py b/src/util/theme.py index 5c53dc8cb..fa0652469 100644 --- a/src/util/theme.py +++ b/src/util/theme.py @@ -3,7 +3,6 @@ from PyQt6 import QtCore from PyQt6 import QtGui -from PyQt6 import QtMultimedia from PyQt6 import QtWidgets from PyQt6 import uic from semantic_version import Version @@ -361,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 @@ -371,9 +371,6 @@ 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 reloadStyleSheets(self): self.stylesheets_reloaded.emit() From 3b8fd28ad8d4e10d05dec7b4dac139a8820e693c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 7 Apr 2024 17:46:52 +0300 Subject: [PATCH 016/123] Update leftover Qt6 incompatible Enum usage --- src/updater/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/updater/__init__.py b/src/updater/__init__.py index 372bfcc38..29714d5d3 100644 --- a/src/updater/__init__.py +++ b/src/updater/__init__.py @@ -4,6 +4,7 @@ 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 @@ -41,7 +42,7 @@ def build(cls, current_version, parent_widget, network_manager): ) return cls(update_settings, checker, notifier, dialog, parent_widget) - def _handle_update(self, releases, mandatory): + 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, @@ -54,7 +55,7 @@ def _handle_update(self, releases, mandatory): return self.dialog.setup(releases) result = self.dialog.exec() - if result == QDialog.Rejected and mandatory: + if result is QDialog.DialogCode.Rejected and mandatory: self.mandatory_update_aborted.emit() def settings_dialog(self): From 7d9346326ff3bdfaa9ccd4cd3091809bc6fc9444 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 7 Apr 2024 18:13:57 +0300 Subject: [PATCH 017/123] Prevent comparing int to None while handling tray icon activation --- src/client/_clientwindow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 2fa0b68b7..d12365144 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -490,7 +490,7 @@ def on_disconnected(self): def appStateChanged(self, state): if state == QtCore.Qt.ApplicationState.ApplicationInactive: - self._lastDeactivateTime = time.time() + self._lastDeactivateTime = time.monotonic() def eventFilter(self, obj, event): if event.type() == QtCore.QEvent.Type.HoverMove: @@ -541,7 +541,11 @@ def handle_tray_icon_activation( reason: QtWidgets.QSystemTrayIcon.ActivationReason, ) -> None: if reason is QtWidgets.QSystemTrayIcon.ActivationReason.Trigger: - inactiveTime = time.time() - self._lastDeactivateTime + if self._lastDeactivateTime is None: + self.showMinimized() + return + + inactiveTime = time.monotonic() - self._lastDeactivateTime if ( self.isMinimized() or inactiveTime >= self.keepActiveForTrayIcon From 447ffd3149fef39100980ac53059d675295e6086 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 8 Apr 2024 21:04:54 +0300 Subject: [PATCH 018/123] Fix ircconnection * use SASL authentication to log in * take irc password from a dedicated service (see https://github.com/FAForever/server/issues/977) --- src/chat/ircconnection.py | 32 +++++++++++++++++++++++++++----- src/client/_clientwindow.py | 9 ++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py index 63dc6bfe8..d91deabd4 100644 --- a/src/chat/ircconnection.py +++ b/src/chat/ircconnection.py @@ -12,6 +12,7 @@ import config import util +from api.ApiAccessors import UserApiAccessor from model.chat.channel import ChannelID from model.chat.channel import ChannelType from model.chat.chatline import ChatLine @@ -81,13 +82,17 @@ def __init__(self): class IrcConnection(IrcSignals, irc.client.SimpleIRCClient): - def __init__(self, host, port, use_ssl): + token_received = pyqtSignal(str) + + def __init__(self, host: int, port: int, use_ssl: bool) -> None: IrcSignals.__init__(self) irc.client.SimpleIRCClient.__init__(self) self.host = host self.port = port self.use_ssl = use_ssl + self.api_accessor = UserApiAccessor() + self.token_received.connect(self.on_token_received) if self.use_ssl: self.factory = irc.connection.Factory(wrapper=ssl.wrap_socket) else: @@ -127,7 +132,21 @@ def disconnect_(self): self._notifier.activated.disconnect() self._notifier = None - def connect_(self, nick, username, password): + 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( "Connecting to IRC at: {}:{}. TLS: {}".format( self.host, self.port, self.use_ssl, @@ -145,13 +164,13 @@ def connect_(self, nick, username, password): nick, connect_factory=self.factory, ircname=nick, - username=username, + sasl_login=username, password=password, ) self._notifier = QSocketNotifier( - self.connection.socket.fileno(), QSocketNotifier.Read, self, + self.connection.socket.fileno(), QSocketNotifier.Type.Read, self, ) - self._notifier.activated.connect(self.reactor.process_once) + self._notifier.activated.connect(lambda: self.reactor.process_once()) self._timer.start(PONG_INTERVAL) return True except irc.client.IRCError: @@ -205,6 +224,9 @@ def _log_client_message(self, 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( diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index d12365144..11a9b1c35 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -888,7 +888,8 @@ def add_warning_button(faction): def _connect_chat(self, me): if not self.use_chat: return - self._chatMVC.connection.connect_(me.login, me.id, self.irc_password) + self._chatMVC.connection.set_nick_and_username(me.login, f"{me.login}@FAF") + self._chatMVC.connection.begin_connection_process() def warningHide(self): """ @@ -1750,8 +1751,10 @@ def handle_welcome(self, message): self.game_session.gameFullSignal.connect(self.emit_game_full) - def handle_irc_password(self, message): - self.irc_password = message.get("password", "") + 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": From 1cf5c6ad910be0d1c56c2f9a4f333e06fd495ff7 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 9 Apr 2024 20:49:03 +0300 Subject: [PATCH 019/123] Fix ice adapter usage * launch it with java.exe from jre * pass required game_id argument * fetch ice_servers from api and don't expect them from the server (see https://github.com/FAForever/server/pull/982) --- src/client/_clientwindow.py | 4 +- src/connectivity/IceAdapterProcess.py | 18 +++---- src/connectivity/IceServersPoller.py | 71 +++++++++------------------ src/fa/game_session.py | 13 ++--- 4 files changed, 36 insertions(+), 70 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 11a9b1c35..691753ed8 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1833,7 +1833,7 @@ def join_game(self, uid, password=None): self.lobby_connection.send(msg) def handle_game_launch(self, message): - + self.game_session.game_uid = message['uid'] self.game_session.startIceAdapter() logger.info("Handling game_launch via JSON {}".format(message)) @@ -1911,8 +1911,6 @@ def handle_game_launch(self, message): launched_at=time.time(), ) - self.game_session.game_uid = message['uid'] - fa.run( info, self.game_session.relay_port, self.replayServer.serverPort(), arguments, self.game_session.game_uid, diff --git a/src/connectivity/IceAdapterProcess.py b/src/connectivity/IceAdapterProcess.py index e9d8e7c1d..d513aeefa 100644 --- a/src/connectivity/IceAdapterProcess.py +++ b/src/connectivity/IceAdapterProcess.py @@ -14,7 +14,7 @@ @with_logger class IceAdapterProcess(object): - def __init__(self, player_id, player_login): + 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 @@ -24,12 +24,13 @@ def __init__(self, player_id, player_login): s.close() if sys.platform == 'win32': - exe_path = os.path.join( - fafpath.get_libdir(), "ice-adapter", "faf-ice-adapter.exe", - ) + 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, ) @@ -37,13 +38,12 @@ def __init__(self, player_id, player_login): "iceadapter/delay_ui_seconds", default=10, type=int, ) self.ice_adapter_process = QProcess() - args = [ + args.extend([ "--id", str(player_id), "--login", player_login, + "--game-id", str(game_id), "--rpc-port", str(self._rpc_server_port), - "--gpgnet-port", "0", - "--log-level", "debug", - ] + ]) if show_adapter_window: args.extend(["--info-window", "--delay-ui", str(delay_adapter_ui)]) if Settings.contains('iceadapter/args'): diff --git a/src/connectivity/IceServersPoller.py b/src/connectivity/IceServersPoller.py index a05ff3d68..1e10eb96c 100644 --- a/src/connectivity/IceServersPoller.py +++ b/src/connectivity/IceServersPoller.py @@ -1,65 +1,38 @@ -from datetime import datetime -from datetime import timedelta - from PyQt6.QtCore import QObject -from PyQt6.QtCore import QTimer +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): - def __init__(self, dispatcher, ice_adapter_client, lobby_connection): + ice_servers_received = pyqtSignal(list) + + def __init__(self, ice_adapter_client: IceAdapterClient, game_uid: int) -> None: QObject.__init__(self) - self._dispatcher = dispatcher self._ice_adapter_client = ice_adapter_client - self._server_connection = lobby_connection - self._dispatcher["ice_servers"] = self.handle_ice_servers - self._valid_until = datetime.now() - self.request_ice_servers() - # credentials need to be valid for 10h, - # usual ttlfor each request is 24h - self.min_valid_seconds = 10 * 3600 - self._last_received_ice_servers = None - self._last_relayed_ice_servers = None + self._game_uid = game_uid - self._check_timer = QTimer(self) - self._check_timer.timeout.connect(self.check_ice_servers) - self._check_timer.start(5000) + self.ice_servers_received.connect(self.set_ice_servers) - def check_ice_servers(self): - seconds_left = (self._valid_until - datetime.now()).seconds - if seconds_left < self.min_valid_seconds: - self._logger.debug("ICE servers expired: requesting new list") - self.request_ice_servers() + self._api_accessor = ApiAccessor() + self.request_ice_servers() - # check if we have a list not sent to the ice-adapter - if ( - not self._last_relayed_ice_servers - and self._last_received_ice_servers - and self._ice_adapter_client.connected - ): - self._ice_adapter_client.call( - "setIceServers", [self._last_received_ice_servers], - ) - self._last_relayed_ice_servers = self._last_received_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 request_ice_servers(self): - self._server_connection.send({'command': 'ice_servers'}) + def handle_ice_servers(self, message: dict) -> None: + servers = message["servers"] + self.ice_servers_received.emit(servers) - def handle_ice_servers(self, message): - ttl = int(message['ttl']) - self._valid_until = datetime.now() + timedelta(seconds=ttl) - self._logger.debug( - "ice_servers valid until {}".format(self._valid_until), - ) - self._last_received_ice_servers = message['ice_servers'] + def set_ice_servers(self, servers: list[dict]) -> None: if self._ice_adapter_client.connected: - self._ice_adapter_client.call( - "setIceServers", [self._last_received_ice_servers], - ) - self._last_relayed_ice_servers = self._last_received_ice_servers + 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", - ) + self._logger.warn("ICE servers received, but not connected to ice-adapter") diff --git a/src/fa/game_session.py b/src/fa/game_session.py index 30e9ab78e..39fa1e6d8 100644 --- a/src/fa/game_session.py +++ b/src/fa/game_session.py @@ -69,6 +69,7 @@ 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) @@ -78,20 +79,14 @@ def startIceAdapter(self): while self._relay_port == 0: QCoreApplication.processEvents() - def onIceAdapterStarted(self, status): + 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( - dispatcher=client.instance.lobby_dispatch, - ice_adapter_client=self.ice_adapter_client, - lobby_connection=client.instance.lobby_connection, - ) + 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: From b9b0e95e968e5b1a81fa5a86d3cff932cea75f98 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 10 Apr 2024 19:19:28 +0300 Subject: [PATCH 020/123] Set request parameters when downloading file if necessary also, pass cloudflare parameters needed to download game files resolves https://github.com/FAForever/client/issues/1133 --- src/downloadManager/__init__.py | 25 ++++++++++++++++++++----- src/fa/updater.py | 9 +++++---- src/vaults/dialogs.py | 11 +++++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 4e6334ec0..4b38191f9 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -26,12 +26,20 @@ class FileDownload(QObject): progress = pyqtSignal(object) finished = pyqtSignal(object) - def __init__(self, nam, addr, dest, destpath=None): + def __init__( + self, + nam: QNetworkAccessManager, + addr: str, + dest: str, + 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 @@ -72,15 +80,22 @@ def _finish(self): self.error = True self.finished.emit(self) - def run(self): - self._running = True - req = QNetworkRequest(QUrl(self.addr)) + 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.setMaximumRedirectsAllowed(3) + return req + def run(self): + self._running = True self.start.emit(self) - self._dfile = self._nam.get(req) + 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) diff --git a/src/fa/updater.py b/src/fa/updater.py index eda68ab16..570da0b98 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -202,21 +202,22 @@ def getFeaturedModIdByName(self, technicalName): def requestSimUrlByUid(self, uid): return SimModFiles().requestAndGetSimModUrlByUid(uid) - def fetchFile(self, _file, filegroup): + def fetchFile(self, _file: dict, filegroup: str) -> None: name = _file['name'] targetDir = os.path.join(util.APPDATA_DIR, filegroup, name) - logger.info('Updater: Downloading {}'.format(_file['url'])) + logger.info('Updater: Downloading {}'.format(_file['cacheableUrl'])) downloaded = downloadFile( - url=_file['url'], + url=_file['cacheableUrl'], target_dir=targetDir, name=( 'Downloading FA file : {url}

' - .format(url=_file['url']) + .format(url=_file['cacheableUrl']) ), category='Update', silent=False, + request_params={_file["hmacParameter"]: _file["hmacToken"]}, ) if not downloaded: diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py index 142a7afad..2dbce1fe7 100644 --- a/src/vaults/dialogs.py +++ b/src/vaults/dialogs.py @@ -204,7 +204,14 @@ def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent): return ret -def downloadFile(url, target_dir, name, category, silent): +def downloadFile( + url: str, + target_dir: str, + name: str, + category: str, + silent: bool, + request_params: dict | None = None, +) -> None: """ Basically a copy of downloadVaultAssetNoMsg without zip """ @@ -214,7 +221,7 @@ def downloadFile(url, target_dir, name, category, silent): output = io.BytesIO() capitCat = category[0].upper() + category[1:] - dler = FileDownload(_global_nam, url, output) + dler = FileDownload(_global_nam, url, output, request_params=request_params) ddialog = VaultDownloadDialog( dler, "Downloading {}".format(category), name, silent, ) From 2aada998315ea8bc7d29370c41fb4dcab81758bd Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 11 Apr 2024 21:39:02 +0300 Subject: [PATCH 021/123] Add more download progress info downloaded size and total size --- src/vaults/dialogs.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py index 2dbce1fe7..a75ef6f66 100644 --- a/src/vaults/dialogs.py +++ b/src/vaults/dialogs.py @@ -48,29 +48,32 @@ def __init__(self, dler, title, label, silent=False): progressBar.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self._progress.setBar(progressBar) + self.progress_measure_interaval = 250 self.timer = QtCore.QTimer() - self.timer.setInterval(500) + self.timer.setInterval(self.progress_measure_interaval) self.timer.timeout.connect(self.updateLabel) self.bytes_prev = 0 - def updateLabel(self): - self._progress.setLabelText( - '{label}\n\n{downloaded} MiB ({speed} MiB/s)' - .format( - label=self.label, - downloaded=self.getDownloadProgressMiB(), - speed=self.getDownloadSpeed(), - ), - ) + 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 getDownloadSpeed(self): + 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 * 2 / 1024 / 1024, 2) + return round(bytes_diff * (1000 / self.progress_measure_interaval) / 1024 / 1024, 2) - def getDownloadProgressMiB(self): + 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() From bb82911573d128e3147536171d0e5a161d592fdc Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 11 Apr 2024 22:38:29 +0300 Subject: [PATCH 022/123] Remove deprecated replay filter parameters and use approximation to filter replays by rating --- src/replays/_replayswidget.py | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 2d2be09ae..377a2d4be 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -853,34 +853,11 @@ def prepareFilters( .format(leaderboardId), ) - if minRating and minRating > 0: - if leaderboardId == 1: - filters.append( - 'playerStats.player.globalRating.rating=ge="{}"' - .format(minRating), - ) - elif leaderboardId == 2: - filters.append( - 'playerStats.player.ladder1v1Rating.rating=ge="{}"' - .format(minRating), - ) - else: - filters.append( - 'playerStats.ratingChanges.meanBefore=ge="{}"' - .format(minRating + 300), - ) - else: - if minRating and minRating > 0: - if modListIndex == "ladder1v1": - filters.append( - 'playerStats.player.ladder1v1Rating.rating=ge="{}"' - .format(minRating), - ) - else: - filters.append( - 'playerStats.player.globalRating.rating=ge="{}"' - .format(minRating), - ) + if minRating and minRating > 0: + filters.append( + 'playerStats.ratingChanges.meanBefore=ge="{}"' + .format(minRating + 300), + ) if mapName: filters.append( From c479a2d95748b89736aa249fb326f8b783240b30 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:26:55 +0300 Subject: [PATCH 023/123] Remove QtWebEngine usage from NewsWidget --- res/news/news.ui | 21 +-- res/news/news_page.html | 14 ++ res/news/{news_webview.css => news_style.css} | 4 +- res/news/news_webview_frame.html | 10 -- src/news/_newswidget.py | 151 ++++++++---------- 5 files changed, 89 insertions(+), 111 deletions(-) create mode 100644 res/news/news_page.html rename res/news/{news_webview.css => news_style.css} (89%) delete mode 100644 res/news/news_webview_frame.html diff --git a/res/news/news.ui b/res/news/news.ui index b3021b139..05c95e5fe 100644 --- a/res/news/news.ui +++ b/res/news/news.ui @@ -217,32 +217,13 @@ - - - - 0 - 0 - - - - - about:blank - - - + - - - 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/src/news/_newswidget.py b/src/news/_newswidget.py index ca15cc249..39604c2f5 100644 --- a/src/news/_newswidget.py +++ b/src/news/_newswidget.py @@ -1,12 +1,18 @@ import logging -import webbrowser -from PyQt6 import QtCore from PyQt6 import QtWidgets +from PyQt6.QtCore import QByteArray +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 +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtNetwork import QNetworkRequest import util from config import Settings -from util.qt import ExternalLinkPage from .newsitem import NewsItem from .newsitem import NewsItemDelegate @@ -15,60 +21,33 @@ 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) - - def eventFilter(self, obj, ev): - return ev.type() == QtCore.QEvent.Type.Paint - - def hide(self, widget): - widget.installEventFilter(self) - widget.update() - - def unhide(self, widget): - widget.removeEventFilter(self) - widget.update() - - 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.reply: QNetworkReply | None = None + self.newsManager = NewsManager(self) self.newsItems = [] + self.images = {} # open all links in external browser - self.newsWebView.setPage(ExternalLinkPage(self)) - - # hide webview until loaded to avoid FOUC - self.hider = Hider() - self.hider.hide(self.newsWebView) - self.newsWebView.loadFinished.connect(self.loadFinished) + self.newsTextBrowser.setOpenExternalLinks(True) 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) @@ -80,62 +59,74 @@ def addNews(self, newsPost): newsItem = NewsItem(newsPost, self.newsList) self.newsItems.append(newsItem) - # QtWebEngine has no user CSS support yet, so let's just prepend it to the - # HTML - def _injectCSS(self, body, link, img): - img = ( - '

'.format(img) - ) - body = body + '
' - link = ( - '
Open in your Web browser
'.format(link) - ) - css = ''.format(self.CSS) - return css + img + body + link - - def updateNews(self): - self.hider.hide(self.newsWebView) + def updateNews(self) -> None: + self.hider.hide(self.newsTextBrowser) self.newsItems = [] self.newsList.clear() self.newsManager.WpApi.download() - def itemChanged(self, current, previous): - if current is not None: - if current.newsPost['external_link'] == '': - link = current.newsPost['link'] - else: - link = current.newsPost['external_link'] - self.newsWebView.page().setHtml( - self.HTML.format( - title=current.newsPost['title'], - content=self._injectCSS( - current.newsPost['excerpt'], link, - current.newsPost['img_url'], - ), - ), - ) - - def linkClicked(self, url): - webbrowser.open(url.toString()) - - def loadFinished(self, ok): - self.hider.unhide(self.newsWebView) - self.newsWebView.loadFinished.disconnect(self.loadFinished) + def download_image(self, img_url: QUrl) -> None: + request = QNetworkRequest(img_url) + self.reply = self.nam.get(request) + self.reply.finished.connect(self.item_image_downloaded) + + def add_image_resource(self, img_url: QUrl, image_data: QByteArray) -> None: + img = QImage() + img.loadFromData(image_data) + scaled = img.scaled(QSize(900, 500)) + + self.images[img_url] = scaled + self.newsTextBrowser.document().addResource( + QTextDocument.ResourceType.ImageResource, + img_url, + scaled, + ) + + def item_image_downloaded(self) -> None: + if self.reply.error() is not self.reply.NetworkError.NoError: + return + self.add_image_resource(self.reply.request().url(), self.reply.readAll()) + self.show_newspage() + + def itemChanged(self, current: NewsItem | None, previous: NewsItem | None) -> None: + if current is None: + return + url = QUrl(current.newsPost["img_url"]) + if url in self.images: + self.show_newspage() + else: + self.download_image(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'] + + content = current.newsPost["excerpt"].strip().removeprefix("

").removesuffix("

") + html = self.HTML.format( + style=self.CSS, + title=current.newsPost['title'], + content=content, + img_source=current.newsPost["img_url"], + 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): + def showEditToolTip(self) -> None: """ Default tooltips are too slow and disappear when user starts typing """ widget = self.hideNewsEdit position = widget.mapToGlobal( - QtCore.QPoint(0 + widget.width(), 0 - widget.height() / 2), + QPoint(0 + widget.width(), 0 - widget.height() / 2), ) QtWidgets.QToolTip.showText( position, From 3f8c5dd6fcb1445491d2c879af6c066be2536796 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 15:50:15 +0300 Subject: [PATCH 024/123] Remove QtWebEngine from UnitDB tab just open those urls in Web browser --- res/unitdb/unitdb.ui | 42 ++++++------ src/client/_clientwindow.py | 4 +- src/config/__init__.py | 1 - src/unitdb/unitdbtab.py | 133 ++++-------------------------------- 4 files changed, 33 insertions(+), 147 deletions(-) diff --git a/res/unitdb/unitdb.ui b/res/unitdb/unitdb.ui index 2c8fd5118..03c2f5156 100644 --- a/res/unitdb/unitdb.ui +++ b/res/unitdb/unitdb.ui @@ -25,6 +25,12 @@ + + + 0 + 0 + + 0 @@ -34,7 +40,7 @@ 16777215 - 30 + 100 @@ -46,6 +52,12 @@ + + + 0 + 0 + + 0 @@ -59,6 +71,12 @@ + + + 0 + 0 + + 0 @@ -73,30 +91,8 @@ - - - - - 0 - 11 - - - - - about:blank - - - - - - - QWebEngineView - QWidget -
QtWebEngineWidgets/QWebEngineView
-
-
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 691753ed8..394409ead 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -74,7 +74,7 @@ from stats import StatsWidget from ui.busy_widget import BusyWidget from ui.status_logo import StatusLogo -from unitdb import unitdbtab +from unitdb.unitdbtab import UnitDBTab from updater import ClientUpdateTools from vaults.mapvault.mapvault import MapVault from vaults.modvault.modvault import ModVault @@ -816,7 +816,7 @@ def setup(self): self, self.gameset, self.players, self.me, ) - self._unitdb = unitdbtab.build_db_tab(config.UNITDB_CONFIG_FILE) + self._unitdb = UnitDBTab() # TODO: some day when the tabs only do UI we'll have all this in the # .ui file diff --git a/src/config/__init__.py b/src/config/__init__.py index c4e653daa..08995b261 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -33,7 +33,6 @@ _unpersisted_settings = {} CONFIG_PATH = os.path.dirname(_settings.fileName()) -UNITDB_CONFIG_FILE = os.path.join(CONFIG_PATH, "unitdb.conf") class Settings: diff --git a/src/unitdb/unitdbtab.py b/src/unitdb/unitdbtab.py index d684a27cd..580c86580 100644 --- a/src/unitdb/unitdbtab.py +++ b/src/unitdb/unitdbtab.py @@ -1,138 +1,29 @@ -import logging - -from PyQt6.QtCore import QObject from PyQt6.QtCore import QUrl -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtNetwork import QNetworkCookie -from PyQt6.QtWebEngineCore import QWebEnginePage -from PyQt6.QtWebEngineCore import QWebEngineProfile +from PyQt6.QtGui import QDesktopServices import util from config import Settings -from ui.busy_widget import BusyWidget - -logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("unitdb/unitdb.ui") -class UnitDbView(FormClass, BaseClass, BusyWidget): - entered = pyqtSignal() - - def __init__(self): +class UnitDbView(FormClass, BaseClass): + def __init__(self) -> None: super(BaseClass, self).__init__() - BusyWidget.__init__(self) self.setupUi(self) - # Isolate profile so we only grab cookies from unitdb - self._page_profile = QWebEngineProfile() - self.unitdbWebView.setPage(QWebEnginePage(self._page_profile)) - - def busy_entered(self): - self.entered.emit() - class UnitDBTab: - def __init__(self, db_widget, cookie_store): - self.db_widget = db_widget - self._cookie_store = cookie_store - self._db_url = Settings.get("UNITDB_URL") - self._db_url_alt = Settings.get("UNITDB_SPOOKY_URL") - self._current_cookie = CurrentCookie( - self._qt_cookie_store, b"unitDB-settings", - ) - self._current_cookie.new_cookie.connect(self._new_cookie) + 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.alternativeDB = Settings.get( - 'unitDB/alternative', type=bool, default=False, - ) - self._first_entered = True - self.db_widget.entered.connect(self.entered) self.db_widget.fafDbButton.pressed.connect(self.open_default_tab) - self.db_widget.spookyDbButton.pressed.connect( - self.open_alternative_tab, - ) - - @property - def _db_view(self): - return self.db_widget.unitdbWebView - - @property - def _qt_cookie_store(self): - return self._db_view.page().profile().cookieStore() - - def _load_settings(self): - qt_store = self._qt_cookie_store - try: - for cookie in self._cookie_store.load_cookie(): - qt_store.setCookie(cookie) - except (IOError, FileNotFoundError) as e: - logger.warning("Failed to load unitdb settings: {}".format(e)) - - def _save_settings(self): - self._cookie_store.save_cookie(self._current_cookie.cookie) - - def _new_cookie(self): - try: - self._save_settings() - except IOError as e: - logger.warning("Failed to save unitdb settings: {}".format(e)) - - def entered(self): - if self._first_entered: - self._first_entered = False - self._load_settings() - - if self.alternativeDB: - self._db_view.setUrl(QUrl(self._db_url_alt)) - else: - self._db_view.setUrl(QUrl(self._db_url)) - - def open_default_tab(self): - if self.alternativeDB: - self.alternativeDB = False - Settings.set('unitDB/alternative', False) - self._db_view.setUrl(QUrl(self._db_url)) - - def open_alternative_tab(self): - if not self.alternativeDB: - self.alternativeDB = True - Settings.set('unitDB/alternative', True) - self._db_view.setUrl(QUrl(self._db_url_alt)) - - -class UnitDBCookieStorage: - def __init__(self, store_file): - self._store_file = store_file - - def load_cookie(self): - with open(self._store_file, 'rb+') as store: - cookies = store.read() - return QNetworkCookie.parseCookies(cookies) - - def save_cookie(self, cookie): - with open(self._store_file, 'wb+') as store: - store.write(cookie + b'\n' if cookie is not None else b'') - - -class CurrentCookie(QObject): - new_cookie = pyqtSignal() - - def __init__(self, source, filter_bytes=None): - QObject.__init__(self) - self._source = source - self._filter_bytes = filter_bytes - self.cookie = None - self._source.cookieAdded.connect(self._cookie_added) - - def _cookie_added(self, cookie): - raw = cookie.toRawForm() - if self._filter_bytes is None or self._filter_bytes in raw: - self.cookie = raw - self.new_cookie.emit() + self.db_widget.spookyDbButton.pressed.connect(self.open_alternative_tab) + def open_default_tab(self) -> None: + QDesktopServices.openUrl(self._db_url) -def build_db_tab(store_file): - db_view = UnitDbView() - storage = UnitDBCookieStorage(store_file) - return UnitDBTab(db_view, storage) + def open_alternative_tab(self) -> None: + QDesktopServices.openUrl(self._db_url_alt) From d9b9f4fea2b1bd437aaa9f3dd177d4de581e13a0 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 16:20:18 +0300 Subject: [PATCH 025/123] Remove QtWebEngine from stats widget --- res/stats/stats.ui | 6 ------ src/stats/_statswidget.py | 32 -------------------------------- 2 files changed, 38 deletions(-) diff --git a/res/stats/stats.ui b/res/stats/stats.ui index e5799a13d..5aea04150 100644 --- a/res/stats/stats.ui +++ b/res/stats/stats.ui @@ -42,12 +42,6 @@
- - - Website - - - Ladder diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py index 570b0de04..b8f7f494e 100644 --- a/src/stats/_statswidget.py +++ b/src/stats/_statswidget.py @@ -2,14 +2,11 @@ import time from PyQt6 import QtCore -from PyQt6 import QtWebEngineCore -from PyQt6 import QtWebEngineWidgets from PyQt6 import QtWidgets import util from api.stats_api import LeaderboardApiConnector from ui.busy_widget import BusyWidget -from util.qt import injectWebviewCSS from .leaderboard_widget import LeaderboardWidget @@ -34,15 +31,8 @@ def __init__(self, client): self.client.lobby_info.statsInfo.connect(self.processStatsInfos) - self.webview = QtWebEngineWidgets.QWebEngineView() - self.webpage = WebEnginePage() - self.webpage.profile().setHttpUserAgent("FAF Client") - - self.websiteTab.layout().addWidget(self.webview) - self.selected_player = None self.selected_player_loaded = False - self.webview.loadFinished.connect(self.webview.show) self.leagues.currentChanged.connect(self.leagueUpdate) self.currentChanged.connect(self.busy_entered) self.pagesDivisions = {} @@ -65,10 +55,6 @@ def __init__(self, client): self.load_stylesheet() # setup other tabs - self.webpage.setUrl( - QtCore.QUrl("https://faforever.com/competitive/leaderboards/1v1"), - ) - self.webview.setPage(self.webpage) self.apiConnector = LeaderboardApiConnector(self.client.lobby_dispatch) self.apiConnector.requestData(dict(sort="id")) @@ -329,27 +315,9 @@ def processStatsInfos(self, message): self.leaderboardsTabChanged, ) - def _injectCSS(self): - if util.THEME.themeurl("ladder/style.css"): - injectWebviewCSS( - self.webview.page(), - util.THEME.readstylesheet("ladder/style.css"), - ) - @QtCore.pyqtSlot() def busy_entered(self): if self.currentIndex() == self.indexOf(self.leaderboardsTab): self.leaderboards.currentChanged.emit( self.leaderboards.currentIndex(), ) - - -class WebEnginePage(QtWebEngineCore.QWebEnginePage): - def acceptNavigationRequest(self, url, type, isMainFrame): - if ( - url.url().startswith("https://faforever.com/competitive/") - or url.url().startswith("https://challonge.com/") - ): - return True - else: - return False From 22f6fc3ed2a8fb44739523c31fd99d81ba331752 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 16:22:35 +0300 Subject: [PATCH 026/123] Remove QtWebEngine from tournaments they (tournaments) won't come back anyway --- src/tourneys/tourneyitem.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/tourneys/tourneyitem.py b/src/tourneys/tourneyitem.py index 497823740..abeb4b552 100644 --- a/src/tourneys/tourneyitem.py +++ b/src/tourneys/tourneyitem.py @@ -1,7 +1,5 @@ from PyQt6 import QtCore from PyQt6 import QtGui -from PyQt6 import QtWebEngineCore -from PyQt6 import QtWebEngineWidgets from PyQt6 import QtWidgets import util @@ -47,17 +45,6 @@ def sizeHint(self, option, index, *args, **kwargs): ) -class QWebPageChrome(QtWebEngineCore.QWebEnginePage): - def __init__(self, *args, **kwargs): - QtWebEngineCore.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" - ) - - class TourneyItem(QtWidgets.QListWidgetItem): FORMATTER_SWISS_OPEN = str( util.THEME.readfile("tournaments/formatters/open.qthtml"), @@ -98,11 +85,9 @@ 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: From 2f23da5a2dfd5347505a846f31d2fa06b7dfe852 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 16:30:57 +0300 Subject: [PATCH 027/123] Remove leftovers of QtWebEngine appearance --- setup.py | 3 -- src/__main__.py | 3 -- src/util/qt.py | 35 ------------------------ tests/unit_tests/client/test_updating.py | 1 - 4 files changed, 42 deletions(-) diff --git a/setup.py b/setup.py index ea8c3dfb4..9f72ffbcc 100644 --- a/setup.py +++ b/setup.py @@ -104,11 +104,8 @@ def get_jsonschema_includes(): 'audio', '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/ice-adapter', 'lib/ice-adapter'), ('lib/qt.conf', 'qt.conf'), diff --git a/src/__main__.py b/src/__main__.py index 5481613cc..8e3d3d06a 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -9,9 +9,6 @@ import sys from types import TracebackType -# According to PyQt5 docs we need to import QtWebEngineWidgets before we create -# QApplication -from PyQt6 import QtWebEngineWidgets # noqa: F401 from PyQt6 import uic from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QApplication diff --git a/src/util/qt.py b/src/util/qt.py index 51ce981ec..b0373130c 100644 --- a/src/util/qt.py +++ b/src/util/qt.py @@ -1,40 +1,5 @@ import types -from PyQt6.QtGui import QDesktopServices -from PyQt6.QtWebEngineCore import QWebEnginePage - - -class ExternalLinkPage(QWebEnginePage): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.linkHovered.connect(self.saveHoveredLink) - self.linkUnderCursor = "" - - def acceptNavigationRequest(self, url, navtype, isMainFrame): - if navtype == QWebEnginePage.NavigationType.NavigationTypeLinkClicked: - if url.toString() == self.linkUnderCursor: - QDesktopServices.openUrl(url) - return False - return True - - def saveHoveredLink(self, url): - self.linkUnderCursor = url - - -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) - def monkeypatch_method(obj, name, fn): old_fn = getattr(obj, name) diff --git a/tests/unit_tests/client/test_updating.py b/tests/unit_tests/client/test_updating.py index 1e3eff129..a0c97e64c 100644 --- a/tests/unit_tests/client/test_updating.py +++ b/tests/unit_tests/client/test_updating.py @@ -1,5 +1,4 @@ import pytest -from PyQt6 import QtWebEngineWidgets # noqa: F401 import config From 4b70abe6999d1d163142f553b6c115657cf58f5a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 22:31:11 +0300 Subject: [PATCH 028/123] Fix type annotation of FileDownload's dest attribute --- src/downloadManager/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 4b38191f9..693f2b9b1 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -30,7 +30,7 @@ def __init__( self, nam: QNetworkAccessManager, addr: str, - dest: str, + dest: QtCore.QIODevice, destpath: str | None = None, request_params: dict | None = None, ) -> None: From 7b96ab94f4ad3d8e8538f2145e9d7f114a2fd75f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 22:36:22 +0300 Subject: [PATCH 029/123] ClientWindow: Refer to the screen the widget is on when manipulating geometry by using QWidget.screen instead of QApplication.primaryScreen --- src/client/_clientwindow.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 394409ead..8b247edaf 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -571,9 +571,7 @@ def show_max_restore(self): else: self.is_window_maximized = True self.current_geometry = self.geometry() - self.setGeometry( - QtWidgets.QApplication.primaryScreen().availableGeometry(), - ) + self.setGeometry(self.screen().availableGeometry()) def mouseDoubleClickEvent(self, event): self.show_max_restore() @@ -607,8 +605,7 @@ def mouseMoveEvent(self, event): self.resize_widget(event.globalPosition()) elif self.moving and self.offset is not None: - desktop = QtWidgets.QApplication.primaryScreen().availableGeometry() - # desktop = QtWidgets.QDesktopWidget().availableGeometry(self) + desktop = self.screen().availableGeometry() if event.globalPosition().y() == 0: self.rubber_band.setGeometry(desktop) self.rubber_band.show() From 43294063b370841b136cd6dd86d1b22732decc0c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 22:42:04 +0300 Subject: [PATCH 030/123] Restore maximized window correctly apparently there is a bug in Qt, and restoring geometry of maximized window results in non-maximized one, so we additionaly store 'maximized' flag --- src/client/_clientwindow.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 8b247edaf..23afd4f30 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1359,6 +1359,8 @@ def checkPlayerAliases(self): def saveWindow(self): util.settings.beginGroup("window") util.settings.setValue("geometry", self.saveGeometry()) + if self.is_window_maximized: + util.settings.setValue("maximized", True) util.settings.endGroup() def show_autojoin_settings_dialog(self): @@ -1459,12 +1461,14 @@ def load_settings(self): # 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", False) util.settings.endGroup() + if maximized: + self.setGeometry(self.screen().availableGeometry()) + elif geometry: + self.restoreGeometry(geometry) def load_chat(self): cc = self._chat_config From e7d01de1c15a775a40bd35b507ad532e7a84226a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Apr 2024 22:45:17 +0300 Subject: [PATCH 031/123] Fix existing tests --- tests/fa/test_featured.py | 11 ++++++----- tests/fa/test_updater.py | 6 +++--- tests/unit_tests/themes/test_theme.py | 2 +- tests/unit_tests/themes/test_themeset.py | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py index c6017b87c..3e83739ed 100644 --- a/tests/fa/test_featured.py +++ b/tests/fa/test_featured.py @@ -1,6 +1,7 @@ __author__ = 'Thygrrr' -import collections + +from typing import Callable import pytest from PyQt6 import QtCore @@ -46,7 +47,7 @@ def test_updater_has_progress_bar_mod_progress(application): def test_updater_has_method_append_log(application): assert isinstance( updater.UpdaterProgressDialog(None).appendLog, - collections.Callable, + Callable, ) @@ -57,7 +58,7 @@ def test_updater_append_log_accepts_string(application): def test_updater_has_method_add_watch(application): assert isinstance( updater.UpdaterProgressDialog(None).addWatch, - collections.Callable, + Callable, ) @@ -94,7 +95,7 @@ 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( @@ -114,4 +115,4 @@ def test_updater_does_not_hide_and_accept_before_all_watches_are_finished( application.processEvents() assert u.isVisible() - assert not u.result() == QtWidgets.QDialog.Accepted + assert not u.result() == QtWidgets.QDialog.DialogCode.Accepted diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py index 7bd0e61ed..c8bd8f20f 100644 --- a/tests/fa/test_updater.py +++ b/tests/fa/test_updater.py @@ -1,6 +1,6 @@ __author__ = 'Thygrrr' -import collections +from typing import Callable import pytest from PyQt6 import QtCore @@ -46,7 +46,7 @@ def test_updater_has_progress_bar_mod_progress(application): def test_updater_has_method_append_log(application): assert isinstance( updater.UpdaterProgressDialog(None).appendLog, - collections.Callable, + Callable, ) @@ -57,7 +57,7 @@ def test_updater_append_log_accepts_string(application): def test_updater_has_method_add_watch(application): assert isinstance( updater.UpdaterProgressDialog(None).addWatch, - collections.Callable, + Callable, ) diff --git a/tests/unit_tests/themes/test_theme.py b/tests/unit_tests/themes/test_theme.py index a2a39cdc8..0988cdb06 100644 --- a/tests/unit_tests/themes/test_theme.py +++ b/tests/unit_tests/themes/test_theme.py @@ -70,7 +70,7 @@ def test_version_correctly_read(tmpdir): def test_pixmap_cache_caches(tmpdir, mocker): - mocker.patch('PyQt5.QtGui.QPixmap', side_effect=[1, 2]) + mocker.patch('PyQt6.QtGui.QPixmap', side_effect=[1, 2]) themedir = tmpdir.mkdir("theme") themedir.join("file").write("content") themedir.join("second_file").write("content") diff --git a/tests/unit_tests/themes/test_themeset.py b/tests/unit_tests/themes/test_themeset.py index 3b45f656f..c761dcc03 100644 --- a/tests/unit_tests/themes/test_themeset.py +++ b/tests/unit_tests/themes/test_themeset.py @@ -154,8 +154,8 @@ def test_loadTheme(mocker): 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) @@ -202,8 +202,8 @@ def test_returns_when_not_found(mocker): 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) From 29314182b142f55ba2bf5b19e87d14d3cf9aa34c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 14 Apr 2024 18:54:35 +0300 Subject: [PATCH 032/123] ircconnection: Remove the ssl.wrap_socket wrapper Remove the ssl.wrap_socket() function usage, it was deprecated in Python 3.7: instead, create an ssl.SSLContext object and call its ssl.SSLContext.wrap_socket method --- src/chat/ircconnection.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py index d91deabd4..7bdcec821 100644 --- a/src/chat/ircconnection.py +++ b/src/chat/ircconnection.py @@ -84,19 +84,15 @@ def __init__(self): class IrcConnection(IrcSignals, irc.client.SimpleIRCClient): token_received = pyqtSignal(str) - def __init__(self, host: int, port: int, use_ssl: bool) -> None: + def __init__(self, host: int, port: int) -> None: IrcSignals.__init__(self) irc.client.SimpleIRCClient.__init__(self) self.host = host self.port = port - self.use_ssl = use_ssl self.api_accessor = UserApiAccessor() self.token_received.connect(self.on_token_received) - if self.use_ssl: - self.factory = irc.connection.Factory(wrapper=ssl.wrap_socket) - else: - self.factory = irc.connection.Factory() + self.factory = self.create_connection_factory(self.port_uses_ssl(port)) self._password = None self._nick = None @@ -109,19 +105,26 @@ def __init__(self, host: int, port: int, use_ssl: bool) -> None: self._connected = False @classmethod - def build(cls, settings, use_ssl=True, **kwargs): - port = settings.get('chat/port', 6697 if use_ssl else 6667, int) + def build(cls, settings, **kwargs): + port = settings.get('chat/port', 6697, int) host = settings.get('chat/host', 'irc.' + config.defaults['host'], str) - return cls(host, port, use_ssl) + return cls(host, port) + + @staticmethod + def port_uses_ssl(port: int) -> bool: + return port == 6697 + + @staticmethod + def create_connection_factory(use_ssl: bool) -> irc.connection.Factory: + if use_ssl: + # unverified because certificate is self-signed + context = ssl._create_unverified_context() + return irc.connection.Factory(wrapper=context.wrap_socket) + return irc.connection.Factory() def setPortFromConfig(self): self.port = config.Settings.get('chat/port', type=int) - if self.port == 6697: - self.use_ssl = True - self.factory = irc.connection.Factory(wrapper=ssl.wrap_socket) - else: - self.use_ssl = False - self.factory = irc.connection.Factory() + self.factory = self.create_connection_factory(self.port_uses_ssl(self.port)) def setHostFromConfig(self): self.host = config.Settings.get('chat/host', type=str) @@ -147,11 +150,7 @@ 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( - "Connecting to IRC at: {}:{}. TLS: {}".format( - self.host, self.port, self.use_ssl, - ), - ) + logger.info(f"Connecting to IRC at: {self.host}:{self.port}") self._nick = nick self._username = username From d93219037dd9bfeb146b6d4896fb9c0fa355e8b9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:30:17 +0300 Subject: [PATCH 033/123] Adapt QMessageBox's buttons syntax to PyQt6 --- src/client/_clientwindow.py | 12 ++++++------ src/fa/mods.py | 10 +++++----- src/fa/replayserver.py | 6 +++--- src/games/_gameswidget.py | 8 ++++---- src/mapGenerator/mapgenManager.py | 10 +++++----- src/replays/_replayswidget.py | 6 +++--- src/tourneys/_tournamentswidget.py | 8 ++++---- src/util/__init__.py | 8 ++++---- src/util/theme.py | 8 ++++---- src/vaults/mapvault/mapvault.py | 14 +++++++------- src/vaults/mapvault/mapwidget.py | 6 +++--- src/vaults/modvault/modvault.py | 8 ++++---- src/vaults/modvault/modwidget.py | 6 +++--- src/vaults/modvault/utils.py | 6 +++--- 14 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 23afd4f30..ccba3c462 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1005,10 +1005,10 @@ def closeEvent(self, event): "Seems like you still have Forged Alliance running!" "
Close anyway?" ), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) - if result == QtWidgets.QMessageBox.No: + if result == QtWidgets.QMessageBox.StandardButton.No: event.ignore() return @@ -1280,10 +1280,10 @@ def clearSettings(self): "Clear Settings", "Are you sure you wish to clear all settings, " "login info, etc. used by this program?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) - if result == QtWidgets.QMessageBox.Yes: + if result == QtWidgets.QMessageBox.StandardButton.Yes: util.settings.clear() util.settings.sync() QtWidgets.QMessageBox.information( diff --git a/src/fa/mods.py b/src/fa/mods.py index f531849c6..d1b2a84c8 100644 --- a/src/fa/mods.py +++ b/src/fa/mods.py @@ -36,14 +36,14 @@ def checkMods(mods): # mods is a dictionary of uid-name pairs "downloaded automatically in the future", ) msgbox.setStandardButtons( - QtWidgets.QMessageBox.Yes - | QtWidgets.QMessageBox.YesToAll - | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.YesToAll + | QtWidgets.QMessageBox.StandardButton.No, ) result = msgbox.exec() - if result == QtWidgets.QMessageBox.No: + 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: diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py index 4bfbc9411..d315e9cb5 100644 --- a/src/fa/replayserver.py +++ b/src/fa/replayserver.py @@ -215,10 +215,10 @@ def doListen(self) -> bool: "likely)
  • another program is listening on port " "{}
  • ".format(self.serverPort()) ), - QtWidgets.QMessageBox.Retry, - QtWidgets.QMessageBox.Abort, + QtWidgets.QMessageBox.StandardButton.Retry, + QtWidgets.QMessageBox.StandardButton.Abort, ) - if answer == QtWidgets.QMessageBox.Abort: + if answer == QtWidgets.QMessageBox.StandardButton.Abort: return False return True diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index 12f996216..72283fe59 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -367,9 +367,9 @@ def kickPlayerFromParty(self, playerId): result = QtWidgets.QMessageBox.question( self, "Kick Player: {}".format(login), "Are you sure you want to kick {} from party?".format(login), - QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No, ) - if result == QtWidgets.QMessageBox.Yes: + if result == QtWidgets.QMessageBox.StandardButton.Yes: self.stopSearch() msg = { 'command': 'kick_player_from_party', @@ -380,9 +380,9 @@ def kickPlayerFromParty(self, playerId): def leave_party(self): result = QtWidgets.QMessageBox.question( self, "Leaving Party", "Are you sure you want to leave party?", - QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No, ) - if result == QtWidgets.QMessageBox.Yes: + if result == QtWidgets.QMessageBox.StandardButton.Yes: msg = { 'command': 'leave_party', } diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py index 752fa0f72..1fd325521 100644 --- a/src/mapGenerator/mapgenManager.py +++ b/src/mapGenerator/mapgenManager.py @@ -75,14 +75,14 @@ def generateMap(self, mapname=None, args=None): "time.", ) msgbox.setStandardButtons( - QtWidgets.QMessageBox.Yes - | QtWidgets.QMessageBox.YesToAll - | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.YesToAll + | QtWidgets.QMessageBox.StandardButton.No, ) result = msgbox.exec() - if result == QtWidgets.QMessageBox.No: + if result == QtWidgets.QMessageBox.StandardButton.No: return False - elif result == QtWidgets.QMessageBox.YesToAll: + elif result == QtWidgets.QMessageBox.StandardButton.YesToAll: Settings.set('mapGenerator/autostart', True) mapsFolder = getUserMapsFolder() diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 377a2d4be..50acd3d57 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -964,9 +964,9 @@ def onlineTreeDoubleClicked(self, item): client.instance, "Live Game ended", "Would you like to watch the replay from the vault?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, - ) == QtWidgets.QMessageBox.Yes: + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, + ) == QtWidgets.QMessageBox.StandardButton.Yes: req = QNetworkRequest(QtCore.QUrl(item.url)) self.replayDownload.get(req) diff --git a/src/tourneys/_tournamentswidget.py b/src/tourneys/_tournamentswidget.py index 1f653d1b6..96718dcca 100644 --- a/src/tourneys/_tournamentswidget.py +++ b/src/tourneys/_tournamentswidget.py @@ -64,9 +64,9 @@ def tourneyDoubleClicked(self, item): self.client, "Register", "Do you want to register to this tournament ?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, ) - if reply == QtWidgets.QMessageBox.Yes: + if reply == QtWidgets.QMessageBox.StandardButton.Yes: self.tourneyServer.send( dict( command="add_participant", @@ -80,9 +80,9 @@ def tourneyDoubleClicked(self, item): self.client, "Register", "Do you want to leave this tournament ?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, ) - if reply == QtWidgets.QMessageBox.Yes: + if reply == QtWidgets.QMessageBox.StandardButton.Yes: self.tourneyServer.send( dict( command="remove_participant", diff --git a/src/util/__init__.py b/src/util/__init__.py index 139199884..91b3be673 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -259,13 +259,13 @@ def clearDirectory(directory, confirm=True): "Are you sure you wish to clear the following directory:" "
      {}".format(directory) ), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + 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: diff --git a/src/util/theme.py b/src/util/theme.py index fa0652469..563c6c3e0 100644 --- a/src/util/theme.py +++ b/src/util/theme.py @@ -264,17 +264,17 @@ def theme_changed(): ) b_yes = box.addButton( "Apply this once", - QtWidgets.QMessageBox.YesRole, + QtWidgets.QMessageBox.ButtonRole.YesRole, ) b_always = box.addButton( "Always apply for this FA version", - QtWidgets.QMessageBox.YesRole, + QtWidgets.QMessageBox.ButtonRole.YesRole, ) b_default = box.addButton( "Use default theme", - QtWidgets.QMessageBox.NoRole, + QtWidgets.QMessageBox.ButtonRole.NoRole, ) - b_no = box.addButton("Abort", QtWidgets.QMessageBox.NoRole) + b_no = box.addButton("Abort", QtWidgets.QMessageBox.ButtonRole.NoRole) box.exec() result = box.clickedButton() diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py index db8dd12a6..aed416f2b 100644 --- a/src/vaults/mapvault/mapvault.py +++ b/src/vaults/mapvault/mapvault.py @@ -162,12 +162,12 @@ def uploadMap(self): "{}\nDo you want to upload the map?" .format(scenariolua.errorMsg) ), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) else: - uploadmap = QtWidgets.QMessageBox.Yes - if uploadmap == QtWidgets.QMessageBox.Yes: + uploadmap = QtWidgets.QMessageBox.StandardButton.Yes + if uploadmap == QtWidgets.QMessageBox.StandardButton.Yes: savelua = luaparser.luaParser( os.path.join(mapDir, maps.getSaveFile(mapDir)), ) @@ -241,10 +241,10 @@ def downloadMap(self, link): "Seems like you already have that map!
    Would you " "like to see it?" ), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) - if show == QtWidgets.QMessageBox.Yes: + if show == QtWidgets.QMessageBox.StandardButton.Yes: util.showDirInFileBrowser(maps.folderForMap(avail_name)) @QtCore.pyqtSlot(str) diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py index b92f11536..4633eaa20 100644 --- a/src/vaults/mapvault/mapwidget.py +++ b/src/vaults/mapvault/mapwidget.py @@ -71,10 +71,10 @@ def download(self): self.parent.client, "Delete Map", "Are you sure you want to delete this map?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) - if show == QtWidgets.QMessageBox.Yes: + if show == QtWidgets.QMessageBox.StandardButton.Yes: self.parent.removeMap(self._map.folderName) self.done(1) diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 620d9069d..7cc1f6ebe 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -154,12 +154,12 @@ def openUploadForm(self): modinfofile.errorMsg + "\nDo you want to upload the mod?" ), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) else: - uploadmod = QtWidgets.QMessageBox.Yes - if uploadmod == QtWidgets.QMessageBox.Yes: + uploadmod = QtWidgets.QMessageBox.StandardButton.Yes + if uploadmod == QtWidgets.QMessageBox.StandardButton.Yes: modinfo = utils.ModInfo(**modinfo) modinfo.setFolder(os.path.split(modDir)[1]) modinfo.update() diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py index 6ad0d2dd3..796dd600e 100644 --- a/src/vaults/modvault/modwidget.py +++ b/src/vaults/modvault/modwidget.py @@ -90,10 +90,10 @@ def download(self): self.parent.client, "Delete Mod", "Are you sure you want to delete this mod?", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) - if show == QtWidgets.QMessageBox.Yes: + if show == QtWidgets.QMessageBox.StandardButton.Yes: self.parent.removeMod(self.mod) self.done(1) diff --git a/src/vaults/modvault/utils.py b/src/vaults/modvault/utils.py index 15c8e37c9..886335817 100644 --- a/src/vaults/modvault/utils.py +++ b/src/vaults/modvault/utils.py @@ -438,10 +438,10 @@ def handle_exist(path, modname): "already exists and contains {}. Do you want to " "overwrite this mod?" ).format(modpath, oldmod.totalname), - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.StandardButton.Yes, + QtWidgets.QMessageBox.StandardButton.No, ) - if result == QtWidgets.QMessageBox.No: + if result == QtWidgets.QMessageBox.StandardButton.No: return False removeMod(oldmod) return True From 776475f0069700cdad4cbfb500fd800fae87cd08 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:22:06 +0300 Subject: [PATCH 034/123] Refactor downloadManager a little * create a class for general-purpose downloading, not only previews * make preview downloaders to be subclasses of general-purpose downloader * take map preivew url from settings instead of doing the 'update_url_prefix' shenanigans --- src/client/_clientwindow.py | 19 +--- src/config/__init__.py | 10 +- src/config/production.py | 20 +--- src/downloadManager/__init__.py | 188 +++++++++++++++---------------- src/replays/_replayswidget.py | 4 +- src/replays/replayToolbox.py | 6 +- src/replays/replayitem.py | 2 +- src/tutorials/tutorialitem.py | 2 +- src/vaults/mapvault/mapitem.py | 7 +- src/vaults/mapvault/mapwidget.py | 17 +-- src/vaults/modvault/moditem.py | 5 +- src/vaults/vaultitem.py | 2 + 12 files changed, 131 insertions(+), 151 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index ccba3c462..2a1426b05 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -44,9 +44,8 @@ from client.user import UserRelationTrackers from connectivity.ConnectivityDialog import ConnectivityDialog from coop import CoopWidget -from downloadManager import MAP_PREVIEW_ROOT from downloadManager import AvatarDownloader -from downloadManager import PreviewDownloader +from downloadManager import MapSmallPreviewDownloader from fa.factions import Factions from fa.game_runner import GameRunner from fa.game_session import GameSession @@ -236,21 +235,14 @@ def __init__(self, *args, **kwargs): ) self.me.relations = self.user_relations - self.map_downloader = PreviewDownloader( - util.MAP_PREVIEW_SMALL_DIR, - util.MAP_PREVIEW_LARGE_DIR, - MAP_PREVIEW_ROOT, - ) - self.mod_downloader = PreviewDownloader( - util.MOD_PREVIEW_DIR, None, None, - ) + 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.gameset.added.connect(self.fill_in_session_info) @@ -706,7 +698,7 @@ def setup(self): self.game_launcher = build_launcher( self.players, self.me, self, self.gameview_builder, - self.map_downloader, + self.map_preview_downloader, ) self._avatar_widget_builder = AvatarWidget.builder( parent_widget=self, @@ -743,7 +735,7 @@ def setup(self): me=self.me, user_relations=self.user_relations, power_tools=self.power_tools, - map_preview_dler=self.map_downloader, + map_preview_dler=self.map_preview_downloader, avatar_dler=self.avatar_downloader, avatar_widget_builder=self._avatar_widget_builder, alias_viewer=self._alias_viewer, @@ -1560,7 +1552,6 @@ def on_widget_login_data(self, api_changed): self._chatMVC.connection.setPortFromConfig() if api_changed: self.ladder.refreshLeaderboards() - self.map_downloader.update_url_prefix() self.news.updateNews() self.games.refreshMods() diff --git a/src/config/__init__.py b/src/config/__init__.py index 08995b261..258749839 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -216,10 +216,18 @@ def is_beta(): 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(host=Settings.get('host')) + defaults[key] = value.format_map(FormatDefault(host=Settings.get("host"))) if environment == 'production': defaults = production_defaults diff --git a/src/config/production.py b/src/config/production.py index bdd5808bd..8fa983809 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -50,28 +50,18 @@ 'replay_server/port': 15000, 'relay_server/host': 'lobby.{host}', 'relay_server/port': 8000, + 'vault/map_preview_url': 'https://content.{host}/maps/previews/{size}/{name}.png', '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_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' - ), + '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' - ), + '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', diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 693f2b9b1..793a141f2 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -1,13 +1,16 @@ +from __future__ import annotations + import logging import os -import urllib.error -import urllib.parse -import urllib.request +from io import BytesIO -from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets +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 @@ -30,7 +33,7 @@ def __init__( self, nam: QNetworkAccessManager, addr: str, - dest: QtCore.QIODevice, + dest: QFile | BytesIO, destpath: str | None = None, request_params: dict | None = None, ) -> None: @@ -70,13 +73,9 @@ def cancel(self): def _finish(self): # check status code - statusCode = self._dfile.attribute( - QNetworkRequest.Attribute.HttpStatusCodeAttribute, - ) + statusCode = self._dfile.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) if statusCode != 200: - logger.debug( - 'Download failed: {} -> {}'.format(self.addr, statusCode), - ) + logger.debug(f"Download failed: {self.addr} -> {statusCode}") self.error = True self.finished.emit(self) @@ -137,82 +136,80 @@ def succeeded(self): return not self.error and not self.canceled def waitForCompletion(self): - waitFlag = QtCore.QEventLoop.ProcessEventsFlag.WaitForMoreEvents + waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: QtWidgets.QApplication.processEvents(waitFlag) -MAP_PREVIEW_ROOT = "{}/maps/previews/small/" - - -class PreviewDownload(QtCore.QObject): - done = QtCore.pyqtSignal(object, object) +class GeneralDownload(QObject): + done = pyqtSignal(object, object) - def __init__(self, nam, name, url, target_dir, delay_timer=None): - QtCore.QObject.__init__(self) + 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) + def _prepare_dl(self) -> FileDownload: + file_obj = self._get_cachefile(self.name + ".part") + dl = FileDownload(self._nam, self._url, file_obj) 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.OpenModeFlag.WriteOnly) - return img, imgpath + def _get_cachefile(self, name: str) -> QFile: + filepath = os.path.join(self._target_dir, name) + file_obj = QFile(filepath) + file_obj.open(QIODevice.OpenModeFlag.WriteOnly) + return file_obj - 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): + def _finished(self, dl: FileDownload) -> None: dl.dest.close() - logger.debug("Finished download from " + dl.addr) + destpath = dl.dest.fileName() if self.failed(): - logger.debug("Web Preview failed for: {}".format(self.name)) - os.unlink(dl.destpath) - filepath = "games/unknown_map.png" - is_local = True + logger.debug(f"Download failed for: {self.name}") + os.unlink(destpath) 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)) + logger.debug("Finished download from " + dl.addr) + dl.dest.rename(destpath.removesuffix(".part")) + self.done.emit(self, dl.dest.fileName()) def failed(self): return not self._dl.succeeded() -class DownloadRequest(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 @@ -231,86 +228,83 @@ def finished(self, name, result): self.done.emit(name, result) -class PreviewDownloader(QtCore.QObject): +class GeneralDownloader(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, target_dir_large, route): - QtCore.QObject.__init__(self) + def __init__(self, target_dir: str) -> None: + super().__init__() self._nam = QNetworkAccessManager(self) self._target_dir = target_dir - self._target_dir_large = target_dir_large - self._route = route - self._default_url_prefix = None - self._downloads = {} - self._timeouts = DownloadTimeouts( - self.PREVIEW_REDOWNLOAD_TIMEOUT, - self.PREVIEW_DOWN_FAILS_TO_TIMEOUT, - ) - self.update_url_prefix() - - def update_url_prefix(self): - if self._route: - self._default_url_prefix = self._route.format( - Settings.get('content/host'), - ) - - def download_preview(self, name, req, url=None, large=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, large) - - 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, large): + self._downloads: dict[str, GeneralDownload] = {} + 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, large) + self._add_download(name, url) dl = self._downloads[name] req.dl = dl - def _add_download(self, name, url, large): + def _add_download(self, name: str, url: str) -> None: if self._timeouts.on_timeout(name): delay = self._timeouts.timer else: delay = None - - targetDir = self._target_dir - if large: - targetDir = self._target_dir_large - dl = PreviewDownload(self._nam, name, url, targetDir, delay) + dl = GeneralDownload(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: GeneralDownload, 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(GeneralDownloader): + 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) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 50acd3d57..8eb0288ae 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -92,7 +92,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) @@ -408,7 +408,7 @@ def _setup_complete_appearance(self): if icon: self.setIcon(0, icon) else: - dler = client.instance.map_downloader + 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")) diff --git a/src/replays/replayToolbox.py b/src/replays/replayToolbox.py index 5eca2b94c..97d0a7b67 100644 --- a/src/replays/replayToolbox.py +++ b/src/replays/replayToolbox.py @@ -7,6 +7,7 @@ from config import Settings from downloadManager import DownloadRequest +from downloadManager import MapLargePreviewDownloader from util import MAP_PREVIEW_LARGE_DIR logger = logging.getLogger(__name__) @@ -112,6 +113,7 @@ def __init__( 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) @@ -356,11 +358,9 @@ def updateMapPreview(self): preview.setPixmap(pix) preview.currentMap = selectedReplay.mapname else: - self.client.map_downloader.download_preview( + self._map_preview_dler.download_preview( selectedReplay.mapname, self._map_dl_request, - url=selectedReplay.previewUrlLarge, - large=True, ) def _on_map_preview_downloaded(self, mapname, result): diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index f3d614ba6..b2263e5f0 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -229,7 +229,7 @@ def update(self, replay, client): if not self.icon: self.icon = util.THEME.icon("games/unknown_map.png") if self.mapname != "unknown": - self.client.map_downloader.download_preview( + self.client.map_preview_downloader.download_preview( self.mapname, self._map_dl_request, ) diff --git a/src/tutorials/tutorialitem.py b/src/tutorials/tutorialitem.py index 853d76a80..9e6063275 100644 --- a/src/tutorials/tutorialitem.py +++ b/src/tutorials/tutorialitem.py @@ -126,7 +126,7 @@ 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.client.map_preview_downloader.download_preview( self.mapname, self._map_dl_request, ) diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py index 7468a0d69..d57c92003 100644 --- a/src/vaults/mapvault/mapitem.py +++ b/src/vaults/mapvault/mapitem.py @@ -20,6 +20,7 @@ def __init__(self, parent, folderName, *args, **kwargs): self.folderName = folderName self.thumbstrSmall = "" self.thumbnailLarge = "" + self._preview_dler.set_target_dir(util.MAP_PREVIEW_SMALL_DIR) def update(self, item_dict): self.name = maps.getDisplayName(item_dict["folderName"]) @@ -49,8 +50,10 @@ def update(self, item_dict): else: self.setItemIcon("games/unknown_map.png") else: - self.parent.client.map_downloader.download_preview( - self.folderName, self._item_dl_request, self.thumbstrSmall, + self._preview_dler.download( + f"{self.folderName}.png", + self._item_dl_request, + self.thumbstrSmall, ) VaultItem.update(self) diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py index 4633eaa20..a73c86d01 100644 --- a/src/vaults/mapvault/mapwidget.py +++ b/src/vaults/mapvault/mapwidget.py @@ -5,8 +5,9 @@ from PyQt6 import QtGui from PyQt6 import QtWidgets -import downloadManager import util +from downloadManager import DownloadRequest +from downloadManager import MapLargePreviewDownloader from fa import maps from mapGenerator import mapgenUtils @@ -37,11 +38,8 @@ def __init__(self, parent, _map, *args, **kwargs): self.Info.setText("{} Uploaded {}".format(maptext, str(_map.date))) self.Players.setText("Maximum players: {}".format(_map.maxPlayers)) self.Size.setText("Size: {} x {} km".format(_map.width, _map.height)) - self.map_downloader = downloadManager.PreviewDownloader( - util.MAP_PREVIEW_SMALL_DIR, util.MAP_PREVIEW_LARGE_DIR, - downloadManager.MAP_PREVIEW_ROOT, - ) - self._map_dl_request = downloadManager.DownloadRequest() + 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 @@ -90,12 +88,7 @@ def updatePreview(self): util.THEME.pixmap("games/generated_map.png"), ) else: - self.map_downloader.download_preview( - self._map.folderName, - self._map_dl_request, - url=self._map.thumbnailLarge, - large=True, - ) + self._preview_dler.download_preview(self._map.folderName, self._map_dl_request) def _on_preview_downloaded(self, mapname, result): filename, themed = result diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py index d5dc5517b..e3e505a0b 100644 --- a/src/vaults/modvault/moditem.py +++ b/src/vaults/modvault/moditem.py @@ -19,6 +19,7 @@ def __init__(self, parent, uid, *args, **kwargs): self.thumbstr = "" self.isuidmod = False self.uploadedbyuser = False + self._preview_dler.set_target_dir(util.MOD_PREVIEW_DIR) def shouldBeVisible(self): p = self.parent @@ -56,9 +57,7 @@ def update(self, item_dict): if img: self.setItemIcon(img, False) else: - self.parent.client.mod_downloader.download_preview( - name[:-4], self._item_dl_request, self.thumbstr, - ) + self._preview_dler.download(name, self._item_dl_request, self.thumbstr) VaultItem.update(self) diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py index 2405d5103..11c321491 100644 --- a/src/vaults/vaultitem.py +++ b/src/vaults/vaultitem.py @@ -4,6 +4,7 @@ import util from downloadManager import DownloadRequest +from downloadManager import GeneralDownloader class VaultItem(QtWidgets.QListWidgetItem): @@ -29,6 +30,7 @@ def __init__(self, parent, *args, **kwargs): self.link = "" self.setHidden(True) + self._preview_dler = GeneralDownloader(util.CACHE_DIR) self._item_dl_request = DownloadRequest() self._item_dl_request.done.connect(self._on_item_downloaded) From 5394cfcb43571554ad6a62e67e8047ad79c10f35 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:01:00 +0300 Subject: [PATCH 035/123] Refactor downloadManager a little bit more * add classes that manage the process of creating-downloading-closing buffers/files internally, one just needs to specify target directory and filename so they can be reused to download anything * fix sim mod downloading --- src/api/sim_mod_updater.py | 14 ++-- src/config/production.py | 1 + src/downloadManager/__init__.py | 96 ++++++++++++++++++++-- src/fa/game_runner.py | 12 +-- src/fa/maps.py | 23 +++--- src/fa/mods.py | 36 ++++----- src/fa/updater.py | 56 +++++++------ src/mapGenerator/mapgenManager.py | 14 ++-- src/model/game.py | 4 +- src/util/gameurl.py | 4 +- src/vaults/dialogs.py | 130 ++++++++++++------------------ src/vaults/modvault/modvault.py | 4 +- src/vaults/modvault/modwidget.py | 4 +- src/vaults/modvault/utils.py | 14 +--- 14 files changed, 221 insertions(+), 191 deletions(-) diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py index e8cae3158..a38b44456 100644 --- a/src/api/sim_mod_updater.py +++ b/src/api/sim_mod_updater.py @@ -8,16 +8,16 @@ class SimModFiles(DataApiAccessor): def __init__(self) -> None: super().__init__('/data/modVersion') - self.simModUrl = '' + self.mod_url = "" def requestData(self, queryDict: dict) -> None: self.get_by_query(queryDict, self.handleData) - def getUrlFromMessage(self, message): - self.simModUrl = message[0]['downloadUrl'] + def get_url_from_message(self, message: dict) -> str: + self.mod_url = message["data"][0]["downloadUrl"] - def requestAndGetSimModUrlByUid(self, uid: int) -> str: - queryDict = dict(filter='uid=={}'.format(uid)) - self.get_by_query(queryDict, self.getUrlFromMessage) + 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.simModUrl + return self.mod_url diff --git a/src/config/production.py b/src/config/production.py index 8fa983809..dc872ba18 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -51,6 +51,7 @@ 'relay_server/host': 'lobby.{host}', 'relay_server/port': 8000, '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 diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 793a141f2..afc006392 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -2,6 +2,7 @@ import logging import os +import zipfile from io import BytesIO from PyQt6 import QtGui @@ -14,6 +15,7 @@ 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 @@ -21,7 +23,7 @@ logger = logging.getLogger(__name__) -class FileDownload(QObject): +class BaseDownload(QObject): """ A simple async one-shot file downloader. """ @@ -51,7 +53,7 @@ def __init__( self.bytes_total = 0 self.bytes_progress = 0 - self._dfile = None + self._dfile: QNetworkReply | None = None self._reading = False self._running = False @@ -61,6 +63,7 @@ def _stop(self): ran = self._running self._running = False if ran: + self._about_to_finish() self._finish() def _error(self): @@ -71,12 +74,17 @@ def cancel(self): self.canceled = True self._stop() - def _finish(self): + def _handle_status(self) -> None: # check status code statusCode = self._dfile.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) if statusCode != 200: logger.debug(f"Download failed: {self.addr} -> {statusCode}") self.error = True + + def _about_to_finish(self) -> None: + self._handle_status() + + def _finish(self) -> None: self.finished.emit(self) def prepare_request(self) -> QNetworkRequest: @@ -135,12 +143,88 @@ def _readloop(self): def succeeded(self): return not self.error and not self.canceled + def failed(self) -> bool: + return not self.succeeded() + def waitForCompletion(self): waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: QtWidgets.QApplication.processEvents(waitFlag) +class FileDownload(BaseDownload): + def __init__( + self, + target_path: str, + nam: QNetworkAccessManager, + addr: str, + request_params: dict | None = None, + ) -> None: + self._target_path = f"{target_path}.part" + self._output = QFile(target_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(): + logger.debug(f"Download failed for: {self.name}") + os.unlink(self._target_path) + else: + logger.debug(f"Finished download from {self.addr}") + self._output.rename(self._target_path.removesuffix(".part")) + + +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 GeneralDownload(QObject): done = pyqtSignal(object, object) @@ -171,9 +255,9 @@ def _start_download(self) -> None: self._dl = self._prepare_dl() self._dl.run() - def _prepare_dl(self) -> FileDownload: + def _prepare_dl(self) -> BaseDownload: file_obj = self._get_cachefile(self.name + ".part") - dl = FileDownload(self._nam, self._url, file_obj) + dl = BaseDownload(self._nam, self._url, file_obj) dl.finished.connect(self._finished) dl.blocksize = None return dl @@ -190,7 +274,7 @@ def remove_request(self, req: DownloadRequest) -> None: def add_request(self, req: DownloadRequest) -> None: self.requests.add(req) - def _finished(self, dl: FileDownload) -> None: + def _finished(self, dl: BaseDownload) -> None: dl.dest.close() destpath = dl.dest.fileName() if self.failed(): diff --git a/src/fa/game_runner.py b/src/fa/game_runner.py index 0ed860a7f..0ff0f6a60 100644 --- a/src/fa/game_runner.py +++ b/src/fa/game_runner.py @@ -1,9 +1,9 @@ -import json import logging import fa from fa.replay import replay from model.game import GameState +from util.gameurl import GameUrl logger = logging.getLogger(__name__) @@ -29,16 +29,10 @@ def run_game_from_url(self, gurl): elif game.state == GameState.PLAYING: replay(gurl) - def _join_game_from_url(self, gurl): + def _join_game_from_url(self, gurl: GameUrl) -> None: logger.debug("Joining game from URL: " + gurl.to_url().toString()) if fa.instance.available(): - if gurl.mods is None: - add_mods = [] - else: - try: - add_mods = json.loads(gurl.mods) # should be a list - except (json.JSONDecodeError, TypeError): - logger.info("Failed to decode game mods!") + 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/maps.py b/src/fa/maps.py index 1f23cc200..74896e365 100644 --- a/src/fa/maps.py +++ b/src/fa/maps.py @@ -7,10 +7,8 @@ import struct import sys import tempfile -import urllib.error -import urllib.parse -import urllib.request import zipfile +from typing import Callable from PyQt6 import QtCore from PyQt6 import QtGui @@ -26,9 +24,6 @@ logger = logging.getLogger(__name__) route = Settings.get('content/host') -VAULT_PREVIEW_ROOT = "{}/faf/vault/map_previews/small/".format(route) -VAULT_DOWNLOAD_ROOT = "{}/maps/" -VAULT_COUNTER_ROOT = "{}/faf/vault/map_vault/inc_downloads.php".format(route) __exist_maps = None @@ -63,12 +58,12 @@ 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(name + ".zip") + return Settings.get("vault/map_download_url").format(name=name) def link2name(link): @@ -462,11 +457,15 @@ def downloadMap(name, silent=False): return True -def _doDownloadMap(name, link, silent): - url = VAULT_DOWNLOAD_ROOT.format(Settings.get('content/host')) + link - logger.debug("Getting map from: " + url) +def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None]]: + logger.debug(f"Getting map from: {link}") return downloadVaultAssetNoMsg( - url, getUserMapsFolder(), lambda m, d: True, name, "map", silent, + url=link, + target_dir=getUserMapsFolder(), + exist_handler=lambda m, d: True, + name=name, + category="map", + silent=silent, ) diff --git a/src/fa/mods.py b/src/fa/mods.py index d1b2a84c8..ff367ea60 100644 --- a/src/fa/mods.py +++ b/src/fa/mods.py @@ -4,27 +4,25 @@ import config import fa -import vaults.modvault.utils +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 {}".format(", ".join(mods))) - to_download = [] - inst = vaults.modvault.utils.getInstalledMods() - uids = [mod.uid for mod in inst] - for uid in mods: - if uid not in uids: - to_download.append(uid) + + 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( @@ -46,32 +44,26 @@ def checkMods(mods): # mods is a dictionary of uid-name pairs elif result == QtWidgets.QMessageBox.StandardButton.YesToAll: config.Settings.set('mods/autodownload', True) - for uid in to_download: + for item in to_download.items(): # Spawn an update for the required mod - updater = fa.updater.Updater(uid, sim=True) + updater = fa.updater.Updater("sim", sim_mod=item) result = updater.run() if result != fa.updater.Updater.RESULT_SUCCESS: - logger.warning("Failure getting {}: {}".format(uid, mods[uid])) + logger.warning(f"Failure getting {item}") return False actual_mods = [] - inst = vaults.modvault.utils.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", - ( - "{} was apparently not installed correctly. Please check " - "this.".format(mods[uid]) - ), + f"{name} was apparently not installed correctly. Please check this.", ) return actual_mods.append(uids[uid]) - if not vaults.modvault.utils.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/updater.py b/src/fa/updater.py index 570da0b98..77f2d7d9b 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -23,7 +23,7 @@ from api.featured_mod_updater import FeaturedModId from api.sim_mod_updater import SimModFiles from config import Settings -from vaults.dialogs import downloadFile +from vaults.dialogs import download_file from vaults.modvault import utils logger = logging.getLogger(__name__) @@ -111,11 +111,11 @@ class Updater(QtCore.QObject): def __init__( self, - featured_mod, - version=None, - modversions=None, - sim=False, - silent=False, + featured_mod: str, + version: int | None = None, + modversions: dict | None = None, + sim_mod: tuple[str, str] | None = None, + silent: bool = False, *args, **kwargs, ): @@ -133,7 +133,7 @@ def __init__( self.version = version self.modversions = modversions - self.sim = sim + self.sim_mod = sim_mod self.modpath = None self.result = self.RESULT_NONE @@ -199,25 +199,24 @@ def getFilesToUpdate(self, id_, version): def getFeaturedModIdByName(self, technicalName): return FeaturedModId().requestAndGetFeaturedModIdByName(technicalName) - def requestSimUrlByUid(self, uid): - return SimModFiles().requestAndGetSimModUrlByUid(uid) + def request_sim_url_by_uid(self, uid: str) -> str: + return SimModFiles().request_and_get_sim_mod_url_by_id(uid) - def fetchFile(self, _file: dict, filegroup: str) -> None: - name = _file['name'] - targetDir = os.path.join(util.APPDATA_DIR, filegroup, name) + def fetch_file(self, file_info: dict, filegroup: str) -> None: + name = file_info["name"] + url = file_info["cacheableUrl"] + target_dir = os.path.join(util.APPDATA_DIR, filegroup) - logger.info('Updater: Downloading {}'.format(_file['cacheableUrl'])) + logger.info(f"Updater: Downloading {url}") - downloaded = downloadFile( - url=_file['cacheableUrl'], - target_dir=targetDir, - name=( - 'Downloading FA file : {url}

    ' - .format(url=_file['cacheableUrl']) - ), - category='Update', + downloaded = download_file( + url=url, + target_dir=target_dir, + name=name, + category="Update", silent=False, - request_params={_file["hmacParameter"]: _file["hmacToken"]}, + request_params={file_info["hmacParameter"]: file_info["hmacToken"]}, + label=f"Downloading FA file : {url}

    ", ) if not downloaded: @@ -294,7 +293,7 @@ def updateFiles(self, filegroup, files): if self.keep_cache or self.in_session_cache: files_to_check.append(_file) else: - self.fetchFile(_file, filegroup) + self.fetch_file(_file, filegroup) self.filesToUpdate.remove(_file) self.updatedFiles.append(_file['name']) @@ -305,7 +304,7 @@ def updateFiles(self, filegroup, files): self.replaceFromCache(replaceable_files, filegroup) for _file in need_to_download: self.moveToCache([_file], filegroup) - self.fetchFile(_file, filegroup) + self.fetch_file(_file, filegroup) self.filesToUpdate.remove(_file) self.updatedFiles.append(_file['name']) @@ -375,13 +374,12 @@ def prepareBinFAF(self): # need to patch them os.chmod(dst_file, st.st_mode | stat.S_IWRITE) - def doUpdate(self): + def doUpdate(self) -> None: """ The core function that does most of the actual update work.""" try: - if self.sim: - if utils.downloadMod( - self.requestSimUrlByUid(self.featured_mod), - ): + if self.sim_mod: + uid, name = self.sim_mod + if utils.downloadMod(self.request_sim_url_by_uid(uid), name): self.result = self.RESULT_SUCCESS else: self.result = self.RESULT_FAILURE diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py index 1fd325521..6bca9efb6 100644 --- a/src/mapGenerator/mapgenManager.py +++ b/src/mapGenerator/mapgenManager.py @@ -13,7 +13,7 @@ from fa.maps import getUserMapsFolder from mapGenerator.mapgenProcess import MapGeneratorProcess from mapGenerator.mapgenUtils import generatedMapPattern -from vaults.dialogs import downloadFile +from vaults.dialogs import download_file logger = logging.getLogger(__name__) @@ -132,21 +132,21 @@ def generateRandomMap(self): return self.generateMap(mapName) - def versionController(self, version): + def versionController(self, version: str) -> str: name = GENERATOR_JAR_NAME.format(version) - filePath = os.path.join(util.MAPGEN_DIR, name) + 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 filePath + return file_path # Download from github if not url = RELEASE_URL + RELEASE_VERSION_PATH.format(version=version) - return downloadFile( - url, filePath, name, "map generator", silent=False, - ) + if download_file(url, util.MAPGEN_DIR, name, "map generator", silent=False): + return file_path + return "" def checkUpdates(self): ''' diff --git a/src/model/game.py b/src/model/game.py index 98c560a2e..0a575b869 100644 --- a/src/model/game.py +++ b/src/model/game.py @@ -167,9 +167,7 @@ def url(self, player_id): else: gtype = GameUrlType.LIVE_REPLAY - return GameUrl( - gtype, self.mapname, self.featured_mod, self.uid, player_id, - ) + return GameUrl(gtype, self.mapname, self.featured_mod, self.uid, player_id, self.sim_mods) # Utility functions start here. diff --git a/src/util/gameurl.py b/src/util/gameurl.py index a7ed29cd5..0e653ae1a 100644 --- a/src/util/gameurl.py +++ b/src/util/gameurl.py @@ -28,8 +28,8 @@ def to_url(self): query = QUrlQuery() query.addQueryItem("map", self.map) query.addQueryItem("mod", self.mod) - if self.mods is not None: - query.addQueryItem("mods", self.mods) + if self.mods: + query.addQueryItem("mods", ";".join(self.mods)) if self.game_type == GameUrlType.OPEN_GAME: url.setPath("/{}".format(self.player)) diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py index a75ef6f66..a73d49932 100644 --- a/src/vaults/dialogs.py +++ b/src/vaults/dialogs.py @@ -1,13 +1,13 @@ -import io import logging import os -import zipfile +from typing import Callable from PyQt6 import QtCore from PyQt6 import QtNetwork from PyQt6 import QtWidgets from downloadManager import FileDownload +from downloadManager import ZipDownloadExtract logger = logging.getLogger(__name__) @@ -19,7 +19,13 @@ class VaultDownloadDialog(object): DL_ERROR = 2 UNKNOWN_ERROR = 3 - def __init__(self, dler, title, label, silent=False): + def __init__( + self, + dler: FileDownload | ZipDownloadExtract, + title: str, + label: str, + silent: bool = False, + ) -> None: self._silent = silent self._result = None @@ -122,8 +128,15 @@ def _finished(self, dler): def downloadVaultAssetNoMsg( - url, target_dir, exist_handler, name, category, silent, -): + 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. @@ -132,69 +145,39 @@ def downloadVaultAssetNoMsg( msg = None msg_title = "" msg_text = "" - output = io.BytesIO() - capitCat = category[0].upper() + category[1:] + capit_cat = f"{category[0].upper()}{category[1:]}" - dler = FileDownload(_global_nam, url, output) - ddialog = VaultDownloadDialog( - dler, "Downloading {}".format(category), name, silent, - ) + if os.path.exists(os.path.join(target_dir, name)): + proceed = exist_handler(target_dir, name) + if not proceed: + return False, msg + + dler = ZipDownloadExtract(target_dir, _global_nam, url, request_params) + ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent) result = ddialog.run() if result == VaultDownloadDialog.CANCELED: - logger.warning("{} Download canceled for: {}".format(capitCat, url)) + logger.warning(f"{capit_cat} Download canceled for: {url}") if result in [ VaultDownloadDialog.DL_ERROR, VaultDownloadDialog.UNKNOWN_ERROR, ]: - logger.warning( - "Vault download failed, {} probably not in vault " - "(or broken).".format(category), - ) - msg_title = "{} not downloadable".format(capitCat) + logger.warning(f"Vault download failed, {category} probably not in vault (or broken).") + msg_title = "{} not downloadable".format(capit_cat) msg_text = ( - "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) + 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." ) - if msg_title and msg_text: def msg(): QtWidgets.QMessageBox.information(None, msg_title, msg_text) 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 BaseException: - logger.error("Extract error") - - def msg(): - QtWidgets.QMessageBox.information( - None, - "{} installation failed".format(capitCat), - ( - "This {} could not be installed (please report this {} " - "or bug).".format(category, category) - ), - ) - return False, msg + return True, msg def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent): @@ -207,56 +190,43 @@ def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent): return ret -def downloadFile( +def download_file( url: str, target_dir: str, name: str, category: str, silent: bool, request_params: dict | None = None, -) -> None: + label: str = "", +) -> bool: """ Basically a copy of downloadVaultAssetNoMsg without zip """ global _global_nam - msg = None - output = io.BytesIO() - capitCat = category[0].upper() + category[1:] + capit_cat = f"{category[0].upper()}{category[1:]}" - dler = FileDownload(_global_nam, url, output, request_params=request_params) - ddialog = VaultDownloadDialog( - dler, "Downloading {}".format(category), name, silent, - ) + os.makedirs(target_dir, exist_ok=True) + + target_path = os.path.join(target_dir, name) + dler = FileDownload(target_path, _global_nam, url, request_params) + ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent) result = ddialog.run() if result == VaultDownloadDialog.CANCELED: - logger.warning("{} Download canceled for: {}".format(capitCat, url)) + logger.warning(f"{capit_cat} Download canceled for: {url}") if result in [ VaultDownloadDialog.DL_ERROR, VaultDownloadDialog.UNKNOWN_ERROR, ]: - logger.warning("Download failed. {}".format(url)) - - def msg(): - QtWidgets.QMessageBox.information( - None, - "{} not downloadable".format(capitCat), - ( - "Failed to download {} from
    " - "{}".format(category, url) - ), - ) + logger.warning(f"Download failed. {url}") + QtWidgets.QMessageBox.information( + None, + f"{capit_cat} not downloadable", + f"Failed to download {category} from
    {url}", + ) if result != VaultDownloadDialog.SUCCESS: - if msg: - msg() return False - if not os.path.exists(os.path.dirname(target_dir)): - os.makedirs(os.path.dirname(target_dir)) - - with open(target_dir, "w+b") as f: - f.write(output.getvalue()) - - return target_dir + return True diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 7cc1f6ebe..3ffc0c738 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -172,8 +172,8 @@ def openUploadForm(self): "This folder doesn't contain a mod_info.lua file", ) - def downloadMod(self, mod): - if utils.downloadMod(mod): + def downloadMod(self, link: str, name: str) -> bool: + if utils.downloadMod(link, name): self.uids = [mod.uid for mod in utils.getInstalledMods()] self.updateVisibilities() return True diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py index 796dd600e..74b97b3a6 100644 --- a/src/vaults/modvault/modwidget.py +++ b/src/vaults/modvault/modwidget.py @@ -81,9 +81,9 @@ def load_stylesheet(self): self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) @QtCore.pyqtSlot() - def download(self): + def download(self) -> None: if self.mod.uid not in self.parent.uids: - self.parent.downloadMod(self.mod) + self.parent.downloadMod(self.mod.link, self.mod.name) self.done(1) else: show = QtWidgets.QMessageBox.question( diff --git a/src/vaults/modvault/utils.py b/src/vaults/modvault/utils.py index 886335817..294741c91 100644 --- a/src/vaults/modvault/utils.py +++ b/src/vaults/modvault/utils.py @@ -420,14 +420,10 @@ def generateThumbnail(sourcename, destname): return False -def downloadMod(item): - if isinstance(item, str): - link = item - else: - link = item.link - logger.debug("Getting mod from: {}".format(link)) +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( @@ -446,9 +442,7 @@ def handle_exist(path, modname): 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): From dbad8493fec69ea6e4635eb741f7cb94eef4b0f8 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 17 Apr 2024 19:50:53 +0300 Subject: [PATCH 036/123] Use FileDownload instead of BaseDownload in DownloadWrapper so that wrapper doesn't need to create and cleanup files --- src/downloadManager/__init__.py | 46 ++++++++++++--------------------- src/vaults/vaultitem.py | 4 +-- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index afc006392..d5b0ad049 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -160,8 +160,10 @@ def __init__( addr: str, request_params: dict | None = None, ) -> None: - self._target_path = f"{target_path}.part" - self._output = QFile(target_path) + 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) @@ -172,11 +174,11 @@ def _about_to_finish(self) -> None: def cleanup(self) -> None: self._output.close() if self.failed(): - logger.debug(f"Download failed for: {self.name}") - os.unlink(self._target_path) + logger.debug(f"Download failed for: {self._target_path}") + os.unlink(self._cache_path) else: logger.debug(f"Finished download from {self.addr}") - self._output.rename(self._target_path.removesuffix(".part")) + self._output.rename(self._target_path) class ZipDownloadExtract(BaseDownload): @@ -225,7 +227,7 @@ def cleanup(self) -> None: self._output.close() -class GeneralDownload(QObject): +class DownloadWrapper(QObject): done = pyqtSignal(object, object) def __init__( @@ -255,34 +257,20 @@ def _start_download(self) -> None: self._dl = self._prepare_dl() self._dl.run() - def _prepare_dl(self) -> BaseDownload: - file_obj = self._get_cachefile(self.name + ".part") - dl = BaseDownload(self._nam, self._url, file_obj) + 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: str) -> QFile: - filepath = os.path.join(self._target_dir, name) - file_obj = QFile(filepath) - file_obj.open(QIODevice.OpenModeFlag.WriteOnly) - return file_obj - def remove_request(self, req: DownloadRequest) -> None: self.requests.remove(req) def add_request(self, req: DownloadRequest) -> None: self.requests.add(req) - def _finished(self, dl: BaseDownload) -> None: - dl.dest.close() - destpath = dl.dest.fileName() - if self.failed(): - logger.debug(f"Download failed for: {self.name}") - os.unlink(destpath) - else: - logger.debug("Finished download from " + dl.addr) - dl.dest.rename(destpath.removesuffix(".part")) + def _finished(self, dl: FileDownload) -> None: self.done.emit(self, dl.dest.fileName()) def failed(self): @@ -312,7 +300,7 @@ def finished(self, name, result): self.done.emit(name, result) -class GeneralDownloader(QObject): +class Downloader(QObject): """ Class for downloading. Clients ask to download by giving download requests, which are stored by name. After download is complete, all @@ -328,7 +316,7 @@ def __init__(self, target_dir: str) -> None: super().__init__() self._nam = QNetworkAccessManager(self) self._target_dir = target_dir - self._downloads: dict[str, GeneralDownload] = {} + 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: @@ -348,11 +336,11 @@ def _add_download(self, name: str, url: str) -> None: delay = self._timeouts.timer else: delay = None - dl = GeneralDownload(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, download: GeneralDownload, download_path: str) -> None: + 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: @@ -362,7 +350,7 @@ def _finished_download(self, download: GeneralDownload, download_path: str) -> N req.finished(download.name, (download_path, download.failed())) -class MapPreviewDownloader(GeneralDownloader): +class MapPreviewDownloader(Downloader): def __init__(self, target_dir: str, size: str) -> None: super().__init__(target_dir) self.size = size diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py index 11c321491..db04d38d5 100644 --- a/src/vaults/vaultitem.py +++ b/src/vaults/vaultitem.py @@ -3,8 +3,8 @@ from PyQt6 import QtWidgets import util +from downloadManager import Downloader from downloadManager import DownloadRequest -from downloadManager import GeneralDownloader class VaultItem(QtWidgets.QListWidgetItem): @@ -30,7 +30,7 @@ def __init__(self, parent, *args, **kwargs): self.link = "" self.setHidden(True) - self._preview_dler = GeneralDownloader(util.CACHE_DIR) + self._preview_dler = Downloader(util.CACHE_DIR) self._item_dl_request = DownloadRequest() self._item_dl_request.done.connect(self._on_item_downloaded) From 2ec6f51683b8722328e2d0469d39728340ddc963 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 17 Apr 2024 19:59:43 +0300 Subject: [PATCH 037/123] NewsWidget: Download news images into cache folder --- src/news/_newswidget.py | 55 +++++++++++++++++++++-------------------- src/util/__init__.py | 5 +++- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py index 39604c2f5..e34836ee3 100644 --- a/src/news/_newswidget.py +++ b/src/news/_newswidget.py @@ -1,18 +1,18 @@ import logging +import os.path from PyQt6 import QtWidgets -from PyQt6.QtCore import QByteArray 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 -from PyQt6.QtNetwork import QNetworkReply -from PyQt6.QtNetwork import QNetworkRequest import util from config import Settings +from downloadManager import Downloader +from downloadManager import DownloadRequest from .newsitem import NewsItem from .newsitem import NewsItemDelegate @@ -35,11 +35,12 @@ def __init__(self, *args, **kwargs) -> None: self.setupUi(self) self.nam = QNetworkAccessManager() - self.reply: QNetworkReply | None = None + 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 = [] - self.images = {} # open all links in external browser self.newsTextBrowser.setOpenExternalLinks(True) @@ -65,37 +66,36 @@ def updateNews(self) -> None: self.newsList.clear() self.newsManager.WpApi.download() - def download_image(self, img_url: QUrl) -> None: - request = QNetworkRequest(img_url) - self.reply = self.nam.get(request) - self.reply.finished.connect(self.item_image_downloaded) + 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, img_url: QUrl, image_data: QByteArray) -> None: - img = QImage() - img.loadFromData(image_data) + 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) - self.images[img_url] = scaled - self.newsTextBrowser.document().addResource( - QTextDocument.ResourceType.ImageResource, - img_url, - scaled, - ) - - def item_image_downloaded(self) -> None: - if self.reply.error() is not self.reply.NetworkError.NoError: - return - self.add_image_resource(self.reply.request().url(), self.reply.readAll()) + 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 = QUrl(current.newsPost["img_url"]) - if url in self.images: + + 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.download_image(url) + self._downloader.download(image_name, self._images_dl_request, url) def show_newspage(self) -> None: current = self.newsList.currentItem() @@ -105,12 +105,13 @@ def show_newspage(self) -> None: 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=current.newsPost["img_url"], + img_source=image_name, external_link=external_link, ) self.newsTextBrowser.setHtml(html) diff --git a/src/util/__init__.py b/src/util/__init__.py index 91b3be673..6586f2e04 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -55,6 +55,9 @@ 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 data downloaded for FA extras EXTRA_DIR = os.path.join(APPDATA_DIR, "extra") @@ -159,7 +162,7 @@ def setPersonalDir(): 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, + THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR, NEWS_CACHE_DIR, ]: if not os.path.isdir(data_dir): os.makedirs(data_dir) From 587bff73228b1e8b831f6d3c0522d33f585ea457 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 18 Apr 2024 18:38:19 +0300 Subject: [PATCH 038/123] vaults/dialogs: Extract some repeatable code into a function --- src/util/__init__.py | 7 +++ src/vaults/dialogs.py | 108 +++++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/util/__init__.py b/src/util/__init__.py index 6586f2e04..6d758ce27 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -501,3 +501,10 @@ def strtodate(s): def datetostr(d): return d.strftime("%Y-%m-%d %H:%M:%S") + + +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/vaults/dialogs.py b/src/vaults/dialogs.py index a73d49932..b892759d6 100644 --- a/src/vaults/dialogs.py +++ b/src/vaults/dialogs.py @@ -1,5 +1,7 @@ import logging import os +from enum import Enum +from enum import auto from typing import Callable from PyQt6 import QtCore @@ -8,16 +10,19 @@ 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): - # Result codes - SUCCESS = 0 - CANCELED = 1 - DL_ERROR = 2 - UNKNOWN_ERROR = 3 def __init__( self, @@ -102,24 +107,25 @@ def _cont(self, dler): QtWidgets.QApplication.processEvents() - def _finished(self, dler): + def _finished(self, dler: FileDownload | ZipDownloadExtract) -> None: self.timer.stop() self._progress.reset() + self._set_result(dler) - if not dler.succeeded(): + def _set_result(self, dler: FileDownload | ZipDownloadExtract) -> None: + if dler.failed(): if dler.canceled: - self._result = self.CANCELED + self._result = VaultDownloadResult.CANCELED return - elif dler.error: - self._result = self.DL_ERROR + self._result = VaultDownloadResult.DL_ERROR return else: logger.error('Unknown download error') - self._result = self.UNKNOWN_ERROR + self._result = VaultDownloadResult.UNKNOWN_ERROR return - self._result = self.SUCCESS + self._result = VaultDownloadResult.SUCCESS return @@ -127,6 +133,25 @@ def _finished(self, dler): _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, @@ -142,28 +167,24 @@ def downloadVaultAssetNoMsg( logging things. """ global _global_nam + msg = None - msg_title = "" - msg_text = "" - capit_cat = f"{category[0].upper()}{category[1:]}" + 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) - ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent) - result = ddialog.run() - - if result == VaultDownloadDialog.CANCELED: - logger.warning(f"{capit_cat} Download canceled for: {url}") + result = _download_asset(dler, capit_cat, silent, label or name) if result in [ - VaultDownloadDialog.DL_ERROR, - VaultDownloadDialog.UNKNOWN_ERROR, + VaultDownloadResult.DL_ERROR, + VaultDownloadResult.UNKNOWN_ERROR, ]: - logger.warning(f"Vault download failed, {category} probably not in vault (or broken).") + 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)." @@ -174,19 +195,20 @@ def downloadVaultAssetNoMsg( def msg(): QtWidgets.QMessageBox.information(None, msg_title, msg_text) - if result != VaultDownloadDialog.SUCCESS: - return False, msg + return result == VaultDownloadResult.SUCCESS, msg - return True, msg - -def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent): - ret, dialog = downloadVaultAssetNoMsg( - url, target_dir, exist_handler, name, category, silent, - ) +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 @@ -202,31 +224,21 @@ def download_file( """ Basically a copy of downloadVaultAssetNoMsg without zip """ - - global _global_nam - capit_cat = f"{category[0].upper()}{category[1:]}" + 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) - ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent) - result = ddialog.run() + result = _download_asset(dler, capit_cat, silent, label or name) - if result == VaultDownloadDialog.CANCELED: - logger.warning(f"{capit_cat} Download canceled for: {url}") if result in [ - VaultDownloadDialog.DL_ERROR, - VaultDownloadDialog.UNKNOWN_ERROR, + VaultDownloadResult.DL_ERROR, + VaultDownloadResult.UNKNOWN_ERROR, ]: - logger.warning(f"Download failed. {url}") QtWidgets.QMessageBox.information( None, f"{capit_cat} not downloadable", f"Failed to download {category} from
    {url}", ) - - if result != VaultDownloadDialog.SUCCESS: - return False - - return True + return result == VaultDownloadResult.SUCCESS From 00b50d80746c5835dbc90d64cbae34115b13c59d Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 18 Apr 2024 18:38:49 +0300 Subject: [PATCH 039/123] Do not try to call NoneType --- src/fa/maps.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/fa/maps.py b/src/fa/maps.py index 74896e365..fc49c8d4f 100644 --- a/src/fa/maps.py +++ b/src/fa/maps.py @@ -439,25 +439,22 @@ def preview(mapname, pixmap=False): 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 msg is None: - name = name.replace(" ", "_") - link = name2link(name) - ret, msg = _doDownloadMap(name, link, silent) - if not ret: - msg() - return ret + if not ret and msg is None: + name = name.replace(" ", "_") + link = name2link(name) + ret, msg = _doDownloadMap(name, link, silent) + if not ret and msg is not None: + msg() + return ret - return True - -def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None]]: +def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None] | None]: logger.debug(f"Getting map from: {link}") return downloadVaultAssetNoMsg( url=link, From 65d478bc238c271ea5ed6ce40c9f83f62abc423a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 19 Apr 2024 19:13:59 +0300 Subject: [PATCH 040/123] Add PyQt6-NetworkAuth to requirements forgotten to add earlier --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7f4deedcb..299d0bd20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ cx_Freeze ipaddress pathlib pyqt6 +pyqt6-networkauth pytest pytest-cov pytest-mock From 94dec788b4ac0aac8e6751ac45550bf76ae49e3b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 20 Apr 2024 19:31:57 +0300 Subject: [PATCH 041/123] Remove duplicate logger.debug call this information is already logged in base class --- src/downloadManager/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index d5b0ad049..9c67ffd0a 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -174,7 +174,6 @@ def _about_to_finish(self) -> None: def cleanup(self) -> None: self._output.close() if self.failed(): - logger.debug(f"Download failed for: {self._target_path}") os.unlink(self._cache_path) else: logger.debug(f"Finished download from {self.addr}") From 6df2d082691be03c62e256a74c700810d3c2aa21 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 21 Apr 2024 23:20:09 +0300 Subject: [PATCH 042/123] Create models for api object used in vaults so information about them is stored nicely in some data containers and not spread out everywhere in class attributes also, create parsers for them hopefully this will ease the process of managing api data in the future --- src/api/models/AbstractEntity.py | 8 + src/api/models/GeneratedMapParams.py | 47 +++++ src/api/models/Map.py | 18 ++ src/api/models/MapPoolAssignment.py | 12 ++ src/api/models/MapType.py | 17 ++ src/api/models/MapVersion.py | 43 +++++ src/api/models/Mod.py | 16 ++ src/api/models/ModType.py | 16 ++ src/api/models/ModVersion.py | 16 ++ src/api/models/Player.py | 9 + src/api/models/ReviewsSummary.py | 11 ++ src/api/models/__init__.py | 0 src/api/parsers/GeneratedMapParamsParser.py | 18 ++ src/api/parsers/MapParser.py | 45 +++++ src/api/parsers/MapPoolAssignmentParser.py | 46 +++++ src/api/parsers/MapVersionParser.py | 24 +++ src/api/parsers/ModParser.py | 25 +++ src/api/parsers/ModVersionParser.py | 21 +++ src/api/parsers/PlayerParser.py | 17 ++ src/api/parsers/ReviewsSummaryParser.py | 22 +++ src/api/parsers/__init__.py | 0 src/api/vaults_api.py | 197 +++++++------------- src/vaults/mapvault/mapitem.py | 121 ++++++------ src/vaults/mapvault/mapvault.py | 49 ++--- src/vaults/mapvault/mapwidget.py | 61 +++--- src/vaults/modvault/moditem.py | 107 +++++------ src/vaults/modvault/modvault.py | 20 +- src/vaults/modvault/modwidget.py | 35 ++-- src/vaults/vault.py | 41 ++-- src/vaults/vaultitem.py | 145 +++++++------- 30 files changed, 796 insertions(+), 411 deletions(-) create mode 100644 src/api/models/AbstractEntity.py create mode 100644 src/api/models/GeneratedMapParams.py create mode 100644 src/api/models/Map.py create mode 100644 src/api/models/MapPoolAssignment.py create mode 100644 src/api/models/MapType.py create mode 100644 src/api/models/MapVersion.py create mode 100644 src/api/models/Mod.py create mode 100644 src/api/models/ModType.py create mode 100644 src/api/models/ModVersion.py create mode 100644 src/api/models/Player.py create mode 100644 src/api/models/ReviewsSummary.py create mode 100644 src/api/models/__init__.py create mode 100644 src/api/parsers/GeneratedMapParamsParser.py create mode 100644 src/api/parsers/MapParser.py create mode 100644 src/api/parsers/MapPoolAssignmentParser.py create mode 100644 src/api/parsers/MapVersionParser.py create mode 100644 src/api/parsers/ModParser.py create mode 100644 src/api/parsers/ModVersionParser.py create mode 100644 src/api/parsers/PlayerParser.py create mode 100644 src/api/parsers/ReviewsSummaryParser.py create mode 100644 src/api/parsers/__init__.py diff --git a/src/api/models/AbstractEntity.py b/src/api/models/AbstractEntity.py new file mode 100644 index 000000000..56b8dbb72 --- /dev/null +++ b/src/api/models/AbstractEntity.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class AbstractEntity: + uid: str + create_time: str + update_time: str diff --git a/src/api/models/GeneratedMapParams.py b/src/api/models/GeneratedMapParams.py new file mode 100644 index 000000000..cc12451c3 --- /dev/null +++ b/src/api/models/GeneratedMapParams.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from api.models.Map import Map +from api.models.MapType import MapType +from api.models.MapVersion import MapSize +from api.models.MapVersion import MapVersion + + +@dataclass +class GeneratedMapParams: + name: str + spawns: int + size: int + gen_version: str + + def to_map(self) -> Map: + uid = f"neroxis_map_generator_{self.gen_version}_{self.name}_{self.spawns}_{self.size}" + version = MapVersion( + uid=uid, + create_time="", + update_time="", + folder_name=uid, + games_played=0, + description="Randomly Generated Map", + max_players=self.spawns, + size=MapSize(self.size, self.size), + version=self.gen_version, + hidden=False, + ranked=True, + download_url="", + thumbnail_url_small="", + thumbnail_url_large="", + ) + return Map( + uid=uid, + create_time="", + update_time="", + display_name=self.name, + author=None, + recommended=False, + reviews_summary=None, + games_played=0, + maptype=MapType.SKIRMISH, + version=version, + ) diff --git a/src/api/models/Map.py b/src/api/models/Map.py new file mode 100644 index 000000000..de6c053f0 --- /dev/null +++ b/src/api/models/Map.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +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 + + +@dataclass +class Map(AbstractEntity): + display_name: str + recommended: int + author: Player | None + reviews_summary: ReviewsSummary | None + games_played: int + maptype: MapType + version: MapVersion | None = None diff --git a/src/api/models/MapPoolAssignment.py b/src/api/models/MapPoolAssignment.py new file mode 100644 index 000000000..9291b1f10 --- /dev/null +++ b/src/api/models/MapPoolAssignment.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from api.models.AbstractEntity import AbstractEntity +from api.models.GeneratedMapParams import GeneratedMapParams +from api.models.MapVersion import MapVersion + + +@dataclass +class MapPoolAssignment(AbstractEntity): + map_params: GeneratedMapParams | None + map_version: MapVersion | None + weight: int 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..a5377870e --- /dev/null +++ b/src/api/models/MapVersion.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass + +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" + + +@dataclass +class MapVersion(AbstractEntity): + folder_name: str + games_played: int + description: str + max_players: int + size: MapSize + version: int + hidden: bool + ranked: bool + download_url: str + thumbnail_url_small: str + thumbnail_url_large: str diff --git a/src/api/models/Mod.py b/src/api/models/Mod.py new file mode 100644 index 000000000..97708721b --- /dev/null +++ b/src/api/models/Mod.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from api.models.AbstractEntity import AbstractEntity +from api.models.ModVersion import ModVersion +from api.models.Player import Player +from api.models.ReviewsSummary import ReviewsSummary + + +@dataclass +class Mod(AbstractEntity): + display_name: str + recommended: bool + author: str + reviews_summary: ReviewsSummary | None + uploader: Player | None + version: ModVersion diff --git a/src/api/models/ModType.py b/src/api/models/ModType.py new file mode 100644 index 000000000..cbbe35573 --- /dev/null +++ b/src/api/models/ModType.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from enum import Enum + + +class ModType(Enum): + UI = "modType.ui" + SIM = "modType.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..b1e6bf29e --- /dev/null +++ b/src/api/models/ModVersion.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from api.models.AbstractEntity import AbstractEntity +from api.models.ModType import ModType + + +@dataclass +class ModVersion(AbstractEntity): + description: str + download_url: str + filename: str + hidden: bool + ranked: bool + thumbnail_url: str + modtype: ModType + version: str diff --git a/src/api/models/Player.py b/src/api/models/Player.py new file mode 100644 index 000000000..509d9a1ca --- /dev/null +++ b/src/api/models/Player.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from api.models.AbstractEntity import AbstractEntity + + +@dataclass +class Player(AbstractEntity): + login: str + user_agent: str diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py new file mode 100644 index 000000000..ee305fb4b --- /dev/null +++ b/src/api/models/ReviewsSummary.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class ReviewsSummary: + positive: float + negative: float + score: float + average_score: float + num_reviews: int + lower_bound: float 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/GeneratedMapParamsParser.py b/src/api/parsers/GeneratedMapParamsParser.py new file mode 100644 index 000000000..7e0c5fa41 --- /dev/null +++ b/src/api/parsers/GeneratedMapParamsParser.py @@ -0,0 +1,18 @@ +from api.models.Map import Map +from src.api.models.GeneratedMapParams import GeneratedMapParams + + +class GeneratedMapParamsParser: + + @staticmethod + def parse(params_info: dict) -> GeneratedMapParams: + return GeneratedMapParams( + name=params_info["type"], + spawns=params_info["spawns"], + size=params_info["size"], + gen_version=params_info["version"], + ) + + @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..0b50b5cc7 --- /dev/null +++ b/src/api/parsers/MapParser.py @@ -0,0 +1,45 @@ +from api.models.Map import Map +from api.models.MapType import MapType +from api.parsers.MapVersionParser import MapVersionParser +from api.parsers.PlayerParser import PlayerParser +from api.parsers.ReviewsSummaryParser import ReviewsSummaryParser + + +class MapParser: + + @staticmethod + def parse(api_result: dict) -> Map: + return Map( + uid=api_result["id"], + create_time=api_result["createTime"], + update_time=api_result["updateTime"], + display_name=api_result["displayName"], + recommended=api_result["recommended"], + author=PlayerParser.parse(api_result["author"]), + reviews_summary=ReviewsSummaryParser.parse(api_result["reviewsSummary"]), + games_played=api_result["gamesPlayed"], + maptype=MapType.from_string(api_result["mapType"]), + ) + + @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: + version = MapVersionParser.parse(version_info) + return Map( + uid=map_info["id"], + create_time=map_info["createTime"], + update_time=map_info["updateTime"], + display_name=map_info["displayName"], + recommended=map_info["recommended"], + author=PlayerParser.parse(map_info["author"]), + reviews_summary=ReviewsSummaryParser.parse(map_info["reviewsSummary"]), + games_played=map_info["gamesPlayed"], + maptype=MapType.from_string(map_info["mapType"]), + version=version, + ) diff --git a/src/api/parsers/MapPoolAssignmentParser.py b/src/api/parsers/MapPoolAssignmentParser.py new file mode 100644 index 000000000..4cac1d90a --- /dev/null +++ b/src/api/parsers/MapPoolAssignmentParser.py @@ -0,0 +1,46 @@ +from api.models.Map import Map +from api.models.MapPoolAssignment import MapPoolAssignment +from api.parsers.GeneratedMapParamsParser import GeneratedMapParamsParser +from api.parsers.MapParser import MapParser +from api.parsers.MapVersionParser import MapVersionParser + + +class MapPoolAssignmentParser: + + @staticmethod + def parse(assignment_info: dict) -> MapPoolAssignment: + if assignment_info["mapVersion"]: + map_version = MapVersionParser.parse(assignment_info["mapVersion"]) + map_params = None + elif assignment_info["mapParams"]: + map_version = None + map_params = GeneratedMapParamsParser.parse(assignment_info["mapParams"]) + + return MapPoolAssignment( + uid=assignment_info["id"], + create_time=assignment_info["createTime"], + update_time=assignment_info["updateTime"], + map_version=map_version, + map_params=map_params, + weight=assignment_info["weight"], + ) + + @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: + return MapParser.parse_version( + assignment_info["mapVersion"]["map"], + assignment_info["mapVersion"], + ) + 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..a9d07d865 --- /dev/null +++ b/src/api/parsers/MapVersionParser.py @@ -0,0 +1,24 @@ +from api.models.MapVersion import MapSize +from api.models.MapVersion import MapVersion + + +class MapVersionParser: + + @staticmethod + def parse(version_info: dict) -> MapVersion: + return MapVersion( + uid=version_info["id"], + create_time=version_info["createTime"], + update_time=version_info["updateTime"], + folder_name=version_info["folderName"], + games_played=version_info["gamesPlayed"], + description=version_info["description"], + max_players=version_info["maxPlayers"], + size=MapSize(version_info["height"], version_info["width"]), + version=version_info["version"], + hidden=version_info["hidden"], + ranked=version_info["ranked"], + download_url=version_info["downloadUrl"], + thumbnail_url_small=version_info["thumbnailUrlSmall"], + thumbnail_url_large=version_info["thumbnailUrlLarge"], + ) diff --git a/src/api/parsers/ModParser.py b/src/api/parsers/ModParser.py new file mode 100644 index 000000000..ba8930eda --- /dev/null +++ b/src/api/parsers/ModParser.py @@ -0,0 +1,25 @@ +from api.models.Mod import Mod +from api.parsers.ModVersionParser import ModVersionParser +from api.parsers.PlayerParser import PlayerParser +from api.parsers.ReviewsSummaryParser import ReviewsSummaryParser + + +class ModParser: + + @staticmethod + def parse(mod_info: dict) -> Mod: + return Mod( + uid=mod_info["id"], + create_time=mod_info["createTime"], + update_time=mod_info["updateTime"], + display_name=mod_info["displayName"], + recommended=mod_info["recommended"], + author=mod_info["author"], + reviews_summary=ReviewsSummaryParser.parse(mod_info["reviewsSummary"]), + uploader=PlayerParser.parse(mod_info["uploader"]), + version=ModVersionParser.parse(mod_info["latestVersion"]), + ) + + @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..5dbde04e2 --- /dev/null +++ b/src/api/parsers/ModVersionParser.py @@ -0,0 +1,21 @@ +from api.models.ModVersion import ModType +from api.models.ModVersion import ModVersion + + +class ModVersionParser: + + @staticmethod + def parse(api_result: dict) -> ModVersion: + return ModVersion( + uid=api_result["uid"], + create_time=api_result["createTime"], + update_time=api_result["updateTime"], + description=api_result["description"], + download_url=api_result["downloadUrl"], + filename=api_result["filename"], + hidden=api_result["hidden"], + ranked=api_result["ranked"], + thumbnail_url=api_result["thumbnailUrl"], + modtype=ModType.from_string(api_result["type"]), + version=api_result["version"], + ) diff --git a/src/api/parsers/PlayerParser.py b/src/api/parsers/PlayerParser.py new file mode 100644 index 000000000..3f32588d1 --- /dev/null +++ b/src/api/parsers/PlayerParser.py @@ -0,0 +1,17 @@ +from api.models.Player import Player + + +class PlayerParser: + + @staticmethod + def parse(player_info: dict) -> Player | None: + if not player_info: + return None + + return Player( + uid=player_info["id"], + create_time=player_info["createTime"], + update_time=player_info["updateTime"], + login=player_info["login"], + user_agent=player_info["userAgent"], + ) diff --git a/src/api/parsers/ReviewsSummaryParser.py b/src/api/parsers/ReviewsSummaryParser.py new file mode 100644 index 000000000..797c89864 --- /dev/null +++ b/src/api/parsers/ReviewsSummaryParser.py @@ -0,0 +1,22 @@ +from api.models.ReviewsSummary import ReviewsSummary + + +def _avoid_none(value: float | int | None) -> float | int: + return value or 0 + + +class ReviewsSummaryParser: + + @staticmethod + def parse(reviews_info: dict) -> ReviewsSummary | None: + if not reviews_info: + return None + + return ReviewsSummary( + positive=_avoid_none(reviews_info["positive"]), + negative=_avoid_none(reviews_info["negative"]), + score=_avoid_none(reviews_info["score"]), + average_score=_avoid_none(reviews_info["averageScore"]), + num_reviews=_avoid_none(reviews_info["reviews"]), + lower_bound=_avoid_none(reviews_info["lowerBound"]), + ) 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/vaults_api.py b/src/api/vaults_api.py index ab8af345d..ba69edc93 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -1,6 +1,9 @@ import logging from api.ApiAccessors import DataApiAccessor +from api.parsers.MapParser import MapParser +from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser +from api.parsers.ModParser import ModParser from client.connection import Dispatcher logger = logging.getLogger(__name__) @@ -13,82 +16,61 @@ def __init__(self, dispatch: Dispatcher) -> None: def requestData(self, params: dict | None = None) -> None: params = params or {} - self.get_by_query(params, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = dict( - command='modvault_info', - values=[], - meta=message['meta'], - ) - for mod in message['data']: - preparedMod = dict( - name=mod['displayName'], - uid=mod['latestVersion']['uid'], - link=mod['latestVersion']['downloadUrl'], - description=mod['latestVersion']['description'], - author=mod['author'], - version=mod['latestVersion']['version'], - ui=mod['latestVersion']['type'] == 'UI', - thumbnail=mod['latestVersion']['thumbnailUrl'], - date=mod['latestVersion']['updateTime'], - rating=0, - reviews=0, - ) - if len(mod['reviewsSummary']) > 0: - score = mod['reviewsSummary']['score'] - reviews = mod['reviewsSummary']['reviews'] - if reviews > 0: - preparedMod['rating'] = float( - '{:1.2f}'.format(score / reviews), - ) - preparedMod['reviews'] = reviews - preparedData["values"].append(preparedMod) - self.dispatch.dispatch(preparedData) + self._add_default_include(params) + self._extend_filters(params) + self.get_by_query(params, self.handle_data) + + def _add_default_include(self, params: dict) -> dict: + params["include"] = ",".join(("latestVersion", "reviewsSummary", "uploader")) + return params + + def _extend_filters(self, params: dict) -> dict: + additional_filter = "latestVersion.hidden=='false'" + if cur_filters := params.get("filter", ""): + params["filter"] = f"{cur_filters};{additional_filter}" + else: + params["filter"] = additional_filter + return params + + def handle_data(self, message: dict) -> None: + parsed_data = { + "command": "modvault_info", + "values": ModParser.parse_many(message["data"]), + "meta": message["meta"], + } + self.dispatch.dispatch(parsed_data) class MapApiConnector(DataApiAccessor): def __init__(self, dispatch: Dispatcher) -> None: - super().__init__('/data/map') + super().__init__("/data/map") self.dispatch = dispatch def requestData(self, params: dict | None = None) -> None: params = params or {} - self.get_by_query(params, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = dict( - command='mapvault_info', - values=[], - meta=message['meta'], - ) - for _map in message['data']: - preparedMap = dict( - name=_map['displayName'], - folderName=_map['latestVersion']['folderName'], - link=_map['latestVersion']['downloadUrl'], - description=_map['latestVersion']['description'], - maxPlayers=_map['latestVersion']['maxPlayers'], - version=_map['latestVersion']['version'], - ranked=_map['latestVersion']['ranked'], - thumbnailSmall=_map['latestVersion']['thumbnailUrlSmall'], - thumbnailLarge=_map['latestVersion']['thumbnailUrlLarge'], - date=_map['latestVersion']['updateTime'], - height=_map['latestVersion']['height'], - width=_map['latestVersion']['width'], - rating=0, - reviews=0, - ) - if len(_map['reviewsSummary']) > 0: - score = _map['reviewsSummary']['score'] - reviews = _map['reviewsSummary']['reviews'] - if reviews > 0: - preparedMap['rating'] = float( - '{:1.2f}'.format(score / reviews), - ) - preparedMap['reviews'] = reviews - preparedData['values'].append(preparedMap) - self.dispatch.dispatch(preparedData) + self._add_default_include(params) + self._extend_filters(params) + self.get_by_query(params, self.parse_data) + + def _extend_filters(self, params: dict) -> dict: + additional_filter = "latestVersion.hidden=='false'" + if cur_filters := params.get("filter", ""): + params["filter"] = f"{cur_filters};{additional_filter}" + else: + params["filter"] = additional_filter + return params + + def _add_default_include(self, params: dict) -> dict: + params["include"] = ",".join(("latestVersion", "reviewsSummary", "author")) + return params + + def parse_data(self, message: dict) -> None: + prepared_data = { + "command": "mapvault_info", + "values": MapParser.parse_many(message["data"]), + "meta": message["meta"], + } + self.dispatch.dispatch(prepared_data) class MapPoolApiConnector(DataApiAccessor): @@ -98,64 +80,21 @@ def __init__(self, dispatch: Dispatcher) -> None: def requestData(self, params: dict | None) -> None: params = params or {} - self.get_by_query(params, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = dict( - command='mapvault_info', - values=[], - meta=message['meta'], - ) - for data in message['data']: - if len(data['mapVersion']) > 0: - _map = data['mapVersion'] - preparedMap = dict( - name=_map['map']['displayName'], - folderName=_map['folderName'], - link=_map['downloadUrl'], - description=_map['description'], - maxPlayers=_map['maxPlayers'], - version=_map['version'], - ranked=_map['ranked'], - thumbnailSmall=_map['thumbnailUrlSmall'], - thumbnailLarge=_map['thumbnailUrlLarge'], - date=_map['updateTime'], - height=_map['height'], - width=_map['width'], - rating=0, - reviews=0, - ) - if len(_map['reviewsSummary']) > 0: - score = _map['reviewsSummary']['score'] - reviews = _map['reviewsSummary']['reviews'] - if reviews > 0: - preparedMap['rating'] = float( - '{:1.2f}'.format(score / reviews), - ) - preparedMap['reviews'] = reviews - elif data['mapParams'] is not None: - _map = data['mapParams'] - preparedMap = dict( - name="Neroxis Map Generator", - folderName=( - 'neroxis_map_generator_{}_size={}km_spawns={}'.format( - _map['version'], - int(_map['size'] / 51.2), - _map['spawns'], - ) - ), - link='', - description='Randomly generated map', - maxPlayers=_map['spawns'], - version='1', - ranked=True, - thumbnailSmall='', - thumbnailLarge='', - date='', - height=_map['size'], - width=_map['size'], - rating=0, - reviews=0, - ) - preparedData['values'].append(preparedMap) - self.dispatch.dispatch(preparedData) + self.get_by_query(self._add_default_include(params), self.parse_data) + + def _add_default_include(self, params: dict) -> dict: + params["include"] = ",".join(( + "mapVersion", + "mapVersion.map", + "mapVersion.map.author", + "mapVersion.map.reviewsSummary", + )) + return params + + def parse_data(self, message: dict) -> None: + prepared_data = { + "command": "mapvault_info", + "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]), + "meta": message["meta"], + } + self.dispatch.dispatch(prepared_data) diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py index d57c92003..4e4aedbb9 100644 --- a/src/vaults/mapvault/mapitem.py +++ b/src/vaults/mapvault/mapitem.py @@ -1,96 +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 VaultItem +from vaults.vaultitem import VaultListItem +if TYPE_CHECKING: + from vaults.mapvault.mapvault import MapVault -class MapItem(VaultItem): - def __init__(self, parent, folderName, *args, **kwargs): - VaultItem.__init__(self, parent, *args, **kwargs) - self.formatterItem = str( - util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"), - ) - - self.height = 0 - self.width = 0 - self.maxPlayers = 0 - self.thumbnail = None - self.unranked = False - self.folderName = folderName - self.thumbstrSmall = "" - self.thumbnailLarge = "" +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, item_dict): - self.name = maps.getDisplayName(item_dict["folderName"]) - self.description = item_dict["description"] - self.version = item_dict["version"] - self.rating = item_dict["rating"] - self.reviews = item_dict["reviews"] - - self.maxPlayers = item_dict["maxPlayers"] - self.height = int(item_dict["height"] / 51.2) - self.width = int(item_dict["width"] / 51.2) - - self.folderName = item_dict["folderName"] - self.date = item_dict['date'][:10] - self.unranked = not item_dict["ranked"] - self.link = item_dict["link"] - self.thumbstrSmall = item_dict["thumbnailSmall"] - self.thumbnailLarge = item_dict["thumbnailLarge"] - - self.thumbnail = maps.preview(self.folderName) - if self.thumbnail: - self.setIcon(self.thumbnail) + def update(self) -> None: + if thumbnail := maps.preview(self.item_version.folder_name): + self.setIcon(thumbnail) else: - if self.thumbstrSmall == "": - if mapgenUtils.isGeneratedMap(self.folderName): + 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.folderName}.png", + f"{self.item_version.folder_name}.png", self._item_dl_request, - self.thumbstrSmall, + self.item_version.thumbnail_url_small, ) - VaultItem.update(self) + VaultListItem.update(self) - def shouldBeVisible(self): + def should_be_visible(self) -> bool: p = self.parent if p.showType == "all": return True elif p.showType == "unranked": - return self.unranked + return not self.item_version.ranked elif p.showType == "ranked": - return not self.unranked + return self.item_version.ranked elif p.showType == "installed": - return maps.isMapAvailable(self.folderName) + return maps.isMapAvailable(self.item_version.folder_name) else: return True - def updateVisibility(self): - if self.unranked: - self.itemType_ = "Unranked map" - if maps.isMapAvailable(self.folderName): - self.color = "green" + def update_visibility(self): + if maps.isMapAvailable(self.item_version.folder_name): + color = "green" else: - self.color = "white" + 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.formatterItem.format( - color=self.color, - version=self.version, - title=self.name, - description=self.trimmedDescription, - rating=self.rating, - reviews=self.reviews, - date=self.date, - modtype=self.itemType_, - height=self.height, - width=self.width, + 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=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) - VaultItem.updateVisibility(self) + 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 index aed416f2b..ba56e90da 100644 --- a/src/vaults/mapvault/mapvault.py +++ b/src/vaults/mapvault/mapvault.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import os import shutil @@ -5,25 +7,30 @@ 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 MapItem +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, *args, **kwargs): + def __init__(self, client: ClientWindow, *args, **kwargs) -> None: QtCore.QObject.__init__(self, *args, **kwargs) Vault.__init__(self, client, *args, **kwargs) @@ -52,12 +59,12 @@ def __init__(self, client, *args, **kwargs): self.UIButton.hide() self.uploadButton.hide() - def createItem(self, item_key: str) -> MapItem: - return MapItem(self, item_key) + def create_item(self, item: Map) -> MapListItem: + return MapListItem(self, item) @QtCore.pyqtSlot(dict) def mapInfo(self, message: dict) -> None: - super().itemsInfo(message) + super().items_info(message) @QtCore.pyqtSlot(int) def sortChanged(self, index): @@ -69,7 +76,7 @@ def sortChanged(self, index): self.sortType = "rating" elif index == 3: self.sortType = "size" - self.updateVisibilities() + self.update_visibilities() @QtCore.pyqtSlot(int) def showChanged(self, index): @@ -81,28 +88,24 @@ def showChanged(self, index): self.showType = "ranked" elif index == 3: self.showType = "installed" - self.updateVisibilities() + self.update_visibilities() @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) - def itemClicked(self, item): + def itemClicked(self, item: MapListItem) -> None: widget = MapWidget(self, item) widget.exec() def requestMapPool(self, queueName, minRating): self.apiConnector = self.mapPoolApiConnector - self.searchQuery = dict( - include=( - 'mapVersion,mapVersion.map.latestVersion,' - 'mapVersion.reviewsSummary' - ), - filter=( - 'mapPool.matchmakerQueueMapPool.matchmakerQueue.' - 'technicalName=="{}";' - '(mapPool.matchmakerQueueMapPool.minRating=le="{}",' - 'mapPool.matchmakerQueueMapPool.minRating=isnull="true")' - .format(queueName, minRating) - ), - ) + 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 @@ -232,7 +235,7 @@ def downloadMap(self, link): if avail_name is None: maps.downloadMap(name) self.installed_maps.append(name) - self.updateVisibilities() + self.update_visibilities() else: show = QtWidgets.QMessageBox.question( self.client, @@ -253,4 +256,4 @@ def removeMap(self, folder): if os.path.exists(maps_folder): shutil.rmtree(maps_folder) self.installed_maps.remove(folder) - self.updateVisibilities() + self.update_visibilities() diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py index a73c86d01..a9178b2f5 100644 --- a/src/vaults/mapvault/mapwidget.py +++ b/src/vaults/mapvault/mapwidget.py @@ -1,5 +1,7 @@ +from __future__ import annotations import os +from typing import TYPE_CHECKING from PyQt6 import QtCore from PyQt6 import QtGui @@ -10,6 +12,10 @@ 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") @@ -17,7 +23,7 @@ class MapWidget(FormClass, BaseClass): ICONSIZE = QtCore.QSize(256, 256) - def __init__(self, parent, _map, *args, **kwargs): + def __init__(self, parent: MapVault, list_item: MapListItem, *args, **kwargs) -> None: BaseClass.__init__(self, *args, **kwargs) self.setupUi(self) @@ -26,32 +32,32 @@ def __init__(self, parent, _map, *args, **kwargs): util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) self.load_stylesheet() - self.setWindowTitle(_map.name) - - self._map = _map + self._map = list_item.item_info + self.map_version = list_item.item_version + self.setWindowTitle(self._map.display_name) - self.Title.setText(_map.name) - self.Description.setText(_map.description) + self.Title.setText(self._map.display_name) + self.Description.setText(self.map_version.description) maptext = "" - if _map.unranked: + if not self.map_version.ranked: maptext = "Unranked map\n" - self.Info.setText("{} Uploaded {}".format(maptext, str(_map.date))) - self.Players.setText("Maximum players: {}".format(_map.maxPlayers)) - self.Size.setText("Size: {} x {} km".format(_map.width, _map.height)) + 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.updatePreview() + self.update_preview() - if maps.isBase(self._map.folderName): + 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.folderName): + elif mapgenUtils.isGeneratedMap(self.map_version.folder_name): self.DownloadButton.setEnabled(False) - elif maps.isMapAvailable(self._map.folderName): + elif maps.isMapAvailable(self.map_version.folder_name): self.DownloadButton.setText("Remove Map") self.DownloadButton.clicked.connect(self.download) @@ -60,9 +66,9 @@ def load_stylesheet(self): self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) @QtCore.pyqtSlot() - def download(self): - if not maps.isMapAvailable(self._map.folderName): - self.parent.downloadMap(self._map.link) + 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( @@ -73,24 +79,23 @@ def download(self): QtWidgets.QMessageBox.StandardButton.No, ) if show == QtWidgets.QMessageBox.StandardButton.Yes: - self.parent.removeMap(self._map.folderName) + self.parent.removeMap(self.map_version.folder_name) self.done(1) - def updatePreview(self): - imgPath = os.path.join( - util.MAP_PREVIEW_LARGE_DIR, self._map.folderName + ".png", - ) + 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.folderName): - self.Picture.setPixmap( - util.THEME.pixmap("games/generated_map.png"), - ) + 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.folderName, self._map_dl_request) + self._preview_dler.download_preview( + self.map_version.folder_name, + self._map_dl_request, + ) - def _on_preview_downloaded(self, mapname, result): + def _on_preview_downloaded(self, mapname, result: tuple[str, bool]) -> None: filename, themed = result pixmap = util.THEME.pixmap(filename, themed) if themed: diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py index e3e505a0b..a9fefc767 100644 --- a/src/vaults/modvault/moditem.py +++ b/src/vaults/modvault/moditem.py @@ -1,86 +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 VaultItem - +from vaults.vaultitem import VaultListItem -class ModItem(VaultItem): - def __init__(self, parent, uid, *args, **kwargs): - VaultItem.__init__(self, parent, *args, **kwargs) +if TYPE_CHECKING: + from vaults.modvault.modvault import ModVault - self.formatterItem = str( - util.THEME.readfile("vaults/modvault/modinfo.qthtml"), - ) - self.uid = uid - self.author = "" - self.thumbstr = "" - self.isuidmod = False - self.uploadedbyuser = False +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 shouldBeVisible(self): + def should_be_visible(self) -> bool: p = self.parent if p.showType == "all": return True elif p.showType == "ui": - return self.isuimod + return self.item_version.modtype == ModType.UI elif p.showType == "sim": - return not self.isuimod + return self.item_version.modtype == ModType.SIM elif p.showType == "yours": - return self.uploadedbyuser + return self.item_info.author == self.parent.client.login elif p.showType == "installed": - return self.uid in self.parent.uids + return self.item_version.uid in self.parent.uids else: return True - def update(self, item_dict): - self.name = item_dict["name"] - self.description = item_dict["description"] - self.version = item_dict["version"] - self.author = item_dict["author"] - self.rating = item_dict["rating"] - self.reviews = item_dict["reviews"] - self.date = item_dict['date'][:10] - self.isuimod = item_dict["ui"] - self.link = item_dict["link"] - self.thumbstr = item_dict["thumbnail"] - self.uploadedbyuser = (self.author == self.parent.client.login) - - if self.thumbstr == "": - self.setItemIcon("games/unknown_map.png") - else: - name = os.path.basename(urllib.parse.unquote(self.thumbstr)) + 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.setItemIcon(img, False) + self.set_item_icon(img, False) else: - self._preview_dler.download(name, self._item_dl_request, self.thumbstr) + self._preview_dler.download(name, self._item_dl_request, thumbstr) + else: + self.set_item_icon("games/unknown_map.png") + super().update() - VaultItem.update(self) + def update_visibility(self) -> None: + if self.item_version.modtype == ModType.UI: + modtype = "UI mod" + else: + modtype = "" - def updateVisibility(self): - if self.isuimod: - self.itemType_ = "UI mod" - if self.uid in self.parent.uids: - self.color = "green" + if self.item_version.uid in self.parent.uids: + color = "green" else: - self.color = "white" + 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.formatterItem.format( - color=self.color, - version=self.version, - title=self.name, - description=self.trimmedDescription, - rating=self.rating, - reviews=self.reviews, - date=self.date, - modtype=self.itemType_, - author=self.author, + 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=self.item_version.create_time, + modtype=modtype, + author=self.item_info.author, ), ) - - VaultItem.updateVisibility(self) + super().update_visibility() diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 3ffc0c738..6a435da9d 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -46,7 +46,8 @@ from api.vaults_api import ModApiConnector from vaults.modvault import utils -from vaults.modvault.moditem import ModItem +from vaults.modvault.moditem import ModListItem +from vaults.modvault.utils import ModInfo from vaults.vault import Vault from .modwidget import ModWidget @@ -78,12 +79,12 @@ def __init__(self, client, *args, **kwargs): self.uploadButton.hide() - def createItem(self, item_key: str) -> ModItem: - return ModItem(self, item_key) + def create_item(self, item_key: str) -> ModListItem: + return ModListItem(self, item_key) @QtCore.pyqtSlot(dict) def modInfo(self, message: dict) -> None: - super().itemsInfo(message) + super().items_info(message) @QtCore.pyqtSlot(int) def sortChanged(self, index): @@ -93,7 +94,7 @@ def sortChanged(self, index): self.sortType = "date" elif index == 2: self.sortType = "rating" - self.updateVisibilities() + self.update_visibilities() @QtCore.pyqtSlot(int) def showChanged(self, index): @@ -107,7 +108,7 @@ def showChanged(self, index): self.showType = "yours" elif index == 4: self.showType = "installed" - self.updateVisibilities() + self.update_visibilities() @QtCore.pyqtSlot(QtWidgets.QListWidgetItem) def modClicked(self, item): @@ -175,12 +176,13 @@ def openUploadForm(self): def downloadMod(self, link: str, name: str) -> bool: if utils.downloadMod(link, name): self.uids = [mod.uid for mod in utils.getInstalledMods()] - self.updateVisibilities() + self.update_visibilities() return True else: return False - def removeMod(self, mod): + 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] - mod.updateVisibility() + self.update_visibilities() diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py index 74b97b3a6..c7dcc8b1b 100644 --- a/src/vaults/modvault/modwidget.py +++ b/src/vaults/modvault/modwidget.py @@ -7,7 +7,9 @@ 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 @@ -17,7 +19,7 @@ class ModWidget(FormClass, BaseClass): ICONSIZE = QtCore.QSize(100, 100) - def __init__(self, parent, mod, *args, **kwargs): + def __init__(self, parent: QtWidgets.QWidget, mod_item: ModListItem, *args, **kwargs) -> None: BaseClass.__init__(self, *args, **kwargs) self.setupUi(self) @@ -26,25 +28,26 @@ def __init__(self, parent, mod, *args, **kwargs): util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) self.load_stylesheet() - self.setWindowTitle(mod.name) + self.mod = mod_item.item_info + self.mod_version = mod_item.item_version - self.mod = mod + self.setWindowTitle(self.mod.display_name) - self.Title.setText(mod.name) - self.Description.setText(mod.description) + self.Title.setText(self.mod.display_name) + self.Description.setText(self.mod_version.description) modtext = "" - if mod.isuimod: - modtext = "UI mod\n" + if self.mod_version.modtype == ModType.UI: + modtext = "UI mod" self.Info.setText( - modtext + "By {}\nUploaded {}".format(mod.author, str(mod.date)), + f"{modtext}\nBy {self.mod.author}\nUploaded {self.mod_version.create_time}", ) - mod.thumbnail = utils.getIcon( - os.path.basename(urllib.parse.unquote(mod.thumbstr)), + thumbnail = utils.getIcon( + os.path.basename(urllib.parse.unquote(self.mod_version.thumbnail_url)), ) - if mod.thumbnail is None: + if thumbnail is None: self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png")) else: - pixmap = util.THEME.pixmap(mod.thumbnail, False) + pixmap = util.THEME.pixmap(thumbnail, False) self.Picture.setPixmap(pixmap.scaled(self.ICONSIZE)) # ensure that pixmap is set @@ -56,7 +59,7 @@ def __init__(self, parent, mod, *args, **kwargs): self.tabWidget.setEnabled(False) - if self.mod.uid in self.parent.uids: + if self.mod_version.uid in self.parent.uids: self.DownloadButton.setText("Remove Mod") self.DownloadButton.clicked.connect(self.download) @@ -82,8 +85,8 @@ def load_stylesheet(self): @QtCore.pyqtSlot() def download(self) -> None: - if self.mod.uid not in self.parent.uids: - self.parent.downloadMod(self.mod.link, self.mod.name) + 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( @@ -94,7 +97,7 @@ def download(self) -> None: QtWidgets.QMessageBox.StandardButton.No, ) if show == QtWidgets.QMessageBox.StandardButton.Yes: - self.parent.removeMod(self.mod) + self.parent.removeMod(self.mod.display_name, self.mod_version.uid) self.done(1) @QtCore.pyqtSlot() diff --git a/src/vaults/vault.py b/src/vaults/vault.py index f7c5a577b..9b621f571 100644 --- a/src/vaults/vault.py +++ b/src/vaults/vault.py @@ -1,11 +1,17 @@ +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 VaultItem from vaults.vaultitem import VaultItemDelegate +from vaults.vaultitem import VaultListItem + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow logger = logging.getLogger(__name__) @@ -14,7 +20,7 @@ class Vault(FormClass, BaseClass, BusyWidget): - def __init__(self, client, *args, **kwargs): + def __init__(self, client: ClientWindow, *args, **kwargs) -> None: QtCore.QObject.__init__(self, *args, **kwargs) self.setupUi(self) self.client = client @@ -31,7 +37,7 @@ def __init__(self, client, *args, **kwargs): self.sortType = "alphabetical" self.showType = "all" self.searchString = "" - self.searchQuery = dict(include='latestVersion,reviewsSummary') + self.searchQuery = {} self.apiConnector = None self.pageSize = self.quantityBox.value() @@ -87,22 +93,21 @@ def goToPage(self, page: int) -> None: self.pageNumber = self.pageBox.value() self.updateQuery(self.pageNumber) self.apiConnector.requestData(self.searchQuery) - self.updateVisibilities() + self.update_visibilities() - def createItem(self, item_key: str) -> VaultItem: - return VaultItem(self, item_key) + def create_item(self, item_key: str) -> VaultListItem: + return VaultListItem(self, item_key) @QtCore.pyqtSlot(dict) - def itemsInfo(self, message: dict) -> None: + def items_info(self, message: dict) -> None: for value in message["values"]: - item_key = value[self.items_uid] + item_key = value.uid if item_key in self._items: item = self._items[item_key] else: - item = self.createItem(item_key) + item = self.create_item(value) self._items[item_key] = item self.itemList.addItem(item) - item.update(value) self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder) self.processMeta(message["meta"]) @@ -117,7 +122,7 @@ def processMeta(self, message: dict) -> None: def resetSearch(self): self.searchString = '' self.searchInput.clear() - self.searchQuery = dict(include='latestVersion,reviewsSummary') + self.searchQuery.clear() self.goToPage(1) def search(self): @@ -126,10 +131,7 @@ def search(self): self.resetSearch() else: self.searchString = self.searchString.strip() - self.searchQuery = dict( - include='latestVersion,reviewsSummary', - filter='displayName=="*{}*"'.format(self.searchString), - ) + self.searchQuery = {"filter": f"displayName=='*{self.searchString}*'"} self.goToPage(1) @QtCore.pyqtSlot() @@ -137,11 +139,10 @@ def busy_entered(self): if not self._items: self.goToPage(self.pageNumber) - def updateVisibilities(self): + def update_visibilities(self) -> None: logger.debug( - "Updating visibilities with sort '{}' and visibility '{}'" - .format(self.sortType, self.showType), + f"Updating visibilities with sort {self.sortType!r} and visibility {self.showType!r}", ) - for _item in self._items: - self._items[_item].updateVisibility() + 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 index db04d38d5..4e5cbcf00 100644 --- a/src/vaults/vaultitem.py +++ b/src/vaults/vaultitem.py @@ -1,102 +1,116 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from PyQt6 import QtCore from PyQt6 import QtGui -from PyQt6 import QtWidgets +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 VaultItem(QtWidgets.QListWidgetItem): + +class VaultListItem(QListWidgetItem): TEXTWIDTH = 230 ICONSIZE = 100 PADDING = 10 - def __init__(self, parent, *args, **kwargs): - QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs) + def __init__(self, parent: Vault, item_info: Mod | Map, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) self.parent = parent - - self.name = "" - self.description = "" - self.trimmedDescription = "" - self.version = 0 - self.rating = 0 - self.reviews = 0 - self.date = None - - self.itemType_ = "" - self.color = "white" - - self.link = "" 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.ensureIcon() - self.updateVisibility() + self.ensure_icon() + self.update_visibility() - def setItemIcon(self, filename, themed=True): + 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(): - icon.addPixmap( - pixmap.scaled( - QtCore.QSize(self.ICONSIZE, self.ICONSIZE), - ), - ) + scaled_pixmap = pixmap.scaled(QtCore.QSize(self.ICONSIZE, self.ICONSIZE)) + icon.addPixmap(scaled_pixmap) self.setIcon(icon) - def ensureIcon(self): + def ensure_icon(self): if self.icon() is None or self.icon().isNull(): - self.setItemIcon("games/unknown_map.png") + self.set_item_icon("games/unknown_map.png") - def _on_item_downloaded(self, mapname, result): + def _on_item_downloaded(self, mapname: str, result: tuple[str, bool]) -> None: filename, themed = result - self.setItemIcon(filename, themed) - self.ensureIcon() + self.set_item_icon(filename, themed) + self.ensure_icon() - def updateVisibility(self): - self.setHidden(not self.shouldBeVisible()) - if len(self.description) < 200: - self.trimmedDescription = self.description - else: - self.trimmedDescription = self.description[:197] + "..." + def should_be_hidden(self) -> bool: + return not self.should_be_visible() - self.setToolTip('

    {}

    '.format(self.description)) + def should_be_visible(self) -> bool: + return True - def __ge__(self, other): + 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): + def __lt__(self, other: VaultListItem) -> bool: if self.parent.sortType == "alphabetical": - return self.name.lower() > other.name.lower() + return self._lt_alphabetical(other) elif self.parent.sortType == "rating": - if self.rating == other.rating: - if self.reviews == other.reviews: - return self.name.lower() > other.name.lower() - return self.reviews < other.reviews - return self.rating < other.rating - elif self.parent.sortType == "size": - if self.height * self.width == other.height * other.width: - return self.name.lower() > other.name.lower() - return self.height * self.width < other.height * other.width + return self._lt_rating(other) elif self.parent.sortType == "date": - 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 + 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 -class VaultItemDelegate(QtWidgets.QStyledItemDelegate): + if review is None: + return other_review is not None + if other_review is None: + return review is None - def __init__(self, *args, **kwargs): - QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs) + 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) @@ -106,15 +120,14 @@ def paint(self, painter, option, index, *args, **kwargs): html.setHtml(option.text) icon = QtGui.QIcon(option.icon) - iconsize = QtCore.QSize(VaultItem.ICONSIZE, VaultItem.ICONSIZE) + 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 = "" - option.widget.style().drawControl( - QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, - ) + control_element = QStyle.ControlElement.CE_ItemViewItem + option.widget.style().drawControl(control_element, option, painter, option.widget) # Shadow painter.fillRect( @@ -158,12 +171,12 @@ def sizeHint(self, option, index, *args, **kwargs): html = QtGui.QTextDocument() html.setHtml(option.text) - html.setTextWidth(VaultItem.TEXTWIDTH) + html.setTextWidth(VaultListItem.TEXTWIDTH) return QtCore.QSize( ( - VaultItem.ICONSIZE - + VaultItem.TEXTWIDTH - + VaultItem.PADDING + VaultListItem.ICONSIZE + + VaultListItem.TEXTWIDTH + + VaultListItem.PADDING ), - VaultItem.ICONSIZE + VaultItem.PADDING, + VaultListItem.ICONSIZE + VaultListItem.PADDING, ) From 0e297cf321afca573f9367f4d8bd620ed99ea68f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 22 Apr 2024 21:00:06 +0300 Subject: [PATCH 043/123] FileDownload: Except possible permission error when deleting cache file --- src/downloadManager/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 9c67ffd0a..2f475f7c7 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -174,7 +174,10 @@ def _about_to_finish(self) -> None: def cleanup(self) -> None: self._output.close() if self.failed(): - os.unlink(self._cache_path) + 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) From e148c51d9bb2e7e3bb7d69d0c7335c6aa424df86 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 23 Apr 2024 22:12:18 +0300 Subject: [PATCH 044/123] Factor out a superclass for vault api connectors --- src/api/vaults_api.py | 105 +++++++++++++++++++++++------------------- src/vaults/vault.py | 2 +- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py index ba69edc93..2fff9d01a 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Sequence from api.ApiAccessors import DataApiAccessor from api.parsers.MapParser import MapParser @@ -9,30 +10,55 @@ logger = logging.getLogger(__name__) -class ModApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher) -> None: - super().__init__('/data/mod') - self.dispatch = dispatch +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.parse_data) - def requestData(self, params: dict | None = None) -> None: - params = params or {} - self._add_default_include(params) - self._extend_filters(params) - self.get_by_query(params, self.handle_data) + def _add_default_includes(self, query_options: dict) -> dict: + return self._extend_includes(query_options, self._includes) - def _add_default_include(self, params: dict) -> dict: - params["include"] = ",".join(("latestVersion", "reviewsSummary", "uploader")) - return params + 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 _extend_filters(self, params: dict) -> dict: + def _apply_default_filters(self, query_options: dict) -> dict: + cur_filters = query_options.get("filter", "") additional_filter = "latestVersion.hidden=='false'" - if cur_filters := params.get("filter", ""): - params["filter"] = f"{cur_filters};{additional_filter}" - else: - params["filter"] = additional_filter - return params + query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";") + return query_options - def handle_data(self, message: dict) -> None: + def parse_data(self, message: dict) -> None: + raise NotImplementedError + + +class ModApiConnector(VaultsApiConnector): + def __init__(self, dispatch: Dispatcher) -> None: + super().__init__("/data/mod") + self.dispatch = dispatch + + 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 parse_data(self, message: dict) -> None: parsed_data = { "command": "modvault_info", "values": ModParser.parse_many(message["data"]), @@ -41,28 +67,14 @@ def handle_data(self, message: dict) -> None: self.dispatch.dispatch(parsed_data) -class MapApiConnector(DataApiAccessor): +class MapApiConnector(VaultsApiConnector): def __init__(self, dispatch: Dispatcher) -> None: super().__init__("/data/map") self.dispatch = dispatch - def requestData(self, params: dict | None = None) -> None: - params = params or {} - self._add_default_include(params) - self._extend_filters(params) - self.get_by_query(params, self.parse_data) - - def _extend_filters(self, params: dict) -> dict: - additional_filter = "latestVersion.hidden=='false'" - if cur_filters := params.get("filter", ""): - params["filter"] = f"{cur_filters};{additional_filter}" - else: - params["filter"] = additional_filter - return params - - def _add_default_include(self, params: dict) -> dict: - params["include"] = ",".join(("latestVersion", "reviewsSummary", "author")) - return params + def _extend_query_options(self, query_options: dict) -> dict: + super()._extend_query_options(query_options) + self._extend_includes(query_options, ["author"]) def parse_data(self, message: dict) -> None: prepared_data = { @@ -73,23 +85,20 @@ def parse_data(self, message: dict) -> None: self.dispatch.dispatch(prepared_data) -class MapPoolApiConnector(DataApiAccessor): +class MapPoolApiConnector(VaultsApiConnector): def __init__(self, dispatch: Dispatcher) -> None: - super().__init__('/data/mapPoolAssignment') + super().__init__("/data/mapPoolAssignment") self.dispatch = dispatch - - def requestData(self, params: dict | None) -> None: - params = params or {} - self.get_by_query(self._add_default_include(params), self.parse_data) - - def _add_default_include(self, params: dict) -> dict: - params["include"] = ",".join(( + self._includes = ( "mapVersion", "mapVersion.map", "mapVersion.map.author", "mapVersion.map.reviewsSummary", - )) - return params + ) + + def _extend_query_options(self, query_options: dict) -> dict: + self._add_default_includes(query_options) + return query_options def parse_data(self, message: dict) -> None: prepared_data = { diff --git a/src/vaults/vault.py b/src/vaults/vault.py index 9b621f571..55792e318 100644 --- a/src/vaults/vault.py +++ b/src/vaults/vault.py @@ -92,7 +92,7 @@ def goToPage(self, page: int) -> None: self.pageBox.setValue(page) self.pageNumber = self.pageBox.value() self.updateQuery(self.pageNumber) - self.apiConnector.requestData(self.searchQuery) + self.apiConnector.request_data(self.searchQuery) self.update_visibilities() def create_item(self, item_key: str) -> VaultListItem: From ad040107c289bda7daf10a4a1072446bec2a079b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 24 Apr 2024 18:46:23 +0300 Subject: [PATCH 045/123] Do not use Dispatcher to transmit vaults' signals use bare pyqtSignal for that --- src/api/vaults_api.py | 37 +++++++++++++++------------------ src/client/connection.py | 24 ++++----------------- src/vaults/mapvault/mapvault.py | 10 ++++----- src/vaults/modvault/modvault.py | 4 ++-- 4 files changed, 28 insertions(+), 47 deletions(-) diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py index 2fff9d01a..5ff34089b 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -1,16 +1,19 @@ import logging from collections.abc import Sequence +from PyQt6.QtCore import pyqtSignal + from api.ApiAccessors import DataApiAccessor from api.parsers.MapParser import MapParser from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser from api.parsers.ModParser import ModParser -from client.connection import Dispatcher logger = logging.getLogger(__name__) class VaultsApiConnector(DataApiAccessor): + data_ready = pyqtSignal(dict) + def __init__(self, route: str) -> None: super().__init__(route) self._includes = ("latestVersion", "reviewsSummary") @@ -27,7 +30,7 @@ def _copy_query_options(self, query_options: dict | None) -> dict: 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.parse_data) + 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) @@ -44,51 +47,47 @@ def _apply_default_filters(self, query_options: dict) -> dict: query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";") return query_options - def parse_data(self, message: dict) -> None: - raise NotImplementedError + def parse_data(self, message: dict) -> dict: + return message + + def handle_response(self, message: dict) -> None: + self.data_ready.emit(self.parse_data(message)) class ModApiConnector(VaultsApiConnector): - def __init__(self, dispatch: Dispatcher) -> None: + def __init__(self) -> None: super().__init__("/data/mod") - self.dispatch = dispatch 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 parse_data(self, message: dict) -> None: - parsed_data = { - "command": "modvault_info", + def parse_data(self, message: dict) -> dict: + return { "values": ModParser.parse_many(message["data"]), "meta": message["meta"], } - self.dispatch.dispatch(parsed_data) class MapApiConnector(VaultsApiConnector): - def __init__(self, dispatch: Dispatcher) -> None: + def __init__(self) -> None: super().__init__("/data/map") - self.dispatch = dispatch def _extend_query_options(self, query_options: dict) -> dict: super()._extend_query_options(query_options) self._extend_includes(query_options, ["author"]) def parse_data(self, message: dict) -> None: - prepared_data = { - "command": "mapvault_info", + return { "values": MapParser.parse_many(message["data"]), "meta": message["meta"], } - self.dispatch.dispatch(prepared_data) class MapPoolApiConnector(VaultsApiConnector): - def __init__(self, dispatch: Dispatcher) -> None: + def __init__(self) -> None: super().__init__("/data/mapPoolAssignment") - self.dispatch = dispatch self._includes = ( "mapVersion", "mapVersion.map", @@ -101,9 +100,7 @@ def _extend_query_options(self, query_options: dict) -> dict: return query_options def parse_data(self, message: dict) -> None: - prepared_data = { - "command": "mapvault_info", + return { "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]), "meta": message["meta"], } - self.dispatch.dispatch(prepared_data) diff --git a/src/client/connection.py b/src/client/connection.py index f8928169b..b4b3b63f1 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -392,13 +392,11 @@ class LobbyInfo(QtCore.QObject): 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) social = QtCore.pyqtSignal(dict) serverSession = QtCore.pyqtSignal(dict) - mapVaultInfo = QtCore.pyqtSignal(dict) aliasInfo = QtCore.pyqtSignal(dict) matchmakerQueueInfo = QtCore.pyqtSignal(dict) @@ -418,24 +416,10 @@ def __init__(self, dispatcher, gameset, playerset): self._dispatcher["session"] = self._simple_emit(self.serverSession) self._dispatcher["alias_info"] = self._simple_emit(self.aliasInfo) self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info - self._dispatcher["modvault_info"] = self._simple_emit( - self.modVaultInfo, - ) - self._dispatcher["mapvault_info"] = self._simple_emit( - self.mapVaultInfo, - ) - 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._dispatcher["matchmaker_queue_info"] = self._simple_emit( - self.matchmakerQueueInfo, - ) + 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._dispatcher["matchmaker_queue_info"] = self._simple_emit(self.matchmakerQueueInfo) self._gameset = gameset self._playerset = playerset diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py index ba56e90da..8ae503926 100644 --- a/src/vaults/mapvault/mapvault.py +++ b/src/vaults/mapvault/mapvault.py @@ -37,7 +37,6 @@ def __init__(self, client: ClientWindow, *args, **kwargs) -> None: logger.debug("Map Vault tab instantiating") self.itemList.itemDoubleClicked.connect(self.itemClicked) - self.client.lobby_info.mapVaultInfo.connect(self.mapInfo) self.client.authorized.connect(self.busy_entered) self.installed_maps = maps.getUserMaps() @@ -47,10 +46,11 @@ def __init__(self, client: ClientWindow, *args, **kwargs) -> None: for type_ in ["Unranked Only", "Ranked Only", "Installed"]: self.ShowTypeList.addItem(type_) - self.mapApiConnector = MapApiConnector(self.client.lobby_dispatch) - self.mapPoolApiConnector = MapPoolApiConnector( - self.client.lobby_dispatch, - ) + 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.items_uid = "folderName" diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 6a435da9d..1d3e8c0bb 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -66,14 +66,14 @@ def __init__(self, client, *args, **kwargs): self.itemList.itemDoubleClicked.connect(self.modClicked) self.UIButton.clicked.connect(self.openUIModForm) - self.client.lobby_info.modVaultInfo.connect(self.modInfo) 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.client.lobby_dispatch) + self.apiConnector = ModApiConnector() + self.apiConnector.data_ready.connect(self.modInfo) self.items_uid = "uid" From 1db3a0232d15848f32838098e4656bfd591d4437 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 25 Apr 2024 22:20:35 +0300 Subject: [PATCH 046/123] Do not use Dispatcher to dispatch data from API i think it was originally supposed to dispatch server messages also, move some common parts of ApiAcessors into their base class --- src/api/ApiAccessors.py | 14 ++++ src/api/featured_mod_api.py | 21 ++---- src/api/featured_mod_updater.py | 9 +-- src/api/matchmaker_queue_api.py | 18 ++--- src/api/player_api.py | 34 ++------- src/api/replaysapi.py | 22 +----- src/api/sim_mod_updater.py | 3 - src/api/stats_api.py | 38 ++-------- src/api/vaults_api.py | 18 +---- src/client/aliasviewer.py | 6 +- src/client/connection.py | 13 ---- src/games/_gameswidget.py | 5 +- src/games/automatchframe.py | 12 +-- src/replays/_replayswidget.py | 40 +++++----- src/stats/_statswidget.py | 90 ++++------------------- src/stats/leaderboard_widget.py | 39 ++++------ src/stats/models/leaderboardtablemodel.py | 6 +- 17 files changed, 103 insertions(+), 285 deletions(-) diff --git a/src/api/ApiAccessors.py b/src/api/ApiAccessors.py index 874a02cb4..bc17ed6c5 100644 --- a/src/api/ApiAccessors.py +++ b/src/api/ApiAccessors.py @@ -1,5 +1,7 @@ import logging +from PyQt6.QtCore import pyqtSignal + from api.ApiBase import ApiBase logger = logging.getLogger(__name__) @@ -18,6 +20,8 @@ def __init__(self, route: str = "") -> None: class DataApiAccessor(ApiAccessor): + data_ready = pyqtSignal(dict) + def parse_message(self, message: dict) -> dict: included = self.parseIncluded(message) result = {} @@ -87,3 +91,13 @@ 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/featured_mod_api.py b/src/api/featured_mod_api.py index 203aad324..1577fc4cb 100644 --- a/src/api/featured_mod_api.py +++ b/src/api/featured_mod_api.py @@ -1,27 +1,18 @@ import logging from api.ApiAccessors import DataApiAccessor -from client.connection import Dispatcher logger = logging.getLogger(__name__) class FeaturedModApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher) -> None: + def __init__(self) -> None: super().__init__('/data/featuredMod') - self.dispatch = dispatch - def requestData(self) -> None: - self.get_by_query({}, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = { - "command": "mod_info_api", - "values": [], - } + def prepare_data(self, message: dict) -> None: + values = [] for mod in message["data"]: - preparedMod = { - "command": "mod_info_api", + prepared_mod = { "name": mod["technicalName"], "fullname": mod["displayName"], "publish": mod.get("visible", False), @@ -31,5 +22,5 @@ def handleData(self, message: dict) -> None: "No description provided", ), } - preparedData["values"].append(preparedMod) - self.dispatch.dispatch(preparedData) + values.append(prepared_mod) + return {"values": values} diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py index e1d2cbd6e..3a9b96163 100644 --- a/src/api/featured_mod_updater.py +++ b/src/api/featured_mod_updater.py @@ -10,10 +10,7 @@ def __init__(self, mod_id: int, version: str) -> None: super().__init__('/featuredMods/{}/files/{}'.format(mod_id, version)) self.featuredModFiles = [] - def requestData(self) -> None: - self.get_by_query({}, self.handleData) - - def handleData(self, message): + def handle_response(self, message): self.featuredModFiles = message["data"] def getFiles(self): @@ -27,10 +24,6 @@ def __init__(self) -> None: super().__init__('/data/featuredMod') self.featuredModId = 0 - def requestData(self, queryDict: dict | None = None) -> None: - queryDict = queryDict or {} - self.get_by_query(queryDict, self.handleData) - def handleFeaturedModId(self, message): self.featuredModId = message['data'][0]['id'] diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py index e4ca62d43..576a85a31 100644 --- a/src/api/matchmaker_queue_api.py +++ b/src/api/matchmaker_queue_api.py @@ -1,22 +1,16 @@ import logging from api.ApiAccessors import DataApiAccessor -from client.connection import Dispatcher logger = logging.getLogger(__name__) -class matchmakerQueueApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher) -> None: +class MatchmakerQueueApiConnector(DataApiAccessor): + def __init__(self) -> None: super().__init__('/data/matchmakerQueue') - self.dispatch = dispatch - def requestData(self, queryDict: dict | None = None) -> None: - queryDict = queryDict or {} - self.get_by_query(queryDict, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = { + def prepare_data(self, message: dict) -> None: + prepared_data = { "command": "matchmaker_queue_info", "values": [], "meta": message["meta"], @@ -28,5 +22,5 @@ def handleData(self, message: dict) -> None: "id": queue["id"], "leaderboardId": queue["leaderboard"]["id"], } - preparedData["values"].append(preparedQueue) - self.dispatch.dispatch(preparedData) + prepared_data["values"].append(preparedQueue) + return prepared_data diff --git a/src/api/player_api.py b/src/api/player_api.py index 235a22638..5253c8854 100644 --- a/src/api/player_api.py +++ b/src/api/player_api.py @@ -1,34 +1,17 @@ import logging +from PyQt6.QtCore import pyqtSignal + from api.ApiAccessors import DataApiAccessor -from client.connection import Dispatcher logger = logging.getLogger(__name__) class PlayerApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher) -> None: + alias_info = pyqtSignal(dict) + + def __init__(self) -> None: super().__init__('/data/player') - self.dispatch = dispatch - - def requestDataForLeaderboard( - self, - leaderboardName: str, - queryDict: dict | None = None, - ) -> None: - queryDict = queryDict or {} - self.leaderboardName = leaderboardName - self.get_by_query(queryDict, self.handleDataForLeaderboard) - - def handleDataForLeaderboard(self, message: dict) -> None: - preparedData = dict( - command='stats', - type='player', - leaderboardName=self.leaderboardName, - values=message['data'], - meta=message['meta'], - ) - self.dispatch.dispatch(preparedData) def requestDataForAliasViewer(self, nameToFind: str) -> None: queryDict = { @@ -42,9 +25,4 @@ def requestDataForAliasViewer(self, nameToFind: str) -> None: self.get_by_query(queryDict, self.handleDataForAliasViewer) def handleDataForAliasViewer(self, message: dict) -> None: - preparedData = dict( - command='alias_info', - values=message['data'], - meta=message['meta'], - ) - self.dispatch.dispatch(preparedData) + self.alias_info.emit(message) diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py index f76ebd17e..a9a8e99b7 100644 --- a/src/api/replaysapi.py +++ b/src/api/replaysapi.py @@ -1,30 +1,10 @@ import logging from api.ApiAccessors import DataApiAccessor -from client.connection import Dispatcher logger = logging.getLogger(__name__) class ReplaysApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher) -> None: + def __init__(self) -> None: super().__init__('/data/game') - self.dispatch = dispatch - - def requestData(self, params: dict) -> None: - self.get_by_query(params, self.handleData) - - def handleData(self, message): - preparedData = dict( - command="replay_vault", - action="search_result", - replays={}, - featuredMods={}, - maps={}, - players={}, - playerStats={}, - ) - - preparedData["replays"] = message["data"] - - self.dispatch.dispatch(preparedData) diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py index a38b44456..9b5bacbd2 100644 --- a/src/api/sim_mod_updater.py +++ b/src/api/sim_mod_updater.py @@ -10,9 +10,6 @@ def __init__(self) -> None: super().__init__('/data/modVersion') self.mod_url = "" - def requestData(self, queryDict: dict) -> None: - self.get_by_query(queryDict, self.handleData) - def get_url_from_message(self, message: dict) -> str: self.mod_url = message["data"][0]["downloadUrl"] diff --git a/src/api/stats_api.py b/src/api/stats_api.py index ad8f1fc0c..af4301ab1 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -1,46 +1,20 @@ import logging from api.ApiAccessors import DataApiAccessor -from client.connection import Dispatcher logger = logging.getLogger(__name__) class LeaderboardRatingApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher, leaderboardName: str) -> None: + def __init__(self, leaderboard_name: str) -> None: super().__init__('/data/leaderboardRating') - self.dispatch = dispatch - self.leadeboardName = leaderboardName + self.leaderboard_name = leaderboard_name - def requestData(self, queryDict: dict | None = None) -> None: - queryDict = queryDict or {} - self.get_by_query(queryDict, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = dict( - command='stats', - type='leaderboardRating', - leaderboardName=self.leadeboardName, - values=message["data"], - meta=message["meta"], - ) - self.dispatch.dispatch(preparedData) + def prepare_data(self, message: dict) -> None: + message["leaderboard"] = self.leaderboard_name + return message class LeaderboardApiConnector(DataApiAccessor): - def __init__(self, dispatch: Dispatcher | None = None) -> None: + def __init__(self) -> None: super().__init__('/data/leaderboard') - self.dispatch = dispatch - - def requestData(self, queryDict: dict | None = None) -> None: - queryDict = queryDict or {} - self.get_by_query(queryDict, self.handleData) - - def handleData(self, message: dict) -> None: - preparedData = dict( - command='stats', - type='leaderboard', - values=message["data"], - meta=message["meta"], - ) - self.dispatch.dispatch(preparedData) diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py index 5ff34089b..c1cf6504a 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -1,8 +1,6 @@ import logging from collections.abc import Sequence -from PyQt6.QtCore import pyqtSignal - from api.ApiAccessors import DataApiAccessor from api.parsers.MapParser import MapParser from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser @@ -12,8 +10,6 @@ class VaultsApiConnector(DataApiAccessor): - data_ready = pyqtSignal(dict) - def __init__(self, route: str) -> None: super().__init__(route) self._includes = ("latestVersion", "reviewsSummary") @@ -47,12 +43,6 @@ def _apply_default_filters(self, query_options: dict) -> dict: query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";") return query_options - def parse_data(self, message: dict) -> dict: - return message - - def handle_response(self, message: dict) -> None: - self.data_ready.emit(self.parse_data(message)) - class ModApiConnector(VaultsApiConnector): def __init__(self) -> None: @@ -63,9 +53,9 @@ def _extend_query_options(self, query_options: dict) -> dict: self._extend_includes(query_options, ["uploader"]) return query_options - def parse_data(self, message: dict) -> dict: + def prepare_data(self, message: dict) -> dict: return { - "values": ModParser.parse_many(message["data"]), + "values": ModParser.parse_many(message["data"]), "meta": message["meta"], } @@ -78,7 +68,7 @@ def _extend_query_options(self, query_options: dict) -> dict: super()._extend_query_options(query_options) self._extend_includes(query_options, ["author"]) - def parse_data(self, message: dict) -> None: + def prepare_data(self, message: dict) -> None: return { "values": MapParser.parse_many(message["data"]), "meta": message["meta"], @@ -99,7 +89,7 @@ def _extend_query_options(self, query_options: dict) -> dict: self._add_default_includes(query_options) return query_options - def parse_data(self, message: dict) -> None: + def prepare_data(self, message: dict) -> None: return { "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]), "meta": message["meta"], diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py index 58a2d1355..6996511ce 100644 --- a/src/client/aliasviewer.py +++ b/src/client/aliasviewer.py @@ -14,8 +14,8 @@ class AliasViewer: def __init__(self, client, alias_formatter): self.client = client self.formatter = alias_formatter - self.api_connector = PlayerApiConnector(self.client.lobby_dispatch) - self.client.lobby_info.aliasInfo.connect(self.process_alias_info) + self.api_connector = PlayerApiConnector() + self.api_connector.alias_info.connect(self.process_alias_info) self.name_to_find = "" self.searching = False self.timer = QTimer() @@ -37,7 +37,7 @@ def process_alias_info(self, message): self.stop_alias_search() player_aliases, other_users = [], [] - for player in message["values"]: + for player in message["data"]: if player["login"].lower() == self.name_to_find.lower(): player_aliases.append({ "name": player["login"], diff --git a/src/client/connection.py b/src/client/connection.py index b4b3b63f1..e1257b76e 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -391,14 +391,11 @@ class LobbyInfo(QtCore.QObject): statsInfo = QtCore.pyqtSignal(dict) coopInfo = QtCore.pyqtSignal(dict) tutorialsInfo = QtCore.pyqtSignal(dict) - modInfo = QtCore.pyqtSignal(dict) replayVault = QtCore.pyqtSignal(dict) coopLeaderBoard = QtCore.pyqtSignal(dict) avatarList = QtCore.pyqtSignal(list) social = QtCore.pyqtSignal(dict) serverSession = QtCore.pyqtSignal(dict) - aliasInfo = QtCore.pyqtSignal(dict) - matchmakerQueueInfo = QtCore.pyqtSignal(dict) def __init__(self, dispatcher, gameset, playerset): QtCore.QObject.__init__(self) @@ -406,20 +403,15 @@ def __init__(self, dispatcher, gameset, playerset): self._dispatcher = dispatcher self._dispatcher["stats"] = self._simple_emit(self.statsInfo) self._dispatcher["coop_info"] = self._simple_emit(self.coopInfo) - self._dispatcher["mod_info_api"] = self._simple_emit(self.modInfo) - self._dispatcher["mod_info"] = lambda _: None self._dispatcher["game_info"] = self.handle_game_info 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["alias_info"] = self._simple_emit(self.aliasInfo) - self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info 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._dispatcher["matchmaker_queue_info"] = self._simple_emit(self.matchmakerQueueInfo) self._gameset = gameset self._playerset = playerset @@ -454,11 +446,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.modVaultInfo.emit(mod) - def handle_avatar(self, message): if "avatarlist" in message: self.avatarList.emit(message["avatarlist"]) diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index 72283fe59..650778b8c 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -97,7 +97,8 @@ def __init__( self._game_model = CustomGameFilterModel(self._me, game_model) self._game_launcher = game_launcher - self.apiConnector = FeaturedModApiConnector(self.client.lobby_dispatch) + self.apiConnector = FeaturedModApiConnector() + self.apiConnector.data_ready.connect(self.processModInfo) self.gameview = gameview_builder(self._game_model, self.gameList) self.gameview.game_double_clicked.connect(self.gameDoubleClicked) @@ -106,8 +107,6 @@ def __init__( self.ispassworded = False self.party = None - self.client.lobby_info.modInfo.connect(self.processModInfo) - self.client.matchmaker_info.connect(self.handleMatchmakerInfo) self.client.game_enter.connect(self.stopSearch) self.client.viewing_replay.connect(self.stopSearch) diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py index 2b8f6cec5..2a2aa75ea 100644 --- a/src/games/automatchframe.py +++ b/src/games/automatchframe.py @@ -10,7 +10,7 @@ import fa import util -from api.matchmaker_queue_api import matchmakerQueueApiConnector +from api.matchmaker_queue_api import MatchmakerQueueApiConnector from config import Settings from fa.factions import Factions @@ -89,13 +89,9 @@ def __init__( self.secondsToAutomatch = 0 self.ratingType = "" - self.client.lobby_info.matchmakerQueueInfo.connect( - self.handleApiQueueInfo, - ) - self.apiConnector = matchmakerQueueApiConnector( - self.client.lobby_dispatch, - ) - self.apiConnector.requestData(queryDict=dict(include="leaderboard")) + self.apiConnector = MatchmakerQueueApiConnector() + self.apiConnector.data_ready.connect(self.handleApiQueueInfo) + self.apiConnector.requestData({"include": "leaderboard"}) title = self.queueName.replace("_", " ").capitalize() self.automatchTitle.setText(title) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 8eb0288ae..d7ad42e63 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -683,8 +683,8 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): self.onlineReplays = {} self.selectedReplay = None - self.apiConnector = ReplaysApiConnector(self._dispatcher) - 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.onDownloadFinished) self.toolboxHandler = ReplayToolboxHandler( @@ -1038,27 +1038,25 @@ def onDownloadFinished(self, reply): faf_replay.close() replay(os.path.join(util.CACHE_DIR, "temp.fafreplay")) - def replayVault(self, message): - action = message["action"] + def process_replays_data(self, message: dict) -> None: self.stopSearchVault() self._w.replayInfos.clear() - if action == "search_result": - self.onlineReplays = {} - replays = message["replays"] - 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["replays"]) == 0: - self._w.searchInfoLabel.setText( - "No replays found", - ) - self._w.advSearchInfoLabel.setText( - "No replays found", - ) + 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, it won't be part of the new tree diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py index b8f7f494e..a13bef632 100644 --- a/src/stats/_statswidget.py +++ b/src/stats/_statswidget.py @@ -29,8 +29,6 @@ def __init__(self, client): self.client = client - self.client.lobby_info.statsInfo.connect(self.processStatsInfos) - self.selected_player = None self.selected_player_loaded = False self.leagues.currentChanged.connect(self.leagueUpdate) @@ -56,8 +54,9 @@ def __init__(self, client): # setup other tabs - self.apiConnector = LeaderboardApiConnector(self.client.lobby_dispatch) - self.apiConnector.requestData(dict(sort="id")) + 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)) @@ -242,78 +241,19 @@ def leaderboardsTabChanged(self, curr): if self.leaderboards.widget(curr) is not None: self.leaderboards.widget(curr).entered() - @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 == "leaderboard": - self.leaderboardNames.clear() - for value in message["values"]: - self.leaderboardNames.append(value["technicalName"]) - for i in range(len(self.leaderboardNames)): - self.leaderboards.insertTab( - i, - LeaderboardWidget( - self.client, self, self.leaderboardNames[i], - ), - self.leaderboardNames[i].capitalize().replace("_", " "), - ) - self.client.replays.leaderboardList.addItem( - self.leaderboardNames[i], - ) - - self.leaderboards.setCurrentIndex(1) - self.leaderboards.currentChanged.connect( - self.leaderboardsTabChanged, + 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): diff --git a/src/stats/leaderboard_widget.py b/src/stats/leaderboard_widget.py index a8a8d1636..a10dc476d 100644 --- a/src/stats/leaderboard_widget.py +++ b/src/stats/leaderboard_widget.py @@ -41,12 +41,9 @@ def __init__( self.client = client self.parent = parent self.leaderboardName = leaderboardName - self.apiConnector = LeaderboardRatingApiConnector( - self.client.lobby_dispatch, self.leaderboardName, - ) - self.playerApiConnector = PlayerApiConnector( - self.client.lobby_dispatch, - ) + 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 @@ -77,7 +74,6 @@ def __init__( self.pageBox.valueChanged.connect(self.checkTotalPages) self.refreshButton.clicked.connect(self.refreshLeaderboard) - self.client.lobby_info.statsInfo.connect(self.processStatsInfos) self.findInPageLine.textChanged.connect(self.findEntry) self.findInPageLine.returnPressed.connect( lambda: self.findEntry(self.findInPageLine.text()), @@ -169,16 +165,12 @@ def showColumns(self): self.showColumnCheckBoxes[index].blockSignals(False) - def processStatsInfos(self, message): - if message["type"] == "leaderboardRating": - if message["leaderboardName"] == self.leaderboardName: - self.createLeaderboard(message) - self.processMeta(message["meta"]) - self.resetLoading() - self.timer.stop() - elif message["type"] == "player": - if message["leaderboardName"] == self.leaderboardName: - self.createPlayerCompleter(message) + 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) @@ -220,20 +212,15 @@ def findEntry(self, text): self.tableView.selectRow(self.model.logins.index(row)) break - def searchPlayer(self): + def searchPlayer(self) -> None: query = { "filter": 'login=="{}*"'.format(self.searchPlayerLine.text()), "page[size]": 10, } - self.playerApiConnector.requestDataForLeaderboard( - self.leaderboardName, query, - ) - - def createPlayerCompleter(self, message): - logins = [] - for value in message["values"]: - logins.append(value["login"]) + 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()), diff --git a/src/stats/models/leaderboardtablemodel.py b/src/stats/models/leaderboardtablemodel.py index 18b04dd69..c01c95d06 100644 --- a/src/stats/models/leaderboardtablemodel.py +++ b/src/stats/models/leaderboardtablemodel.py @@ -9,14 +9,14 @@ def __init__(self, data=None): QAbstractTableModel.__init__(self) self.load_data(data) - def load_data(self, data): - self.values = data["values"] + 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["values"]) + self.row_count = len(data["data"]) def rowCount(self, parent=QModelIndex()): return self.row_count From 53bdebcd36d6aeeca7ab265809af6def82e50ade Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 25 Apr 2024 22:28:49 +0300 Subject: [PATCH 047/123] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0eb7f6ad..2a40de6c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer From 4426540b88de2559b9e585bcaa21fdc99f843efe Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 26 Apr 2024 23:05:31 +0300 Subject: [PATCH 048/123] Create api model for FeaturedMod --- src/api/featured_mod_api.py | 16 ++-------------- src/api/models/FeaturedMod.py | 10 ++++++++++ src/api/parsers/FeaturedModParser.py | 21 +++++++++++++++++++++ src/games/_gameswidget.py | 14 +++++++------- src/games/moditem.py | 18 +++++++++--------- 5 files changed, 49 insertions(+), 30 deletions(-) create mode 100644 src/api/models/FeaturedMod.py create mode 100644 src/api/parsers/FeaturedModParser.py diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py index 1577fc4cb..164e9332d 100644 --- a/src/api/featured_mod_api.py +++ b/src/api/featured_mod_api.py @@ -1,6 +1,7 @@ import logging from api.ApiAccessors import DataApiAccessor +from api.parsers.FeaturedModParser import FeaturedModParser logger = logging.getLogger(__name__) @@ -10,17 +11,4 @@ def __init__(self) -> None: super().__init__('/data/featuredMod') def prepare_data(self, message: dict) -> None: - values = [] - for mod in message["data"]: - prepared_mod = { - "name": mod["technicalName"], - "fullname": mod["displayName"], - "publish": mod.get("visible", False), - "order": mod.get("order", 0), - "desc": mod.get( - "description", - "No description provided", - ), - } - values.append(prepared_mod) - return {"values": values} + return {"values": FeaturedModParser.parse_many(message["data"])} diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py new file mode 100644 index 000000000..abb8f9030 --- /dev/null +++ b/src/api/models/FeaturedMod.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass +class FeaturedMod: + name: str + fullname: str + visible: bool + order: int + description: str diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py new file mode 100644 index 000000000..2ebfcdd9a --- /dev/null +++ b/src/api/parsers/FeaturedModParser.py @@ -0,0 +1,21 @@ +from api.models.FeaturedMod import FeaturedMod + + +class FeaturedModParser: + + @staticmethod + def parse(data: dict) -> FeaturedMod: + return FeaturedMod( + name=data["technicalName"], + fullname=data["displayName"], + visible=data.get("visible", False), + order=data.get("order", 0), + description=data.get( + "description", + "No description provided", + ), + ) + + @staticmethod + def parse_many(data: list[dict]) -> list[FeaturedMod]: + return [FeaturedModParser.parse(info) for info in data] diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index 650778b8c..9d1a2b057 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -98,7 +98,7 @@ def __init__( self._game_launcher = game_launcher self.apiConnector = FeaturedModApiConnector() - self.apiConnector.data_ready.connect(self.processModInfo) + 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) @@ -172,14 +172,14 @@ def onLogOut(self): self.matchmakerFramesInitialized = False @pyqtSlot(dict) - def processModInfo(self, message): + def process_mod_info(self, message: dict) -> None: """ Slot that interprets and propagates mod_info messages into the mod list """ - for value in message["values"]: - mod = value['name'] + for featured_mod in message["values"]: + mod = featured_mod.name old_mod = self.mods.get(mod, None) - self.mods[mod] = ModItem(value) + self.mods[mod] = ModItem(featured_mod) if old_mod: if mod in mod_invisible: @@ -191,12 +191,12 @@ def processModInfo(self, message): if self.client.replays.modList.itemText(i) == old_mod.mod: self.client.replays.modList.removeItem(i) - if value["publish"]: + if featured_mod.visible: self.modList.addItem(self.mods[mod]) else: mod_invisible[mod] = self.mods[mod] - self.client.replays.modList.addItem(value["name"]) + self.client.replays.modList.addItem(mod) @pyqtSlot(int) def togglePrivateGames(self, state): diff --git a/src/games/moditem.py b/src/games/moditem.py index 93fd64680..7142083f1 100644 --- a/src/games/moditem.py +++ b/src/games/moditem.py @@ -5,6 +5,7 @@ import client import util +from api.models.FeaturedMod import FeaturedMod # Maps names of featured mods to ModItem objects. mods = {} @@ -18,18 +19,17 @@ 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) @@ -56,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 From ab20f214128d482dfb9faef6ced40b567d2f4ba4 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 27 Apr 2024 21:47:24 +0300 Subject: [PATCH 049/123] Clean up updater a little * remove unused code from Updater class * fix duplications in updating logic * do not redownload ForgedAlliance.exe file -- patch it if needed So we take files of specific version from API and update all except ForgedAlliance.exe -- versions for this file are broken (at least for fafbeta and fafdevelop featured mods) -- we patch it ourselves: patching is just writing version to special addresses --- src/config/production.py | 2 + src/fa/check.py | 14 +- src/fa/mods.py | 2 +- src/fa/replayparser.py | 2 +- src/fa/updater.py | 444 +++++++++++++++------------------------ 5 files changed, 176 insertions(+), 288 deletions(-) diff --git a/src/config/production.py b/src/config/production.py index dc872ba18..e0f805a5a 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -28,6 +28,8 @@ '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, diff --git a/src/fa/check.py b/src/fa/check.py index 166f5eec5..6e9275d8d 100644 --- a/src/fa/check.py +++ b/src/fa/check.py @@ -150,12 +150,12 @@ def checkMovies(files): def check( - featured_mod, - mapname=None, - version=None, - modVersions=None, - sim_mods=None, - silent=False, + 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 @@ -182,7 +182,7 @@ def check( ) result = game_updater.run() - if result != fa.updater.Updater.RESULT_SUCCESS: + if result != fa.updater.UpdaterResult.SUCCESS: return False try: diff --git a/src/fa/mods.py b/src/fa/mods.py index ff367ea60..a05b78afd 100644 --- a/src/fa/mods.py +++ b/src/fa/mods.py @@ -48,7 +48,7 @@ def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name # Spawn an update for the required mod updater = fa.updater.Updater("sim", sim_mod=item) result = updater.run() - if result != fa.updater.Updater.RESULT_SUCCESS: + if result != fa.updater.UpdaterResult.SUCCESS: logger.warning(f"Failure getting {item}") return False diff --git a/src/fa/replayparser.py b/src/fa/replayparser.py index a6d647da8..6bc708e1b 100644 --- a/src/fa/replayparser.py +++ b/src/fa/replayparser.py @@ -35,7 +35,7 @@ def getVersion(self): 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: diff --git a/src/fa/updater.py b/src/fa/updater.py index 77f2d7d9b..950388034 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -13,11 +13,11 @@ import shutil import stat import time +from enum import Enum from PyQt6 import QtCore from PyQt6 import QtWidgets -import config import util from api.featured_mod_updater import FeaturedModFiles from api.featured_mod_updater import FeaturedModId @@ -93,21 +93,20 @@ 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 + ILLEGAL = 3 # User has the wrong version of FA + BUSY = 4 # Server is currently busy + PASS = 5 # User refuses to update by canceling the wizard + + class Updater(QtCore.QObject): """ This is the class that does the actual installation work. """ - # Network configuration - 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, @@ -124,19 +123,13 @@ def __init__( """ QtCore.QObject.__init__(self, *args, **kwargs) - self.filesToUpdate = [] - self.updatedFiles = [] - - self.lastData = time.time() - self.featured_mod = featured_mod self.version = version self.modversions = modversions - self.sim_mod = sim_mod - self.modpath = None + self.silent = silent - self.result = self.RESULT_NONE + self.result = UpdaterResult.NONE self.keep_cache = not Settings.get( 'cache/do_not_keep', type=bool, default=True, @@ -144,12 +137,7 @@ def __init__( self.in_session_cache = Settings.get( 'cache/in_session', type=bool, default=False, ) - self.fmod_cache_dir = os.path.join(util.CACHE_DIR, 'featured_mod') - if self.keep_cache or self.in_session_cache: - if not os.path.exists(self.fmod_cache_dir): - os.mkdir(self.fmod_cache_dir) - self.silent = silent self.progress = QtWidgets.QProgressDialog() if self.silent: self.progress.setCancelButton(None) @@ -161,11 +149,7 @@ def __init__( self.progress.setAutoClose(False) self.progress.setAutoReset(False) self.progress.setModal(1) - self.progress.setWindowTitle( - "Updating {}".format(self.featured_mod.upper()), - ) - - self.bytesToSend = 0 + self.progress.setWindowTitle(f"Updating {self.featured_mod.upper()}") def run(self, *args, **kwargs): clearLog() @@ -181,29 +165,41 @@ def run(self, *args, **kwargs): if not self.progress.wasCanceled(): log("Connected to update server at {}".format(timestamp())) - self.doUpdate() + self.do_update() self.progress.setLabelText("Cleaning up.") self.progress.close() else: log("Cancelled connecting to server.") - self.result = self.RESULT_CANCEL + self.result = UpdaterResult.CANCEL log("Update finished at {}".format(timestamp())) return self.result - def getFilesToUpdate(self, id_, version): - return FeaturedModFiles(id_, version).getFiles() + def get_files_to_update(self, mod_id: int, version: str) -> list[dict]: + return FeaturedModFiles(mod_id, version).getFiles() - def getFeaturedModIdByName(self, technicalName): - return FeaturedModId().requestAndGetFeaturedModIdByName(technicalName) + def get_featured_mod_id_by_name(self, technical_name: str) -> int: + return FeaturedModId().requestAndGetFeaturedModIdByName(technical_name) def request_sim_url_by_uid(self, uid: str) -> str: return SimModFiles().request_and_get_sim_mod_url_by_id(uid) - def fetch_file(self, file_info: dict, filegroup: str) -> None: + @staticmethod + def _file_needs_update(file_info: dict) -> bool: + filegroup = file_info["group"] + filename = file_info["name"] + filepath = os.path.join(util.APPDATA_DIR, filegroup, filename) + return filename != Settings.get("game/exe-name") and util.md5(filepath) != file_info["md5"] + + def fetch_files(self, files: list[dict]) -> None: + for file in files: + self.fetch_single_file(file) + + def fetch_single_file(self, file_info: dict) -> None: name = file_info["name"] + filegroup = file_info["group"] url = file_info["cacheableUrl"] target_dir = os.path.join(util.APPDATA_DIR, filegroup) @@ -226,122 +222,84 @@ def fetch_file(self, file_info: dict, filegroup: str) -> None: "Operation aborted while waiting for data.", ) - def moveFromCache(self, files, filegroup): - src_dir = os.path.join(util.APPDATA_DIR, filegroup) - cache_dir = os.path.join(self.fmod_cache_dir, filegroup) - for _file in files: - 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 moveToCache(self, files, filegroup): - src_dir = os.path.join(util.APPDATA_DIR, filegroup) - cache_dir = os.path.join(self.fmod_cache_dir, filegroup) - for _file in files: - if os.path.exists(os.path.join(src_dir, _file['name'])): - 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)) - - def replaceFromCache(self, files, filegroup): - self.moveToCache(files, filegroup) - self.moveFromCache(files, filegroup) - - def checkCache(self, filegroup, files_to_check): - cache_dir = os.path.join(self.fmod_cache_dir, filegroup) - if not os.path.exists(cache_dir): - os.mkdir(cache_dir) - for src_dir, _, _files in os.walk(cache_dir): - files_in_cache = _files - replaceable_files, need_to_download = [], [] - for _file in files_to_check: - if _file['md5'] in files_in_cache: - replaceable_files.append(_file) - self.filesToUpdate.remove(_file) - else: - need_to_download.append(_file) - return replaceable_files, need_to_download - - def updateFiles(self, filegroup, files): - """ - Updates the files in a given file group, in the destination - subdirectory of the Forged Alliance path. - """ - QtWidgets.QApplication.processEvents() + def move_many_from_cache(self, files: list[dict]) -> None: + for file in files: + self.move_from_cache(file) + + def move_from_cache(self, file_info: dict) -> None: + src_dir = os.path.join(util.APPDATA_DIR, file_info["group"]) + cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"]) + if os.path.exists(os.path.join(cache_dir, file_info["md5"])): + shutil.move( + os.path.join(cache_dir, file_info["md5"]), + os.path.join(src_dir, file_info["name"]), + ) - self.progress.setLabelText("Updating files: " + filegroup) + def move_many_to_cache(self, files: list[dict]) -> None: + for file in files: + self.move_to_cache(file) + + def move_to_cache(self, file_info: dict) -> None: + src_dir = os.path.join(util.APPDATA_DIR, file_info["group"]) + cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"]) + if os.path.exists(os.path.join(src_dir, file_info["name"])): + md5 = util.md5(os.path.join(src_dir, file_info["name"])) + shutil.move( + os.path.join(src_dir, file_info["name"]), + os.path.join(cache_dir, md5), + ) + util.setAccessTime(os.path.join(cache_dir, md5)) - targetdir = os.path.join(util.APPDATA_DIR, filegroup) - if not os.path.exists(targetdir): - os.makedirs(targetdir) + def replace_from_cache(self, file: dict) -> None: + self.move_to_cache(file) + self.move_from_cache(file) - files_to_check = [] + def replace_many_from_cache(self, files: list[dict]) -> None: + for file in files: + self.replace_from_cache(file) - for _file in files: - md5File = util.md5( - os.path.join(util.APPDATA_DIR, filegroup, _file['name']), - ) - md5NewFile = _file['md5'] - if md5File == md5NewFile: - self.filesToUpdate.remove(_file) + def check_cache(self, files_to_check: list[dict]) -> None: + replaceable_files, need_to_download = [], [] + for file in files_to_check: + cache_dir = os.path.join(util.GAME_CACHE_DIR, file["group"]) + os.makedirs(cache_dir, exist_ok=True) + if self._is_cached(file): + replaceable_files.append(file) else: - if self.keep_cache or self.in_session_cache: - files_to_check.append(_file) - else: - self.fetch_file(_file, filegroup) - self.filesToUpdate.remove(_file) - self.updatedFiles.append(_file['name']) + need_to_download.append(file) + return replaceable_files, need_to_download - if len(files_to_check) > 0: - replaceable_files, need_to_download = ( - self.checkCache(filegroup, files_to_check) - ) - self.replaceFromCache(replaceable_files, filegroup) - for _file in need_to_download: - self.moveToCache([_file], filegroup) - self.fetch_file(_file, filegroup) - self.filesToUpdate.remove(_file) - self.updatedFiles.append(_file['name']) + @staticmethod + def _is_cached(file_info: dict) -> bool: + cached_file = os.path.join(util.GAME_CACHE_DIR, file_info["group"], file_info["name"]) + return os.path.isfile(cached_file) - self.waitUntilFilesAreUpdated() + def create_cache_subdirs(self, files: list[dict]) -> None: + for file in files: + target = os.path.join(util.GAME_CACHE_DIR, file["group"]) + os.makedirs(target, exist_ok=True) - def waitUntilFilesAreUpdated(self): + def update_files(self, files: list[dict]) -> None: """ - A simple loop that updates the progress bar while the server sends - actual file data + Updates the files in the destination + subdirectory of the Forged Alliance path. """ - 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.", - ) + self.create_cache_subdirs(files) + self.patch_fa_exe_if_needed(files) - if self.result != self.RESULT_NONE: - raise UpdaterFailure( - "Operation failed while waiting for data.", - ) + to_update = list(filter(self._file_needs_update, files)) + replacable_files, need_to_download = self.check_cache(to_update) - if time.time() - self.lastData > self.TIMEOUT: - raise UpdaterTimeout( - "Connection timed out while waiting for data.", - ) - - QtWidgets.QApplication.processEvents() + if self.keep_cache or self.in_session_cache: + self.replace_many_from_cache(replacable_files) + self.move_many_to_cache(need_to_download) + else: + self.move_many_from_cache(replacable_files) + self.fetch_files(need_to_download) log("Updates applied successfully.") - def prepareBinFAF(self): + 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 @@ -350,9 +308,7 @@ def prepareBinFAF(self): 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', - ) + 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, @@ -374,170 +330,100 @@ def prepareBinFAF(self): # need to patch them os.chmod(dst_file, st.st_mode | stat.S_IWRITE) - def doUpdate(self) -> None: + self.download_fa_executable() + + def download_fa_executable(self) -> bool: + fa_exe_name = Settings.get("game/exe-name") + fa_exe = os.path.join(util.BIN_DIR, fa_exe_name) + + if os.path.isfile(fa_exe): + return True + + url = Settings.get("game/exe-url") + return download_file( + url=url, + target_dir=util.BIN_DIR, + name=fa_exe_name, + category="Update", + silent=False, + label=f"Downloading FA file : {url}

    ", + ) + + def patch_fa_executable(self, version: int) -> None: + exe_path = os.path.join(util.BIN_DIR, "ForgedAlliance.exe") + version_addresses = (0xd3d40, 0x47612d, 0x476666) + with open(exe_path, "rb+") as file: + for address in version_addresses: + file.seek(address) + file.write(version.to_bytes(4, "little")) + + def patch_fa_exe_if_needed(self, files: list[dict]) -> None: + for file in files: + if file["name"] == Settings.get("game/exe-name"): + version = int(self._resolve_base_version(file)) + self.patch_fa_executable(version) + return + + def update_featured_mod(self, modname: str, modversion: str) -> list[dict]: + fmod_id = self.get_featured_mod_id_by_name(modname) + files = self.get_files_to_update(fmod_id, 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, base_info: dict | None) -> str: + if self.version: + return str(self.version) + if base_info: + return str(base_info["version"]) + return "latest" + + def do_update(self) -> None: """ The core function that does most of the actual update work.""" try: if self.sim_mod: uid, name = self.sim_mod if utils.downloadMod(self.request_sim_url_by_uid(uid), name): - self.result = self.RESULT_SUCCESS + self.result = UpdaterResult.SUCCESS else: - self.result = self.RESULT_FAILURE + self.result = UpdaterResult.FAILURE else: # Prepare FAF directory & all necessary files - self.prepareBinFAF() - - toUpdate = [] - filesToUpdateInBin = [] - filesToUpdateInGamedata = [] - + self.prepare_bin_FAF() # Update the mod if it's requested - if ( - self.featured_mod == "faf" - # HACK - ladder1v1 "is" FAF. :-) - or self.featured_mod == "ladder1v1" - ): - if self.version: - # id for faf (or ladder1v1) is 0 - toUpdate = self.getFilesToUpdate('0', self.version) - else: - toUpdate = self.getFilesToUpdate('0', 'latest') - - for _file in toUpdate: - if _file['group'] == 'bin': - filesToUpdateInBin.append(_file) - else: - filesToUpdateInGamedata.append(_file) - - self.filesToUpdate = filesToUpdateInBin.copy() - self.updateFiles("bin", filesToUpdateInBin) - self.filesToUpdate = filesToUpdateInGamedata.copy() - self.updateFiles("gamedata", filesToUpdateInGamedata) - - elif ( - self.featured_mod == 'fafbeta' - or self.featured_mod == 'fafdevelop' - ): - # no need to update faf first for these mods - id_ = self.getFeaturedModIdByName(self.featured_mod) - if self.modversions: - modversion = sorted( - self.modversions.items(), - key=lambda item: item[1], - reverse=True, - )[0][1] - else: - modversion = 'latest' - - toUpdate = self.getFilesToUpdate(id_, modversion) - toUpdate.sort( - key=lambda item: item['version'], - reverse=True, - ) - - # file lists for fafbeta and fafdevelop contain wrong - # version of ForgedAlliance.exe - if self.version: - faf_version = self.version - else: - faf_version = toUpdate[0]['version'] - - for _file in toUpdate: - if _file['group'] == 'bin': - if _file['name'] != 'ForgedAlliance.exe': - filesToUpdateInBin.append(_file) - else: - filesToUpdateInGamedata.append(_file) - - self.filesToUpdate = filesToUpdateInBin.copy() - self.updateFiles('bin', filesToUpdateInBin) - self.filesToUpdate = filesToUpdateInGamedata.copy() - self.updateFiles('gamedata', filesToUpdateInGamedata) - - filesToUpdateInBin.clear() - filesToUpdateInGamedata.clear() - - # update proper version of bin - toUpdate = self.getFilesToUpdate('0', faf_version) - - for _file in toUpdate: - if _file['group'] == 'bin': - filesToUpdateInBin.append(_file) - - self.filesToUpdate = filesToUpdateInBin.copy() - self.updateFiles('bin', filesToUpdateInBin) - + if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"): + self.update_featured_mod(self.featured_mod, self._resolve_base_version()) else: # update faf first - # id for faf (or ladder1v1) is 0 - if self.version: - toUpdate = self.getFilesToUpdate('0', self.version) - else: - toUpdate = self.getFilesToUpdate('0', 'latest') - - for _file in toUpdate: - if _file['group'] == 'bin': - filesToUpdateInBin.append(_file) - else: - filesToUpdateInGamedata.append(_file) - - self.filesToUpdate = filesToUpdateInBin.copy() - self.updateFiles("bin", filesToUpdateInBin) - self.filesToUpdate = filesToUpdateInGamedata.copy() - self.updateFiles("gamedata", filesToUpdateInGamedata) - - filesToUpdateInBin.clear() - filesToUpdateInGamedata.clear() - - # update featuredMod then - id_ = self.getFeaturedModIdByName(self.featured_mod) - if self.modversions: - modversion = sorted( - self.modversions.items(), - key=lambda item: item[1], - reverse=True, - )[0][1] - else: - modversion = 'latest' - - toUpdate = self.getFilesToUpdate(id_, modversion) - - for _file in toUpdate: - if _file['group'] == 'bin': - filesToUpdateInBin.append(_file) - else: - filesToUpdateInGamedata.append(_file) - - self.filesToUpdate = filesToUpdateInBin.copy() - self.updateFiles("bin", filesToUpdateInBin) - self.filesToUpdate = filesToUpdateInGamedata.copy() - self.updateFiles("gamedata", filesToUpdateInGamedata) - - except UpdaterTimeout as e: - log("TIMEOUT: {}".format(e)) - self.result = self.RESULT_FAILURE + self.update_featured_mod("faf", self._resolve_base_version()) + # update featured mod then + self.update_featured_mod(self.featured_mod, self._resolve_modversion()) except UpdaterCancellation as e: log("CANCELLED: {}".format(e)) - self.result = self.RESULT_CANCEL + self.result = UpdaterResult.CANCEL except BaseException as e: log("EXCEPTION: {}".format(e)) - self.result = self.RESULT_FAILURE + self.result = UpdaterResult.FAILURE else: - self.result = self.RESULT_SUCCESS + self.result = UpdaterResult.SUCCESS # 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: + if self.result == UpdaterResult.CANCEL: pass # The user knows damn well what happened here. - elif self.result == self.RESULT_PASS: + elif self.result == UpdaterResult.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: + elif self.result == UpdaterResult.BUSY: QtWidgets.QMessageBox.information( QtWidgets.QApplication.activeWindow(), "Server Busy", @@ -546,7 +432,7 @@ def doUpdate(self) -> None: "again later." ), ) - elif self.result == self.RESULT_FAILURE: + elif self.result == UpdaterResult.FAILURE: failureDialog() # If nothing terribly bad happened until now, From e0f4d78c68794ffb9f0b8e7964e56b99ca17daa2 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 28 Apr 2024 10:11:00 +0300 Subject: [PATCH 050/123] Updater: Be more specific with Qt imports --- src/fa/updater.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index 950388034..78d716c40 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -15,8 +15,13 @@ import time from enum import Enum -from PyQt6 import QtCore -from PyQt6 import QtWidgets +from PyQt6.QtCore import QObject +from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QDialog +from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QProgressDialog import util from api.featured_mod_updater import FeaturedModFiles @@ -43,22 +48,22 @@ def __init__(self, parent): self.adjustSize() self.watches = [] - @QtCore.pyqtSlot(str) + @pyqtSlot(str) def appendLog(self, text): self.logPlainTextEdit.appendPlainText(text) - @QtCore.pyqtSlot(QtCore.QObject) + @pyqtSlot(QObject) def addWatch(self, watch): self.watches.append(watch) watch.finished.connect(self.watchFinished) - @QtCore.pyqtSlot() + @pyqtSlot() def watchFinished(self) -> None: for watch in self.watches: if not watch.isFinished(): return # equivalent to self.accept(), but clearer - self.done(QtWidgets.QDialog.DialogCode.Accepted) + self.done(QDialog.DialogCode.Accepted) def clearLog(): @@ -103,7 +108,7 @@ class UpdaterResult(Enum): PASS = 5 # User refuses to update by canceling the wizard -class Updater(QtCore.QObject): +class Updater(QObject): """ This is the class that does the actual installation work. """ @@ -121,7 +126,7 @@ def __init__( """ Constructor """ - QtCore.QObject.__init__(self, *args, **kwargs) + super().__init__(*args, **kwargs) self.featured_mod = featured_mod self.version = version @@ -138,13 +143,13 @@ def __init__( 'cache/in_session', type=bool, default=False, ) - self.progress = QtWidgets.QProgressDialog() + self.progress = QProgressDialog() if self.silent: self.progress.setCancelButton(None) else: self.progress.setCancelButtonText("Cancel") self.progress.setWindowFlags( - QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, + Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, ) self.progress.setAutoClose(False) self.progress.setAutoReset(False) @@ -157,7 +162,7 @@ def run(self, *args, **kwargs): log("Using appdata: " + util.APPDATA_DIR) self.progress.show() - QtWidgets.QApplication.processEvents() + QApplication.processEvents() # Actual network code adapted from previous version self.progress.setLabelText("Connecting to update server...") @@ -418,14 +423,14 @@ def do_update(self) -> None: if self.result == UpdaterResult.CANCEL: pass # The user knows damn well what happened here. elif self.result == UpdaterResult.PASS: - QtWidgets.QMessageBox.information( - QtWidgets.QApplication.activeWindow(), + QMessageBox.information( + QApplication.activeWindow(), "Installation Required", "You can't play without a legal version of Forged Alliance.", ) elif self.result == UpdaterResult.BUSY: - QtWidgets.QMessageBox.information( - QtWidgets.QApplication.activeWindow(), + QMessageBox.information( + QApplication.activeWindow(), "Server Busy", ( "The Server is busy preparing new patch files.
    Try " From 638ab1b135cb08ed4be02541652b5ee9a7e6855d Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 29 Apr 2024 19:57:41 +0300 Subject: [PATCH 051/123] Do not require optional argument --- src/fa/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index 78d716c40..e58d10802 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -380,7 +380,7 @@ def _resolve_modversion(self) -> str: return str(max(self.modversions.values())) return "latest" - def _resolve_base_version(self, base_info: dict | None) -> str: + def _resolve_base_version(self, base_info: dict | None = None) -> str: if self.version: return str(self.version) if base_info: From 2c36817ecc0af2ade61220d94c07504383c42c9f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 30 Apr 2024 19:44:27 +0300 Subject: [PATCH 052/123] Unpack movies inside Updater because it has the information about files being updated --- src/fa/check.py | 57 ----------------------------------------------- src/fa/updater.py | 3 +++ src/fa/utils.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 57 deletions(-) create mode 100644 src/fa/utils.py diff --git a/src/fa/check.py b/src/fa/check.py index 6e9275d8d..7bf6465cc 100644 --- a/src/fa/check.py +++ b/src/fa/check.py @@ -1,9 +1,6 @@ from __future__ import annotations -import binascii import logging -import os -import zipfile from typing import TYPE_CHECKING from PyQt6 import QtWidgets @@ -102,53 +99,6 @@ def game(parent): return True -def crc32(fname): - try: - with open(fname) as stream: - return binascii.crc32(stream.read()) - except BaseException: - 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 BaseException: - 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: str, mapname: str | None = None, @@ -185,13 +135,6 @@ def check( if result != fa.updater.UpdaterResult.SUCCESS: return False - try: - if len(game_updater.updatedFiles) > 0: - checkMovies(game_updater.updatedFiles) - except BaseException: - logger.exception('Error checking game files for movies') - return False - # Now it's down to having the right map if mapname: if not map_(mapname, silent=silent): diff --git a/src/fa/updater.py b/src/fa/updater.py index e58d10802..755759f83 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -28,6 +28,7 @@ from api.featured_mod_updater import FeaturedModId from api.sim_mod_updater import SimModFiles from config import Settings +from fa.utils import unpack_movies from vaults.dialogs import download_file from vaults.modvault import utils @@ -302,6 +303,8 @@ def update_files(self, files: list[dict]) -> None: self.move_many_from_cache(replacable_files) self.fetch_files(need_to_download) + + unpack_movies(to_update) log("Updates applied successfully.") def prepare_bin_FAF(self) -> None: diff --git a/src/fa/utils.py b/src/fa/utils.py new file mode 100644 index 000000000..9cd0a59e7 --- /dev/null +++ b/src/fa/utils.py @@ -0,0 +1,54 @@ +import binascii +import logging +import os +import zipfile + +from util import APPDATA_DIR + +logger = logging.getLogger(__name__) + + +def crc32(filepath: str) -> int | None: + try: + with open(filepath) as stream: + return binascii.crc32(stream.read()) + except Exception as e: + logger.exception(f"CRC check fail! Details: {e}") + return None + + +def unpack_movies(files: list[dict]) -> None: + """ + 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(f"checking updated files: {files}") + + # construct dirs + gd = os.path.join(APPDATA_DIR, "gamedata") + + for file in files: + fname = file["name"] + origpath = os.path.join(gd, fname) + + 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}") + continue + + for zi in zf.infolist(): + if zi.filename.startswith("movies"): + 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) From 8628d66e9a5e84a305325d0d1a8033e27f549a7b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 1 May 2024 03:50:48 +0300 Subject: [PATCH 053/123] Use correct PyQt namespace in SecondaryServer this server doesn't work anyway and was deprecated ages ago and shoudl be pruned together with unused widgets --- src/secondaryServer/secondaryserver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/secondaryServer/secondaryserver.py b/src/secondaryServer/secondaryserver.py index d51557a2f..49da8bd26 100644 --- a/src/secondaryServer/secondaryserver.py +++ b/src/secondaryServer/secondaryserver.py @@ -164,19 +164,19 @@ 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: + if socketError == QtNetwork.QAbstractSocket.SocketError.RemoteHostClosedError: log( "FA Server down: The server is down for maintenance, please " "try later.", ) - elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError: + elif socketError == QtNetwork.QAbstractSocket.SocketError.HostNotFoundError: log( "Connection to Host lost. Please check the host name and port " "settings.", ) - elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError: + elif socketError == QtNetwork.QAbstractSocket.SocketError.ConnectionRefusedError: log("The connection was refused by the peer.") else: log( From 67f237fe560db48fe7e79526538d9b5cdf2a8cd7 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 1 May 2024 20:03:53 +0300 Subject: [PATCH 054/123] Prune featured_mod_updater remove FeaturedModId class -- use existing FeaturedModApiConnector --- src/api/featured_mod_api.py | 31 +++++++++++++++++++++-- src/api/featured_mod_updater.py | 37 ---------------------------- src/api/models/FeaturedMod.py | 1 + src/api/parsers/FeaturedModParser.py | 1 + src/fa/updater.py | 17 +++++++------ 5 files changed, 40 insertions(+), 47 deletions(-) delete mode 100644 src/api/featured_mod_updater.py diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py index 164e9332d..c779c9c2a 100644 --- a/src/api/featured_mod_api.py +++ b/src/api/featured_mod_api.py @@ -1,6 +1,7 @@ import logging from api.ApiAccessors import DataApiAccessor +from api.models.FeaturedMod import FeaturedMod from api.parsers.FeaturedModParser import FeaturedModParser logger = logging.getLogger(__name__) @@ -8,7 +9,33 @@ class FeaturedModApiConnector(DataApiAccessor): def __init__(self) -> None: - super().__init__('/data/featuredMod') + super().__init__("/data/featuredMod") - def prepare_data(self, message: dict) -> None: + 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 FeaturedModFiles(DataApiAccessor): + def __init__(self, mod_id: str, version: str) -> None: + super().__init__(f"/featuredMods/{mod_id}/files/{version}") + self.featuredModFiles = [] + + def handle_response(self, message): + self.featuredModFiles = message["data"] + + def get_files(self): + self.requestData() + self.waitForCompletion() + return self.featuredModFiles diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py deleted file mode 100644 index 3a9b96163..000000000 --- a/src/api/featured_mod_updater.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging - -from api.ApiAccessors import DataApiAccessor - -logger = logging.getLogger(__name__) - - -class FeaturedModFiles(DataApiAccessor): - def __init__(self, mod_id: int, version: str) -> None: - super().__init__('/featuredMods/{}/files/{}'.format(mod_id, version)) - self.featuredModFiles = [] - - def handle_response(self, message): - self.featuredModFiles = message["data"] - - def getFiles(self): - self.requestData() - self.waitForCompletion() - return self.featuredModFiles - - -class FeaturedModId(DataApiAccessor): - def __init__(self) -> None: - super().__init__('/data/featuredMod') - self.featuredModId = 0 - - def handleFeaturedModId(self, message): - self.featuredModId = message['data'][0]['id'] - - def requestFeaturedModIdByName(self, technicalName: str) -> None: - queryDict = dict(filter='technicalName=={}'.format(technicalName)) - self.get_by_query(queryDict, self.handleFeaturedModId) - - def requestAndGetFeaturedModIdByName(self, technicalName): - self.requestFeaturedModIdByName(technicalName) - self.waitForCompletion() - return self.featuredModId diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py index abb8f9030..0d08d37b8 100644 --- a/src/api/models/FeaturedMod.py +++ b/src/api/models/FeaturedMod.py @@ -3,6 +3,7 @@ @dataclass class FeaturedMod: + uid: str name: str fullname: str visible: bool diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py index 2ebfcdd9a..11fb83f09 100644 --- a/src/api/parsers/FeaturedModParser.py +++ b/src/api/parsers/FeaturedModParser.py @@ -6,6 +6,7 @@ class FeaturedModParser: @staticmethod def parse(data: dict) -> FeaturedMod: return FeaturedMod( + uid=data["id"], name=data["technicalName"], fullname=data["displayName"], visible=data.get("visible", False), diff --git a/src/fa/updater.py b/src/fa/updater.py index 755759f83..f864e284a 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -24,8 +24,9 @@ from PyQt6.QtWidgets import QProgressDialog import util -from api.featured_mod_updater import FeaturedModFiles -from api.featured_mod_updater import FeaturedModId +from api.featured_mod_api import FeaturedModApiConnector +from api.featured_mod_api import FeaturedModFiles +from api.models.FeaturedMod import FeaturedMod from api.sim_mod_updater import SimModFiles from config import Settings from fa.utils import unpack_movies @@ -183,11 +184,11 @@ def run(self, *args, **kwargs): log("Update finished at {}".format(timestamp())) return self.result - def get_files_to_update(self, mod_id: int, version: str) -> list[dict]: - return FeaturedModFiles(mod_id, version).getFiles() + def get_files_to_update(self, mod_id: str, version: str) -> list[dict]: + return FeaturedModFiles(mod_id, version).get_files() - def get_featured_mod_id_by_name(self, technical_name: str) -> int: - return FeaturedModId().requestAndGetFeaturedModIdByName(technical_name) + def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod: + return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name) def request_sim_url_by_uid(self, uid: str) -> str: return SimModFiles().request_and_get_sim_mod_url_by_id(uid) @@ -373,8 +374,8 @@ def patch_fa_exe_if_needed(self, files: list[dict]) -> None: return def update_featured_mod(self, modname: str, modversion: str) -> list[dict]: - fmod_id = self.get_featured_mod_id_by_name(modname) - files = self.get_files_to_update(fmod_id, modversion) + fmod = self.get_featured_mod_by_name(modname) + files = self.get_files_to_update(fmod.uid, modversion) self.update_files(files) return files From 7df55f17c2440c3cb5b033f7c3077102eb4dfa01 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 2 May 2024 22:17:17 +0300 Subject: [PATCH 055/123] CoopWidget: Fix sim_mods being incorrectly checked on game join --- src/coop/_coopwidget.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 035cc8620..de70bd265 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -13,6 +13,7 @@ from coop.coopmapitem import CoopMapItemDelegate from coop.coopmodel import CoopGameFilterModel from fa.replay import replay +from model.game import Game from ui.busy_widget import BusyWidget logger = logging.getLogger(__name__) @@ -52,7 +53,7 @@ def __init__( 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) @@ -278,16 +279,14 @@ def processCoopInfo(self, message): self.coop[uid] = itemCoop - 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: From 62177ca65d75e64bf7f1a656857465c5e5ebcdc4 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 3 May 2024 17:06:37 +0300 Subject: [PATCH 056/123] Make checking md5 and unpacking movies less UI-blocking spawn/use QProgressDialog to block other windows from receving inputs and call setValue() on progress done (which calls processEvents interally) --- src/fa/updater.py | 32 +++++++++++++++++---- src/fa/utils.py | 73 +++++++++++++++++++++++++++++------------------ 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index f864e284a..27a7392d1 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -195,10 +195,29 @@ def request_sim_url_by_uid(self, uid: str) -> str: @staticmethod def _file_needs_update(file_info: dict) -> bool: - filegroup = file_info["group"] - filename = file_info["name"] - filepath = os.path.join(util.APPDATA_DIR, filegroup, filename) - return filename != Settings.get("game/exe-name") and util.md5(filepath) != file_info["md5"] + return ( + file_info["name"] != Settings.get("game/exe-name") + and file_info["old_md5"] != file_info["md5"] + ) + + def _calc_md5s(self, files: list[dict]) -> None: + self.progress.setMaximum(len(files)) + + for index, file_info in enumerate(files, start=1): + + if self.progress.wasCanceled(): + raise UpdaterCancellation() + + filegroup = file_info["group"] + filename = file_info["name"] + filepath = os.path.join(util.APPDATA_DIR, filegroup, filename) + + self.progress.setLabelText(f"Calculating md5 for {filename}...") + + file_info["old_md5"] = util.md5(filepath) + + self.progress.setValue(index) + self.progress.setMaximum(0) def fetch_files(self, files: list[dict]) -> None: for file in files: @@ -293,6 +312,9 @@ def update_files(self, files: list[dict]) -> None: """ self.create_cache_subdirs(files) self.patch_fa_exe_if_needed(files) + self._calc_md5s(files) + + self.progress.setLabelText("Updating files...") to_update = list(filter(self._file_needs_update, files)) replacable_files, need_to_download = self.check_cache(to_update) @@ -305,7 +327,7 @@ def update_files(self, files: list[dict]) -> None: self.fetch_files(need_to_download) - unpack_movies(to_update) + unpack_movies(files) log("Updates applied successfully.") def prepare_bin_FAF(self) -> None: diff --git a/src/fa/utils.py b/src/fa/utils.py index 9cd0a59e7..5167601c0 100644 --- a/src/fa/utils.py +++ b/src/fa/utils.py @@ -3,6 +3,9 @@ import os import zipfile +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QProgressDialog + from util import APPDATA_DIR logger = logging.getLogger(__name__) @@ -10,45 +13,59 @@ def crc32(filepath: str) -> int | None: try: - with open(filepath) as stream: + with open(filepath, "rb") as stream: return binascii.crc32(stream.read()) except Exception as e: - logger.exception(f"CRC check fail! Details: {e}") + logger.exception(f"CRC check for {filepath!r} fail! Details: {e}") return None -def unpack_movies(files: list[dict]) -> None: +def unpack_movies_from_file(file_info: dict) -> None: """ 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(f"checking updated files: {files}") - # construct dirs gd = os.path.join(APPDATA_DIR, "gamedata") - for file in files: - fname = file["name"] - origpath = os.path.join(gd, fname) - - 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}") - continue - - for zi in zf.infolist(): - if zi.filename.startswith("movies"): - 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) + fname = file_info["name"] + origpath = os.path.join(gd, fname) + + 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(): + if zi.filename.startswith("movies") 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) + + +def unpack_movies(files: list[dict]) -> None: + logger.info("Checking files for movies") + + progress = QProgressDialog() + progress.setWindowTitle("Updating Movies") + progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) + progress.setModal(True) + progress.setCancelButton(None) + progress.setMaximum(len(files)) + progress.setValue(0) + + for index, file in enumerate(files, start=1): + filename = file["name"] + progress.setLabelText(f"Checking for movies in {filename}...") + unpack_movies_from_file(file) + progress.setValue(index) From c25637d1ab2ce72f0147b3cb4b0ea0d123df0489 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 3 May 2024 18:03:33 +0300 Subject: [PATCH 057/123] Unpack sounds along with movies from gamefiles --- src/fa/updater.py | 4 ++-- src/fa/utils.py | 22 +++++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index 27a7392d1..e3d012c73 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -29,7 +29,7 @@ from api.models.FeaturedMod import FeaturedMod from api.sim_mod_updater import SimModFiles from config import Settings -from fa.utils import unpack_movies +from fa.utils import unpack_movies_and_sounds from vaults.dialogs import download_file from vaults.modvault import utils @@ -327,7 +327,7 @@ def update_files(self, files: list[dict]) -> None: self.fetch_files(need_to_download) - unpack_movies(files) + unpack_movies_and_sounds(files) log("Updates applied successfully.") def prepare_bin_FAF(self) -> None: diff --git a/src/fa/utils.py b/src/fa/utils.py index 5167601c0..33500a777 100644 --- a/src/fa/utils.py +++ b/src/fa/utils.py @@ -20,10 +20,10 @@ def crc32(filepath: str) -> int | None: return None -def unpack_movies_from_file(file_info: dict) -> None: +def unpack_movies_and_sounds_from_file(file_info: dict) -> None: """ - Unpacks movies (based on path in zipfile) to the movies folder. - Movies must be unpacked for FA to be able to play them. + 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. """ @@ -41,7 +41,11 @@ def unpack_movies_from_file(file_info: dict) -> None: return for zi in zf.infolist(): - if zi.filename.startswith("movies") and not zi.is_dir(): + 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 @@ -53,11 +57,11 @@ def unpack_movies_from_file(file_info: dict) -> None: zf.extract(zi, APPDATA_DIR) -def unpack_movies(files: list[dict]) -> None: - logger.info("Checking files for movies") +def unpack_movies_and_sounds(files: list[dict]) -> None: + logger.info("Checking files for movies and sounds") progress = QProgressDialog() - progress.setWindowTitle("Updating Movies") + progress.setWindowTitle("Updating Movies and Sounds") progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) progress.setModal(True) progress.setCancelButton(None) @@ -66,6 +70,6 @@ def unpack_movies(files: list[dict]) -> None: for index, file in enumerate(files, start=1): filename = file["name"] - progress.setLabelText(f"Checking for movies in {filename}...") - unpack_movies_from_file(file) + progress.setLabelText(f"Checking for movies and sounds in {filename}...") + unpack_movies_and_sounds_from_file(file) progress.setValue(index) From 4c81a0d42e4ad246a4e17df3929d3964634dd16a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 3 May 2024 18:16:13 +0300 Subject: [PATCH 058/123] Updater: Do not close progress prematurely --- src/fa/updater.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index e3d012c73..2e2758a5f 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -442,9 +442,6 @@ def do_update(self) -> None: else: self.result = UpdaterResult.SUCCESS - # Hide progress dialog if it's still showing. - self.progress.close() - # Integrated handlers for the various things that could go wrong if self.result == UpdaterResult.CANCEL: pass # The user knows damn well what happened here. From d1e795f076bd213340d3f79e50274fc5eb3b99ee Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 May 2024 09:41:36 +0300 Subject: [PATCH 059/123] Create api model for FeaturedModFile --- src/api/featured_mod_api.py | 14 ++-- src/api/models/FeaturedModFile.py | 14 ++++ src/api/parsers/FeaturedModFileParser.py | 21 ++++++ src/fa/updater.py | 93 ++++++++++++------------ src/fa/utils.py | 11 ++- 5 files changed, 93 insertions(+), 60 deletions(-) create mode 100644 src/api/models/FeaturedModFile.py create mode 100644 src/api/parsers/FeaturedModFileParser.py diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py index c779c9c2a..9d1f67b7c 100644 --- a/src/api/featured_mod_api.py +++ b/src/api/featured_mod_api.py @@ -2,6 +2,8 @@ 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__) @@ -27,15 +29,15 @@ def request_and_get_fmod_by_name(self, technicalName) -> FeaturedMod: return self.featured_mod -class FeaturedModFiles(DataApiAccessor): +class FeaturedModFilesApiConnector(DataApiAccessor): def __init__(self, mod_id: str, version: str) -> None: super().__init__(f"/featuredMods/{mod_id}/files/{version}") - self.featuredModFiles = [] + self.featured_mod_files = [] - def handle_response(self, message): - self.featuredModFiles = message["data"] + def handle_response(self, message: dict) -> None: + self.featured_mod_files = FeaturedModFileParser.parse_many(message["data"]) - def get_files(self): + def get_files(self) -> list[FeaturedModFile]: self.requestData() self.waitForCompletion() - return self.featuredModFiles + return self.featured_mod_files diff --git a/src/api/models/FeaturedModFile.py b/src/api/models/FeaturedModFile.py new file mode 100644 index 000000000..8162c8540 --- /dev/null +++ b/src/api/models/FeaturedModFile.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass +class FeaturedModFile: + uid: str + version: int + group: str + name: str + md5: str + url: str + cacheable_url: str + hmac_token: str + hmac_parameter: str diff --git a/src/api/parsers/FeaturedModFileParser.py b/src/api/parsers/FeaturedModFileParser.py new file mode 100644 index 000000000..ee2acecc7 --- /dev/null +++ b/src/api/parsers/FeaturedModFileParser.py @@ -0,0 +1,21 @@ +from api.models.FeaturedModFile import FeaturedModFile + + +class FeaturedModFileParser: + @staticmethod + def parse(api_result: dict) -> FeaturedModFile: + return FeaturedModFile( + uid=api_result["id"], + version=api_result["version"], + group=api_result["group"], + name=api_result["name"], + md5=api_result["md5"], + url=api_result["url"], + cacheable_url=api_result["cacheableUrl"], + hmac_token=api_result["hmacToken"], + hmac_parameter=api_result["hmacParameter"], + ) + + @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/fa/updater.py b/src/fa/updater.py index 2e2758a5f..2b9293a3c 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -25,8 +25,9 @@ import util from api.featured_mod_api import FeaturedModApiConnector -from api.featured_mod_api import FeaturedModFiles +from api.featured_mod_api import FeaturedModFilesApiConnector from api.models.FeaturedMod import FeaturedMod +from api.models.FeaturedModFile import FeaturedModFile from api.sim_mod_updater import SimModFiles from config import Settings from fa.utils import unpack_movies_and_sounds @@ -185,7 +186,7 @@ def run(self, *args, **kwargs): return self.result def get_files_to_update(self, mod_id: str, version: str) -> list[dict]: - return FeaturedModFiles(mod_id, version).get_files() + 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) @@ -194,50 +195,46 @@ def request_sim_url_by_uid(self, uid: str) -> str: return SimModFiles().request_and_get_sim_mod_url_by_id(uid) @staticmethod - def _file_needs_update(file_info: dict) -> bool: + def _file_needs_update(file: FeaturedModFile) -> bool: return ( - file_info["name"] != Settings.get("game/exe-name") - and file_info["old_md5"] != file_info["md5"] + file.name != Settings.get("game/exe-name") + and file.old_md5 != file.md5 ) - def _calc_md5s(self, files: list[dict]) -> None: + def _calc_md5s(self, files: list[FeaturedModFile]) -> None: self.progress.setMaximum(len(files)) - for index, file_info in enumerate(files, start=1): + for index, file in enumerate(files, start=1): if self.progress.wasCanceled(): raise UpdaterCancellation() - filegroup = file_info["group"] - filename = file_info["name"] - filepath = os.path.join(util.APPDATA_DIR, filegroup, filename) + filepath = os.path.join(util.APPDATA_DIR, file.group, file.name) - self.progress.setLabelText(f"Calculating md5 for {filename}...") + self.progress.setLabelText(f"Calculating md5 for {file.name}...") - file_info["old_md5"] = util.md5(filepath) + file.old_md5 = util.md5(filepath) self.progress.setValue(index) self.progress.setMaximum(0) - def fetch_files(self, files: list[dict]) -> None: + def fetch_files(self, files: list[FeaturedModFile]) -> None: for file in files: self.fetch_single_file(file) - def fetch_single_file(self, file_info: dict) -> None: - name = file_info["name"] - filegroup = file_info["group"] - url = file_info["cacheableUrl"] - target_dir = os.path.join(util.APPDATA_DIR, filegroup) + def fetch_single_file(self, file: FeaturedModFile) -> None: + target_dir = os.path.join(util.APPDATA_DIR, file.group) + url = file.cacheable_url logger.info(f"Updater: Downloading {url}") downloaded = download_file( url=url, target_dir=target_dir, - name=name, + name=file.name, category="Update", silent=False, - request_params={file_info["hmacParameter"]: file_info["hmacToken"]}, + request_params={file.hmac_parameter: file.hmac_token}, label=f"Downloading FA file : {url}

    ", ) @@ -248,46 +245,46 @@ def fetch_single_file(self, file_info: dict) -> None: "Operation aborted while waiting for data.", ) - def move_many_from_cache(self, files: list[dict]) -> None: + def move_many_from_cache(self, files: list[FeaturedModFile]) -> None: for file in files: self.move_from_cache(file) - def move_from_cache(self, file_info: dict) -> None: - src_dir = os.path.join(util.APPDATA_DIR, file_info["group"]) - cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"]) - if os.path.exists(os.path.join(cache_dir, file_info["md5"])): + 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_info["md5"]), - os.path.join(src_dir, file_info["name"]), + os.path.join(cache_dir, file.md5), + os.path.join(src_dir, file.name), ) def move_many_to_cache(self, files: list[dict]) -> None: for file in files: self.move_to_cache(file) - def move_to_cache(self, file_info: dict) -> None: - src_dir = os.path.join(util.APPDATA_DIR, file_info["group"]) - cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"]) - if os.path.exists(os.path.join(src_dir, file_info["name"])): - md5 = util.md5(os.path.join(src_dir, file_info["name"])) + def move_to_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(src_dir, file.name)): + md5 = util.md5(os.path.join(src_dir, file.name)) shutil.move( - os.path.join(src_dir, file_info["name"]), + os.path.join(src_dir, file.name), os.path.join(cache_dir, md5), ) util.setAccessTime(os.path.join(cache_dir, md5)) - def replace_from_cache(self, file: dict) -> None: + def replace_from_cache(self, file: FeaturedModFile) -> None: self.move_to_cache(file) self.move_from_cache(file) - def replace_many_from_cache(self, files: list[dict]) -> None: + def replace_many_from_cache(self, files: list[FeaturedModFile]) -> None: for file in files: self.replace_from_cache(file) - def check_cache(self, files_to_check: list[dict]) -> None: + def check_cache(self, files_to_check: list[FeaturedModFile]) -> None: replaceable_files, need_to_download = [], [] for file in files_to_check: - cache_dir = os.path.join(util.GAME_CACHE_DIR, file["group"]) + cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group) os.makedirs(cache_dir, exist_ok=True) if self._is_cached(file): replaceable_files.append(file) @@ -296,16 +293,16 @@ def check_cache(self, files_to_check: list[dict]) -> None: return replaceable_files, need_to_download @staticmethod - def _is_cached(file_info: dict) -> bool: - cached_file = os.path.join(util.GAME_CACHE_DIR, file_info["group"], file_info["name"]) + def _is_cached(file: FeaturedModFile) -> bool: + cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name) return os.path.isfile(cached_file) - def create_cache_subdirs(self, files: list[dict]) -> None: + def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None: for file in files: - target = os.path.join(util.GAME_CACHE_DIR, file["group"]) + target = os.path.join(util.GAME_CACHE_DIR, file.group) os.makedirs(target, exist_ok=True) - def update_files(self, files: list[dict]) -> None: + def update_files(self, files: list[FeaturedModFile]) -> None: """ Updates the files in the destination subdirectory of the Forged Alliance path. @@ -388,14 +385,14 @@ def patch_fa_executable(self, version: int) -> None: file.seek(address) file.write(version.to_bytes(4, "little")) - def patch_fa_exe_if_needed(self, files: list[dict]) -> None: + def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None: for file in files: - if file["name"] == Settings.get("game/exe-name"): + if file.name == Settings.get("game/exe-name"): version = int(self._resolve_base_version(file)) self.patch_fa_executable(version) return - def update_featured_mod(self, modname: str, modversion: str) -> list[dict]: + 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.uid, modversion) self.update_files(files) @@ -406,11 +403,11 @@ def _resolve_modversion(self) -> str: return str(max(self.modversions.values())) return "latest" - def _resolve_base_version(self, base_info: dict | None = None) -> str: + def _resolve_base_version(self, exe_info: FeaturedModFile | None = None) -> str: if self.version: return str(self.version) - if base_info: - return str(base_info["version"]) + if exe_info: + return str(exe_info.version) return "latest" def do_update(self) -> None: diff --git a/src/fa/utils.py b/src/fa/utils.py index 33500a777..78355342a 100644 --- a/src/fa/utils.py +++ b/src/fa/utils.py @@ -6,6 +6,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QProgressDialog +from api.models.FeaturedModFile import FeaturedModFile from util import APPDATA_DIR logger = logging.getLogger(__name__) @@ -20,7 +21,7 @@ def crc32(filepath: str) -> int | None: return None -def unpack_movies_and_sounds_from_file(file_info: dict) -> None: +def unpack_movies_and_sounds_from_file(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. @@ -30,8 +31,7 @@ def unpack_movies_and_sounds_from_file(file_info: dict) -> None: # construct dirs gd = os.path.join(APPDATA_DIR, "gamedata") - fname = file_info["name"] - origpath = os.path.join(gd, fname) + origpath = os.path.join(gd, file.name) if os.path.exists(origpath) and zipfile.is_zipfile(origpath): try: @@ -57,7 +57,7 @@ def unpack_movies_and_sounds_from_file(file_info: dict) -> None: zf.extract(zi, APPDATA_DIR) -def unpack_movies_and_sounds(files: list[dict]) -> None: +def unpack_movies_and_sounds(files: list[FeaturedModFile]) -> None: logger.info("Checking files for movies and sounds") progress = QProgressDialog() @@ -69,7 +69,6 @@ def unpack_movies_and_sounds(files: list[dict]) -> None: progress.setValue(0) for index, file in enumerate(files, start=1): - filename = file["name"] - progress.setLabelText(f"Checking for movies and sounds in {filename}...") + progress.setLabelText(f"Checking for movies and sounds in {file.name}...") unpack_movies_and_sounds_from_file(file) progress.setValue(index) From 6773cc56fbda12234ecf9ea6a78aeb84119bda52 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 May 2024 09:44:29 +0300 Subject: [PATCH 060/123] Updater: Get fa exe name from settings everywhere --- src/fa/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index 2b9293a3c..81cd8eb06 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -378,7 +378,7 @@ def download_fa_executable(self) -> bool: ) def patch_fa_executable(self, version: int) -> None: - exe_path = os.path.join(util.BIN_DIR, "ForgedAlliance.exe") + exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name")) version_addresses = (0xd3d40, 0x47612d, 0x476666) with open(exe_path, "rb+") as file: for address in version_addresses: From d30ba146f39ca9c84fe3041af95314846e0fbe6a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 4 May 2024 11:32:27 +0300 Subject: [PATCH 061/123] Do not add attributes to FeaturedModFile after it is initialized and keep the ability of it (and other api models) to become immutable (frozen) in the future --- src/fa/updater.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index 81cd8eb06..2c8b94378 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -14,6 +14,7 @@ import stat import time from enum import Enum +from functools import partial from PyQt6.QtCore import QObject from PyQt6.QtCore import Qt @@ -195,15 +196,15 @@ def request_sim_url_by_uid(self, uid: str) -> str: return SimModFiles().request_and_get_sim_mod_url_by_id(uid) @staticmethod - def _file_needs_update(file: FeaturedModFile) -> bool: - return ( - file.name != Settings.get("game/exe-name") - and file.old_md5 != file.md5 - ) + def _file_needs_update(file: FeaturedModFile, md5s: dict[str, str]) -> bool: + incoming_md5 = file.md5 + current_md5 = md5s[file.md5] + return file.name != Settings.get("game/exe-name") and current_md5 != incoming_md5 - def _calc_md5s(self, files: list[FeaturedModFile]) -> None: + def _calc_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]: self.progress.setMaximum(len(files)) + result = {} for index, file in enumerate(files, start=1): if self.progress.wasCanceled(): @@ -213,10 +214,11 @@ def _calc_md5s(self, files: list[FeaturedModFile]) -> None: self.progress.setLabelText(f"Calculating md5 for {file.name}...") - file.old_md5 = util.md5(filepath) + result[file.md5] = util.md5(filepath) self.progress.setValue(index) self.progress.setMaximum(0) + return result def fetch_files(self, files: list[FeaturedModFile]) -> None: for file in files: @@ -309,11 +311,11 @@ def update_files(self, files: list[FeaturedModFile]) -> None: """ self.create_cache_subdirs(files) self.patch_fa_exe_if_needed(files) - self._calc_md5s(files) + md5s = self._calc_md5s(files) self.progress.setLabelText("Updating files...") - to_update = list(filter(self._file_needs_update, files)) + to_update = list(filter(partial(self._file_needs_update, md5s=md5s), files)) replacable_files, need_to_download = self.check_cache(to_update) if self.keep_cache or self.in_session_cache: From 2764bf7c65bee214f5477bcd6100789bdc0feef0 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 5 May 2024 13:04:14 +0300 Subject: [PATCH 062/123] Fail update if sim mod wasn't downloaded download function doesn't (normally) raise exceptions and 'result' attribute gets assigned to UpdaterResult.SUCCESS thanks to try..else block --- src/fa/updater.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/fa/updater.py b/src/fa/updater.py index 2c8b94378..dbbb2e53c 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -417,10 +417,8 @@ def do_update(self) -> None: try: if self.sim_mod: uid, name = self.sim_mod - if utils.downloadMod(self.request_sim_url_by_uid(uid), name): - self.result = UpdaterResult.SUCCESS - else: - self.result = UpdaterResult.FAILURE + if not utils.downloadMod(self.request_sim_url_by_uid(uid), name): + raise UpdaterFailure("Sim mod wasn't downloaded") else: # Prepare FAF directory & all necessary files self.prepare_bin_FAF() From 817425a01fb61a06849e8a2fe3f9d51ac571f5b1 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 6 May 2024 22:12:25 +0300 Subject: [PATCH 063/123] Check possible map preview files more thoroughly coop missions preview files do not contain version (e.g. '.v0039') and also can be in different case Windows doesn't care about cases, but Linux does although client most likely doesn't work on Linux at the moment we don't want to break Linux compatibility even further --- src/fa/maps.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/fa/maps.py b/src/fa/maps.py index fc49c8d4f..c973e9b87 100644 --- a/src/fa/maps.py +++ b/src/fa/maps.py @@ -218,7 +218,10 @@ def genPrevFromDDS(sourcename: str, destname: str, small: bool = False) -> None: 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 """ @@ -240,10 +243,11 @@ def exportPreviewFromMap(mapname, positions=None): return previews mapname = os.path.basename(mapdir).lower() + mapname_no_version, *_ = mapname.partition(".") if isGeneratedMap(mapname): mapfilename = os.path.join(mapdir, mapname + ".scmap") else: - mapfilename = os.path.join(mapdir, mapname.split(".")[0] + ".scmap") + mapfilename = os.path.join(mapdir, f"{mapname_no_version}.scmap") mode = os.stat(mapdir)[0] if not (mode and stat.S_IWRITE): @@ -253,9 +257,20 @@ 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") + 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) @@ -419,7 +434,7 @@ def preview(mapname, pixmap=False): return util.THEME.icon(img, False, pixmap) # Try to find in local map folder - img = exportPreviewFromMap(mapname) + img = export_preview_from_map(mapname) if ( img @@ -471,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.") From a50f36b4c53cc8cd165997156d7e3a2a3fc2ef4c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 7 May 2024 13:17:26 +0300 Subject: [PATCH 064/123] Do not ping server constantly while in game ping server only if there was no message from it within a minute currently 'keepalive' functionality is only enabled during game and it was constantly pinging server every 3 seconds which sometimes caused infinite reconnect loop when client sends 'ping', doesn't get 'pong' within those 3 secs and forces disonnect to start over again the main version of why this happens is: 1. There is some connection error for whatever reason 2. Client tries to reconnect - Reconnecting means exchanging 'startup' messages between client and server, which includes some long messages (e.g. player_info, which contains a list of all players and is so long that actually requires several messages to be fully sent) or messages that could be delayed (e.g. ask_session) - Keepalive timer continues to ping server every 3 seconds as it stops only when game closes 3. The above connection/authentication process takes more than 3 seconds, or catches bad timing 4. Client forces disconnect and the loop repeats probably this current keepalive logic should be reviewed and checked, because there are 2 timers: _keepalive_timer and _reconnect_timer, and _keepalive_timer doesn't stop on disconnect (maybe it should) anyway, looks like increasing 'ping' interval and pinging only when there is no apparent conversation between client and server is the right thing to do --- src/client/connection.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/client/connection.py b/src/client/connection.py index e1257b76e..620ec307d 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import logging import sys @@ -27,11 +29,11 @@ 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) @@ -44,8 +46,8 @@ def __init__(self, connection): self._keepalive = False self._keepalive_timer = QtCore.QTimer(self) self._keepalive_timer.timeout.connect(self._ping_connection) - self.keepalive_interval = 3 * 1000 - self._waiting_for_pong = False + self.keepalive_interval = 60 * 1000 + self._waiting_for_message = False @property def enabled(self): @@ -71,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(): @@ -126,25 +128,27 @@ def _ping_connection(self): not self._enabled or self._connection.state != ConnectionState.CONNECTED ): - self._waiting_for_pong = False + 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_() else: - self._waiting_for_pong = True - self._connection.send(dict(command="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): @@ -154,13 +158,14 @@ class ServerConnection(QtCore.QObject): 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 = 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) @@ -267,7 +272,7 @@ def set_upnp(self, port): self.socket.localAddress().toString(), port, "UDP", ) - def processDataFromServer(self, data): + def processDataFromServer(self, data: str) -> None: self._data = "" for line in data.splitlines(): action = json.loads(line) @@ -277,7 +282,6 @@ def processDataFromServer(self, data): self.send(dict(command="pong")) elif command == "pong": logger.debug("Server: PONG") - self.received_pong.emit() else: try: self._dispatch(action) From aae49fa564df0f04fffbb3b12b0f60577179ece3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 7 May 2024 14:18:15 +0300 Subject: [PATCH 065/123] Stop reconnect_timer when connection is established so it doesn't continue to call connection.do_connect() after successful reconnect --- src/client/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/connection.py b/src/client/connection.py index 620ec307d..697581390 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -100,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 @@ -110,7 +111,6 @@ def handle_disconnected(self): if self._connection_attempts < 3: logger.info("Reconnecting immediately") - self._reconnect_timer.stop() self._connection.do_connect() elif self._reconnect_timer.isActive(): return From 4773a966bb8e600b9103a21bcfaca25a83197955 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 7 May 2024 20:36:56 +0300 Subject: [PATCH 066/123] util: Add GAME_CACHE_DIR constant which is needed since ab20f214128d482dfb9faef6ced40b567d2f4ba4 ridiculous! --- src/util/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/util/__init__.py b/src/util/__init__.py index 6d758ce27..5ee297d77 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -58,6 +58,9 @@ # 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") @@ -163,6 +166,7 @@ def setPersonalDir(): 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, ]: if not os.path.isdir(data_dir): os.makedirs(data_dir) From 35e5f96f6b046f37821060b0c6bc27fe1a83239a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 8 May 2024 15:08:38 +0300 Subject: [PATCH 067/123] replayserver: Fix PyQt's namespaces --- src/fa/replayserver.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py index d315e9cb5..02eceea7c 100644 --- a/src/fa/replayserver.py +++ b/src/fa/replayserver.py @@ -1,3 +1,4 @@ +from __future__ import annotations import json import logging @@ -26,13 +27,17 @@ class ReplayRecorder(QtCore.QObject): """ __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.") @@ -168,7 +173,7 @@ def writeReplayFile(self): ) replay = QtCore.QFile(filename) - replay.open(QtCore.QIODevice.OpenModeFlag.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()) From 4e96ec3713caa51dfcf28fd70abe4f26a24acae8 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 9 May 2024 20:30:38 +0300 Subject: [PATCH 068/123] Remove Qt ANGLE workaround as it is not supported and was removed in Qt6 --- src/__main__.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/__main__.py b/src/__main__.py index 8e3d3d06a..df9860c0a 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -36,24 +36,8 @@ 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.', -) 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') path = os.path.join(os.path.dirname(sys.argv[0]), "PyQt6.uic.widget-plugins") From 453fe7042e1cfd67fb06b9167328709b6ddaa5ba Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 10 May 2024 15:13:06 +0300 Subject: [PATCH 069/123] Refactor updater * run Updater's tasks in a separate thread to not block the GUI thread * make use of UpdaterProgressDialog which was in the code since like project creation, but was never used * use QEventLoop to processEvents in Updater (it is still undesirable behaviour, but preferred to using QApplication.processEvents()) * factor out downloading sim mods from updater -- it doesn't fit into updater, and also spawns GUI windows (and Updater runs in non-GUI thread) now updater doesn't block GUI at all! --- res/fa/updater/updater.ui | 397 +++++++++++++++------------ src/fa/mods.py | 14 +- src/fa/update_processor.py | 285 ++++++++++++++++++++ src/fa/updater.py | 532 ++++++++++--------------------------- src/fa/updater_misc.py | 75 ++++++ src/fa/utils.py | 22 +- 6 files changed, 732 insertions(+), 593 deletions(-) create mode 100644 src/fa/update_processor.py create mode 100644 src/fa/updater_misc.py diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui index ea13a733a..7c7ade422 100644 --- a/res/fa/updater/updater.ui +++ b/res/fa/updater/updater.ui @@ -6,159 +6,273 @@ 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 + 180 + + - QFrame::StyledPanel + QFrame::Shape::StyledPanel - QFrame::Raised + QFrame::Shadow::Raised - - 0 - - + 0 - + + 0 + + + 0 + + + 0 + + 0 - - - 100 - - - 24 + + + + 0 + 0 + - - Qt::AlignCenter + + + Courier + - + true - - Qt::Horizontal - - - false - - - Game - - - - - 24 - - - Qt::AlignCenter - - - Featured Mod - - - - - - - 24 - - - Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + 16777215 + 160 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + 0 + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 2 + + + + + 0 - - Map + + 0 - + + + + + 0 + 0 + + + + + 16777215 + 40 + + + + Updating Game Data + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Expanding + + + + 40 + 20 + + + + + + + + + 16777215 + 40 + + + + Abort + + + + - - - - Details + + + + 2 - - false - - + + + + 100 + + + 24 + + + Qt::AlignmentFlag::AlignCenter + + + true + + + Qt::Orientation::Horizontal + + + false + + + Game + + + + + + + 24 + + + Qt::AlignmentFlag::AlignCenter + + + md5 + + + + + + + 24 + + + Qt::AlignmentFlag::AlignCenter + + + Featured Mod + + + + + + + 24 + + + Qt::AlignmentFlag::AlignCenter + + + Movies and Sounds + + + + - - + + + + + + Details + + + false + + + + @@ -166,54 +280,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/src/fa/mods.py b/src/fa/mods.py index a05b78afd..773ba4d1d 100644 --- a/src/fa/mods.py +++ b/src/fa/mods.py @@ -3,7 +3,8 @@ from PyQt6 import QtWidgets import config -import fa +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 @@ -44,12 +45,11 @@ def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name elif result == QtWidgets.QMessageBox.StandardButton.YesToAll: config.Settings.set('mods/autodownload', True) - for item in to_download.items(): - # Spawn an update for the required mod - updater = fa.updater.Updater("sim", sim_mod=item) - result = updater.run() - if result != fa.updater.UpdaterResult.SUCCESS: - logger.warning(f"Failure getting {item}") + 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 = [] diff --git a/src/fa/update_processor.py b/src/fa/update_processor.py new file mode 100644 index 000000000..8a2228265 --- /dev/null +++ b/src/fa/update_processor.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import logging +import os +import shutil +import stat + +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.updater_misc import ProgressInfo +from fa.updater_misc import UpdaterCancellation +from fa.updater_misc import UpdaterFailure +from fa.updater_misc import UpdaterResult +from fa.updater_misc import log +from fa.utils import unpack_movies_and_sounds +from vaults.dialogs import download_file + +logger = logging.getLogger(__name__) + + +class UpdateProcessor(QObject): + done = pyqtSignal(UpdaterResult) + md5_progress = pyqtSignal(ProgressInfo) + movies_progress = pyqtSignal(ProgressInfo) + game_progress = pyqtSignal(ProgressInfo) + featured_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 + + 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]: + exe_name = Settings.get("game/exe-name") + return [ + file for file in files + if precalculated_md5s[file.md5] != file.md5 and file.name != exe_name + ] + + 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) + self.md5_progress.emit(ProgressInfo(index, total, file.name)) + result[file.md5] = util.md5(filepath) + return result + + def fetch_file(self, file: FeaturedModFile) -> None: + target_path = os.path.join(util.APPDATA_DIR, file.group, file.name) + url = file.cacheable_url + logger.info(f"Updater: Downloading {url}") + + dler = FileDownload( + target_path=target_path, + nam=self.nam, + addr=url, + request_params={file.hmac_parameter: file.hmac_token}, + ) + dler.progress.connect(lambda: self.download_progress.emit(dler)) + dler.start.connect(lambda: self.download_started.emit(dler)) + dler.finished.connect(lambda: self.download_finished.emit(dler)) + dler.run() + dler.waitForCompletion() + if dler.failed(): + raise UpdaterFailure() + + def check_download_failure(self, dler: FileDownload) -> None: + if dler.failed(): + raise UpdaterFailure(f"Failed to download from {dler.addr!r}") + + 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.name) + return os.path.isfile(cached_file) + + def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None: + for file in files: + target = os.path.join(util.GAME_CACHE_DIR, file.group) + os.makedirs(target, exist_ok=True) + + def _update_file( + self, + file: FeaturedModFile, + precalculated_md5s: dict[str, str] | None = None, + ) -> None: + if self._is_cached(file): + if self.cache_enabled: + self.move_to_cache(file, precalculated_md5s) + self.move_from_cache(file) + else: + self.fetch_file(file) + + def update_files(self, files: list[FeaturedModFile]) -> None: + """ + Updates the files in the destination + subdirectory of the Forged Alliance path. + """ + self.create_cache_subdirs(files) + self.patch_fa_exe_if_needed(files) + md5s = self._calculate_md5s(files) + + to_update = self._filter_files_to_update(files, md5s) + total = len(to_update) + + for index, file in enumerate(to_update, start=1): + self.featured_mod_progress.emit(ProgressInfo(index, total, file.name)) + self._update_file(file, md5s) + + 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): + self.movies_progress.emit(ProgressInfo(index, total, file.name)) + unpack_movies_and_sounds(file) + + 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): + self.game_progress.emit(ProgressInfo(index, total_files, file)) + 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.download_fa_executable() + + def download_fa_executable(self) -> bool: + fa_exe_name = Settings.get("game/exe-name") + fa_exe = os.path.join(util.BIN_DIR, fa_exe_name) + + if os.path.isfile(fa_exe): + return True + + url = Settings.get("game/exe-url") + return download_file( + url=url, + target_dir=util.BIN_DIR, + name=fa_exe_name, + category="Update", + silent=False, + label=f"Downloading FA file : {url}

    ", + ) + + def patch_fa_executable(self, version: int) -> None: + exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name")) + version_addresses = (0xd3d40, 0x47612d, 0x476666) + with open(exe_path, "rb+") as file: + for address in version_addresses: + file.seek(address) + file.write(version.to_bytes(4, "little")) + + def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None: + for file in files: + if file.name == Settings.get("game/exe-name"): + version = int(self._resolve_base_version(file)) + self.patch_fa_executable(version) + return + + 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.uid, 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.update_featured_mod(self.featured_mod, self._resolve_base_version()) + else: + # update faf first + self.update_featured_mod("faf", self._resolve_base_version()) + # update featured mod then + self.update_featured_mod(self.featured_mod, self._resolve_modversion()) + except UpdaterCancellation as e: + log("CANCELLED: {}".format(e), logger) + self.result = UpdaterResult.CANCEL + except Exception as e: + log("EXCEPTION: {}".format(e), logger) + logger.exception(f"EXCEPTION: {e}") + self.result = UpdaterResult.FAILURE + else: + self.result = UpdaterResult.SUCCESS + self.done.emit(self.result) diff --git a/src/fa/updater.py b/src/fa/updater.py index dbbb2e53c..402e64c2c 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -8,471 +8,205 @@ @author thygrrr """ +from __future__ import annotations + import logging -import os -import shutil -import stat -import time -from enum import Enum -from functools import partial +from PyQt6.QtCore import QEventLoop from PyQt6.QtCore import QObject -from PyQt6.QtCore import Qt +from PyQt6.QtCore import QThread +from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSlot -from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QTextCursor from PyQt6.QtWidgets import QDialog -from PyQt6.QtWidgets import QMessageBox -from PyQt6.QtWidgets import QProgressDialog 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 api.sim_mod_updater import SimModFiles -from config import Settings -from fa.utils import unpack_movies_and_sounds -from vaults.dialogs import download_file -from vaults.modvault import utils +from downloadManager import FileDownload +from fa.updater_misc import ProgressInfo +from fa.updater_misc import UpdaterResult +from fa.updater_misc import clear_log +from fa.updater_misc import failure_dialog +from fa.updater_misc import log +from fa.updater_misc import timestamp +from src.fa.update_processor import UpdateProcessor logger = logging.getLogger(__name__) -# This contains a complete dump of everything that was supplied to logOutput -debugLog = [] - FormClass, BaseClass = util.THEME.loadUiType("fa/updater/updater.ui") class UpdaterProgressDialog(FormClass, BaseClass): - def __init__(self, parent): + aborted = pyqtSignal() + + def __init__(self, parent, abortable: bool) -> None: BaseClass.__init__(self, parent) self.setupUi(self) - self.logPlainTextEdit.setVisible(False) + self.setModal(True) + self.logPlainTextEdit.setLineWrapMode(self.logPlainTextEdit.LineWrapMode.NoWrap) + self.logFrame.setVisible(False) self.adjustSize() self.watches = [] + if not abortable: + self.abortButton.hide() + + self.rejected.connect(self.abort) + self.abortButton.clicked.connect(self.reject) + self.detailsButton.clicked.connect(self.change_details_visibility) + + 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 appendLog(self, text): + 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 addWatch(self, watch): + def add_watch(self, watch: QObject) -> None: self.watches.append(watch) - watch.finished.connect(self.watchFinished) + watch.finished.connect(self.watch_finished) @pyqtSlot() - def watchFinished(self) -> None: + def watch_finished(self) -> None: for watch in self.watches: - if not watch.isFinished(): + if not watch.is_finished(): return # equivalent to self.accept(), but clearer self.done(QDialog.DialogCode.Accepted) -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 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 - ILLEGAL = 3 # User has the wrong version of FA - BUSY = 4 # Server is currently busy - PASS = 5 # User refuses to update by canceling the wizard - - 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, - sim_mod: tuple[str, str] | None = None, - silent: bool = False, - *args, - **kwargs, + self, + featured_mod: str, + version: int | None = None, + modversions: dict | None = None, + silent: bool = False, + *args, + **kwargs, ): """ Constructor """ super().__init__(*args, **kwargs) - self.featured_mod = featured_mod - self.version = version - self.modversions = modversions - self.sim_mod = sim_mod - self.silent = silent - - self.result = UpdaterResult.NONE - - self.keep_cache = not Settings.get( - 'cache/do_not_keep', type=bool, default=True, - ) - self.in_session_cache = Settings.get( - 'cache/in_session', type=bool, default=False, - ) - - self.progress = QProgressDialog() - if self.silent: - self.progress.setCancelButton(None) - else: - self.progress.setCancelButtonText("Cancel") - self.progress.setWindowFlags( - Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, - ) - self.progress.setAutoClose(False) - self.progress.setAutoReset(False) - self.progress.setModal(1) - self.progress.setWindowTitle(f"Updating {self.featured_mod.upper()}") - - def run(self, *args, **kwargs): - clearLog() - log("Update started at " + timestamp()) - log("Using appdata: " + util.APPDATA_DIR) - - self.progress.show() - QApplication.processEvents() - - # Actual network code adapted from previous version - self.progress.setLabelText("Connecting to update server...") - - if not self.progress.wasCanceled(): - log("Connected to update server at {}".format(timestamp())) - - self.do_update() - - self.progress.setLabelText("Cleaning up.") - - self.progress.close() - else: - log("Cancelled connecting to server.") - self.result = UpdaterResult.CANCEL - - log("Update finished at {}".format(timestamp())) - return self.result - - 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) - - def request_sim_url_by_uid(self, uid: str) -> str: - return SimModFiles().request_and_get_sim_mod_url_by_id(uid) - - @staticmethod - def _file_needs_update(file: FeaturedModFile, md5s: dict[str, str]) -> bool: - incoming_md5 = file.md5 - current_md5 = md5s[file.md5] - return file.name != Settings.get("game/exe-name") and current_md5 != incoming_md5 + self.worker_thread = QThread() + self.update_processor = UpdateProcessor(featured_mod, version, modversions, silent) + self.update_processor.moveToThread(self.worker_thread) - def _calc_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]: - self.progress.setMaximum(len(files)) + self.update_processor.done.connect(self.on_update_done) + self.update_processor.md5_progress.connect(self.on_md5_progress) + self.update_processor.movies_progress.connect(self.on_movies_progress) + self.update_processor.game_progress.connect(self.on_game_progress) + self.update_processor.featured_mod_progress.connect(self.on_feat_mod_progress) + self.update_processor.download_progress.connect(self.on_download_progress) + self.update_processor.download_finished.connect(self.on_download_finished) + self.update_processor.download_started.connect(self.on_download_started) + self.worker_thread.started.connect(self.update_processor.do_update) - result = {} - for index, file in enumerate(files, start=1): + self.progress = UpdaterProgressDialog(None, silent) + self.progress.aborted.connect(self.abort) - if self.progress.wasCanceled(): - raise UpdaterCancellation() - - filepath = os.path.join(util.APPDATA_DIR, file.group, file.name) + self.result = UpdaterResult.NONE - self.progress.setLabelText(f"Calculating md5 for {file.name}...") + def on_movies_progress(self, info: ProgressInfo) -> None: + self.progress.moviesProgress.setMaximum(info.total) + self.progress.moviesProgress.setValue(info.progress) + self.progress.append_log(f"Checking for movies and sounds: {info.description}") - result[file.md5] = util.md5(filepath) + def on_md5_progress(self, info: ProgressInfo) -> None: + self.progress.md5Progress.setMaximum(info.total) + self.progress.md5Progress.setValue(info.progress) + self.progress.append_log(f"Calculating md5: {info.description}") - self.progress.setValue(index) - self.progress.setMaximum(0) - return result + def on_game_progress(self, info: ProgressInfo) -> None: + self.progress.gameProgress.setMaximum(info.total) + self.progress.gameProgress.setValue(info.progress) + self.progress.append_log(f"Copying game files: {info.description}") - def fetch_files(self, files: list[FeaturedModFile]) -> None: - for file in files: - self.fetch_single_file(file) + def on_feat_mod_progress(self, info: ProgressInfo) -> None: + self.progress.modProgress.setMaximum(info.total) + self.progress.modProgress.setValue(info.progress) + self.progress.append_log(f"Processing something: {info.description}") - def fetch_single_file(self, file: FeaturedModFile) -> None: - target_dir = os.path.join(util.APPDATA_DIR, file.group) + def on_download_progress(self, dler: FileDownload) -> None: + if dler.bytes_total == 0: + return - url = file.cacheable_url - logger.info(f"Updater: Downloading {url}") + total = dler.bytes_total + ready = dler.bytes_progress - downloaded = download_file( - url=url, - target_dir=target_dir, - name=file.name, - category="Update", - silent=False, - request_params={file.hmac_parameter: file.hmac_token}, - label=f"Downloading FA file : {url}

    ", - ) + total_mb = round(total / (1024 ** 2), 2) + ready_mb = round(ready / (1024 ** 2), 2) - if not downloaded: - # FIXME: the information about the reason is already given in the - # dowloadFile function, need to come up with better way probably - raise UpdaterCancellation( - "Operation aborted while waiting for data.", - ) - - def move_many_from_cache(self, files: list[FeaturedModFile]) -> None: - for file in files: - self.move_from_cache(file) - - 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_many_to_cache(self, files: list[dict]) -> None: - for file in files: - self.move_to_cache(file) - - def move_to_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(src_dir, file.name)): - 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)) - - def replace_from_cache(self, file: FeaturedModFile) -> None: - self.move_to_cache(file) - self.move_from_cache(file) - - def replace_many_from_cache(self, files: list[FeaturedModFile]) -> None: - for file in files: - self.replace_from_cache(file) - - def check_cache(self, files_to_check: list[FeaturedModFile]) -> None: - replaceable_files, need_to_download = [], [] - for file in files_to_check: - cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group) - os.makedirs(cache_dir, exist_ok=True) - if self._is_cached(file): - replaceable_files.append(file) - else: - need_to_download.append(file) - return replaceable_files, need_to_download - - @staticmethod - def _is_cached(file: FeaturedModFile) -> bool: - cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name) - return os.path.isfile(cached_file) - - def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None: - for file in files: - target = os.path.join(util.GAME_CACHE_DIR, file.group) - os.makedirs(target, exist_ok=True) - - def update_files(self, files: list[FeaturedModFile]) -> None: - """ - Updates the files in the destination - subdirectory of the Forged Alliance path. - """ - self.create_cache_subdirs(files) - self.patch_fa_exe_if_needed(files) - md5s = self._calc_md5s(files) + 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}]" - self.progress.setLabelText("Updating files...") + bar = construct_bar() + percent_text = f"{100 * ready / total:.1f}%" + text = f"{bar} {percent_text} ({ready_mb} MB / {total_mb} MB)" + self.progress.replace_last_log_line(text) - to_update = list(filter(partial(self._file_needs_update, md5s=md5s), files)) - replacable_files, need_to_download = self.check_cache(to_update) + def on_download_finished(self, dler: FileDownload) -> None: + self.progress.append_log("Finished downloading.") - if self.keep_cache or self.in_session_cache: - self.replace_many_from_cache(replacable_files) - self.move_many_to_cache(need_to_download) - else: - self.move_many_from_cache(replacable_files) + def on_download_started(self, dler: FileDownload) -> None: + self.progress.append_log(f"Downloading file from {dler.addr}\n") - self.fetch_files(need_to_download) + def run(self) -> UpdaterResult: + clear_log() + log(f"Update started at {timestamp()}", logger) + log(f"Using appdata: {util.APPDATA_DIR}", logger) - unpack_movies_and_sounds(files) - log("Updates applied successfully.") + self.progress.show() + self.worker_thread.start() - 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 - """ - self.progress.setLabelText("Preparing binFAF...") - - # 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) - 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) - # 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.download_fa_executable() - - def download_fa_executable(self) -> bool: - fa_exe_name = Settings.get("game/exe-name") - fa_exe = os.path.join(util.BIN_DIR, fa_exe_name) - - if os.path.isfile(fa_exe): - return True - - url = Settings.get("game/exe-url") - return download_file( - url=url, - target_dir=util.BIN_DIR, - name=fa_exe_name, - category="Update", - silent=False, - label=f"Downloading FA file : {url}

    ", - ) + loop = QEventLoop() + self.worker_thread.finished.connect(loop.quit) + loop.exec() - def patch_fa_executable(self, version: int) -> None: - exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name")) - version_addresses = (0xd3d40, 0x47612d, 0x476666) - with open(exe_path, "rb+") as file: - for address in version_addresses: - file.seek(address) - file.write(version.to_bytes(4, "little")) - - def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None: - for file in files: - if file.name == Settings.get("game/exe-name"): - version = int(self._resolve_base_version(file)) - self.patch_fa_executable(version) - return + self.progress.accept() + log(f"Update finished at {timestamp()}", logger) + return self.result - 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.uid, 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: - if self.sim_mod: - uid, name = self.sim_mod - if not utils.downloadMod(self.request_sim_url_by_uid(uid), name): - raise UpdaterFailure("Sim mod wasn't downloaded") - else: - # 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.update_featured_mod(self.featured_mod, self._resolve_base_version()) - else: - # update faf first - self.update_featured_mod("faf", self._resolve_base_version()) - # update featured mod then - self.update_featured_mod(self.featured_mod, self._resolve_modversion()) - except UpdaterCancellation as e: - log("CANCELLED: {}".format(e)) - self.result = UpdaterResult.CANCEL - except BaseException as e: - log("EXCEPTION: {}".format(e)) - self.result = UpdaterResult.FAILURE - else: - self.result = UpdaterResult.SUCCESS + 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 self.result == UpdaterResult.CANCEL: + if result == UpdaterResult.CANCEL: pass # The user knows damn well what happened here. - elif self.result == UpdaterResult.PASS: - QMessageBox.information( - QApplication.activeWindow(), - "Installation Required", - "You can't play without a legal version of Forged Alliance.", - ) - elif self.result == UpdaterResult.BUSY: - QMessageBox.information( - QApplication.activeWindow(), - "Server Busy", - ( - "The Server is busy preparing new patch files.
    Try " - "again later." - ), - ) - elif self.result == UpdaterResult.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 + elif result == UpdaterResult.FAILURE: + failure_dialog() + def abort(self) -> None: + self.result = UpdaterResult.CANCEL + self.stop_thread() -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()) + def stop_thread(self) -> None: + self.worker_thread.quit() + self.worker_thread.wait(1000) diff --git a/src/fa/updater_misc.py b/src/fa/updater_misc.py new file mode 100644 index 000000000..09774e5a9 --- /dev/null +++ b/src/fa/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/utils.py b/src/fa/utils.py index 78355342a..f45e1ef52 100644 --- a/src/fa/utils.py +++ b/src/fa/utils.py @@ -3,9 +3,6 @@ import os import zipfile -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QProgressDialog - from api.models.FeaturedModFile import FeaturedModFile from util import APPDATA_DIR @@ -21,7 +18,7 @@ def crc32(filepath: str) -> int | None: return None -def unpack_movies_and_sounds_from_file(file: FeaturedModFile) -> 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. @@ -55,20 +52,3 @@ def unpack_movies_and_sounds_from_file(file: FeaturedModFile) -> None: or crc32(tgtpath) != zi.CRC ): zf.extract(zi, APPDATA_DIR) - - -def unpack_movies_and_sounds(files: list[FeaturedModFile]) -> None: - logger.info("Checking files for movies and sounds") - - progress = QProgressDialog() - progress.setWindowTitle("Updating Movies and Sounds") - progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) - progress.setModal(True) - progress.setCancelButton(None) - progress.setMaximum(len(files)) - progress.setValue(0) - - for index, file in enumerate(files, start=1): - progress.setLabelText(f"Checking for movies and sounds in {file.name}...") - unpack_movies_and_sounds_from_file(file) - progress.setValue(index) From 20af1d525ded73c9b167e7771d84212e720b72ae Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 10 May 2024 15:47:49 +0300 Subject: [PATCH 070/123] Fix up BaseDownload * use QEventLoop to wait for finish (looks like it works from within non-GUI thread, while QApplication.processEvents() does not) * emit progress signal on QNetworkReply's progress, not on every 8 KB of data written to disk, which caused an enormous signal spam --- src/downloadManager/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 2f475f7c7..ba4be5a27 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -6,7 +6,6 @@ from io import BytesIO from PyQt6 import QtGui -from PyQt6 import QtWidgets from PyQt6.QtCore import QEventLoop from PyQt6.QtCore import QFile from PyQt6.QtCore import QIODevice @@ -113,9 +112,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: @@ -138,7 +138,6 @@ def _readloop(self): else: bs = self.blocksize self.dest.write(self._dfile.read(bs)) - self.progress.emit(self) def succeeded(self): return not self.error and not self.canceled @@ -146,10 +145,14 @@ def succeeded(self): def failed(self) -> bool: return not self.succeeded() - def waitForCompletion(self): - waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents - while self._running: - QtWidgets.QApplication.processEvents(waitFlag) + 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): From b0889877bed244b01764189abfa2de83d1aa8d92 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 11 May 2024 16:56:11 +0300 Subject: [PATCH 071/123] Provide clearer info about update process * add label, which tells about current and total featured mods to be updated --- res/fa/updater/updater.ui | 27 ++++++++++------- src/fa/update_processor.py | 32 ++++++++++++-------- src/fa/updater.py | 61 +++++++++++++++++++++++--------------- 3 files changed, 73 insertions(+), 47 deletions(-) diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui index 7c7ade422..415cd055f 100644 --- a/res/fa/updater/updater.ui +++ b/res/fa/updater/updater.ui @@ -200,7 +200,7 @@ 100 - 24 + 0 Qt::AlignmentFlag::AlignCenter @@ -215,46 +215,53 @@ false - Game + Game (%v/%m) - + + + Updating FAF... (1/1) + + + + + - 24 + 0 Qt::AlignmentFlag::AlignCenter - md5 + Check hash (%v/%m) - 24 + 0 Qt::AlignmentFlag::AlignCenter - Featured Mod + Update (%v/%m) - + - 24 + 0 Qt::AlignmentFlag::AlignCenter - Movies and Sounds + Check Movies and Sounds (%v/%m) diff --git a/src/fa/update_processor.py b/src/fa/update_processor.py index 8a2228265..b51ccf8e9 100644 --- a/src/fa/update_processor.py +++ b/src/fa/update_processor.py @@ -27,12 +27,14 @@ logger = logging.getLogger(__name__) -class UpdateProcessor(QObject): +class UpdaterWorker(QObject): done = pyqtSignal(UpdaterResult) - md5_progress = pyqtSignal(ProgressInfo) - movies_progress = pyqtSignal(ProgressInfo) + + current_mod = pyqtSignal(ProgressInfo) + hash_progress = pyqtSignal(ProgressInfo) + extras_progress = pyqtSignal(ProgressInfo) game_progress = pyqtSignal(ProgressInfo) - featured_mod_progress = pyqtSignal(ProgressInfo) + mod_progress = pyqtSignal(ProgressInfo) download_started = pyqtSignal(FileDownload) download_progress = pyqtSignal(FileDownload) @@ -80,7 +82,7 @@ def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]: result = {} for index, file in enumerate(files, start=1): filepath = os.path.join(util.APPDATA_DIR, file.group, file.name) - self.md5_progress.emit(ProgressInfo(index, total, file.name)) + self.hash_progress.emit(ProgressInfo(index, total, file.name)) result[file.md5] = util.md5(filepath) return result @@ -103,10 +105,6 @@ def fetch_file(self, file: FeaturedModFile) -> None: if dler.failed(): raise UpdaterFailure() - def check_download_failure(self, dler: FileDownload) -> None: - if dler.failed(): - raise UpdaterFailure(f"Failed to download from {dler.addr!r}") - 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) @@ -142,7 +140,7 @@ def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None: target = os.path.join(util.GAME_CACHE_DIR, file.group) os.makedirs(target, exist_ok=True) - def _update_file( + def update_file( self, file: FeaturedModFile, precalculated_md5s: dict[str, str] | None = None, @@ -166,16 +164,21 @@ def update_files(self, files: list[FeaturedModFile]) -> None: 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.featured_mod_progress.emit(ProgressInfo(index, total, file.name)) - self._update_file(file, md5s) + self.mod_progress.emit(ProgressInfo(index, total, file.name)) + self.update_file(file, md5s) + + self.unpack_movies_and_sounds(files) 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): - self.movies_progress.emit(ProgressInfo(index, total, file.name)) + self.extras_progress.emit(ProgressInfo(index, total, file.name)) unpack_movies_and_sounds(file) def prepare_bin_FAF(self) -> None: @@ -267,11 +270,14 @@ def do_update(self) -> None: 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("CANCELLED: {}".format(e), logger) diff --git a/src/fa/updater.py b/src/fa/updater.py index 402e64c2c..892a04312 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -28,7 +28,7 @@ from fa.updater_misc import failure_dialog from fa.updater_misc import log from fa.updater_misc import timestamp -from src.fa.update_processor import UpdateProcessor +from src.fa.update_processor import UpdaterWorker logger = logging.getLogger(__name__) @@ -111,43 +111,56 @@ def __init__( super().__init__(*args, **kwargs) self.worker_thread = QThread() - self.update_processor = UpdateProcessor(featured_mod, version, modversions, silent) - self.update_processor.moveToThread(self.worker_thread) - - self.update_processor.done.connect(self.on_update_done) - self.update_processor.md5_progress.connect(self.on_md5_progress) - self.update_processor.movies_progress.connect(self.on_movies_progress) - self.update_processor.game_progress.connect(self.on_game_progress) - self.update_processor.featured_mod_progress.connect(self.on_feat_mod_progress) - self.update_processor.download_progress.connect(self.on_download_progress) - self.update_processor.download_finished.connect(self.on_download_finished) - self.update_processor.download_started.connect(self.on_download_started) - self.worker_thread.started.connect(self.update_processor.do_update) + 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.on_processed_mod_changed) + self.worker.hash_progress.connect(self.on_hash_progress) + self.worker.extras_progress.connect(self.on_movies_progress) + self.worker.game_progress.connect(self.on_game_progress) + self.worker.mod_progress.connect(self.on_mod_progress) + self.worker.download_progress.connect(self.on_download_progress) + self.worker.download_finished.connect(self.on_download_finished) + self.worker.download_started.connect(self.on_download_started) + self.worker_thread.started.connect(self.worker.do_update) self.progress = UpdaterProgressDialog(None, silent) self.progress.aborted.connect(self.abort) self.result = UpdaterResult.NONE + def on_processed_mod_changed(self, info: ProgressInfo) -> None: + text = f"Updating {info.description.upper()}... ({info.progress}/{info.total})" + self.progress.currentModLabel.setText(text) + self.progress.hashProgress.setValue(0) + self.progress.modProgress.setValue(0) + self.progress.extrasProgress.setValue(0) + def on_movies_progress(self, info: ProgressInfo) -> None: - self.progress.moviesProgress.setMaximum(info.total) - self.progress.moviesProgress.setValue(info.progress) + self.progress.extrasProgress.setMaximum(info.total) + self.progress.extrasProgress.setValue(info.progress) self.progress.append_log(f"Checking for movies and sounds: {info.description}") - def on_md5_progress(self, info: ProgressInfo) -> None: - self.progress.md5Progress.setMaximum(info.total) - self.progress.md5Progress.setValue(info.progress) + def on_hash_progress(self, info: ProgressInfo) -> None: + self.progress.hashProgress.setMaximum(info.total) + self.progress.hashProgress.setValue(info.progress) self.progress.append_log(f"Calculating md5: {info.description}") def on_game_progress(self, info: ProgressInfo) -> None: self.progress.gameProgress.setMaximum(info.total) self.progress.gameProgress.setValue(info.progress) - self.progress.append_log(f"Copying game files: {info.description}") - - def on_feat_mod_progress(self, info: ProgressInfo) -> None: - self.progress.modProgress.setMaximum(info.total) - self.progress.modProgress.setValue(info.progress) - self.progress.append_log(f"Processing something: {info.description}") + self.progress.append_log(f"Checking/copying game file: {info.description}") + + def on_mod_progress(self, info: ProgressInfo) -> None: + if info.total == 0: + self.progress.modProgress.setMaximum(1) + self.progress.modProgress.setValue(1) + self.progress.append_log("Everything is up to date.") + else: + self.progress.append_log(f"Updating file: {info.description}") + self.progress.modProgress.setMaximum(info.total) + self.progress.modProgress.setValue(info.progress) def on_download_progress(self, dler: FileDownload) -> None: if dler.bytes_total == 0: From fb80bb9a680b07665b5e7787a763c16c7aa83618 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 12 May 2024 21:35:54 +0300 Subject: [PATCH 072/123] Give user some chances to cancel game update --- src/downloadManager/__init__.py | 7 +++++ src/fa/update_processor.py | 52 ++++++++++++++++++++++++--------- src/fa/updater.py | 7 ++--- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index ba4be5a27..22daafad8 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -71,6 +71,8 @@ def _error(self): def cancel(self): self.canceled = True + if not self._dfile.isFinished(): + self._dfile.abort() self._stop() def _handle_status(self) -> None: @@ -145,6 +147,11 @@ def succeeded(self): def failed(self) -> bool: return not self.succeeded() + def error_string(self) -> str: + if self._dfile is not None: + return self._dfile.errorString() + return "" + def waitForCompletion(self) -> None: if not self._running: return diff --git a/src/fa/update_processor.py b/src/fa/update_processor.py index b51ccf8e9..a9f603f39 100644 --- a/src/fa/update_processor.py +++ b/src/fa/update_processor.py @@ -4,6 +4,7 @@ import os import shutil import stat +from functools import wraps from PyQt6.QtCore import QObject from PyQt6.QtCore import pyqtSignal @@ -60,6 +61,17 @@ def __init__( in_session_cache = Settings.get("cache/in_session", type=bool, default=False) self.cache_enabled = keep_cache or in_session_cache + self.dler: FileDownload | None = None + self._interruption_requested = False + + 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() @@ -77,13 +89,14 @@ def _filter_files_to_update( if precalculated_md5s[file.md5] != file.md5 and file.name != exe_name ] + @_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) - self.hash_progress.emit(ProgressInfo(index, total, file.name)) result[file.md5] = util.md5(filepath) + self.hash_progress.emit(ProgressInfo(index, total, file.name)) return result def fetch_file(self, file: FeaturedModFile) -> None: @@ -91,19 +104,21 @@ def fetch_file(self, file: FeaturedModFile) -> None: url = file.cacheable_url logger.info(f"Updater: Downloading {url}") - dler = FileDownload( + self.dler = FileDownload( target_path=target_path, nam=self.nam, addr=url, request_params={file.hmac_parameter: file.hmac_token}, ) - dler.progress.connect(lambda: self.download_progress.emit(dler)) - dler.start.connect(lambda: self.download_started.emit(dler)) - dler.finished.connect(lambda: self.download_finished.emit(dler)) - dler.run() - dler.waitForCompletion() - if dler.failed(): - raise UpdaterFailure() + self.dler.progress.connect(lambda: self.download_progress.emit(self.dler)) + self.dler.start.connect(lambda: self.download_started.emit(self.dler)) + self.dler.finished.connect(lambda: self.download_finished.emit(self.dler)) + self.dler.run() + self.dler.waitForCompletion() + if self.dler.canceled: + raise UpdaterCancellation(self.dler.error_string()) + elif self.dler.failed(): + raise UpdaterFailure(f"Update failed: {self.dler.error_sring()}") def move_from_cache(self, file: FeaturedModFile) -> None: src_dir = os.path.join(util.APPDATA_DIR, file.group) @@ -140,6 +155,7 @@ def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None: target = os.path.join(util.GAME_CACHE_DIR, file.group) os.makedirs(target, exist_ok=True) + @_check_interruption def update_file( self, file: FeaturedModFile, @@ -152,6 +168,7 @@ def update_file( else: self.fetch_file(file) + @_check_interruption def update_files(self, files: list[FeaturedModFile]) -> None: """ Updates the files in the destination @@ -168,18 +185,19 @@ def update_files(self, files: list[FeaturedModFile]) -> None: self.mod_progress.emit(ProgressInfo(0, 0, "")) for index, file in enumerate(to_update, start=1): - self.mod_progress.emit(ProgressInfo(index, total, file.name)) self.update_file(file, md5s) + self.mod_progress.emit(ProgressInfo(index, total, file.name)) self.unpack_movies_and_sounds(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): - self.extras_progress.emit(ProgressInfo(index, total, file.name)) unpack_movies_and_sounds(file) + self.extras_progress.emit(ProgressInfo(index, total, file.name)) def prepare_bin_FAF(self) -> None: """ @@ -201,7 +219,6 @@ def prepare_bin_FAF(self) -> None: os.makedirs(dst_dir, exist_ok=True) total_files = len(files) for index, file in enumerate(files, start=1): - self.game_progress.emit(ProgressInfo(index, total_files, file)) src_file = os.path.join(src_dir, file) dst_file = os.path.join(dst_dir, file) if not os.path.exists(dst_file): @@ -210,6 +227,7 @@ def prepare_bin_FAF(self) -> None: # 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)) self.download_fa_executable() @@ -245,6 +263,7 @@ def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None: self.patch_fa_executable(version) 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.uid, modversion) @@ -280,12 +299,17 @@ def do_update(self) -> None: 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("CANCELLED: {}".format(e), logger) + log(f"CANCELLED: {e}", logger) self.result = UpdaterResult.CANCEL except Exception as e: - log("EXCEPTION: {}".format(e), logger) + 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: + if self.dler is not None: + self.dler.cancel() + self._interruption_requested = True diff --git a/src/fa/updater.py b/src/fa/updater.py index 892a04312..b05c847da 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -39,7 +39,7 @@ class UpdaterProgressDialog(FormClass, BaseClass): aborted = pyqtSignal() - def __init__(self, parent, abortable: bool) -> None: + def __init__(self, parent: QObject, silent: bool) -> None: BaseClass.__init__(self, parent) self.setupUi(self) self.setModal(True) @@ -48,7 +48,7 @@ def __init__(self, parent, abortable: bool) -> None: self.adjustSize() self.watches = [] - if not abortable: + if silent: self.abortButton.hide() self.rejected.connect(self.abort) @@ -217,8 +217,7 @@ def handle_result_if_needed(self, result: UpdaterResult) -> None: failure_dialog() def abort(self) -> None: - self.result = UpdaterResult.CANCEL - self.stop_thread() + self.worker.abort() def stop_thread(self) -> None: self.worker_thread.quit() From 35e7108dac97b8cc85d0a259980b3e6af534d308 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 13 May 2024 23:18:34 +0300 Subject: [PATCH 073/123] Fix luaparser's inability to parse lines with multiple '=' coop missions create lobby presets with values for some keys, that look like '======================' workaround this by splitting assignments by ' = ', not by '=' -- this forces whitespaces around this operator though if one wants the file to be parsed also, improve readability of some parts, which i made worse back then --- src/vaults/luaparser.py | 60 +++++++++++------------------------------ 1 file changed, 16 insertions(+), 44 deletions(-) diff --git a/src/vaults/luaparser.py b/src/vaults/luaparser.py index 8d872b76a..a0f130c75 100644 --- a/src/vaults/luaparser.py +++ b/src/vaults/luaparser.py @@ -128,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 @@ -162,9 +163,7 @@ 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) else: @@ -199,9 +198,7 @@ def __processLine(self, parent=""): else: count = 0 if resultKey in self.__searchResult: - resultVal = ( - self.__searchResult[resultKey] + count - ) + resultVal = self.__searchResult[resultKey] + count else: resultVal = count else: @@ -222,25 +219,15 @@ def __processLine(self, parent=""): self.__searchResult[resultKey] = resultVal else: if keydst in self.__searchResult: - if isinstance( - self.__searchResult[keydst], dict, - ): - self.__searchResult[keydst][resultKey] = ( - resultVal - ) + if isinstance(self.__searchResult[keydst], dict): + self.__searchResult[keydst][resultKey] = resultVal else: self.__searchResult[keydst] = dict() - self.__searchResult[keydst][resultKey] = ( - resultVal - ) + 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 @@ -278,43 +265,28 @@ def __checkErrors(self): if len(key.split(":")) == 2 or key.find("*") != -1: if self.__foundItemsCount[key] == 0: if resultKey in self.__defaultValues: - self.__searchResult[resultKey] = ( - self.__defaultValues[resultKey] - ) + self.__searchResult[resultKey] = self.__defaultValues[resultKey] else: self.error = True self.errors += 1 - self.errorMsg += ( - "Error: no matches for '{}' were " - "found\n".format(key) - ) + 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] - ) + self.__searchResult[resultKey] = self.__defaultValues[resultKey] else: self.error = True self.errors += 1 - self.errorMsg += ( - "Error: no matches for '{}' were " - "found\n".format(key) - ) + self.errorMsg += f"Error: no matches for {key!r} were found\n" elif self.__foundItemsCount[key] > 1: self.warning = True self.warnings += 1 - self.errorMsg += ( - "Warning: there were duplicate " - "occurrences for '{}'\n".format(key) - ) + 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 From f0b0d75a96625ca66254dc272a6aaf9fdd6d004c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 13 May 2024 23:25:40 +0300 Subject: [PATCH 074/123] Fix maximizing/resizing window --- src/client/_clientwindow.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 2a1426b05..917d037df 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -622,9 +622,7 @@ def mouseMoveEvent(self, event): def resize_widget(self, mouse_position: QtCore.QRectF) -> None: mouse_point = mouse_position.toPoint() if mouse_point.y() == 0: - self.rubber_band.setGeometry( - QtWidgets.QDesktopWidget().availableGeometry(self), - ) + self.rubber_band.setGeometry(self.screen().availableGeometry()) self.rubber_band.show() else: self.rubber_band.hide() @@ -1351,8 +1349,7 @@ def checkPlayerAliases(self): def saveWindow(self): util.settings.beginGroup("window") util.settings.setValue("geometry", self.saveGeometry()) - if self.is_window_maximized: - util.settings.setValue("maximized", True) + util.settings.setValue("maximized", self.is_window_maximized) util.settings.endGroup() def show_autojoin_settings_dialog(self): @@ -1455,7 +1452,7 @@ def load_settings(self): geometry = util.settings.value("geometry", None) # 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", False) + maximized = util.settings.value("maximized", defaultValue=False, type=bool) util.settings.endGroup() if maximized: self.setGeometry(self.screen().availableGeometry()) From 8c6ee76edd85fa53afc8be890c8cfedae6768d7c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 04:22:43 +0300 Subject: [PATCH 075/123] Present vault items more concisely --- res/vaults/mapvault/mapinfo.qthtml | 7 +++---- res/vaults/modvault/modinfo.qthtml | 7 +++---- src/util/__init__.py | 14 ++++++++++---- src/vaults/mapvault/mapitem.py | 2 +- src/vaults/modvault/moditem.py | 2 +- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/res/vaults/mapvault/mapinfo.qthtml b/res/vaults/mapvault/mapinfo.qthtml index 554672dba..ef4444343 100644 --- a/res/vaults/mapvault/mapinfo.qthtml +++ b/res/vaults/mapvault/mapinfo.qthtml @@ -6,10 +6,9 @@

    - - - - + + +
    Size: {width} x {height} km
    Rating: {rating}
    {reviews} Rated this
    Uploaded {date}
    Size: {width} x {height} km
    Rating: {rating} ({reviews})
    Uploaded: {date}
    diff --git a/res/vaults/modvault/modinfo.qthtml b/res/vaults/modvault/modinfo.qthtml index 626e71ea2..067884c52 100644 --- a/res/vaults/modvault/modinfo.qthtml +++ b/res/vaults/modvault/modinfo.qthtml @@ -6,10 +6,9 @@

    - - - - + + +
    Author: {author}
    Rating: {rating}
    {reviews} Rated this
    Uploaded {date}
    Author: {author}
    Rating: {rating} ({reviews})
    Uploaded {date}
    diff --git a/src/util/__init__.py b/src/util/__init__.py index 5ee297d77..7de380d2f 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -9,7 +9,9 @@ import sys 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 @@ -499,12 +501,16 @@ def uniqueID(session): return None -def strtodate(s): - return datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S") +def strtodate(s: str) -> QDateTime: + return QDateTime.fromString(s, Qt.DateFormat.ISODate).toLocalTime() -def datetostr(d): - return d.strftime("%Y-%m-%d %H:%M:%S") +def datetostr(d: QDateTime) -> str: + return d.toString("yyyy-mm-dd HH:MM:ss") + + +def utctolocal(s: str) -> str: + return datetostr(strtodate(s)) def capitalize(string: str) -> str: diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py index 4e4aedbb9..ed4481d2e 100644 --- a/src/vaults/mapvault/mapitem.py +++ b/src/vaults/mapvault/mapitem.py @@ -70,7 +70,7 @@ def update_visibility(self): description=self.item_version.description, rating=score, reviews=reviews, - date=self.item_version.create_time, + date=util.utctolocal(self.item_version.create_time), modtype=maptype, height=self.item_version.size.height_km, width=self.item_version.size.width_km, diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py index a9fefc767..b510b63a9 100644 --- a/src/vaults/modvault/moditem.py +++ b/src/vaults/modvault/moditem.py @@ -73,7 +73,7 @@ def update_visibility(self) -> None: description=self.item_version.description, rating=score, reviews=reviews, - date=self.item_version.create_time, + date=util.utctolocal(self.item_version.create_time), modtype=modtype, author=self.item_info.author, ), From b0a963fb95c61c903ae72fa763b2434a64f0a9e9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 14:52:02 +0300 Subject: [PATCH 076/123] Set stylesheet for UpdaterProgressDialog --- res/fa/updater/updater.ui | 129 +++++++++++++++++++++----------------- src/fa/updater.py | 4 ++ 2 files changed, 75 insertions(+), 58 deletions(-) diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui index 415cd055f..419d4f9a1 100644 --- a/res/fa/updater/updater.ui +++ b/res/fa/updater/updater.ui @@ -43,7 +43,7 @@ 16777215 - 180 + 200 @@ -100,7 +100,7 @@ 16777215 - 160 + 180 @@ -131,59 +131,15 @@ 2 - - - - 0 - - - 0 - + + - - - - 0 - 0 - - - - - 16777215 - 40 - - + - Updating Game Data - - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Expanding - - - - 40 - 20 - - - - - - - - - 16777215 - 40 - + Details - - Abort + + false @@ -267,20 +223,77 @@ - - + + + + 0 + + + 0 + - + + + + 0 + 0 + + + + + 16777215 + 40 + + - Details + Updating Game Data - - false + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Expanding + + + + 40 + 20 + + + + + + + + + 16777215 + 40 + + + + Abort + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + +
    diff --git a/src/fa/updater.py b/src/fa/updater.py index b05c847da..0a0a90742 100644 --- a/src/fa/updater.py +++ b/src/fa/updater.py @@ -54,6 +54,10 @@ def __init__(self, parent: QObject, silent: bool) -> None: 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() From 1c80b2fbfade28260c9d06a33a902aa863c4d5db Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 14:54:27 +0300 Subject: [PATCH 077/123] Do not try to update coop mod twice when trying to host a game -- game launcher already does that --- src/coop/_coopwidget.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index de70bd265..a5025e0fc 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -231,7 +231,7 @@ def coopListClicked(self, item): ), ) - def coopListDoubleClicked(self, item): + def coopListDoubleClicked(self, item: CoopMapItem) -> None: """ Hosting a coop event """ @@ -244,9 +244,6 @@ def coopListDoubleClicked(self, item): self.client.games.stopSearch() - if not fa.check.check("coop"): - return - self._game_launcher.host_game(item.name, item.mod, mapname) @QtCore.pyqtSlot(dict) From 0f93ac576177ab0def9c6a13ca420baf48d488cd Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 18:30:01 +0300 Subject: [PATCH 078/123] Fix updating MapGenerator --- src/mapGenerator/mapgenManager.py | 52 +++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py index 6bca9efb6..baef51ea8 100644 --- a/src/mapGenerator/mapgenManager.py +++ b/src/mapGenerator/mapgenManager.py @@ -1,14 +1,18 @@ -# system imports import logging import os import random -from PyQt6 import QtCore -from PyQt6 import QtNetwork 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 -# local imports from config import Settings from fa.maps import getUserMapsFolder from mapGenerator.mapgenProcess import MapGeneratorProcess @@ -22,10 +26,12 @@ GENERATOR_JAR_NAME = "MapGenerator_{}.jar" -class MapGeneratorManager(object): - def __init__(self): +class MapGeneratorManager(QObject): + version_received = pyqtSignal() + + def __init__(self) -> None: + super().__init__() self.latestVersion = None - self.response = None self.currentVersion = Settings.get('mapGenerator/version', "0", str) @@ -148,24 +154,20 @@ def versionController(self, version: str) -> str: return file_path return "" - def checkUpdates(self): + def checkUpdates(self) -> None: ''' Not downloading anything here. Just requesting latest version and return the number ''' - self.manager = QtNetwork.QNetworkAccessManager() - self.manager.finished.connect(self.onRequestFinished) + self.manager = QNetworkAccessManager() + self.manager.finished.connect(self.on_request_finished) - request = QtNetwork.QNetworkRequest( - QtCore.QUrl(RELEASE_URL + "latest"), - ) + request = QNetworkRequest(QUrl(RELEASE_URL).resolved(QUrl("latest"))) self.manager.get(request) progress = QtWidgets.QProgressDialog() progress.setCancelButtonText("Cancel") - progress.setWindowFlags( - QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint, - ) + progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) progress.setAutoClose(False) progress.setAutoReset(False) progress.setMinimum(0) @@ -175,15 +177,13 @@ def checkUpdates(self): progress.setWindowTitle("Looking for updates") progress.show() - while not self.response: - QtWidgets.QApplication.processEvents() + loop = QEventLoop() + self.version_received.connect(loop.quit) + loop.exec() progress.close() - def onRequestFinished(self, reply): - redirectUrl = reply.attribute(2) - if redirectUrl: - redirectUrl = redirectUrl.toString() - if "releases/tag/" in redirectUrl: - self.latestVersion = redirectUrl.rsplit('/', 1)[1] - - self.response = True + 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() From 5946b6d1f9d7f87332d9a64209b732f26e14abfa Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 19:07:28 +0300 Subject: [PATCH 079/123] Move game updater files into a dedicated folder --- src/fa/__init__.py | 10 ++++++++-- src/fa/check.py | 8 ++++---- src/fa/game_updater/__init__.py | 0 src/fa/{updater_misc.py => game_updater/misc.py} | 0 src/fa/{ => game_updater}/updater.py | 14 +++++++------- .../worker.py} | 10 +++++----- 6 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 src/fa/game_updater/__init__.py rename src/fa/{updater_misc.py => game_updater/misc.py} (100%) rename src/fa/{ => game_updater}/updater.py (96%) rename src/fa/{update_processor.py => game_updater/worker.py} (98%) diff --git a/src/fa/__init__.py b/src/fa/__init__.py index fbc8b0c23..523458cd9 100644 --- a/src/fa/__init__.py +++ b/src/fa/__init__.py @@ -3,7 +3,13 @@ # 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, factions, maps, mods, replayserver, updater, wizards +from . import check +from . import factions +from . import game_updater +from . import maps +from . import mods +from . import replayserver +from . import wizards from .game_process import instance from .play import run from .replay import replay @@ -14,7 +20,7 @@ "maps", "mods", "replayserver", - "updater", + "game_updater", "wizards", "instance", "run", diff --git a/src/fa/check.py b/src/fa/check.py index 7bf6465cc..d735ccc2f 100644 --- a/src/fa/check.py +++ b/src/fa/check.py @@ -8,6 +8,8 @@ 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 validatePath from fa.path import writeFAPathLua @@ -127,12 +129,10 @@ def check( 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.UpdaterResult.SUCCESS: + if result != UpdaterResult.SUCCESS: return False # Now it's down to having the right map 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/updater_misc.py b/src/fa/game_updater/misc.py similarity index 100% rename from src/fa/updater_misc.py rename to src/fa/game_updater/misc.py diff --git a/src/fa/updater.py b/src/fa/game_updater/updater.py similarity index 96% rename from src/fa/updater.py rename to src/fa/game_updater/updater.py index 0a0a90742..6f85d8cdb 100644 --- a/src/fa/updater.py +++ b/src/fa/game_updater/updater.py @@ -22,13 +22,13 @@ import util from downloadManager import FileDownload -from fa.updater_misc import ProgressInfo -from fa.updater_misc import UpdaterResult -from fa.updater_misc import clear_log -from fa.updater_misc import failure_dialog -from fa.updater_misc import log -from fa.updater_misc import timestamp -from src.fa.update_processor import UpdaterWorker +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__) diff --git a/src/fa/update_processor.py b/src/fa/game_updater/worker.py similarity index 98% rename from src/fa/update_processor.py rename to src/fa/game_updater/worker.py index a9f603f39..e27fd65a7 100644 --- a/src/fa/update_processor.py +++ b/src/fa/game_updater/worker.py @@ -17,11 +17,11 @@ from api.models.FeaturedModFile import FeaturedModFile from config import Settings from downloadManager import FileDownload -from fa.updater_misc import ProgressInfo -from fa.updater_misc import UpdaterCancellation -from fa.updater_misc import UpdaterFailure -from fa.updater_misc import UpdaterResult -from fa.updater_misc import log +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.utils import unpack_movies_and_sounds from vaults.dialogs import download_file From 75b937af65c08f589ab9f5d7d227f1bfc4b1ec71 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 19:17:32 +0300 Subject: [PATCH 080/123] Make UpdaterProgressDialog abortable by default --- src/fa/game_updater/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py index 6f85d8cdb..5f6ad1561 100644 --- a/src/fa/game_updater/updater.py +++ b/src/fa/game_updater/updater.py @@ -39,7 +39,7 @@ class UpdaterProgressDialog(FormClass, BaseClass): aborted = pyqtSignal() - def __init__(self, parent: QObject, silent: bool) -> None: + def __init__(self, parent: QObject, silent: bool = False) -> None: BaseClass.__init__(self, parent) self.setupUi(self) self.setModal(True) From 5776ce4bde59a4029ef8ba41fefb3c679f88a814 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 19:32:06 +0300 Subject: [PATCH 081/123] Fix failing tests and remove duplicate ones --- runtests.py | 2 - src/fa/game_updater/updater.py | 2 +- tests/fa/test_featured.py | 118 --------------------------------- tests/fa/test_updater.py | 39 +++++------ 4 files changed, 17 insertions(+), 144 deletions(-) delete mode 100644 tests/fa/test_featured.py diff --git a/runtests.py b/runtests.py index fc148a245..3beb4c35d 100644 --- a/runtests.py +++ b/runtests.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python3 - import sys import pytest diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py index 5f6ad1561..47d314195 100644 --- a/src/fa/game_updater/updater.py +++ b/src/fa/game_updater/updater.py @@ -87,7 +87,7 @@ def add_watch(self, watch: QObject) -> None: @pyqtSlot() def watch_finished(self) -> None: for watch in self.watches: - if not watch.is_finished(): + if not watch.isFinished(): return # equivalent to self.accept(), but clearer self.done(QDialog.DialogCode.Accepted) diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py deleted file mode 100644 index 3e83739ed..000000000 --- a/tests/fa/test_featured.py +++ /dev/null @@ -1,118 +0,0 @@ -__author__ = 'Thygrrr' - - -from typing import Callable - -import pytest -from PyQt6 import QtCore -from PyQt6 import QtWidgets - -from fa import updater - - -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, - 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, - 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_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.DialogCode.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.DialogCode.Accepted diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py index c8bd8f20f..0e9b0757e 100644 --- a/tests/fa/test_updater.py +++ b/tests/fa/test_updater.py @@ -6,7 +6,7 @@ from PyQt6 import QtCore from PyQt6 import QtWidgets -from fa import updater +from fa.game_updater.updater import UpdaterProgressDialog class NoIsFinished(QtCore.QObject): @@ -19,73 +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, + UpdaterProgressDialog(None).gameProgress, QtWidgets.QProgressBar, ) def test_updater_has_progress_bar_mod_progress(application): assert isinstance( - updater.UpdaterProgressDialog(None).mapProgress, + UpdaterProgressDialog(None).modProgress, QtWidgets.QProgressBar, ) def test_updater_has_method_append_log(application): assert isinstance( - updater.UpdaterProgressDialog(None).appendLog, + 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, + 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, ): 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_finished( application, ): - u = updater.UpdaterProgressDialog(None) - u.addWatch(NoIsFinished()) + 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() @@ -100,12 +93,12 @@ def test_updater_hides_and_accepts_if_all_watches_are_finished(application): def test_updater_does_not_hide_and_accept_before_all_watches_are_finished( application, ): - u = updater.UpdaterProgressDialog(None) + 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() From 7e057d0ac7c033849e32e99c176bc19a96b3bad3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 20:31:57 +0300 Subject: [PATCH 082/123] Tweak setup and release scripts use 'natives' dir as a directory for ice-adapter and jre because cx_Freeze also writes packages into this folder --- .github/workflows/release.yml | 52 +++++------ .gitignore | 2 + lib/qt.conf | 3 - lib/xdelta3.exe | Bin 314880 -> 0 bytes setup.py | 166 ++++++++++++---------------------- src/fafpath.py | 4 +- 6 files changed, 83 insertions(+), 144 deletions(-) delete mode 100644 lib/qt.conf delete mode 100755 lib/xdelta3.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c1d89f8e..2e4c78ac6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,51 +13,33 @@ jobs: runs-on: windows-latest env: UID_VERSION: v4.0.4 - ICE_ADAPTER_VERSION: v3.1.2 - PYWHEEL_INFIX: "cp36" + ICE_ADAPTER_VERSION: v3.3.7 BUILD_VERSION: ${{ github.event.inputs.version }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Set up Python 3.6.7 - uses: actions/setup-python@v2 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: 3.6.7 - architecture: x86 + python-version: 3.12 - name: Install dependencies run: | python -m pip install --upgrade pip - pip install sip==4.19.8 - pip install pyqt5==5.7.1 - pip install https://github.com/FAForever/python-wheels/releases/download/2.0.0/pywin32-221-${{ env.PYWHEEL_INFIX }}-${{ env.PYWHEEL_INFIX }}m-win32.whl - pip install wheel - pip install pytest - pip install cx_Freeze==5.0.2 - pip install -r requirements.txt - - - name: Copy required files for packaging in setup.py - run: | - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\plugins\\imageformats .\\imageformats /I - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\plugins\\platforms .\\platforms /I - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\plugins\\audio .\\audio /I - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\bin . - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\pywin32_system32 . - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\resources . /I + python -m pip install -r requirements.txt - name: Download ICE adapter and UID calculator run: | - mkdir lib\ice-adapter - Invoke-WebRequest -Uri "https://github.com/FAForever/uid/releases/download/$($env:UID_VERSION)/faf-uid.exe" -OutFile ".\\lib\\faf-uid.exe" + 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://github.com/FAForever/java-ice-adapter/releases/download/v1.0.0/faf-ice-adapter-jre-base.7z" -OutFile ".\\faf-ice-adapter-jre-base.7z" - 7z x faf-ice-adapter-jre-base.7z -olib - Remove-Item .\lib\ice-adapter\jre -Force -Recurse - Remove-Item .\lib\ice-adapter\LICENSE.txt -Force -Recurse - Invoke-WebRequest -Uri "https://content.faforever.com/jre/windows-amd64-15.0.1.tar.gz" -OutFile ".\\windows-amd64-15.0.1.tar.gz" + 7z x faf-ice-adapter-jre-base.7z -obuild_setup + Remove-Item .\build_setup\ice-adapter\jre -Force -Recurse + 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 -olib/ice-adapter/jre - Invoke-WebRequest -Uri "https://github.com/FAForever/java-ice-adapter/releases/download/$($env:ICE_ADAPTER_VERSION)/faf-ice-adapter.jar" -OutFile ".\\lib\\ice-adapter\\faf-ice-adapter.jar" + 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.jar" -OutFile ".\\build_setup\\ice-adapter\\faf-ice-adapter.jar" - name: Test with pytest run: | @@ -81,6 +63,13 @@ jobs: echo "::set-output name=WINDOWS_MSI::${WINDOWS_MSI}" echo "::set-output name=WINDOWS_MSI_NAME::${WINDOWS_MSI_NAME}" + - name: Calculate checksum + id: checksum + run: | + $MSI_SUM = $(CertUtil -hashfile ${{ steps.artifact_paths.outputs.WINDOWS_MSI }} SHA256)[1] -replace " ","" + Write-Host $MSI_SUM + echo "::set-output name=MSI_SUM::${MSI_SUM}" + - name: Create draft release id: create_release uses: actions/create-release@v1 @@ -89,6 +78,7 @@ jobs: with: tag_name: ${{ github.event.inputs.version }} release_name: ${{ github.event.inputs.version }} + body: "SHA256: ${{ steps.checksum.outputs.MSI_SUM }}" draft: true prerelease: true diff --git a/.gitignore b/.gitignore index b4c72e31a..c2df4b965 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,9 @@ *.vcproj .idea/ lib/ +natives/ build/ +build_setup/ dist/ Thumbs.db .DS_Store 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 2b4970de817b744be2ff67cf149811905442ba3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 314880 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

    }X`n|- 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 diff --git a/setup.py b/setup.py index 9f72ffbcc..337fe0a99 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,7 @@ import os -import site import sys -from pathlib import Path -import PyQt6.uic -import sip - -sip.setapi('QString', 2) -sip.setapi('QVariant', 2) -sip.setapi('QStringList', 2) -sip.setapi('QList', 2) -sip.setapi('QProcess', 2) - -if sys.platform == 'win32': +if sys.platform == "win32": from cx_Freeze import Executable from cx_Freeze import setup else: @@ -20,152 +9,113 @@ 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") - build_version = os.getenv('BUILD_VERSION') - build_version = build_version.replace(' ', '') + 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) -# Ugly hack to fix broken PyQt5 (FIXME - necessary?) -for module in ["invoke.py", "load_plugin.py"]: - try: - silly_file = Path(PyQt6.__path__[0]) / "uic" / "port_v2" / module - print("Removing {}".format(silly_file)) - silly_file.unlink() - except OSError: - pass - - -def get_jsonschema_includes(): - schemas = os.path.join(site.getsitepackages()[1], "jsonschema", "schemas") - onlyfiles = [ - f - for f in os.listdir(schemas) - if os.path.isfile(os.path.join(schemas, f)) - ] - return [ - (os.path.join(schemas, f), os.path.join("jsonschema", "schemas", f)) - for f in onlyfiles - ] - shortcut_table = [ ( - 'DesktopShortcut', # Shortcut - 'DesktopFolder', # Directory_ - 'FA Forever', # Name - 'TARGETDIR', # Component_ - '[TARGETDIR]FAForever.exe', # Target + "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 + "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': + +if sys.platform == "win32": # Dependencies are automatically detected, but it might need fine tuning. build_exe_options = { - 'include_files': [ - 'res', - 'imageformats', - 'platforms', - 'audio', - 'libeay32.dll', - 'ssleay32.dll', - 'libGLESv2.dll', # ditto - 'icudtl.dat', # ditto - ('lib/faf-uid.exe', 'lib/faf-uid.exe'), - ('lib/ice-adapter', 'lib/ice-adapter'), - ('lib/qt.conf', 'qt.conf'), - ('lib/xdelta3.exe', 'lib/xdelta3.exe'), - ], - 'zip_includes': get_jsonschema_includes(), - 'include_msvcr': True, - 'optimize': 2, - # cx_freeze >5.0.0 fails to add idna, we'll remove it once they fix it - # jinja2 dies with 'cannot import compat' without asyncio - 'packages': [ - 'asyncio', 'PyQt5', 'PyQt5.uic', 'idna', - 'PyQt5.QtWidgets', 'PyQt5.QtNetwork', 'win32com', - 'win32com.client', 'pkg_resources._vendor', + "include_files": [ + "res", + ("build_setup/faf-uid.exe", "natives/faf-uid.exe"), + ("build_setup/ice-adapter", "natives/ice-adapter"), ], - 'silent': True, - 'excludes': ['numpy', 'scipy', 'matplotlib', 'tcl', 'tkinter'], + "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"], - # Place source files in zip archive, like in cx_freeze 4.3.4 - 'zip_include_packages': ["*"], - 'zip_exclude_packages': [], + "zip_include_packages": ["*"], + "zip_exclude_packages": ["jsonchema", "jsonschema_specifications"], } platform_options = { - 'executables': [ + "executables": [ Executable( - 'src/__main__.py', + "src/__main__.py", base=base, - targetName='FAForever.exe', - icon='res/faf.ico', + target_name="FAForever.exe", + icon="res/faf.ico", ), ], - 'requires': ['sip', 'PyQt5', 'cx_Freeze'], - 'options': { - 'build_exe': build_exe_options, - 'bdist_msi': bdist_msi_options, + "options": { + "build_exe": build_exe_options, + "bdist_msi": bdist_msi_options, }, - 'version': msi_version, + "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', + 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.' + "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', + author="FA Forever Community", + maintainer="Sheeo", + url="http://www.faforever.com", + license="GNU General Public License, Version 3", **platform_options, ) diff --git a/src/fafpath.py b/src/fafpath.py index 83dcc4601..ece91f082 100644 --- a/src/fafpath.py +++ b/src/fafpath.py @@ -50,13 +50,13 @@ 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(): From c9afeb61c921705844a3a6ad67c4c8129fca9c12 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 20:50:02 +0300 Subject: [PATCH 083/123] Update checks.yml script --- .github/workflows/checks.yml | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3cf6ac723..219048b45 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,33 +8,19 @@ on: jobs: checks: runs-on: windows-latest - env: - PYWHEEL_INFIX: "cp36" - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Set up Python 3.6.7 - uses: actions/setup-python@v2 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: 3.6.7 - architecture: x86 + python-version: 3.12 - name: Install dependencies run: | python -m pip install --upgrade pip - pip install sip==4.19.8 - pip install pyqt5==5.7.1 - pip install https://github.com/FAForever/python-wheels/releases/download/2.0.0/pywin32-221-${{ env.PYWHEEL_INFIX }}-${{ env.PYWHEEL_INFIX }}m-win32.whl - pip install wheel - pip install pytest - pip install cx_Freeze==5.0.2 pip install -r requirements.txt - - name: Copy required dlls - run: | - xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\pywin32_system32 . - - name: Test with pytest run: | python runtests.py -vv --full-trace From c31b5a083ce87dfc7c682c136d06f46d38d75de8 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 21:12:24 +0300 Subject: [PATCH 084/123] Fix faf-ice-adapter download url --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e4c78ac6..5cc4b74c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: 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.jar" -OutFile ".\\build_setup\\ice-adapter\\faf-ice-adapter.jar" + Invoke-WebRequest -Uri "https://github.com/FAForever/java-ice-adapter/releases/download/$($env:ICE_ADAPTER_VERSION)/faf-ice-adapter-$($env:ICE_ADAPTER_VERSION)-nojfx.jar" -OutFile ".\\build_setup\\ice-adapter\\faf-ice-adapter.jar" - name: Test with pytest run: | From 8650c063aad44adb79dbc9f8489f78112f5a8774 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 21:15:24 +0300 Subject: [PATCH 085/123] Cache pip dependencies in github actions --- .github/workflows/checks.yml | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 219048b45..b9c82e9a5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,6 +15,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.12 + cache: pip - name: Install dependencies run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cc4b74c1..c75ed772f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.12 + cache: pip - name: Install dependencies run: | From 248ce6eeada3c71dc31d74b79467f4e19141d7e7 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 21:31:05 +0300 Subject: [PATCH 086/123] GitHub actions: Try to avoid deprecated 'set-output' command --- .github/workflows/release.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c75ed772f..589797518 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,15 +61,15 @@ jobs: Write-Host $WINDOWS_MSI $WINDOWS_MSI_NAME = (Get-Item $WINDOWS_MSI).Name Write-Host $WINDOWS_MSI_NAME - echo "::set-output name=WINDOWS_MSI::${WINDOWS_MSI}" - echo "::set-output name=WINDOWS_MSI_NAME::${WINDOWS_MSI_NAME}" + echo "WINDOWS_MSI=$WINDOWS_MSI" >> "$GITHUB_ENV" + echo "WINDOWS_MSI_NAME=$WINDOWS_MSI_NAME" >> "$GITHUB_ENV" - name: Calculate checksum id: checksum run: | - $MSI_SUM = $(CertUtil -hashfile ${{ steps.artifact_paths.outputs.WINDOWS_MSI }} SHA256)[1] -replace " ","" + $MSI_SUM = $(CertUtil -hashfile "$WINDOWS_MSI" SHA256)[1] -replace " ","" Write-Host $MSI_SUM - echo "::set-output name=MSI_SUM::${MSI_SUM}" + echo "MSI_SUM=$MSI_SUM" >> "GITHUB_ENV" - name: Create draft release id: create_release @@ -86,9 +86,9 @@ jobs: - name: Check release paths run: | echo "MSI path:" - Write-Host ${{ steps.artifact_paths.outputs.WINDOWS_MSI }} + Write-Host $WINDOWS_MSI echo "MSI filename:" - Write-Host ${{ steps.artifact_paths.outputs.WINDOWS_MSI_NAME }} + Write-Host $WINDOWS_MSI_NAME - name: Upload Windows msi id: upload-msi @@ -97,6 +97,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ${{ steps.artifact_paths.outputs.WINDOWS_MSI }} - asset_name: ${{ steps.artifact_paths.outputs.WINDOWS_MSI_NAME }} + asset_path: $WINDOWS_MSI + asset_name: $WINDOWS_MSI_NAME asset_content_type: application/vnd.microsoft.portable-executable From e5d181968cb3659eef03722d9ad8497eadb41633 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 14 May 2024 21:54:52 +0300 Subject: [PATCH 087/123] Use GITHUB_ENV environment var to save steps outputs in PowerShell, you have to use access it via $env:GITHUB_ENV rather than just $GITHUB_ENV --- .github/workflows/release.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 589797518..6b8583fec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,15 +61,16 @@ jobs: Write-Host $WINDOWS_MSI $WINDOWS_MSI_NAME = (Get-Item $WINDOWS_MSI).Name Write-Host $WINDOWS_MSI_NAME - echo "WINDOWS_MSI=$WINDOWS_MSI" >> "$GITHUB_ENV" - echo "WINDOWS_MSI_NAME=$WINDOWS_MSI_NAME" >> "$GITHUB_ENV" + echo "WINDOWS_MSI=$WINDOWS_MSI" >> "$env:GITHUB_ENV" + echo "WINDOWS_MSI_NAME=$WINDOWS_MSI_NAME" >> "$env:GITHUB_ENV" - name: Calculate checksum id: checksum run: | - $MSI_SUM = $(CertUtil -hashfile "$WINDOWS_MSI" SHA256)[1] -replace " ","" + 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" >> "GITHUB_ENV" + echo "MSI_SUM=$MSI_SUM" >> "$env:GITHUB_ENV" - name: Create draft release id: create_release @@ -79,16 +80,16 @@ jobs: with: tag_name: ${{ github.event.inputs.version }} release_name: ${{ github.event.inputs.version }} - body: "SHA256: ${{ steps.checksum.outputs.MSI_SUM }}" + body: "SHA256: $env:MSI_SUM" draft: true prerelease: true - name: Check release paths run: | echo "MSI path:" - Write-Host $WINDOWS_MSI + Write-Host $env:WINDOWS_MSI echo "MSI filename:" - Write-Host $WINDOWS_MSI_NAME + Write-Host $env:WINDOWS_MSI_NAME - name: Upload Windows msi id: upload-msi @@ -97,6 +98,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: $WINDOWS_MSI - asset_name: $WINDOWS_MSI_NAME + asset_path: "$env:WINDOWS_MSI" + asset_name: "$env:WINDOWS_MSI_NAME" asset_content_type: application/vnd.microsoft.portable-executable From 1bdf4bf62ee63ab185d65abb3fc60c984cb3c566 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 15 May 2024 10:58:04 +0300 Subject: [PATCH 088/123] Drop unmaintained GitHub actions --- .github/workflows/release.yml | 33 +++++++-------------------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b8583fec..8e3aef8b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,9 +58,9 @@ jobs: (Get-Item $files).FullName } $WINDOWS_MSI = getMsiPath - Write-Host $WINDOWS_MSI + Write-Host "MSI path: $WINDOWS_MSI" $WINDOWS_MSI_NAME = (Get-Item $WINDOWS_MSI).Name - Write-Host $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" @@ -74,30 +74,11 @@ jobs: - name: Create draft release id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: ncipollo/release-action@v1 with: - tag_name: ${{ github.event.inputs.version }} - release_name: ${{ github.event.inputs.version }} - body: "SHA256: $env:MSI_SUM" + commit: ${{ github.sha }} + tag: ${{ github.event.inputs.version }} + body: "SHA256: ${{ env.MSI_SUM }}" draft: true prerelease: true - - - name: Check release paths - run: | - echo "MSI path:" - Write-Host $env:WINDOWS_MSI - echo "MSI filename:" - Write-Host $env:WINDOWS_MSI_NAME - - - name: Upload Windows msi - id: upload-msi - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: "$env:WINDOWS_MSI" - asset_name: "$env:WINDOWS_MSI_NAME" - asset_content_type: application/vnd.microsoft.portable-executable + artifacts: ${{ env.WINDOWS_MSI }} From c4d1580ecccd153f056f39f7d2d5322a4680b24f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 15 May 2024 13:24:25 +0300 Subject: [PATCH 089/123] Use faf-ice-adapter with jfx it's 3 times larger than nojfx version, but looks like nothing we can do about it --- .github/workflows/release.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e3aef8b1..d92e08a8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,13 +34,10 @@ jobs: 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://github.com/FAForever/java-ice-adapter/releases/download/v1.0.0/faf-ice-adapter-jre-base.7z" -OutFile ".\\faf-ice-adapter-jre-base.7z" - 7z x faf-ice-adapter-jre-base.7z -obuild_setup - Remove-Item .\build_setup\ice-adapter\jre -Force -Recurse 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)-nojfx.jar" -OutFile ".\\build_setup\\ice-adapter\\faf-ice-adapter.jar" + 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: | From 84416b596dda0314cdf5b858ee9762dc645b4ab8 Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 16 May 2024 22:27:37 +0300 Subject: [PATCH 090/123] Don't use VaultDownloadDialog to download FA exe it tries to spawn Dialog in a non-GUI thread --- src/fa/game_updater/worker.py | 57 +++++++++++++++-------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/fa/game_updater/worker.py b/src/fa/game_updater/worker.py index e27fd65a7..2996971d7 100644 --- a/src/fa/game_updater/worker.py +++ b/src/fa/game_updater/worker.py @@ -23,7 +23,6 @@ from fa.game_updater.misc import UpdaterResult from fa.game_updater.misc import log from fa.utils import unpack_movies_and_sounds -from vaults.dialogs import download_file logger = logging.getLogger(__name__) @@ -99,26 +98,10 @@ def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]: self.hash_progress.emit(ProgressInfo(index, total, file.name)) return result - def fetch_file(self, file: FeaturedModFile) -> None: + def fetch_fmod_file(self, file: FeaturedModFile) -> None: target_path = os.path.join(util.APPDATA_DIR, file.group, file.name) url = file.cacheable_url - logger.info(f"Updater: Downloading {url}") - - self.dler = FileDownload( - target_path=target_path, - nam=self.nam, - addr=url, - request_params={file.hmac_parameter: file.hmac_token}, - ) - self.dler.progress.connect(lambda: self.download_progress.emit(self.dler)) - self.dler.start.connect(lambda: self.download_started.emit(self.dler)) - self.dler.finished.connect(lambda: self.download_finished.emit(self.dler)) - self.dler.run() - self.dler.waitForCompletion() - if self.dler.canceled: - raise UpdaterCancellation(self.dler.error_string()) - elif self.dler.failed(): - raise UpdaterFailure(f"Update failed: {self.dler.error_sring()}") + 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) @@ -150,10 +133,11 @@ def _is_cached(file: FeaturedModFile) -> bool: cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name) return os.path.isfile(cached_file) - def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None: + def ensure_subdirs(self, files: list[FeaturedModFile]) -> None: for file in files: - target = os.path.join(util.GAME_CACHE_DIR, file.group) - os.makedirs(target, exist_ok=True) + 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( @@ -166,7 +150,7 @@ def update_file( self.move_to_cache(file, precalculated_md5s) self.move_from_cache(file) else: - self.fetch_file(file) + self.fetch_fmod_file(file) @_check_interruption def update_files(self, files: list[FeaturedModFile]) -> None: @@ -174,7 +158,7 @@ def update_files(self, files: list[FeaturedModFile]) -> None: Updates the files in the destination subdirectory of the Forged Alliance path. """ - self.create_cache_subdirs(files) + self.ensure_subdirs(files) self.patch_fa_exe_if_needed(files) md5s = self._calculate_md5s(files) @@ -238,15 +222,22 @@ def download_fa_executable(self) -> bool: if os.path.isfile(fa_exe): return True - url = Settings.get("game/exe-url") - return download_file( - url=url, - target_dir=util.BIN_DIR, - name=fa_exe_name, - category="Update", - silent=False, - label=f"Downloading FA file : {url}

    ", - ) + self._download(fa_exe, Settings.get("game/exe-url")) + return True + + def _download(self, target_path: str, url: str, params: dict) -> None: + logger.info(f"Updater: Downloading {url}") + self.dler = FileDownload(target_path, self.nam, url, params) + self.dler.progress.connect(lambda: self.download_progress.emit(self.dler)) + self.dler.start.connect(lambda: self.download_started.emit(self.dler)) + self.dler.finished.connect(lambda: self.download_finished.emit(self.dler)) + self.dler.run() + self.dler.waitForCompletion() + if self.dler.canceled: + raise UpdaterCancellation(self.dler.error_string()) + elif self.dler.failed(): + raise UpdaterFailure(f"Update failed: {self.dler.error_sring()}") + self.dler.deleteLater() def patch_fa_executable(self, version: int) -> None: exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name")) From 339bc4857458e676ef173230558c32d6ba004c0b Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 17 May 2024 21:08:39 +0300 Subject: [PATCH 091/123] Create gamedata dirs on init --- src/util/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/__init__.py b/src/util/__init__.py index 7de380d2f..c00d702a3 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -168,7 +168,7 @@ def setPersonalDir(): 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, + GAME_CACHE_DIR, GAMEDATA_DIR, BIN_DIR, REPLAY_DIR, ]: if not os.path.isdir(data_dir): os.makedirs(data_dir) From 5a207129e7a9211285f4936d0d64d9da0e62befe Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 18 May 2024 20:25:30 +0300 Subject: [PATCH 092/123] updater: Move UI-related methods into UI class --- src/fa/game_updater/updater.py | 127 ++++++++++++++++----------------- 1 file changed, 63 insertions(+), 64 deletions(-) diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py index 47d314195..09d90f3c8 100644 --- a/src/fa/game_updater/updater.py +++ b/src/fa/game_updater/updater.py @@ -92,79 +92,37 @@ def watch_finished(self) -> None: # equivalent to self.accept(), but clearer self.done(QDialog.DialogCode.Accepted) - -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.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.on_processed_mod_changed) - self.worker.hash_progress.connect(self.on_hash_progress) - self.worker.extras_progress.connect(self.on_movies_progress) - self.worker.game_progress.connect(self.on_game_progress) - self.worker.mod_progress.connect(self.on_mod_progress) - self.worker.download_progress.connect(self.on_download_progress) - self.worker.download_finished.connect(self.on_download_finished) - self.worker.download_started.connect(self.on_download_started) - self.worker_thread.started.connect(self.worker.do_update) - - self.progress = UpdaterProgressDialog(None, silent) - self.progress.aborted.connect(self.abort) - - self.result = UpdaterResult.NONE - def on_processed_mod_changed(self, info: ProgressInfo) -> None: text = f"Updating {info.description.upper()}... ({info.progress}/{info.total})" - self.progress.currentModLabel.setText(text) - self.progress.hashProgress.setValue(0) - self.progress.modProgress.setValue(0) - self.progress.extrasProgress.setValue(0) + 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.progress.extrasProgress.setMaximum(info.total) - self.progress.extrasProgress.setValue(info.progress) - self.progress.append_log(f"Checking for movies and sounds: {info.description}") + 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.progress.hashProgress.setMaximum(info.total) - self.progress.hashProgress.setValue(info.progress) - self.progress.append_log(f"Calculating md5: {info.description}") + 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.progress.gameProgress.setMaximum(info.total) - self.progress.gameProgress.setValue(info.progress) - self.progress.append_log(f"Checking/copying game file: {info.description}") + 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.progress.modProgress.setMaximum(1) - self.progress.modProgress.setValue(1) - self.progress.append_log("Everything is up to date.") + self.modProgress.setMaximum(1) + self.modProgress.setValue(1) + self.append_log("Everything is up to date.") else: - self.progress.append_log(f"Updating file: {info.description}") - self.progress.modProgress.setMaximum(info.total) - self.progress.modProgress.setValue(info.progress) + 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: @@ -184,13 +142,54 @@ def construct_bar(blockchar: str = "=", fillchar: str = " ") -> str: bar = construct_bar() percent_text = f"{100 * ready / total:.1f}%" text = f"{bar} {percent_text} ({ready_mb} MB / {total_mb} MB)" - self.progress.replace_last_log_line(text) + self.replace_last_log_line(text) def on_download_finished(self, dler: FileDownload) -> None: - self.progress.append_log("Finished downloading.") + self.append_log("Finished downloading.") def on_download_started(self, dler: FileDownload) -> None: - self.progress.append_log(f"Downloading file from {dler.addr}\n") + 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() From 18d033f7ee67b3ec5cc881ad68f426979f1d7bf0 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 19 May 2024 06:29:07 +0300 Subject: [PATCH 093/123] Set blocksize to None downloading game files --- src/fa/game_updater/worker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fa/game_updater/worker.py b/src/fa/game_updater/worker.py index 2996971d7..801144407 100644 --- a/src/fa/game_updater/worker.py +++ b/src/fa/game_updater/worker.py @@ -228,6 +228,7 @@ def download_fa_executable(self) -> bool: def _download(self, target_path: str, url: str, params: dict) -> None: logger.info(f"Updater: Downloading {url}") self.dler = FileDownload(target_path, self.nam, url, params) + self.dler.blocksize = None self.dler.progress.connect(lambda: self.download_progress.emit(self.dler)) self.dler.start.connect(lambda: self.download_started.emit(self.dler)) self.dler.finished.connect(lambda: self.download_finished.emit(self.dler)) From c3b60eeeb2d06a095b9189f21e98a7a8716d202b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 20 May 2024 22:32:22 +0300 Subject: [PATCH 094/123] Stop spoofing java client this client has its redirect_uri configured now --- src/oauth/oauth_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/oauth/oauth_flow.py b/src/oauth/oauth_flow.py index ad691c01a..6f5cdd2b1 100644 --- a/src/oauth/oauth_flow.py +++ b/src/oauth/oauth_flow.py @@ -79,8 +79,7 @@ def setup_credentials(self) -> None: """ Set client's credentials, scopes and OAuth endpoints """ - # client_id = Settings.get("oauth/client_id") - client_id = "faf-java-client" # FIXME: ask to configure ports for python client + client_id = Settings.get("oauth/client_id") scopes = Settings.get("oauth/scope") oauth_host = QUrl(Settings.get("oauth/host")) From 3b51ef8695e7c30ea092b9557bcdb125106ceeb0 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 22 May 2024 18:24:25 +0300 Subject: [PATCH 095/123] Use QWebSocket to connect to chat recent server update broke the standard SSL/TLS port for IRC -- it refuses to perform handshake correctly it is not known whether it will be fixed and when, so we just adapt -- the underlying irc library thinks it's python's socket when it is Qt's! --- src/chat/ircconnection.py | 83 ++++++++++++--------------------- src/chat/socketadapter.py | 97 +++++++++++++++++++++++++++++++++++++++ src/config/production.py | 4 +- 3 files changed, 129 insertions(+), 55 deletions(-) create mode 100644 src/chat/socketadapter.py diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py index 7bdcec821..9ce80644f 100644 --- a/src/chat/ircconnection.py +++ b/src/chat/ircconnection.py @@ -1,25 +1,28 @@ +from __future__ import annotations + import logging import re -import ssl import sys -import irc -import irc.client +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 QSocketNotifier -from PyQt6.QtCore import QTimer 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__) -PONG_INTERVAL = 60000 # milliseconds between pongs IRC_ELEVATION = '%@~%+&' @@ -81,59 +84,41 @@ def __init__(self): QObject.__init__(self) -class IrcConnection(IrcSignals, irc.client.SimpleIRCClient): +class IrcConnection(IrcSignals, SimpleIRCClient): + reactor_class = ReactorForSocketAdapter + token_received = pyqtSignal(str) def __init__(self, host: int, port: int) -> None: IrcSignals.__init__(self) - irc.client.SimpleIRCClient.__init__(self) + SimpleIRCClient.__init__(self) self.host = host self.port = port self.api_accessor = UserApiAccessor() self.token_received.connect(self.on_token_received) - self.factory = self.create_connection_factory(self.port_uses_ssl(port)) + self.connect_factory = ConnectionFactory() self._password = None self._nick = None - self._notifier = None - self._timer = QTimer() - self._timer.timeout.connect(self.reactor.process_once) - self._nickserv_registered = False self._connected = False @classmethod - def build(cls, settings, **kwargs): - port = settings.get('chat/port', 6697, int) - host = settings.get('chat/host', 'irc.' + config.defaults['host'], str) + 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) - @staticmethod - def port_uses_ssl(port: int) -> bool: - return port == 6697 - - @staticmethod - def create_connection_factory(use_ssl: bool) -> irc.connection.Factory: - if use_ssl: - # unverified because certificate is self-signed - context = ssl._create_unverified_context() - return irc.connection.Factory(wrapper=context.wrap_socket) - return irc.connection.Factory() - - def setPortFromConfig(self): - self.port = config.Settings.get('chat/port', type=int) - self.factory = self.create_connection_factory(self.port_uses_ssl(self.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): + def disconnect_(self) -> None: self.connection.disconnect() - if self._notifier is not None: - self._notifier.activated.disconnect() - self._notifier = None def set_nick_and_username(self, nick: str, username: str) -> None: self._nick = nick @@ -161,18 +146,14 @@ def connect_(self, nick: str, username: str, password: str) -> bool: self.host, self.port, nick, - connect_factory=self.factory, + connect_factory=self.connect_factory, ircname=nick, sasl_login=username, password=password, ) - self._notifier = QSocketNotifier( - self.connection.socket.fileno(), QSocketNotifier.Type.Read, self, - ) - self._notifier.activated.connect(lambda: self.reactor.process_once()) - self._timer.start(PONG_INTERVAL) + self.connection.socket.message_received.connect(self.reactor.process_once) return True - except irc.client.IRCError: + except IRCError: logger.debug("Unable to connect to IRC server.") logger.error("IRC Exception", exc_info=sys.exc_info()) return False @@ -285,11 +266,8 @@ def userdata(data): chatters = [userdata(user) for user in listing] self.new_channel_chatters.emit(channel, chatters) - def on_whoisuser(self, c, e): - if e.arguments[-1] == self._nick: - if not self._connected: - self._connected = True - self.on_connected() + 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) @@ -413,13 +391,13 @@ def on_privnotice(self, c, e): # 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): + 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 irc.client.is_channel(target): + if not is_channel(target): return None, text return target, rest @@ -442,9 +420,8 @@ def _handle_nickserv_message(self, notice): elif "hold on" in notice or "You have regained control" in notice: self.connection.nick(self._nick) - def on_disconnect(self, c, e): + def on_disconnect(self, c: ServerConnection, e: Event) -> None: self._connected = False - self._timer.stop() self.disconnected.emit() def on_privmsg(self, c, e): @@ -452,11 +429,11 @@ def on_privmsg(self, c, e): text = "\n".join(e.arguments) self._emit_line(chatter, None, ChannelType.PRIVATE, text) - def on_action(self, c, e): + def on_action(self, c: ServerConnection, e: Event) -> None: chatter = self._event_to_chatter(e) target = e.target text = "\n".join(e.arguments) - if irc.client.is_channel(target): + if is_channel(target): chtype = ChannelType.PUBLIC else: chtype = ChannelType.PRIVATE diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py new file mode 100644 index 000000000..9dbde5a30 --- /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.binaryFrameReceived.connect(self.on_bin_message_received) + self.socket.textMessageReceived.connect(self.on_text_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 on_text_message_received(self, message: str) -> None: + self.buffer += f"{message}\r\n".encode() + self.message_received.emit() + + def read(self, size: int) -> bytes: + 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: + self.socket.sendBinaryMessage(message.strip()) + + 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) + 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/config/production.py b/src/config/production.py index e0f805a5a..4c8b74e4d 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -13,8 +13,8 @@ 'display_name': 'Main Server (recommended)', 'api': 'https://api.{host}', 'user_api': 'https://user.{host}', - 'chat/host': 'irc.{host}', - 'chat/port': 6697, + 'chat/host': 'chat.{host}', + 'chat/port': 443, 'client/data_path': APPDATA_DIR, 'client/logs/path': join(APPDATA_DIR, 'logs'), 'client/logs/level': logging.INFO, From 2448fed8ffc83f6a19c95e9519bf5cd24be5b7b3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 23 May 2024 21:25:14 +0300 Subject: [PATCH 096/123] Add option to change app style --- res/client/change_style.ui | 70 ++++++++++++++++++++++++++++++++++++++ res/client/client.css | 19 +++++++++-- src/__main__.py | 11 +++++- src/client/theme_menu.py | 46 +++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 res/client/change_style.ui 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/client.css b/res/client/client.css index 08b1b7223..02296d37b 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -467,12 +467,19 @@ 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; @@ -761,7 +768,13 @@ 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 diff --git a/src/__main__.py b/src/__main__.py index df9860c0a..aa466060c 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -14,9 +14,11 @@ 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. @@ -78,7 +80,6 @@ def excepthook( def admin_user_error_dialog() -> None: - from config import Settings ignore_admin = Settings.get("client/ignore_admin", False, bool) if not ignore_admin: box = QMessageBox() @@ -110,6 +111,13 @@ def run_faf(): 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 @@ -117,6 +125,7 @@ def run_faf(): QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts) app = QApplication(["FAF Python Client"] + trailing_args) + set_style(app) if sys.platform == 'win32': import ctypes diff --git a/src/client/theme_menu.py b/src/client/theme_menu.py index 2f1c0f422..065360867 100644 --- a/src/client/theme_menu.py +++ b/src/client/theme_menu.py @@ -1,6 +1,49 @@ 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): @@ -12,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: @@ -21,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() From 2b766fc4a67bcbd380bfb5801c270212ccf8ab2f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 24 May 2024 19:27:36 +0300 Subject: [PATCH 097/123] Game updater: Keep all dler objects till the very end they are in the different thread, and there may be delays between worker sending signals and GUI thread receiving them by keeping dlers alive we let Qt queue and process all the signals emitted by worker, so GUI thread processes them correctly --- src/fa/game_updater/worker.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/fa/game_updater/worker.py b/src/fa/game_updater/worker.py index 801144407..af4742490 100644 --- a/src/fa/game_updater/worker.py +++ b/src/fa/game_updater/worker.py @@ -60,7 +60,7 @@ def __init__( in_session_cache = Settings.get("cache/in_session", type=bool, default=False) self.cache_enabled = keep_cache or in_session_cache - self.dler: FileDownload | None = None + self.dlers: list[FileDownload] = [] self._interruption_requested = False def _check_interruption(fn): @@ -227,18 +227,18 @@ def download_fa_executable(self) -> bool: def _download(self, target_path: str, url: str, params: dict) -> None: logger.info(f"Updater: Downloading {url}") - self.dler = FileDownload(target_path, self.nam, url, params) - self.dler.blocksize = None - self.dler.progress.connect(lambda: self.download_progress.emit(self.dler)) - self.dler.start.connect(lambda: self.download_started.emit(self.dler)) - self.dler.finished.connect(lambda: self.download_finished.emit(self.dler)) - self.dler.run() - self.dler.waitForCompletion() - if self.dler.canceled: - raise UpdaterCancellation(self.dler.error_string()) - elif self.dler.failed(): - raise UpdaterFailure(f"Update failed: {self.dler.error_sring()}") - self.dler.deleteLater() + 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, version: int) -> None: exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name")) @@ -302,6 +302,6 @@ def do_update(self) -> None: self.done.emit(self.result) def abort(self) -> None: - if self.dler is not None: - self.dler.cancel() + for dler in self.dlers: + dler.cancel() self._interruption_requested = True From b2fac3ff017ec4bc0a570f167e2cec877a8fe5f6 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 25 May 2024 12:39:16 +0300 Subject: [PATCH 098/123] socketadapter: Exit event loop on error --- src/chat/socketadapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py index 9dbde5a30..95f07e7f6 100644 --- a/src/chat/socketadapter.py +++ b/src/chat/socketadapter.py @@ -73,6 +73,7 @@ def connect(self, server_address: tuple[str, int]) -> None: # 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: From bc1c2fafabd3be66de1ba896dfc5e6bc972c11be Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 26 May 2024 09:59:37 +0300 Subject: [PATCH 099/123] Revive ability to connect to test server (and do not update news when connecting to it) --- src/client/_clientwindow.py | 1 - src/client/connection.py | 2 +- src/config/testing.py | 2 +- src/news/_newswidget.py | 6 ------ 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 917d037df..6982a2951 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1549,7 +1549,6 @@ def on_widget_login_data(self, api_changed): self._chatMVC.connection.setPortFromConfig() if api_changed: self.ladder.refreshLeaderboards() - self.news.updateNews() self.games.refreshMods() self.oauth_flow.setup_credentials() diff --git a/src/client/connection.py b/src/client/connection.py index 697581390..30339ece9 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -242,7 +242,7 @@ def do_connect(self): 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("com?", "com/?") + url = data["accessUrl"].replace("?verify", "/?verify") return QUrl(url) def handle_lobby_access_api_response(self, data: dict) -> None: diff --git a/src/config/testing.py b/src/config/testing.py index 142990e11..916f9972b 100644 --- a/src/config/testing.py +++ b/src/config/testing.py @@ -2,5 +2,5 @@ default_values = production_defaults.copy() default_values['display_name'] = 'Test Server' -default_values['host'] = 'test.faforever.com' +default_values['host'] = 'faforever.xyz' default_values['oauth/client_id'] = 'faf-java-client' diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py index e34836ee3..d873952bf 100644 --- a/src/news/_newswidget.py +++ b/src/news/_newswidget.py @@ -60,12 +60,6 @@ def addNews(self, newsPost): newsItem = NewsItem(newsPost, self.newsList) self.newsItems.append(newsItem) - def updateNews(self) -> None: - self.hider.hide(self.newsTextBrowser) - self.newsItems = [] - self.newsList.clear() - self.newsManager.WpApi.download() - def download_image(self, img_url: str) -> None: name = os.path.basename(img_url) self._downloader.download(name, self._images_dl_request, img_url) From 95bdbdf96e6a8c30f7a8f06b85248de19c2b7d3b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 27 May 2024 21:58:17 +0300 Subject: [PATCH 100/123] Use pydantic to validate ReplayMetadata jsonchema was super slow -- like ~450 times slower, which caused a very noticeable lag on opening the replays tab for the first time after app launch --- requirements.txt | 8 ++-- setup.py | 2 +- src/replays/_replayswidget.py | 82 ++++++++++------------------------- src/replays/models.py | 15 +++++++ 4 files changed, 43 insertions(+), 64 deletions(-) create mode 100644 src/replays/models.py diff --git a/requirements.txt b/requirements.txt index 299d0bd20..ae30a7404 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ cx_Freeze +idna ipaddress +irc +jinja2 pathlib +pydantic pyqt6 pyqt6-networkauth pytest @@ -9,8 +13,4 @@ pytest-mock pytest-qt pywin32 semantic_version -idna -jsonschema -jinja2 zstandard -irc diff --git a/setup.py b/setup.py index 337fe0a99..728138e0c 100644 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ "excludes": ["tkinter", "unittest", "pydoc", "tcl"], "zip_include_packages": ["*"], - "zip_exclude_packages": ["jsonchema", "jsonschema_specifications"], + "zip_exclude_packages": [], } platform_options = { diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index d7ad42e63..233efaa48 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -3,7 +3,7 @@ import os import time -import jsonschema +from pydantic import ValidationError from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets @@ -19,6 +19,7 @@ from downloadManager import DownloadRequest from fa.replay import replay from model.game import GameState +from replays.models import MetadataModel from replays.replayitem import ReplayItem from replays.replayitem import ReplayItemDelegate from replays.replayToolbox import ReplayToolboxHandler @@ -289,67 +290,30 @@ def _removeGame(self, game): class ReplayMetadata: def __init__(self, data): 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 + @property + def is_incomplete(self) -> bool: + if self.model is None: + return True + return not self.model.complete + 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 + if self.model.launched_at > 0: + return self.model.launched_at + return self.model.game_time class LocalReplayItem(QtWidgets.QTreeWidgetItem): @@ -396,38 +360,38 @@ def _setup_incomplete_appearance(self): # 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_preview_downloader - dler.download_preview(data['mapname'], self._map_dl_request) + 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.get_color("default")), ) - self.setText(1, data['title']) + 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.setText(3, data.featured_mod) self.setTextAlignment(3, QtCore.Qt.AlignmentFlag.AlignCenter) def replay_bucket(self): 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) From 01a168a0cb1fb505f083a9a79b60981ef84fdb52 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 27 May 2024 22:11:58 +0300 Subject: [PATCH 101/123] Bump create_release action version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d92e08a8d..7281c3a71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: - name: Create draft release id: create_release - uses: ncipollo/release-action@v1 + uses: ncipollo/release-action@v1.14.0 with: commit: ${{ github.sha }} tag: ${{ github.event.inputs.version }} From 67448f2766e5f2127e4e55829c61927f77b615ac Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 28 May 2024 21:30:38 +0300 Subject: [PATCH 102/123] Fix updater download fa exe in old-style fashion -- from the link in filelist and patch it if version we would like to see differs from one in the file we downloaded (this is true for at least fafbeta and fafdevelop mods) --- src/fa/game_updater/patcher.py | 30 ++++++++++++++++++++ src/fa/game_updater/worker.py | 51 ++++++++++++++-------------------- src/util/qt.py | 14 ++++++++++ 3 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 src/fa/game_updater/patcher.py 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/worker.py b/src/fa/game_updater/worker.py index af4742490..a5167ae5a 100644 --- a/src/fa/game_updater/worker.py +++ b/src/fa/game_updater/worker.py @@ -22,6 +22,7 @@ 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__) @@ -62,6 +63,7 @@ def __init__( self.dlers: list[FileDownload] = [] self._interruption_requested = False + self.fa_patcher = FAPatcher() def _check_interruption(fn): @wraps(fn) @@ -82,11 +84,7 @@ def _filter_files_to_update( files: list[FeaturedModFile], precalculated_md5s: dict[str, str], ) -> list[FeaturedModFile]: - exe_name = Settings.get("game/exe-name") - return [ - file for file in files - if precalculated_md5s[file.md5] != file.md5 and file.name != exe_name - ] + 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]: @@ -130,7 +128,7 @@ def move_to_cache( @staticmethod def _is_cached(file: FeaturedModFile) -> bool: - cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name) + 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: @@ -145,9 +143,8 @@ def update_file( file: FeaturedModFile, precalculated_md5s: dict[str, str] | None = None, ) -> None: + self.move_to_cache(file, precalculated_md5s) if self._is_cached(file): - if self.cache_enabled: - self.move_to_cache(file, precalculated_md5s) self.move_from_cache(file) else: self.fetch_fmod_file(file) @@ -159,7 +156,6 @@ def update_files(self, files: list[FeaturedModFile]) -> None: subdirectory of the Forged Alliance path. """ self.ensure_subdirs(files) - self.patch_fa_exe_if_needed(files) md5s = self._calculate_md5s(files) to_update = self._filter_files_to_update(files, md5s) @@ -173,6 +169,7 @@ def update_files(self, files: list[FeaturedModFile]) -> None: 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: @@ -213,18 +210,6 @@ def prepare_bin_FAF(self) -> None: os.chmod(dst_file, st.st_mode | stat.S_IWRITE) self.game_progress.emit(ProgressInfo(index, total_files, file)) - self.download_fa_executable() - - def download_fa_executable(self) -> bool: - fa_exe_name = Settings.get("game/exe-name") - fa_exe = os.path.join(util.BIN_DIR, fa_exe_name) - - if os.path.isfile(fa_exe): - return True - - self._download(fa_exe, Settings.get("game/exe-url")) - return True - 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) @@ -240,19 +225,25 @@ def _download(self, target_path: str, url: str, params: dict) -> None: elif dler.failed(): raise UpdaterFailure(f"Update failed: {dler.error_sring()}") - def patch_fa_executable(self, version: int) -> None: - exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name")) - version_addresses = (0xd3d40, 0x47612d, 0x476666) - with open(exe_path, "rb+") as file: - for address in version_addresses: - file.seek(address) - file.write(version.to_bytes(4, "little")) + 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"): - version = int(self._resolve_base_version(file)) - self.patch_fa_executable(version) + self.patch_fa_executable(file) return @_check_interruption diff --git a/src/util/qt.py b/src/util/qt.py index b0373130c..3bbdce2ca 100644 --- a/src/util/qt.py +++ b/src/util/qt.py @@ -1,4 +1,8 @@ import types +from contextlib import contextmanager +from typing import Generator + +from PyQt6.QtCore import QFile def monkeypatch_method(obj, name, fn): @@ -7,3 +11,13 @@ def monkeypatch_method(obj, name, fn): 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() From 76206a2f27f6853f9644c0fa451f202b2782d8de Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 29 May 2024 19:34:58 +0300 Subject: [PATCH 103/123] Retrieve coop missions from API it's a matter of time when server stops sending coop_info messages and probably it's a start of transforming all of the api models into pydantic models --- src/api/coop_api.py | 13 +++++++++ src/api/models/CoopMission.py | 15 +++++++++++ src/api/models/CoopScenario.py | 13 +++++++++ src/coop/_coopwidget.py | 48 ++++++++++++++++------------------ src/coop/coopmapitem.py | 25 ++++++++++-------- 5 files changed, 77 insertions(+), 37 deletions(-) create mode 100644 src/api/coop_api.py create mode 100644 src/api/models/CoopMission.py create mode 100644 src/api/models/CoopScenario.py diff --git a/src/api/coop_api.py b/src/api/coop_api.py new file mode 100644 index 000000000..78680c2ba --- /dev/null +++ b/src/api/coop_api.py @@ -0,0 +1,13 @@ +from api.ApiAccessors import DataApiAccessor +from api.models.CoopScenario import CoopScenario + + +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": [CoopScenario(**entry) for entry in message["data"]]} diff --git a/src/api/models/CoopMission.py b/src/api/models/CoopMission.py new file mode 100644 index 000000000..b6608f781 --- /dev/null +++ b/src/api/models/CoopMission.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from pydantic import Field + + +class CoopMission(BaseModel): + uid: 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/CoopScenario.py b/src/api/models/CoopScenario.py new file mode 100644 index 000000000..94d246975 --- /dev/null +++ b/src/api/models/CoopScenario.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from pydantic import Field + +from api.models.CoopMission import CoopMission + + +class CoopScenario(BaseModel): + uid: int = Field(alias="id") + name: str + order: int + description: str | None + faction: str + maps: list[CoopMission] diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index a5025e0fc..008daa869 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -9,6 +9,8 @@ import fa import util +from api.coop_api import CoopApiAccessor +from api.models.CoopScenario import CoopScenario from coop.coopmapitem import CoopMapItem from coop.coopmapitem import CoopMapItemDelegate from coop.coopmodel import CoopGameFilterModel @@ -45,7 +47,8 @@ def __init__( 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.ResizeMode.ResizeToContents, @@ -55,7 +58,7 @@ def __init__( self.gameview = self._gameview_builder(self._game_model, self.gameList) self.gameview.game_double_clicked.connect(self.game_double_clicked) - self.coopList.itemDoubleClicked.connect(self.coopListDoubleClicked) + self.coopList.itemDoubleClicked.connect(self.coop_list_double_clicked) self.coopList.itemClicked.connect(self.coopListClicked) self.client.lobby_info.coopLeaderBoard.connect( @@ -194,7 +197,7 @@ def processLeaderBoardInfos(self, message): def busy_entered(self): if not self.loaded: - self.client.lobby_connection.send(dict(command="coop_list")) + self.coop_api.request_coop_scenarios() self.loaded = True def askLeaderBoard(self): @@ -231,50 +234,43 @@ def coopListClicked(self, item): ), ) - def coopListDoubleClicked(self, item: CoopMapItem) -> None: + 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.stopSearch() - 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, - "{}".format(typeCoop), - ) - self.cooptypes[typeCoop] = root_item + root_item.setText(0, f"{type_coop}") + 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.order, self) + item_coop.update(mission) + root_item.addChild(item_coop) - self.coop[uid] = itemCoop + self.coop[mission.uid] = item_coop def game_double_clicked(self, game: Game) -> None: """ diff --git a/src/coop/coopmapitem.py b/src/coop/coopmapitem.py index 5b95c6331..39356e81b 100644 --- a/src/coop/coopmapitem.py +++ b/src/coop/coopmapitem.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets import util +from api.models.CoopMission import CoopMission class CoopMapItemDelegate(QtWidgets.QStyledItemDelegate): @@ -61,28 +64,28 @@ class CoopMapItem(QtWidgets.QTreeWidgetItem): FORMATTER_COOP = str(util.THEME.readfile("coop/formatters/coop.qthtml")) - def __init__(self, uid, parent, *args, **kwargs): + def __init__(self, order: int, parent: QtWidgets.QWidget, *args, **kwargs) -> None: QtWidgets.QTreeWidgetItem.__init__(self, *args, **kwargs) - self.uid = uid + self.order = order self.parent = parent 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.viewtext = self.FORMATTER_COOP.format( name=self.name, @@ -106,7 +109,7 @@ 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 + # Default: order + return self.order > other.order From afb7dad9f59f6cbdcec990b76e709a01eda9eb61 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 29 May 2024 19:51:00 +0300 Subject: [PATCH 104/123] Increase max height of replay's groupbox to make all letters inside QLineEdits visible (they were truncated because of padding) --- res/replays/replays.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/replays/replays.ui b/res/replays/replays.ui index 68ed71a56..8270bfada 100644 --- a/res/replays/replays.ui +++ b/res/replays/replays.ui @@ -288,7 +288,7 @@ 380 - 260 + 300 From c66c4b207b5621cf27479285b6b75558507e8b45 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 30 May 2024 18:27:31 +0300 Subject: [PATCH 105/123] Remove unused items_uid from vaults the purpose of which has become a mystery --- src/vaults/mapvault/mapvault.py | 2 -- src/vaults/modvault/modvault.py | 2 -- src/vaults/vault.py | 1 - 3 files changed, 5 deletions(-) diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py index 8ae503926..1cdc7450e 100644 --- a/src/vaults/mapvault/mapvault.py +++ b/src/vaults/mapvault/mapvault.py @@ -53,8 +53,6 @@ def __init__(self, client: ClientWindow, *args, **kwargs) -> None: self.apiConnector = self.mapApiConnector - self.items_uid = "folderName" - self.busy_entered() self.UIButton.hide() self.uploadButton.hide() diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py index 1d3e8c0bb..5b0683add 100644 --- a/src/vaults/modvault/modvault.py +++ b/src/vaults/modvault/modvault.py @@ -75,8 +75,6 @@ def __init__(self, client, *args, **kwargs): self.apiConnector = ModApiConnector() self.apiConnector.data_ready.connect(self.modInfo) - self.items_uid = "uid" - self.uploadButton.hide() def create_item(self, item_key: str) -> ModListItem: diff --git a/src/vaults/vault.py b/src/vaults/vault.py index 55792e318..147c364ae 100644 --- a/src/vaults/vault.py +++ b/src/vaults/vault.py @@ -61,7 +61,6 @@ def __init__(self, client: ClientWindow, *args, **kwargs) -> None: self.lastButton.clicked.connect(lambda: self.goToPage(self.totalPages)) self.resetButton.clicked.connect(self.resetSearch) - self.items_uid = "" self._items = {} self._installed_items = {} From db88312c50f71c0a9b744b9ab1eb880761f2545f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 31 May 2024 06:13:37 +0300 Subject: [PATCH 106/123] Fix MapSize calculation --- src/api/models/MapVersion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/models/MapVersion.py b/src/api/models/MapVersion.py index a5377870e..f42004345 100644 --- a/src/api/models/MapVersion.py +++ b/src/api/models/MapVersion.py @@ -12,11 +12,11 @@ class MapSize: @property def width_km(self) -> int: - return self.width_px // 51.2 + return self.width_px / 51.2 @property def height_km(self) -> int: - return self.height_px // 51.2 + 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 From 860046199aba556338319f8ef7963f916927737c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 1 Jun 2024 20:01:14 +0300 Subject: [PATCH 107/123] Add tooltips to coop widget items --- src/coop/_coopwidget.py | 1 + src/coop/coopmapitem.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 008daa869..f15782f52 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -260,6 +260,7 @@ def process_coop_info(self, message: dict[str, list[CoopScenario]]) -> None: root_item = QtWidgets.QTreeWidgetItem() self.coopList.addTopLevelItem(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: diff --git a/src/coop/coopmapitem.py b/src/coop/coopmapitem.py index 39356e81b..8c3acb5b3 100644 --- a/src/coop/coopmapitem.py +++ b/src/coop/coopmapitem.py @@ -92,6 +92,10 @@ def update(self, mission: CoopMission) -> None: 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 From 57dbab6420eb2764c916d67ba41b8c39a2cf6714 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 2 Jun 2024 08:34:07 +0300 Subject: [PATCH 108/123] Use pydantic to define API models --- .pre-commit-config.yaml | 4 --- setup.cfg | 8 +++-- src/api/models/AbstractEntity.py | 15 +++++---- src/api/models/CoopMission.py | 19 +++++++----- src/api/models/CoopScenario.py | 13 +++++--- src/api/models/GeneratedMapParams.py | 27 ++++++++-------- src/api/models/Map.py | 36 ++++++++++++++++------ src/api/models/MapPoolAssignment.py | 26 +++++++++++++--- src/api/models/MapVersion.py | 30 ++++++++++-------- src/api/models/Mod.py | 30 +++++++++++++----- src/api/models/ModVersion.py | 24 +++++++++------ src/api/models/Player.py | 9 +++--- src/api/models/ReviewsSummary.py | 24 +++++++++------ src/api/parsers/MapParser.py | 33 +++----------------- src/api/parsers/MapPoolAssignmentParser.py | 18 +---------- src/api/parsers/MapVersionParser.py | 18 +---------- src/api/parsers/ModParser.py | 15 +-------- src/api/parsers/ModVersionParser.py | 15 +-------- src/api/parsers/PlayerParser.py | 8 +---- src/coop/coopmapitem.py | 8 ++--- src/vaults/vault.py | 2 +- 21 files changed, 186 insertions(+), 196 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a40de6c3..9a5293d5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,10 +13,6 @@ repos: rev: 7.0.0 hooks: - id: flake8 -- repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v2.0.4 - hooks: - - id: autopep8 - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: diff --git a/setup.cfg b/setup.cfg index 105ad2804..7023589a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,9 @@ [isort] -force_single_line=True +force_single_line = True [flake8] -max_line_length=100 +max_line_length = 100 +per-file-ignores = + # E221: multiple spaces before operator + # E501: line too long + src/api/models/*: E221, E501 diff --git a/src/api/models/AbstractEntity.py b/src/api/models/AbstractEntity.py index 56b8dbb72..c6bb11902 100644 --- a/src/api/models/AbstractEntity.py +++ b/src/api/models/AbstractEntity.py @@ -1,8 +1,11 @@ -from dataclasses import dataclass +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field -@dataclass -class AbstractEntity: - uid: str - create_time: str - update_time: str +class AbstractEntity(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + xd: str = Field(alias="id") + create_time: str = Field(alias="createTime") + update_time: str = Field(alias="updateTime") diff --git a/src/api/models/CoopMission.py b/src/api/models/CoopMission.py index b6608f781..2b1422236 100644 --- a/src/api/models/CoopMission.py +++ b/src/api/models/CoopMission.py @@ -1,15 +1,18 @@ from pydantic import BaseModel +from pydantic import ConfigDict from pydantic import Field class CoopMission(BaseModel): - uid: int = Field(alias="id") - category: str - description: str - download_url: str = Field(alias="downloadUrl") - folder_name: str = Field(alias="folderName") - name: str - order: int + model_config = ConfigDict(populate_by_name=True) + + 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 + version: int diff --git a/src/api/models/CoopScenario.py b/src/api/models/CoopScenario.py index 94d246975..505d27945 100644 --- a/src/api/models/CoopScenario.py +++ b/src/api/models/CoopScenario.py @@ -1,13 +1,16 @@ from pydantic import BaseModel +from pydantic import ConfigDict from pydantic import Field from api.models.CoopMission import CoopMission class CoopScenario(BaseModel): - uid: int = Field(alias="id") - name: str - order: int + model_config = ConfigDict(populate_by_name=True) + + xd: int = Field(alias="id") + name: str + order: int description: str | None - faction: str - maps: list[CoopMission] + faction: str + maps: list[CoopMission] diff --git a/src/api/models/GeneratedMapParams.py b/src/api/models/GeneratedMapParams.py index cc12451c3..6491b4077 100644 --- a/src/api/models/GeneratedMapParams.py +++ b/src/api/models/GeneratedMapParams.py @@ -1,31 +1,34 @@ from __future__ import annotations -from dataclasses import dataclass +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field from api.models.Map import Map from api.models.MapType import MapType -from api.models.MapVersion import MapSize from api.models.MapVersion import MapVersion -@dataclass -class GeneratedMapParams: - name: str - spawns: int - size: int - gen_version: str +class GeneratedMapParams(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + 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( - uid=uid, + xd=uid, create_time="", update_time="", folder_name=uid, games_played=0, description="Randomly Generated Map", max_players=self.spawns, - size=MapSize(self.size, self.size), + height=self.size, + width=self.size, version=self.gen_version, hidden=False, ranked=True, @@ -34,7 +37,7 @@ def to_map(self) -> Map: thumbnail_url_large="", ) return Map( - uid=uid, + xd=uid, create_time="", update_time="", display_name=self.name, @@ -42,6 +45,6 @@ def to_map(self) -> Map: recommended=False, reviews_summary=None, games_played=0, - maptype=MapType.SKIRMISH, + map_type=MapType.SKIRMISH.value, version=version, ) diff --git a/src/api/models/Map.py b/src/api/models/Map.py index de6c053f0..97c983011 100644 --- a/src/api/models/Map.py +++ b/src/api/models/Map.py @@ -1,4 +1,5 @@ -from dataclasses import dataclass +from pydantic import Field +from pydantic import field_validator from api.models.AbstractEntity import AbstractEntity from api.models.MapType import MapType @@ -7,12 +8,29 @@ from api.models.ReviewsSummary import ReviewsSummary -@dataclass class Map(AbstractEntity): - display_name: str - recommended: int - author: Player | None - reviews_summary: ReviewsSummary | None - games_played: int - maptype: MapType - version: MapVersion | None = None + 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 index 9291b1f10..fa38d754c 100644 --- a/src/api/models/MapPoolAssignment.py +++ b/src/api/models/MapPoolAssignment.py @@ -1,12 +1,28 @@ -from dataclasses import dataclass +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 -@dataclass class MapPoolAssignment(AbstractEntity): - map_params: GeneratedMapParams | None - map_version: MapVersion | None - weight: int + 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/MapVersion.py b/src/api/models/MapVersion.py index f42004345..08521a849 100644 --- a/src/api/models/MapVersion.py +++ b/src/api/models/MapVersion.py @@ -2,6 +2,8 @@ from dataclasses import dataclass +from pydantic import Field + from api.models.AbstractEntity import AbstractEntity @@ -28,16 +30,20 @@ def __str__(self) -> str: return f"{self.width_km} x {self.height_km} km" -@dataclass class MapVersion(AbstractEntity): - folder_name: str - games_played: int - description: str - max_players: int - size: MapSize - version: int - hidden: bool - ranked: bool - download_url: str - thumbnail_url_small: str - thumbnail_url_large: str + 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 index 97708721b..c2d113174 100644 --- a/src/api/models/Mod.py +++ b/src/api/models/Mod.py @@ -1,4 +1,5 @@ -from dataclasses import dataclass +from pydantic import Field +from pydantic import field_validator from api.models.AbstractEntity import AbstractEntity from api.models.ModVersion import ModVersion @@ -6,11 +7,24 @@ from api.models.ReviewsSummary import ReviewsSummary -@dataclass class Mod(AbstractEntity): - display_name: str - recommended: bool - author: str - reviews_summary: ReviewsSummary | None - uploader: Player | None - version: ModVersion + 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/ModVersion.py b/src/api/models/ModVersion.py index b1e6bf29e..cbce8d237 100644 --- a/src/api/models/ModVersion.py +++ b/src/api/models/ModVersion.py @@ -1,16 +1,20 @@ -from dataclasses import dataclass +from pydantic import Field from api.models.AbstractEntity import AbstractEntity from api.models.ModType import ModType -@dataclass class ModVersion(AbstractEntity): - description: str - download_url: str - filename: str - hidden: bool - ranked: bool - thumbnail_url: str - modtype: ModType - version: str + 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 index 509d9a1ca..18001ed06 100644 --- a/src/api/models/Player.py +++ b/src/api/models/Player.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass +from __future__ import annotations + +from pydantic import Field from api.models.AbstractEntity import AbstractEntity -@dataclass class Player(AbstractEntity): - login: str - user_agent: str + login: str + user_agent: str | None = Field(alias="userAgent") diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py index ee305fb4b..5686d293c 100644 --- a/src/api/models/ReviewsSummary.py +++ b/src/api/models/ReviewsSummary.py @@ -1,11 +1,17 @@ -from dataclasses import dataclass +from pydantic import BaseModel +from pydantic import Field +from pydantic import field_validator -@dataclass -class ReviewsSummary: - positive: float - negative: float - score: float - average_score: float - num_reviews: int - lower_bound: float +class ReviewsSummary(BaseModel): + 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) -> float | int: + return value or 0 diff --git a/src/api/parsers/MapParser.py b/src/api/parsers/MapParser.py index 0b50b5cc7..f8c063b9c 100644 --- a/src/api/parsers/MapParser.py +++ b/src/api/parsers/MapParser.py @@ -1,25 +1,12 @@ from api.models.Map import Map -from api.models.MapType import MapType -from api.parsers.MapVersionParser import MapVersionParser -from api.parsers.PlayerParser import PlayerParser -from api.parsers.ReviewsSummaryParser import ReviewsSummaryParser +from api.models.MapVersion import MapVersion class MapParser: @staticmethod def parse(api_result: dict) -> Map: - return Map( - uid=api_result["id"], - create_time=api_result["createTime"], - update_time=api_result["updateTime"], - display_name=api_result["displayName"], - recommended=api_result["recommended"], - author=PlayerParser.parse(api_result["author"]), - reviews_summary=ReviewsSummaryParser.parse(api_result["reviewsSummary"]), - games_played=api_result["gamesPlayed"], - maptype=MapType.from_string(api_result["mapType"]), - ) + return Map(**api_result) @staticmethod def parse_many(api_result: list[dict]) -> list[Map]: @@ -30,16 +17,6 @@ def parse_many(api_result: list[dict]) -> list[Map]: @staticmethod def parse_version(map_info: dict, version_info: dict) -> Map: - version = MapVersionParser.parse(version_info) - return Map( - uid=map_info["id"], - create_time=map_info["createTime"], - update_time=map_info["updateTime"], - display_name=map_info["displayName"], - recommended=map_info["recommended"], - author=PlayerParser.parse(map_info["author"]), - reviews_summary=ReviewsSummaryParser.parse(map_info["reviewsSummary"]), - games_played=map_info["gamesPlayed"], - maptype=MapType.from_string(map_info["mapType"]), - version=version, - ) + 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 index 4cac1d90a..358a13c41 100644 --- a/src/api/parsers/MapPoolAssignmentParser.py +++ b/src/api/parsers/MapPoolAssignmentParser.py @@ -1,29 +1,13 @@ from api.models.Map import Map from api.models.MapPoolAssignment import MapPoolAssignment -from api.parsers.GeneratedMapParamsParser import GeneratedMapParamsParser from api.parsers.MapParser import MapParser -from api.parsers.MapVersionParser import MapVersionParser class MapPoolAssignmentParser: @staticmethod def parse(assignment_info: dict) -> MapPoolAssignment: - if assignment_info["mapVersion"]: - map_version = MapVersionParser.parse(assignment_info["mapVersion"]) - map_params = None - elif assignment_info["mapParams"]: - map_version = None - map_params = GeneratedMapParamsParser.parse(assignment_info["mapParams"]) - - return MapPoolAssignment( - uid=assignment_info["id"], - create_time=assignment_info["createTime"], - update_time=assignment_info["updateTime"], - map_version=map_version, - map_params=map_params, - weight=assignment_info["weight"], - ) + return MapPoolAssignment(**assignment_info) @staticmethod def parse_many(assignment_info: list[dict]) -> list[MapPoolAssignment]: diff --git a/src/api/parsers/MapVersionParser.py b/src/api/parsers/MapVersionParser.py index a9d07d865..f603950d6 100644 --- a/src/api/parsers/MapVersionParser.py +++ b/src/api/parsers/MapVersionParser.py @@ -1,4 +1,3 @@ -from api.models.MapVersion import MapSize from api.models.MapVersion import MapVersion @@ -6,19 +5,4 @@ class MapVersionParser: @staticmethod def parse(version_info: dict) -> MapVersion: - return MapVersion( - uid=version_info["id"], - create_time=version_info["createTime"], - update_time=version_info["updateTime"], - folder_name=version_info["folderName"], - games_played=version_info["gamesPlayed"], - description=version_info["description"], - max_players=version_info["maxPlayers"], - size=MapSize(version_info["height"], version_info["width"]), - version=version_info["version"], - hidden=version_info["hidden"], - ranked=version_info["ranked"], - download_url=version_info["downloadUrl"], - thumbnail_url_small=version_info["thumbnailUrlSmall"], - thumbnail_url_large=version_info["thumbnailUrlLarge"], - ) + return MapVersion(**version_info) diff --git a/src/api/parsers/ModParser.py b/src/api/parsers/ModParser.py index ba8930eda..b97711728 100644 --- a/src/api/parsers/ModParser.py +++ b/src/api/parsers/ModParser.py @@ -1,24 +1,11 @@ from api.models.Mod import Mod -from api.parsers.ModVersionParser import ModVersionParser -from api.parsers.PlayerParser import PlayerParser -from api.parsers.ReviewsSummaryParser import ReviewsSummaryParser class ModParser: @staticmethod def parse(mod_info: dict) -> Mod: - return Mod( - uid=mod_info["id"], - create_time=mod_info["createTime"], - update_time=mod_info["updateTime"], - display_name=mod_info["displayName"], - recommended=mod_info["recommended"], - author=mod_info["author"], - reviews_summary=ReviewsSummaryParser.parse(mod_info["reviewsSummary"]), - uploader=PlayerParser.parse(mod_info["uploader"]), - version=ModVersionParser.parse(mod_info["latestVersion"]), - ) + return Mod(**mod_info) @staticmethod def parse_many(api_result: list[dict]) -> list[Mod]: diff --git a/src/api/parsers/ModVersionParser.py b/src/api/parsers/ModVersionParser.py index 5dbde04e2..d572046bd 100644 --- a/src/api/parsers/ModVersionParser.py +++ b/src/api/parsers/ModVersionParser.py @@ -1,4 +1,3 @@ -from api.models.ModVersion import ModType from api.models.ModVersion import ModVersion @@ -6,16 +5,4 @@ class ModVersionParser: @staticmethod def parse(api_result: dict) -> ModVersion: - return ModVersion( - uid=api_result["uid"], - create_time=api_result["createTime"], - update_time=api_result["updateTime"], - description=api_result["description"], - download_url=api_result["downloadUrl"], - filename=api_result["filename"], - hidden=api_result["hidden"], - ranked=api_result["ranked"], - thumbnail_url=api_result["thumbnailUrl"], - modtype=ModType.from_string(api_result["type"]), - version=api_result["version"], - ) + return ModVersion(**api_result) diff --git a/src/api/parsers/PlayerParser.py b/src/api/parsers/PlayerParser.py index 3f32588d1..cead4158a 100644 --- a/src/api/parsers/PlayerParser.py +++ b/src/api/parsers/PlayerParser.py @@ -8,10 +8,4 @@ def parse(player_info: dict) -> Player | None: if not player_info: return None - return Player( - uid=player_info["id"], - create_time=player_info["createTime"], - update_time=player_info["updateTime"], - login=player_info["login"], - user_agent=player_info["userAgent"], - ) + return Player(**player_info) diff --git a/src/coop/coopmapitem.py b/src/coop/coopmapitem.py index 8c3acb5b3..2c8b32836 100644 --- a/src/coop/coopmapitem.py +++ b/src/coop/coopmapitem.py @@ -64,10 +64,10 @@ class CoopMapItem(QtWidgets.QTreeWidgetItem): FORMATTER_COOP = str(util.THEME.readfile("coop/formatters/coop.qthtml")) - def __init__(self, order: int, parent: QtWidgets.QWidget, *args, **kwargs) -> None: + def __init__(self, uid: int, parent: QtWidgets.QWidget, *args, **kwargs) -> None: QtWidgets.QTreeWidgetItem.__init__(self, *args, **kwargs) - self.order = order + self.uid = uid self.parent = parent self.name = None @@ -115,5 +115,5 @@ def __ge__(self, other): def __lt__(self, other: CoopMapItem) -> bool: """ Comparison operator used for item list sorting """ - # Default: order - return self.order > other.order + # Default: uid + return self.uid > other.uid diff --git a/src/vaults/vault.py b/src/vaults/vault.py index 147c364ae..87d69fb28 100644 --- a/src/vaults/vault.py +++ b/src/vaults/vault.py @@ -100,7 +100,7 @@ def create_item(self, item_key: str) -> VaultListItem: @QtCore.pyqtSlot(dict) def items_info(self, message: dict) -> None: for value in message["values"]: - item_key = value.uid + item_key = value.xd if item_key in self._items: item = self._items[item_key] else: From f43e5393d7604ec941b8d33217fd625ff761f5da Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 2 Jun 2024 17:03:25 +0300 Subject: [PATCH 109/123] Fetch coop leaderboards from API --- res/client/client.css | 41 +++- res/client/scrollLeft.png | Bin 0 -> 408 bytes res/client/scrollRight.png | Bin 0 -> 438 bytes res/coop/coop.ui | 194 +++++++----------- src/api/coop_api.py | 47 ++++- src/api/models/CoopResult.py | 14 ++ src/api/models/Game.py | 18 ++ src/api/models/PlayerStats.py | 8 + src/api/parsers/CoopResultParser.py | 11 + src/api/parsers/CoopScenarioParser.py | 11 + src/coop/__init__.py | 3 + src/coop/_coopwidget.py | 188 ++++++----------- src/coop/cooptableitemdelegate.py | 42 ++++ src/coop/cooptablemodel.py | 64 ++++++ src/coop/cooptableview.py | 19 ++ src/qt/itemviews/tableheaderview.py | 72 +++++++ src/qt/itemviews/tableitemdelegte.py | 61 ++++++ src/qt/itemviews/tableview.py | 56 +++++ src/stats/itemviews/leaderboardheaderview.py | 68 ------ .../itemviews/leaderboarditemdelegate.py | 63 ++---- src/stats/itemviews/leaderboardtableview.py | 68 ++---- src/util/qt.py | 10 + 22 files changed, 641 insertions(+), 417 deletions(-) create mode 100644 res/client/scrollLeft.png create mode 100644 res/client/scrollRight.png create mode 100644 src/api/models/CoopResult.py create mode 100644 src/api/models/Game.py create mode 100644 src/api/models/PlayerStats.py create mode 100644 src/api/parsers/CoopResultParser.py create mode 100644 src/api/parsers/CoopScenarioParser.py create mode 100644 src/coop/cooptableitemdelegate.py create mode 100644 src/coop/cooptablemodel.py create mode 100644 src/coop/cooptableview.py create mode 100644 src/qt/itemviews/tableheaderview.py create mode 100644 src/qt/itemviews/tableitemdelegte.py create mode 100644 src/qt/itemviews/tableview.py delete mode 100644 src/stats/itemviews/leaderboardheaderview.py diff --git a/res/client/client.css b/res/client/client.css index 02296d37b..e60c6479b 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -317,6 +317,11 @@ QToolBox#replayToolBox::tab background-color: rgb(32, 32, 37); } +QTableView +{ + outline: none, +} + QTableView::item:hover { background-color: #606060; @@ -555,8 +560,13 @@ QScrollArea /* Scrollbars*/ -QScrollBar { +QScrollBar:horizontal { + background-color: grey; + height: 15px; + margin: 0 16px; +} +QScrollBar:vertical { background-color: grey; width: 15px; margin: 16px 0; @@ -576,8 +586,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; @@ -585,8 +606,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; @@ -605,9 +626,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 { diff --git a/res/client/scrollLeft.png b/res/client/scrollLeft.png new file mode 100644 index 0000000000000000000000000000000000000000..0f7e21b8a08a011680c6acc800876e77eb4e9b46 GIT binary patch literal 408 zcmV;J0cZY+P)004R= z004l4008;_004mL004C`008P>0026e000+nl3&F}00009a7bBm000XU000XU0RWnu z7ytkP9!W$&R5(xNRl#b5KoE5YT&o9fp_f3(q2~nuC3p#>(0?kq1S;er{TKhDcoaNH zR9y4cb-hH^Hil5>V|_F3j*n$#c4eIN=f%SFJZ4!I>j+QBOuY`K$_zk@Kyu6m^>#M?yO0o~u4`THaZC#pMX}y& zJ`RU0%lfG?n1rx}JkNt5_&lA^vBKDECL!!)8-~Xyf4@ovo@>d+c@AvR0d*394 z1L*sHk|b}tU0IemmHomPnCrT)s}&L{qq004R= z004l4008;_004mL004C`008P>0026e000+nl3&F}00009a7bBm000XU000XU0RWnu z7ytkPJV``BR5(xNQ?Y8pFc7sQ90wb`g%bJ^Hm3cQk|mHr|EXjtG>|3qU;G0dLLp1Z zU@(nc+vL>KN!HR-EosQmKIC_I(&r?c3@JJcmk?GE7Ub=8n&){O$F@`tf?cO6=iF4a`ZSq2P1_32nN6^UMzw?M zJ+IfU9bua9`?!pWojBKF{~Ui)2(L~`bFW7O3-G;du`0zLAc~?r0zftG5G&KOb(KMf g7=cb**KfPs2S+yGz5ob2!vFvP07*qoM6N<$f(Ov9ZU6uP literal 0 HcmV?d00001 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/src/api/coop_api.py b/src/api/coop_api.py index 78680c2ba..116f07b62 100644 --- a/src/api/coop_api.py +++ b/src/api/coop_api.py @@ -1,5 +1,8 @@ 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): @@ -10,4 +13,46 @@ def request_coop_scenarios(self) -> None: self.requestData({"include": "maps"}) def prepare_data(self, message: dict) -> dict[str, list[CoopScenario]]: - return {"values": [CoopScenario(**entry) for entry in message["data"]]} + return {"values": CoopScenarioParser.parse_many(message["data"])} + + +class CoopResultApiAccessor(DataApiAccessor): + def __init__(self): + 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/models/CoopResult.py b/src/api/models/CoopResult.py new file mode 100644 index 000000000..1f7d2cd35 --- /dev/null +++ b/src/api/models/CoopResult.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from pydantic import Field + +from api.models.Game import Game + + +class CoopResult(BaseModel): + 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/Game.py b/src/api/models/Game.py new file mode 100644 index 000000000..c242d8ee0 --- /dev/null +++ b/src/api/models/Game.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from pydantic import Field + +from api.models.Player import Player +from api.models.PlayerStats import PlayerStats + + +class Game(BaseModel): + 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/PlayerStats.py b/src/api/models/PlayerStats.py new file mode 100644 index 000000000..6fea58f66 --- /dev/null +++ b/src/api/models/PlayerStats.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel +from pydantic import Field + +from api.models.Player import Player + + +class PlayerStats(BaseModel): + player: Player | None = Field(None) diff --git a/src/api/parsers/CoopResultParser.py b/src/api/parsers/CoopResultParser.py new file mode 100644 index 000000000..b25d2938e --- /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) -> None: + 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/coop/__init__.py b/src/coop/__init__.py index 327683cd1..35041c273 100644 --- a/src/coop/__init__.py +++ b/src/coop/__init__.py @@ -1,9 +1,12 @@ import logging +from coop.cooptableview import CoopLeaderboardTableView + # For use by other modules from ._coopwidget import CoopWidget __all__ = ( + "CoopLeaderboardTableView", "CoopWidget", ) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index f15782f52..96450078c 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -1,22 +1,37 @@ +from __future__ import annotations + import logging import os +from typing import TYPE_CHECKING 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 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 +from util.qt import qopen + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow logger = logging.getLogger(__name__) @@ -25,8 +40,13 @@ class CoopWidget(FormClass, BaseClass, BusyWidget): def __init__( - self, client, game_model, me, gameview_builder, game_launcher, - ): + self, + client: ClientWindow, + game_model: GameModel, + me: User, + gameview_builder: GameViewBuilder, + game_launcher: GameLauncher, + ) -> None: BaseClass.__init__(self) @@ -50,6 +70,9 @@ def __init__( self.coop_api = CoopApiAccessor() self.coop_api.data_ready.connect(self.process_coop_info) + 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, ) @@ -59,32 +82,24 @@ def __init__( self.gameview.game_double_clicked.connect(self.game_double_clicked) self.coopList.itemDoubleClicked.connect(self.coop_list_double_clicked) - self.coopList.itemClicked.connect(self.coopListClicked) + 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.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 @@ -98,28 +113,21 @@ def _addExistingGames(self, gameset): 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", - ), - ) + 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 - faf_replay.open(open_mode) - faf_replay.write(reply.readAll()) - faf_replay.flush() - faf_replay.close() + 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: @@ -130,94 +138,33 @@ 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 += ( - '

    {}


    ' - .format(self.selectedItem.name) - ) - html += ( - "" - ) - - formatter = self.FORMATTER_LADDER - formatter_header = self.FORMATTER_LADDER_HEADER - cursor = w.textCursor() - cursor.movePosition(QtGui.QTextCursor.MoveOperation.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.setItemDelegate(CoopLeaderboardItemDelegate(self)) self.leaderBoard.setVisible(True) def busy_entered(self): if not self.loaded: self.coop_api.request_coop_scenarios() - self.loaded = True - 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 - def coopListClicked(self, item): + 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 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: @@ -226,13 +173,7 @@ def coopListClicked(self, item): 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 coop_list_double_clicked(self, item: CoopMapItem) -> None: """ @@ -267,11 +208,12 @@ def process_coop_info(self, message: dict[str, list[CoopScenario]]) -> None: root_item = self.cooptypes[type_coop] for mission in campaign.maps: - item_coop = CoopMapItem(mission.order, self) + item_coop = CoopMapItem(mission.xd, self) item_coop.update(mission) root_item.addChild(item_coop) - self.coop[mission.uid] = item_coop + self.coop[mission.xd] = item_coop + self.loaded = True def game_double_clicked(self, game: Game) -> None: """ 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/qt/itemviews/tableheaderview.py b/src/qt/itemviews/tableheaderview.py new file mode 100644 index 000000000..810c95824 --- /dev/null +++ b/src/qt/itemviews/tableheaderview.py @@ -0,0 +1,72 @@ +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/stats/itemviews/leaderboardheaderview.py b/src/stats/itemviews/leaderboardheaderview.py deleted file mode 100644 index 1c8249b22..000000000 --- a/src/stats/itemviews/leaderboardheaderview.py +++ /dev/null @@ -1,68 +0,0 @@ -from PyQt6 import QtWidgets -from PyQt6.QtCore import QModelIndex -from PyQt6.QtCore import QRect -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QHoverEvent -from PyQt6.QtGui import QPainter - - -class VerticalHeaderView(QtWidgets.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 = QtWidgets.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 = QtWidgets.QStyle.StateFlag.State_None - - if self.highlightSections(): - if self.selectionModel().rowIntersectsSelection(index, QModelIndex()): - state |= QtWidgets.QStyle.StateFlag.State_On - elif index == self.hover: - state |= QtWidgets.QStyle.StateFlag.State_MouseOver - - opt.state |= state - - self.style().drawControl(QtWidgets.QStyle.ControlElement.CE_Header, opt, painter, self) - - def mouseMoveEvent(self, event): - QtWidgets.QHeaderView.mouseMoveEvent(self, event) - self.parent().updateHoverRow(event) - self.updateHoverSection(event) - - def wheelEvent(self, event): - QtWidgets.QHeaderView.wheelEvent(self, event) - self.parent().updateHoverRow(event) - self.updateHoverSection(event) - - def mousePressEvent(self, event): - QtWidgets.QHeaderView.mousePressEvent(self, event) - self.parent().updateHoverRow(event) - self.updateHoverSection(event) - - def updateHoverSection(self, event: QHoverEvent) -> None: - index = self.logicalIndexAt(event.position().toPoint()) - oldHover = self.hover - self.hover = index - - if self.hover != oldHover: - if oldHover != -1: - self.updateSection(oldHover) - if self.hover != -1: - self.updateSection(self.hover) diff --git a/src/stats/itemviews/leaderboarditemdelegate.py b/src/stats/itemviews/leaderboarditemdelegate.py index dc62c89e7..3142963ed 100644 --- a/src/stats/itemviews/leaderboarditemdelegate.py +++ b/src/stats/itemviews/leaderboarditemdelegate.py @@ -1,49 +1,30 @@ -from PyQt6 import QtCore -from PyQt6 import QtWidgets +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(QtWidgets.QStyledItemDelegate): + +class LeaderboardItemDelegate(TableItemDelegate): def paint( self, painter: QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, + option: QStyleOptionViewItem, + index: QModelIndex, ) -> None: - opt = QtWidgets.QStyleOptionViewItem(option) - opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus - opt.state &= ~QtWidgets.QStyle.StateFlag.State_MouseOver - - view = opt.styleObject - behavior = view.selectionBehavior() - hoverIndex = view.hoverIndex() - - if ( - not (option.state & QtWidgets.QStyle.StateFlag.State_Selected) - and behavior is not QtWidgets.QTableView.SelectionBehavior.SelectItems - ): - if ( - behavior is QtWidgets.QTableView.SelectionBehavior.SelectRows - and hoverIndex.row() == index.row() - ): - opt.state |= QtWidgets.QStyle.StateFlag.State_MouseOver - - self.initStyleOption(opt, index) - painter.save() + opt = self._customize_style_option(option, index) text = opt.text - opt.text = "" - control_element = QtWidgets.QStyle.ControlElement.CE_ItemViewItem - opt.widget.style().drawControl(control_element, opt, painter, opt.widget) - if opt.state & QtWidgets.QStyle.StateFlag.State_Selected: - painter.setPen(QtCore.Qt.GlobalColor.white) - if index.column() == 0: - rect = QtCore.QRect(opt.rect) - rect.setLeft(int(opt.rect.left() + opt.rect.width() // 2.125)) - alignment_flags = ( - QtCore.Qt.AlignmentFlag.AlignLeft - | QtCore.Qt.AlignmentFlag.AlignVCenter - ) - painter.drawText(rect, alignment_flags, text) - else: - painter.drawText(opt.rect, QtCore.Qt.AlignmentFlag.AlignCenter, text) - painter.restore() + + 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/leaderboardtableview.py b/src/stats/itemviews/leaderboardtableview.py index 9157c0f47..69d78bd63 100644 --- a/src/stats/itemviews/leaderboardtableview.py +++ b/src/stats/itemviews/leaderboardtableview.py @@ -1,54 +1,14 @@ -from PyQt6 import QtCore -from PyQt6 import QtGui -from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QCursor +from PyQt6.QtGui import QMouseEvent -from .leaderboardheaderview import VerticalHeaderView -from .leaderboardtablemenu import LeaderboardTableMenu +from qt.itemviews.tableview import TableView +from stats.itemviews.leaderboardtablemenu import LeaderboardTableMenu -class LeaderboardTableView(QtWidgets.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.mHoverRow = -1 - - def hoverIndex(self): - return QtCore.QModelIndex(self.model().index(self.mHoverRow, 0)) - - def updateHoverRow(self, event: QtGui.QHoverEvent) -> None: - index = self.indexAt(event.position().toPoint()) - oldHoverRow = self.mHoverRow - self.mHoverRow = index.row() - - if ( - self.selectionBehavior() is self.SelectionBehavior.SelectRows - and oldHoverRow != self.mHoverRow - ): - if oldHoverRow != -1: - for i in range(self.model().columnCount()): - self.update(self.model().index(oldHoverRow, i)) - if self.mHoverRow != -1: - for i in range(self.model().columnCount()): - self.update(self.model().index(self.mHoverRow, i)) - - def mouseMoveEvent(self, event): - QtWidgets.QTableView.mouseMoveEvent(self, event) - self.updateHoverRow(event) - self.verticalHeader().updateHoverSection(event) - - def wheelEvent(self, event): - QtWidgets.QTableView.wheelEvent(self, event) - self.updateHoverRow(event) - self.verticalHeader().updateHoverSection(event) - - def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: - if event.button() is QtCore.Qt.MouseButton.RightButton: +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) @@ -56,15 +16,15 @@ def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: name = self.model().data(name_index) uid = int(self.model().data(id_index)) self.selectRow(row) - self.contextMenu(event, name, uid) + self.context_menu(name, uid) + self.update_hover_row(event) + self.verticalHeader().update_hover_section(event) else: - QtWidgets.QTableView.mousePressEvent(self, event) - self.updateHoverRow(event) - self.verticalHeader().updateHoverSection(event) + TableView.mousePressEvent(self, event) - def contextMenu(self, event, name, uid): + 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(QtGui.QCursor.pos()) + menu.popup(QCursor.pos()) diff --git a/src/util/qt.py b/src/util/qt.py index 3bbdce2ca..92ce9bd49 100644 --- a/src/util/qt.py +++ b/src/util/qt.py @@ -3,6 +3,7 @@ from typing import Generator from PyQt6.QtCore import QFile +from PyQt6.QtGui import QPainter def monkeypatch_method(obj, name, fn): @@ -21,3 +22,12 @@ def qopen(path: str, flags: QFile.OpenModeFlag) -> Generator[QFile, None, None]: yield file finally: file.close() + + +@contextmanager +def qpainter(painter: QPainter) -> Generator[QPainter, None, None]: + try: + painter.save() + yield painter + finally: + painter.restore() From 53556dd55849f83efdda11d9eeb6e30be3d31e7c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 3 Jun 2024 05:19:15 +0300 Subject: [PATCH 110/123] socketadapter: Replace binaryFrameReceived signal with binaryMessageReceived the original intent was to use binaryMessageReceived but autocomplete chose binaryFrameReceived and somehow it still worked --- src/chat/socketadapter.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py index 95f07e7f6..c2e27540a 100644 --- a/src/chat/socketadapter.py +++ b/src/chat/socketadapter.py @@ -23,8 +23,7 @@ class WebSocketToSocket(QObject): def __init__(self) -> None: super().__init__() self.socket = QWebSocket() - self.socket.binaryFrameReceived.connect(self.on_bin_message_received) - self.socket.textMessageReceived.connect(self.on_text_message_received) + self.socket.binaryMessageReceived.connect(self.on_bin_message_received) self.socket.errorOccurred.connect(self.on_socket_error) self.buffer = b"" @@ -38,10 +37,6 @@ def on_bin_message_received(self, message: bytes) -> None: self.buffer += message + b"\r\n" self.message_received.emit() - def on_text_message_received(self, message: str) -> None: - self.buffer += f"{message}\r\n".encode() - self.message_received.emit() - def read(self, size: int) -> bytes: ans, self.buffer = self.buffer[:size], self.buffer[size:] return ans From cf09da2655e175e3214514a16f3b21b330a93d0d Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 3 Jun 2024 05:57:40 +0300 Subject: [PATCH 111/123] Parse MapVersion for MapPoolAssignment only once MapPoolAssignment model already has parsed MapVersion as its attribute, so use this to assign version to Map model --- src/api/parsers/MapPoolAssignmentParser.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/api/parsers/MapPoolAssignmentParser.py b/src/api/parsers/MapPoolAssignmentParser.py index 358a13c41..8b2448bff 100644 --- a/src/api/parsers/MapPoolAssignmentParser.py +++ b/src/api/parsers/MapPoolAssignmentParser.py @@ -19,10 +19,9 @@ def parse_to_map(assignment_info: dict) -> Map: if pool.map_params is not None: return pool.map_params.to_map() if pool.map_version is not None: - return MapParser.parse_version( - assignment_info["mapVersion"]["map"], - assignment_info["mapVersion"], - ) + 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 From 7bef3ffc4f4a650b08aec903d3da5a2efe7701ba Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 3 Jun 2024 19:28:08 +0300 Subject: [PATCH 112/123] Define FeaturedMod and FeaturedModFile as pydantic models --- src/api/models/FeaturedMod.py | 18 +++++++++--------- src/api/models/FeaturedModFile.py | 24 ++++++++++++------------ src/api/parsers/FeaturedModFileParser.py | 12 +----------- src/api/parsers/FeaturedModParser.py | 12 +----------- src/fa/game_updater/worker.py | 2 +- 5 files changed, 24 insertions(+), 44 deletions(-) diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py index 0d08d37b8..8a9cb2a29 100644 --- a/src/api/models/FeaturedMod.py +++ b/src/api/models/FeaturedMod.py @@ -1,11 +1,11 @@ -from dataclasses import dataclass +from pydantic import BaseModel +from pydantic import Field -@dataclass -class FeaturedMod: - uid: str - name: str - fullname: str - visible: bool - order: int - description: str +class FeaturedMod(BaseModel): + 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 index 8162c8540..0ecea4ec6 100644 --- a/src/api/models/FeaturedModFile.py +++ b/src/api/models/FeaturedModFile.py @@ -1,14 +1,14 @@ -from dataclasses import dataclass +from pydantic import BaseModel +from pydantic import Field -@dataclass -class FeaturedModFile: - uid: str - version: int - group: str - name: str - md5: str - url: str - cacheable_url: str - hmac_token: str - hmac_parameter: str +class FeaturedModFile(BaseModel): + 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/parsers/FeaturedModFileParser.py b/src/api/parsers/FeaturedModFileParser.py index ee2acecc7..ef55a1395 100644 --- a/src/api/parsers/FeaturedModFileParser.py +++ b/src/api/parsers/FeaturedModFileParser.py @@ -4,17 +4,7 @@ class FeaturedModFileParser: @staticmethod def parse(api_result: dict) -> FeaturedModFile: - return FeaturedModFile( - uid=api_result["id"], - version=api_result["version"], - group=api_result["group"], - name=api_result["name"], - md5=api_result["md5"], - url=api_result["url"], - cacheable_url=api_result["cacheableUrl"], - hmac_token=api_result["hmacToken"], - hmac_parameter=api_result["hmacParameter"], - ) + return FeaturedModFile(**api_result) @staticmethod def parse_many(api_result: list[dict]) -> list[FeaturedModFile]: diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py index 11fb83f09..da95bdac8 100644 --- a/src/api/parsers/FeaturedModParser.py +++ b/src/api/parsers/FeaturedModParser.py @@ -5,17 +5,7 @@ class FeaturedModParser: @staticmethod def parse(data: dict) -> FeaturedMod: - return FeaturedMod( - uid=data["id"], - name=data["technicalName"], - fullname=data["displayName"], - visible=data.get("visible", False), - order=data.get("order", 0), - description=data.get( - "description", - "No description provided", - ), - ) + return FeaturedMod(**data) @staticmethod def parse_many(data: list[dict]) -> list[FeaturedMod]: diff --git a/src/fa/game_updater/worker.py b/src/fa/game_updater/worker.py index a5167ae5a..25b93eb7d 100644 --- a/src/fa/game_updater/worker.py +++ b/src/fa/game_updater/worker.py @@ -249,7 +249,7 @@ def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None: @_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.uid, modversion) + files = self.get_files_to_update(fmod.xd, modversion) self.update_files(files) return files From bb976fab5b6241cff84cbeb104e251e28417e714 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 3 Jun 2024 19:44:56 +0300 Subject: [PATCH 113/123] Create base model for API models with common model_config --- src/api/models/AbstractEntity.py | 6 ++---- src/api/models/ConfiguredModel.py | 6 ++++++ src/api/models/CoopMission.py | 6 ++---- src/api/models/CoopResult.py | 4 ++-- src/api/models/CoopScenario.py | 7 ++----- src/api/models/FeaturedMod.py | 5 +++-- src/api/models/FeaturedModFile.py | 5 +++-- src/api/models/Game.py | 4 ++-- src/api/models/GeneratedMapParams.py | 7 ++----- src/api/models/PlayerStats.py | 4 ++-- src/api/models/ReviewsSummary.py | 5 +++-- 11 files changed, 29 insertions(+), 30 deletions(-) create mode 100644 src/api/models/ConfiguredModel.py diff --git a/src/api/models/AbstractEntity.py b/src/api/models/AbstractEntity.py index c6bb11902..c49e15a4b 100644 --- a/src/api/models/AbstractEntity.py +++ b/src/api/models/AbstractEntity.py @@ -1,11 +1,9 @@ -from pydantic import BaseModel -from pydantic import ConfigDict from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel -class AbstractEntity(BaseModel): - model_config = ConfigDict(populate_by_name=True) +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 index 2b1422236..09a64acdc 100644 --- a/src/api/models/CoopMission.py +++ b/src/api/models/CoopMission.py @@ -1,11 +1,9 @@ -from pydantic import BaseModel -from pydantic import ConfigDict from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel -class CoopMission(BaseModel): - model_config = ConfigDict(populate_by_name=True) +class CoopMission(ConfiguredModel): xd: int = Field(alias="id") category: str description: str diff --git a/src/api/models/CoopResult.py b/src/api/models/CoopResult.py index 1f7d2cd35..8e4d34e02 100644 --- a/src/api/models/CoopResult.py +++ b/src/api/models/CoopResult.py @@ -1,10 +1,10 @@ -from pydantic import BaseModel from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel from api.models.Game import Game -class CoopResult(BaseModel): +class CoopResult(ConfiguredModel): xd: str = Field(alias="id") duration: int mission: int diff --git a/src/api/models/CoopScenario.py b/src/api/models/CoopScenario.py index 505d27945..5d30ca789 100644 --- a/src/api/models/CoopScenario.py +++ b/src/api/models/CoopScenario.py @@ -1,13 +1,10 @@ -from pydantic import BaseModel -from pydantic import ConfigDict from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel from api.models.CoopMission import CoopMission -class CoopScenario(BaseModel): - model_config = ConfigDict(populate_by_name=True) - +class CoopScenario(ConfiguredModel): xd: int = Field(alias="id") name: str order: int diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py index 8a9cb2a29..cff4af044 100644 --- a/src/api/models/FeaturedMod.py +++ b/src/api/models/FeaturedMod.py @@ -1,8 +1,9 @@ -from pydantic import BaseModel from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel -class FeaturedMod(BaseModel): + +class FeaturedMod(ConfiguredModel): xd: str = Field(alias="id") name: str = Field(alias="technicalName") fullname: str = Field(alias="displayName") diff --git a/src/api/models/FeaturedModFile.py b/src/api/models/FeaturedModFile.py index 0ecea4ec6..154f40b31 100644 --- a/src/api/models/FeaturedModFile.py +++ b/src/api/models/FeaturedModFile.py @@ -1,8 +1,9 @@ -from pydantic import BaseModel from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel -class FeaturedModFile(BaseModel): + +class FeaturedModFile(ConfiguredModel): xd: str = Field(alias="id") version: int group: str diff --git a/src/api/models/Game.py b/src/api/models/Game.py index c242d8ee0..00dde8dd1 100644 --- a/src/api/models/Game.py +++ b/src/api/models/Game.py @@ -1,11 +1,11 @@ -from pydantic import BaseModel from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel from api.models.Player import Player from api.models.PlayerStats import PlayerStats -class Game(BaseModel): +class Game(ConfiguredModel): end_time: str = Field(alias="endTime") xd: str = Field(alias="id") name: str diff --git a/src/api/models/GeneratedMapParams.py b/src/api/models/GeneratedMapParams.py index 6491b4077..d77eb15e2 100644 --- a/src/api/models/GeneratedMapParams.py +++ b/src/api/models/GeneratedMapParams.py @@ -1,17 +1,14 @@ from __future__ import annotations -from pydantic import BaseModel -from pydantic import ConfigDict 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(BaseModel): - model_config = ConfigDict(populate_by_name=True) - +class GeneratedMapParams(ConfiguredModel): name: str = Field(alias="type") spawns: int size: int diff --git a/src/api/models/PlayerStats.py b/src/api/models/PlayerStats.py index 6fea58f66..ea4ec724f 100644 --- a/src/api/models/PlayerStats.py +++ b/src/api/models/PlayerStats.py @@ -1,8 +1,8 @@ -from pydantic import BaseModel from pydantic import Field +from api.models.ConfiguredModel import ConfiguredModel from api.models.Player import Player -class PlayerStats(BaseModel): +class PlayerStats(ConfiguredModel): player: Player | None = Field(None) diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py index 5686d293c..db73c0e56 100644 --- a/src/api/models/ReviewsSummary.py +++ b/src/api/models/ReviewsSummary.py @@ -1,9 +1,10 @@ -from pydantic import BaseModel from pydantic import Field from pydantic import field_validator +from api.models.ConfiguredModel import ConfiguredModel -class ReviewsSummary(BaseModel): + +class ReviewsSummary(ConfiguredModel): positive: float negative: float score: float From 056997d2ab8fd47062b1512df45722224b71bb00 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 3 Jun 2024 20:17:37 +0300 Subject: [PATCH 114/123] Fix ModType enum --- src/api/models/ModType.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/models/ModType.py b/src/api/models/ModType.py index cbbe35573..823e3b478 100644 --- a/src/api/models/ModType.py +++ b/src/api/models/ModType.py @@ -4,8 +4,8 @@ class ModType(Enum): - UI = "modType.ui" - SIM = "modType.sim" + UI = "UI" + SIM = "SIM" OTHER = "" @staticmethod From 70b0331a6f32b6c016ef1ee61c0976a558e676f8 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 3 Jun 2024 21:24:33 +0300 Subject: [PATCH 115/123] Do not leak access tokens into logs --- src/oauth/oauth_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oauth/oauth_flow.py b/src/oauth/oauth_flow.py index 6f5cdd2b1..0fa3d03ad 100644 --- a/src/oauth/oauth_flow.py +++ b/src/oauth/oauth_flow.py @@ -65,7 +65,7 @@ def on_expiration_at_changed(self, expiration_at: QDateTime) -> None: self._expires_in = QDateTime.currentDateTime().msecsTo(expiration_at) def on_token_changed(self, new_token: str) -> None: - self._logger.debug(f"Token changed to: {new_token}") + self._logger.debug("Token changed") def on_granted(self) -> None: self._logger.debug("Token granted successfuly!") From c1999d92c29ac5fe606e091f2356b511ac31ecbe Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 4 Jun 2024 05:45:52 +0300 Subject: [PATCH 116/123] Remove some stupid rules from .gitignore --- .gitignore | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.gitignore b/.gitignore index c2df4b965..f279bfdd6 100644 --- a/.gitignore +++ b/.gitignore @@ -26,12 +26,3 @@ client_venv *.egg-info .vagrant .cache -*.dll -*.exe -*.pac -*.conf -*.dat -audio -imageformats -platforms -*.json From ff807e5eefac99707125aa23572eb2bab6f5082f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 4 Jun 2024 21:29:11 +0300 Subject: [PATCH 117/123] Bump faf-uid version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7281c3a71..26b6370ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: environment: deploy runs-on: windows-latest env: - UID_VERSION: v4.0.4 + UID_VERSION: v4.0.6 ICE_ADAPTER_VERSION: v3.3.7 BUILD_VERSION: ${{ github.event.inputs.version }} From 87c952955c9e6374591ead5ce25fdca8bb5628f4 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 4 Jun 2024 21:40:13 +0300 Subject: [PATCH 118/123] Make a global object for OAuth2Flow --- src/api/ApiBase.py | 3 ++- src/client/_clientwindow.py | 7 +++---- src/oauth/oauth_flow.py | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index 630a033b7..d856bdf25 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -15,6 +15,7 @@ from config import Settings from oauth.oauth_flow import OAuth2Flow +from oauth.oauth_flow import OAuth2FlowInstance logger = logging.getLogger(__name__) @@ -23,7 +24,7 @@ class ApiBase(QObject): - oauth: OAuth2Flow = OAuth2Flow() + oauth: OAuth2Flow = OAuth2FlowInstance def __init__(self, route: str = "") -> None: QObject.__init__(self) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 6982a2951..ebbd2f6f0 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -12,7 +12,6 @@ import notifications as ns import util import util.crash -from api.ApiBase import ApiBase from chat import ChatMVC from chat._avatarWidget import AvatarWidget from chat.channel_autojoiner import ChannelAutojoiner @@ -66,7 +65,7 @@ from model.rating import MatchmakerQueueType from model.rating import RatingType from news import NewsWidget -from oauth.oauth_flow import OAuth2Flow +from oauth.oauth_flow import OAuth2FlowInstance from power import PowerTools from replays import ReplaysWidget from secondaryServer import SecondaryServer @@ -142,8 +141,8 @@ def __init__(self, *args, **kwargs): ) self._network_access_manager = QNetworkAccessManager(self) - self.oauth_flow = OAuth2Flow(parent=self) - ApiBase.set_oauth(self.oauth_flow) + 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) diff --git a/src/oauth/oauth_flow.py b/src/oauth/oauth_flow.py index 0fa3d03ad..9710e990f 100644 --- a/src/oauth/oauth_flow.py +++ b/src/oauth/oauth_flow.py @@ -94,3 +94,6 @@ def setup_credentials(self) -> None: self.setClientIdentifier(client_id) self.setAccessTokenUrl(token_url) self.setScope(" ".join(scopes)) + + +OAuth2FlowInstance = OAuth2Flow() From 97b65c934f983f8fb4fd2d344c026ea03a20356f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 5 Jun 2024 06:32:28 +0300 Subject: [PATCH 119/123] Disable sorting for coop leaderboards filter model for them is not yet implemented and probably won't ever be --- src/coop/_coopwidget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 96450078c..78fb5dc39 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -140,6 +140,7 @@ def process_leaderboard_infos(self, message: dict[str, list[CoopResult]]): w = self.leaderBoardTextFour model = CoopLeaderBoardModel(message) w.setModel(model) + w.setSortingEnabled(False) w.setItemDelegate(CoopLeaderboardItemDelegate(self)) self.leaderBoard.setVisible(True) From 35d528604abca65807d6a7f770ee947b777182a3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 5 Jun 2024 06:55:44 +0300 Subject: [PATCH 120/123] Let pydantic parse json of ReviewsSummary somehow it was forgotten to add this change in 57dbab6420eb2764c916d67ba41b8c39a2cf6714 --- src/api/parsers/ReviewsSummaryParser.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/api/parsers/ReviewsSummaryParser.py b/src/api/parsers/ReviewsSummaryParser.py index 797c89864..cabb60c57 100644 --- a/src/api/parsers/ReviewsSummaryParser.py +++ b/src/api/parsers/ReviewsSummaryParser.py @@ -1,22 +1,10 @@ from api.models.ReviewsSummary import ReviewsSummary -def _avoid_none(value: float | int | None) -> float | int: - return value or 0 - - class ReviewsSummaryParser: @staticmethod def parse(reviews_info: dict) -> ReviewsSummary | None: if not reviews_info: return None - - return ReviewsSummary( - positive=_avoid_none(reviews_info["positive"]), - negative=_avoid_none(reviews_info["negative"]), - score=_avoid_none(reviews_info["score"]), - average_score=_avoid_none(reviews_info["averageScore"]), - num_reviews=_avoid_none(reviews_info["reviews"]), - lower_bound=_avoid_none(reviews_info["lowerBound"]), - ) + return ReviewsSummary(**reviews_info) From 8a8604c5ecd4839f6d211408c649235bcde7a5e9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 6 Jun 2024 03:03:55 +0300 Subject: [PATCH 121/123] Fix some minor overlooked things noticed by PR reviewer (type hints and code invariants) --- src/api/coop_api.py | 2 +- src/api/models/ReviewsSummary.py | 2 +- src/api/parsers/CoopResultParser.py | 2 +- src/api/parsers/GeneratedMapParamsParser.py | 7 +------ src/qt/itemviews/tableheaderview.py | 3 +-- src/replays/_replayswidget.py | 4 ++-- 6 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/api/coop_api.py b/src/api/coop_api.py index 116f07b62..d1e1cf6c6 100644 --- a/src/api/coop_api.py +++ b/src/api/coop_api.py @@ -17,7 +17,7 @@ def prepare_data(self, message: dict) -> dict[str, list[CoopScenario]]: class CoopResultApiAccessor(DataApiAccessor): - def __init__(self): + def __init__(self) -> None: super().__init__("/data/coopResult") def prepare_query_dict(self, mission: int) -> dict: diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py index db73c0e56..dd7268b6b 100644 --- a/src/api/models/ReviewsSummary.py +++ b/src/api/models/ReviewsSummary.py @@ -14,5 +14,5 @@ class ReviewsSummary(ConfiguredModel): @field_validator("*", mode="before") @classmethod - def avoid_none(cls, value: float | int) -> float | int: + def avoid_none(cls, value: float | int | None) -> float | int: return value or 0 diff --git a/src/api/parsers/CoopResultParser.py b/src/api/parsers/CoopResultParser.py index b25d2938e..c9786db25 100644 --- a/src/api/parsers/CoopResultParser.py +++ b/src/api/parsers/CoopResultParser.py @@ -3,7 +3,7 @@ class CoopResultParser: @staticmethod - def parse(api_result: dict) -> None: + def parse(api_result: dict) -> CoopResult: return CoopResult(**api_result) @staticmethod diff --git a/src/api/parsers/GeneratedMapParamsParser.py b/src/api/parsers/GeneratedMapParamsParser.py index 7e0c5fa41..43b50c2b4 100644 --- a/src/api/parsers/GeneratedMapParamsParser.py +++ b/src/api/parsers/GeneratedMapParamsParser.py @@ -6,12 +6,7 @@ class GeneratedMapParamsParser: @staticmethod def parse(params_info: dict) -> GeneratedMapParams: - return GeneratedMapParams( - name=params_info["type"], - spawns=params_info["spawns"], - size=params_info["size"], - gen_version=params_info["version"], - ) + return GeneratedMapParams(**params_info) @staticmethod def parse_to_map(params_info: dict) -> Map: diff --git a/src/qt/itemviews/tableheaderview.py b/src/qt/itemviews/tableheaderview.py index 810c95824..07b995e08 100644 --- a/src/qt/itemviews/tableheaderview.py +++ b/src/qt/itemviews/tableheaderview.py @@ -62,8 +62,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: def update_hover_section(self, event: QHoverEvent) -> None: index = self.logicalIndexAt(event.position().toPoint()) - old_hover = self.hover - self.hover = index + old_hover, self.hover = self.hover, index if self.hover != old_hover: if old_hover != -1: diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 233efaa48..8f48b4d65 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -288,7 +288,7 @@ def _removeGame(self, game): class ReplayMetadata: - def __init__(self, data): + def __init__(self, data: str) -> None: self.raw_data = data self.is_broken = False self.model: MetadataModel | None = None @@ -310,7 +310,7 @@ def is_incomplete(self) -> bool: return True return not self.model.complete - def launch_time(self): + def launch_time(self) -> float: if self.model.launched_at > 0: return self.model.launched_at return self.model.game_time From 45b407cd39158f68ac0183e75dac2e425bbfbd1d Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 6 Jun 2024 03:07:00 +0300 Subject: [PATCH 122/123] Show notifications without overlapping taskbar --- src/notifications/ns_dialog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py index 5c56e9fcb..2426b2e14 100644 --- a/src/notifications/ns_dialog.py +++ b/src/notifications/ns_dialog.py @@ -4,7 +4,6 @@ import time from PyQt6 import QtCore -from PyQt6 import QtWidgets from PyQt6.QtMultimedia import QSoundEffect import util @@ -98,8 +97,8 @@ def mousePressEvent(self, event): if event.button() == QtCore.Qt.MouseButton.RightButton: self.hide() - def updatePosition(self): - screen_size = QtWidgets.QApplication.primaryScreen().geometry() + def updatePosition(self) -> None: + screen_size = self.screen().availableGeometry() dialog_size = self.geometry() # self.client.notificationSystem.settings.popup_position position = self.settings.popup_position From b82e3d34e48d4a810f2cce1d0ec5428a88b63077 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 6 Jun 2024 03:10:07 +0300 Subject: [PATCH 123/123] Force disconnect from chat if websocket is not connected or can't write --- src/chat/socketadapter.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py index c2e27540a..3a4422025 100644 --- a/src/chat/socketadapter.py +++ b/src/chat/socketadapter.py @@ -38,6 +38,8 @@ def on_bin_message_received(self, message: bytes) -> None: 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 @@ -49,7 +51,9 @@ def shutdown(self, how: int) -> None: self.socket.deleteLater() def write(self, message: bytes) -> None: - self.socket.sendBinaryMessage(message.strip()) + sent = self.socket.sendBinaryMessage(message.strip()) + if sent == 0: + raise OSError def send(self, message: bytes) -> None: """ Alias for write, just in case """