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

This commit is contained in:
tretrauit 2022-02-16 00:49:33 +07:00
parent 048a7ac9d0
commit c22918673b
Signed by: tretrauit
GPG Key ID: 862760FF1903319E
19 changed files with 389 additions and 58 deletions

80
.gitignore vendored
View File

@ -152,3 +152,83 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
_trial_temp/
_trail_temp.lock

3
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2022 tretrauit
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -1,3 +1,3 @@
# worthless-launcher
A worthless CLI launcher.
A worthless CLI launcher written in Python.

View File

@ -1 +1,3 @@
aiohttp==3.8.1
appdirs~=1.4.4
aiofiles~=0.8.0

View File

@ -0,0 +1,38 @@
import unittest
import asyncio
import worthless
from worthless.classes import launcher
client = worthless.Launcher(overseas=False)
class LauncherCNTest(unittest.TestCase):
def test_get_version_info(self):
version_info = asyncio.run(client.get_version_info())
print("get_version_info test.")
print("get_version_info: ", version_info)
self.assertIsInstance(version_info, dict)
def test_get_launcher_info(self):
launcher_info = asyncio.run(client.get_launcher_info())
print("get_launcher_info test.")
print("get_launcher_info: ", launcher_info)
print("raw: ", launcher_info.raw)
self.assertIsInstance(launcher_info, launcher.Info)
def test_get_launcher_full_info(self):
launcher_info = asyncio.run(client.get_launcher_full_info())
print("get_launcher_full_info test.")
print("get_launcher_full_info: ", launcher_info)
print("raw: ", launcher_info.raw)
self.assertIsInstance(launcher_info, launcher.Info)
def test_get_launcher_background_url(self):
bg_url = asyncio.run(client.get_launcher_background_url())
print("get_launcher_background_url test.")
print("get_launcher_background_url: ", bg_url)
self.assertIsInstance(bg_url, str)
self.assertTrue(bg_url)
if __name__ == '__main__':
unittest.main()

View File

@ -5,7 +5,7 @@ from worthless.classes import launcher
client = worthless.Launcher()
class LauncherTest(unittest.TestCase):
class LauncherOverseasTest(unittest.TestCase):
def test_get_version_info(self):
version_info = asyncio.run(client.get_version_info())
print("get_version_info test.")
@ -16,12 +16,14 @@ class LauncherTest(unittest.TestCase):
launcher_info = asyncio.run(client.get_launcher_info())
print("get_launcher_info test.")
print("get_launcher_info: ", launcher_info)
print("raw: ", launcher_info.raw)
self.assertIsInstance(launcher_info, launcher.Info)
def test_get_launcher_full_info(self):
launcher_info = asyncio.run(client.get_launcher_full_info())
print("get_launcher_full_info test.")
print("get_launcher_full_info: ", launcher_info)
print("raw: ", launcher_info.raw)
self.assertIsInstance(launcher_info, launcher.Info)
def test_get_launcher_background_url(self):

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