├── .python-version ├── .git-blame-ignore-revs ├── .vscode ├── extensions.json └── settings.json ├── src └── picomc │ ├── java │ ├── SysDump.class │ └── __init__.py │ ├── __main__.py │ ├── mod │ ├── __init__.py │ ├── fabric.py │ ├── ftb.py │ ├── curse.py │ └── forge.py │ ├── errors.py │ ├── logging.py │ ├── __init__.py │ ├── cli │ ├── __init__.py │ ├── utils.py │ ├── mod.py │ ├── config.py │ ├── play.py │ ├── main.py │ ├── version.py │ ├── account.py │ └── instance.py │ ├── osinfo.py │ ├── rules.py │ ├── utils.py │ ├── yggdrasil.py │ ├── windows.py │ ├── launcher.py │ ├── library.py │ ├── config.py │ ├── downloader.py │ ├── msapi.py │ ├── account.py │ ├── instance.py │ └── version.py ├── .gitignore ├── requirements.lock ├── requirements-dev.lock ├── pyproject.toml ├── LICENSE └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.12.2 2 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # change project strucutre 2 | 16da1dc0d39f3390f2abeb6213a50ebb4124b77d 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff" 4 | ] 5 | } -------------------------------------------------------------------------------- /src/picomc/java/SysDump.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sammko/picomc/HEAD/src/picomc/java/SysDump.class -------------------------------------------------------------------------------- /src/picomc/__main__.py: -------------------------------------------------------------------------------- 1 | #!/bin/python 2 | 3 | if __name__ == "__main__": 4 | from picomc import main 5 | 6 | main() 7 | -------------------------------------------------------------------------------- /src/picomc/mod/__init__.py: -------------------------------------------------------------------------------- 1 | from . import curse, fabric, forge, ftb 2 | 3 | LOADERS = [fabric, forge] 4 | PACKS = [curse, ftb] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # venv 10 | .venv -------------------------------------------------------------------------------- /src/picomc/errors.py: -------------------------------------------------------------------------------- 1 | class PicomcError(Exception): 2 | pass 3 | 4 | 5 | class AuthenticationError(PicomcError): 6 | pass 7 | 8 | 9 | class RefreshError(PicomcError): 10 | pass 11 | 12 | 13 | class ValidationError(PicomcError): 14 | pass 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "explicit", 6 | "source.organizeImports": "explicit" 7 | }, 8 | "editor.defaultFormatter": "charliermarsh.ruff" 9 | } 10 | } -------------------------------------------------------------------------------- /src/picomc/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import coloredlogs 4 | 5 | logger = logging.getLogger("picomc.cli") 6 | debug = False 7 | 8 | 9 | def initialize(debug_): 10 | global debug 11 | debug = debug_ 12 | coloredlogs.install( 13 | level="DEBUG" if debug else "INFO", 14 | fmt="%(levelname)s %(message)s", 15 | logger=logger, 16 | ) 17 | -------------------------------------------------------------------------------- /src/picomc/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from picomc.cli import picomc_cli 4 | 5 | MINPYVERSION = (3, 7, 0) 6 | 7 | if sys.version_info < MINPYVERSION: 8 | print( 9 | "picomc requires at least Python version " 10 | "{}.{}.{}. You are using {}.{}.{}.".format(*MINPYVERSION, *sys.version_info) 11 | ) 12 | sys.exit(1) 13 | 14 | 15 | def main(): 16 | picomc_cli() 17 | -------------------------------------------------------------------------------- /src/picomc/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import register_account_cli 2 | from .config import register_config_cli 3 | from .instance import register_instance_cli 4 | from .main import picomc_cli 5 | from .mod import register_mod_cli 6 | from .play import register_play_cli 7 | from .version import register_version_cli 8 | 9 | register_account_cli(picomc_cli) 10 | register_version_cli(picomc_cli) 11 | register_instance_cli(picomc_cli) 12 | register_config_cli(picomc_cli) 13 | register_mod_cli(picomc_cli) 14 | register_play_cli(picomc_cli) 15 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | 10 | -e file:. 11 | certifi==2024.2.2 12 | # via requests 13 | charset-normalizer==3.3.2 14 | # via requests 15 | click==8.1.7 16 | # via picomc 17 | colorama==0.4.6 18 | # via picomc 19 | coloredlogs==15.0.1 20 | # via picomc 21 | humanfriendly==10.0 22 | # via coloredlogs 23 | idna==3.7 24 | # via requests 25 | requests==2.31.0 26 | # via picomc 27 | tqdm==4.66.2 28 | # via picomc 29 | urllib3==2.2.1 30 | # via requests 31 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | 10 | -e file:. 11 | certifi==2024.2.2 12 | # via requests 13 | charset-normalizer==3.3.2 14 | # via requests 15 | click==8.1.7 16 | # via picomc 17 | colorama==0.4.6 18 | # via picomc 19 | coloredlogs==15.0.1 20 | # via picomc 21 | humanfriendly==10.0 22 | # via coloredlogs 23 | idna==3.7 24 | # via requests 25 | requests==2.31.0 26 | # via picomc 27 | tqdm==4.66.2 28 | # via picomc 29 | urllib3==2.2.1 30 | # via requests 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "picomc" 3 | version = "0.5.1" 4 | description = "A very small CLI Minecraft launcher." 5 | authors = [ 6 | { name = "Samuel Čavoj", email = "samuel@cavoj.net" } 7 | ] 8 | dependencies = [ 9 | "click~=8.1", 10 | "coloredlogs~=15.0", 11 | "colorama~=0.4", 12 | "requests~=2.31", 13 | "tqdm~=4.66", 14 | ] 15 | readme = "README.md" 16 | requires-python = ">= 3.8" 17 | 18 | [project.scripts] 19 | picomc = "picomc:main" 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | 25 | [tool.rye] 26 | managed = true 27 | dev-dependencies = [] 28 | 29 | [tool.hatch.metadata] 30 | allow-direct-references = true 31 | 32 | [tool.hatch.build.targets.wheel] 33 | packages = ["src/picomc"] 34 | 35 | [tool.ruff.lint] 36 | extend-select = ["I", "W"] -------------------------------------------------------------------------------- /src/picomc/cli/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import click 4 | 5 | from picomc.launcher import Launcher 6 | 7 | pass_launcher = click.make_pass_decorator(Launcher) 8 | 9 | 10 | def pass_launcher_attrib(attr): 11 | def decorator(fn): 12 | @pass_launcher 13 | @functools.wraps(fn) 14 | def wrapper(launcher, *a, **kwa): 15 | x = getattr(launcher, attr) 16 | fn(x, *a, **kwa) 17 | 18 | return wrapper 19 | 20 | return decorator 21 | 22 | 23 | pass_config_manager = pass_launcher_attrib("config_manager") 24 | pass_account_manager = pass_launcher_attrib("account_manager") 25 | pass_version_manager = pass_launcher_attrib("version_manager") 26 | pass_instance_manager = pass_launcher_attrib("instance_manager") 27 | pass_global_config = pass_launcher_attrib("global_config") 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017-2024 Samuel Čavoj 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/picomc/osinfo.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | 5 | class OsInfo: 6 | platform: str 7 | arch: str 8 | 9 | def __init__(self): 10 | self.platform = self.get_platform() 11 | self.arch = self.get_arch() 12 | 13 | @staticmethod 14 | def get_platform(): 15 | return {"darwin": "osx", "win32": "windows"}.get(sys.platform, sys.platform) 16 | 17 | @staticmethod 18 | def get_arch(): 19 | mach = platform.machine().lower() 20 | if mach == "amd64": # Windows 64-bit 21 | return "x86_64" 22 | elif mach in ("i386", "i486", "i586", "i686"): # Linux 32-bit 23 | return "x86" 24 | elif mach == "aarch64": # Linux 25 | return "arm64" 26 | else: 27 | # Windows 32-bit (x86) and Linux 64-bit (x86_64) return the expected 28 | # values by default. Unsupported architectures are left untouched. 29 | return mach 30 | 31 | @staticmethod 32 | def get_os_version(java_info): 33 | if not java_info: 34 | return None, None 35 | version = java_info.get("os.version") 36 | return version 37 | 38 | 39 | osinfo = OsInfo() 40 | -------------------------------------------------------------------------------- /src/picomc/cli/mod.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from picomc import mod 4 | 5 | 6 | @click.group() 7 | def mod_cli(): 8 | """Helpers to install modded Minecraft.""" 9 | pass 10 | 11 | 12 | def list_loaders(ctx, param, value): 13 | if not value or ctx.resilient_parsing: 14 | return 15 | for loader in mod.LOADERS: 16 | print(loader._loader_name) 17 | ctx.exit() 18 | 19 | 20 | @mod_cli.group("loader") 21 | @click.option( 22 | "--list", 23 | "-l", 24 | is_eager=True, 25 | is_flag=True, 26 | expose_value=False, 27 | callback=list_loaders, 28 | help="List available mod loaders", 29 | ) 30 | def loader_cli(): 31 | """Manage mod loaders. 32 | 33 | Loaders are customized Minecraft 34 | versions which can load other mods, e.g. Forge or Fabric. 35 | Installing a loader creates a new version which can be used by instances.""" 36 | pass 37 | 38 | 39 | for loader in mod.LOADERS: 40 | loader.register_cli(loader_cli) 41 | 42 | 43 | @mod_cli.group("pack") 44 | def pack_cli(): 45 | """Install mod packs.""" 46 | pass 47 | 48 | 49 | for pack in mod.PACKS: 50 | pack.register_cli(pack_cli) 51 | 52 | 53 | def register_mod_cli(picomc_cli): 54 | picomc_cli.add_command(mod_cli, name="mod") 55 | -------------------------------------------------------------------------------- /src/picomc/cli/config.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from picomc.cli.utils import pass_global_config 4 | 5 | 6 | @click.group() 7 | def config_cli(): 8 | """Configure picomc.""" 9 | pass 10 | 11 | 12 | @config_cli.command() 13 | @pass_global_config 14 | def show(cfg): 15 | """Print the current config.""" 16 | 17 | for k, v in cfg.bottom.items(): 18 | if k not in cfg: 19 | print("[default] {}: {}".format(k, v)) 20 | for k, v in cfg.items(): 21 | print("{}: {}".format(k, v)) 22 | 23 | 24 | @config_cli.command("set") 25 | @click.argument("key") 26 | @click.argument("value") 27 | @pass_global_config 28 | def _set(cfg, key, value): 29 | """Set a global config value.""" 30 | cfg[key] = value 31 | 32 | 33 | @config_cli.command() 34 | @click.argument("key") 35 | @pass_global_config 36 | def get(cfg, key): 37 | """Print a global config value.""" 38 | try: 39 | print(cfg[key]) 40 | except KeyError: 41 | print("No such item.") 42 | 43 | 44 | @config_cli.command() 45 | @click.argument("key") 46 | @pass_global_config 47 | def delete(cfg, key): 48 | """Delete a key from the global config.""" 49 | try: 50 | del cfg[key] 51 | except KeyError: 52 | print("No such item.") 53 | 54 | 55 | def register_config_cli(picomc_cli): 56 | picomc_cli.add_command(config_cli, name="config") 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | picomc 2 | ==== 3 | 4 | `picomc` is a cross-platform command-line Minecraft launcher. It supports 5 | all(?) officialy available Minecraft versions, account switching and 6 | multiple separate instances of the game. The on-disk launcher file 7 | structure mimics the vanilla launcher and as such most mod installers 8 | (such as forge, fabric or optifine) should work with picomc just fine, 9 | though you will have to change the installation path. 10 | Don't hesitate to report any problems you run into. 11 | 12 | Installation 13 | --- 14 | 15 | The easiest and most portable way to install picomc is using pip, from the 16 | Python Package Index (PyPI): 17 | 18 | ``` 19 | pip install picomc 20 | ``` 21 | 22 | Depending on your configuration, you may either have to run this command 23 | with elevated privileges (using e.g. `sudo`) or add the `--user` flag like this: 24 | 25 | ``` 26 | pip install --user picomc 27 | ``` 28 | 29 | Usage 30 | --- 31 | 32 | The quickest way to get started is to run 33 | 34 | ``` 35 | picomc play 36 | ``` 37 | 38 | which, on the first launch, will ask you for your account details, 39 | create an instance named `default` using the latest version of Minecraft 40 | and launch it. 41 | 42 | Of course, more advanced features are available. Try running 43 | 44 | ``` 45 | picomc --help 46 | ``` 47 | 48 | and you should be able to figure it out. More detailed documentation 49 | may appear someday in the future. 50 | 51 | Development 52 | --- 53 | 54 | For project management and dependency tracking `picomc` uses 55 | [Rye](https://rye-up.com/). -------------------------------------------------------------------------------- /src/picomc/rules.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from picomc.logging import logger 4 | from picomc.osinfo import osinfo 5 | 6 | 7 | def match_rule(rule, java_info): 8 | # This launcher currently does not support any of the extended 9 | # features, which currently include at least: 10 | # - is_demo_user 11 | # - has_custom_resolution 12 | # It is not clear whether an `os` and `features` matcher may 13 | # be present simultaneously - assuming not. 14 | if "features" in rule: 15 | return False 16 | 17 | if "os" in rule: 18 | os_version = osinfo.get_os_version(java_info) 19 | 20 | osmatch = True 21 | if "name" in rule["os"]: 22 | osmatch = osmatch and rule["os"]["name"] == osinfo.platform 23 | if "arch" in rule["os"]: 24 | osmatch = osmatch and re.match(rule["os"]["arch"], osinfo.arch) 25 | if "version" in rule["os"]: 26 | osmatch = osmatch and re.match(rule["os"]["version"], os_version) 27 | return osmatch 28 | 29 | if len(rule) > 1: 30 | logger.warning("Not matching unknown rule {}".format(rule.keys())) 31 | return False 32 | 33 | return True 34 | 35 | 36 | def match_ruleset(ruleset, java_info): 37 | # An empty ruleset is satisfied, but if a ruleset only contains rules which 38 | # you don't match, it is not. 39 | if len(ruleset) == 0: 40 | return True 41 | sat = False 42 | for rule in ruleset: 43 | if match_rule(rule, java_info): 44 | sat = rule["action"] == "allow" 45 | return sat 46 | -------------------------------------------------------------------------------- /src/picomc/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import re 4 | import sys 5 | from enum import Enum, auto 6 | from functools import partial 7 | from pathlib import Path 8 | 9 | from picomc.logging import logger 10 | 11 | 12 | def join_classpath(*cp): 13 | return os.pathsep.join(map(str, cp)) 14 | 15 | 16 | def file_sha1(filename): 17 | h = hashlib.sha1() 18 | with open(filename, "rb", buffering=0) as f: 19 | for b in iter(partial(f.read, 128 * 1024), b""): 20 | h.update(b) 21 | return h.hexdigest() 22 | 23 | 24 | def die(mesg, code=1): 25 | logger.error(mesg) 26 | sys.exit(code) 27 | 28 | 29 | # https://github.com/django/django/blob/master/django/utils/text.py#L222 30 | def sanitize_name(s): 31 | s = str(s).strip().replace(" ", "_") 32 | return re.sub(r"(?u)[^-\w.]", "", s) 33 | 34 | 35 | def recur_files(path: Path): 36 | for dirpath, dirnames, filenames in os.walk(path): 37 | for f in filenames: 38 | yield Path(dirpath) / f 39 | 40 | 41 | class Directory(Enum): 42 | ASSETS = auto() 43 | ASSET_INDEXES = auto() 44 | ASSET_OBJECTS = auto() 45 | ASSET_VIRTUAL = auto() 46 | INSTANCES = auto() 47 | LIBRARIES = auto() 48 | VERSIONS = auto() 49 | 50 | 51 | class CachedProperty: 52 | def __init__(self, fn): 53 | self.fn = fn 54 | 55 | def __get__(self, obj, cls): 56 | if obj is None: 57 | return self 58 | value = obj.__dict__[self.fn.__name__] = self.fn(obj) 59 | return value 60 | 61 | 62 | cached_property = CachedProperty 63 | -------------------------------------------------------------------------------- /src/picomc/cli/play.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | 3 | import click 4 | 5 | from picomc.account import AccountError, OfflineAccount, OnlineAccount 6 | from picomc.cli.utils import pass_account_manager, pass_instance_manager, pass_launcher 7 | 8 | 9 | @click.command() 10 | @click.argument("version", required=False) 11 | @click.option("-a", "--account", "account_name") 12 | @click.option("--verify", is_flag=True, default=False) 13 | @pass_instance_manager 14 | @pass_account_manager 15 | @pass_launcher 16 | def play(launcher, am, im, version, account_name, verify): 17 | """Play Minecraft without having to deal with stuff""" 18 | if account_name: 19 | account = am.get(account_name) 20 | else: 21 | try: 22 | account = am.get_default() 23 | except AccountError: 24 | username = input("Choose your account name:\n> ") 25 | email = input( 26 | "\nIf you have a mojang account with a Minecraft license,\n" 27 | "enter your email. Leave blank if you want to play offline:\n> " 28 | ) 29 | if email: 30 | account = OnlineAccount.new(am, username, email) 31 | else: 32 | account = OfflineAccount.new(am, username) 33 | am.add(account) 34 | if email: 35 | password = getpass.getpass("\nPassword:\n> ") 36 | account.authenticate(password) 37 | if not im.exists("default"): 38 | im.create("default", "latest") 39 | inst = im.get("default") 40 | inst.launch(account, version, verify_hashes=verify) 41 | 42 | 43 | def register_play_cli(picomc_cli): 44 | picomc_cli.add_command(play) 45 | -------------------------------------------------------------------------------- /src/picomc/cli/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | from pathlib import Path 4 | 5 | import click 6 | 7 | from picomc import logging 8 | from picomc.launcher import Launcher 9 | from picomc.logging import logger 10 | 11 | 12 | def print_version(printer): 13 | import importlib.metadata 14 | import platform 15 | 16 | try: 17 | version = importlib.metadata.version("picomc") 18 | except importlib.metadata.PackageNotFoundError: 19 | version = "?" 20 | 21 | printer("picomc, version {}".format(version)) 22 | printer("Python {}".format(platform.python_version())) 23 | 24 | 25 | def click_print_version(ctx, param, value): 26 | if not value or ctx.resilient_parsing: 27 | return 28 | 29 | print_version(click.echo) 30 | ctx.exit() 31 | 32 | 33 | @click.group() 34 | @click.option("--debug/--no-debug", default=None) 35 | @click.option("-r", "--root", help="Application data directory.", default=None) 36 | @click.option( 37 | "--version", 38 | is_flag=True, 39 | callback=click_print_version, 40 | expose_value=False, 41 | is_eager=True, 42 | ) 43 | @click.pass_context 44 | def picomc_cli(ctx: click.Context, debug, root): 45 | """picomc is a minimal CLI Minecraft launcher.""" 46 | logging.initialize(debug) 47 | 48 | if debug: 49 | print_version(logger.debug) 50 | 51 | final_root = os.getenv("PICOMC_ROOT") 52 | if root is not None: 53 | final_root = root 54 | 55 | if final_root is not None: 56 | final_root = Path(final_root).resolve() 57 | 58 | launcher_cm = Launcher.new(root=final_root, debug=debug) 59 | launcher = launcher_cm.__enter__() 60 | ctx.call_on_close(partial(launcher_cm.__exit__, None, None, None)) 61 | 62 | ctx.obj = launcher 63 | -------------------------------------------------------------------------------- /src/picomc/yggdrasil.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | import requests 4 | from requests.exceptions import RequestException 5 | 6 | from picomc.errors import AuthenticationError, RefreshError 7 | 8 | 9 | class MojangYggdrasil: 10 | BASE_URL = "https://authserver.mojang.com" 11 | 12 | def __init__(self, client_token): 13 | self.client_token = client_token 14 | 15 | def authenticate(self, username, password): 16 | ep = urljoin(self.BASE_URL, "/authenticate") 17 | 18 | try: 19 | resp = requests.post( 20 | ep, 21 | json={ 22 | "agent": {"name": "Minecraft", "version": 1}, 23 | "username": username, 24 | "password": password, 25 | "clientToken": self.client_token, 26 | "requestUser": True, 27 | }, 28 | ) 29 | j = resp.json() 30 | if not resp.ok and "errorMessage" in j: 31 | raise AuthenticationError("Server response: " + j["errorMessage"]) 32 | resp.raise_for_status() 33 | except RequestException as e: 34 | raise AuthenticationError(e) 35 | 36 | try: 37 | access_token = j["accessToken"] 38 | uuid = j["selectedProfile"]["id"] 39 | name = j["selectedProfile"]["name"] 40 | return (access_token, uuid, name) 41 | except KeyError as e: 42 | raise AuthenticationError("Missing field in response", e) 43 | 44 | def refresh(self, access_token): 45 | ep = urljoin(self.BASE_URL, "/refresh") 46 | try: 47 | resp = requests.post( 48 | ep, 49 | json={ 50 | "accessToken": access_token, 51 | "clientToken": self.client_token, 52 | "requestUser": True, 53 | }, 54 | ) 55 | j = resp.json() 56 | if not resp.ok and "errorMessage" in j: 57 | raise RefreshError(j["errorMessage"]) 58 | resp.raise_for_status() 59 | except RequestException as e: 60 | raise RefreshError("Failed to refresh", e) 61 | 62 | try: 63 | access_token = j["accessToken"] 64 | uuid = j["selectedProfile"]["id"] 65 | name = j["selectedProfile"]["name"] 66 | return (access_token, uuid, name) 67 | except KeyError: 68 | raise RefreshError("Missing field in response") 69 | 70 | def validate(self, access_token): 71 | ep = urljoin(self.BASE_URL, "/validate") 72 | resp = requests.post( 73 | ep, json={"accessToken": access_token, "clientToken": self.client_token} 74 | ) 75 | return resp.status_code == 204 76 | -------------------------------------------------------------------------------- /src/picomc/windows.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | from picomc.logging import logger 6 | 7 | 8 | def get_appdata_uwp(): 9 | """Use SHGetKnownFolderPath to get the real location of AppData after redirection 10 | for the current UWP python installation.""" 11 | import ctypes 12 | from ctypes import windll, wintypes 13 | from uuid import UUID 14 | 15 | # https://docs.microsoft.com/en-us/windows/win32/api/guiddef/ns-guiddef-guid 16 | class GUID(ctypes.Structure): 17 | _fields_ = [ 18 | ("Data1", wintypes.DWORD), 19 | ("Data2", wintypes.WORD), 20 | ("Data3", wintypes.WORD), 21 | ("Data4", wintypes.BYTE * 8), 22 | ] 23 | 24 | def __init__(self, uuidstr): 25 | uuid = UUID(uuidstr) 26 | ctypes.Structure.__init__(self) 27 | ( 28 | self.Data1, 29 | self.Data2, 30 | self.Data3, 31 | self.Data4[0], 32 | self.Data4[1], 33 | rest, 34 | ) = uuid.fields 35 | for i in range(2, 8): 36 | self.Data4[i] = rest >> (8 - i - 1) * 8 & 0xFF 37 | 38 | SHGetKnownFolderPath = windll.shell32.SHGetKnownFolderPath 39 | SHGetKnownFolderPath.argtypes = [ 40 | ctypes.POINTER(GUID), 41 | wintypes.DWORD, 42 | wintypes.HANDLE, 43 | ctypes.POINTER(ctypes.c_wchar_p), 44 | ] 45 | RoamingAppData = "{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}" 46 | KF_FLAG_RETURN_FILTER_REDIRECTION_TARGET = 0x00040000 47 | 48 | result = ctypes.c_wchar_p() 49 | guid = GUID(RoamingAppData) 50 | flags = KF_FLAG_RETURN_FILTER_REDIRECTION_TARGET 51 | if SHGetKnownFolderPath(ctypes.byref(guid), flags, 0, ctypes.byref(result)): 52 | raise ctypes.WinError() 53 | return result.value 54 | 55 | 56 | def get_appdata(): 57 | # If the used Python installation comes from the Microsoft Store, it is 58 | # subject to path redirection for the AppData folder. The paths then passed 59 | # to java are invalid. Instead figure out the real location on disk and use 60 | # that. Another option would be to use a completely different location 61 | # for all files, not sure which solution is better. 62 | # https://docs.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-behind-the-scenes 63 | 64 | # HACK: This check is relatively fragile 65 | if "WindowsApps\\PythonSoftwareFoundation" in sys.base_exec_prefix: 66 | logger.warning( 67 | "Detected Microsoft Store Python distribution. " 68 | "It is recommended to install Python using the official installer " 69 | "or a package manager like Chocolatey." 70 | ) 71 | appdata = get_appdata_uwp() 72 | logger.warning("Using redirected AppData directory: {}".format(appdata)) 73 | return Path(appdata) 74 | else: 75 | return Path(os.getenv("APPDATA")) 76 | -------------------------------------------------------------------------------- /src/picomc/cli/version.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import posixpath 4 | import urllib.parse 5 | 6 | import click 7 | 8 | from picomc.cli.utils import pass_version_manager 9 | from picomc.logging import logger 10 | from picomc.utils import die, file_sha1 11 | from picomc.version import VersionType 12 | 13 | 14 | def version_cmd(fn): 15 | @click.argument("version_name") 16 | @pass_version_manager 17 | @functools.wraps(fn) 18 | def inner(vm, *args, version_name, **kwargs): 19 | return fn(*args, version=vm.get_version(version_name), **kwargs) 20 | 21 | return inner 22 | 23 | 24 | @click.group() 25 | def version_cli(): 26 | """Manage Minecraft versions.""" 27 | pass 28 | 29 | 30 | @version_cli.command() 31 | @click.option("--release", is_flag=True, default=False) 32 | @click.option("--snapshot", is_flag=True, default=False) 33 | @click.option("--alpha", is_flag=True, default=False) 34 | @click.option("--beta", is_flag=True, default=False) 35 | @click.option("--local", is_flag=True, default=False) 36 | @click.option("--all", is_flag=True, default=False) 37 | @pass_version_manager 38 | def list(vm, release, snapshot, alpha, beta, local, all): 39 | """List available Minecraft versions.""" 40 | if all: 41 | release = snapshot = alpha = beta = local = True 42 | elif not (release or snapshot or alpha or beta): 43 | logger.info( 44 | "Showing only locally installed versions. " 45 | "Use `version list --help` to get more info." 46 | ) 47 | local = True 48 | T = VersionType.create(release, snapshot, alpha, beta) 49 | versions = vm.version_list(vtype=T, local=local) 50 | print("\n".join(versions)) 51 | 52 | 53 | @version_cli.command() 54 | @version_cmd 55 | @click.option("--verify", is_flag=True, default=False) 56 | def prepare(version, verify): 57 | """Download required files for the version.""" 58 | version.prepare(verify_hashes=verify) 59 | 60 | 61 | @version_cli.command() 62 | @version_cmd 63 | @click.argument("which", default="client") 64 | @click.option("--output", default=None) 65 | def jar(version, which, output): 66 | """Download the file and save.""" 67 | dlspec = version.vspec.downloads.get(which, None) 68 | if not dlspec: 69 | die("No such dlspec exists for version {}".format(version.version_name)) 70 | url = dlspec["url"] 71 | sha1 = dlspec["sha1"] 72 | ext = posixpath.basename(urllib.parse.urlsplit(url).path).split(".")[-1] 73 | if output is None: 74 | output = "{}_{}.{}".format(version.version_name, which, ext) 75 | if os.path.exists(output): 76 | die("Refusing to overwrite {}".format(output)) 77 | logger.info("Hash (sha1) should be {}".format(sha1)) 78 | logger.info("Downloading the {} file and saving to {}".format(which, output)) 79 | urllib.request.urlretrieve(dlspec["url"], output) 80 | if file_sha1(output) != sha1: 81 | logger.warning("Hash of downloaded file does not match") 82 | 83 | 84 | def register_version_cli(root_cli): 85 | root_cli.add_command(version_cli, "version") 86 | -------------------------------------------------------------------------------- /src/picomc/cli/account.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from picomc.account import ( 4 | AccountError, 5 | MicrosoftAccount, 6 | OfflineAccount, 7 | OnlineAccount, 8 | RefreshError, 9 | ) 10 | from picomc.cli.utils import pass_account_manager 11 | from picomc.logging import logger 12 | from picomc.yggdrasil import AuthenticationError 13 | 14 | 15 | def account_cmd(fn): 16 | return click.argument("account")(fn) 17 | 18 | 19 | @click.group() 20 | def account_cli(): 21 | """Manage your accounts.""" 22 | pass 23 | 24 | 25 | @account_cli.command("list") 26 | @pass_account_manager 27 | def _list(am): 28 | """List avaiable accounts.""" 29 | alist = am.list() 30 | if alist: 31 | lines = ("{}{}".format("* " if am.is_default(u) else " ", u) for u in alist) 32 | print("\n".join(lines)) 33 | else: 34 | logger.info("No accounts.") 35 | 36 | 37 | @account_cli.command() 38 | @account_cmd 39 | @click.argument("mojang_username", required=False) 40 | @click.option("--ms", "--microsoft", "microsoft", is_flag=True, default=False) 41 | @pass_account_manager 42 | def create(am, account, mojang_username, microsoft): 43 | """Create an account.""" 44 | try: 45 | if mojang_username: 46 | if microsoft: 47 | logger.error("Do not use --microsoft with mojang_username argument") 48 | return 49 | acc = OnlineAccount.new(am, account, mojang_username) 50 | elif microsoft: 51 | acc = MicrosoftAccount.new(am, account) 52 | else: 53 | acc = OfflineAccount.new(am, account) 54 | am.add(acc) 55 | except AccountError as e: 56 | logger.error("Could not create account: %s", e) 57 | 58 | 59 | @account_cli.command() 60 | @account_cmd 61 | @pass_account_manager 62 | def authenticate(am, account): 63 | """Retrieve access token from Mojang servers using password.""" 64 | 65 | try: 66 | a = am.get(account) 67 | except AccountError: 68 | logger.error("AccountError", exc_info=True) 69 | return 70 | 71 | try: 72 | if isinstance(a, OfflineAccount): 73 | logger.error("Offline accounts cannot be authenticated") 74 | elif isinstance(a, OnlineAccount): 75 | import getpass 76 | 77 | p = getpass.getpass("Password: ") 78 | a.authenticate(p) 79 | elif isinstance(a, MicrosoftAccount): 80 | a.authenticate() 81 | else: 82 | logger.error("Unknown account type") 83 | except AuthenticationError as e: 84 | logger.error("Authentication failed: %s", e) 85 | 86 | 87 | @account_cli.command() 88 | @account_cmd 89 | @pass_account_manager 90 | def refresh(am, account): 91 | """Refresh access token with Mojang servers.""" 92 | try: 93 | a = am.get(account) 94 | a.refresh() 95 | except (AccountError, RefreshError) as e: 96 | logger.error("Could not refresh account: %s", e) 97 | 98 | 99 | @account_cli.command() 100 | @account_cmd 101 | @pass_account_manager 102 | def remove(am, account): 103 | """Remove the account.""" 104 | try: 105 | am.remove(account) 106 | except AccountError as e: 107 | logger.error("Could not remove account: %s", e) 108 | 109 | 110 | @account_cli.command() 111 | @account_cmd 112 | @pass_account_manager 113 | def setdefault(am, account): 114 | """Set the account as default.""" 115 | try: 116 | default = am.get(account) 117 | am.set_default(default) 118 | except AccountError as e: 119 | logger.error("Could not set default account: %s", e) 120 | 121 | 122 | def register_account_cli(picomc_cli): 123 | picomc_cli.add_command(account_cli, name="account") 124 | -------------------------------------------------------------------------------- /src/picomc/java/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | from importlib import resources 5 | from tempfile import TemporaryDirectory 6 | 7 | from picomc.logging import logger 8 | from picomc.utils import die 9 | 10 | # 11 | # SysDump.class: 12 | # 13 | # import java.io.IOException; 14 | # 15 | # public class SysDump { 16 | # public static void main(String[] args) throws IOException { 17 | # System.getProperties().storeToXML(System.out, ""); 18 | # } 19 | # } 20 | # 21 | # Compiled with an antique version of java for widest compatibility. 22 | # Ideally we would distribute the source .java file and build it in the 23 | # picomc build process, but that would bring in a dependency for (old) java 24 | # and extra complexity. 25 | # 26 | 27 | 28 | def get_java_info(java): 29 | with TemporaryDirectory() as tmpdir: 30 | with resources.open_binary("picomc.java", "SysDump.class") as incf, open( 31 | os.path.join(tmpdir, "SysDump.class"), "wb" 32 | ) as outcf: 33 | shutil.copyfileobj(incf, outcf) 34 | ret = subprocess.run( 35 | [java, "-cp", ".", "SysDump"], cwd=tmpdir, capture_output=True 36 | ) 37 | from xml.etree import ElementTree 38 | 39 | xmlstr = ret.stdout.decode("utf8") 40 | props = ElementTree.fromstring(xmlstr) 41 | res = dict() 42 | for entry in props: 43 | if "entry" != entry.tag or "key" not in entry.attrib: 44 | continue 45 | res[entry.attrib["key"]] = entry.text 46 | return res 47 | 48 | 49 | def get_major_version(java_version): 50 | split = java_version.split(".") 51 | 52 | if len(split) == 1: 53 | # "12", "18-beta", "9-ea", "17-internal" 54 | first = split[0] 55 | elif split[0] == "1": 56 | # "1.8.0_201" 57 | first = split[1] 58 | else: 59 | # "17.0.1" 60 | first = split[0] 61 | 62 | return first.split("-")[0] 63 | 64 | 65 | def check_version_against(version: str, wanted): 66 | wanted_major = str(wanted["majorVersion"]) 67 | running_major = get_major_version(version) 68 | 69 | return wanted_major == running_major 70 | 71 | 72 | def wanted_to_str(wanted): 73 | component = wanted["component"] 74 | major = str(wanted["majorVersion"]) 75 | 76 | if component == "jre-legacy": 77 | return f"1.{major}.0" 78 | else: 79 | return str(major) 80 | 81 | 82 | def assert_java(java, wanted): 83 | try: 84 | jinfo = get_java_info(java) 85 | bitness = jinfo.get("sun.arch.data.model", None) 86 | if bitness and bitness != "64": 87 | logger.warning( 88 | "You are not using 64-bit java. Things will probably not work." 89 | ) 90 | 91 | logger.info( 92 | "Using java version: {} ({})".format( 93 | jinfo["java.version"], jinfo["java.vm.name"] 94 | ) 95 | ) 96 | 97 | if not check_version_against(jinfo["java.version"], wanted): 98 | logger.warning( 99 | "The version of Minecraft you are launching " 100 | "uses java {} by default.".format(wanted_to_str(wanted)) 101 | ) 102 | 103 | logger.warning( 104 | "You may experience issues, especially with older versions of Minecraft." 105 | ) 106 | 107 | major = get_major_version(jinfo["java.version"]) 108 | if int(major) < wanted["majorVersion"]: 109 | logger.error( 110 | "Note that at least java {} is required to launch at all.".format( 111 | wanted_to_str(wanted) 112 | ) 113 | ) 114 | 115 | return jinfo 116 | 117 | except FileNotFoundError: 118 | die( 119 | "Could not execute java at: {}. Have you installed it? Is it in yout PATH?".format( 120 | java 121 | ) 122 | ) 123 | -------------------------------------------------------------------------------- /src/picomc/launcher.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import ExitStack, contextmanager 3 | from pathlib import Path, PurePath 4 | 5 | from picomc.account import AccountManager 6 | from picomc.config import Config, ConfigManager 7 | from picomc.instance import InstanceManager 8 | from picomc.logging import logger 9 | from picomc.utils import Directory, cached_property 10 | from picomc.version import VersionManager 11 | from picomc.windows import get_appdata 12 | 13 | 14 | def get_default_root(): 15 | logger.debug("Resolving default application root") 16 | platforms = { 17 | "linux": lambda: Path("~/.local/share/picomc").expanduser(), 18 | "win32": lambda: get_appdata() / ".picomc", 19 | "darwin": lambda: Path("~/Library/Application Support/picomc").expanduser(), 20 | } 21 | if sys.platform in platforms: 22 | return platforms[sys.platform]() 23 | else: 24 | # This is probably better than nothing and should be fine on most 25 | # widely-used platforms other than the supported ones. Too bad in 26 | # case of something exotic. Minecraft doesn't run on those anyway. 27 | return Path("~/.picomc").expanduser() 28 | 29 | 30 | DIRECTORY_MAP = { 31 | Directory.ASSETS: PurePath("assets"), 32 | Directory.ASSET_INDEXES: PurePath("assets", "indexes"), 33 | Directory.ASSET_OBJECTS: PurePath("assets", "objects"), 34 | Directory.ASSET_VIRTUAL: PurePath("assets", "virtual"), 35 | Directory.INSTANCES: PurePath("instances"), 36 | Directory.LIBRARIES: PurePath("libraries"), 37 | Directory.VERSIONS: PurePath("versions"), 38 | } 39 | 40 | 41 | class Launcher: 42 | root: Path 43 | exit_stack: ExitStack 44 | debug: bool 45 | 46 | @cached_property 47 | def config_manager(self) -> ConfigManager: 48 | return self.exit_stack.enter_context(ConfigManager(self.root)) 49 | 50 | @cached_property 51 | def account_manager(self) -> AccountManager: 52 | return AccountManager(self) 53 | 54 | @cached_property 55 | def version_manager(self) -> VersionManager: 56 | return VersionManager(self) 57 | 58 | @cached_property 59 | def instance_manager(self) -> InstanceManager: 60 | return InstanceManager(self) 61 | 62 | @cached_property 63 | def global_config(self) -> Config: 64 | return self.config_manager.global_config 65 | 66 | @classmethod 67 | @contextmanager 68 | def new(cls, *args, **kwargs): 69 | """Create a Launcher instance with the application root at the given 70 | location. This is a context manager and a Launcher instance is returned.""" 71 | with ExitStack() as es: 72 | yield cls(es, *args, **kwargs) 73 | 74 | def __init__(self, exit_stack: ExitStack, root: Path = None, debug=False): 75 | """Create a Launcher instance reusing an existing ExitStack.""" 76 | self.exit_stack = exit_stack 77 | self.debug = debug 78 | if root is None: 79 | root = get_default_root() 80 | self.root = root 81 | logger.debug("Using application directory: {}".format(self.root)) 82 | self.ensure_filesystem() 83 | 84 | def get_path(self, *pathsegments) -> Path: 85 | """Constructs a path relative to the Launcher root. `pathsegments` is 86 | specified similarly to `pathlib.PurePath`. Additionally, if the first 87 | element of `pathsegments` is a `picomc.utils.Directory`, it is resolved.""" 88 | it = iter(pathsegments) 89 | try: 90 | d = next(it) 91 | if isinstance(d, Directory): 92 | d = DIRECTORY_MAP[d] 93 | return self.root / d / PurePath(*it) 94 | except StopIteration: 95 | return self.root 96 | 97 | def write_profiles_dummy(self): 98 | """Writes a minimal launcher_profiles.json which is expected to exist 99 | by installers and tooling surrounding the vanilla launcher.""" 100 | # This file makes the forge installer happy. 101 | fname = self.get_path("launcher_profiles.json") 102 | with open(fname, "w") as fd: 103 | fd.write(r'{"profiles":{}}') 104 | 105 | def ensure_filesystem(self): 106 | """Create directory structure for the application.""" 107 | for d in DIRECTORY_MAP: 108 | path = self.get_path(d) 109 | try: 110 | path.mkdir(parents=True) 111 | except FileExistsError: 112 | pass 113 | else: 114 | logger.debug("Created dir: {}".format(path)) 115 | self.write_profiles_dummy() 116 | -------------------------------------------------------------------------------- /src/picomc/library.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | from dataclasses import dataclass 3 | from pathlib import Path, PurePosixPath 4 | from platform import architecture 5 | from string import Template 6 | from typing import Optional 7 | 8 | from picomc.logging import logger 9 | from picomc.osinfo import osinfo 10 | 11 | 12 | @dataclass 13 | class Artifact: 14 | url: Optional[str] 15 | path: PurePosixPath 16 | sha1: Optional[str] 17 | size: Optional[int] 18 | filename: str 19 | 20 | @classmethod 21 | def from_json(cls, obj): 22 | path = None 23 | if "path" in obj: 24 | path = PurePosixPath(obj["path"]) 25 | filename = None 26 | if path: 27 | filename = path.name 28 | return cls( 29 | url=obj.get("url", None), 30 | path=path, 31 | sha1=obj["sha1"], 32 | size=obj["size"], 33 | filename=filename, 34 | ) 35 | 36 | @classmethod 37 | def make(cls, descriptor): 38 | descriptor, *ext = descriptor.split("@") 39 | ext = ext[0] if ext else "jar" 40 | group, art_id, version, *class_ = descriptor.split(":") 41 | classifier = None 42 | if class_: 43 | classifier = class_[0] 44 | group = group.replace(".", "/") 45 | v2 = "-".join([version] + ([classifier] if classifier else [])) 46 | 47 | filename = f"{art_id}-{v2}.{ext}" 48 | path = PurePosixPath(group) / art_id / version / filename 49 | 50 | return cls(url=None, path=path, sha1=None, size=None, filename=filename) 51 | 52 | def get_localpath(self, base): 53 | return Path(base) / self.path 54 | 55 | 56 | class Library: 57 | MOJANG_BASE_URL = "https://libraries.minecraft.net/" 58 | 59 | def __init__(self, json_lib): 60 | self.json_lib = json_lib 61 | self._populate() 62 | 63 | def _populate(self): 64 | js = self.json_lib 65 | self.descriptor = js["name"] 66 | self.is_native = "natives" in js 67 | self.is_classpath = not (self.is_native or js.get("presenceOnly", False)) 68 | self.base_url = js.get("url", Library.MOJANG_BASE_URL) 69 | 70 | self.available = True 71 | 72 | self.native_classifier = None 73 | if self.is_native: 74 | try: 75 | classifier_tmpl = self.json_lib["natives"][osinfo.platform] 76 | arch = architecture()[0][:2] 77 | self.native_classifier = Template(classifier_tmpl).substitute(arch=arch) 78 | self.descriptor = self.descriptor + ":" + self.native_classifier 79 | except KeyError: 80 | logger.warning( 81 | f"Native {self.descriptor} is not available " 82 | f"for current platform {osinfo.platform}." 83 | ) 84 | self.available = False 85 | return 86 | 87 | self.virt_artifact = Artifact.make(self.descriptor) 88 | self.virt_artifact.url = urllib.parse.urljoin( 89 | self.base_url, self.virt_artifact.path.as_posix() 90 | ) 91 | self.artifact = self.resolve_artifact() 92 | 93 | # Just use filename and path derived from the name. 94 | self.filename = self.virt_artifact.filename 95 | self.path = self.virt_artifact.path 96 | 97 | if self.artifact: 98 | final_art = self.artifact 99 | 100 | # Sanity check 101 | if self.artifact.path is not None: 102 | assert self.virt_artifact.path == self.artifact.path 103 | else: 104 | final_art = self.virt_artifact 105 | 106 | self.url = final_art.url 107 | self.sha1 = final_art.sha1 108 | self.size = final_art.size 109 | 110 | def resolve_artifact(self): 111 | if self.is_native: 112 | if self.native_classifier is None: 113 | # Native not available for current platform 114 | return None 115 | else: 116 | try: 117 | art = self.json_lib["downloads"]["classifiers"][ 118 | self.native_classifier 119 | ] 120 | return Artifact.from_json(art) 121 | except KeyError: 122 | return None 123 | else: 124 | try: 125 | return Artifact.from_json(self.json_lib["downloads"]["artifact"]) 126 | except KeyError: 127 | return None 128 | 129 | def get_abspath(self, library_root): 130 | return self.virt_artifact.get_localpath(library_root) 131 | -------------------------------------------------------------------------------- /src/picomc/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from contextlib import AbstractContextManager 4 | 5 | from picomc.logging import logger 6 | from picomc.utils import cached_property 7 | 8 | 9 | def get_default_java(): 10 | java_home = os.getenv("JAVA_HOME") 11 | if java_home is not None: 12 | candidates = ["java", "java.exe"] 13 | for candidate in candidates: 14 | path = os.path.join(java_home, "bin", candidate) 15 | if os.path.isfile(path): 16 | logger.debug("Detected JAVA_HOME, using as default") 17 | return path 18 | return "java" 19 | 20 | 21 | def get_default_config(): 22 | return { 23 | "java.path": get_default_java(), 24 | "java.memory.min": "512M", 25 | "java.memory.max": "2G", 26 | "java.jvmargs": "-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M", 27 | } 28 | 29 | 30 | class ConfigManager(AbstractContextManager): 31 | def __init__(self, root): 32 | self.configs = dict() 33 | self.root = root 34 | 35 | @cached_property 36 | def global_config(self): 37 | return self.get("config.json", bottom=get_default_config()) 38 | 39 | def __exit__(self, type, value, traceback): 40 | self.commit_all_dirty() 41 | 42 | def get(self, path, bottom={}, init={}): 43 | abspath = os.path.join(self.root, path) 44 | if abspath in self.configs: 45 | return self.configs[abspath] 46 | conf = Config(abspath, bottom=bottom, init=init) 47 | self.configs[abspath] = conf 48 | return conf 49 | 50 | def get_instance_config(self, path): 51 | return self.get(path, bottom=self.global_config) 52 | 53 | def commit_all_dirty(self): 54 | logger.debug("Commiting all dirty configs") 55 | for conf in self.configs.values(): 56 | conf.save_if_dirty() 57 | 58 | 59 | class OverlayDict(dict): 60 | def __init__(self, bottom={}, init={}): 61 | super().__init__(**init) 62 | self.bottom = bottom 63 | 64 | # By default get does not call __missing__ but immediately returns default. 65 | def get(self, key, default=None): 66 | try: 67 | return self[key] 68 | except KeyError: 69 | return default 70 | 71 | def __missing__(self, key): 72 | return self.bottom[key] 73 | 74 | def __repr__(self): 75 | return "{}[{}]".format(super().__repr__(), repr(self.bottom)) 76 | 77 | 78 | class Config(OverlayDict): 79 | def __init__(self, config_file, bottom={}, init={}): 80 | super().__init__(init=init, bottom=bottom) 81 | self.filepath = config_file 82 | self.dirty = not self.load() 83 | 84 | # TODO This way of detecting dirtyness is not good enough, as for example 85 | # a dict within the config can be modified (account config is not flat) 86 | # Not sure what to do about this 87 | 88 | def __setitem__(self, key, value): 89 | self.dirty = True 90 | super().__setitem__(key, value) 91 | 92 | def __delitem__(self, key): 93 | self.dirty = True 94 | return super().__delitem__(key) 95 | 96 | # The update, setdefault and clear implementations are necessary, because 97 | # the builtins do not call __setitem__ (__delitem__) thereforce would not trip the 98 | # dirty flag. 99 | 100 | def clear(self): 101 | self.dirty = True 102 | return super().clear() 103 | 104 | def update(self, *args, **kwargs): 105 | # This has false positives, but who cares 106 | self.dirty = True 107 | super().update(*args, **kwargs) 108 | 109 | def setdefault(self, key, value=None): 110 | if key not in self: 111 | self.dirty = True 112 | self[key] = value 113 | return self[key] 114 | 115 | def load(self): 116 | logger.debug("Loading Config from {}".format(self.filepath)) 117 | try: 118 | with open(self.filepath, "r") as fd: 119 | data = json.load(fd) 120 | self.clear() 121 | self.update(data) 122 | return True 123 | except FileNotFoundError: 124 | return False 125 | 126 | def save(self): 127 | logger.debug("Writing Config to {}".format(self.filepath)) 128 | os.makedirs(os.path.dirname(self.filepath), exist_ok=True) 129 | with open(self.filepath, "w") as fd: 130 | json.dump(self, fd, indent=4) 131 | 132 | def save_if_dirty(self): 133 | if self.dirty: 134 | self.save() 135 | self.dirty = False 136 | -------------------------------------------------------------------------------- /src/picomc/cli/instance.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import click 4 | 5 | from picomc.account import AccountError 6 | from picomc.cli.utils import pass_account_manager, pass_instance_manager, pass_launcher 7 | from picomc.logging import logger 8 | from picomc.utils import Directory, die, sanitize_name 9 | 10 | 11 | def instance_cmd(fn): 12 | @click.argument("instance_name") 13 | @functools.wraps(fn) 14 | def inner(*args, instance_name, **kwargs): 15 | return fn(*args, instance_name=sanitize_name(instance_name), **kwargs) 16 | 17 | return inner 18 | 19 | 20 | @click.group() 21 | def instance_cli(): 22 | """Manage your instances.""" 23 | pass 24 | 25 | 26 | @instance_cli.command() 27 | @instance_cmd 28 | @click.argument("version", default="latest") 29 | @pass_instance_manager 30 | def create(im, instance_name, version): 31 | """Create a new instance.""" 32 | if im.exists(instance_name): 33 | logger.error("An instance with that name already exists.") 34 | return 35 | im.create(instance_name, version) 36 | 37 | 38 | @instance_cli.command() 39 | @pass_instance_manager 40 | def list(im): 41 | """Show a list of instances.""" 42 | print("\n".join(im.list())) 43 | 44 | 45 | @instance_cli.command() 46 | @instance_cmd 47 | @pass_instance_manager 48 | def delete(im, instance_name): 49 | """Delete the instance (from disk).""" 50 | if im.exists(instance_name): 51 | im.delete(instance_name) 52 | else: 53 | logger.error("No such instance exists.") 54 | 55 | 56 | @instance_cli.command() 57 | @instance_cmd 58 | @click.option("--verify", is_flag=True, default=False) 59 | @click.option("-a", "--account", default=None) 60 | @click.option("--version-override", default=None) 61 | @pass_instance_manager 62 | @pass_account_manager 63 | def launch(am, im, instance_name, account, version_override, verify): 64 | """Launch the instance.""" 65 | if account is None: 66 | account = am.get_default() 67 | else: 68 | account = am.get(account) 69 | if not im.exists(instance_name): 70 | logger.error("No such instance exists.") 71 | return 72 | inst = im.get(instance_name) 73 | try: 74 | inst.launch(account, version_override, verify_hashes=verify) 75 | except AccountError as e: 76 | logger.error("Not launching due to account error: {}".format(e)) 77 | 78 | 79 | @instance_cli.command("natives") 80 | @instance_cmd 81 | @pass_instance_manager 82 | def extract_natives(im, instance_name): 83 | """Extract natives and leave them on disk.""" 84 | if not im.exists(instance_name): 85 | die("No such instance exists.") 86 | inst = im.get(instance_name) 87 | inst.extract_natives() 88 | 89 | 90 | @instance_cli.command("dir") 91 | @click.argument("instance_name", required=False) 92 | @pass_instance_manager 93 | @pass_launcher 94 | def _dir(launcher, im, instance_name): 95 | """Print root directory of instance.""" 96 | if not instance_name: 97 | # TODO 98 | print(launcher.get_path(Directory.INSTANCES)) 99 | else: 100 | instance_name = sanitize_name(instance_name) 101 | print(im.get_root(instance_name)) 102 | 103 | 104 | @instance_cli.command("rename") 105 | @instance_cmd 106 | @click.argument("new_name") 107 | @pass_instance_manager 108 | def rename(im, instance_name, new_name): 109 | """Rename an instance.""" 110 | new_name = sanitize_name(new_name) 111 | if im.exists(instance_name): 112 | if im.exists(new_name): 113 | die("Instance with target name already exists.") 114 | im.rename(instance_name, new_name) 115 | else: 116 | die("No such instance exists.") 117 | 118 | 119 | @instance_cli.group("config") 120 | @instance_cmd 121 | @pass_instance_manager 122 | @click.pass_context 123 | def config_cli(ctx, im, instance_name): 124 | """Configure an instance.""" 125 | if im.exists(instance_name): 126 | ctx.obj = im.get(instance_name).config 127 | else: 128 | die("No such instance exists.") 129 | 130 | 131 | @config_cli.command("show") 132 | @click.pass_obj 133 | def config_show(config): 134 | """Print the current instance config.""" 135 | 136 | for k, v in config.items(): 137 | print("{}: {}".format(k, v)) 138 | 139 | 140 | @config_cli.command("set") 141 | @click.argument("key") 142 | @click.argument("value") 143 | @click.pass_obj 144 | def config_set(config, key, value): 145 | """Set an instance config value.""" 146 | config[key] = value 147 | 148 | 149 | @config_cli.command("get") 150 | @click.argument("key") 151 | @click.pass_obj 152 | def config_get(config, key): 153 | """Print an instance config value.""" 154 | try: 155 | print(config[key]) 156 | except KeyError: 157 | print("No such item.") 158 | 159 | 160 | @config_cli.command("delete") 161 | @click.argument("key") 162 | @click.pass_obj 163 | def config_delete(config, key): 164 | """Delete a key from the instance config.""" 165 | try: 166 | del config[key] 167 | except KeyError: 168 | print("No such item.") 169 | 170 | 171 | def register_instance_cli(picomc_cli): 172 | picomc_cli.add_command(instance_cli, name="instance") 173 | -------------------------------------------------------------------------------- /src/picomc/mod/fabric.py: -------------------------------------------------------------------------------- 1 | import json 2 | import urllib.parse 3 | from datetime import datetime, timezone 4 | 5 | import click 6 | import requests 7 | 8 | from picomc.cli.utils import pass_launcher 9 | from picomc.logging import logger 10 | from picomc.utils import Directory, die 11 | 12 | _loader_name = "fabric" 13 | 14 | PACKAGE = "net.fabricmc" 15 | MAVEN_BASE = "https://maven.fabricmc.net/" 16 | LOADER_NAME = "fabric-loader" 17 | MAPPINGS_NAME = "intermediary" 18 | 19 | __all__ = ["register_cli"] 20 | 21 | 22 | class VersionError(Exception): 23 | pass 24 | 25 | 26 | def latest_game_version(): 27 | url = "https://meta.fabricmc.net/v2/versions/game" 28 | obj = requests.get(url).json() 29 | for ver in obj: 30 | if ver["stable"]: 31 | return ver["version"] 32 | 33 | 34 | def get_loader_meta(game_version, loader_version): 35 | url = "https://meta.fabricmc.net/v2/versions/loader/{}".format( 36 | urllib.parse.quote(game_version) 37 | ) 38 | obj = requests.get(url).json() 39 | if len(obj) == 0: 40 | raise VersionError("Specified game version is unsupported") 41 | if loader_version is None: 42 | ver = next(v for v in obj if v["loader"]["stable"]) 43 | else: 44 | try: 45 | ver = next(v for v in obj if v["loader"]["version"] == loader_version) 46 | except StopIteration: 47 | raise VersionError("Specified loader version is not available") from None 48 | return ver["loader"]["version"], ver["launcherMeta"] 49 | 50 | 51 | def resolve_version(game_version=None, loader_version=None): 52 | if game_version is None: 53 | game_version = latest_game_version() 54 | 55 | loader_version, loader_obj = get_loader_meta(game_version, loader_version) 56 | return game_version, loader_version, loader_obj 57 | 58 | 59 | def generate_vspec_obj(version_name, loader_obj, loader_version, game_version): 60 | out = dict() 61 | 62 | out["id"] = version_name 63 | out["inheritsFrom"] = game_version 64 | out["jar"] = game_version # Prevent the jar from being duplicated 65 | 66 | current_time = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") 67 | out["time"] = current_time 68 | 69 | mainClass = loader_obj["mainClass"] 70 | if isinstance(mainClass, dict): 71 | mainClass = mainClass["client"] 72 | out["mainClass"] = mainClass 73 | 74 | libs = [] 75 | for side in ["common", "client"]: 76 | libs.extend(loader_obj["libraries"][side]) 77 | 78 | for artifact, version in [ 79 | (MAPPINGS_NAME, game_version), 80 | (LOADER_NAME, loader_version), 81 | ]: 82 | libs.append( 83 | {"name": "{}:{}:{}".format(PACKAGE, artifact, version), "url": MAVEN_BASE} 84 | ) 85 | 86 | out["libraries"] = libs 87 | 88 | return out 89 | 90 | 91 | def install(versions_root, game_version=None, loader_version=None, version_name=None): 92 | game_version, loader_version, loader_obj = resolve_version( 93 | game_version, loader_version 94 | ) 95 | 96 | if version_name is None: 97 | version_name = "{}-{}-{}".format(LOADER_NAME, loader_version, game_version) 98 | 99 | version_dir = versions_root / version_name 100 | if version_dir.exists(): 101 | die(f"Version with name {version_name} already exists") 102 | 103 | msg = f"Installing Fabric version {loader_version}-{game_version}" 104 | if version_name: 105 | logger.info(msg + f" as {version_name}") 106 | else: 107 | logger.info(msg) 108 | 109 | vspec_obj = generate_vspec_obj( 110 | version_name, loader_obj, loader_version, game_version 111 | ) 112 | 113 | version_dir.mkdir() 114 | with open(version_dir / f"{version_name}.json", "w") as fd: 115 | json.dump(vspec_obj, fd, indent=2) 116 | 117 | 118 | @click.group("fabric") 119 | def fabric_cli(): 120 | """The Fabric loader. 121 | 122 | Find out more about Fabric at https://fabricmc.net/""" 123 | pass 124 | 125 | 126 | @fabric_cli.command("install") 127 | @click.argument("game_version", required=False) 128 | @click.argument("loader_version", required=False) 129 | @click.option("--name", default=None) 130 | @pass_launcher 131 | def install_cli(launcher, game_version, loader_version, name): 132 | """Install Fabric. If no additional arguments are specified, the latest 133 | supported stable (non-snapshot) game version is chosen. The most recent 134 | loader version for the given game version is selected automatically. Both 135 | the game version and the loader version may be overridden.""" 136 | versions_root = launcher.get_path(Directory.VERSIONS) 137 | try: 138 | install( 139 | versions_root, 140 | game_version, 141 | loader_version, 142 | version_name=name, 143 | ) 144 | except VersionError as e: 145 | logger.error(e) 146 | 147 | 148 | @fabric_cli.command("version") 149 | @click.argument("game_version", required=False) 150 | def version_cli(game_version): 151 | """Resolve the loader version. If game version is not specified, the latest 152 | supported stable (non-snapshot) is chosen automatically.""" 153 | try: 154 | game_version, loader_version, _ = resolve_version(game_version) 155 | logger.info(f"{loader_version}-{game_version}") 156 | except VersionError as e: 157 | logger.error(e) 158 | 159 | 160 | def register_cli(root): 161 | root.add_command(fabric_cli) 162 | -------------------------------------------------------------------------------- /src/picomc/mod/ftb.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | from pathlib import Path, PurePath 3 | 4 | import click 5 | import requests 6 | 7 | from picomc.cli.utils import pass_instance_manager, pass_launcher 8 | from picomc.downloader import DownloadQueue 9 | from picomc.logging import logger 10 | from picomc.mod import forge 11 | from picomc.utils import Directory, die, sanitize_name 12 | 13 | BASE_URL = "https://api.modpacks.ch/" 14 | MODPACK_URL = BASE_URL + "public/modpack/{}" 15 | VERSION_URL = MODPACK_URL + "/{}" 16 | 17 | 18 | class FTBError(Exception): 19 | pass 20 | 21 | 22 | class InvalidVersionError(FTBError): 23 | pass 24 | 25 | 26 | class APIError(FTBError): 27 | pass 28 | 29 | 30 | def get_pack_manifest(pack_id): 31 | resp = requests.get(MODPACK_URL.format(pack_id)) 32 | resp.raise_for_status() 33 | j = resp.json() 34 | if j["status"] == "error": 35 | raise APIError(j["message"]) 36 | return j 37 | 38 | 39 | def get_version_manifest(pack_id, version_id): 40 | resp = requests.get(VERSION_URL.format(pack_id, version_id)) 41 | resp.raise_for_status() 42 | j = resp.json() 43 | if j["status"] == "error": 44 | raise APIError(j["message"]) 45 | return j 46 | 47 | 48 | def resolve_pack_meta(pack: str, pack_version=None, use_beta=False): 49 | if pack.isascii() and pack.isdecimal(): 50 | # We got pack ID 51 | pack_id = int(pack) 52 | else: 53 | # We got pack slug 54 | raise NotImplementedError( 55 | "Pack slug resolution is currently not available. Please use the numerical pack ID." 56 | ) 57 | 58 | pack_manifest = get_pack_manifest(pack_id) 59 | 60 | if pack_version is not None: 61 | for version in pack_manifest["versions"]: 62 | if version["name"] == pack_version: 63 | version_id = version["id"] 64 | break 65 | else: 66 | raise InvalidVersionError(pack_version) 67 | else: 68 | 69 | def filt(v): 70 | return use_beta or v["type"].lower() == "release" 71 | 72 | filtered_versions = filter(filt, pack_manifest["versions"]) 73 | version_id = max(filtered_versions, key=itemgetter("updated"))["id"] 74 | 75 | return pack_manifest, get_version_manifest(pack_id, version_id) 76 | 77 | 78 | def install(pack_id, version, launcher, im, instance_name, use_beta): 79 | try: 80 | pack_manifest, version_manifest = resolve_pack_meta(pack_id, version, use_beta) 81 | except NotImplementedError as ex: 82 | die(ex) 83 | 84 | pack_name = pack_manifest["name"] 85 | pack_version = version_manifest["name"] 86 | 87 | if instance_name is None: 88 | instance_name = sanitize_name(f"{pack_name}-{pack_version}") 89 | 90 | if im.exists(instance_name): 91 | die("Instance {} already exists".format(instance_name)) 92 | 93 | logger.info(f"Installing {pack_name} {pack_version} as {instance_name}") 94 | 95 | forge_version_name = None 96 | game_version = None 97 | for target in version_manifest["targets"]: 98 | if target["name"] == "forge": 99 | try: 100 | forge_version_name = forge.install( 101 | versions_root=launcher.get_path(Directory.VERSIONS), 102 | libraries_root=launcher.get_path(Directory.LIBRARIES), 103 | forge_version=target["version"], 104 | ) 105 | except forge.AlreadyInstalledError as ex: 106 | forge_version_name = ex.args[0] 107 | elif target["name"] == "minecraft": 108 | game_version = target["version"] 109 | else: 110 | logger.warn(f"Skipping unsupported target {target['name']}") 111 | 112 | inst_version = forge_version_name or game_version 113 | 114 | inst = im.create(instance_name, inst_version) 115 | inst.config["java.memory.max"] = str(version_manifest["specs"]["recommended"]) + "M" 116 | 117 | mcdir: Path = inst.get_minecraft_dir() 118 | dq = DownloadQueue() 119 | for f in version_manifest["files"]: 120 | filepath: Path = mcdir / PurePath(f["path"]) / f["name"] 121 | filepath.parent.mkdir(exist_ok=True, parents=True) 122 | dq.add(f["url"], filepath, f["size"]) 123 | 124 | logger.info("Downloading modpack files") 125 | dq.download() 126 | 127 | logger.info(f"Installed successfully as {instance_name}") 128 | 129 | 130 | @click.group("ftb") 131 | def ftb_cli(): 132 | """Handles modern FTB modpacks""" 133 | pass 134 | 135 | 136 | @ftb_cli.command("install") 137 | @click.argument("pack_id") 138 | @click.argument("version", required=False) 139 | @click.option("--name", "-n", default=None, help="Name of the resulting instance") 140 | @click.option("--beta", "-b", is_flag=True, help="Consider beta modpack versions") 141 | @pass_instance_manager 142 | @pass_launcher 143 | def install_cli(launcher, im, pack_id, name, version, beta): 144 | """Install an FTB modpack. 145 | 146 | An instance is created with the correct version of forge selected and all 147 | the mods from the pack installed. 148 | 149 | PACK_ID can be the numeric id of the FTB modpack or the slug from the URL to its 150 | website. 151 | 152 | VERSION is the version name, for example 2.1.3, not its ID. If VERSION is not 153 | specified, the latest is automatically chosen. If --beta is used, the chosen 154 | version can be a beta version. Otherwise, only stable versions are considered.""" 155 | install(pack_id, version, launcher, im, name, use_beta=beta) 156 | 157 | 158 | def register_cli(root): 159 | root.add_command(ftb_cli) 160 | -------------------------------------------------------------------------------- /src/picomc/downloader.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import os 3 | import shutil 4 | import tempfile 5 | import threading 6 | from concurrent.futures import ThreadPoolExecutor 7 | from contextlib import contextmanager 8 | 9 | import certifi 10 | import urllib3 11 | from tqdm import tqdm 12 | 13 | import picomc.logging 14 | from picomc.logging import logger 15 | 16 | 17 | @contextmanager 18 | def DlTempFile(*args, default_mode=0o666, try_delete=True, **kwargs): 19 | """A NamedTemporaryFile which is created with permissions as per 20 | the current umask. It is removed after exiting the context manager, 21 | but only if it stil exists.""" 22 | if kwargs.get("delete", False): 23 | raise ValueError("delete must be False") 24 | kwargs["delete"] = False 25 | f = tempfile.NamedTemporaryFile(*args, **kwargs) 26 | umask = os.umask(0) 27 | os.umask(umask) 28 | os.chmod(f.name, default_mode & ~umask) 29 | try: 30 | with f as uf: 31 | yield uf 32 | finally: 33 | if try_delete and os.path.exists(f.name): 34 | os.unlink(f.name) 35 | 36 | 37 | class Downloader: 38 | def __init__(self, queue, total_size=None, workers=16): 39 | self.queue = queue 40 | self.total = len(queue) 41 | self.known_size = total_size is not None 42 | if self.known_size: 43 | self.total_size = total_size 44 | self.errors = list() 45 | self.workers = workers 46 | self.fut_to_url = dict() 47 | self.http_pool = urllib3.PoolManager( 48 | cert_reqs="CERT_REQUIRED", ca_certs=certifi.where() 49 | ) 50 | self.stop_event = threading.Event() 51 | 52 | def copyfileobj_prog(self, fsrc, fdst, callback, length=0): 53 | if not length: 54 | # COPY_BUFSIZE is undocumented and requires python 3.8 55 | length = getattr(shutil, "COPY_BUFSIZE", 64 * 1024) 56 | 57 | fsrc_read = fsrc.read 58 | fdst_write = fdst.write 59 | 60 | while True: 61 | if self.stop_event.is_set(): 62 | raise InterruptedError 63 | buf = fsrc_read(length) 64 | if not buf: 65 | break 66 | fdst_write(buf) 67 | callback(len(buf)) 68 | 69 | def download_file(self, i, url, dest, sz_callback): 70 | # In case the task could not be cancelled 71 | if self.stop_event.is_set(): 72 | raise InterruptedError 73 | os.makedirs(os.path.dirname(dest), exist_ok=True) 74 | logger.debug("Downloading [{}/{}]: {}".format(i, self.total, url)) 75 | resp = self.http_pool.request("GET", url, preload_content=False) 76 | if resp.status != 200: 77 | self.errors.append( 78 | "Failed to download ({}) [{}/{}]: {}".format( 79 | resp.status, i, self.total, url 80 | ) 81 | ) 82 | resp.release_conn() 83 | return 84 | with DlTempFile(dir=os.path.dirname(dest), delete=False) as tempf: 85 | self.copyfileobj_prog(resp, tempf, sz_callback) 86 | tempf.close() 87 | os.replace(tempf.name, dest) 88 | resp.release_conn() 89 | 90 | def reap_future(self, future, tq): 91 | try: 92 | future.result() 93 | except Exception as ex: 94 | msg = f"Exception while downloading {self.fut_to_url[future]}: {ex}" 95 | self.errors.append(msg) 96 | else: 97 | if not self.known_size: 98 | tq.update(1) 99 | # if we have size, the progress bar was already updated 100 | # from within the thread 101 | 102 | def cancel(self, tq, tpe): 103 | tq.close() 104 | logger.warning("Stopping downloader threads.") 105 | self.stop_event.set() 106 | tpe.shutdown() 107 | for fut in self.fut_to_url: 108 | fut.cancel() 109 | 110 | def download(self): 111 | logger.debug("Downloading {} files.".format(self.total)) 112 | disable_progressbar = picomc.logging.debug 113 | 114 | if self.known_size: 115 | cm_progressbar = tqdm( 116 | total=self.total_size, 117 | disable=disable_progressbar, 118 | unit_divisor=1024, 119 | unit="iB", 120 | unit_scale=True, 121 | ) 122 | else: 123 | cm_progressbar = tqdm(total=self.total, disable=disable_progressbar) 124 | 125 | with cm_progressbar as tq, ThreadPoolExecutor(max_workers=self.workers) as tpe: 126 | for i, (url, dest) in enumerate(self.queue, start=1): 127 | cb = tq.update if self.known_size else (lambda x: None) 128 | fut = tpe.submit(self.download_file, i, url, dest, cb) 129 | self.fut_to_url[fut] = url 130 | 131 | try: 132 | for fut in concurrent.futures.as_completed(self.fut_to_url.keys()): 133 | self.reap_future(fut, tq) 134 | except KeyboardInterrupt as ex: 135 | self.cancel(tq, tpe) 136 | raise ex from None 137 | 138 | # Do this at the end in order to not break the progress bar. 139 | for error in self.errors: 140 | logger.error(error) 141 | 142 | return not self.errors 143 | 144 | 145 | class DownloadQueue: 146 | def __init__(self): 147 | self.q = [] 148 | self.size = 0 149 | 150 | def add(self, url, filename, size=None): 151 | self.q.append((url, filename)) 152 | if self.size is not None and size is not None: 153 | self.size += size 154 | else: 155 | self.size = None 156 | 157 | def __len__(self): 158 | return len(self.q) 159 | 160 | def download(self): 161 | if not self.q: 162 | return True 163 | return Downloader(self.q, total_size=self.size).download() 164 | -------------------------------------------------------------------------------- /src/picomc/msapi.py: -------------------------------------------------------------------------------- 1 | import colorama 2 | import requests 3 | from requests.exceptions import RequestException 4 | 5 | from picomc.errors import AuthenticationError, RefreshError, ValidationError 6 | from picomc.logging import logger 7 | 8 | URL_DEVICE_AUTH = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" 9 | URL_TOKEN = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" 10 | URL_XBL = "https://user.auth.xboxlive.com/user/authenticate" 11 | URL_XSTS = "https://xsts.auth.xboxlive.com/xsts/authorize" 12 | URL_MCS = "https://api.minecraftservices.com/authentication/login_with_xbox" 13 | URL_MCS_PROFILE = "https://api.minecraftservices.com/minecraft/profile" 14 | 15 | CLIENT_ID = "c52aed44-3b4d-4215-99c5-824033d2bc0f" 16 | SCOPE = "XboxLive.signin offline_access" 17 | GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" 18 | 19 | 20 | class MicrosoftAuthApi: 21 | def _ms_oauth(self): 22 | data = {"client_id": CLIENT_ID, "scope": SCOPE} 23 | 24 | resp = requests.post(URL_DEVICE_AUTH, data) 25 | resp.raise_for_status() 26 | 27 | j = resp.json() 28 | device_code = j["device_code"] 29 | 30 | msg = j["message"] 31 | user_code = j["user_code"] 32 | link = j["verification_uri"] 33 | 34 | msg = msg.replace( 35 | user_code, colorama.Fore.RED + user_code + colorama.Fore.RESET 36 | ).replace(link, colorama.Style.BRIGHT + link + colorama.Style.NORMAL) 37 | 38 | logger.info(msg) 39 | 40 | data = {"code": device_code, "grant_type": GRANT_TYPE, "client_id": CLIENT_ID} 41 | 42 | first = True 43 | while True: 44 | if first: 45 | input("Press enter to continue... ") 46 | else: 47 | input("Press enter to try again... ") 48 | first = False 49 | 50 | resp = requests.post(URL_TOKEN, data) 51 | if resp.status_code == 400: 52 | j = resp.json() 53 | logger.debug(j) 54 | if j["error"] == "authorization_pending": 55 | logger.warning(j["error_description"]) 56 | logger.info(msg) 57 | continue 58 | else: 59 | raise AuthenticationError(j["error_description"]) 60 | resp.raise_for_status() 61 | 62 | j = resp.json() 63 | break 64 | 65 | access_token = j["access_token"] 66 | refresh_token = j["refresh_token"] 67 | logger.debug("OAuth device code flow successful") 68 | return access_token, refresh_token 69 | 70 | def _ms_oauth_refresh(self, refresh_token): 71 | data = { 72 | "refresh_token": refresh_token, 73 | "grant_type": "refresh_token", 74 | "client_id": CLIENT_ID, 75 | } 76 | resp = requests.post(URL_TOKEN, data) 77 | resp.raise_for_status() 78 | 79 | j = resp.json() 80 | access_token = j["access_token"] 81 | refresh_token = j["refresh_token"] 82 | logger.debug("OAuth code flow refresh successful") 83 | return access_token, refresh_token 84 | 85 | def _xbl_auth(self, access_token): 86 | data = { 87 | "Properties": { 88 | "AuthMethod": "RPS", 89 | "SiteName": "user.auth.xboxlive.com", 90 | "RpsTicket": f"d={access_token}", 91 | }, 92 | "RelyingParty": "http://auth.xboxlive.com", 93 | "TokenType": "JWT", 94 | } 95 | resp = requests.post(URL_XBL, json=data) 96 | resp.raise_for_status() 97 | 98 | j = resp.json() 99 | logger.debug("XBL auth successful") 100 | return j["Token"], j["DisplayClaims"]["xui"][0]["uhs"] 101 | 102 | def _xsts_auth(self, xbl_token): 103 | data = { 104 | "Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl_token]}, 105 | "RelyingParty": "rp://api.minecraftservices.com/", 106 | "TokenType": "JWT", 107 | } 108 | resp = requests.post(URL_XSTS, json=data) 109 | resp.raise_for_status() 110 | 111 | j = resp.json() 112 | logger.debug("XSTS auth successful") 113 | return j["Token"] 114 | 115 | def _mcs_auth(self, uhs, xsts_token): 116 | data = {"identityToken": f"XBL3.0 x={uhs};{xsts_token}"} 117 | resp = requests.post(URL_MCS, json=data) 118 | resp.raise_for_status() 119 | 120 | j = resp.json() 121 | logger.debug("Minecraft services auth successful") 122 | return j["access_token"] 123 | 124 | def get_profile(self, mc_access_token): 125 | try: 126 | resp = requests.get( 127 | URL_MCS_PROFILE, headers={"Authorization": f"Bearer {mc_access_token}"} 128 | ) 129 | resp.raise_for_status() 130 | except RequestException as e: 131 | raise AuthenticationError(e) 132 | return resp.json() 133 | 134 | def _auth_rest(self, access_token, refresh_token): 135 | xbl_token, uhs = self._xbl_auth(access_token) 136 | xsts_token = self._xsts_auth(xbl_token) 137 | mc_access_token = self._mcs_auth(uhs, xsts_token) 138 | return mc_access_token 139 | 140 | def authenticate(self): 141 | try: 142 | access_token, refresh_token = self._ms_oauth() 143 | mc_access_token = self._auth_rest(access_token, refresh_token) 144 | return mc_access_token, refresh_token 145 | except RequestException as e: 146 | raise AuthenticationError(e) 147 | except KeyError as e: 148 | raise AuthenticationError("Missing field in response", e) 149 | 150 | def validate(self, mc_access_token): 151 | try: 152 | resp = requests.get( 153 | URL_MCS_PROFILE, headers={"Authorization": f"Bearer {mc_access_token}"} 154 | ) 155 | if resp.status_code == 401: 156 | return False 157 | 158 | resp.raise_for_status() 159 | profile = resp.json() 160 | 161 | return "id" in profile 162 | except RequestException as e: 163 | raise ValidationError(e) 164 | 165 | def refresh(self, refresh_token): 166 | try: 167 | access_token, new_refresh_token = self._ms_oauth_refresh(refresh_token) 168 | mc_access_token = self._auth_rest(access_token, refresh_token) 169 | return mc_access_token, new_refresh_token 170 | except RequestException as e: 171 | raise RefreshError(e) 172 | -------------------------------------------------------------------------------- /src/picomc/account.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from picomc.errors import RefreshError, ValidationError 4 | from picomc.logging import logger 5 | from picomc.msapi import MicrosoftAuthApi 6 | from picomc.yggdrasil import MojangYggdrasil 7 | 8 | 9 | class NAMESPACE_NULL: 10 | bytes = b"" 11 | 12 | 13 | def generate_client_token(): 14 | # Any random string, this matches the behaviour of the official launcher. 15 | return str(uuid.uuid4().hex) 16 | 17 | 18 | class Account: 19 | def __init__(self, **kwargs): 20 | self.__dict__.update(self.DEFAULTS) 21 | self.__dict__.update(kwargs) 22 | 23 | def __repr__(self): 24 | return self.name 25 | 26 | def to_dict(self): 27 | return {k: getattr(self, k) for k in self.DEFAULTS.keys()} 28 | 29 | def save(self): 30 | self._am.save(self) 31 | 32 | @classmethod 33 | def from_config(cls, am, name, config): 34 | if config.get("microsoft", False): 35 | c = MicrosoftAccount 36 | elif config.get("online", False): 37 | c = OnlineAccount 38 | else: 39 | c = OfflineAccount 40 | return c(name=name, _am=am, **config) 41 | 42 | 43 | class OfflineAccount(Account): 44 | DEFAULTS = {"uuid": "-", "online": False} 45 | access_token = "-" 46 | 47 | @classmethod 48 | def new(cls, am, name): 49 | u = uuid.uuid3(NAMESPACE_NULL, "OfflinePlayer:{}".format(name)).hex 50 | return cls(name=name, uuid=u, _am=am) 51 | 52 | @property 53 | def gname(self): 54 | return self.name 55 | 56 | def refresh(self): 57 | return False 58 | 59 | def can_launch_game(self): 60 | return True 61 | 62 | 63 | class OnlineAccount(Account): 64 | DEFAULTS = { 65 | "uuid": "-", 66 | "online": True, 67 | "gname": "-", 68 | "access_token": "-", 69 | "is_authenticated": False, 70 | "username": "-", 71 | } 72 | 73 | fresh = False 74 | 75 | @classmethod 76 | def new(cls, am, name, username): 77 | return cls(name=name, username=username, _am=am) 78 | 79 | def validate(self): 80 | r = self._am.yggdrasil.validate(self.access_token) 81 | if r: 82 | self.fresh = True 83 | return r 84 | 85 | def refresh(self, force=False): 86 | if self.fresh and not force: 87 | return False 88 | if self.is_authenticated: 89 | if self.validate(): 90 | return 91 | else: 92 | try: 93 | refresh = self._am.yggdrasil.refresh(self.access_token) 94 | self.access_token, self.uuid, self.gname = refresh 95 | self.fresh = True 96 | return True 97 | except RefreshError as e: 98 | logger.error( 99 | "Failed to refresh access_token," " please authenticate again." 100 | ) 101 | self.is_authenticated = False 102 | raise e 103 | finally: 104 | self.save() 105 | else: 106 | raise AccountError("Not authenticated.") 107 | 108 | def authenticate(self, password): 109 | self.access_token, self.uuid, self.gname = self._am.yggdrasil.authenticate( 110 | self.username, password 111 | ) 112 | self.is_authenticated = True 113 | self.fresh = True 114 | self.save() 115 | 116 | def can_launch_game(self): 117 | return self.is_authenticated 118 | 119 | 120 | class MicrosoftAccount(Account): 121 | DEFAULTS = { 122 | "uuid": "-", 123 | "online": True, 124 | "microsoft": True, 125 | "gname": "-", 126 | "access_token": "-", 127 | "refresh_token": "-", 128 | "is_authenticated": False, 129 | } 130 | 131 | @classmethod 132 | def new(cls, am, name): 133 | return cls(name=name, _am=am) 134 | 135 | def refresh(self, force=False): 136 | if not self.is_authenticated: 137 | raise RefreshError("Account is not authenticated, cannot refresh") 138 | try: 139 | valid = self._am.msapi.validate(self.access_token) 140 | except ValidationError as e: 141 | raise RefreshError(e) 142 | if valid: 143 | logger.debug("msa: token still valid") 144 | return False 145 | else: 146 | logger.debug("msa: token not valid anymore, refreshing") 147 | self.access_token, self.refresh_token = self._am.msapi.refresh( 148 | self.refresh_token 149 | ) 150 | self.save() 151 | return True 152 | 153 | def authenticate(self): 154 | self.access_token, self.refresh_token = self._am.msapi.authenticate() 155 | profile = self._am.msapi.get_profile(self.access_token) 156 | self.gname = profile["name"] 157 | self.uuid = profile["id"] 158 | self.is_authenticated = True 159 | self.save() 160 | 161 | def can_launch_game(self): 162 | return self.is_authenticated 163 | 164 | 165 | class AccountError(ValueError): 166 | def __str__(self): 167 | return " ".join(self.args) 168 | 169 | 170 | DEFAULT_CONFIG = { 171 | "default": None, 172 | "accounts": {}, 173 | "client_token": generate_client_token(), 174 | } 175 | 176 | 177 | class AccountManager: 178 | CONFIG_FILE = "accounts.json" 179 | 180 | def __init__(self, launcher): 181 | self.config = launcher.config_manager.get(self.CONFIG_FILE, init=DEFAULT_CONFIG) 182 | self.yggdrasil = MojangYggdrasil(self.config["client_token"]) 183 | self.msapi = MicrosoftAuthApi() 184 | 185 | def list(self): 186 | return self.config["accounts"].keys() 187 | 188 | def get(self, name): 189 | try: 190 | acc = Account.from_config(self, name, self.config["accounts"][name]) 191 | acc.is_default = self.config["default"] == name 192 | return acc 193 | except KeyError as ke: 194 | raise AccountError("Account does not exist:", name) from ke 195 | 196 | def exists(self, name): 197 | return name in self.config["accounts"] 198 | 199 | def get_default(self): 200 | default = self.config["default"] 201 | if not default: 202 | raise AccountError("Default account not configured.") 203 | return self.get(default) 204 | 205 | def is_default(self, name): 206 | return name == self.config["default"] 207 | 208 | def set_default(self, account): 209 | self.config["default"] = account.name 210 | 211 | def add(self, account): 212 | if self.exists(account.name): 213 | raise AccountError("An account already exists with that name.") 214 | if not self.config["default"] and not self.config["accounts"]: 215 | self.config["default"] = account.name 216 | self.save(account) 217 | 218 | def save(self, account): 219 | self.config["accounts"][account.name] = account.to_dict() 220 | # HACK This doesn't trip the crappy dirty flag on config, set manually 221 | self.config.dirty = True 222 | 223 | def remove(self, name): 224 | try: 225 | if self.config["default"] == name: 226 | self.config["default"] = None 227 | del self.config["accounts"][name] 228 | # HACK This doesn't trip the crappy dirty flag on config, set manually 229 | self.config.dirty = True 230 | except KeyError: 231 | raise AccountError("Account does not exist:", name) 232 | -------------------------------------------------------------------------------- /src/picomc/mod/curse.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import json 3 | import os 4 | import re 5 | import shutil 6 | from concurrent.futures import ThreadPoolExecutor 7 | from pathlib import Path, PurePath 8 | from tempfile import TemporaryFile 9 | from xml.etree import ElementTree 10 | from zipfile import ZipFile 11 | 12 | import click 13 | import requests 14 | from tqdm import tqdm 15 | 16 | from picomc.cli.utils import pass_instance_manager, pass_launcher 17 | from picomc.downloader import DownloadQueue 18 | from picomc.logging import logger 19 | from picomc.mod import forge 20 | from picomc.utils import Directory, die, sanitize_name 21 | 22 | FORGE_PREFIX = "forge-" 23 | ADDON_URL = "https://addons-ecs.forgesvc.net/api/v2/addon" 24 | GETINFO_URL = "https://addons-ecs.forgesvc.net/api/v2/addon/{}/file/{}" 25 | GETURL_URL = GETINFO_URL + "/download-url" 26 | 27 | 28 | def resolve_project_id(proj_id): 29 | headers = {"User-Agent": "curl"} 30 | resp = requests.get(f"{ADDON_URL}/{proj_id}", headers=headers) 31 | resp.raise_for_status() 32 | meta = resp.json() 33 | files = meta["latestFiles"] 34 | files.sort(key=lambda f: f["fileDate"], reverse=True) 35 | return files[0]["downloadUrl"] 36 | 37 | 38 | def get_file_url(file_id, proj_id=None): 39 | headers = {"User-Agent": "curl"} 40 | if proj_id is None: 41 | proj_id = "anything" 42 | resp = requests.get(GETURL_URL.format(proj_id, file_id), headers=headers) 43 | resp.raise_for_status() 44 | return resp.text 45 | 46 | 47 | def resolve_packurl(path): 48 | if path.startswith("https://") and path.endswith(".zip"): 49 | return path 50 | regex = r"^(https:\/\/|twitch:\/\/)www\.curseforge\.com\/minecraft\/modpacks\/[-a-z0-9]+\/(download|download-client|files)\/(\d+)(\/file|\?client=y|)$" 51 | match = re.match(regex, path) 52 | if match: 53 | file_id = match.group(3) 54 | return get_file_url(file_id) 55 | else: 56 | regex = r"^curseforge:\/\/install\?addonId=\d+&fileId=(\d+)" 57 | match = re.match(regex, path) 58 | if match: 59 | file_id = match.group(1) 60 | return get_file_url(file_id) 61 | else: 62 | raise ValueError("Unsupported URL") 63 | 64 | 65 | def resolve_ccip(filename): 66 | xml = ElementTree.parse(filename) 67 | proj_attr = xml.find("project").attrib 68 | return get_file_url(proj_attr["file"], proj_attr["id"]) 69 | 70 | 71 | def install_from_zip(zipfileobj, launcher, instance_manager, instance_name=None): 72 | with ZipFile(zipfileobj) as pack_zf: 73 | for fileinfo in pack_zf.infolist(): 74 | fpath = PurePath(fileinfo.filename) 75 | if fpath.parts[-1] == "manifest.json" and len(fpath.parts) <= 2: 76 | manifest_zipinfo = fileinfo 77 | archive_prefix = fpath.parent 78 | break 79 | else: 80 | raise ValueError("Zip file does not contain manifest") 81 | 82 | with pack_zf.open(manifest_zipinfo) as fd: 83 | manifest = json.load(fd) 84 | 85 | assert manifest["manifestType"] == "minecraftModpack" 86 | assert manifest["manifestVersion"] == 1 87 | 88 | assert len(manifest["minecraft"]["modLoaders"]) == 1 89 | forge_ver = manifest["minecraft"]["modLoaders"][0]["id"] 90 | 91 | assert forge_ver.startswith(FORGE_PREFIX) 92 | forge_ver = forge_ver[len(FORGE_PREFIX) :] 93 | packname = manifest["name"] 94 | packver = manifest["version"] 95 | if instance_name is None: 96 | instance_name = "{}-{}".format( 97 | sanitize_name(packname), sanitize_name(packver) 98 | ) 99 | logger.info(f"Installing {packname} version {packver}") 100 | else: 101 | logger.info( 102 | f"Installing {packname} version {packver} as instance {instance_name}" 103 | ) 104 | 105 | if instance_manager.exists(instance_name): 106 | die("Instace {} already exists".format(instance_name)) 107 | 108 | try: 109 | forge.install( 110 | versions_root=launcher.get_path(Directory.VERSIONS), 111 | libraries_root=launcher.get_path(Directory.LIBRARIES), 112 | forge_version=forge_ver, 113 | ) 114 | except forge.AlreadyInstalledError: 115 | pass 116 | 117 | # Trusting the game version from the manifest may be a bad idea 118 | inst = instance_manager.create( 119 | instance_name, 120 | "{}-forge-{}".format(manifest["minecraft"]["version"], forge_ver), 121 | ) 122 | # This is a random guess, but better than the vanilla 1G 123 | inst.config["java.memory.max"] = "4G" 124 | 125 | project_files = {mod["projectID"]: mod["fileID"] for mod in manifest["files"]} 126 | headers = {"User-Agent": "curl"} 127 | dq = DownloadQueue() 128 | 129 | logger.info("Retrieving mod metadata from curse") 130 | modcount = len(project_files) 131 | mcdir: Path = inst.get_minecraft_dir() 132 | moddir = mcdir / "mods" 133 | with tqdm(total=modcount) as tq: 134 | # Try to get as many file_infos as we can in one request 135 | # This endpoint only provides a few "latest" files for each project, 136 | # so it's not guaranteed that the response will contain the fileID 137 | # we are looking for. It's a gamble, but usually worth it in terms 138 | # of request count. The time benefit is not that great, as the endpoint 139 | # is slow. 140 | resp = requests.post( 141 | ADDON_URL, json=list(project_files.keys()), headers=headers 142 | ) 143 | resp.raise_for_status() 144 | projects_meta = resp.json() 145 | for proj in projects_meta: 146 | proj_id = proj["id"] 147 | want_file = project_files[proj_id] 148 | for file_info in proj["latestFiles"]: 149 | if want_file == file_info["id"]: 150 | dq.add( 151 | file_info["downloadUrl"], 152 | moddir / file_info["fileName"], 153 | size=file_info["fileLength"], 154 | ) 155 | del project_files[proj_id] 156 | 157 | batch_recvd = modcount - len(project_files) 158 | logger.debug("Got {} batched".format(batch_recvd)) 159 | tq.update(batch_recvd) 160 | 161 | with ThreadPoolExecutor(max_workers=16) as tpe: 162 | 163 | def dl(pid, fid): 164 | resp = requests.get(GETINFO_URL.format(pid, fid), headers=headers) 165 | resp.raise_for_status() 166 | file_info = resp.json() 167 | assert file_info["id"] == fid 168 | dq.add( 169 | file_info["downloadUrl"], 170 | moddir / file_info["fileName"], 171 | size=file_info["fileLength"], 172 | ) 173 | 174 | # Get remaining individually 175 | futmap = {} 176 | for pid, fid in project_files.items(): 177 | fut = tpe.submit(dl, pid, fid) 178 | futmap[fut] = (pid, fid) 179 | 180 | for fut in concurrent.futures.as_completed(futmap.keys()): 181 | try: 182 | fut.result() 183 | except Exception as ex: 184 | pid, fid = futmap[fut] 185 | logger.error( 186 | "Could not get metadata for {}/{}: {}".format(pid, fid, ex) 187 | ) 188 | else: 189 | tq.update(1) 190 | 191 | logger.info("Downloading mod jars") 192 | dq.download() 193 | 194 | logger.info("Copying overrides") 195 | overrides = archive_prefix / manifest["overrides"] 196 | for fileinfo in pack_zf.infolist(): 197 | if fileinfo.is_dir(): 198 | continue 199 | fname = fileinfo.filename 200 | try: 201 | outpath = mcdir / PurePath(fname).relative_to(overrides) 202 | except ValueError: 203 | continue 204 | if not outpath.parent.exists(): 205 | outpath.parent.mkdir(parents=True, exist_ok=True) 206 | with pack_zf.open(fileinfo) as infile, open(outpath, "wb") as outfile: 207 | shutil.copyfileobj(infile, outfile) 208 | 209 | logger.info("Done installing {}".format(instance_name)) 210 | 211 | 212 | def install_from_path(path, launcher, instance_manager, instance_name=None): 213 | if path.isascii() and path.isdecimal(): 214 | path = resolve_project_id(path) 215 | elif os.path.exists(path): 216 | if path.endswith(".ccip"): 217 | path = resolve_ccip(path) 218 | elif path.endswith(".zip"): 219 | with open(path, "rb") as fd: 220 | return install_from_zip(fd, launcher, instance_manager, instance_name) 221 | else: 222 | die("File must be .ccip or .zip") 223 | 224 | zipurl = resolve_packurl(path) 225 | with requests.get(zipurl, stream=True) as r: 226 | r.raise_for_status() 227 | with TemporaryFile() as tempfile: 228 | for chunk in r.iter_content(chunk_size=8192): 229 | tempfile.write(chunk) 230 | install_from_zip(tempfile, launcher, instance_manager, instance_name) 231 | 232 | 233 | @click.group("curse") 234 | def curse_cli(): 235 | """Handles modpacks from curseforge.com""" 236 | pass 237 | 238 | 239 | @curse_cli.command("install") 240 | @click.argument("path") 241 | @click.option("--name", "-n", default=None, help="Name of the resulting instance") 242 | @pass_instance_manager 243 | @pass_launcher 244 | def install_cli(launcher, im, path, name): 245 | """Install a modpack. 246 | 247 | An instance is created with the correct version of forge selected and all 248 | the mods from the pack installed. 249 | 250 | PATH can be a URL of the modpack (either twitch:// or https:// 251 | containing a numeric identifier of the file), a path to either a downloaded 252 | curse zip file or a ccip file or the project ID.""" 253 | install_from_path(path, launcher, im, name) 254 | 255 | 256 | def register_cli(root): 257 | root.add_command(curse_cli) 258 | -------------------------------------------------------------------------------- /src/picomc/instance.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shlex 3 | import shutil 4 | import subprocess 5 | import zipfile 6 | from operator import attrgetter 7 | from pathlib import Path 8 | from string import Template 9 | from tempfile import mkdtemp 10 | 11 | from picomc import logging 12 | from picomc.errors import RefreshError 13 | from picomc.java import assert_java 14 | from picomc.logging import logger 15 | from picomc.rules import match_ruleset 16 | from picomc.utils import Directory, join_classpath, sanitize_name 17 | 18 | 19 | class InstanceError(Exception): 20 | pass 21 | 22 | 23 | class InstanceNotFoundError(InstanceError): 24 | pass 25 | 26 | 27 | class NativesExtractor: 28 | def __init__(self, libraries_root, instance, natives): 29 | self.libraries_root = libraries_root 30 | self.instance = instance 31 | self.natives = natives 32 | self.ndir = mkdtemp(prefix="natives-", dir=instance.get_relpath()) 33 | 34 | def get_natives_path(self): 35 | return self.ndir 36 | 37 | def extract(self): 38 | dedup = set() 39 | for library in self.natives: 40 | fullpath = library.get_abspath(self.libraries_root) 41 | if fullpath in dedup: 42 | logger.debug( 43 | "Skipping duplicate natives archive: " "{}".format(fullpath) 44 | ) 45 | continue 46 | dedup.add(fullpath) 47 | logger.debug("Extracting natives archive: {}".format(fullpath)) 48 | with zipfile.ZipFile(fullpath) as zf: 49 | # TODO take exclude into account 50 | zf.extractall(path=self.ndir) 51 | 52 | def __enter__(self): 53 | self.extract() 54 | return self.ndir 55 | 56 | def __exit__(self, ext_type, exc_value, traceback): 57 | logger.debug("Cleaning up natives.") 58 | shutil.rmtree(self.ndir) 59 | 60 | 61 | def process_arguments(arguments_dict, java_info): 62 | def subproc(obj): 63 | args = [] 64 | for a in obj: 65 | if isinstance(a, str): 66 | args.append(a) 67 | else: 68 | if "rules" in a and not match_ruleset(a["rules"], java_info): 69 | continue 70 | if isinstance(a["value"], list): 71 | args.extend(a["value"]) 72 | elif isinstance(a["value"], str): 73 | args.append(a["value"]) 74 | else: 75 | logger.error("Unknown type of value field.") 76 | return args 77 | 78 | return subproc(arguments_dict["game"]), subproc(arguments_dict.get("jvm")) 79 | 80 | 81 | class Instance: 82 | def __init__(self, launcher, root, name): 83 | self.instance_manager = launcher.instance_manager 84 | self.launcher = launcher 85 | 86 | self.name = sanitize_name(name) 87 | self.libraries_root = self.launcher.get_path(Directory.LIBRARIES) 88 | self.assets_root = self.launcher.get_path(Directory.ASSETS) 89 | self.directory = root 90 | self.config = self.launcher.config_manager.get_instance_config( 91 | Path("instances", Path(self.name), "config.json") 92 | ) 93 | 94 | def get_relpath(self, rel=""): 95 | return self.directory / rel 96 | 97 | def get_minecraft_dir(self): 98 | return self.get_relpath("minecraft") 99 | 100 | def get_java(self): 101 | return self.config["java.path"] 102 | 103 | def set_version(self, version): 104 | self.config["version"] = version 105 | 106 | def launch(self, account, version=None, verify_hashes=False): 107 | vobj = self.launcher.version_manager.get_version( 108 | version or self.config["version"] 109 | ) 110 | logger.info("Launching instance: {}".format(self.name)) 111 | if version or vobj.version_name == self.config["version"]: 112 | logger.info("Using version: {}".format(vobj.version_name)) 113 | else: 114 | logger.info( 115 | "Using version: {} -> {}".format( 116 | self.config["version"], vobj.version_name 117 | ) 118 | ) 119 | logger.info("Using account: {}".format(account)) 120 | gamedir = self.get_minecraft_dir() 121 | os.makedirs(gamedir, exist_ok=True) 122 | 123 | java = self.get_java() 124 | java_info = assert_java(java, vobj.java_version) 125 | 126 | libraries = vobj.get_libraries(java_info) 127 | vobj.prepare_launch(gamedir, java_info, verify_hashes) 128 | # Do this here so that configs are not needlessly overwritten after 129 | # the game quits 130 | self.launcher.config_manager.commit_all_dirty() 131 | with NativesExtractor( 132 | self.libraries_root, self, filter(attrgetter("is_native"), libraries) 133 | ) as natives_dir: 134 | self._exec_mc( 135 | account, 136 | vobj, 137 | java, 138 | java_info, 139 | gamedir, 140 | filter(attrgetter("is_classpath"), libraries), 141 | natives_dir, 142 | verify_hashes, 143 | ) 144 | 145 | def extract_natives(self): 146 | vobj = self.launcher.version_manager.get_version(self.config["version"]) 147 | java_info = assert_java(self.get_java(), vobj.java_version) 148 | vobj.download_libraries(java_info, verify_hashes=True) 149 | libs = vobj.get_libraries(java_info) 150 | ne = NativesExtractor( 151 | self.libraries_root, self, filter(attrgetter("is_native"), libs) 152 | ) 153 | ne.extract() 154 | logger.info("Extracted natives to {}".format(ne.get_natives_path())) 155 | 156 | def _exec_mc( 157 | self, account, v, java, java_info, gamedir, libraries, natives, verify_hashes 158 | ): 159 | libs = [lib.get_abspath(self.libraries_root) for lib in libraries] 160 | libs.append(v.jarfile) 161 | classpath = join_classpath(*libs) 162 | 163 | version_type, user_type = ( 164 | ("picomc", "mojang") if account.online else ("picomc/offline", "offline") 165 | ) 166 | 167 | mc = v.vspec.mainClass 168 | 169 | if hasattr(v.vspec, "minecraftArguments"): 170 | mcargs = shlex.split(v.vspec.minecraftArguments) 171 | sjvmargs = ["-Djava.library.path={}".format(natives), "-cp", classpath] 172 | elif hasattr(v.vspec, "arguments"): 173 | mcargs, jvmargs = process_arguments(v.vspec.arguments, java_info) 174 | sjvmargs = [] 175 | for a in jvmargs: 176 | tmpl = Template(a) 177 | res = tmpl.substitute( 178 | natives_directory=natives, 179 | launcher_name="picomc", 180 | launcher_version="1", 181 | classpath=classpath, 182 | version_name=v.version_name, 183 | jar_name=v.jarname, 184 | library_directory=self.libraries_root, 185 | classpath_separator=os.pathsep, 186 | ) 187 | sjvmargs.append(res) 188 | 189 | if not account.can_launch_game(): 190 | logger.error( 191 | "Account is not ready to launch game. Online accounts need to be authenticated at least once" 192 | ) 193 | return 194 | try: 195 | account.refresh() 196 | except RefreshError as e: 197 | logger.warning(f"Failed to refresh account due to an error: {e}") 198 | 199 | smcargs = [] 200 | for a in mcargs: 201 | tmpl = Template(a) 202 | res = tmpl.substitute( 203 | auth_player_name=account.gname, 204 | auth_uuid=account.uuid, 205 | auth_access_token=account.access_token, 206 | # Only used in old versions. 207 | auth_session="token:{}:{}".format(account.access_token, account.uuid), 208 | user_type=user_type, 209 | user_properties={}, 210 | version_type=version_type, 211 | version_name=v.version_name, 212 | game_directory=gamedir, 213 | assets_root=self.assets_root, 214 | assets_index_name=v.vspec.assets, 215 | game_assets=v.get_virtual_asset_path(), 216 | clientid="", # TODO fill these out properly 217 | auth_xuid="", 218 | ) 219 | smcargs.append(res) 220 | 221 | my_jvm_args = [ 222 | "-Xms{}".format(self.config["java.memory.min"]), 223 | "-Xmx{}".format(self.config["java.memory.max"]), 224 | ] 225 | 226 | if verify_hashes: 227 | my_jvm_args.append("-Dpicomc.verify=true") 228 | 229 | my_jvm_args += shlex.split(self.config["java.jvmargs"]) 230 | 231 | fargs = [java] + sjvmargs + my_jvm_args + [mc] + smcargs 232 | if logging.debug: 233 | logger.debug("Launching: " + shlex.join(fargs)) 234 | else: 235 | logger.info("Launching the game") 236 | subprocess.run(fargs, cwd=gamedir) 237 | 238 | 239 | class InstanceManager: 240 | def __init__(self, launcher): 241 | self.launcher = launcher 242 | self.instances_root = launcher.get_path(Directory.INSTANCES) 243 | 244 | def get_root(self, name): 245 | return self.instances_root / name 246 | 247 | def get(self, name): 248 | if not self.exists(name): 249 | raise InstanceNotFoundError(name) 250 | return Instance(self.launcher, self.get_root(name), name) 251 | 252 | def exists(self, name): 253 | return os.path.exists(self.get_root(name) / "config.json") 254 | 255 | def list(self): 256 | return (name for name in os.listdir(self.instances_root) if self.exists(name)) 257 | 258 | def create(self, name, version): 259 | iroot = self.get_root(name) 260 | os.mkdir(iroot) 261 | inst = Instance(self.launcher, iroot, name) 262 | inst.set_version(version) 263 | inst.config.save() 264 | return inst 265 | 266 | def delete(self, name): 267 | shutil.rmtree(self.get_root(name)) 268 | 269 | def rename(self, old, new): 270 | oldpath = self.get_root(old) 271 | newpath = self.get_root(new) 272 | assert not os.path.exists(newpath) 273 | assert os.path.exists(oldpath) 274 | shutil.move(oldpath, newpath) 275 | -------------------------------------------------------------------------------- /src/picomc/mod/forge.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import posixpath 4 | import shutil 5 | import urllib.parse 6 | from dataclasses import dataclass 7 | from operator import itemgetter 8 | from pathlib import Path, PurePath 9 | from tempfile import TemporaryDirectory 10 | from xml.etree import ElementTree 11 | from zipfile import ZipFile 12 | 13 | import click 14 | import requests 15 | 16 | from picomc.cli.utils import pass_launcher 17 | from picomc.downloader import DownloadQueue 18 | from picomc.library import Artifact 19 | from picomc.logging import logger 20 | from picomc.utils import Directory 21 | 22 | _loader_name = "forge" 23 | 24 | MAVEN_URL = "https://files.minecraftforge.net/maven/net/minecraftforge/forge/" 25 | PROMO_FILE = "promotions_slim.json" 26 | META_FILE = "maven-metadata.xml" 27 | INSTALLER_FILE = "forge-{}-installer.jar" 28 | INSTALL_PROFILE_FILE = "install_profile.json" 29 | VERSION_INFO_FILE = "version.json" 30 | INSTALLV1_CLASS = PurePath("net/minecraftforge/installer/json/InstallV1.class") 31 | 32 | FORGE_WRAPPER = { 33 | "mainClass": "net.cavoj.picoforgewrapper.Main", 34 | "library": { 35 | "name": "net.cavoj:PicoForgeWrapper:1.3", 36 | "downloads": { 37 | "artifact": { 38 | "url": "https://storage.googleapis.com/picomc-forgewrapper/PicoForgeWrapper-1.3.jar", 39 | "sha1": "2c5ed0a503d360b9ebec434a48e1385038b87097", 40 | "size": 7274, 41 | } 42 | }, 43 | }, 44 | } 45 | 46 | FORGE_WRAPPER_NEW = { 47 | "mainClass": "net.cavoj.picoforgewrapper.Main", 48 | "library": { 49 | "name": "net.cavoj:PicoForgeWrapper:1.5", 50 | "downloads": { 51 | "artifact": { 52 | "url": "https://storage.googleapis.com/picomc-forgewrapper/PicoForgeWrapper-1.5.jar", 53 | "sha1": "c31e7cbb682e9329be35b12b12f76c9ee23c2916", 54 | "size": 7573, 55 | } 56 | }, 57 | }, 58 | } 59 | 60 | 61 | class VersionResolutionError(Exception): 62 | pass 63 | 64 | 65 | class AlreadyInstalledError(Exception): 66 | pass 67 | 68 | 69 | class InstallationError(Exception): 70 | pass 71 | 72 | 73 | def get_all_versions(): 74 | resp = requests.get(urllib.parse.urljoin(MAVEN_URL, META_FILE)) 75 | X = ElementTree.fromstring(resp.content) 76 | return (v.text for v in X.findall("./versioning/versions/")) 77 | 78 | 79 | def _version_as_tuple(ver): 80 | return tuple(map(int, ver.split("."))) 81 | 82 | 83 | def get_applicable_promos(latest=False): 84 | resp = requests.get(urllib.parse.urljoin(MAVEN_URL, PROMO_FILE)) 85 | promo_obj = resp.json() 86 | 87 | for id_, forge_version in promo_obj["promos"].items(): 88 | is_latest = id_.endswith("latest") 89 | if is_latest and not latest: 90 | continue 91 | game_version = id_.split("-")[0] 92 | if "_" in game_version: 93 | # 1.7.10_pre4 94 | game_version = game_version.split("_")[0] 95 | 96 | yield (forge_version, game_version) 97 | 98 | 99 | def best_version_from_promos(promos, game_version=None): 100 | if game_version is None: 101 | _, game_version = max(promos, key=lambda obj: _version_as_tuple(obj[1])) 102 | versions_for_game = list(filter(lambda obj: obj[1] == game_version, promos)) 103 | if len(versions_for_game) == 0: 104 | raise VersionResolutionError( 105 | "No forge available for game version. Try using --latest." 106 | ) 107 | forge_version = max(map(itemgetter(0), versions_for_game), key=_version_as_tuple) 108 | 109 | return game_version, forge_version 110 | 111 | 112 | def full_from_forge(all_versions, forge_version): 113 | for v in all_versions: 114 | gv, fv, *_ = v.split("-") 115 | if fv == forge_version: 116 | return gv, v 117 | raise VersionResolutionError( 118 | f"Given Forge version ({forge_version}) does not exist" 119 | ) 120 | 121 | 122 | def resolve_version(game_version=None, forge_version=None, latest=False): 123 | logger.info("Fetching Forge metadata") 124 | all_versions = set(get_all_versions()) 125 | 126 | logger.info("Resolving version") 127 | 128 | if forge_version is None: 129 | promos = list(get_applicable_promos(latest)) 130 | game_version, forge_version = best_version_from_promos(promos, game_version) 131 | 132 | found_game, full = full_from_forge(all_versions, forge_version) 133 | if game_version and found_game != game_version: 134 | raise VersionResolutionError("Version mismatch") 135 | game_version = found_game 136 | 137 | return game_version, forge_version, full 138 | 139 | 140 | @dataclass 141 | class ForgeInstallContext: 142 | version: str # The full Forge version string 143 | version_info: dict # The version.json file from installer package 144 | game_version: str 145 | forge_version: str 146 | version_dir: Path 147 | libraries_dir: Path 148 | version_name: str # Name of the output picomc profile 149 | extract_dir: Path # Root of extracted installer 150 | installer_file: Path 151 | install_profile: dict 152 | 153 | 154 | def install_classic(ctx: ForgeInstallContext): 155 | # TODO Some processing of the libraries should be done to remove duplicates. 156 | vspec = make_base_vspec(ctx) 157 | save_vspec(ctx, vspec) 158 | install_meta = ctx.install_profile["install"] 159 | src_file = ctx.extract_dir / install_meta["filePath"] 160 | dst_file = ctx.libraries_dir / Artifact.make(install_meta["path"]).path 161 | os.makedirs(dst_file.parent, exist_ok=True) 162 | shutil.copy(src_file, dst_file) 163 | 164 | 165 | def make_base_vspec(ctx: ForgeInstallContext): 166 | vi = ctx.version_info 167 | vspec = {} 168 | for key in [ 169 | "arguments", 170 | "minecraftArguments", 171 | "inheritsFrom", 172 | "type", 173 | "releaseTime", 174 | "time", 175 | "mainClass", 176 | ]: 177 | if key in vi: 178 | vspec[key] = vi[key] 179 | 180 | vspec["id"] = ctx.version_name 181 | if "inheritsFrom" in vi: 182 | vspec["jar"] = vi["inheritsFrom"] # Prevent vanilla jar duplication 183 | else: 184 | # This is the case for som really old forge versions, before the 185 | # launcher supported inheritsFrom. Libraries should also be filtered 186 | # in this case, as they contain everything from the vanilla vspec as well. 187 | # TODO 188 | logger.warning( 189 | "Support for this version of Forge is not epic yet. Problems may arise." 190 | ) 191 | vspec["jar"] = ctx.game_version 192 | vspec["inheritsFrom"] = ctx.game_version 193 | vspec["libraries"] = vi["libraries"] 194 | 195 | return vspec 196 | 197 | 198 | def save_vspec(ctx, vspec): 199 | with open(ctx.version_dir / f"{ctx.version_name}.json", "w") as fd: 200 | json.dump(vspec, fd, indent=2) 201 | 202 | 203 | def copy_libraries(ctx): 204 | lib_path = ctx.install_profile["path"] 205 | if lib_path is None: 206 | # 1.17 forge jar is no longer packaged in the installer but it can 207 | # be downloaded like the rest 208 | logger.debug("Forge lib not bundled in installer, skipping copy") 209 | return 210 | libdir_relative = Artifact.make(lib_path).path.parent 211 | srcdir = ctx.extract_dir / "maven" / libdir_relative 212 | dstdir = ctx.libraries_dir / libdir_relative 213 | dstdir.mkdir(parents=True, exist_ok=True) 214 | for f in srcdir.iterdir(): 215 | shutil.copy2(f, dstdir) 216 | 217 | 218 | def install_newstyle(ctx: ForgeInstallContext): 219 | vspec = make_base_vspec(ctx) 220 | save_vspec(ctx, vspec) 221 | copy_libraries(ctx) 222 | 223 | 224 | def install_113(ctx: ForgeInstallContext): 225 | vspec = make_base_vspec(ctx) 226 | 227 | # Find out if the installer is of new format by checking if InstallV1 class exists 228 | is_wrapper_new = (ctx.extract_dir / INSTALLV1_CLASS).exists() 229 | wrapper = FORGE_WRAPPER_NEW if is_wrapper_new else FORGE_WRAPPER 230 | 231 | original_main_class = vspec["mainClass"] 232 | vspec["libraries"] = [wrapper["library"]] + vspec["libraries"] 233 | vspec["mainClass"] = wrapper["mainClass"] 234 | 235 | if is_wrapper_new: 236 | logger.debug("Using new PicoForgeWrapper") 237 | if "jvm" not in vspec["arguments"]: 238 | vspec["arguments"]["jvm"] = list() 239 | vspec["arguments"]["jvm"] += [f"-Dpicomc.mainClass={original_main_class}"] 240 | 241 | if _version_as_tuple(ctx.forge_version) >= (37, 0, 0): 242 | found = None 243 | for i, arg in enumerate(vspec["arguments"]["jvm"]): 244 | if arg.startswith("-DignoreList"): 245 | found = i 246 | break 247 | if found is not None: 248 | logger.debug("Found -DignoreList, extending.") 249 | vspec["arguments"]["jvm"][i] += r",${jar_name}.jar" 250 | else: 251 | logger.warn( 252 | "Could not locate -DignoreList arg, something is probably wrong. The game may not work." 253 | ) 254 | 255 | logger.debug("Adding export to jvm args.") 256 | vspec["arguments"]["jvm"] += [ 257 | "--add-exports", 258 | "cpw.mods.bootstraplauncher/cpw.mods.bootstraplauncher=ALL-UNNAMED", 259 | ] 260 | 261 | for install_lib in ctx.install_profile["libraries"]: 262 | install_lib["presenceOnly"] = True 263 | vspec["libraries"].append(install_lib) 264 | 265 | save_vspec(ctx, vspec) 266 | 267 | copy_libraries(ctx) 268 | 269 | installer_descriptor = f"net.minecraftforge:forge:{ctx.version}:installer" 270 | installer_libpath = ctx.libraries_dir / Artifact.make(installer_descriptor).path 271 | os.makedirs(installer_libpath.parent, exist_ok=True) 272 | shutil.copy(ctx.installer_file, installer_libpath) 273 | 274 | 275 | def install( 276 | versions_root: Path, 277 | libraries_root, 278 | game_version=None, 279 | forge_version=None, 280 | latest=False, 281 | version_name=None, 282 | ): 283 | game_version, forge_version, version = resolve_version( 284 | game_version, forge_version, latest 285 | ) 286 | 287 | if version_name is None: 288 | version_name = f"{game_version}-forge-{forge_version}" 289 | 290 | version_dir = versions_root / version_name 291 | if version_dir.exists(): 292 | logger.info(f"Forge {version} already installed as {version_name}") 293 | raise AlreadyInstalledError( 294 | version_name, f"Version with name {version_name} already exists" 295 | ) 296 | 297 | logger.info(f"Installing Forge {version} as {version_name}") 298 | 299 | for line in ( 300 | "As the Forge project is kept alive mostly thanks to ads on their downloads\n" 301 | "site, please consider supporting them at https://www.patreon.com/LexManos/\n" 302 | "or by visiting their website and looking at some ads." 303 | ).splitlines(): 304 | logger.warn(line) 305 | 306 | installer_url = urllib.parse.urljoin( 307 | MAVEN_URL, posixpath.join(version, INSTALLER_FILE.format(version)) 308 | ) 309 | # TODO Legacy forge versions don't have an installer 310 | with TemporaryDirectory(prefix=".forge-installer-", dir=versions_root) as tempdir: 311 | tempdir = Path(tempdir) 312 | installer_file = tempdir / "installer.jar" 313 | extract_dir = tempdir / "installer" 314 | 315 | dq = DownloadQueue() 316 | dq.add(installer_url, installer_file) 317 | logger.info("Downloading installer") 318 | if not dq.download(): 319 | raise InstallationError("Failed to download installer.") 320 | os.mkdir(version_dir) 321 | try: 322 | os.mkdir(extract_dir) 323 | ctx = ForgeInstallContext( 324 | version=version, 325 | version_info=None, 326 | game_version=game_version, 327 | forge_version=forge_version, 328 | version_dir=versions_root / version_name, 329 | libraries_dir=libraries_root, 330 | version_name=version_name, 331 | extract_dir=extract_dir, 332 | installer_file=installer_file, 333 | install_profile=None, 334 | ) 335 | with ZipFile(installer_file) as zf: 336 | zf.extractall(path=extract_dir) 337 | with open(extract_dir / INSTALL_PROFILE_FILE) as fd: 338 | ctx.install_profile = json.load(fd) 339 | if "install" in ctx.install_profile: 340 | ctx.version_info = ctx.install_profile["versionInfo"] 341 | logger.info("Installing from classic installer") 342 | install_classic(ctx) 343 | else: 344 | with open(extract_dir / VERSION_INFO_FILE) as fd: 345 | ctx.version_info = json.load(fd) 346 | if len(ctx.install_profile["processors"]) == 0: 347 | logger.info("Installing legacy version from newstyle installer") 348 | # A legacy version with an updated installer 349 | install_newstyle(ctx) 350 | else: 351 | logger.info("Installing with PicoForgeWrapper") 352 | install_113(ctx) 353 | logger.info("Done installing Forge") 354 | except: # noqa E722 355 | shutil.rmtree(version_dir, ignore_errors=True) 356 | raise 357 | return version_name 358 | 359 | 360 | @click.group("forge") 361 | def forge_cli(): 362 | """The Forge loader. 363 | 364 | Get more information about Forge at https://minecraftforge.net/""" 365 | pass 366 | 367 | 368 | @forge_cli.command("install") 369 | @click.option("--name", default=None) 370 | @click.argument("forge_version", required=False) 371 | @click.option("--game", "-g", default=None) 372 | @click.option("--latest", "-l", is_flag=True) 373 | @pass_launcher 374 | def install_cli(launcher, name, forge_version, game, latest): 375 | """Installs Forge. 376 | 377 | The best version is selected automatically based on the given parameters. 378 | By default, only stable Forge versions are considered, use --latest to 379 | enable beta versions as well. 380 | 381 | You can install a specific version of forge using the FORGE_VERSION argument. 382 | You can also choose the newest version for a specific version of Minecraft 383 | using --game.""" 384 | try: 385 | install( 386 | launcher.get_path(Directory.VERSIONS), 387 | launcher.get_path(Directory.LIBRARIES), 388 | game, 389 | forge_version, 390 | latest, 391 | version_name=name, 392 | ) 393 | except (VersionResolutionError, InstallationError, AlreadyInstalledError) as e: 394 | logger.error(e) 395 | 396 | 397 | @forge_cli.command("version") 398 | @click.argument("forge_version", required=False) 399 | @click.option("--game", "-g", default=None) 400 | @click.option("--latest", "-l", is_flag=True) 401 | def version_cli(forge_version, game, latest): 402 | """Resolve version without installing.""" 403 | try: 404 | game_version, forge_version, version = resolve_version( 405 | game, forge_version, latest 406 | ) 407 | logger.info(f"Found Forge version {forge_version} for Minecraft {game_version}") 408 | except VersionResolutionError as e: 409 | logger.error(e) 410 | 411 | 412 | def register_cli(root): 413 | root.add_command(forge_cli) 414 | -------------------------------------------------------------------------------- /src/picomc/version.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import json 3 | import operator 4 | import os 5 | import posixpath 6 | import shutil 7 | import urllib.parse 8 | import urllib.request 9 | from functools import reduce 10 | from pathlib import PurePath 11 | 12 | import requests 13 | 14 | from picomc.downloader import DownloadQueue 15 | from picomc.java import get_java_info 16 | from picomc.library import Library 17 | from picomc.logging import logger 18 | from picomc.rules import match_ruleset 19 | from picomc.utils import Directory, die, file_sha1, recur_files 20 | 21 | 22 | class VersionType(enum.Flag): 23 | NONE = 0 24 | RELEASE = enum.auto() 25 | SNAPSHOT = enum.auto() 26 | ALPHA = enum.auto() 27 | BETA = enum.auto() 28 | ANY = RELEASE | SNAPSHOT | ALPHA | BETA 29 | 30 | def match(self, s): 31 | names = { 32 | "release": VersionType.RELEASE, 33 | "snapshot": VersionType.SNAPSHOT, 34 | "old_alpha": VersionType.ALPHA, 35 | "old_beta": VersionType.BETA, 36 | } 37 | return bool(names[s] & self) 38 | 39 | @staticmethod 40 | def create(release, snapshot, alpha, beta): 41 | D = { 42 | VersionType.RELEASE: release, 43 | VersionType.SNAPSHOT: snapshot, 44 | VersionType.ALPHA: alpha, 45 | VersionType.BETA: beta, 46 | }.items() 47 | return reduce(operator.or_, (k for k, v in D if v), VersionType.NONE) 48 | 49 | 50 | def argumentadd(d1, d2): 51 | d = d1.copy() 52 | for k, v in d2.items(): 53 | if k in d: 54 | d[k] += v 55 | else: 56 | d[k] = v 57 | return d 58 | 59 | 60 | _sentinel = object() 61 | 62 | LEGACY_ASSETS = { 63 | "id": "legacy", 64 | "sha1": "770572e819335b6c0a053f8378ad88eda189fc14", 65 | "size": 109634, 66 | "totalSize": 153475165, 67 | "url": ( 68 | "https://launchermeta.mojang.com/v1/packages/" 69 | "770572e819335b6c0a053f8378ad88eda189fc14/legacy.json" 70 | ), 71 | } 72 | 73 | LEGACY_JAVA_VERSION = { 74 | "component": "jre-legacy", 75 | "majorVersion": 8, 76 | } 77 | 78 | 79 | class VersionSpec: 80 | def __init__(self, vobj, version_manager): 81 | self.vobj = vobj 82 | self.chain = self.resolve_chain(version_manager) 83 | self.initialize_fields() 84 | 85 | def resolve_chain(self, version_manager): 86 | chain = [] 87 | chain.append(self.vobj) 88 | cv = self.vobj 89 | while "inheritsFrom" in cv.raw_vspec: 90 | cv = version_manager.get_version(cv.raw_vspec["inheritsFrom"]) 91 | chain.append(cv) 92 | return chain 93 | 94 | def attr_override(self, attr, default=_sentinel): 95 | for v in self.chain: 96 | if attr in v.raw_vspec: 97 | return v.raw_vspec[attr] 98 | if default is _sentinel: 99 | raise AttributeError(attr) 100 | return default 101 | 102 | def attr_reduce(self, attr, reduce_func): 103 | L = [v.raw_vspec[attr] for v in self.chain[::-1] if attr in v.raw_vspec] 104 | if not L: 105 | raise AttributeError(attr) 106 | return reduce(reduce_func, L) 107 | 108 | def initialize_fields(self): 109 | try: 110 | self.minecraftArguments = self.attr_override("minecraftArguments") 111 | except AttributeError: 112 | pass 113 | try: 114 | self.arguments = self.attr_reduce("arguments", argumentadd) 115 | except AttributeError: 116 | pass 117 | 118 | # TODO maybe we could detect extremely old versions of Minecraft and 119 | # put an appropriate version of java here in that case 120 | self.javaVersion = self.attr_override( 121 | "javaVersion", default=LEGACY_JAVA_VERSION 122 | ) 123 | self.mainClass = self.attr_override("mainClass") 124 | self.assetIndex = self.attr_override("assetIndex", default=None) 125 | self.assets = self.attr_override("assets", default="legacy") 126 | if self.assetIndex is None and self.assets == "legacy": 127 | self.assetIndex = LEGACY_ASSETS 128 | self.libraries = self.attr_reduce("libraries", lambda x, y: y + x) 129 | self.jar = self.attr_override("jar", default=self.vobj.version_name) 130 | self.downloads = self.attr_override("downloads", default={}) 131 | 132 | 133 | class Version: 134 | ASSETS_URL = "https://resources.download.minecraft.net/" 135 | 136 | def __init__(self, version_name, launcher, version_manifest): 137 | self.version_name = version_name 138 | self.launcher = launcher 139 | self.vm = launcher.version_manager 140 | self.version_manifest = version_manifest 141 | self._libraries = dict() 142 | 143 | self.versions_root = self.vm.versions_root 144 | self.assets_root = self.launcher.get_path(Directory.ASSETS) 145 | 146 | self.raw_vspec = self.get_raw_vspec() 147 | self.vspec = VersionSpec(self, self.vm) 148 | 149 | if self.vspec.assetIndex is not None: 150 | self.raw_asset_index = self.get_raw_asset_index(self.vspec.assetIndex) 151 | 152 | self.jarname = self.vspec.jar 153 | self.jarfile = self.versions_root / self.jarname / "{}.jar".format(self.jarname) 154 | 155 | self.java_version = self.vspec.javaVersion 156 | 157 | def get_raw_vspec(self): 158 | vspec_path = ( 159 | self.versions_root / self.version_name / "{}.json".format(self.version_name) 160 | ) 161 | if not self.version_manifest: 162 | if vspec_path.exists(): 163 | logger.debug("Found custom vspec ({})".format(self.version_name)) 164 | with open(vspec_path) as fp: 165 | return json.load(fp) 166 | else: 167 | die("Specified version ({}) not available".format(self.version_name)) 168 | url = self.version_manifest["url"] 169 | sha1 = self.version_manifest["sha1"] 170 | 171 | if vspec_path.exists() and file_sha1(vspec_path) == sha1: 172 | logger.debug( 173 | "Using cached vspec files, hash matches manifest ({})".format( 174 | self.version_name 175 | ) 176 | ) 177 | with open(vspec_path) as fp: 178 | return json.load(fp) 179 | 180 | try: 181 | logger.debug("Downloading vspec file") 182 | raw = requests.get(url).content 183 | vspec_path.parent.mkdir(parents=True, exist_ok=True) 184 | with open(vspec_path, "wb") as fp: 185 | fp.write(raw) 186 | j = json.loads(raw) 187 | return j 188 | except requests.ConnectionError: 189 | die("Failed to retrieve version json file. Check your internet connection.") 190 | 191 | def get_raw_asset_index(self, asset_index_spec): 192 | iid = asset_index_spec["id"] 193 | url = asset_index_spec["url"] 194 | sha1 = asset_index_spec["sha1"] 195 | fpath = self.launcher.get_path(Directory.ASSET_INDEXES, "{}.json".format(iid)) 196 | if fpath.exists() and file_sha1(fpath) == sha1: 197 | logger.debug("Using cached asset index, hash matches vspec") 198 | with open(fpath) as fp: 199 | return json.load(fp) 200 | try: 201 | logger.debug("Downloading new asset index") 202 | raw = requests.get(url).content 203 | with open(fpath, "wb") as fp: 204 | fp.write(raw) 205 | return json.loads(raw) 206 | except requests.ConnectionError: 207 | die("Failed to retrieve asset index.") 208 | 209 | def get_raw_asset_index_nodl(self, id_): 210 | fpath = self.launcher.get_path(Directory.ASSET_INDEXES, "{}.json".format(id_)) 211 | if fpath.exists(): 212 | with open(fpath) as fp: 213 | return json.load(fp) 214 | else: 215 | die("Asset index specified in 'assets' not available.") 216 | 217 | def get_libraries(self, java_info): 218 | if java_info is not None: 219 | key = java_info.get("java.home", None) 220 | else: 221 | key = None 222 | if key and key in self._libraries: 223 | return self._libraries[key] 224 | else: 225 | libs = [] 226 | for lib in self.vspec.libraries: 227 | if "rules" in lib and not match_ruleset(lib["rules"], java_info): 228 | continue 229 | lib_obj = Library(lib) 230 | if not lib_obj.available: 231 | continue 232 | libs.append(lib_obj) 233 | if key: 234 | self._libraries[key] = libs 235 | return libs 236 | 237 | def get_jarfile_dl(self, verify_hashes=False, force=False): 238 | """Checks existence and hash of cached jar. Returns None if ok, otherwise 239 | returns download (url, size)""" 240 | logger.debug("Attempting to use jarfile: {}".format(self.jarfile)) 241 | dlspec = self.vspec.downloads.get("client", None) 242 | if dlspec is None: 243 | logger.debug("jarfile dlspec not availble, skipping hash check.") 244 | if not self.jarfile.exists(): 245 | die("jarfile does not exist and can not be downloaded.") 246 | return 247 | 248 | logger.debug("Checking jarfile.") 249 | if ( 250 | force 251 | or not self.jarfile.exists() 252 | # The fabric-installer places an empty jarfile here, due to some 253 | # quirk of an old (git blame 2 years) version of the vanilla launcher. 254 | # https://github.com/FabricMC/fabric-installer/blob/master/src/main/java/net/fabricmc/installer/client/ClientInstaller.java#L49 255 | or os.path.getsize(self.jarfile) == 0 256 | or (verify_hashes and file_sha1(self.jarfile) != dlspec["sha1"]) 257 | ): 258 | logger.info( 259 | "Jar file ({}) will be downloaded with libraries.".format(self.jarname) 260 | ) 261 | return dlspec["url"], dlspec.get("size", None) 262 | 263 | def download_libraries(self, java_info, verify_hashes=False, force=False): 264 | """Downloads missing libraries.""" 265 | logger.info("Checking libraries.") 266 | q = DownloadQueue() 267 | for library in self.get_libraries(java_info): 268 | if not library.available: 269 | continue 270 | basedir = self.launcher.get_path(Directory.LIBRARIES) 271 | abspath = library.get_abspath(basedir) 272 | ok = abspath.is_file() and os.path.getsize(abspath) > 0 273 | if verify_hashes and library.sha1 is not None: 274 | ok = ok and file_sha1(abspath) == library.sha1 275 | if not ok and not library.url: 276 | logger.error( 277 | f"Library {library.filename} is missing or corrupt " 278 | "and has no download url." 279 | ) 280 | continue 281 | if force or not ok: 282 | q.add(library.url, library.get_abspath(basedir), library.size) 283 | jardl = self.get_jarfile_dl(verify_hashes, force) 284 | if jardl is not None: 285 | url, size = jardl 286 | q.add(url, self.jarfile, size=size) 287 | if len(q) > 0: 288 | logger.info("Downloading {} libraries.".format(len(q))) 289 | if not q.download(): 290 | logger.error( 291 | "Some libraries failed to download. If they are part of a non-vanilla " 292 | "profile, the original installer may need to be used." 293 | ) 294 | 295 | def _populate_virtual_assets(self, asset_index, where): 296 | for name, obj in asset_index["objects"].items(): 297 | sha = obj["hash"] 298 | objpath = self.launcher.get_path(Directory.ASSET_OBJECTS, sha[0:2], sha) 299 | path = where / PurePath(*name.split("/")) 300 | # Maybe check file hash first? Would that be faster? 301 | path.parent.mkdir(parents=True, exist_ok=True) 302 | shutil.copy(objpath, path) 303 | 304 | def get_virtual_asset_path(self): 305 | return self.launcher.get_path( 306 | Directory.ASSET_VIRTUAL, self.vspec.assetIndex["id"] 307 | ) 308 | 309 | def prepare_assets_launch(self, gamedir): 310 | launch_asset_index = self.get_raw_asset_index_nodl(self.vspec.assets) 311 | is_map_resources = launch_asset_index.get("map_to_resources", False) 312 | if is_map_resources: 313 | logger.info("Mapping resources") 314 | where = gamedir / "resources" 315 | logger.debug("Resources path: {}".format(where)) 316 | self._populate_virtual_assets(launch_asset_index, where) 317 | 318 | def download_assets(self, verify_hashes=False, force=False): 319 | """Downloads missing assets.""" 320 | 321 | hashes = dict() 322 | for obj in self.raw_asset_index["objects"].values(): 323 | hashes[obj["hash"]] = obj["size"] 324 | 325 | logger.info("Checking {} assets.".format(len(hashes))) 326 | 327 | is_virtual = self.raw_asset_index.get("virtual", False) 328 | 329 | fileset = set(recur_files(self.assets_root)) 330 | q = DownloadQueue() 331 | objpath = self.launcher.get_path(Directory.ASSET_OBJECTS) 332 | for sha in hashes: 333 | abspath = objpath / sha[0:2] / sha 334 | ok = abspath in fileset # file exists 335 | if verify_hashes: 336 | ok = ok and file_sha1(abspath) == sha 337 | if force or not ok: 338 | url = urllib.parse.urljoin( 339 | self.ASSETS_URL, posixpath.join(sha[0:2], sha) 340 | ) 341 | q.add(url, abspath, size=hashes[sha]) 342 | 343 | if len(q) > 0: 344 | logger.info("Downloading {} assets.".format(len(q))) 345 | if not q.download(): 346 | logger.warning("Some assets failed to download.") 347 | 348 | if is_virtual: 349 | logger.info("Copying virtual assets") 350 | where = self.get_virtual_asset_path() 351 | logger.debug("Virtual asset path: {}".format(where)) 352 | self._populate_virtual_assets(self.raw_asset_index, where) 353 | 354 | def prepare(self, java_info=None, verify_hashes=False): 355 | if not java_info: 356 | java_info = get_java_info(self.launcher.global_config.get("java.path")) 357 | self.download_libraries(java_info, verify_hashes) 358 | if hasattr(self, "raw_asset_index"): 359 | self.download_assets(verify_hashes) 360 | 361 | def prepare_launch(self, gamedir, java_info, verify_hahes=False): 362 | self.prepare(java_info, verify_hahes) 363 | self.prepare_assets_launch(gamedir) 364 | 365 | 366 | class VersionManager: 367 | MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" 368 | 369 | def __init__(self, launcher): 370 | self.launcher = launcher 371 | self.versions_root = launcher.get_path(Directory.VERSIONS) 372 | self.manifest = self.get_manifest() 373 | 374 | def resolve_version_name(self, v): 375 | """Takes a metaversion and resolves to a version.""" 376 | if v == "latest": 377 | v = self.manifest["latest"]["release"] 378 | logger.debug("Resolved latest -> {}".format(v)) 379 | elif v == "snapshot": 380 | v = self.manifest["latest"]["snapshot"] 381 | logger.debug("Resolved snapshot -> {}".format(v)) 382 | return v 383 | 384 | def get_manifest(self): 385 | manifest_filepath = self.launcher.get_path(Directory.VERSIONS, "manifest.json") 386 | try: 387 | m = requests.get(self.MANIFEST_URL).json() 388 | with open(manifest_filepath, "w") as mfile: 389 | json.dump(m, mfile, indent=4, sort_keys=True) 390 | return m 391 | except requests.ConnectionError: 392 | logger.warning( 393 | "Failed to retrieve version_manifest. " 394 | "Check your internet connection." 395 | ) 396 | try: 397 | with open(manifest_filepath) as mfile: 398 | logger.warning("Using cached version_manifest.") 399 | return json.load(mfile) 400 | except FileNotFoundError: 401 | logger.warning("Cached version manifest not available.") 402 | raise RuntimeError("Failed to retrieve version manifest.") 403 | 404 | def version_list(self, vtype=VersionType.RELEASE, local=False): 405 | r = [v["id"] for v in self.manifest["versions"] if vtype.match(v["type"])] 406 | if local: 407 | r += sorted( 408 | "{} [local]".format(path.name) 409 | for path in self.versions_root.iterdir() 410 | if not path.name.startswith(".") and path.is_dir() 411 | ) 412 | return r 413 | 414 | def get_version(self, version_name): 415 | name = self.resolve_version_name(version_name) 416 | version_manifest = None 417 | for ver in self.manifest["versions"]: 418 | if ver["id"] == name: 419 | version_manifest = ver 420 | break 421 | return Version(name, self.launcher, version_manifest) 422 | --------------------------------------------------------------------------------