Support chinese variant, QQ object in launcher and some code optimizations

This commit is contained in:
2022-02-16 00:49:33 +07:00
parent 048a7ac9d0
commit c22918673b
19 changed files with 389 additions and 58 deletions

View File

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

View File

@ -3,7 +3,7 @@ class Background:
Note that the `background` variable is an url to the background image,
while the `url` variable contains an empty string, so it seems that the
`url` and `icon` variables are used by the official launcher itself.
`url` and `icon` variables are not used by the official launcher itself.
Also, the launcher background checksum is using an algorithm which I
haven't found out yet, so you better not rely on it but instead rely
@ -16,8 +16,9 @@ class Background:
- :class:`str` url: The url variable.
- :class:`str` version: The launcher background version.
- :class:`str` bg_checksum: The launcher background checksum.
- :class:`dict` raw: The launcher background raw information in dict.
- :class:`dict` raw: The launcher background raw information.
"""
def __init__(self, background, icon, url, version, bg_checksum, raw):
"""Inits the launcher background class"""
self.background = background
@ -31,4 +32,4 @@ class Background:
def from_dict(data) -> 'Background':
"""Creates a launcher background from a dictionary."""
return Background(data["background"], data["icon"], data["url"],
data["version"], data["bg_checksum"], data)
data["version"], data["bg_checksum"], data)

View File

@ -16,9 +16,10 @@ class IconButton:
- :class:`str` qr_img: The QR code url.
- :class:`str` qr_desc: The QR code description.
- :class:`str` img_hover: The icon url when hovered over.
- :class:`dict[LauncherIconOtherLink]` other_links: Other links in the button.
- :class:`dict` raw: The launcher background raw information in dict.
- :class:`list[LauncherIconOtherLink]` other_links: Other links in the button.
- :class:`dict` raw: The launcher background raw information.
"""
def __init__(self, icon_id, img, tittle, url, qr_img, qr_desc, img_hover, other_links, raw):
"""Inits the launcher icon class"""
self.icon_id = icon_id
@ -38,5 +39,4 @@ class IconButton:
for link in data['other_links']:
other_links.append(IconOtherLink.from_dict(link))
return IconButton(data["icon_id"], data["img"], data["tittle"], data["url"], data["qr_img"],
data["qr_desc"], data["img_hover"], other_links, data)
data["qr_desc"], data["img_hover"], other_links, data)

View File

@ -1,16 +1,33 @@
from worthless.classes.launcher import background, banner, iconbutton, post
from worthless.classes.launcher import background, banner, iconbutton, post, qq
Background = background.Background
Banner = banner.Banner
IconButton = iconbutton.IconButton
Post = post.Post
QQ = qq.QQ
class Info:
def __init__(self, lc_background: Background, lc_banner: list[Banner], icon: list[IconButton], lc_post: list[Post]):
"""Contains the launcher information
Note that QQ is not wrapped due to not having access to the chinese yuanshen launcher.
You can contribute to the project if you want :D
Attributes:
- :class:`worthless.classes.launcher.background.Background` background: The launcher background information.
- :class:`worthless.classes.launcher.banner.Banner` banner: The launcher banner information.
- :class:`worthless.classes.launcher.iconbutton.IconButton` icon: The launcher icon buttons information.
- :class:`worthless.classes.launcher.qq.QQ` post: The launcher QQ posts information.
- :class:`dict` raw: The launcher raw information.
"""
def __init__(self, lc_background: Background, lc_banner: list[Banner], icon: list[IconButton], lc_post: list[Post],
lc_qq: list[QQ], raw: dict):
self.background = lc_background
self.banner = lc_banner
self.icon = icon
self.post = lc_post
self.qq = lc_qq
self.raw = raw
@staticmethod
def from_dict(data):
@ -24,5 +41,8 @@ class Info:
lc_post = []
for p in data["post"]:
lc_post.append(Post.from_dict(p))
return Info(bg, lc_banner, lc_icon, lc_post)
lc_qq = []
for q in data["qq"]:
lc_qq.append(QQ.from_dict(q))
return Info(bg, lc_banner, lc_icon, lc_post, lc_qq, data)

View File

