refactor: convert all task-intensive functions to async.

chore: rename gui.py to cli.py
fix: internal downloader can resume download now.
feat: add verify_game, verify_from_pkg_version, clear_cache to installer.py.
feat: add clear_cache to patcher.py.
fix: linux now check for pkexec before executing it.
fix: add get_name to voicepack.py, latest.py, diff.py to get name from path (since the developer didn't set a name to these files in the sdk url)
chore: remove deprecation message in read_version_from_config in installer.py
misc: use chunk from self._download_chunk instead of being hardcoded to 8192.
fix: is_telemetry_blocked will only wait 15s for a connection.
chore: move appdirs to constants.py

This commit refactor almost all functions to be compatible with asyncio, also restructured CLI to use asyncio.run on main function instead of executing it randomly.
Also prioritize the use of asyncio.gather, sometimes making tasks faster
This commit is contained in:
tretrauit 2022-06-25 01:13:47 +07:00
parent 8b2d0cad8f
commit a5659f7ff3
Signed by: tretrauit
GPG Key ID: CDDE1C97EE305DAF
14 changed files with 515 additions and 406 deletions

View File

@ -1,3 +1,5 @@
# worthless-launcher # worthless-launcher
A worthless CLI launcher written in Python. A worthless CLI launcher written in Python.
Check out its website at https://tretrauit.gitlab.io/worthless-launcher for more information.

View File

@ -1,4 +1,3 @@
aiohttp==3.8.1 aiohttp==3.8.1
appdirs~=1.4.4 appdirs~=1.4.4
aiopath~=0.6.10 aiopath~=0.6.10
xdelta3~=0.0.5

View File

@ -9,7 +9,7 @@ README = (HERE / "README.md").read_text()
setup( setup(
name='worthless', name='worthless',
version='1.3.1-2', version='2.0.0',
packages=['worthless', 'worthless.classes', 'worthless.classes.launcher', 'worthless.classes.installer'], packages=['worthless', 'worthless.classes', 'worthless.classes.launcher', 'worthless.classes.installer'],
url='https://git.froggi.es/tretrauit/worthless-launcher', url='https://git.froggi.es/tretrauit/worthless-launcher',
license='MIT License', license='MIT License',

View File

@ -1,6 +1,6 @@
#!/usr/bin/python3 #!/usr/bin/python3
import asyncio
from worthless import cli
from worthless import gui if __name__ == "__main__":
asyncio.run(cli.main())
if __name__ == '__main__':
gui.main()

View File

@ -12,6 +12,9 @@ class Diff:
self.voice_packs = voice_packs self.voice_packs = voice_packs
self.raw = raw self.raw = raw
def get_name(self):
return self.path.split("/")[-1]
@staticmethod @staticmethod
def from_dict(data): def from_dict(data):
voice_packs = [] voice_packs = []

View File

@ -14,6 +14,9 @@ class Latest:
self.segments = segments self.segments = segments
self.raw = raw self.raw = raw
def get_name(self):
return self.path.split("/")[-1]
@staticmethod @staticmethod
def from_dict(data): def from_dict(data):
voice_packs = [] voice_packs = []

View File

@ -7,6 +7,9 @@ class Voicepack:
self.md5 = md5 self.md5 = md5
self.raw = raw self.raw = raw
def get_name(self):
return self.path.split("/")[-1]
@staticmethod @staticmethod
def from_dict(data): def from_dict(data):
return Voicepack(data["language"], data["name"], data["path"], data["size"], data["md5"], data) return Voicepack(data["language"], data["name"], data["path"], data["size"], data["md5"], data)

View File

@ -22,145 +22,146 @@ class UI:
def _ask(self, question): def _ask(self, question):
if self._noconfirm: if self._noconfirm:
# Fake dialog # Fake dialog
print(question + " (y/n): y") print(question + " [Y/n]:")
return True return True
answer = "" answer = ""
while answer.lower() not in ['y', 'n']: while answer.lower() not in ['y', 'n', '']:
if answer != "": if answer != "":
print("Invalid choice, please try again.") print("Invalid choice, please try again.")
answer = input(question + " (y/n): ") answer = input(question + " [Y/n]: ")
return answer.lower() == 'y' return answer.lower() == 'y' or answer == ''
def override_game_version(self, version: str): def override_game_version(self, version: str):
self._installer._version = version self._installer._version = version
def get_game_version(self): async def get_game_version(self):
print(self._installer.get_game_version()) print(await self._installer.get_game_version())
def block_telemetry(self): async def block_telemetry(self):
print("Checking for available telemetry to block...") print("Checking for available telemetry to block...")
try: try:
asyncio.run(self._patcher.block_telemetry()) await self._patcher.block_telemetry()
except ValueError: except ValueError:
print("No telemetry to block.") print("No telemetry to block.")
else: else:
print("Telemetry blocked.") print("Telemetry blocked.")
def check_telemetry(self): async def check_telemetry(self):
block_status = asyncio.run(self._patcher.is_telemetry_blocked()) block_status = await self._patcher.is_telemetry_blocked()
if not block_status: if not block_status:
print("Telemetry is blocked.") print("Telemetry is blocked.")
else: else:
print("Telemetry is not blocked, you need to block these hosts below.") print("Telemetry is not blocked, you need to block these hosts below.")
for block in block_status: for hosts in block_status:
print(block) print(hosts)
def _update_from_archive(self, filepath): async def _update_from_archive(self, filepath):
print("Reverting patches if patched...") print("Reverting patches if patched...")
self._patcher.revert_patch(True) await self._patcher.revert_patch(True)
print("Updating game from archive (this may takes some time)...") print("Updating game from archive (This may takes some time)...")
asyncio.run(self._installer.update_game(filepath)) await self._installer.update_game(filepath)
self._installer.set_version_config() self._installer.set_version_config()
def _install_from_archive(self, filepath, force_reinstall): async def _install_from_archive(self, filepath, force_reinstall):
print("Installing game from archive (this may takes some time)...") print("Installing game from archive (This may takes some time)...")
self._installer.install_game(filepath, force_reinstall) print(filepath)
await self._installer.install_game(filepath, force_reinstall)
self._installer.set_version_config() self._installer.set_version_config()
def _apply_voiceover_from_archive(self, filepath): async def _apply_voiceover_from_archive(self, filepath):
print("Applying voiceover from archive (this may takes some time)...") print("Applying voiceover from archive (This may takes some time)...")
self._installer.apply_voiceover(filepath) print("Voiceover archive:", filepath)
await self._installer.apply_voiceover(filepath)
def install_voiceover_from_file(self, filepath): async def install_voiceover_from_file(self, filepath):
print("Archive voiceover language: {} ({})".format( print("Archive voiceover language: {} ({})".format(
self._installer.get_voiceover_archive_language(filepath), await self._installer.get_voiceover_archive_language(filepath),
"Full archive" if self._installer.get_voiceover_archive_type(filepath) else "Update archive")) "Full archive" if await self._installer.get_voiceover_archive_type(filepath) else "Update archive"))
if not self._ask("Do you want to apply this voiceover pack? ({})".format(filepath)): if not self._ask("Do you want to apply this voiceover pack? ({})".format(filepath)):
print("Aborting apply process.") print("Aborting apply process.")
return return
self._apply_voiceover_from_archive(filepath) await self._apply_voiceover_from_archive(filepath)
print("Voiceover applied successfully.") print("Voiceover applied successfully.")
def revert_patch(self): async def revert_patch(self):
print("Reverting patches...") print("Reverting patches...")
self._patcher.revert_patch(True) await self._patcher.revert_patch(True)
print("Patches reverted.") print("Patches reverted.")
def patch_game(self, login_fix: bool = False): async def patch_game(self, login_fix: bool = False):
print("NOTE: Hereby you are violating the game's Terms of Service!") print("NOTE: Hereby you are violating the game's Terms of Service!")
print("Do not patch the game if you don't know what you are doing!") print("Do not patch the game if you don't know what you are doing!")
if not self._ask("Do you want to patch the game? (This will overwrite your game files!)"): if not self._ask("Do you want to patch the game? (This will overwrite your game files!)"):
print("Aborting patch process.") print("Aborting patch process.")
return return
self.block_telemetry() await self.block_telemetry()
print("Updating patches...") print("Updating patches...")
asyncio.run(self._patcher.download_patch()) await self._patcher.download_patch()
print("Patching game...") print("Patching game...")
self._patcher.apply_patch(login_fix) await self._patcher.apply_patch(login_fix)
print("Game patched.") print("Game patched.")
print("Please refrain from sharing this project to public, thank you.") print("Please refrain from sharing this project to public, thank you.")
def install_from_file(self, filepath): async def install_from_file(self, filepath):
gamever = self._installer.get_game_version() gamever = await self._installer.get_game_version()
print("Archive game version: " + self._installer.get_game_archive_version(filepath)) print("Archive game version:", await self._installer.get_game_archive_version(filepath))
if gamever: if gamever:
print("Current game installation detected. ({})".format(self._installer.get_game_version())) print("Current game installation detected. ({})".format(await self._installer.get_game_version()))
if not self._ask("Do you want to update the game? ({})".format(filepath)): if not self._ask("Do you want to update the game? ({})".format(filepath)):
print("Aborting update process.") print("Aborting update process.")
return return
self._update_from_archive(filepath) await self._update_from_archive(filepath)
print("Game updated successfully.") print("Game updated successfully.")
else: else:
print("No game installation detected.") print("No game installation detected.")
if not self._ask("Do you want to install the game? ({})".format(filepath)): if not self._ask("Do you want to install the game? ({})".format(filepath)):
print("Aborting installation process.") print("Aborting installation process.")
return return
self._install_from_archive(filepath, False) await self._install_from_archive(filepath, False)
print("Game installed successfully.") print("Game installed successfully.")
def download_patch(self): async def download_patch(self):
print("Downloading patches...") print("Downloading patches...")
asyncio.run(self._patcher.download_patch()) await self._patcher.download_patch()
def download_game(self): async def download_game(self):
print("Downloading full game (This will take a long time)...") print("Downloading full game (This will take a long time)...")
asyncio.run(self._installer.download_full_game()) await self._installer.download_full_game()
def download_game_update(self): async def download_game_update(self):
print("Downloading game update (This will take a long time)...") print("Downloading game update (This will take a long time)...")
asyncio.run(self._installer.download_game_update()) await self._installer.download_game_update()
def download_voiceover(self, languages: str): async def download_voiceover(self, languages: str):
res_info = asyncio.run(self._launcher.get_resource_info()) res_info = await self._launcher.get_resource_info()
for lng in languages.split(" "): for lng in languages.split(" "):
for vo in res_info.game.latest.voice_packs: for vo in res_info.game.latest.voice_packs:
if not self._installer.voiceover_lang_translate(lng) == vo.language: if not self._installer.voiceover_lang_translate(lng) == vo.language:
continue continue
print("Downloading voiceover pack for {} (This will take a long time)...".format(lng)) print("Downloading voiceover pack for {} (This will take a long time)...".format(lng))
asyncio.run(self._installer.download_full_voiceover(lng)) await self._installer.download_full_voiceover(lng)
def download_voiceover_update(self, languages: str): async def download_voiceover_update(self, languages: str):
res_info = asyncio.run(self._launcher.get_resource_info()) res_info = await self._launcher.get_resource_info()
for lng in languages.split(" "): for lng in languages.split(" "):
for vo in res_info.game.latest.voice_packs: for vo in res_info.game.latest.voice_packs:
if not self._installer.voiceover_lang_translate(lng) == vo.language: if not self._installer.voiceover_lang_translate(lng) == vo.language:
continue continue
print("Downloading voiceover update pack for {} (This will take a long time)...".format(lng)) print("Downloading voiceover update pack for {} (This will take a long time)...".format(lng))
asyncio.run(self._installer.download_voiceover_update(lng)) await self._installer.download_voiceover_update(lng)
def install_game(self, forced: bool = False): async def install_game(self, forced: bool = False):
res_info = asyncio.run(self._launcher.get_resource_info()) res_info = await self._launcher.get_resource_info()
print("Latest game version: {}".format(res_info.game.latest.version)) print("Latest game version: {}".format(res_info.game.latest.version))
if not self._ask("Do you want to install the game?"): if not self._ask("Do you want to install the game?"):
print("Aborting game installation process.") print("Aborting game installation process.")
return return
self.download_game() await self.download_game()
print("Installing game...") print("Game archive:", res_info.game.latest.get_name())
self._install_from_archive(self._installer.temp_path.joinpath(res_info.game.latest.name), forced) await self._install_from_archive(self._installer.temp_path.joinpath(res_info.game.latest.get_name()), forced)
def install_voiceover(self, languages: str): async def install_voiceover(self, languages: str):
res_info = asyncio.run(self._launcher.get_resource_info()) res_info = await self._launcher.get_resource_info()
print("Latest game version: {}".format(res_info.game.latest.version))
for lng in languages.split(" "): for lng in languages.split(" "):
for vo in res_info.game.latest.voice_packs: for vo in res_info.game.latest.voice_packs:
if not self._installer.voiceover_lang_translate(lng) == vo.language: if not self._installer.voiceover_lang_translate(lng) == vo.language:
@ -169,19 +170,20 @@ class UI:
print("Aborting voiceover installation process.") print("Aborting voiceover installation process.")
return return
print("Downloading voiceover pack (This will take a long time)...") print("Downloading voiceover pack (This will take a long time)...")
asyncio.run(self._installer.download_full_voiceover(lng)) await self._installer.download_full_voiceover(lng)
print("Installing voiceover pack...") await self._apply_voiceover_from_archive(
self._apply_voiceover_from_archive(self._installer.temp_path.joinpath(res_info.game.latest.name)) self._installer.temp_path.joinpath(vo.get_name())
)
break break
def update_game(self): async def update_game(self):
game_ver = self._installer.get_game_version() game_ver = await self._installer.get_game_version()
if not game_ver: if not game_ver:
self.install_game() await self.install_game()
return return
print("Current game installation detected. ({})".format(game_ver)) print("Current game installation detected: {}".format(game_ver))
diff_archive = asyncio.run(self._installer.get_game_diff_archive()) diff_archive = await self._installer.get_game_diff_archive()
res_info = asyncio.run(self._launcher.get_resource_info()) res_info = await self._launcher.get_resource_info()
if not diff_archive: if not diff_archive:
print("No game updates available.") print("No game updates available.")
return return
@ -190,19 +192,24 @@ class UI:
print("Aborting game update process.") print("Aborting game update process.")
return return
print("Downloading game update (This will take a long time)...") print("Downloading game update (This will take a long time)...")
asyncio.run(self._installer.download_game_update()) await self._installer.download_game_update()
print("Installing game update...") print("Installing game update...")
self.install_from_file(self._installer.temp_path.joinpath(res_info.game.latest.name)) await self.install_from_file(self._installer.temp_path.joinpath(res_info.game.latest.name))
def update_voiceover(self, languages: str): async def update_voiceover(self, languages: str | list):
game_ver = self._installer.get_game_version() if isinstance(languages, str):
languages = languages.split(" ")
game_ver = await self._installer.get_game_version()
if not game_ver: if not game_ver:
self.install_voiceover(languages) print("Couldn't detect current game installation, is game installed?")
return return
print("Current game installation detected. ({})".format(game_ver)) installed_voiceovers = await self._installer.get_installed_voiceovers()
for lng in languages.split(" "): print(f"Installed voiceovers: {None if installed_voiceovers == [] else ', '.join(installed_voiceovers)}")
diff_archive = asyncio.run(self._installer.get_voiceover_diff_archive(lng)) for lng in languages:
# res_info = asyncio.run(self._launcher.get_resource_info()) if self._installer.voiceover_lang_translate(lng, "locale") not in installed_voiceovers:
await self.install_voiceover(lng)
continue
diff_archive = await self._installer.get_voiceover_diff_archive(lng)
if not diff_archive: if not diff_archive:
print("No voiceover updates available for {}.".format(lng)) print("No voiceover updates available for {}.".format(lng))
continue continue
@ -210,19 +217,40 @@ class UI:
print("Aborting this voiceover language update process.") print("Aborting this voiceover language update process.")
continue continue
print("Downloading voiceover update (This may takes some time)...") print("Downloading voiceover update (This may takes some time)...")
asyncio.run(self._installer.download_voiceover_update(lng)) await self._installer.download_voiceover_update(lng)
print("Installing voiceover update for {}...".format(lng)) print("Installing voiceover update for {}...".format(lng))
self._apply_voiceover_from_archive(self._installer.temp_path.joinpath(diff_archive.name)) await self._apply_voiceover_from_archive(self._installer.temp_path.joinpath(diff_archive.get_name()))
def update_game_voiceover(self, languages: str): async def update_game_voiceover(self, languages: str):
self.update_game() await self.update_game()
self.update_voiceover(languages) await self.update_voiceover(languages)
def interactive_ui(self): async def update_all(self):
raise NotImplementedError() await self.update_game()
await self.update_voiceover(await self._installer.get_installed_voiceovers())
async def verify_game(self):
game_ver = await self._installer.get_game_version()
if not game_ver:
print("Couldn't detect current game installation, is game installed?")
return
print("Verifying game contents... (This may takes a long time)")
failed_files = await self._installer.verify_game(ignore_mismatch=True)
if not failed_files:
print("All good.")
return
print("Some game files got corrupted (mismatch md5), uh oh.")
for file in failed_files:
print("{}: expected {}, actual {}".format(file[0], file[1], file[2]))
async def clear_cache(self):
if self._ask("Do you want to clear Installer cache (contains downloaded game files, etc)"):
await self._installer.clear_cache()
if self._ask("Do you want to clear Patcher cache (contains files used to patch)"):
await self._patcher.clear_cache()
def main(): async def main():
default_dirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR) default_dirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR)
parser = argparse.ArgumentParser(prog="worthless", description="A worthless launcher written in Python.") parser = argparse.ArgumentParser(prog="worthless", description="A worthless launcher written in Python.")
parser.add_argument("-D", "--dir", action="store", type=Path, default=Path.cwd(), parser.add_argument("-D", "--dir", action="store", type=Path, default=Path.cwd(),
@ -259,19 +287,15 @@ def main():
parser.add_argument("-Rs", "--remove", action="store_true", help="Remove the game (if installed)") 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("-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("-Rv", "--remove-voiceover", action="store_true", help="Remove a Voiceover pack (if installed)")
parser.add_argument("-V", "--verify", action="store_true", help="Verify the game installation")
parser.add_argument("--get-game-version", action="store_true", help="Get the current game version") parser.add_argument("--get-game-version", action="store_true", help="Get the current game version")
parser.add_argument("--no-overseas", action="store_true", help="Don't use overseas server") parser.add_argument("--no-overseas", action="store_true", help="Don't use overseas server")
parser.add_argument("--check-telemetry", action="store_true", help="Check for the telemetry information") parser.add_argument("--check-telemetry", action="store_true", help="Check for the telemetry information")
parser.add_argument("--clear-cache", action="store_true", help="Clear cache used by worthless")
parser.add_argument("--from-ver", action="store", help="Override the detected game version", type=str, default=None) parser.add_argument("--from-ver", action="store", help="Override the detected game version", type=str, default=None)
parser.add_argument("--noconfirm", action="store_true", parser.add_argument("--noconfirm", action="store_true",
help="Do not ask any for confirmation. (Ignored in interactive mode)") help="Do not ask any for confirmation. (Ignored in interactive mode)")
args = parser.parse_args() args = parser.parse_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 and not args.get_game_version and not \
args.install_voiceover_from_file and not args.update_voiceover and not args.download_game and not \
args.download_voiceover and not args.download_game_update and not args.download_voiceover_update and not \
args.install_voiceover_from_file and not args.update_all and not args.login_fix and not args.check_telemetry\
and not args.from_ver
if args.temporary_dir: if args.temporary_dir:
args.temporary_dir.mkdir(parents=True, exist_ok=True) args.temporary_dir.mkdir(parents=True, exist_ok=True)
@ -293,50 +317,62 @@ def main():
ui.override_game_version(args.from_ver) ui.override_game_version(args.from_ver)
if args.get_game_version: if args.get_game_version:
ui.get_game_version() await ui.get_game_version()
if args.check_telemetry: if args.check_telemetry:
ui.check_telemetry() await ui.check_telemetry()
# Download
if args.download_game: if args.download_game:
ui.download_game() await ui.download_game()
if args.download_voiceover: if args.download_voiceover:
ui.download_voiceover(args.download_voiceover) await ui.download_voiceover(args.download_voiceover)
if args.download_game_update: if args.download_game_update:
ui.download_game_update() await ui.download_game_update()
if args.download_voiceover_update: if args.download_voiceover_update:
ui.download_voiceover_update(args.download_voiceover_update) await ui.download_voiceover_update(args.download_voiceover_update)
# Install
if args.install: if args.install:
ui.install_game() await ui.install_game()
if args.update_all:
raise NotImplementedError() # TODO
if args.update:
ui.update_game_voiceover(args.update)
if args.update_voiceover:
ui.update_voiceover(args.update_voiceover)
if args.install_from_file: if args.install_from_file:
ui.install_from_file(args.install_from_file) await ui.install_from_file(args.install_from_file)
if args.install_voiceover_from_file: if args.install_voiceover_from_file:
ui.install_voiceover_from_file(args.install_voiceover_from_file) await ui.install_voiceover_from_file(args.install_voiceover_from_file)
# Update
if args.update_all:
await ui.update_all()
if args.update:
await ui.update_game_voiceover(args.update)
if args.update_voiceover:
await ui.update_voiceover(args.update_voiceover)
# Patch
if args.patch: if args.patch:
ui.patch_game(args.login_fix) await ui.patch_game(args.login_fix)
if args.remove_patch: if args.remove_patch:
ui.revert_patch() await ui.revert_patch()
if interactive_mode: # Verify
ui.interactive_ui()
if args.verify:
await ui.verify_game()
if args.clear_cache:
await ui.clear_cache()
if __name__ == "__main__": if __name__ == "__main__":
main() asyncio.run(main())

View File

@ -1,5 +1,9 @@
from appdirs import AppDirs
APP_NAME="worthless" APP_NAME="worthless"
APP_AUTHOR="tretrauit" APP_AUTHOR="tretrauit"
APPDIRS=AppDirs(APP_NAME, APP_AUTHOR)
LAUNCHER_API_URL_OS = "https://sdk-os-static.hoyoverse.com/hk4e_global/mdk/launcher/api" LAUNCHER_API_URL_OS = "https://sdk-os-static.hoyoverse.com/hk4e_global/mdk/launcher/api"
LAUNCHER_API_URL_CN = "https://sdk-static.mihoyo.com/hk4e_cn/mdk/launcher/api" LAUNCHER_API_URL_CN = "https://sdk-static.mihoyo.com/hk4e_cn/mdk/launcher/api"
HDIFFPATCH_GIT_URL="https://github.com/sisong/HDiffPatch" HDIFFPATCH_GIT_URL="https://github.com/sisong/HDiffPatch"

View File

@ -3,10 +3,9 @@ import re
import shutil import shutil
import platform import platform
import aiohttp import aiohttp
import appdirs
import zipfile import zipfile
import warnings
import json import json
import hashlib
from pathlib import Path from pathlib import Path
from configparser import ConfigParser from configparser import ConfigParser
from aiopath import AsyncPath from aiopath import AsyncPath
@ -23,19 +22,21 @@ async def _download_file(file_url: str, file_name: str, file_path: Path | str, f
:param file_name: :param file_name:
:return: :return:
""" """
params = {} headers = {}
file_path = AsyncPath(file_path).joinpath(file_name) file_path = AsyncPath(file_path).joinpath(file_name)
if overwrite: if overwrite:
await file_path.unlink(missing_ok=True) await file_path.unlink(missing_ok=True)
if await file_path.exists(): if await file_path.exists():
cur_len = len(await file_path.read_bytes()) cur_len = (await file_path.stat()).st_size
params |= { headers |= {
"Range": f"bytes={cur_len}-{file_len if file_len else ''}" "Range": f"bytes={cur_len}-{file_len if file_len else ''}"
} }
else: else:
await file_path.touch() await file_path.touch()
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
rsp = await session.get(file_url, params=params, timeout=None) rsp = await session.get(file_url, headers=headers, timeout=None)
if rsp.status == 416:
return
rsp.raise_for_status() rsp.raise_for_status()
while True: while True:
chunk = await rsp.content.read(chunks) chunk = await rsp.content.read(chunks)
@ -51,7 +52,7 @@ class HDiffPatch:
git_url = constants.HDIFFPATCH_GIT_URL git_url = constants.HDIFFPATCH_GIT_URL
self._git_url = git_url self._git_url = git_url
if not data_dir: if not data_dir:
self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR) self._appdirs = constants.APPDIRS
self.temp_path = Path(self._appdirs.user_cache_dir).joinpath("HDiffPatch") self.temp_path = Path(self._appdirs.user_cache_dir).joinpath("HDiffPatch")
self.data_path = Path(self._appdirs.user_data_dir).joinpath("Tools/HDiffPatch") self.data_path = Path(self._appdirs.user_data_dir).joinpath("Tools/HDiffPatch")
else: else:
@ -98,7 +99,7 @@ class HDiffPatch:
hpatchz_name = "hpatchz" + (".exe" if platform.system() == "Windows" else "") hpatchz_name = "hpatchz" + (".exe" if platform.system() == "Windows" else "")
return self._get_hdiffpatch_exec(hpatchz_name) return self._get_hdiffpatch_exec(hpatchz_name)
async def patch_file(self, in_file, out_file, patch_file, wait=False): async def patch_file(self, in_file, out_file, patch_file, error=False, wait=False):
hpatchz = self.get_hpatchz_executable() hpatchz = self.get_hpatchz_executable()
if not hpatchz: if not hpatchz:
raise RuntimeError("hpatchz executable not found") raise RuntimeError("hpatchz executable not found")
@ -106,6 +107,8 @@ class HDiffPatch:
if not wait: if not wait:
return proc return proc
await proc.wait() await proc.wait()
if error and proc.returncode != 0:
raise RuntimeError(f"Patching failed, return code is {proc.returncode}")
return proc return proc
def get_hdiffz_executable(self): def get_hdiffz_executable(self):
@ -141,91 +144,33 @@ class HDiffPatch:
await _download_file(url, name, self.temp_path, overwrite=True) await _download_file(url, name, self.temp_path, overwrite=True)
if not extract: if not extract:
return return
archive = zipfile.ZipFile(self.temp_path.joinpath(name)) with zipfile.ZipFile(self.temp_path.joinpath(name), 'r') as f:
archive.extractall(self.data_path) await asyncio.to_thread(f.extractall, path=self.data_path)
archive.close()
class Installer: class Installer:
def _read_version_from_config(self): def __init__(self, gamedir: str | Path | AsyncPath = AsyncPath.cwd(),
warnings.warn("This function is not reliable as upgrading game version from worthless\ overseas: bool = True, data_dir: str | Path | AsyncPath = None):
doesn't write the config.", DeprecationWarning) if isinstance(gamedir, str | Path):
if not self._config_file.exists(): gamedir = AsyncPath(gamedir)
raise FileNotFoundError(f"Config file {self._config_file} not found")
cfg = ConfigParser()
cfg.read(str(self._config_file))
return cfg.get("General", "game_version")
@staticmethod
def read_version_from_game_file(globalgamemanagers: Path | bytes):
"""
Reads the version from the globalgamemanagers file. (Data/globalgamemanagers)
Uses `An Anime Game Launcher` method to read the version:
https://gitlab.com/KRypt0n_/an-anime-game-launcher/-/blob/main/src/ts/Game.ts#L26
:return: Game version (ex 1.0.0)
"""
if isinstance(globalgamemanagers, Path):
with globalgamemanagers.open("rb") as f:
data = f.read().decode("ascii", errors="ignore")
else:
data = globalgamemanagers.decode("ascii", errors="ignore")
result = re.search(r"([1-9]+\.[0-9]+\.[0-9]+)_[\d]+_[\d]+", data)
if not result:
raise ValueError("Could not find version in game file")
return result.group(1)
def get_game_data_name(self):
if self._overseas:
return "GenshinImpact_Data/"
else:
return "YuanShen_Data/"
def get_game_data_path(self):
return self._gamedir.joinpath(self.get_game_data_name())
def get_game_version(self):
globalgamemanagers = self.get_game_data_path().joinpath("./globalgamemanagers")
if not globalgamemanagers.exists():
return
return self.read_version_from_game_file(globalgamemanagers)
def get_installed_voiceovers(self):
"""
Returns a list of installed voiceovers.
:return: List of installed voiceovers
"""
voiceovers = []
for file in self.get_game_data_path().joinpath("StreamingAssets/Audio/GeneratedSoundBanks/Windows").iterdir():
if file.is_dir():
voiceovers.append(file.name)
return voiceovers
def __init__(self, gamedir: str | Path = Path.cwd(), overseas: bool = True, data_dir: str | Path = None):
if isinstance(gamedir, str):
gamedir = Path(gamedir)
self._gamedir = gamedir self._gamedir = gamedir
if not data_dir: if not data_dir:
self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR) self._appdirs = constants.APPDIRS
self.temp_path = Path(self._appdirs.user_cache_dir).joinpath("Installer") self.temp_path = AsyncPath(self._appdirs.user_cache_dir).joinpath("Installer")
else: else:
if not isinstance(data_dir, Path): if isinstance(data_dir, str | AsyncPath):
data_dir = Path(data_dir) data_dir = AsyncPath(data_dir)
self.temp_path = data_dir.joinpath("Temp/Installer/") self.temp_path = data_dir.joinpath("Temp/Installer/")
self.temp_path.mkdir(parents=True, exist_ok=True) Path(self.temp_path).mkdir(parents=True, exist_ok=True)
config_file = self._gamedir.joinpath("config.ini") config_file = self._gamedir.joinpath("config.ini")
self._config_file = config_file.resolve() self._config_file = config_file
self._download_chunk = 8192 self._download_chunk = 8192
self._overseas = overseas self._overseas = overseas
self._version = self.get_game_version() self._version = None
self._launcher = Launcher(self._gamedir, overseas=self._overseas) self._launcher = Launcher(self._gamedir, overseas=self._overseas)
self._hdiffpatch = HDiffPatch(data_dir=data_dir) self._hdiffpatch = HDiffPatch(data_dir=data_dir)
self._config = LauncherConfig(self._config_file, self._version) self._config = LauncherConfig(self._config_file, self._version)
self._game_version_re = re.compile(r"([1-9]+\.[0-9]+\.[0-9]+)_\d+_\d+")
def set_download_chunk(self, chunk: int):
self._download_chunk = chunk
async def _download_file(self, file_url: str, file_name: str, file_len: int = None, overwrite=False): async def _download_file(self, file_url: str, file_name: str, file_len: int = None, overwrite=False):
""" """
@ -234,76 +179,143 @@ class Installer:
:param file_name: :param file_name:
:return: :return:
""" """
await _download_file(file_url, file_name, self.temp_path, file_len=file_len, overwrite=overwrite) await _download_file(file_url, file_name, self.temp_path, file_len=file_len, overwrite=overwrite,
chunks=self._download_chunk)
def get_game_archive_version(self, game_archive: str | Path): async def read_version_from_config(self):
if not game_archive.exists(): if not await self._config_file.exists():
raise FileNotFoundError(f"Game archive {game_archive} not found") raise FileNotFoundError(f"Config file {self._config_file} not found")
archive = zipfile.ZipFile(game_archive, 'r') cfg = ConfigParser()
return self.read_version_from_game_file(archive.read(self.get_game_data_name() + "globalgamemanagers")) await asyncio.to_thread(cfg.read, str(self._config_file))
return cfg.get("General", "game_version")
async def read_version_from_game_file(self, globalgamemanagers: AsyncPath | Path | bytes) -> str:
"""
Reads the version from the globalgamemanagers file. (Data/globalgamemanagers)
Uses `An Anime Game Launcher` method to read the version:
https://gitlab.com/KRypt0n_/an-anime-game-launcher/-/blob/main/src/ts/Game.ts#L26
:return: Game version (ex 1.0.0)
"""
if isinstance(globalgamemanagers, Path | AsyncPath):
globalgamemanagers = AsyncPath(globalgamemanagers)
data = await globalgamemanagers.read_text("ascii", errors="ignore")
else:
data = globalgamemanagers.decode("ascii", errors="ignore")
result = self._game_version_re.search(data)
if not result:
raise ValueError("Could not find version in game file")
return result.group(1)
@staticmethod @staticmethod
def voiceover_lang_translate(lang: str): def voiceover_lang_translate(lang: str, base_language="game") -> str:
""" """
Translates the voiceover language to the language code used by the game. Translates the voiceover language to the language code used by the game.
:param lang: Language to translate :param lang: Language to translate
:param base_language: Base language type (game/locale/both)
:return: Language code :return: Language code
""" """
match lang: if base_language == "game" or base_language == "both":
case "English(US)": match lang.lower():
return "en-us" case "english(us)":
case "Japanese": return "en-us"
return "ja-jp" case "japanese":
case "Chinese": return "ja-jp"
return "zh-cn" case "chinese":
case "Korean": return "zh-cn"
return "ko-kr" case "korean":
return "ko-kr"
if base_language == "locale" or base_language == "both":
match lang.lower().replace("_", "-"):
case "en-us":
return "English(US)"
case "ja-jp":
return "Japanese"
case "zh-cn":
return "Chinese"
case "ko-kr":
return "Korean"
# If nothing else matches
return lang return lang
@staticmethod @staticmethod
def get_voiceover_archive_language(voiceover_archive: str | Path): async def get_voiceover_archive_language(voiceover_archive: str | Path | AsyncPath) -> str:
if isinstance(voiceover_archive, str): if isinstance(voiceover_archive, str | Path):
voiceover_archive = Path(voiceover_archive).resolve() voiceover_archive = Path(voiceover_archive).resolve()
if not voiceover_archive.exists(): if not voiceover_archive.exists():
raise FileNotFoundError(f"Voiceover archive {voiceover_archive} not found") raise FileNotFoundError(f"Voiceover archive {voiceover_archive} not found")
archive = zipfile.ZipFile(voiceover_archive, 'r') with zipfile.ZipFile(voiceover_archive, 'r') as f:
archive_path = zipfile.Path(archive) for file in zipfile.Path(f).iterdir():
for file in archive_path.iterdir(): if file.name.endswith("_pkg_version"):
if file.name.endswith("_pkg_version"): return file.name.split("_")[1]
return file.name.split("_")[1]
def get_voiceover_archive_type(self, voiceover_archive: str | Path): @staticmethod
vo_lang = self.get_voiceover_archive_language(voiceover_archive) async def get_voiceover_archive_type(voiceover_archive: str | Path) -> bool:
archive = zipfile.ZipFile(voiceover_archive, 'r') """
archive_path = zipfile.Path(archive) Gets voiceover archive type.
files = archive.read("Audio_{}_pkg_version".format(vo_lang)).decode().split("\n") :param voiceover_archive:
for file in files: :return: True if this is a full archive, else False.
if file.strip() and not archive_path.joinpath(json.loads(file)["remoteName"]).exists(): """
return False vo_lang = Installer.get_voiceover_archive_language(voiceover_archive)
with zipfile.ZipFile(voiceover_archive, 'r') as f:
archive_path = zipfile.Path(f)
files = (await asyncio.to_thread(f.read, "Audio_{}_pkg_version".format(vo_lang))).decode().split("\n")
for file in files:
if file.strip() and not archive_path.joinpath(json.loads(file)["remoteName"]).exists():
return False
return True return True
def apply_voiceover(self, voiceover_archive: str | Path): def set_download_chunk(self, chunk: int):
# Since Voiceover packages are unclear about diff package or full package self._download_chunk = chunk
# we will try to extract the voiceover package and apply it to the game
# making this function universal for both cases
if not self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}")
if isinstance(voiceover_archive, str):
voiceover_archive = Path(voiceover_archive).resolve()
if not voiceover_archive.exists():
raise FileNotFoundError(f"Voiceover archive {voiceover_archive} not found")
archive = zipfile.ZipFile(voiceover_archive, 'r')
archive.extractall(self._gamedir)
archive.close()
async def update_game(self, game_archive: str | Path): def get_game_data_name(self):
if not self.get_game_data_path().exists(): if self._overseas:
return "GenshinImpact_Data/"
else:
return "YuanShen_Data/"
def get_game_data_path(self) -> AsyncPath:
return self._gamedir.joinpath(self.get_game_data_name())
async def get_game_archive_version(self, game_archive: str | Path):
if not game_archive.exists():
raise FileNotFoundError(f"Game archive {game_archive} not found")
with zipfile.ZipFile(game_archive, 'r') as f:
return await self.read_version_from_game_file(
await asyncio.to_thread(f.read, self.get_game_data_name() + "globalgamemanagers")
)
async def get_game_version(self) -> str | None:
globalgamemanagers = self.get_game_data_path().joinpath("./globalgamemanagers")
if not await globalgamemanagers.exists():
try:
return await self.read_version_from_config()
except FileNotFoundError:
return
return await self.read_version_from_game_file(globalgamemanagers)
async def get_installed_voiceovers(self) -> list[str]:
"""
Returns a list of installed voiceovers.
:return: List of installed voiceovers
"""
voiceovers = []
async for file in self.get_game_data_path()\
.joinpath("StreamingAssets/Audio/GeneratedSoundBanks/Windows").iterdir():
if await file.is_dir():
voiceovers.append(file.name)
return voiceovers
async def update_game(self, game_archive: str | Path | AsyncPath):
if not await self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}") raise FileNotFoundError(f"Game not found in {self._gamedir}")
if isinstance(game_archive, str): if isinstance(game_archive, str | Path):
game_archive = Path(game_archive).resolve() game_archive = Path(game_archive).resolve()
if not game_archive.exists(): if not game_archive.exists():
raise FileNotFoundError(f"Update archive {game_archive} not found") raise FileNotFoundError(f"Update archive {game_archive} not found")
archive = zipfile.ZipFile(game_archive, 'r') archive = zipfile.ZipFile(game_archive, 'r')
if not self._hdiffpatch.get_hpatchz_executable(): if not self._hdiffpatch.get_hpatchz_executable():
@ -320,13 +332,13 @@ class Installer:
# hdiffpatch implementation # hdiffpatch implementation
hdifffiles = [] hdifffiles = []
for x in archive.read("hdifffiles.txt").decode().split("\n"): for x in (await asyncio.to_thread(archive.read, "hdifffiles.txt")).decode().split("\n"):
if x: if x:
hdifffiles.append(json.loads(x)["remoteName"]) hdifffiles.append(json.loads(x)["remoteName"])
patch_jobs = [] patch_jobs = []
for file in hdifffiles: for file in hdifffiles:
current_game_file = self._gamedir.joinpath(file) current_game_file = self._gamedir.joinpath(file)
if not current_game_file.exists(): if not await current_game_file.exists():
# Not patching since we don't have the file # Not patching since we don't have the file
continue continue
@ -341,10 +353,11 @@ class Installer:
patch_path, wait=True) patch_path, wait=True)
patch_path.unlink() patch_path.unlink()
if proc.returncode != 0: if proc.returncode != 0:
# Let the game redownload the file. # Let the game download the file.
old_file.rename(old_file.with_suffix(old_suffix)) old_file.rename(old_file.with_suffix(old_suffix))
return return
old_file.unlink() old_file.unlink()
files.remove(patch_file) files.remove(patch_file)
patch_jobs.append(extract_and_patch(current_game_file, patch_file)) patch_jobs.append(extract_and_patch(current_game_file, patch_file))
@ -353,15 +366,16 @@ class Installer:
deletefiles = archive.read("deletefiles.txt").decode().split("\n") deletefiles = archive.read("deletefiles.txt").decode().split("\n")
for file in deletefiles: for file in deletefiles:
current_game_file = self._gamedir.joinpath(file) current_game_file = self._gamedir.joinpath(file)
if not current_game_file.exists(): if not await current_game_file.exists():
continue continue
if current_game_file.is_file(): if current_game_file.is_file():
current_game_file.unlink(missing_ok=True) current_game_file.unlink(missing_ok=True)
archive.extractall(self._gamedir, members=files) await asyncio.to_thread(archive.extractall, self._gamedir, members=files)
archive.close() archive.close()
# Update game version on local variable. # Update game version on local variable.
self._version = self.get_game_version() self._version = await self.get_game_version()
self.set_version_config()
def set_version_config(self, version: str = None): def set_version_config(self, version: str = None):
if not version: if not version:
@ -370,11 +384,11 @@ class Installer:
self._config.save() self._config.save()
async def download_full_game(self): async def download_full_game(self):
archive = await self._launcher.get_resource_info() resource = await self._launcher.get_resource_info()
if archive is None: if resource is None:
raise RuntimeError("Failed to fetch game resource info.") raise RuntimeError("Failed to fetch game resource info.")
archive_name = archive.game.latest.path.split("/")[-1] archive_name = resource.game.latest.path.split("/")[-1]
await self._download_file(archive.game.latest.path, archive_name, archive.game.latest.size) await self._download_file(resource.game.latest.path, archive_name, resource.game.latest.size)
async def download_full_voiceover(self, language: str): async def download_full_voiceover(self, language: str):
archive = await self._launcher.get_resource_info() archive = await self._launcher.get_resource_info()
@ -383,19 +397,57 @@ class Installer:
translated_lang = self.voiceover_lang_translate(language) translated_lang = self.voiceover_lang_translate(language)
for vo in archive.game.latest.voice_packs: for vo in archive.game.latest.voice_packs:
if vo.language == translated_lang: if vo.language == translated_lang:
await self._download_file(vo.path, vo.name, vo.size) await self._download_file(vo.path, vo.get_name(), vo.size)
async def download_game_update(self, from_version: str = None): async def uninstall_game(self):
await asyncio.to_thread(shutil.rmtree, self._gamedir, ignore_errors=True)
async def _extract_game_file(self, archive: str | Path | AsyncPath):
if isinstance(archive, str | AsyncPath):
archive = Path(archive).resolve()
if not archive.exists():
raise FileNotFoundError(f"'{archive}' not found")
with zipfile.ZipFile(archive, 'r') as f:
await asyncio.to_thread(f.extractall, path=self._gamedir)
async def apply_voiceover(self, voiceover_archive: str | Path):
# Since Voiceover packages are unclear about diff package or full package
# we will try to extract the voiceover package and apply it to the game
# making this function universal for both cases
if not await self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}")
await self._extract_game_file(voiceover_archive)
async def install_game(self, game_archive: str | Path | AsyncPath, force_reinstall: bool = False):
"""Installs the game to the current directory
If `force_reinstall` is True, the game will be uninstalled then reinstalled.
"""
if await self.get_game_data_path().exists():
if not force_reinstall:
raise ValueError(f"Game is already installed in {self._gamedir}")
await self.uninstall_game()
await self._gamedir.mkdir(parents=True, exist_ok=True)
await self._extract_game_file(game_archive)
self._version = await self.get_game_version()
self.set_version_config()
async def _get_game_resource(self, from_version: str = None):
if not from_version: if not from_version:
if self._version: if self._version:
from_version = self._version from_version = self._version
else: else:
from_version = self._version = self.get_game_version() from_version = self._version = await self.get_game_version()
if not from_version: if not from_version:
raise ValueError("No game version found") raise ValueError("No game version found")
version_info = await self._launcher.get_resource_info() game_resource = await self._launcher.get_resource_info()
if version_info is None: if not game_resource:
raise RuntimeError("Failed to fetch game resource info.") raise ValueError("Could not fetch game resource")
return game_resource
async def download_game_update(self, from_version: str = None):
version_info = await self._get_game_resource()
if self._version == version_info.game.latest.version: if self._version == version_info.game.latest.version:
raise ValueError("Game is already up to date.") raise ValueError("Game is already up to date.")
diff_archive = await self.get_game_diff_archive(from_version) diff_archive = await self.get_game_diff_archive(from_version)
@ -404,56 +456,17 @@ class Installer:
await self._download_file(diff_archive.path, diff_archive.name, diff_archive.size) await self._download_file(diff_archive.path, diff_archive.name, diff_archive.size)
async def download_voiceover_update(self, language: str, from_version: str = None): async def download_voiceover_update(self, language: str, from_version: str = None):
if not from_version:
if self._version:
from_version = self._version
else:
from_version = self._version = self.get_game_version()
if not from_version:
raise ValueError("No game version found")
version_info = await self._launcher.get_resource_info()
if version_info is None:
raise RuntimeError("Failed to fetch game resource info.")
diff_archive = await self.get_voiceover_diff_archive(language, from_version) diff_archive = await self.get_voiceover_diff_archive(language, from_version)
if diff_archive is None: if diff_archive is None:
raise ValueError("Voiceover diff archive is not available for this version, please reinstall.") raise ValueError("Voiceover diff archive is not available for this version, please reinstall.")
await self._download_file(diff_archive.path, diff_archive.name, diff_archive.size) await self._download_file(diff_archive.path, diff_archive.name, diff_archive.size)
def uninstall_game(self):
shutil.rmtree(self._gamedir)
def install_game(self, game_archive: str | Path, force_reinstall: bool = False):
"""Installs the game to the current directory
If `force_reinstall` is True, the game will be uninstalled then reinstalled.
"""
if self.get_game_data_path().exists():
if not force_reinstall:
raise ValueError(f"Game is already installed in {self._gamedir}")
self.uninstall_game()
self._gamedir.mkdir(parents=True, exist_ok=True)
if isinstance(game_archive, str):
game_archive = Path(game_archive).resolve()
if not game_archive.exists():
raise FileNotFoundError(f"Install archive {game_archive} not found")
archive = zipfile.ZipFile(game_archive, 'r')
archive.extractall(self._gamedir)
archive.close()
async def get_voiceover_diff_archive(self, lang: str, from_version: str = None): async def get_voiceover_diff_archive(self, lang: str, from_version: str = None):
"""Gets a diff archive from `from_version` to the latest one """Gets a diff archive from `from_version` to the latest one
If from_version is not specified, it will be taken from the game version. If from_version is not specified, it will be taken from the game version.
""" """
if not from_version: game_resource = await self._get_game_resource()
if self._version:
from_version = self._version
else:
from_version = self._version = self.get_game_version()
if not from_version:
raise ValueError("No game version found")
game_resource = await self._launcher.get_resource_info()
if not game_resource: if not game_resource:
raise ValueError("Could not fetch game resource") raise ValueError("Could not fetch game resource")
translated_lang = self.voiceover_lang_translate(lang) translated_lang = self.voiceover_lang_translate(lang)
@ -470,16 +483,48 @@ class Installer:
If from_version is not specified, it will be taken from the game version. If from_version is not specified, it will be taken from the game version.
""" """
if not from_version: game_resource = await self._get_game_resource()
if self._version:
from_version = self._version
else:
from_version = self._version = self.get_game_version()
if not from_version:
raise ValueError("No game version found")
game_resource = await self._launcher.get_resource_info()
if not game_resource:
raise ValueError("Could not fetch game resource")
for v in game_resource.game.diffs: for v in game_resource.game.diffs:
if v.version == from_version: if v.version == from_version:
return v return v
async def verify_from_pkg_version(self, pkg_version: AsyncPath, ignore_mismatch=False):
contents = await pkg_version.read_text()
async def calculate_md5(file_to_calculate):
async with AsyncPath(file_to_calculate).open("rb") as f:
file_hash = hashlib.md5()
while chunk := await f.read(self._download_chunk):
file_hash.update(chunk)
return file_hash.hexdigest()
async def verify_file(file_to_verify, md5):
file_md5 = await calculate_md5(file_to_verify)
if file_md5 == md5:
return None
if ignore_mismatch:
return file_to_verify, md5, file_md5
raise ValueError(f"MD5 does not match for {file_to_verify}, expected md5: {md5}, actual md5: {file_md5}")
verify_jobs = []
for content in contents.split("\r\n"):
if not content.strip():
continue
info = json.loads(content)
verify_jobs.append(verify_file(self._gamedir.joinpath(info["remoteName"]), info["md5"]))
verify_result = await asyncio.gather(*verify_jobs)
failed_files = []
for file in verify_result:
if file is not None:
failed_files.append(file)
return None if not failed_files else failed_files
async def verify_game(self, pkg_version: str | Path | AsyncPath = None, ignore_mismatch=False):
if pkg_version is None:
pkg_version = self._gamedir.joinpath("pkg_version")
return await self.verify_from_pkg_version(pkg_version, ignore_mismatch)
async def clear_cache(self):
await asyncio.to_thread(shutil.rmtree, self.temp_path, ignore_errors=True)

