#!/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()