From 525fde83be2e1df63078bfd190d07a6ab460578a Mon Sep 17 00:00:00 2001 From: Lixfel Date: Wed, 13 Apr 2022 22:16:25 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + launcher.py | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 .gitignore create mode 100755 launcher.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a09c56d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea diff --git a/launcher.py b/launcher.py new file mode 100755 index 0000000..16bd6fa --- /dev/null +++ b/launcher.py @@ -0,0 +1,318 @@ +#!/bin/env python3 +import sys +from typing import Optional + +import requests +import json +import zipfile +import subprocess +from pathlib import Path + + +os_name = 'linux' +os_arch = 'x86' +base_path = Path.home() / Path('.minecraft') +jvm_params = ['-Xmx2G', '-Xshareclasses:nonfatal,name=minecraft', '-Xsyslog:none', '-Xtrace:none', '-Xdisableexplicitgc', '-XX:+AlwaysPreTouch', '-XX:+CompactStrings'] + + +account_path = base_path / 'account.json' +versions_path = base_path / 'versions' +version_manifest_path = versions_path / 'version_manifest_v2.json' +assets_path = base_path / 'assets' +asset_indexes_path = assets_path / 'indexes' +asset_objects_path = assets_path / 'objects' +asset_log_path = assets_path / 'log_configs' +libraries_path = base_path / 'libraries' +native_base_path = base_path / 'bin' + + +def load_json(file: Path) -> dict: + with file.open('r') as f: + return json.load(f) + + +def download_to_file(url: str, file: Path): + print(f"Downloading {file.name}") + r = requests.get(url, stream=True) + file.parent.mkdir(parents=True, exist_ok=True) + with file.open('wb') as f: + for chunk in r.iter_content(chunk_size=4096): + if chunk: + f.write(chunk) + + +def get_version_manifest() -> dict: + if not version_manifest_path.exists(): + download_to_file('https://launchermeta.mojang.com/mc/game/version_manifest.json', version_manifest_path) + return load_json(version_manifest_path) + + +def get_version_description(version: str, version_manifest: dict) -> dict: + version_description_path = versions_path / version / (version + '.json') + if not version_description_path.exists(): + version_urls = [version_info['url'] for version_info in version_manifest['versions'] if version == version_info['id']] + if not version_urls: + version_manifest_path.unlink() + version_manifest = get_version_manifest() + version_urls = [version_info['url'] for version_info in version_manifest['versions'] if version == version_info['id']] + download_to_file(version_urls[0], version_description_path) + + return load_json(version_description_path) + + +def load_assets(version_description: dict): + if 'assets' not in version_description: + return + + asset_index = asset_indexes_path / (version_description['assets'] + '.json') + if not asset_index.exists(): + download_to_file(version_description['assetIndex']['url'], asset_index) + for asset in load_json(asset_index)['objects'].values(): + hash = asset['hash'] + asset_path = asset_objects_path / hash[:2] / hash + if not asset_path.exists(): + download_to_file(f'https://resources.download.minecraft.net/{hash[:2]}/{hash}', asset_path) + + +def load_logging(version_description: dict) -> list[str]: + if 'logging' not in version_description: + return [] + + logging_path = asset_log_path / version_description['logging']['client']['file']['id'] + if not logging_path.exists(): + download_to_file(version_description['logging']['client']['file']['url'], logging_path) + return [version_description['logging']['client']['argument'].replace('${path}', str(logging_path.absolute()))] + + +def rules_applying(object: dict): + if 'rules' not in object: + return True + + def rule_applying(rule): + if 'os' in rule: + if 'name' in rule['os']: + return rule['os']['name'] == os_name + elif 'arch' in rule['os']: + return rule['os']['arch'] == os_arch + else: + raise Exception(rule['os']) + elif 'features' in rule: + if 'is_demo_user' in rule['features']: + return not rule['features']['is_demo_user'] + elif 'has_custom_resolution' in rule['features']: + return rule['features']['has_custom_resolution'] + else: + raise Exception(rule['features']) + return True + + allowed = False + for rule in object['rules']: + if rule_applying(rule): + action = rule['action'] + if action == 'allow': + allowed = True + elif action == 'disallow': + return False + else: + raise Exception(action) + return allowed + + +def load_libraries(version_description: dict, native_libraries_path: Path) -> list[Path]: + libraries = [] + if 'libraries' not in version_description: + return libraries + + for library in version_description['libraries']: + if not rules_applying(library): + continue + + if 'downloads' in library: + library_path = libraries_path / library['downloads']['artifact']['path'] + else: + package, name, version = library['name'].split(':') + path = f'{package.replace(".", "/")}/{name}/{version}/{name}-{version}.jar' + library_path = libraries_path / path + + libraries.append(library_path) + if not library_path.exists(): + if 'downloads' in library: + download_to_file(library['downloads']['artifact']['url'], library_path) + else: + download_to_file(library["url"] + path, library_path) + + if 'natives' in library and os_name in library['natives']: + native_id = library['natives'][os_name] + native_path = libraries_path / library['downloads']['classifiers'][native_id]['path'] + if not native_path.exists(): + download_to_file(library['downloads']['classifiers'][native_id]['url'], native_path) + + zip = zipfile.ZipFile(str(native_path)) + for info in zip.infolist(): + if info.is_dir(): + continue + extracted_path = native_libraries_path / info.filename + if not extracted_path.exists(): + extracted_path.parent.mkdir(parents=True, exist_ok=True) + with extracted_path.open('wb') as f: + f.write(zip.read(info.filename)) + + return libraries + + +def backup_dict(key: str, primary_dict: dict, backup_dict: dict): + return primary_dict[key] if key in primary_dict else backup_dict[key] + + +def parse_args(arg, version_description: dict, inherited_description: dict, native_libraries_path: Path, classpath: str, account: dict) -> list[str]: + if type(arg) is str: + return [arg.replace( + '${natives_directory}', str(native_libraries_path.absolute()) + ).replace( + '${launcher_name}', 'lixfel-launcher' + ).replace( + '${launcher_version}', '1.0' + ).replace( + '${classpath}', classpath + ).replace( + '${auth_player_name}', account['username'] + ).replace( + '${version_name}', backup_dict('id', version_description, inherited_description) + ).replace( + '${game_directory}', str(base_path.absolute()) + ).replace( + '${assets_root}', str(assets_path.absolute()) + ).replace( + '${assets_index_name}', backup_dict('assets', version_description, inherited_description) + ).replace( + '${auth_uuid}', account['uuid'] + ).replace( + '${auth_access_token}', account['accessToken'] + ).replace( + '${clientid}', account['clientToken'] + ).replace( + '${auth_xuid}', 'null' + ).replace( + '${user_type}', 'mojang' + ).replace( + '${version_type}', backup_dict('type', version_description, inherited_description) + ).replace( + '${resolution_width}', str(1280) + ).replace( + '${resolution_height}', str(720) + )] + elif type(arg) is list: + return [v for subargs in arg for v in parse_args(subargs, version_description, inherited_description, native_libraries_path, classpath, account)] + elif type(arg) is dict: + if not rules_applying(arg): + return [] + return parse_args(arg['value'], version_description, inherited_description, native_libraries_path, classpath, account) + raise Exception(arg) + + +def yggdrasil_request(url: str, payload: dict) -> Optional[dict]: + r = requests.post(f'https://authserver.mojang.com/{url}', json=payload, headers={'Content-Type': 'application/json'}) + if len(r.content) == 0 and r.status_code // 100 == 2: + return None + result = json.loads(r.content.decode('utf-8')) + if r.status_code // 100 != 2: + raise Exception(result) + return result + + +def new_account_file(email: str, password: str, authenticate: dict) -> dict: + account = { + 'email': email, + 'password': password, + 'accessToken': authenticate['accessToken'], + 'clientToken': authenticate['clientToken'], + 'uuid': authenticate['selectedProfile']['id'], + 'username': authenticate['selectedProfile']['name'] + } + with account_path.open('w') as f: + json.dump(account, f) + return account + + +def authenticate_mojang() -> dict: + if account_path.exists(): + account = load_json(account_path) + + try: + yggdrasil_request('validate', {'accessToken': account['accessToken'], 'clientToken': account['clientToken']}) + return account + except: + print('Validation insufficient, trying refresh') + + email = account['email'] + password = account['password'] + + try: + refresh = yggdrasil_request('refresh', {'accessToken': account['accessToken'], 'clientToken': account['clientToken']}) + return new_account_file(email, password, refresh) + except: + print('Refresh insufficient, trying authentication') + else: + import tkinter.simpledialog + email = tkinter.simpledialog.askstring(title='E-Mail', prompt='Mojang-Account E-Mail') + password = tkinter.simpledialog.askstring(title='Password', prompt='Mojang-Account Password') + + try: + authenticate = yggdrasil_request('authenticate', {'agent': {'name': 'Minecraft', 'version': 1}, 'username': email, 'password': password}) + except Exception as e: + import tkinter.messagebox + tkinter.messagebox.showerror(title='Could not authenticate', message='Could not authenticate') + raise e + return new_account_file(email, password, authenticate) + + +def main(): + version_manifest = get_version_manifest() + version = version_manifest['latest']['release'] if len(sys.argv) <= 1 else sys.argv[1] + + account = authenticate_mojang() + + version_description = get_version_description(version, version_manifest) + inherited_description = get_version_description(version_description['inheritsFrom'], + version_manifest) if 'inheritsFrom' in version_description else None + + load_assets(version_description) + if inherited_description: + load_assets(inherited_description) + + native_libraries_path = native_base_path / version + libraries = load_libraries(version_description, native_libraries_path) + if inherited_description: + libraries += load_libraries(inherited_description, native_libraries_path) + + client_path = versions_path / version / (version + '.jar') + libraries.append(client_path) + if not client_path.exists(): + download_to_file(version_description['downloads']['client']['url'], client_path) + + classpath = ':'.join(str(library.absolute()) for library in libraries) + + args = ['java'] + + if inherited_description: + args += parse_args(inherited_description['arguments']['jvm'], version_description, inherited_description, native_libraries_path, classpath, account) + if 'jvm' in version_description['arguments']: + args += parse_args(version_description['arguments']['jvm'], version_description, inherited_description, native_libraries_path, classpath, account) + + args += load_logging(version_description) + if inherited_description: + args += load_logging(inherited_description) + + args += jvm_params + args.append(version_description['mainClass']) + + if 'game' in version_description['arguments']: + args += parse_args(version_description['arguments']['game'], version_description, inherited_description, native_libraries_path, classpath, account) + if inherited_description: + args += parse_args(inherited_description['arguments']['game'], version_description, inherited_description, native_libraries_path, classpath, account) + + subprocess.Popen(args, cwd=str(base_path.absolute()), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +if __name__ == '__main__': + main()