View File

@ -1,5 +1,8 @@
import aiohttp import aiohttp
import locale import locale
from aiopath import AsyncPath
from worthless import constants from worthless import constants
from pathlib import Path from pathlib import Path
from worthless.classes import launcher, installer from worthless.classes import launcher, installer
@ -60,8 +63,8 @@ class Launcher:
"launcher_id": "18", "launcher_id": "18",
"channel_id": "1" "channel_id": "1"
} }
self._lang = "zh-cn" # Use chinese language because this is Pooh version self._lang = "zh-cn" # Use chinese language because this is chinese version
if isinstance(gamedir, str): if isinstance(gamedir, str | AsyncPath):
gamedir = Path(gamedir) gamedir = Path(gamedir)
self._gamedir = gamedir.resolve() self._gamedir = gamedir.resolve()

View File

@ -1,6 +1,8 @@
from configparser import ConfigParser from configparser import ConfigParser
from pathlib import Path from pathlib import Path
from aiopath import AsyncPath
class LauncherConfig: class LauncherConfig:
""" """
@ -23,8 +25,8 @@ class LauncherConfig:
return config return config
def __init__(self, config_path, game_version=None, overseas=True): def __init__(self, config_path, game_version=None, overseas=True):
if isinstance(config_path, str): if isinstance(config_path, str | AsyncPath):
self.config_path = Path(config_path) config_path = Path(config_path)
if not game_version: if not game_version:
game_version = "0.0.0" game_version = "0.0.0"
self.config_path = config_path self.config_path = config_path

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from aiopath import AsyncPath
class LinuxUtils: class LinuxUtils:
@ -8,9 +9,12 @@ class LinuxUtils:
def __init__(self): def __init__(self):
pass pass
async def _exec_command(self, args): @staticmethod
async def _exec_command(args):
"""Execute a command using pkexec (friendly gui) """Execute a command using pkexec (friendly gui)
""" """
if not await AsyncPath("/usr/bin/pkexec").exists():
raise FileNotFoundError("pkexec not found.")
rsp = await asyncio.create_subprocess_shell(args) rsp = await asyncio.create_subprocess_shell(args)
await rsp.wait() await rsp.wait()
match rsp.returncode: match rsp.returncode:
@ -21,14 +25,14 @@ class LinuxUtils:
return rsp return rsp
async def write_text_to_file(self, text, file_path: str | Path): async def write_text_to_file(self, text, file_path: str | Path | AsyncPath):
"""Write text to a file using pkexec (friendly gui) """Write text to a file using pkexec (friendly gui)
""" """
if isinstance(file_path, Path): if isinstance(file_path, Path | AsyncPath):
file_path = str(file_path) file_path = str(file_path)
await self._exec_command('echo -e "{}" | pkexec tee {}'.format(text, file_path)) await self._exec_command('echo -e "{}" | pkexec tee {}'.format(text, file_path))
async def append_text_to_file(self, text, file_path: str | Path): async def append_text_to_file(self, text, file_path: str | Path | AsyncPath):
"""Append text to a file using pkexec (friendly gui) """Append text to a file using pkexec (friendly gui)
""" """
if isinstance(file_path, Path): if isinstance(file_path, Path):