@ -13,13 +13,13 @@ class Post:
Attributes:
- :class:`str` post_id: The launcher post id.
- :class:`str` type: The post type, as explained above.
- :class:`str` type: The post type, can be POST_TYPE_ANNOUNCE, POST_TYPE_ACTIVITY and POST_TYPE_INFO
- :class:`str` tittle: The post title.
- :class:`str` url: The post target url.
- :class:`str` show_time: The time when the post will be shown.
- :class:`str` order: The post order.
- :class:`str` title: The post title.
- :class:`dict` raw: The banner raw information.
- :class:`dict` raw: The post raw information.
"""
def __init__(self, post_id, post_type, tittle, url, show_time, order, title, raw):
self.post_id = post_id

View File

@ -0,0 +1,35 @@
class QQ:
"""Contains the launcher QQ information
Note that QQ is not wrapped due to not having access to the chinese yuanshen launcher.
You can contribute to the project if you want :D
Attributes:
- :class:`str` qq_id: The id of the QQ post
- :class:`str` name: The name of the QQ post
- :class:`int` number: The number of the QQ post
- :class:`str` code: The QQ post url.
- :class:`dict` raw: The launcher raw information.
"""
def __init__(self, qq_id, name, number, code, raw):
self.qq_id = qq_id
self.name = name
self.number = number
self.code = code
self.raw = raw
@staticmethod
def from_dict(raw: dict) -> 'QQ':
"""Creates a QQ object from a dictionary
Args:
raw (dict): The raw dictionary
Returns:
QQ: The QQ object
"""
qq_id = raw.get('qq_id')
name = raw.get('name')
number = int(raw.get('number'))
code = raw.get('code')
return QQ(qq_id, name, number, code, raw)

View File

@ -0,0 +1,8 @@
class mhyResponse:
"""Simple class for wrapping miHoYo web response
Currently not used for anything.
"""
def __init__(self, retcode, message, data):
self.retcode = retcode
self.message = message
self.data = data

View File

@ -1,6 +1,11 @@
LAUNCHER_API_URL = "https://sdk-os-static.mihoyo.com/hk4e_global/mdk/launcher/api"
APP_NAME="worthless"
APP_AUTHOR="tretrauit"
LAUNCHER_API_URL_OS = "https://sdk-os-static.mihoyo.com/hk4e_global/mdk/launcher/api"
LAUNCHER_API_URL_CN = "https://sdk-static.mihoyo.com/hk4e_cn/mdk/launcher/api"
PATCH_GIT_URL = "https://notabug.org/Krock/dawn"
TELEMETRY_URL_LIST = [
"log-upload-os.mihoyo.com",
"log-upload.mihoyo.com",
"overseauspider.yuanshen.com"
"uspider.yuanshen.com"
]

View File

@ -4,40 +4,66 @@ import argparse
from pathlib import Path
def interactive_ui(gamedir=Path.cwd()):
raise NotImplementedError("Interactive UI is not implemented")
class UI:
def __init__(self, gamedir: str, noconfirm: bool) -> None:
self._noconfirm = noconfirm
self._gamedir = gamedir
def _ask(self, title, description):
raise NotImplementedError()
def update_game(gamedir=Path.cwd(), noconfirm=False):
print("Checking for current game version...")
# Call check_game_version()
print("Updating game...")
# Call update_game(fromver)
raise NotImplementedError("Update game is not implemented")
def install_game(self):
# TODO
raise NotImplementedError("Install game is not implemented.")
def update_game(self):
print("Checking for current game version...")
# Call check_game_version()
print("Updating game...")
# Call update_game(fromver)
raise NotImplementedError("Update game is not implemented.")
def interactive_ui(self):
raise NotImplementedError()
def main():
parser = argparse.ArgumentParser(prog="worthless-launcher", description="A worthless launcher written in Python.")
parser = argparse.ArgumentParser(prog="worthless", description="A worthless launcher written in Python.")
parser.add_argument("-D", "-d", "--dir", action="store", type=Path, default=Path.cwd(),
help="Specify the game directory (default current working directory)")
parser.add_argument("-I", "-i", "--install", action="store_true",
parser.add_argument("-S", "--install", action="store_true",
help="Install the game (if not already installed, else do nothing)")
parser.add_argument("-U", "-u", "--update", action="store_true", help="Update the game (if not updated)")
parser.add_argument("-U", "--install-from-file", action="store_true",
help="Install the game from the game archive (if not already installed, \
else update from archive)")
parser.add_argument("-Sp", "--patch", action="store_true",
help="Patch the game (if not already patched, else do nothing)")
parser.add_argument("-Sy", "--update", action="store_true",
help="Update the game and specified voiceover pack only (or install if not found)")
parser.add_argument("-Syu", "--update", action="store_true",
help="Update the game and all installed voiceover packs (or install if not found)")
parser.add_argument("-Rs", "--remove", action="store_true", help="Remove the game (if installed)")
parser.add_argument("-Rp", "--remove-patch", action="store_true", help="Revert the game patch (if patched)")
parser.add_argument("-Rv", "--remove-voiceover", action="store_true", help="Remove a Voiceover pack (if installed)")
parser.add_argument("--noconfirm", action="store_true",
help="Do not ask any questions. (Ignored in interactive mode)")
help="Do not ask any for confirmation. (Ignored in interactive mode)")
args = parser.parse_args()
print(args)
interactive_mode = not args.install and not args.install_from_file and not args.patch and not args.update and not \
args.remove and not args.remove_patch and not args.remove_voiceover
ui = UI(args.dir, args.noconfirm)
if args.install and args.update:
raise ValueError("Cannot specify both --install and --update arguments.")
if args.install:
raise NotImplementedError("Install game is not implemented")
ui.install_game()
if args.update:
update_game(args.dir, args.noconfirm)
ui.update_game()
return
interactive_ui(args.dir)
if interactive_mode:
ui.interactive_ui()
if __name__ == "__main__":

33
worthless/installer.py Normal file
View File

@ -0,0 +1,33 @@
import asyncio
import tarfile
import constants
import appdirs
import aiofiles
from pathlib import Path
import shutil
import aiohttp
from worthless.launcher import Launcher
from configparser import ConfigParser
class Installer:
def __init__(self, gamedir: str | Path = Path.cwd(), overseas: bool = True):
if isinstance(gamedir, str):
gamedir = Path(gamedir)
self._gamedir = gamedir
config_file = self._gamedir.joinpath("config.ini")
self._config_file = config_file.resolve()
self._version = None
self._overseas = overseas
self._launcher = Launcher(self._gamedir, self._overseas)
if config_file.exists():
self._version = self._read_version_from_config()
else: # TODO: Use An Anime Game Launcher method (which is more brutal, but it works)
self._version = "mangosus"
def _read_version_from_config(self):
if self._config_file.exists():
raise FileNotFoundError(f"Config file {self._config_file} not found")
cfg = ConfigParser()
cfg.read(str(self._config_file))
return cfg.get("miHoYo", "game_version")

View File

@ -10,14 +10,28 @@ class Launcher:
Contains functions to get information from server and client like the official launcher.
"""
def __init__(self, gamedir=Path.cwd()):
def __init__(self, gamedir=Path.cwd(), language=None, overseas=True):
"""Initialize the launcher API
Args:
gamedir (Path): Path to the game directory.
"""
self._api = constants.LAUNCHER_API_URL
self._lang = self._get_system_language()
self._overseas = overseas
if overseas:
self._api = constants.LAUNCHER_API_URL_OS
self._params = {
"key": "gcStgarh",
"launcher_id": "10",
}
self._lang = self._get_system_language() if not language else language.lower().replace("_", "-")
else:
self._api = constants.LAUNCHER_API_URL_CN
self._params = {
"key": "eYd89JmJ",
"launcher_id": "18",
"channel_id": "1"
}
self._lang = "zh-cn" # Use chinese language because this is Pooh version
if isinstance(gamedir, str):
gamedir = Path(gamedir)
self._gamedir = gamedir.resolve()
@ -36,18 +50,6 @@ class Launcher:
request_info=rsp.request_info)
return rsp_json
async def _get_launcher_info(self, adv=True) -> launcher.Info:
params = {"key": "gcStgarh",
"filter_adv": str(adv).lower(),
"launcher_id": "10",
"language": self._lang}
rsp = await self._get(self._api + "/content", params=params)
if rsp["data"]["adv"] is None:
params["language"] = "en-us"
rsp = await self._get(self._api + "/content", params=params)
lc_info = launcher.Info.from_dict(rsp["data"])
return lc_info
@staticmethod
def _get_system_language() -> str:
"""Gets system language compatible with server parameters.
@ -61,16 +63,27 @@ class Launcher:
lowercase_lang = lang.lower().replace("_", "-")
return lowercase_lang
except ValueError:
return "en-us"
return "en-us" # Fallback to English if locale is not supported
async def override_gamedir(self, gamedir: str) -> None:
async def _get_launcher_info(self, adv=True) -> launcher.Info:
params = self._params | {"filter_adv": str(adv).lower(),
"language": self._lang}
rsp = await self._get(self._api + "/content", params=params)
if rsp["data"]["adv"] is None:
params["language"] = "en-us"
rsp = await self._get(self._api + "/content", params=params)
lc_info = launcher.Info.from_dict(rsp["data"])
return lc_info
async def override_gamedir(self, gamedir: str | Path) -> None:
"""Overrides game directory with another directory.
Args:
gamedir (str): New directory to override with.
"""
self._gamedir = Path(gamedir).resolve()
if isinstance(gamedir, str):
gamedir = Path(gamedir).resolve()
self._gamedir = gamedir
async def override_language(self, language: str) -> None:
"""Overrides system detected language with another language.
@ -92,8 +105,7 @@ class Launcher:
aiohttp.ClientResponseError: An error occurred while fetching the information.
"""
rsp = await self._get(self._api + "/resource", params={"key": "gcStgarh",
"launcher_id": "10"})
rsp = await self._get(self._api + "/resource", params=self._params)
return rsp
async def get_launcher_info(self) -> launcher.Info:

View File

@ -1,31 +1,96 @@
import asyncio
import tarfile
import constants
import appdirs
import aiofiles
from pathlib import Path
import shutil
import aiohttp
class Patcher:
def __init__(self, gamedir=Path.cwd()):
def __init__(self, gamedir=Path.cwd(), data_dir: str | Path = None, patch_url: str = None):
self._gamedir = gamedir
self._patch_url = constants.PATCH_GIT_URL
self._patch_url = (patch_url if patch_url else constants.PATCH_GIT_URL).replace('http://', 'https://')
if not data_dir:
self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR)
self._patch_path = Path(self._appdirs.user_data_dir).joinpath("Patch")
self._temp_path = Path(self._appdirs.user_cache_dir)
else:
if not isinstance(data_dir, Path):
override_data_dir = Path(data_dir)
self._patch_path = data_dir.joinpath("Patch")
self._temp_path = data_dir.joinpath("Temp")
@staticmethod
async def _get(url, **kwargs) -> aiohttp.ClientResponse:
async with aiohttp.ClientSession() as session:
rsp = await session.get(url, **kwargs)
rsp.raise_for_status()
return rsp
async def _get_git_archive(self, archive_format="tar.gz", branch="master"):
"""
Get the git archive of the patch repository.
This supports Gitea API and also introduce workaround for https://notabug.org
:return: Archive file in bytes
"""
# Replace http with https
if self._patch_url.startswith('https://notabug.org'):
archive_url = self._patch_url + '/archive/master.{}'.format(archive_format)
return await (await self._get(archive_url)).read()
try:
url_split = self._patch_url.split('//')
git_server = url_split[0]
git_owner, git_repo = url_split[1].split('/')
archive_url = git_server + '/api/v1/repos/{}/{}/archive/{}.{}'.format(
git_owner, git_repo, branch, archive_format
)
archive = await self._get(archive_url)
except aiohttp.ClientResponseError:
pass
else:
return await archive.read()
return
async def _download_repo(self):
if shutil.which("git"):
if not self._patch_path.exists() or not self._patch_path.is_dir() \
or not self._patch_path.joinpath(".git").exists():
await asyncio.create_subprocess_exec("git", "clone", self._patch_url, str(self._patch_path))
else:
await asyncio.create_subprocess_exec("git", "pull", cwd=str(self._patch_path))
else:
archive = await self._get_git_archive()
if not archive:
raise RuntimeError("Cannot download patch repository")
with tarfile.open(archive) as tar:
tar.extractall(self._patch_path)
def override_patch_url(self, url) -> None:
"""
Override the patch url.
:param url: Patch repository url, the url must be a valid git repository.
:return: None
"""
self._patch_url = url
def download_patch(self) -> None:
async def download_patch(self) -> None:
"""
If `git` exists, this will clone the patch git url and save it to a temporary directory.
Else, this will download the patch from the patch url and save it to a temporary directory. (Not reliable)
:return: None
"""
pass
await self._download_repo()
def apply_patch(self, crash_fix=False) -> None:
"""
Patch the game (and optionally patch the login door crash fix if specified)
:param crash_fix: Whether to patch the login door crash fix or not
:return: None
"""
@ -34,6 +99,7 @@ class Patcher:
def revert_patch(self):
"""
Revert the patch (and revert the login door crash fix if patched)
:return: None
"""
pass