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:
2022-06-25 01:13:47 +07:00
parent 8b2d0cad8f
commit a5659f7ff3
14 changed files with 515 additions and 406 deletions

View File

@ -1,11 +1,13 @@
import os
import platform
import tarfile
import appdirs
from pathlib import Path
import shutil
import aiohttp
import asyncio
from aiopath import AsyncPath
from worthless import constants
from worthless.launcher import Launcher
from worthless.installer import Installer
@ -26,16 +28,19 @@ except ImportError:
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._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).joinpath("Patcher")
self._appdirs = constants.APPDIRS
self._patch_path = AsyncPath(self._appdirs.user_data_dir).joinpath("Patch")
self._temp_path = AsyncPath(self._appdirs.user_cache_dir).joinpath("Patcher")
else:
if not isinstance(data_dir, Path):
data_dir = Path(data_dir)
if isinstance(data_dir, str | Path):
data_dir = AsyncPath(data_dir)
self._patch_path = data_dir.joinpath("Patch")
self._temp_path = data_dir.joinpath("Temp/Patcher")
self._overseas = overseas
@ -43,7 +48,7 @@ class Patcher:
self._launcher = Launcher(self._gamedir, overseas=overseas)
match platform.system():
case "Linux":
self._linuxutils = linux.LinuxUtils()
self._linux = linux.LinuxUtils()
@staticmethod
async def _get(url, **kwargs) -> aiohttp.ClientResponse:
@ -76,15 +81,15 @@ class Patcher:
else:
return await archive.read()
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():
async def _download_repo(self, fallback=False):
if shutil.which("git") and not fallback:
if not await self._patch_path.is_dir() or not await self._patch_path.joinpath(".git").exists():
proc = await asyncio.create_subprocess_exec("git", "clone", self._patch_url, str(self._patch_path))
await proc.wait()
else:
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:
archive = await self._get_git_archive()
if not archive:
@ -120,24 +125,18 @@ class Patcher:
telemetry_url = constants.TELEMETRY_URL_LIST
else:
telemetry_url = constants.TELEMETRY_URL_CN_LIST
if optional:
telemetry_url |= constants.TELEMETRY_OPTIONAL_URL_LIST
unblocked_list = []
async with aiohttp.ClientSession() as session:
for url in telemetry_url:
try:
await session.get("https://" + url)
except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError):
await session.get("https://" + url, timeout=15)
except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError, asyncio.exceptions.TimeoutError):
continue
else:
unblocked_list.append(url)
if optional:
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
return None if not unblocked_list else unblocked_list
async def block_telemetry(self, optional=False):
telemetry = await self.is_telemetry_blocked(optional)
@ -148,20 +147,15 @@ class Patcher:
telemetry_hosts += "0.0.0.0 " + url + "\n"
match platform.system():
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
# TODO: Windows and macOS
raise NotImplementedError("Platform not implemented.")
async def _patch_unityplayer_fallback(self):
# xdelta3-python doesn't work because it's outdated.
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
async def _patch_unityplayer_fallback(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
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",
str(self._gamedir.joinpath("UnityPlayer.dll.bak")),
str(self._patch_path.joinpath(
@ -169,16 +163,11 @@ class Patcher:
str(self._gamedir.joinpath("UnityPlayer.dll")), cwd=self._gamedir)
await proc.wait()
async def _patch_xlua_fallback(self):
# xdelta3-python doesn't work becuase it's outdated.
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
async def _patch_xlua_fallback(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
data_name = self._installer.get_game_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",
str(self._gamedir.joinpath(
"{}/Plugins/xlua.dll.bak".format(data_name))),
@ -189,12 +178,8 @@ class Patcher:
cwd=self._gamedir)
await proc.wait()
def _patch_unityplayer(self):
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
async def _patch_unityplayer(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
unity_path = self._gamedir.joinpath("UnityPlayer.dll")
patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_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:
f.write(patched_unity_bytes)
def _patch_xlua(self):
patch = "xlua_patch.vcdiff"
gamever = "".join(self._installer.get_game_version().split("."))
async def _patch_xlua(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
data_name = self._installer.get_game_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()
@ -213,13 +197,17 @@ class Patcher:
with Path(self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))).open("wb") as f:
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:
asyncio.run(self._patch_xlua_fallback())
await self._patch_xlua_fallback(patch)
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)
@ -228,25 +216,36 @@ class Patcher:
:return: None
"""
# Patch UnityPlayer.dll
if NO_XDELTA3_MODULE or fallback:
asyncio.run(self._patch_unityplayer_fallback())
# xdelta3-python doesn't work because it's outdated.
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
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
if crash_fix:
self.apply_xlua_patch(fallback=fallback)
patch_jobs.append(self.apply_xlua_patch(fallback=fallback))
# Disable crash reporters
disable_files = [
self._installer.get_game_data_name() + "upload_crash.exe",
self._installer.get_game_data_name() + "Plugins/crashreport.exe",
]
for file in disable_files:
file_path = Path(file).resolve()
if file_path.exists():
file_path.rename(str(file_path) + ".bak")
async def disable_crashreporters():
disable_files = [
self._installer.get_game_data_name() + "upload_crash.exe",
self._installer.get_game_data_name() + "Plugins/crashreport.exe",
]
for file in disable_files:
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
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
last modified if that isn't possible.
@ -255,7 +254,7 @@ class Patcher:
if platform.system() == 'Windows':
return os.path.getctime(file_path)
else:
stat = file_path.stat()
stat = await file_path.stat()
try:
return stat.st_birthtime
except AttributeError:
@ -263,11 +262,11 @@ class Patcher:
# so we'll settle for when its content was last modified.
return stat.st_mtime
def _revert_file(self, original_file: str, base_file: Path, ignore_error=False):
original_path = self._gamedir.joinpath(original_file + ".bak").resolve()
target_file = self._gamedir.joinpath(original_file).resolve()
if original_path.exists():
if abs(self._creation_date(base_file) - self._creation_date(original_path)) > 86400: # 24 hours
async def _revert_file(self, original_file: str, base_file: AsyncPath, ignore_error=False):
original_path = await self._gamedir.joinpath(original_file + ".bak").resolve()
target_file = await self._gamedir.joinpath(original_file).resolve()
if await original_path.exists():
if abs(await self._creation_date(base_file) - await self._creation_date(original_path)) > 86400: # 24 hours
if not ignore_error:
raise RuntimeError("{} is not for this game version.".format(original_path.name))
original_path.unlink(missing_ok=True)
@ -275,30 +274,36 @@ class Patcher:
target_file.unlink(missing_ok=True)
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
"""
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 = [
"UnityPlayer.dll",
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/xlua.dll",
]
revert_job = []
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"]:
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 = []
for ext in extensions:
all_files.extend(self._gamedir.glob(ext))
all_files.extend(await self._gamedir.glob(ext))
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:
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)