View File

@ -1,11 +1,13 @@
import os import os
import platform import platform
import tarfile import tarfile
import appdirs
from pathlib import Path from pathlib import Path
import shutil import shutil
import aiohttp import aiohttp
import asyncio import asyncio
from aiopath import AsyncPath
from worthless import constants from worthless import constants
from worthless.launcher import Launcher from worthless.launcher import Launcher
from worthless.installer import Installer from worthless.installer import Installer
@ -26,16 +28,19 @@ except ImportError:
class Patcher: class Patcher:
def __init__(self, gamedir=Path.cwd(), data_dir: str | Path = None, patch_url: str = None, overseas=True): def __init__(self, gamedir: Path | AsyncPath | str = AsyncPath.cwd(), data_dir: str | Path | AsyncPath = None,
patch_url: str = None, overseas=True):
if isinstance(gamedir, str | Path):
gamedir = AsyncPath(gamedir)
self._gamedir = gamedir self._gamedir = gamedir
self._patch_url = (patch_url if patch_url else constants.PATCH_GIT_URL).replace('http://', 'https://') self._patch_url = (patch_url if patch_url else constants.PATCH_GIT_URL).replace('http://', 'https://')
if not data_dir: if not data_dir:
self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR) self._appdirs = constants.APPDIRS
self._patch_path = Path(self._appdirs.user_data_dir).joinpath("Patch") self._patch_path = AsyncPath(self._appdirs.user_data_dir).joinpath("Patch")
self._temp_path = Path(self._appdirs.user_cache_dir).joinpath("Patcher") self._temp_path = AsyncPath(self._appdirs.user_cache_dir).joinpath("Patcher")
else: else:
if not isinstance(data_dir, Path): if isinstance(data_dir, str | Path):
data_dir = Path(data_dir) data_dir = AsyncPath(data_dir)
self._patch_path = data_dir.joinpath("Patch") self._patch_path = data_dir.joinpath("Patch")
self._temp_path = data_dir.joinpath("Temp/Patcher") self._temp_path = data_dir.joinpath("Temp/Patcher")
self._overseas = overseas self._overseas = overseas
@ -43,7 +48,7 @@ class Patcher:
self._launcher = Launcher(self._gamedir, overseas=overseas) self._launcher = Launcher(self._gamedir, overseas=overseas)
match platform.system(): match platform.system():
case "Linux": case "Linux":
self._linuxutils = linux.LinuxUtils() self._linux = linux.LinuxUtils()
@staticmethod @staticmethod
async def _get(url, **kwargs) -> aiohttp.ClientResponse: async def _get(url, **kwargs) -> aiohttp.ClientResponse:
@ -76,15 +81,15 @@ class Patcher:
else: else:
return await archive.read() return await archive.read()
async def _download_repo(self): async def _download_repo(self, fallback=False):
if shutil.which("git"): if shutil.which("git") and not fallback:
if not self._patch_path.exists() or not self._patch_path.is_dir() \ if not await self._patch_path.is_dir() or not await self._patch_path.joinpath(".git").exists():
or not self._patch_path.joinpath(".git").exists():
proc = await asyncio.create_subprocess_exec("git", "clone", self._patch_url, str(self._patch_path)) proc = await asyncio.create_subprocess_exec("git", "clone", self._patch_url, str(self._patch_path))
await proc.wait()
else: else:
proc = await asyncio.create_subprocess_exec("git", "pull", cwd=str(self._patch_path)) proc = await asyncio.create_subprocess_exec("git", "pull", cwd=str(self._patch_path))
await proc.wait() await proc.wait()
if proc.returncode != 0:
raise RuntimeError("Cannot download patch repository through git.")
else: else:
archive = await self._get_git_archive() archive = await self._get_git_archive()
if not archive: if not archive:
@ -120,24 +125,18 @@ class Patcher:
telemetry_url = constants.TELEMETRY_URL_LIST telemetry_url = constants.TELEMETRY_URL_LIST
else: else:
telemetry_url = constants.TELEMETRY_URL_CN_LIST telemetry_url = constants.TELEMETRY_URL_CN_LIST
if optional:
telemetry_url |= constants.TELEMETRY_OPTIONAL_URL_LIST
unblocked_list = [] unblocked_list = []
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for url in telemetry_url: for url in telemetry_url:
try: try:
await session.get("https://" + url) await session.get("https://" + url, timeout=15)
except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError): except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError, asyncio.exceptions.TimeoutError):
continue continue
else: else:
unblocked_list.append(url) unblocked_list.append(url)
if optional: return None if not unblocked_list else unblocked_list
for url in constants.TELEMETRY_OPTIONAL_URL_LIST:
try:
await session.get("https://" + url)
except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError):
continue
else:
unblocked_list.append(url)
return None if unblocked_list == [] else unblocked_list
async def block_telemetry(self, optional=False): async def block_telemetry(self, optional=False):
telemetry = await self.is_telemetry_blocked(optional) telemetry = await self.is_telemetry_blocked(optional)
@ -148,20 +147,15 @@ class Patcher:
telemetry_hosts += "0.0.0.0 " + url + "\n" telemetry_hosts += "0.0.0.0 " + url + "\n"
match platform.system(): match platform.system():
case "Linux": case "Linux":
await self._linuxutils.append_text_to_file(telemetry_hosts, "/etc/hosts") await self._linux.append_text_to_file(telemetry_hosts, "/etc/hosts")
return return
# TODO: Windows and macOS # TODO: Windows and macOS
raise NotImplementedError("Platform not implemented.") raise NotImplementedError("Platform not implemented.")
async def _patch_unityplayer_fallback(self): async def _patch_unityplayer_fallback(self, patch):
# xdelta3-python doesn't work because it's outdated. gamever = "".join((await self._installer.get_game_version()).split("."))
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
unity_path = self._gamedir.joinpath("UnityPlayer.dll") unity_path = self._gamedir.joinpath("UnityPlayer.dll")
unity_path.rename(self._gamedir.joinpath("UnityPlayer.dll.bak")) await unity_path.rename(self._gamedir.joinpath("UnityPlayer.dll.bak"))
proc = await asyncio.create_subprocess_exec("xdelta3", "-d", "-s", proc = await asyncio.create_subprocess_exec("xdelta3", "-d", "-s",
str(self._gamedir.joinpath("UnityPlayer.dll.bak")), str(self._gamedir.joinpath("UnityPlayer.dll.bak")),
str(self._patch_path.joinpath( str(self._patch_path.joinpath(
@ -169,16 +163,11 @@ class Patcher:
str(self._gamedir.joinpath("UnityPlayer.dll")), cwd=self._gamedir) str(self._gamedir.joinpath("UnityPlayer.dll")), cwd=self._gamedir)
await proc.wait() await proc.wait()
async def _patch_xlua_fallback(self): async def _patch_xlua_fallback(self, patch):
# xdelta3-python doesn't work becuase it's outdated. gamever = "".join((await self._installer.get_game_version()).split("."))
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
data_name = self._installer.get_game_data_name() data_name = self._installer.get_game_data_name()
xlua_path = self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name)) xlua_path = self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))
xlua_path.rename(self._gamedir.joinpath("{}/Plugins/xlua.dll.bak".format(data_name))) await xlua_path.rename(self._gamedir.joinpath("{}/Plugins/xlua.dll.bak".format(data_name)))
proc = await asyncio.create_subprocess_exec("xdelta3", "-d", "-s", proc = await asyncio.create_subprocess_exec("xdelta3", "-d", "-s",
str(self._gamedir.joinpath( str(self._gamedir.joinpath(
"{}/Plugins/xlua.dll.bak".format(data_name))), "{}/Plugins/xlua.dll.bak".format(data_name))),
@ -189,12 +178,8 @@ class Patcher:
cwd=self._gamedir) cwd=self._gamedir)
await proc.wait() await proc.wait()
def _patch_unityplayer(self): async def _patch_unityplayer(self, patch):
if self._overseas: gamever = "".join((await self._installer.get_game_version()).split("."))
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
unity_path = self._gamedir.joinpath("UnityPlayer.dll") unity_path = self._gamedir.joinpath("UnityPlayer.dll")
patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_bytes() patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_bytes()
patched_unity_bytes = xdelta3.decode(unity_path.read_bytes(), patch_bytes) patched_unity_bytes = xdelta3.decode(unity_path.read_bytes(), patch_bytes)
@ -202,9 +187,8 @@ class Patcher:
with Path(self._gamedir.joinpath("UnityPlayer.dll")).open("wb") as f: with Path(self._gamedir.joinpath("UnityPlayer.dll")).open("wb") as f:
f.write(patched_unity_bytes) f.write(patched_unity_bytes)
def _patch_xlua(self): async def _patch_xlua(self, patch):
patch = "xlua_patch.vcdiff" gamever = "".join((await self._installer.get_game_version()).split("."))
gamever = "".join(self._installer.get_game_version().split("."))
data_name = self._installer.get_game_data_name() data_name = self._installer.get_game_data_name()
xlua_path = self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name)) xlua_path = self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))
patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_bytes() patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_bytes()
@ -213,13 +197,17 @@ class Patcher:
with Path(self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))).open("wb") as f: with Path(self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))).open("wb") as f:
f.write(patched_xlua_bytes) f.write(patched_xlua_bytes)
def apply_xlua_patch(self, fallback=True): async def apply_xlua_patch(self, fallback=True):
if self._overseas:
patch = "xlua_patch_os.vcdiff"
else:
patch = "xlua_patch_cn.vcdiff"
if NO_XDELTA3_MODULE or fallback: if NO_XDELTA3_MODULE or fallback:
asyncio.run(self._patch_xlua_fallback()) await self._patch_xlua_fallback(patch)
return return
self._patch_xlua() await self._patch_xlua(patch)
def apply_patch(self, crash_fix=False, fallback=True) -> None: async def apply_patch(self, crash_fix=False, fallback=True) -> None:
""" """
Patch the game (and optionally patch xLua if specified) Patch the game (and optionally patch xLua if specified)
@ -228,25 +216,36 @@ class Patcher:
:return: None :return: None
""" """
# Patch UnityPlayer.dll # Patch UnityPlayer.dll
if NO_XDELTA3_MODULE or fallback: # xdelta3-python doesn't work because it's outdated.
asyncio.run(self._patch_unityplayer_fallback()) if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else: else:
self._patch_unityplayer() patch = "unityplayer_patch_cn.vcdiff"
patch_jobs = []
if NO_XDELTA3_MODULE or fallback:
patch_jobs.append(self._patch_unityplayer_fallback(patch))
else:
patch_jobs.append(self._patch_unityplayer(patch))
# Patch xLua.dll # Patch xLua.dll
if crash_fix: if crash_fix:
self.apply_xlua_patch(fallback=fallback) patch_jobs.append(self.apply_xlua_patch(fallback=fallback))
# Disable crash reporters # Disable crash reporters
disable_files = [
self._installer.get_game_data_name() + "upload_crash.exe", async def disable_crashreporters():
self._installer.get_game_data_name() + "Plugins/crashreport.exe", disable_files = [
] self._installer.get_game_data_name() + "upload_crash.exe",
for file in disable_files: self._installer.get_game_data_name() + "Plugins/crashreport.exe",
file_path = Path(file).resolve() ]
if file_path.exists(): for file in disable_files:
file_path.rename(str(file_path) + ".bak") file_path = Path(file).resolve()
if file_path.exists():
file_path.rename(str(file_path) + ".bak")
patch_jobs.append(disable_crashreporters())
await asyncio.gather(*patch_jobs)
@staticmethod @staticmethod
def _creation_date(file_path: Path): async def _creation_date(file_path: AsyncPath):
""" """
Try to get the date that a file was created, falling back to when it was Try to get the date that a file was created, falling back to when it was
last modified if that isn't possible. last modified if that isn't possible.
@ -255,7 +254,7 @@ class Patcher:
if platform.system() == 'Windows': if platform.system() == 'Windows':
return os.path.getctime(file_path) return os.path.getctime(file_path)
else: else:
stat = file_path.stat() stat = await file_path.stat()
try: try:
return stat.st_birthtime return stat.st_birthtime
except AttributeError: except AttributeError:
@ -263,11 +262,11 @@ class Patcher:
# so we'll settle for when its content was last modified. # so we'll settle for when its content was last modified.
return stat.st_mtime return stat.st_mtime
def _revert_file(self, original_file: str, base_file: Path, ignore_error=False): async def _revert_file(self, original_file: str, base_file: AsyncPath, ignore_error=False):
original_path = self._gamedir.joinpath(original_file + ".bak").resolve() original_path = await self._gamedir.joinpath(original_file + ".bak").resolve()
target_file = self._gamedir.joinpath(original_file).resolve() target_file = await self._gamedir.joinpath(original_file).resolve()
if original_path.exists(): if await original_path.exists():
if abs(self._creation_date(base_file) - self._creation_date(original_path)) > 86400: # 24 hours if abs(await self._creation_date(base_file) - await self._creation_date(original_path)) > 86400: # 24 hours
if not ignore_error: if not ignore_error:
raise RuntimeError("{} is not for this game version.".format(original_path.name)) raise RuntimeError("{} is not for this game version.".format(original_path.name))
original_path.unlink(missing_ok=True) original_path.unlink(missing_ok=True)
@ -275,30 +274,36 @@ class Patcher:
target_file.unlink(missing_ok=True) target_file.unlink(missing_ok=True)
original_path.rename(target_file) original_path.rename(target_file)
def revert_patch(self, ignore_errors=True) -> None: async def revert_patch(self, ignore_errors=True) -> None:
""" """
Revert the patch (and revert the login door crash fix if patched) Revert the patch (and revert the xLua patch if patched)
:return: None :return: None
""" """
game_exec = self._gamedir.joinpath(asyncio.run(self._launcher.get_resource_info()).game.latest.entry) game_exec = self._gamedir.joinpath((await self._launcher.get_resource_info()).game.latest.entry)
revert_files = [ revert_files = [
"UnityPlayer.dll", "UnityPlayer.dll",
self._installer.get_game_data_name() + "upload_crash.exe", self._installer.get_game_data_name() + "upload_crash.exe",
self._installer.get_game_data_name() + "Plugins/crashreport.exe", self._installer.get_game_data_name() + "Plugins/crashreport.exe",
self._installer.get_game_data_name() + "Plugins/xlua.dll", self._installer.get_game_data_name() + "Plugins/xlua.dll",
] ]
revert_job = []
for file in revert_files: for file in revert_files:
self._revert_file(file, game_exec, ignore_errors) revert_job.append(self._revert_file(file, game_exec, ignore_errors))
for file in ["launcher.bat", "mhyprot2_running.reg"]: for file in ["launcher.bat", "mhyprot2_running.reg"]:
self._gamedir.joinpath(file).unlink(missing_ok=True) revert_job.append(self._gamedir.joinpath(file).unlink(missing_ok=True))
def get_files(extensions): async def get_files(extensions):
all_files = [] all_files = []
for ext in extensions: for ext in extensions:
all_files.extend(self._gamedir.glob(ext)) all_files.extend(await self._gamedir.glob(ext))
return all_files return all_files
files = get_files(('*.dxvk-cache', '*_d3d9.log', '*_d3d11.log', '*_dxgi.log')) files = await get_files(('*.dxvk-cache', '*_d3d9.log', '*_d3d11.log', '*_dxgi.log'))
for file in files: for file in files:
file.unlink(missing_ok=True) revert_job.append(file.unlink(missing_ok=True))
await asyncio.gather(*revert_job)
async def clear_cache(self):
await asyncio.to_thread(shutil.rmtree, self._temp_path, ignore_errors=True)