Support chinese variant, QQ object in launcher and some code optimizations
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
from worthless import gui
|
||||
import gui as gui
|
||||
|
||||
if __name__ == '__main__':
|
||||
gui.main()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
35
worthless/classes/launcher/qq.py
Normal file
35
worthless/classes/launcher/qq.py
Normal 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)
|
||||
8
worthless/classes/mhyresponse.py
Normal file
8
worthless/classes/mhyresponse.py
Normal 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
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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
33
worthless/installer.py
Normal 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")
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user