From c22918673bea27fc277b08e218bbdf24bb03ec52 Mon Sep 17 00:00:00 2001 From: tretrauit Date: Wed, 16 Feb 2022 00:49:33 +0700 Subject: [PATCH] Support chinese variant, QQ object in launcher and some code optimizations --- .gitignore | 80 ++++++++++++++++++++++++ .idea/.gitignore | 3 + LICENSE | 2 +- README.md | 2 +- requirements.txt | 2 + tests/launcher_api_cn_test.py | 38 +++++++++++ tests/launcher_api_test.py | 4 +- worthless/__main__.py | 2 +- worthless/classes/launcher/background.py | 7 ++- worthless/classes/launcher/iconbutton.py | 8 +-- worthless/classes/launcher/info.py | 26 +++++++- worthless/classes/launcher/post.py | 4 +- worthless/classes/launcher/qq.py | 35 +++++++++++ worthless/classes/mhyresponse.py | 8 +++ worthless/constants.py | 7 ++- worthless/gui.py | 58 ++++++++++++----- worthless/installer.py | 33 ++++++++++ worthless/launcher.py | 54 +++++++++------- worthless/patcher.py | 74 ++++++++++++++++++++-- 19 files changed, 389 insertions(+), 58 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 tests/launcher_api_cn_test.py create mode 100644 worthless/classes/launcher/qq.py create mode 100644 worthless/classes/mhyresponse.py create mode 100644 worthless/installer.py diff --git a/.gitignore b/.gitignore index 55be276..3d6530d 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,83 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +_trial_temp/ +_trail_temp.lock diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/LICENSE b/LICENSE index 2071b23..b653f6a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +Copyright (c) 2022 tretrauit Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 1acf8cc..e7d5a4d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # worthless-launcher -A worthless CLI launcher. \ No newline at end of file +A worthless CLI launcher written in Python. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7c05f0b..ff2bf94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ aiohttp==3.8.1 +appdirs~=1.4.4 +aiofiles~=0.8.0 \ No newline at end of file diff --git a/tests/launcher_api_cn_test.py b/tests/launcher_api_cn_test.py new file mode 100644 index 0000000..315fdbf --- /dev/null +++ b/tests/launcher_api_cn_test.py @@ -0,0 +1,38 @@ +import unittest +import asyncio +import worthless +from worthless.classes import launcher +client = worthless.Launcher(overseas=False) + + +class LauncherCNTest(unittest.TestCase): + def test_get_version_info(self): + version_info = asyncio.run(client.get_version_info()) + print("get_version_info test.") + print("get_version_info: ", version_info) + self.assertIsInstance(version_info, dict) + + def test_get_launcher_info(self): + launcher_info = asyncio.run(client.get_launcher_info()) + print("get_launcher_info test.") + print("get_launcher_info: ", launcher_info) + print("raw: ", launcher_info.raw) + self.assertIsInstance(launcher_info, launcher.Info) + + def test_get_launcher_full_info(self): + launcher_info = asyncio.run(client.get_launcher_full_info()) + print("get_launcher_full_info test.") + print("get_launcher_full_info: ", launcher_info) + print("raw: ", launcher_info.raw) + self.assertIsInstance(launcher_info, launcher.Info) + + def test_get_launcher_background_url(self): + bg_url = asyncio.run(client.get_launcher_background_url()) + print("get_launcher_background_url test.") + print("get_launcher_background_url: ", bg_url) + self.assertIsInstance(bg_url, str) + self.assertTrue(bg_url) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/launcher_api_test.py b/tests/launcher_api_test.py index 0b369f8..6756647 100644 --- a/tests/launcher_api_test.py +++ b/tests/launcher_api_test.py @@ -5,7 +5,7 @@ from worthless.classes import launcher client = worthless.Launcher() -class LauncherTest(unittest.TestCase): +class LauncherOverseasTest(unittest.TestCase): def test_get_version_info(self): version_info = asyncio.run(client.get_version_info()) print("get_version_info test.") @@ -16,12 +16,14 @@ class LauncherTest(unittest.TestCase): launcher_info = asyncio.run(client.get_launcher_info()) print("get_launcher_info test.") print("get_launcher_info: ", launcher_info) + print("raw: ", launcher_info.raw) self.assertIsInstance(launcher_info, launcher.Info) def test_get_launcher_full_info(self): launcher_info = asyncio.run(client.get_launcher_full_info()) print("get_launcher_full_info test.") print("get_launcher_full_info: ", launcher_info) + print("raw: ", launcher_info.raw) self.assertIsInstance(launcher_info, launcher.Info) def test_get_launcher_background_url(self): diff --git a/worthless/__main__.py b/worthless/__main__.py index c3a60a9..1426c6a 100755 --- a/worthless/__main__.py +++ b/worthless/__main__.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -from worthless import gui +import gui as gui if __name__ == '__main__': gui.main() diff --git a/worthless/classes/launcher/background.py b/worthless/classes/launcher/background.py index 809e124..d6f6fde 100644 --- a/worthless/classes/launcher/background.py +++ b/worthless/classes/launcher/background.py @@ -3,7 +3,7 @@ class Background: Note that the `background` variable is an url to the background image, while the `url` variable contains an empty string, so it seems that the - `url` and `icon` variables are used by the official launcher itself. + `url` and `icon` variables are not used by the official launcher itself. Also, the launcher background checksum is using an algorithm which I haven't found out yet, so you better not rely on it but instead rely @@ -16,8 +16,9 @@ class Background: - :class:`str` url: The url variable. - :class:`str` version: The launcher background version. - :class:`str` bg_checksum: The launcher background checksum. - - :class:`dict` raw: The launcher background raw information in dict. + - :class:`dict` raw: The launcher background raw information. """ + def __init__(self, background, icon, url, version, bg_checksum, raw): """Inits the launcher background class""" self.background = background @@ -31,4 +32,4 @@ class Background: def from_dict(data) -> 'Background': """Creates a launcher background from a dictionary.""" return Background(data["background"], data["icon"], data["url"], - data["version"], data["bg_checksum"], data) + data["version"], data["bg_checksum"], data) diff --git a/worthless/classes/launcher/iconbutton.py b/worthless/classes/launcher/iconbutton.py index 83d7b36..035718e 100644 --- a/worthless/classes/launcher/iconbutton.py +++ b/worthless/classes/launcher/iconbutton.py @@ -16,9 +16,10 @@ class IconButton: - :class:`str` qr_img: The QR code url. - :class:`str` qr_desc: The QR code description. - :class:`str` img_hover: The icon url when hovered over. - - :class:`dict[LauncherIconOtherLink]` other_links: Other links in the button. - - :class:`dict` raw: The launcher background raw information in dict. + - :class:`list[LauncherIconOtherLink]` other_links: Other links in the button. + - :class:`dict` raw: The launcher background raw information. """ + def __init__(self, icon_id, img, tittle, url, qr_img, qr_desc, img_hover, other_links, raw): """Inits the launcher icon class""" self.icon_id = icon_id @@ -38,5 +39,4 @@ class IconButton: for link in data['other_links']: other_links.append(IconOtherLink.from_dict(link)) return IconButton(data["icon_id"], data["img"], data["tittle"], data["url"], data["qr_img"], - data["qr_desc"], data["img_hover"], other_links, data) - + data["qr_desc"], data["img_hover"], other_links, data) diff --git a/worthless/classes/launcher/info.py b/worthless/classes/launcher/info.py index 35b5901..3bbb3af 100644 --- a/worthless/classes/launcher/info.py +++ b/worthless/classes/launcher/info.py @@ -1,16 +1,33 @@ -from worthless.classes.launcher import background, banner, iconbutton, post +from worthless.classes.launcher import background, banner, iconbutton, post, qq Background = background.Background Banner = banner.Banner IconButton = iconbutton.IconButton Post = post.Post +QQ = qq.QQ class Info: - def __init__(self, lc_background: Background, lc_banner: list[Banner], icon: list[IconButton], lc_post: list[Post]): + """Contains the launcher information + + Note that QQ is not wrapped due to not having access to the chinese yuanshen launcher. + You can contribute to the project if you want :D + + Attributes: + + - :class:`worthless.classes.launcher.background.Background` background: The launcher background information. + - :class:`worthless.classes.launcher.banner.Banner` banner: The launcher banner information. + - :class:`worthless.classes.launcher.iconbutton.IconButton` icon: The launcher icon buttons information. + - :class:`worthless.classes.launcher.qq.QQ` post: The launcher QQ posts information. + - :class:`dict` raw: The launcher raw information. + """ + def __init__(self, lc_background: Background, lc_banner: list[Banner], icon: list[IconButton], lc_post: list[Post], + lc_qq: list[QQ], raw: dict): self.background = lc_background self.banner = lc_banner self.icon = icon self.post = lc_post + self.qq = lc_qq + self.raw = raw @staticmethod def from_dict(data): @@ -24,5 +41,8 @@ class Info: lc_post = [] for p in data["post"]: lc_post.append(Post.from_dict(p)) - return Info(bg, lc_banner, lc_icon, lc_post) + lc_qq = [] + for q in data["qq"]: + lc_qq.append(QQ.from_dict(q)) + return Info(bg, lc_banner, lc_icon, lc_post, lc_qq, data) diff --git a/worthless/classes/launcher/post.py b/worthless/classes/launcher/post.py index a6bc7fa..e9f9588 100644 --- a/worthless/classes/launcher/post.py +++ b/worthless/classes/launcher/post.py @@ -13,13 +13,13 @@ class Post: Attributes: - :class:`str` post_id: The launcher post id. - - :class:`str` type: The post type, as explained above. + - :class:`str` type: The post type, can be POST_TYPE_ANNOUNCE, POST_TYPE_ACTIVITY and POST_TYPE_INFO - :class:`str` tittle: The post title. - :class:`str` url: The post target url. - :class:`str` show_time: The time when the post will be shown. - :class:`str` order: The post order. - :class:`str` title: The post title. - - :class:`dict` raw: The banner raw information. + - :class:`dict` raw: The post raw information. """ def __init__(self, post_id, post_type, tittle, url, show_time, order, title, raw): self.post_id = post_id diff --git a/worthless/classes/launcher/qq.py b/worthless/classes/launcher/qq.py new file mode 100644 index 0000000..c1c2f55 --- /dev/null +++ b/worthless/classes/launcher/qq.py @@ -0,0 +1,35 @@ +class QQ: + """Contains the launcher QQ information + + Note that QQ is not wrapped due to not having access to the chinese yuanshen launcher. + You can contribute to the project if you want :D + + Attributes: + - :class:`str` qq_id: The id of the QQ post + - :class:`str` name: The name of the QQ post + - :class:`int` number: The number of the QQ post + - :class:`str` code: The QQ post url. + - :class:`dict` raw: The launcher raw information. + """ + def __init__(self, qq_id, name, number, code, raw): + self.qq_id = qq_id + self.name = name + self.number = number + self.code = code + self.raw = raw + + @staticmethod + def from_dict(raw: dict) -> 'QQ': + """Creates a QQ object from a dictionary + + Args: + raw (dict): The raw dictionary + + Returns: + QQ: The QQ object + """ + qq_id = raw.get('qq_id') + name = raw.get('name') + number = int(raw.get('number')) + code = raw.get('code') + return QQ(qq_id, name, number, code, raw) diff --git a/worthless/classes/mhyresponse.py b/worthless/classes/mhyresponse.py new file mode 100644 index 0000000..0ff17f9 --- /dev/null +++ b/worthless/classes/mhyresponse.py @@ -0,0 +1,8 @@ +class mhyResponse: + """Simple class for wrapping miHoYo web response + Currently not used for anything. + """ + def __init__(self, retcode, message, data): + self.retcode = retcode + self.message = message + self.data = data diff --git a/worthless/constants.py b/worthless/constants.py index e1a5ca0..4c69b64 100644 --- a/worthless/constants.py +++ b/worthless/constants.py @@ -1,6 +1,11 @@ -LAUNCHER_API_URL = "https://sdk-os-static.mihoyo.com/hk4e_global/mdk/launcher/api" +APP_NAME="worthless" +APP_AUTHOR="tretrauit" +LAUNCHER_API_URL_OS = "https://sdk-os-static.mihoyo.com/hk4e_global/mdk/launcher/api" +LAUNCHER_API_URL_CN = "https://sdk-static.mihoyo.com/hk4e_cn/mdk/launcher/api" PATCH_GIT_URL = "https://notabug.org/Krock/dawn" TELEMETRY_URL_LIST = [ "log-upload-os.mihoyo.com", + "log-upload.mihoyo.com", "overseauspider.yuanshen.com" + "uspider.yuanshen.com" ] diff --git a/worthless/gui.py b/worthless/gui.py index f7864a5..10d0aaf 100755 --- a/worthless/gui.py +++ b/worthless/gui.py @@ -4,40 +4,66 @@ import argparse from pathlib import Path -def interactive_ui(gamedir=Path.cwd()): - raise NotImplementedError("Interactive UI is not implemented") +class UI: + def __init__(self, gamedir: str, noconfirm: bool) -> None: + self._noconfirm = noconfirm + self._gamedir = gamedir + def _ask(self, title, description): + raise NotImplementedError() -def update_game(gamedir=Path.cwd(), noconfirm=False): - print("Checking for current game version...") - # Call check_game_version() - print("Updating game...") - # Call update_game(fromver) - raise NotImplementedError("Update game is not implemented") + def install_game(self): + # TODO + raise NotImplementedError("Install game is not implemented.") + + def update_game(self): + print("Checking for current game version...") + # Call check_game_version() + print("Updating game...") + # Call update_game(fromver) + raise NotImplementedError("Update game is not implemented.") + + def interactive_ui(self): + raise NotImplementedError() def main(): - parser = argparse.ArgumentParser(prog="worthless-launcher", description="A worthless launcher written in Python.") + parser = argparse.ArgumentParser(prog="worthless", description="A worthless launcher written in Python.") parser.add_argument("-D", "-d", "--dir", action="store", type=Path, default=Path.cwd(), help="Specify the game directory (default current working directory)") - parser.add_argument("-I", "-i", "--install", action="store_true", + parser.add_argument("-S", "--install", action="store_true", help="Install the game (if not already installed, else do nothing)") - parser.add_argument("-U", "-u", "--update", action="store_true", help="Update the game (if not updated)") + parser.add_argument("-U", "--install-from-file", action="store_true", + help="Install the game from the game archive (if not already installed, \ + else update from archive)") + parser.add_argument("-Sp", "--patch", action="store_true", + help="Patch the game (if not already patched, else do nothing)") + parser.add_argument("-Sy", "--update", action="store_true", + help="Update the game and specified voiceover pack only (or install if not found)") + parser.add_argument("-Syu", "--update", action="store_true", + help="Update the game and all installed voiceover packs (or install if not found)") + parser.add_argument("-Rs", "--remove", action="store_true", help="Remove the game (if installed)") + parser.add_argument("-Rp", "--remove-patch", action="store_true", help="Revert the game patch (if patched)") + parser.add_argument("-Rv", "--remove-voiceover", action="store_true", help="Remove a Voiceover pack (if installed)") parser.add_argument("--noconfirm", action="store_true", - help="Do not ask any questions. (Ignored in interactive mode)") + help="Do not ask any for confirmation. (Ignored in interactive mode)") args = parser.parse_args() - print(args) + interactive_mode = not args.install and not args.install_from_file and not args.patch and not args.update and not \ + args.remove and not args.remove_patch and not args.remove_voiceover + ui = UI(args.dir, args.noconfirm) + if args.install and args.update: raise ValueError("Cannot specify both --install and --update arguments.") if args.install: - raise NotImplementedError("Install game is not implemented") + ui.install_game() if args.update: - update_game(args.dir, args.noconfirm) + ui.update_game() return - interactive_ui(args.dir) + if interactive_mode: + ui.interactive_ui() if __name__ == "__main__": diff --git a/worthless/installer.py b/worthless/installer.py new file mode 100644 index 0000000..a571090 --- /dev/null +++ b/worthless/installer.py @@ -0,0 +1,33 @@ +import asyncio +import tarfile +import constants +import appdirs +import aiofiles +from pathlib import Path +import shutil +import aiohttp +from worthless.launcher import Launcher +from configparser import ConfigParser + + +class Installer: + def __init__(self, gamedir: str | Path = Path.cwd(), overseas: bool = True): + if isinstance(gamedir, str): + gamedir = Path(gamedir) + self._gamedir = gamedir + config_file = self._gamedir.joinpath("config.ini") + self._config_file = config_file.resolve() + self._version = None + self._overseas = overseas + self._launcher = Launcher(self._gamedir, self._overseas) + if config_file.exists(): + self._version = self._read_version_from_config() + else: # TODO: Use An Anime Game Launcher method (which is more brutal, but it works) + self._version = "mangosus" + + def _read_version_from_config(self): + if self._config_file.exists(): + raise FileNotFoundError(f"Config file {self._config_file} not found") + cfg = ConfigParser() + cfg.read(str(self._config_file)) + return cfg.get("miHoYo", "game_version") diff --git a/worthless/launcher.py b/worthless/launcher.py index d5150cc..59aa2fb 100644 --- a/worthless/launcher.py +++ b/worthless/launcher.py @@ -10,14 +10,28 @@ class Launcher: Contains functions to get information from server and client like the official launcher. """ - def __init__(self, gamedir=Path.cwd()): + def __init__(self, gamedir=Path.cwd(), language=None, overseas=True): """Initialize the launcher API Args: gamedir (Path): Path to the game directory. """ - self._api = constants.LAUNCHER_API_URL - self._lang = self._get_system_language() + self._overseas = overseas + if overseas: + self._api = constants.LAUNCHER_API_URL_OS + self._params = { + "key": "gcStgarh", + "launcher_id": "10", + } + self._lang = self._get_system_language() if not language else language.lower().replace("_", "-") + else: + self._api = constants.LAUNCHER_API_URL_CN + self._params = { + "key": "eYd89JmJ", + "launcher_id": "18", + "channel_id": "1" + } + self._lang = "zh-cn" # Use chinese language because this is Pooh version if isinstance(gamedir, str): gamedir = Path(gamedir) self._gamedir = gamedir.resolve() @@ -36,18 +50,6 @@ class Launcher: request_info=rsp.request_info) return rsp_json - async def _get_launcher_info(self, adv=True) -> launcher.Info: - params = {"key": "gcStgarh", - "filter_adv": str(adv).lower(), - "launcher_id": "10", - "language": self._lang} - rsp = await self._get(self._api + "/content", params=params) - if rsp["data"]["adv"] is None: - params["language"] = "en-us" - rsp = await self._get(self._api + "/content", params=params) - lc_info = launcher.Info.from_dict(rsp["data"]) - return lc_info - @staticmethod def _get_system_language() -> str: """Gets system language compatible with server parameters. @@ -61,16 +63,27 @@ class Launcher: lowercase_lang = lang.lower().replace("_", "-") return lowercase_lang except ValueError: - return "en-us" + return "en-us" # Fallback to English if locale is not supported - async def override_gamedir(self, gamedir: str) -> None: + async def _get_launcher_info(self, adv=True) -> launcher.Info: + params = self._params | {"filter_adv": str(adv).lower(), + "language": self._lang} + rsp = await self._get(self._api + "/content", params=params) + if rsp["data"]["adv"] is None: + params["language"] = "en-us" + rsp = await self._get(self._api + "/content", params=params) + lc_info = launcher.Info.from_dict(rsp["data"]) + return lc_info + + async def override_gamedir(self, gamedir: str | Path) -> None: """Overrides game directory with another directory. Args: gamedir (str): New directory to override with. """ - - self._gamedir = Path(gamedir).resolve() + if isinstance(gamedir, str): + gamedir = Path(gamedir).resolve() + self._gamedir = gamedir async def override_language(self, language: str) -> None: """Overrides system detected language with another language. @@ -92,8 +105,7 @@ class Launcher: aiohttp.ClientResponseError: An error occurred while fetching the information. """ - rsp = await self._get(self._api + "/resource", params={"key": "gcStgarh", - "launcher_id": "10"}) + rsp = await self._get(self._api + "/resource", params=self._params) return rsp async def get_launcher_info(self) -> launcher.Info: diff --git a/worthless/patcher.py b/worthless/patcher.py index df7cc84..ae21055 100644 --- a/worthless/patcher.py +++ b/worthless/patcher.py @@ -1,31 +1,96 @@ +import asyncio +import tarfile import constants +import appdirs +import aiofiles from pathlib import Path +import shutil +import aiohttp class Patcher: - def __init__(self, gamedir=Path.cwd()): + def __init__(self, gamedir=Path.cwd(), data_dir: str | Path = None, patch_url: str = None): self._gamedir = gamedir - self._patch_url = constants.PATCH_GIT_URL + self._patch_url = (patch_url if patch_url else constants.PATCH_GIT_URL).replace('http://', 'https://') + if not data_dir: + self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR) + self._patch_path = Path(self._appdirs.user_data_dir).joinpath("Patch") + self._temp_path = Path(self._appdirs.user_cache_dir) + else: + if not isinstance(data_dir, Path): + override_data_dir = Path(data_dir) + self._patch_path = data_dir.joinpath("Patch") + self._temp_path = data_dir.joinpath("Temp") + + @staticmethod + async def _get(url, **kwargs) -> aiohttp.ClientResponse: + async with aiohttp.ClientSession() as session: + rsp = await session.get(url, **kwargs) + rsp.raise_for_status() + return rsp + + async def _get_git_archive(self, archive_format="tar.gz", branch="master"): + """ + Get the git archive of the patch repository. + This supports Gitea API and also introduce workaround for https://notabug.org + + :return: Archive file in bytes + """ + # Replace http with https + if self._patch_url.startswith('https://notabug.org'): + archive_url = self._patch_url + '/archive/master.{}'.format(archive_format) + return await (await self._get(archive_url)).read() + try: + url_split = self._patch_url.split('//') + git_server = url_split[0] + git_owner, git_repo = url_split[1].split('/') + archive_url = git_server + '/api/v1/repos/{}/{}/archive/{}.{}'.format( + git_owner, git_repo, branch, archive_format + ) + archive = await self._get(archive_url) + except aiohttp.ClientResponseError: + pass + else: + return await archive.read() + return + + async def _download_repo(self): + if shutil.which("git"): + if not self._patch_path.exists() or not self._patch_path.is_dir() \ + or not self._patch_path.joinpath(".git").exists(): + await asyncio.create_subprocess_exec("git", "clone", self._patch_url, str(self._patch_path)) + else: + await asyncio.create_subprocess_exec("git", "pull", cwd=str(self._patch_path)) + else: + archive = await self._get_git_archive() + if not archive: + raise RuntimeError("Cannot download patch repository") + + with tarfile.open(archive) as tar: + tar.extractall(self._patch_path) def override_patch_url(self, url) -> None: """ Override the patch url. + :param url: Patch repository url, the url must be a valid git repository. :return: None """ self._patch_url = url - def download_patch(self) -> None: + async def download_patch(self) -> None: """ If `git` exists, this will clone the patch git url and save it to a temporary directory. Else, this will download the patch from the patch url and save it to a temporary directory. (Not reliable) + :return: None """ - pass + await self._download_repo() def apply_patch(self, crash_fix=False) -> None: """ Patch the game (and optionally patch the login door crash fix if specified) + :param crash_fix: Whether to patch the login door crash fix or not :return: None """ @@ -34,6 +99,7 @@ class Patcher: def revert_patch(self): """ Revert the patch (and revert the login door crash fix if patched) + :return: None """ pass