worthless-launcher/worthless/installer.py

637 lines
26 KiB
Python

import asyncio
import hashlib
import json
import platform
import re
import shutil
import zipfile
from configparser import ConfigParser
from pathlib import Path
import aiohttp
from worthless import constants
from worthless.launcher import Launcher
from worthless.launcherconfig import LauncherConfig
async def _download_file(file_url: str, file_name: str, file_path: Path | str, file_len: int = None, overwrite=False,
chunks=8192):
"""
Download file name to temporary directory,
:param file_url:
:param file_name:
:return:
"""
headers = {}
file_path = Path(file_path).joinpath(file_name)
if overwrite:
file_path.unlink(missing_ok=True)
if file_path.exists():
cur_len = (file_path.stat()).st_size
headers |= {
"Range": f"bytes={cur_len}-{file_len if file_len else ''}"
}
else:
file_path.touch()
print(f"Downloading {file_url} to {file_path}...")
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60*60, sock_read=240)) as session:
rsp = await session.get(file_url, headers=headers, timeout=None)
if rsp.status == 416:
return
rsp.raise_for_status()
while True:
chunk = await rsp.content.read(chunks)
await asyncio.sleep(0)
if not chunk:
break
with file_path.open("ab") as f:
f.write(chunk)
def calculate_md5(file_to_calculate):
file_to_calculate = Path(file_to_calculate)
if not file_to_calculate.exists():
return ""
with file_to_calculate.open("rb") as f:
file_hash = hashlib.md5()
while chunk := f.read(1024 * 1024):
file_hash.update(chunk)
return file_hash.hexdigest()
class HDiffPatch:
def __init__(self, git_url=None, data_dir=None):
if not git_url:
git_url = constants.HDIFFPATCH_GIT_URL
self._git_url = git_url
if not data_dir:
self._appdirs = constants.APPDIRS
self.temp_path = Path(self._appdirs.user_cache_dir).joinpath("HDiffPatch")
self.data_path = Path(self._appdirs.user_data_dir).joinpath("Tools/HDiffPatch")
else:
if not isinstance(data_dir, Path):
data_dir = Path(data_dir)
self.data_path = Path(data_dir).joinpath("Tools/HDiffPatch")
self.temp_path = data_dir.joinpath("Temp/HDiffPatch")
Path(self.temp_path).mkdir(parents=True, exist_ok=True)
@staticmethod
def _get_platform_arch():
processor = platform.machine()
match platform.system():
case "Windows":
match processor:
case "i386":
return "windows32"
case "x86_64":
return "windows64"
case "arm":
return "windows_arm32"
case "arm64":
return "windows_arm64"
case "Linux":
match processor:
case "i386":
return "linux32"
case "x86_64":
return "linux64"
case "arm":
return "linux_arm32"
case "arm64":
return "linux_arm64"
case "Darwin":
return "macos"
# Rip BSD they need to use Linux compatibility layer to run this (or use Wine if they prefer that)
raise RuntimeError("Unsupported platform")
def _get_hdiffpatch_exec(self, exec_name):
if shutil.which(exec_name):
return exec_name
if not self.data_path.exists():
return None
if not any(self.data_path.iterdir()):
return None
platform_arch_path = self.data_path.joinpath(self._get_platform_arch())
file = platform_arch_path.joinpath(exec_name)
if file.exists():
file.chmod(0o755)
return str(file)
return None
def get_hpatchz_executable(self):
hpatchz_name = "hpatchz" + (".exe" if platform.system() == "Windows" else "")
return self._get_hdiffpatch_exec(hpatchz_name)
async def patch_file(self, in_file, out_file, patch_file, error=False, wait=False):
print("executing hpatchz")
hpatchz = self.get_hpatchz_executable()
if not hpatchz:
raise RuntimeError("hpatchz executable not found")
proc = await asyncio.create_subprocess_exec(hpatchz, "-f", in_file, patch_file, out_file)
if not wait:
return proc
await proc.wait()
if error and proc.returncode != 0:
raise RuntimeError(f"Patching failed, return code is {proc.returncode}")
return proc
def get_hdiffz_executable(self):
hdiffz_name = "hdiffz" + (".exe" if platform.system() == "Windows" else "")
return self._get_hdiffpatch_exec(hdiffz_name)
async def _get_latest_release_info(self):
async with aiohttp.ClientSession() as session:
split = self._git_url.split("/")
repo = split[-1]
owner = split[-2]
rsp = await session.get("https://api.github.com/repos/{}/{}/releases/latest".format(owner, repo),
params={"Headers": "Accept: application/vnd.github.v3+json"})
rsp.raise_for_status()
archive_processor = self._get_platform_arch()
for asset in (await rsp.json())["assets"]:
if not asset["name"].endswith(".zip"):
continue
if archive_processor not in asset["name"]:
continue
return asset
async def get_latest_release_url(self):
asset = await self._get_latest_release_info()
return asset["browser_download_url"]
async def get_latest_release_name(self):
asset = await self._get_latest_release_info()
return asset["name"]
async def download_latest_release(self, extract=True):
url = await self.get_latest_release_url()
name = await self.get_latest_release_name()
if not url:
raise RuntimeError("Unable to find latest release")
await _download_file(url, name, self.temp_path, overwrite=True)
if not extract:
return
with zipfile.ZipFile(self.temp_path.joinpath(name), 'r') as f:
await asyncio.to_thread(f.extractall, path=self.data_path)
class Installer:
def __init__(self, gamedir: str | Path = Path.cwd(),
overseas: bool = True, data_dir: str | Path = None):
if isinstance(gamedir, str | Path):
gamedir = Path(gamedir)
self._gamedir = gamedir
if not data_dir:
self._appdirs = constants.APPDIRS
self.temp_path = Path(self._appdirs.user_cache_dir).joinpath("Installer")
else:
if isinstance(data_dir, str | Path):
data_dir = Path(data_dir)
self.temp_path = data_dir.joinpath("Temp/Installer/")
Path(self.temp_path).mkdir(parents=True, exist_ok=True)
config_file = self._gamedir.joinpath("config.ini")
self._config_file = config_file
self._download_chunk = 1024 * 1024
self._overseas = overseas
self._version = None
self._launcher = Launcher(self._gamedir, overseas=self._overseas)
self._hdiffpatch = HDiffPatch(data_dir=data_dir)
self._config = LauncherConfig(self._config_file, self._version)
self._game_version_re = re.compile(r"([1-9]+\.[0-9]+\.[0-9]+)_\d+_\d+")
async def _download_file(self, file_url: str, file_name: str, file_len: int = None, overwrite=False):
"""
Download file name to temporary directory,
:param file_url:
:param file_name:
:return:
"""
await _download_file(file_url, file_name, self.temp_path, file_len=file_len, overwrite=overwrite,
chunks=self._download_chunk)
async def read_version_from_config(self):
if not self._config_file.exists():
raise FileNotFoundError(f"Config file {self._config_file} not found")
cfg = ConfigParser()
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: Path | 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 | Path):
globalgamemanagers = Path(globalgamemanagers)
data = 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
def voiceover_lang_translate(lang: str, base_language="game") -> str:
"""
Translates the voiceover language to the language code used by the game.
:param lang: Language to translate
:param base_language: Base language type (game/locale/both)
:return: Language code
"""
if base_language == "game" or base_language == "both":
match lang.lower():
case "english(us)":
return "en-us"
case "japanese":
return "ja-jp"
case "chinese":
return "zh-cn"
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
@staticmethod
async def get_voiceover_archive_language(voiceover_archive: str | Path | Path) -> str:
if isinstance(voiceover_archive, str | Path):
voiceover_archive = Path(voiceover_archive).resolve()
if not voiceover_archive.exists():
raise FileNotFoundError(f"Voiceover archive {voiceover_archive} not found")
with zipfile.ZipFile(voiceover_archive, 'r') as f:
for file in zipfile.Path(f).iterdir():
if file.name.endswith("_pkg_version"):
return file.name.split("_")[1]
@staticmethod
async def get_voiceover_archive_type(voiceover_archive: str | Path) -> bool:
"""
Gets voiceover archive type.
:param voiceover_archive:
:return: True if this is a full archive, else False.
"""
vo_lang = await 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
def set_download_chunk(self, chunk: int):
self._download_chunk = chunk
def get_game_data_name(self):
if self._overseas:
return "GenshinImpact_Data/"
else:
return "YuanShen_Data/"
def get_game_data_path(self) -> Path:
return self._gamedir.joinpath(self.get_game_data_name())
async def get_game_archive_version(self, game_archive: str | Path):
game_archive = Path(game_archive)
if not game_archive.is_file():
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 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 = []
for file in self.get_game_data_path()\
.joinpath("StreamingAssets/AudioAssets/").iterdir():
if file.is_dir():
voiceovers.append(file.name)
return voiceovers
async def _update(self, game_archive: str | Path | Path):
archive = zipfile.ZipFile(game_archive, 'r')
if not self._hdiffpatch.get_hpatchz_executable():
await self._hdiffpatch.download_latest_release()
files = archive.namelist()
# Don't extract these files (they're useless and if the game isn't patched then it'll
# raise 31-4xxx error ingame)
for file in ["deletefiles.txt", "hdifffiles.txt"]:
try:
files.remove(file)
except ValueError:
pass
try:
deletefiles = archive.read("deletefiles.txt").decode().split("\r\n")
for file in deletefiles:
current_game_file = Path(self._gamedir).joinpath(file)
if current_game_file == Path(self._gamedir):
# Don't delete the game folder
print("Game folder detected, not deleting:", current_game_file)
continue
if not current_game_file.relative_to(Path(self._gamedir)):
print("Not deleting (not relative to game):", current_game_file)
continue
print("Deleting", file)
current_game_file.unlink(missing_ok=True)
except Exception as e:
print(f"Error while reading deletefiles.txt: {e}")
# hdiffpatch implementation
try:
hdifffiles = []
for x in (await asyncio.to_thread(archive.read, "hdifffiles.txt")).decode().split("\n"):
if x:
hdifffiles.append(json.loads(x.strip())["remoteName"])
patch_jobs = []
cur_jobs = []
count = 0
for file in hdifffiles:
current_game_file = self._gamedir.joinpath(file)
if not current_game_file.exists():
print("File", file, "not found")
# Not patching since we don't have the file
continue
patch_file = str(file) + ".hdiff"
async def extract_and_patch(old_file, diff_file):
patch_path = self.temp_path.joinpath(diff_file)
patch_path.unlink(missing_ok=True)
try:
print(diff_file)
await asyncio.to_thread(archive.extract, diff_file, self.temp_path)
except FileExistsError:
print("Failed to extract diff file", diff_file)
return
old_suffix = old_file.suffix
old_file = old_file.rename(old_file.with_suffix(".bak"))
proc = await self._hdiffpatch.patch_file(old_file, old_file.with_suffix(old_suffix),
patch_path, wait=True)
patch_path.unlink()
if proc.returncode == 0:
old_file.unlink()
return
# Let the game download the file.
print("Failed to patch {}, reverting and let the in-game updater do the job...".format(
old_file.with_suffix(old_suffix))
)
try:
old_file.rename(old_file.with_suffix(old_suffix))
except Exception:
pass
files.remove(patch_file)
# Limit to 8 process running so it doesn't hang the PC.
if count == 7:
print("add job")
patch_jobs.append(cur_jobs)
cur_jobs = []
count = 0
cur_jobs.append(extract_and_patch(current_game_file, patch_file))
count += 1
# The last list may have count < 7 and the above code will not add them
patch_jobs.append(cur_jobs)
for jobs in patch_jobs:
print("exec jobs", jobs)
await asyncio.gather(*jobs)
except Exception as e:
print(f"Error while reading hdifffiles.txt: {e}")
await asyncio.to_thread(archive.extractall, self._gamedir, members=files)
archive.close()
async def update_game(self, game_archive: str | Path | Path):
if not self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}")
if isinstance(game_archive, str | Path):
game_archive = Path(game_archive).resolve()
if not game_archive.exists():
raise FileNotFoundError(f"Update archive {game_archive} not found")
await self._update(game_archive=game_archive)
# Update game version on local variable.
self._version = await self.get_game_version()
self.set_version_config()
def set_version_config(self, version: str = None):
if not version:
version = self._version
self._config.set_game_version(version)
self._config.save()
async def download_full_game(self, pre_download=False):
game = await self._get_game(pre_download)
if not game.latest.path == "":
archive_name = game.latest.path.split("/")[-1]
if calculate_md5(self.temp_path.joinpath(archive_name)) != game.latest.md5:
raise RuntimeError("mismatch md5 for downloaded game archive")
return
# Segment download
base_archive = None
for i, segment in enumerate(game.latest.segments):
archive_name = segment["path"].split("/")[-1]
if i == 0:
base_archive = archive_name = Path(archive_name).stem # Remove .001
if self.temp_path.joinpath(archive_name + ".downloaded").exists():
continue
await self._download_file(segment["path"], archive_name)
if i != 0:
with open(self.temp_path.joinpath(base_archive), 'ab') as f:
with open(self.temp_path.joinpath(archive_name), 'rb') as f2:
f.write(f2.read())
self.temp_path.joinpath(archive_name).unlink()
self.temp_path.joinpath(archive_name + ".downloaded").touch()
async def download_full_voiceover(self, language: str, pre_download=False):
game = await self._get_game(pre_download)
translated_lang = self.voiceover_lang_translate(language)
for vo in game.latest.voice_packs:
if vo.language == translated_lang:
await self._download_file(vo.path, vo.get_name(), vo.size)
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 | Path):
if isinstance(archive, str | Path):
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 self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}")
if isinstance(voiceover_archive, str | Path):
voiceover_archive = Path(voiceover_archive).resolve()
await self._update(voiceover_archive)
# await self._extract_game_file(voiceover_archive)
async def install_game(self, game_archive: str | Path | 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}")
await self.uninstall_game()
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_version(self):
if self._version:
from_version = self._version
else:
from_version = self._version = await self.get_game_version()
return from_version
async def _get_game_resource(self):
game_resource = await self._launcher.get_resource_info()
if not game_resource:
raise ValueError("Could not fetch game resource")
return game_resource
async def _get_game(self, pre_download=False):
game_resource = await self._get_game_resource()
game = game_resource.game
if pre_download:
game = game_resource.pre_download_game
return game
async def download_game_update(self, from_version: str = None, pre_download=False):
if not from_version:
from_version = await self._get_game_version()
game = await self._get_game(pre_download=pre_download)
if self._version == game.latest.version:
raise ValueError("Game is already up to date.")
diff_archive = await self.get_game_diff_archive(from_version, pre_download)
if diff_archive is None:
raise ValueError("Game diff archive is not available for this version, please reinstall.")
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, pre_download=False):
if not from_version:
from_version = await self._get_game_version()
diff_archive = await self.get_voiceover_diff_archive(language, from_version, pre_download)
if diff_archive is None:
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)
async def get_voiceover_diff_archive(self, lang: str, from_version: str = None, pre_download=False):
"""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 not from_version:
from_version = await self._get_game_version()
game = await self._get_game(pre_download=pre_download)
translated_lang = self.voiceover_lang_translate(lang)
for v in game.diffs:
if v.version.strip() != from_version.strip():
continue
for vo in v.voice_packs:
if vo.language == translated_lang:
return vo
async def get_game_diff_archive(self, from_version: str = None, pre_download=False):
"""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 not from_version:
from_version = await self._get_game_version()
game = await self._get_game(pre_download=pre_download)
for v in game.diffs:
if v.version == from_version:
return v
async def verify_from_pkg_version(self, pkg_version: Path, ignore_mismatch=False):
contents = pkg_version.read_text()
async def verify_file(file_to_verify, md5):
print("Verifying file:", file_to_verify)
file_md5 = await asyncio.to_thread(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 = []
cur_jobs = []
count = 0
for content in contents.split("\r\n"):
if not content.strip():
continue
if count >= 7:
verify_jobs.append(cur_jobs)
cur_jobs = []
count = 0
info = json.loads(content)
cur_jobs.append(verify_file(self._gamedir.joinpath(info["remoteName"]), info["md5"]))
count += 1
verify_jobs.append(cur_jobs)
verify_result = []
for jobs in verify_jobs:
verify_result.extend(await asyncio.gather(*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 | Path = 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)