diff --git a/.gitignore b/.gitignore index d67c89a2..a0677349 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.idea -/__pycache__ \ No newline at end of file +__pycache__ +/packages \ No newline at end of file diff --git a/README.md b/README.md index 34deff38..6d78cea3 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,122 @@ # Portable Minecraft Launcher -An easy to use portable Minecraft launcher in only one Python script ! -This single-script launcher is still compatible with the official (Mojang) Minecraft Launcher stored in `.minecraft` and use it. - -***[Mojang authentication now available!](#mojang-authentication)*** +An easy-to-use portable Minecraft launcher in only one Python script! +This single-script launcher is still compatible with the official (Mojang) Minecraft Launcher stored +in `.minecraft` and use it. +You can now customize the launcher with addons. ![illustration](https://github.com/mindstorm38/portablemc/blob/master/illustration.png?raw=true) -*This launcher is tester for Python 3.8 & 3.6, further testing using other versions are welcome.* - -***[Download the script!](https://raw.githubusercontent.com/mindstorm38/portablemc/master/portablemc.py)*** - -Once you have the script, you can launch it using python (e.g `python portablemc.py`). - -## Table of contents - -- [Arguments](#arguments) - - [Mojang authentication](#mojang-authentication) - - [Minecraft version](#minecraft-version) - - [Offline mode](#username-and-uuid-manual-offline-mode) - - [Main & working directories](#main--working-directory) - - [Demo mode](#demo-mode) - - [Resolution](#window-resolution) - - [No start mode](#no-start-mode) - - [Customize JVM](#customize-jvm-java-virtual-machine) -- [Usage examples](#examples) - -# Arguments -The launcher support various arguments that make it really useful and faster than the official launcher -to test the game in offline mode *(custom username and UUID)*, or demo mode for example. - -*You can read the complete help message using `-h` argument.* - -## Mojang authentication -Do you want to authenticate using your Mojang account ? - -It's now possible using `-l` *(`--login`)* followed by your email or username (for legacy account). -You will be asked for the password once the launcher is start. *If you don't want cache the session, -you can use `-t` (`--temp-login`) flag.* - -> Session are stored in a separated file from official launcher *(`.minecraft/portablemc_tokens`)*, -note that no trace of your password remain in this file, so don't worry about using this ! - -> These arguments override arguments for offline username and UUID. - -Your session is cached and you want to invalidate it ? Use `--logout` followed by your email or username. -This do not start the game. +*This launcher is tested for Python 3.8 & 3.6, further testing using other versions are welcome.* + +# Table of contents +- [Sub-commands](#sub-commands) + - [Start the game](#start-the-game) + - [Authentication](#authentication) + - [Offline mode](#offline-mode) + - [Working directory](#working-directory) + - [Custom JVM](#custom-jvm) + - [Auto connect to a server](#auto-connect-to-a-server) + - [Miscellaneous](#miscellaneous) + - [Search for versions](#search-for-versions) + - [Authentication caching](#authentication-caching) + - [Addons](#addons) +- [Addons (how to)](#addons-how-to) + +# Sub-commands +Arguments are split between multiple sub-command. For example ` `. You can use `-h` +argument to display help *(also work for every sub-commands)*. + +You may need to use `--main-dir ` if you want to change the main directory of the game. The main +directory stores libraries, assets, versions and this launcher's credentials. **By default** the location +of this directory is OS-dependent, but always in your user's home directory, +[check wiki for more information](https://minecraft-fr.gamepedia.com/.minecraft). + +**In this example**, `` must be replaced by any command that +launch the script, for example `python3 portablemc.py`. + +**Note that** this script have a *[shebang](https://fr.wikipedia.org/wiki/Shebang)*, this can be +useful to launch the script on unix OS *(you must have executable permission)*. + +## Start the game +The ` start [arguments...] [version]` sub-command is used to prepare and launch the game. A lot +of arguments allows you to control how to game will behave. The only positional argument is the version, +you can either specify a full version id (which you can get from the [search](#search-for-versions) +sub-command), or a type of version to select the latest of this type (`release` (default) or `snapshot`). + +### Authentication +Online mode is supported by this launcher, use the `-l ` (`--login`) argument to +log into your account *(login with a username is now deprecated by Mojang)*. If your session is not +cached or no longer valid, the launcher will ask for the password. + +You can disable the session caching using the flag argument `-t` (`--temp-login`), if your session is +nor cached nor valid you will be asked for the password for every launch. + +**Note that** your password is not saved! Only the token is saved (the official launcher also do that) +in the file `portablemc_tokens` in the main directory (an argument may allow change of this location +in the future). + +### Offline mode +If you need fake offline accounts you can use `-u ` (`--username`) defines the username and/or +`-i ` (`--uuid`) to define your player's [UUID](https://fr.wikipedia.org/wiki/Universally_unique_identifier). + +If you omit the UUID, a random one is choosen. If you omit the username, the first 8 characters of the UUID +are used for it. **These two arguments are overwritten by the `-l` (`--login`) argument**. + +### Working directory +You can use the argument `--work-dir ` to change the directory where your saves, resource packs and +all "user-specific" content are stored. This can be useful if you have a shared read-only main directory +(`--main-dir`) and user-specific working directory (for example in `~/.minecraft`). + +When starting the game, the binaries (`.DLL`, `.SO` for exemple) are temporary copied to the directory +`/bin`, but you can tell the launcher to copy these binaries into your working directory using +the `--work-dir-bin` flag. This may be useful if you don't have permissions on the main directory. + +### Custom JVM +The Java Virtual Machine is used to run the game, by udefault the launcher use the `java` executable. You +can change it using `--jvm ` argument. By default, some JVM arguments are also passed, these arguments +are the following and were copied from the officiel launcher: -## Minecraft version -By default the launcher starts the latest release version, to change this, you can use the `-v` *(`--version`)* followed by the -version name, or `snapshot` to target the latest snapshot, `release` does the same for latest release. - -Using the `-s` *(`--search`)* flag you can tell this launcher to only search for all versions prefixed by the specified version of `-v` argument, -this stop the application just after searching. Exit codes: `15` if no version was found, else `0`. +``` +-Xmx2G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M +``` -> Note that latest version of Java may not work for old versions of Minecraft. +You can change these arguments using the `--jvm-args `. -## Username and UUID (manual offline mode) -By default, a random player [UUID](https://fr.wikipedia.org/wiki/Universally_unique_identifier) is used, and the username is -extracted from the first part of the UUID's represention *(for a `110e8400-e29b-11d4-a716-446655440000` uuid, the username will be `110e8400`)*. +### Auto connect to a server +Since Minecraft 1.6 *(at least, need further tests to confirm)* we can start the game and automatically +connect to a server. To do that you can use `-s ` (`--server`) for the server address +(e.g. `mc.hypixel.net`) and the `-p` (`--server-port`) to specify its port, by default to 25565. -You can use `-u` *(`--username`)* followed by the username and `-i` *(`--uuid`)* with your user UUID. +### Miscellaneous +With `--dry`, the game is prepared but not started. -> Note that even if you have set another UUID, the username will be the same as default (with extracted part from default UUID). +With `--demo` you can enable the [demo mode](https://minecraft.gamepedia.com/Demo_mode) of the game. -## Main & working directory -You can now configure directories used for game to work. These directories are: -- `--main-dir`: this directory store libraries, assets, versions, binaries (at runtime) and launcher cache *(default values [here](https://minecraft-fr.gamepedia.com/.minecraft))* -- `--work-dir`: this directory store game files like save, resource packs or logs *(if not specified, it is the same as main directory)*. +With `--resol x` you can change the resolution of the game window. -> **Shortcuts versions of previous arguments (`-md`, `-wd`) will be removed in future versions because it's not standard to have short argument with two letters.** +With `--no-better-logging` flag you can disable the better logging configuration used by the launcher +to avoid raw XML logging in the terminal. -> When using a main directory with portablemc for the first time, luncher will ask you to continue or not. +The two arguments `--disable-mp` (mp: multiplayer), `--disable-chat` are obvious *(since 1.16)*. -## Demo mode -Demo mode is a mostly unknown feature that allows to start the game with a restricted play duration, it is disabled by default. -Use `--demo` to enable. +## Search for versions +The ` search [-l] [version]` sub-command is used to search for versions. By default, this command +will search for official versions available to download, you can instead search for local versions +using the `-l` (`--local`) flag. The search string is optional, if not given all official or local +versions are displayed. -## Window resolution -You can set the default window resolution *(does not affect the game if already in fullscreen mode)* by using `--resol` followed by -`x`, `width` and `height` are positive integers. +## Authentication caching +Two subcommand allows you to cache or uncache sessions: ` login|logout `. +These subcommand doesn't prevent you from using the `-l` (`--login`) argument when starting the game, +these are just here to manage the session storage. -## No start mode -By using `--nostart` flag, you force the launcher to download all requirements to the game, but does not start it. +## Addons +The ` addon list|init|show` sub-commands are used to list, initialize (for developpers) and show +addons. -## Customize JVM (Java Virtual Machine) -By default the launcher use the `javaw` executable to launch Minecraft, if you want to -change this executable, use the `--jvm` argument followed by the executable. +# Addons (how to) +Addons for PortableMC are obviously optionnals, officially supported addons can be found in the +['addons' directory](https://github.com/mindstorm38/portablemc/tree/master/addons). +To install addons you need to make a directory `addons` next to the script, and then put addons into it. -You can also set JVM arguments string using the `--jvm-args`. By defaults the JVM arguments are the same as the officiel launcher: -``` --Xmx2G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M -``` - -> The `--java` argument is used if `--jvm` is not defined, but it will be removed in future versions. - -# Examples -``` -python portablemc.py Start latest Minecraft version using offline mode and random username and UUID -python portablemc.py -l Start latest Minecraft version using mojang authentication for specific email or username (legacy) -python portablemc.py -tl Same as previous command, but do not cache the session (you need to re-enter password on each launch) -python portablemc.py --nostart Download all components of the latest Minecraft version but do not start the game -python portablemc.py --logout Logout from a session -python portablemc.py -u OfflineTest -v 1.15 Start 1.15 Minecraft version in offline mode with a username 'OfflineTest' and random UUID -python portablemc.py -sv 1.7 Search for all versions starting with "1.7" -python portablemc.py --main-dir /tmp/mc Start latest Minecraft version in /tmp/mc instead of .minecraft -``` +To check if the addons are properly installed, you can use the ['addon list' sub-command](#addons). \ No newline at end of file diff --git a/addons/__init__.py b/addons/__init__.py new file mode 100644 index 00000000..5896b6e7 --- /dev/null +++ b/addons/__init__.py @@ -0,0 +1,3 @@ +# This file was generated by PortableMC. +# It's only purpose is to make this directory a valid python package. +# Do not modify this file unless you know what you are doing, because this file is not intended to be shared. diff --git a/addons/richer/__init__.py b/addons/richer/__init__.py new file mode 100644 index 00000000..ff8cd27b --- /dev/null +++ b/addons/richer/__init__.py @@ -0,0 +1,11 @@ + +NAME = "Richer" +VERSION = "0.0.1" +AUTHORS = "Théo Rozier" +REQUIRES = "prompt_toolkit" +DESCRIPTION = "Improve downloads progress bars and the game process terminal." + + +def addon_build(pmc): + from .richer import RicherAddon + return RicherAddon(pmc) diff --git a/addons/richer/richer.py b/addons/richer/richer.py new file mode 100644 index 00000000..13204d21 --- /dev/null +++ b/addons/richer/richer.py @@ -0,0 +1,325 @@ +from prompt_toolkit.shortcuts.progress_bar.formatters import Formatter, Label, Text, Percentage, Bar +from prompt_toolkit.layout.controls import FormattedTextControl, BufferControl +from prompt_toolkit.layout.containers import Window, HSplit, VSplit, Container +from prompt_toolkit.shortcuts import ProgressBar, ProgressBarCounter +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.layout import Layout, AnyDimension +from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.document import Document +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.styles import Style + +from typing import cast, Optional, TextIO, Callable +from asyncio import Queue, QueueFull, QueueEmpty +from argparse import ArgumentParser +from subprocess import Popen, PIPE +from threading import Thread +import asyncio + + +class RicherAddon: + + def __init__(self, pmc): + + self.pmc = pmc + + self.progress_bar_formatters = [ + Label(), + Text(" "), + Bar(sym_a="#", sym_b="#", sym_c="."), + Text(" ["), + ByteProgress(), + Text("] ["), + Percentage(), + Text("]"), + ] + + self.RollingLinesWindow = RollingLinesWindow + + def load(self): + + self.pmc.add_message("args.start.not_rich", "Disable the richer extension when starting the game.") + self.pmc.add_message("args.start.single_exit", "For richer terminal, when Minecraft process is terminated, do " + "not ask for Ctrl+C to effectively exit the terminal.") + self.pmc.add_message("start.run.richer.title", "Minecraft {} • {} • {}") + self.pmc.add_message("start.run.richer.command_line", "Command line: {}\n") + + self.pmc.mixin("register_start_arguments", self.register_start_arguments) + self.pmc.mixin("game_runner", self.game_runner) + self.pmc.mixin("download_file", self.download_file) + + def register_start_arguments(self, old, parser: ArgumentParser): + parser.add_argument("--not-rich", help=self.pmc.get_message("args.start.not_rich"), default=False, action="store_true") + parser.add_argument("--single-exit", help=self.pmc.get_message("args.start.single_exit"), default=False, action="store_true") + old(parser) + + def build_application(self, container: Container, keys: KeyBindings) -> Application: + return Application( + layout=Layout(container), + key_bindings=keys, + full_screen=True, + style=Style([ + ("header", "bg:#005fff fg:black") + ]) + ) + + def game_runner(self, old, proc_args: list, proc_cwd: str, options: dict): + + if options["cmd_args"].not_rich: + old(proc_args, proc_cwd, options) + return + + title_text = self.pmc.get_message("start.run.richer.title", + options.get("version", "unknown_version"), + options.get("username", "anonymous"), + options.get("uuid", "uuid")) + + buffer_window = RollingLinesWindow(400, lexer=ColoredLogLexer(), last_line_return=True) + buffer_window.append(self.pmc.get_message("start.run.richer.command_line", " ".join(proc_args)), "\n") + + container = HSplit([ + VSplit([ + Window(width=2), + Window(FormattedTextControl(text=title_text)), + ], height=1, style="class:header"), + VSplit([ + Window(width=1), + buffer_window, + Window(width=1) + ]) + ]) + + keys = KeyBindings() + double_exit = not options["cmd_args"].single_exit + + @keys.add("c-c") + def _exit(event: KeyPressEvent): + nonlocal process + if not double_exit or process is None: + event.app.exit() + else: + process.kill() + + application = self.build_application(container, keys) + process = Popen(proc_args, cwd=proc_cwd, stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True) + + async def _run_process(): + nonlocal process + stdout_reader = ThreadedProcessReader(cast(TextIO, process.stdout)) + stderr_reader = ThreadedProcessReader(cast(TextIO, process.stderr)) + while True: + code = process.poll() + if code is None: + done, pending = await asyncio.wait(( + stdout_reader.poll(), + stderr_reader.poll() + ), return_when=asyncio.FIRST_COMPLETED) + for done_task in done: + line = done_task.result() + if line is not None: + buffer_window.append(line) + for pending_task in pending: + pending_task.cancel() + else: + stdout_reader.wait_until_closed() + stderr_reader.wait_until_closed() + buffer_window.append(*stdout_reader.poll_all(), *stderr_reader.poll_all()) + break + process = None + if double_exit: + buffer_window.append("", "Minecraft process has terminated, Ctrl+C again to close terminal.") + + async def _run(): + _done, _pending = await asyncio.wait(( + _run_process(), + application.run_async() + ), return_when=asyncio.ALL_COMPLETED if double_exit else asyncio.FIRST_COMPLETED) + if process is not None: + process.kill() + process.wait(timeout=5) + if application.is_running: + application.exit() + + asyncio.get_event_loop().run_until_complete(_run()) + + def download_file(self, _old, entry, **kwargs): + with ProgressBar(formatters=self.progress_bar_formatters) as pb: + progress_task = pb(label=entry.name, total=entry.size) + def progress_callback(p_dl_size: int, _p_size: int, _p_dl_total_size: int, _p_total_size: int): + progress_task.items_completed = p_dl_size + pb.invalidate() + kwargs["progress_callback"] = progress_callback + return self.pmc.download_file_base(entry, **kwargs) + + +class RollingLinesWindow: + + def __init__(self, limit: int, *, + lexer: 'Optional[Lexer]' = None, + wrap_lines: bool = False, + dont_extend_height: bool = False, + last_line_return: bool = False): + + self.last_line_return = last_line_return + self.buffer = Buffer(read_only=True) + self.string_buffer = RollingLinesBuffer(limit) + self.window = Window( + content=BufferControl(buffer=self.buffer, lexer=lexer, focusable=True), + wrap_lines=wrap_lines, + dont_extend_height=dont_extend_height + ) + + def append(self, *lines: str): + if self.string_buffer.append(*lines): + cursor_pos = None + new_text = self.string_buffer.get() + if self.last_line_return: + new_text += "\n" + if self.buffer.cursor_position < len(self.buffer.text): + cursor_pos = self.buffer.cursor_position + self.buffer.set_document(Document(text=new_text, cursor_position=cursor_pos), bypass_readonly=True) + + def __pt_container__(self): + return self.window + + +class RollingLinesBuffer: + + def __init__(self, limit: int): + self._strings = [] + self._limit = limit + + def append(self, *lines: str) -> bool: + if not len(lines): + return False + for line in lines: + if not len(line): + self._strings.append("") + else: + self._strings.extend(line.splitlines()) + while len(self._strings) > self._limit: + self._strings.pop(0) + return True + + def get(self) -> str: + return "\n".join(self._strings) + + +class ThreadedProcessReader: + + def __init__(self, in_stream: TextIO): + self._input = in_stream + self._queue = Queue(100) + self._thread = Thread(target=self._entry, daemon=True) + self._thread.start() + self._closed = False + + def _entry(self): + try: + for line in iter(self._input.readline, ""): + try: + self._queue.put_nowait(line) + except QueueFull: + pass + self._input.close() + except ValueError: + pass + try: + self._queue.put_nowait("") + except QueueFull: + pass + + def wait_until_closed(self): + self._input.close() + self._thread.join(5000) + + async def poll(self) -> Optional[str]: + if self._closed: + return None + val = await self._queue.get() + if not len(val): + self._closed = True + return None if self._closed else val + + def poll_all(self): + try: + val = self._queue.get_nowait() + while val is not None: + yield val + val = self._queue.get_nowait() + except QueueEmpty: + pass + + +class ColoredLogLexer(Lexer): + + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + lines = document.lines + + def get_line(lineno: int) -> StyleAndTextTuples: + + try: + + line = lines[lineno] + + tmp_line = line + tmp_lineno = lineno + got_exception = False + + def has_exception() -> bool: + nonlocal got_exception + got_exception = "Exception" in tmp_line + return got_exception + + while tmp_line[0] == "\t" or has_exception(): + tmp_lineno -= 1 + tmp_line = lines[tmp_lineno] + if tmp_lineno < 0: + return [] + if got_exception: + break + + if "WARN" in tmp_line: + style = "#ffaf00" + elif "ERROR" in tmp_line or got_exception: + style = "#ff005f" + elif "FATAL" in tmp_line: + style = "#bf001d" + else: + style = "" + + return [(style, line.replace("\t", " "))] + + except IndexError: + return [] + + return get_line + + +class ByteProgress(Formatter): + + template = "{current}" + + def format(self, progress_bar: "ProgressBar", progress: "ProgressBarCounter[object]", width: int) -> AnyFormattedText: + n = progress.items_completed + if n < 1000: + return "{:4.0f}B".format(n) + elif n < 1000000: + return "{:4.0f}kB".format(n // 1000) + elif n < 1000000000: + return "{:4.0f}MB".format(n // 1000000) + else: + return "{:4.0f}GB".format(n // 1000000000) + + def get_width(self, progress_bar: "ProgressBar") -> AnyDimension: + width = 5 + for counter in progress_bar.counters: + if counter.items_completed >= 1000: + width = 6 + break + return Dimension.exact(width) diff --git a/addons/scripting/__init__.py b/addons/scripting/__init__.py new file mode 100644 index 00000000..e3f3cbae --- /dev/null +++ b/addons/scripting/__init__.py @@ -0,0 +1,11 @@ + +NAME = "Scripting" +VERSION = "0.0.1" +AUTHORS = "Théo Rozier" +REQUIRES = "addon:richer", "prompt_toolkit" +DESCRIPTION = "Improve the 'richer' addon's game terminal by adding a Python interpreter for the java's reflection API." + + +def addon_build(pmc): + from .scripting import ScriptingAddon + return ScriptingAddon(pmc) diff --git a/addons/scripting/buffer.py b/addons/scripting/buffer.py new file mode 100644 index 00000000..599dbd1c --- /dev/null +++ b/addons/scripting/buffer.py @@ -0,0 +1,93 @@ +from typing import Optional, Union +import struct + + +class ByteBuffer: + + def __init__(self, size: int): + self.data = bytearray(size) + self.limit = 0 + self.pos = 0 + + def clear(self): + self.pos = 0 + self.limit = len(self.data) + + def remaining(self) -> int: + return self.limit - self.pos + + def lshift(self, count: int): + self.data[:(len(self.data) - count)] = self.data[count:] + + def ensure_len(self, length: int, offset: Optional[int] = None): + real_offset = self.pos if offset is None else offset + if real_offset + length > self.limit: + raise ValueError("No more space in the buffer (pos: {}, limit: {}).".format(self.pos, self.limit)) + else: + if offset is None: + self.pos += length + return real_offset + + # PUT # + + def put(self, byte: int, *, offset=None): + struct.pack_into(">B", self.data, self.ensure_len(1, offset), byte & 0xFF) + + def put_bytes(self, arr: Union[bytes, bytearray], length=None, *, offset=None): + if length is None: + length = len(arr) + pos = self.ensure_len(length, offset) + self.data[pos:(pos + length)] = arr[:length] + + def put_short(self, short: int, *, offset=None): + struct.pack_into(">H", self.data, self.ensure_len(2, offset), short & 0xFFFF) + + def put_int(self, integer: int, *, offset=None): + struct.pack_into(">I", self.data, self.ensure_len(4, offset), integer & 0xFFFFFFFF) + + def put_long(self, long: int, *, offset=None): + struct.pack_into(">Q", self.data, self.ensure_len(8, offset), long & 0xFFFFFFFFFFFFFFFF) + + def put_float(self, flt: float, *, offset=None): + struct.pack_into(">f", self.data, self.ensure_len(4, offset), flt) + + def put_double(self, dbl: float, *, offset=None): + struct.pack_into(">d", self.data, self.ensure_len(8, offset), dbl) + + def put_char(self, char: str, *, offset=None): + self.put_short(ord(char[0]), offset=offset) + + def put_string(self, string: str, *, offset=None): + str_buf = string.encode() + str_buf_len = len(str_buf) + offset = self.ensure_len(2 + str_buf_len, offset) + self.put_short(str_buf_len, offset=offset) + self.data[(offset + 2):(offset + 2 + str_buf_len)] = str_buf + + # GET # + + def get(self, *, offset=None, signed=True) -> int: + return struct.unpack_from(">b" if signed else ">B", self.data, self.ensure_len(1, offset))[0] + + def get_short(self, *, offset=None, signed=True) -> int: + return struct.unpack_from(">h" if signed else ">H", self.data, self.ensure_len(2, offset))[0] + + def get_int(self, *, offset=None, signed=True) -> int: + return struct.unpack_from(">i" if signed else ">I", self.data, self.ensure_len(4, offset))[0] + + def get_long(self, *, offset=None, signed=True) -> int: + return struct.unpack_from(">q" if signed else ">Q", self.data, self.ensure_len(8, offset))[0] + + def get_float(self, *, offset=None) -> int: + return struct.unpack_from(">f", self.data, self.ensure_len(4, offset))[0] + + def get_double(self, *, offset=None) -> int: + return struct.unpack_from(">d", self.data, self.ensure_len(8, offset))[0] + + def get_char(self, *, offset=None) -> str: + return chr(self.get_short(offset=offset, signed=False)) + + def get_string(self, *, offset=None) -> str: + str_len = self.get_short(offset=offset, signed=False) + str_pos = self.ensure_len(str_len) + return self.data[str_pos:(str_pos + str_len)].decode() diff --git a/addons/scripting/java/.gitignore b/addons/scripting/java/.gitignore new file mode 100644 index 00000000..e2cc4693 --- /dev/null +++ b/addons/scripting/java/.gitignore @@ -0,0 +1,3 @@ +/.idea +/*.iml +/out/production \ No newline at end of file diff --git a/addons/scripting/java/out/portablemc_scripting.jar b/addons/scripting/java/out/portablemc_scripting.jar new file mode 100644 index 00000000..75feb7ce Binary files /dev/null and b/addons/scripting/java/out/portablemc_scripting.jar differ diff --git a/addons/scripting/java/src/META-INF/MANIFEST.MF b/addons/scripting/java/src/META-INF/MANIFEST.MF new file mode 100644 index 00000000..35fd0ba9 --- /dev/null +++ b/addons/scripting/java/src/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Main-Class: portablemc.scripting.ScriptingClient + diff --git a/addons/scripting/java/src/portablemc/scripting/ScriptingClient.java b/addons/scripting/java/src/portablemc/scripting/ScriptingClient.java new file mode 100644 index 00000000..6c0f11ec --- /dev/null +++ b/addons/scripting/java/src/portablemc/scripting/ScriptingClient.java @@ -0,0 +1,563 @@ +package portablemc.scripting; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.Inet4Address; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; + +public class ScriptingClient implements Runnable { + + public static void main(String[] args) { + + String scriptingMain = System.getProperty("portablemc.scripting.main"); + String rawPort = System.getProperty("portablemc.scripting.port"); + int port = 0; + + if (scriptingMain == null) { + System.err.println("No scripting main class to call, please register property 'portablemc.scripting.main'."); + System.exit(1); + } else if (rawPort == null) { + System.err.println("No scripting port, please specify the server port using 'portablemc.scripting.port'."); + System.exit(1); + } else { + try { + port = Integer.parseInt(rawPort); + } catch (NumberFormatException e) { + System.err.println("Invalid scripting server port '" + rawPort + "'."); + System.exit(1); + } + } + + ScriptingClient client = new ScriptingClient(port); + Thread thread = new Thread(client, "PortableMC Scripting Client Thread"); + thread.setDaemon(true); + thread.start(); + + try { + Class clazz = Class.forName(scriptingMain); + Method method = clazz.getMethod("main", String[].class); + method.invoke(clazz, new Object[]{args}); + } catch (ReflectiveOperationException e) { + System.err.println("Main class not found or invalid as entry point."); + e.printStackTrace(); + System.exit(1); + } + + try { + client.stop(); + thread.join(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + } + + // [0:9] Classes + private static final byte PACKET_GET_CLASS = 1; + private static final byte PACKET_GET_FIELD = 2; + private static final byte PACKET_GET_METHOD = 3; + // [10:19] Fields + private static final byte PACKET_FIELD_GET = 10; + private static final byte PACKET_FIELD_SET = 11; + // [20:29] Methods + private static final byte PACKET_METHOD_INVOKE = 20; + // [30:39] Various objects + private static final byte PACKET_OBJECT_GET_CLASS = 30; + private static final byte PACKET_OBJECT_IS_INSTANCE = 31; + // [40:49] Callbacks + private static final byte PACKET_BIND_CALLBACK = 40; + // [100:109] Results + private static final byte PACKET_RESULT = 100; + private static final byte PACKET_RESULT_CLASS = 101; + private static final byte PACKET_RESULT_BYTE = 102; + // [110:119] Errors + private static final byte PACKET_GENERIC_ERROR = 110; + + private static final HashMap> PRIMITIVE_TYPES = new HashMap<>(); + + static { + PRIMITIVE_TYPES.put("byte", byte.class); + PRIMITIVE_TYPES.put("short", short.class); + PRIMITIVE_TYPES.put("int", int.class); + PRIMITIVE_TYPES.put("long", long.class); + PRIMITIVE_TYPES.put("float", float.class); + PRIMITIVE_TYPES.put("double", double.class); + PRIMITIVE_TYPES.put("boolean", boolean.class); + PRIMITIVE_TYPES.put("char", char.class); + } + + private final int port; + private final Socket socket; + private final ArrayList objects = new ArrayList<>(); + private final HashMap objectsIndices = new HashMap<>(); + private int callbackIdCounter = 0; + + private final ByteBuffer txBuf = ByteBuffer.allocate(4096); + private final ByteBuffer rxBuf = ByteBuffer.allocate(4096); + private OutputStream txStream; + + public ScriptingClient(int port) { + this.port = port; + this.socket = new Socket(); + } + + public void stop() { + try { + this.socket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public void run() { + + try { + + print("Connecting to server at 127.0.0.1:" + this.port + "..."); + this.socket.connect(new InetSocketAddress(Inet4Address.getByName("127.0.0.1"), this.port)); + print("Connected!"); + + InputStream rxStream = this.socket.getInputStream(); + this.txStream = this.socket.getOutputStream(); + + ByteBuffer rxBuf = this.rxBuf; + rxBuf.clear(); + + int readLength, rxPos; + int nextPacketLength = 0; + + while (!this.socket.isClosed()) { + + rxPos = rxBuf.position(); + + if (nextPacketLength == 0 && rxPos >= 3) { + nextPacketLength = Short.toUnsignedInt(rxBuf.getShort(1)) + 3; // +3 for the header + } + + if (nextPacketLength != 0 && nextPacketLength >= rxPos) { + + rxBuf.limit(nextPacketLength); + rxBuf.position(3); + + byte packetType = rxBuf.get(0); + + try { + this.decodePacket(packetType); + } catch (IOException e) { + print("Failed to decode packet " + packetType); + e.printStackTrace(); + } catch (RuntimeException e) { + this.sendGenericError(e.getMessage()); + e.printStackTrace(); + } + + byte[] rxData = rxBuf.array(); + System.arraycopy(rxData, nextPacketLength, rxData, 0, rxData.length - nextPacketLength); + + rxBuf.clear(); + rxBuf.position(rxPos - nextPacketLength); + nextPacketLength = 0; + + } else { + if ((readLength = rxStream.read(rxBuf.array(), rxPos, rxBuf.remaining())) > 0) { + rxBuf.position(rxPos + readLength); + } + } + + } + + print("Scripting client stopped!"); + + } catch (IOException e) { + e.printStackTrace(); + } + + } + + // Packet encoding-decoding // + + private void preparePacket() { + this.txBuf.clear(); + this.txBuf.position(this.txBuf.position() + 3); // Reserved for packet length + } + + private void sendPacket(byte packetType) throws IOException { + int len = this.txBuf.position(); + this.txBuf.put(0, packetType); + this.txBuf.putShort(1, (short) (len - 3)); + this.txStream.write(this.txBuf.array(), 0, len); + } + + private void decodePacket(int packetType) throws IOException { + + ByteBuffer rxBuf = this.rxBuf; + this.preparePacket(); + + if (packetType == PACKET_GET_CLASS) { + this.putIndex(this.ensureCachedClass(getString(rxBuf))); + } else if (packetType == PACKET_GET_FIELD) { + + int classIdx = rxBuf.getInt(); + String fieldName = getString(rxBuf); + int typeClassIdx = rxBuf.getInt(); + Class typeClass = this.getCachedObjectChecked(typeClassIdx, Class.class); + + if (typeClass == null) { + this.putNull(); + } else { + this.putIndex(this.ensureCachedField(classIdx, fieldName, typeClass)); + } + + } else if (packetType == PACKET_GET_METHOD) { + + int classIdx = rxBuf.getInt(); + String methodName = getString(rxBuf); + Class[] parameterTypes = this.getParameterTypes(); + + if (parameterTypes == null) { + this.putNull(); + } else if (methodName.isEmpty()) { // If the name is empty, assume that the request was for a constructor. + this.putIndex(this.ensureCachedConstructor(classIdx, parameterTypes)); + } else { + this.putIndex(this.ensureCachedMethod(classIdx, methodName, parameterTypes)); + } + + } else if (packetType == PACKET_FIELD_GET) { + + int fieldIdx = rxBuf.getInt(); + Object ownerObj = this.getValue(); + this.putValue(this.getCachedFieldValue(fieldIdx, ownerObj)); + + } else if (packetType == PACKET_FIELD_SET) { + + int fieldIdx = rxBuf.getInt(); + Object ownerObj = this.getValue(); + Object valueObj = this.getValue(); + this.setCachedFieldValue(fieldIdx, ownerObj, valueObj); + this.putNull(); + + } else if (packetType == PACKET_METHOD_INVOKE) { + + int methodIdx = rxBuf.getInt(); + Object ownerObj = this.getValue(); + int paramsCount = Byte.toUnsignedInt(rxBuf.get()); + Object[] parameterValues = new Object[paramsCount]; + + for (int i = 0; i < paramsCount; ++i) { + parameterValues[i] = this.getValue(); + } + + this.putValue(this.invokeCachedMethod(methodIdx, ownerObj, parameterValues)); + + } else if (packetType == PACKET_OBJECT_GET_CLASS) { + + Object obj = this.getValue(); + if (obj == null) { + this.putNull(); + } else { + Class objClass = obj.getClass(); + this.putIndex(this.ensureCachedObject(objClass)); + putString(this.txBuf, objClass.getName()); + } + + this.sendPacket(PACKET_RESULT_CLASS); + return; + + } else if (packetType == PACKET_OBJECT_IS_INSTANCE) { + + Class cls = this.getCachedObjectChecked(rxBuf.getInt(), Class.class); + Object obj = this.getCachedObjectChecked(rxBuf.getInt(), Object.class); + this.txBuf.put((byte) (cls != null && cls.isInstance(obj) ? 1 : 0)); + this.sendPacket(PACKET_RESULT_BYTE); + return; + + } else if (packetType == PACKET_BIND_CALLBACK) { + + Class cls = this.getCachedObjectChecked(rxBuf.getInt(), Class.class); + final int callbackId = ++this.callbackIdCounter; + Object callback = null; + + if (cls == Runnable.class) { + callback = (Runnable) () -> { + // TODO + }; + } + + } else { + String errMessage = "Illegal packet type: " + packetType; + this.sendGenericError(errMessage); + print(errMessage); + return; + } + + this.sendPacket(PACKET_RESULT); + + } + + private void sendGenericError(String message) throws IOException { + this.preparePacket(); + putString(this.txBuf, message); + this.sendPacket(PACKET_GENERIC_ERROR); + } + + private Object getValue() { + ByteBuffer rxBuf = this.rxBuf; + int objIndex = rxBuf.getInt(); + if (objIndex < 0) { + switch (objIndex) { + case -2: + return rxBuf.get(); + case -3: + return rxBuf.getShort(); + case -4: + return rxBuf.getInt(); + case -5: + return rxBuf.getLong(); + case -6: + return rxBuf.getFloat(); + case -7: + return rxBuf.getDouble(); + case -8: + return rxBuf.getChar(); + case -9: + return getString(rxBuf); + case -10: + return Boolean.FALSE; + case -11: + return Boolean.TRUE; + default: + return null; + } + } else { + return this.getCachedObjectChecked(objIndex, Object.class); + } + } + + private void putValue(Object value) { + ByteBuffer txBuf = this.txBuf; + if (value == null) { + txBuf.putInt(-1); + } else { + Class clazz = value.getClass(); + if (clazz == Byte.class) { + txBuf.putInt(-2); + txBuf.put((Byte) value); + } else if (clazz == Short.class) { + txBuf.putInt(-3); + txBuf.putShort((Short) value); + } else if (clazz == Integer.class) { + txBuf.putInt(-4); + txBuf.putInt((Integer) value); + } else if (clazz == Long.class) { + txBuf.putInt(-5); + txBuf.putLong((Long) value); + } else if (clazz == Float.class) { + txBuf.putInt(-6); + txBuf.putFloat((Float) value); + } else if (clazz == Double.class) { + txBuf.putInt(-7); + txBuf.putDouble((Double) value); + } else if (clazz == Character.class) { + txBuf.putInt(-8); + txBuf.putDouble((Character) value); + } else if (clazz == String.class) { + txBuf.putInt(-9); + putString(txBuf, (String) value); + } else if (clazz == Boolean.class) { + if ((boolean) value) { + txBuf.putInt(-11); + } else { + txBuf.putInt(-10); + } + } else { + txBuf.putInt(this.ensureCachedObject(value)); + } + } + } + + private void putIndex(int index) { + this.txBuf.putInt(index); + } + + private void putNull() { + this.txBuf.putInt(-1); + } + + private Class[] getParameterTypes() { + ByteBuffer rxBuf = this.rxBuf; + int paramsCount = Byte.toUnsignedInt(rxBuf.get()); + Class[] parameterTypes = new Class[paramsCount]; + for (int i = 0; i < paramsCount; ++i) { + Class clazz = this.getCachedObjectChecked(rxBuf.getInt(), Class.class); + if (clazz == null) { + return null; + } + parameterTypes[i] = clazz; + } + return parameterTypes; + } + + // Cached objects // + + private int ensureCachedObject(Object object) { + return this.objectsIndices.computeIfAbsent(object, obj -> { + int idx = this.objects.size(); + this.objects.add(obj); + return idx; + }); + } + + private T getCachedObjectChecked(int idx, Class clazz) { + if (idx < 0 || idx >= this.objects.size()) { + print("No " + clazz.getSimpleName() + " indexed at " + idx); + return null; + } + try { + return clazz.cast(this.objects.get(idx)); + } catch (ClassCastException e) { + print("Object with id " + idx + " is not a " + clazz.getSimpleName()); + return null; + } + } + + private int ensureCachedClass(String className) { + try { + Class clazz = PRIMITIVE_TYPES.get(className); + return this.ensureCachedObject(clazz == null ? Class.forName(className) : clazz); + } catch (ClassNotFoundException e) { + print("Class not found: " + className); + return -1; + } + } + + private int ensureCachedField(int classIdx, String fieldName, Class typeClass) { + Class clazz = this.getCachedObjectChecked(classIdx, Class.class); + if (clazz == null) { + return -1; + } + try { + Field field = clazz.getDeclaredField(fieldName); + if (field.getType() == typeClass) { + field.setAccessible(true); + return this.ensureCachedObject(field); + } else { + print("Field " + field + " has not the expected type " + typeClass.getSimpleName() + ", got " + field.getType().getSimpleName()); + return -1; + } + } catch (NoSuchFieldException e) { + print("Can't find field " + clazz.getSimpleName() + "." + fieldName); + return -1; + } + } + + private int ensureCachedMethod(int classIdx, String methodName, Class[] parameterTypes) { + Class clazz = this.getCachedObjectChecked(classIdx, Class.class); + if (clazz == null) { + return -1; + } + try { + Method method = clazz.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return this.ensureCachedObject(method); + } catch (NoSuchMethodException e) { + print("Can't find method " + clazz.getSimpleName() + "." + methodName + Arrays.toString(parameterTypes)); + return -1; + } + } + + private int ensureCachedConstructor(int classIdx, Class[] parameterTypes) { + Class clazz = this.getCachedObjectChecked(classIdx, Class.class); + if (clazz == null) { + return -1; + } + try { + Constructor constructor = clazz.getDeclaredConstructor(parameterTypes); + constructor.setAccessible(true); + return this.ensureCachedObject(constructor); + } catch (NoSuchMethodException e) { + print("Can't find constructor " + clazz.getSimpleName() + Arrays.toString(parameterTypes)); + return -1; + } + } + + private Object getCachedFieldValue(int fieldIdx, Object ownerObj) { + Field field = this.getCachedObjectChecked(fieldIdx, Field.class); + if (field == null) { + return null; + } + try { + return field.get(ownerObj); + } catch (IllegalAccessException e) { + print("Can't get field value " + field); + e.printStackTrace(); + return null; + } + } + + private void setCachedFieldValue(int fieldIdx, Object ownerObj, Object value) { + Field field = this.getCachedObjectChecked(fieldIdx, Field.class); + if (field == null) { + return; + } + try { + field.set(ownerObj, value); + } catch (IllegalAccessException e) { + print("Can't get field value " + field); + e.printStackTrace(); + } + } + + private Object invokeCachedMethod(int methodIdx, Object ownerObj, Object[] parameterValues) { + Executable exec = this.getCachedObjectChecked(methodIdx, Executable.class); + if (exec == null) { + return null; + } + try { + if (exec.getClass() == Method.class) { + return ((Method) exec).invoke(ownerObj, parameterValues); + } else { + return ((Constructor) exec).newInstance(parameterValues); + } + } catch (IllegalAccessException | InvocationTargetException | InstantiationException e) { + print("Can't invoke " + exec); + e.printStackTrace(); + return null; + } + } + + // UTF encode utilities // + + private static void putString(ByteBuffer dst, String src) { + byte[] bytes = src.getBytes(StandardCharsets.UTF_8); + dst.putShort((short) bytes.length); + dst.put(bytes); + } + + private static String getString(ByteBuffer src) { + int length = Short.toUnsignedInt(src.getShort()); + int pos = src.position(); + String res = new String(src.array(), pos, length, StandardCharsets.UTF_8); + src.position(pos + length); + return res; + } + + // Print output // + + private static void print(String msg) { + System.out.println(msg); + } + +} diff --git a/addons/scripting/mc/__init__.py b/addons/scripting/mc/__init__.py new file mode 100644 index 00000000..429d72f9 --- /dev/null +++ b/addons/scripting/mc/__init__.py @@ -0,0 +1,5 @@ +from .entity import * +from .level import * +from .text import * +from .base import * +from .gui import * diff --git a/addons/scripting/mc/base.py b/addons/scripting/mc/base.py new file mode 100644 index 00000000..67bb8711 --- /dev/null +++ b/addons/scripting/mc/base.py @@ -0,0 +1,58 @@ +from ..reflect import Runtime, Object, Wrapper, FieldCache +from ..std import Queue, Runnable + +from .entity import LocalPlayer +from .level import ClientLevel +from .gui import Gui + +from typing import Optional + + +class Minecraft(Wrapper): + + class_name = "djz" + field_player = FieldCache(lambda: (Minecraft, "s", LocalPlayer)) # player + field_level = FieldCache(lambda: (Minecraft, "r", ClientLevel)) # level + field_progress_tasks = FieldCache(lambda: (Minecraft, "aU", Queue)) # progressTasks + + def __init__(self, raw: Object): + super().__init__(raw) + self._gui: Optional[Gui] = None + self._progress_tasks: Optional[Queue] = None + + @classmethod + def get_instance(cls, rt: 'Runtime') -> 'Minecraft': + class_minecraft = rt.types[Minecraft] + field_instance = class_minecraft.get_field("F", class_minecraft) # instance + return Minecraft(field_instance.get_static()) + + @property + def player(self) -> 'Optional[LocalPlayer]': + raw = self.field_player.get(self._raw) + return None if raw is None else LocalPlayer(raw) + + @property + def level(self) -> 'Optional[ClientLevel]': + raw = self.field_level.get(self._raw) + return None if raw is None else ClientLevel(raw) + + @property + def gui(self) -> 'Gui': + if self._gui is None: + # This field is final in Minecraft's code, so we can cache it. + class_minecraft = self.runtime.types[Minecraft] + field_gui = class_minecraft.get_field("j", self.runtime.types[Gui]) # gui + self._gui = Gui(field_gui.get(self._raw)) + return self._gui + + def _get_progress_tasks(self) -> Queue: + # Progress tasks is a queue of Runnables, this queue can be used + # to synchronize critical method calls. This queue is thread-safe. + if self._progress_tasks is None: + self._progress_tasks = Queue(self.field_progress_tasks.get(self._raw), Runnable) + return self._progress_tasks + + + + def __str__(self) -> str: + return "" \ No newline at end of file diff --git a/addons/scripting/mc/entity.py b/addons/scripting/mc/entity.py new file mode 100644 index 00000000..52a130fe --- /dev/null +++ b/addons/scripting/mc/entity.py @@ -0,0 +1,92 @@ +from ..reflect import Wrapper, FieldCache, MethodCache +from ..std import Enum +from .text import Component +import enum + + +__all__ = ["EntityPose", "Entity", "LivingEntity", "Player", "AbstractClientPlayer", "LocalPlayer"] + + +class EntityPose(enum.Enum): + + STANDING = 0 + FALL_FLYING = 1 + SLEEPING = 2 + SWIMMING = 3 + SPIN_ATTACK = 4 + CROUCHING = 5 + DYING = 6 + + +class Entity(Wrapper): + + class_name = "aqa" # Entity + field_x = FieldCache(lambda: (Entity, "m", "double")) # xo + field_y = FieldCache(lambda: (Entity, "n", "double")) # yo + field_z = FieldCache(lambda: (Entity, "o", "double")) # zo + method_get_pose = MethodCache(lambda: (Entity, "ae")) # getPose + method_get_name = MethodCache(lambda: (Entity, "R")) # getName + method_get_type_name = MethodCache(lambda: (Entity, "bJ")) # getTypeName + method_set_shared_flag = MethodCache(lambda: (Entity, "b", "int", "boolean")) # setSharedFlag + method_is_glowing = MethodCache(lambda: (Entity, "bE")) # isGlowing + method_set_glowing = MethodCache(lambda: (Entity, "i", "boolean")) # setGlowing + + @property + def x(self) -> float: + return self.field_x.get(self._raw) + + @property + def y(self) -> float: + return self.field_y.get(self._raw) + + @property + def z(self) -> float: + return self.field_z.get(self._raw) + + @property + def pose(self) -> EntityPose: + raw_enum = self.method_get_pose.invoke(self._raw) + if raw_enum is None: + return EntityPose.STANDING + else: + try: + return EntityPose[Enum(raw_enum).name] + except KeyError: + return EntityPose.STANDING + + @property + def glowing(self) -> bool: + return self.method_is_glowing.invoke(self._raw) + + @glowing.setter + def glowing(self, glowing: bool): + # self.method_set_glowing.invoke(self._raw, glowing) + self.method_set_shared_flag.invoke(self._raw, 6, glowing) + # This setter is not reliable for unknown reason. + + @property + def name(self) -> Component: + return Component(self.method_get_name.invoke(self._raw)) + + @property + def type_name(self) -> Component: + return Component(self.method_get_type_name.invoke(self._raw)) + + def __str__(self): + return "<{}>".format(self.__class__.__name__) + + +class LivingEntity(Entity): + class_name = "aqm" + + +class Player(LivingEntity): + class_name = "bfw" + + +class AbstractClientPlayer(Player): + class_name = "dzj" + + +class LocalPlayer(Player): + class_name = "dzm" diff --git a/addons/scripting/mc/gui.py b/addons/scripting/mc/gui.py new file mode 100644 index 00000000..a08ed402 --- /dev/null +++ b/addons/scripting/mc/gui.py @@ -0,0 +1,66 @@ +from ..reflect import Wrapper, MethodCache +from .text import Component + +from typing import Any, Optional + + +__all__ = ["Gui", "Chat"] + + +class Gui(Wrapper): + + class_name = "dkv" + method_set_overlay_message = MethodCache(lambda: (Gui, "a", Component, "boolean")) # setOverlayMessage + method_set_titles = MethodCache(lambda: (Gui, "a", Component, Component, "int", "int", "int")) # setTitles + method_get_chat = MethodCache(lambda: (Gui, "c")) + + def __init__(self, raw): + super().__init__(raw) + self._chat: 'Optional[Chat]' = None + + def set_overlay(self, comp: Any, animate_color: bool = False): + comp = Component.ensure_component(self.runtime, comp) + self.method_set_overlay_message.invoke(self._raw, comp.raw, animate_color) + + def set_title(self, *, title: Optional[Any] = None, subtitle: Optional[Any] = None, fade_in: int = -1, stay: int = -1, fade_out: int = -1): + + if fade_in != -1 or stay != -1 or fade_out != -1: + self.method_set_titles.invoke(self._raw, None, None, fade_in, stay, fade_out) + + if title is not None: + title = Component.ensure_component(self.runtime, title) + self.method_set_titles.invoke(self._raw, title.raw, None, -1, -1, -1) + + if subtitle is not None: + subtitle = Component.ensure_component(self.runtime, subtitle) + self.method_set_titles.invoke(self._raw, None, subtitle.raw, -1, -1, -1) + + @property + def chat(self) -> 'Chat': + if self._chat is None: + self._chat = Chat(self.method_get_chat.invoke(self._raw)) + return self._chat + + def __str__(self): + return "" + + +class Chat(Wrapper): + + class_name = "dlk" # ChatComponent + method_clear_messages = MethodCache(lambda: (Chat, "a", "boolean")) + method_add_message = MethodCache(lambda: (Chat, "a", Component, "int")) + method_remove_message = MethodCache(lambda: (Chat, "b", "int")) + + def clear_messages(self, *, clear_history: bool = False): + self.method_clear_messages.invoke(self._raw, clear_history) + + def add_message(self, comp: Any, *, msg_id: int = 0): + comp = Component.ensure_component(self.runtime, comp) + self.method_add_message.invoke(self._raw, comp.raw, msg_id) + + def remove_message(self, msg_id: int): + self.method_remove_message.invoke(self._raw, msg_id) + + def __str__(self): + return "" diff --git a/addons/scripting/mc/level.py b/addons/scripting/mc/level.py new file mode 100644 index 00000000..b46964ed --- /dev/null +++ b/addons/scripting/mc/level.py @@ -0,0 +1,54 @@ +from ..reflect import Object, Wrapper, FieldCache, MethodCache +from .entity import AbstractClientPlayer +from ..std import List + + +__all__ = ["LevelData", "WritableLevelData", "Level", "ClientLevel"] + + +class LevelData(Wrapper): + class_name = "cyd" + + +class WritableLevelData(LevelData): + class_name = "cyo" + + +class Level(Wrapper): + + class_name = "brx" + field_level_data = FieldCache(lambda: (Level, "u", WritableLevelData)) # fieldData + method_get_game_time = MethodCache(lambda: (LevelData, "e")) # getGameTime() + method_get_day_time = MethodCache(lambda: (LevelData, "f")) # getDayTime() + method_is_raining = MethodCache(lambda: (LevelData, "k")) # isRaining() + method_is_thundering = MethodCache(lambda: (LevelData, "i")) # isThundering() + __slots__ = "_level_data" + + def __init__(self, raw: Object): + super().__init__(raw) + self._level_data = self.field_level_data.get(raw) + + @property + def game_time(self) -> int: + return self.method_get_game_time.invoke(self._level_data) + + @property + def day_time(self) -> int: + return self.method_get_day_time.invoke(self._level_data) + + @property + def is_raining(self) -> int: + return self.method_is_raining.invoke(self._level_data) + + @property + def is_thundering(self) -> int: + return self.method_is_thundering.invoke(self._level_data) + + +class ClientLevel(Level): + + class_name = "dwt" + method_get_players = MethodCache(lambda: (ClientLevel, "x")) # players() + + def get_players(self) -> 'List': + return List(self.method_get_players.invoke(self._raw), AbstractClientPlayer) diff --git a/addons/scripting/mc/text.py b/addons/scripting/mc/text.py new file mode 100644 index 00000000..a886e7ed --- /dev/null +++ b/addons/scripting/mc/text.py @@ -0,0 +1,35 @@ +from ..reflect import Runtime, Wrapper, MethodCache, ConstructorCache +from ..std import String +from typing import Any + + +__all__ = ["Component", "TextComponent"] + + +class Component(Wrapper): + + class_name = "nr" + method_get_string = MethodCache(lambda: (Component, "getString")) + + @classmethod + def ensure_component(cls, rt: 'Runtime', comp: Any) -> 'Component': + if isinstance(comp, Component): + return comp + else: + return TextComponent.new(rt, str(comp)) + + def get_string(self) -> str: + return self.method_get_string.invoke(self._raw) + + def __str__(self): + return "<{} '{}'>".format(self.__class__.__name__, self.get_string()) + + +class TextComponent(Component): + + class_name = "oe" + constructor = ConstructorCache(lambda: (TextComponent, String)) + + @classmethod + def new(cls, rt: 'Runtime', text: str) -> 'TextComponent': + return TextComponent(cls.constructor.construct(rt, text)) diff --git a/addons/scripting/reflect.py b/addons/scripting/reflect.py new file mode 100644 index 00000000..f1c17df7 --- /dev/null +++ b/addons/scripting/reflect.py @@ -0,0 +1,345 @@ +from typing import Optional, Tuple, Union, Callable, Any + + +__all__ = [ + "ReflectError", "ClassNotFoundError", "FieldNotFoundError", "MethodNotFoundError", + "Runtime", + "Object", + "Class", "ClassMember", + "Field", + "Executable", "Method", "Constructor", + "FieldCache", "MethodCache", "ConstructorCache", + "Wrapper", + "AnyType", "AnyTypeWrapped" +] + + +class ReflectError(Exception): ... +class ClassNotFoundError(ReflectError): ... +class FieldNotFoundError(ReflectError): ... +class MethodNotFoundError(ReflectError): ... + + +class Runtime: + + """ + Base class for a reflection runtime. + """ + + def __init__(self): + self._types = Types(self) + + def get_class_from_name(self, name: str) -> 'Class': + raise NotImplementedError + + def get_class_from_object(self, obj: 'Object') -> 'Class': + raise NotImplementedError + + def get_class_field_from_name(self, cls: 'Class', name: str, field_type: 'Class') -> 'Field': + raise NotImplementedError + + def get_class_method_from_name(self, cls: 'Class', name: str, parameter_types: 'Tuple[Class, ...]') -> 'Method': + raise NotImplementedError + + def get_class_constructor_from_name(self, cls: 'Class', parameter_types: 'Tuple[Class, ...]') -> 'Constructor': + raise NotImplementedError + + def get_field_value(self, field: 'Field', owner: 'Optional[Object]') -> 'AnyType': + raise NotImplementedError + + def set_field_value(self, field: 'Field', owner: 'Optional[Object]', value: 'AnyType'): + raise NotImplementedError + + def invoke_method(self, method: 'Method', owner: 'Optional[Object]', parameters: 'Tuple[AnyType, ...]') -> 'AnyType': + raise NotImplementedError + + def invoke_constructor(self, constructor: 'Constructor', parameters: 'Tuple[AnyType, ...]') -> 'Object': + raise NotImplementedError + + def is_class_instance(self, cls: 'Class', obj: 'Object') -> bool: + raise NotImplementedError + + def bind_callback(self, cls: 'Class', func: 'Callable') -> 'Object': + raise NotImplementedError + + @property + def types(self) -> 'Types': + return self._types + + +class Types: + + def __init__(self, rt: 'Runtime'): + self._rt = rt + + def _get_class(self, item) -> 'Class': + if hasattr(item, "class_name"): + item = item.class_name + return self._rt.get_class_from_name(str(item)) + + def __getattr__(self, item) -> 'Class': + return self._get_class(item) + + def __getitem__(self, item) -> 'Class': + return self._get_class(item) + + def __str__(self): + return "" + + +class Object: + + """ + An concrete binding to a runtime's object, this is different from object + wrappers and this class and its subclasses are only used for reflection. + """ + + __slots__ = "_rt", "_ptr", "_cls" + + def __init__(self, rt: 'Runtime', ptr: int): + self._rt = rt + self._ptr = ptr + self._cls: 'Optional[Class]' = None + + def get_runtime(self) -> 'Runtime': + return self._rt + + def get_pointer(self) -> int: + return self._ptr + + def get_class(self) -> 'Class': + if self._cls is None: + self._cls = self._rt.get_class_from_object(self) + return self._cls + + def __str__(self): + return "".format(self._ptr) + + +AnyType = Union[Object, int, float, bool, str, None] + + +class Class(Object): + + __slots__ = "_name", + + def __init__(self, rt: 'Runtime', ptr: int, name: str): + super().__init__(rt, ptr) + self._name = name + + def get_name(self) -> str: + return self._name + + def get_field(self, name: str, field_type: 'Class') -> 'Field': + return self._rt.get_class_field_from_name(self, name, field_type) + + def get_method(self, name: str, *parameter_types: 'Class') -> 'Method': + return self._rt.get_class_method_from_name(self, name, parameter_types) + + def get_constructor(self, *parameter_types: 'Class') -> 'Constructor': + return self._rt.get_class_constructor_from_name(self, parameter_types) + + def is_primitive(self) -> bool: + return self._name in ("byte", "short", "int", "long", "float", "double", "boolean", "char") + + def is_instance(self, obj: 'Object') -> bool: + return self._rt.is_class_instance(self, obj) + + def __str__(self): + return "".format(self._name) + + +class ClassMember(Object): + + __slots__ = "_owner", "_name" + + def __init__(self, rt: 'Runtime', ptr: int, owner: Class, name: str): + super().__init__(rt, ptr) + self._owner = owner + self._name = name + + +class Field(ClassMember): + + __slots__ = "_type" + + def __init__(self, rt: 'Runtime', ptr: int, owner: Class, name: str, field_type: Class): + super().__init__(rt, ptr, owner, name) + self._type = field_type + + def get_type(self) -> 'Class': + return self._type + + def get(self, owner: 'Optional[Object]') -> 'AnyType': + return self._rt.get_field_value(self, owner) + + def set(self, owner: 'Optional[Object]', value: 'AnyType'): + self._rt.set_field_value(self, owner, value) + + def get_static(self) -> 'AnyType': + return self.get(None) + + def set_static(self, value: 'AnyType'): + self.set(None, value) + + def __str__(self): + return "".format(self._type.get_name(), self._owner.get_name(), self._name) + + +class Executable(ClassMember): + + __slots__ = "_parameter_types" + + def __init__(self, rt: Runtime, ptr: int, owner: 'Class', name: str, parameter_types: 'Tuple[Class, ...]'): + super().__init__(rt, ptr, owner, name) + self._parameter_types = parameter_types + + def get_parameter_types(self) -> 'Tuple[Class, ...]': + return self._parameter_types + + +class Method(Executable): + + __slots__ = () + + def __init__(self, rt: Runtime, ptr: int, owner: 'Class', name: str, parameter_types: 'Tuple[Class, ...]'): + super().__init__(rt, ptr, owner, name, parameter_types) + + def invoke(self, owner: 'Optional[Object]', *parameters: 'AnyType') -> 'AnyType': + return self._rt.invoke_method(self, owner, parameters) + + def invoke_static(self, *parameters: 'AnyType') -> 'AnyType': + return self.invoke(None, *parameters) + + def __str__(self): + return "".format( + self._owner.get_name(), + self._name, + ", ".format(*(typ.get_name for typ in self._parameter_types)) + ) + + +class Constructor(Executable): + + def __init__(self, rt: Runtime, ptr: int, owner: 'Class', parameter_types: 'Tuple[Class, ...]'): + super().__init__(rt, ptr, owner, "", parameter_types) + + def construct(self, *parameters: 'AnyType') -> 'Object': + return self._rt.invoke_constructor(self, parameters) + + def __str__(self): + return "".format( + self._owner.get_name(), + ", ".format(*(typ.get_name for typ in self._parameter_types)) + ) + + +# Class wrapper and utilities for it + +class Wrapper: + + """ + Common wrapper class used to interpret raw reflection objects. + """ + + class_name = "java.lang.Object" + class_check = True + __slots__ = "_raw" + + def __init__(self, raw: 'Object'): + if raw is None: + raise ValueError(f"Can't wrap null object on {self.class_name}.") + if self.class_check and not raw.get_runtime().types[self.class_name].is_instance(raw): + raise ValueError(f"Given object is not an instance of {self.class_name}.") + self._raw = raw + + @property + def raw(self) -> 'Object': + return self._raw + + @property + def runtime(self) -> 'Runtime': + return self._raw.get_runtime() + + @staticmethod + def ensure_object(obj: 'AnyTypeWrapped') -> 'AnyType': + if isinstance(obj, Wrapper): + obj = obj.raw + return obj + + def __str__(self): + return f"" + + +AnyTypeWrapped = Union[AnyType, Wrapper] + + +class MemberCache: + + __slots__ = "_member", "_supplier" + + def __init__(self, supplier: 'Callable[[], tuple]'): + self._supplier = supplier + self._member: 'Optional[ClassMember]' = None + + def ensure(self, rt: 'Runtime') -> Any: + if self._member is None or self._member.get_runtime() is not rt: + self._member = self._provide(rt, *self._supplier()) + return self._member + + def _provide(self, rt: 'Runtime', *args) -> 'ClassMember': + raise NotImplementedError + + +class FieldCache(MemberCache): + + def ensure(self, rt: 'Runtime') -> 'Field': + return super().ensure(rt) + + def _provide(self, rt: 'Runtime', *args) -> 'Field': + class_name, field_name, field_type_name = args + cls = rt.types[class_name] + return cls.get_field(field_name, rt.types[field_type_name]) + + def get(self, owner: 'Object') -> 'AnyType': + return self.ensure(owner.get_runtime()).get(owner) + + def set(self, owner: 'Object', value: 'AnyType'): + self.ensure(owner.get_runtime()).set(owner, value) + + def get_static(self, rt: 'Runtime') -> 'AnyType': + return self.ensure(rt).get_static() + + def set_static(self, rt: 'Runtime', value: 'AnyType'): + self.ensure(rt).set_static(value) + + +class MethodCache(MemberCache): + + def ensure(self, rt: 'Runtime') -> 'Method': + return super().ensure(rt) + + def _provide(self, rt: 'Runtime', *args) -> 'Method': + class_name, method_name, *parameter_types = args + cls = rt.types[class_name] + return cls.get_method(method_name, *(rt.types[param_type] for param_type in parameter_types)) + + def invoke(self, owner: 'Object', *parameters: 'AnyType') -> 'AnyType': + return self.ensure(owner.get_runtime()).invoke(owner, *parameters) + + def invoke_static(self, rt: 'Runtime', *parameters: 'AnyType') -> 'AnyType': + return self.ensure(rt).invoke_static(*parameters) + + +class ConstructorCache(MemberCache): + + def ensure(self, rt: 'Runtime') -> 'Constructor': + return super().ensure(rt) + + def _provide(self, rt: 'Runtime', *args) -> 'Constructor': + class_name, *parameter_types = args + cls = rt.types[class_name] + return cls.get_constructor(*(rt.types[param_type] for param_type in parameter_types)) + + def construct(self, rt: 'Runtime', *parameters: 'AnyType') -> 'Object': + return self.ensure(rt).construct(*parameters) diff --git a/addons/scripting/scripting.py b/addons/scripting/scripting.py new file mode 100644 index 00000000..930b9342 --- /dev/null +++ b/addons/scripting/scripting.py @@ -0,0 +1,286 @@ +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.layout.containers import Window, HSplit, VSplit, Container +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.processors import BeforeInput +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.application import Application +from prompt_toolkit.filters import Condition +from prompt_toolkit.widgets import TextArea +from prompt_toolkit.buffer import Buffer + +from prompt_toolkit.lexers import PygmentsLexer +from pygments.lexers.python import PythonLexer + +from typing import List, Optional, Dict, Callable, Any +from argparse import ArgumentParser, Namespace +from threading import Thread +from os import path + +from ..richer.richer import RollingLinesWindow +from .server import ScriptingServer + + +JAR_FILE_PATH = path.join(path.dirname(__file__), "java/out/portablemc_scripting.jar") + + +class ScriptingAddon: + + def __init__(self, pmc): + + self.pmc = pmc + self.richer = None + + self.server: 'Optional[ScriptingServer]' = None + self.active = False + + def load(self): + + self.richer = self.pmc.get_addon("richer").instance + self.richer.double_exit = True + + self.pmc.add_message("args.start.scripting", "Enable the scripting extension injection at startup.") + self.pmc.add_message("start.scripting.start_server", "Scripting server started on port {}.") + self.pmc.add_message("start.scripting.title", "Live Scripting • port: {}") + + self.pmc.mixin("register_start_arguments", self.register_start_arguments) + self.pmc.mixin("game_start", self.game_start) + self.pmc.mixin("build_application", self.build_application, self.richer) + + def register_start_arguments(self, old, parser: ArgumentParser): + parser.add_argument("--scripting", help=self.pmc.get_message("args.start.scripting"), default=False, action="store_true") + old(parser) + + def game_start(self, old, *, cmd_args: Namespace, **kwargs) -> None: + + if cmd_args.scripting: + + self.server = ScriptingServer() + self.active = True + + def libraries_modifier(classpath_libs: List[str], _native_libs: List[str]): + classpath_libs.append(JAR_FILE_PATH) + + def args_modifier(args: List[str], main_class_index: int): + self.server.start() + self.pmc.print("start.scripting.start_server", self.server.get_port()) + old_main_class = args[main_class_index] + args[main_class_index] = "portablemc.scripting.ScriptingClient" + args.insert(main_class_index, "-Dportablemc.scripting.main={}".format(old_main_class)) + args.insert(main_class_index, "-Dportablemc.scripting.port={}".format(self.server.get_port())) + + kwargs["libraries_modifier"] = libraries_modifier + kwargs["args_modifier"] = args_modifier + + old(cmd_args=cmd_args, **kwargs) + + if cmd_args.scripting: + self.server.stop() + + def build_application(self, old, container: Container, keys: KeyBindings) -> Application: + + interpreter = None + + if self.active: + + title_text = self.pmc.get_message("start.scripting.title", self.server.get_port()) + interpreter = Interpreter(self.server) + + container = VSplit([ + container, + Window(char=' ', width=1, style="class:header"), + HSplit([ + VSplit([ + Window(width=2), + Window(FormattedTextControl(text=title_text)), + ], height=1, style="class:header"), + VSplit([ + Window(width=1), + interpreter, + Window(width=1) + ]) + ]) + ]) + + keys.add("tab", filter=~Condition(interpreter.require_key_tab))(focus_next) + keys.add("s-tab", filter=~Condition(interpreter.require_key_tab))(focus_previous) + + app = old(container, keys) + + if self.active: + app.layout.focus(interpreter.input) + + return app + + +class Interpreter: + + INDENTATION_SPACES = 4 + + def __init__(self, server: 'ScriptingServer'): + + # Customize the default builtins, and use DynamicDict + # in order to avoid reflection at startup time. + builtins = DynamicDict(globals()["__builtins__"]) + builtins["print"] = self._custom_print + del builtins["help"] + del builtins["input"] + del builtins["breakpoint"] + + # Add customized builtins, types and minecraft dynamic + # value and also all builtins class wrappers. + from . import std, mc + builtins["types"] = server.types + builtins.add_dyn("mc", lambda: mc.Minecraft.get_instance(server)) + for mod_name, cls_name, cls in self.iter_modules_classes(std, mc): + builtins[cls_name] = cls + + self.locals = {} + self.globals = { + "__builtins__": builtins + } + + self.code_indent = 0 + self.code = [] + + self.lexer = PygmentsLexer(PythonLexer) + self.window = RollingLinesWindow(100, lexer=self.lexer, wrap_lines=True, dont_extend_height=True) + self.input = TextArea( + height=1, + multiline=False, + wrap_lines=False, + accept_handler=self._input_accept, + lexer=self.lexer + ) + + self.prompt_processor = BeforeInput(">>> ", "") + self.input.control.input_processors.clear() + self.input.control.input_processors.append(self.prompt_processor) + + keys = KeyBindings() + keys.add("tab", filter=Condition(self.require_key_tab))(self._handle_tab) + + self.split = HSplit([ + Window(), + self.window, + self.input + ], key_bindings=keys) + + # Printing + + def print_line(self, line: str): + self.window.append(*line.splitlines()) + + def _custom_print(self, *args, sep: str = " ", **_kwargs): + self.print_line(sep.join(str(arg) for arg in args)) + + def _out_traceback(self): + import traceback + import sys + err_type, err, tb = sys.exc_info() + for line in traceback.extract_tb(tb).format()[2:]: + self.print_line("{}".format(line)) + self.print_line("{}: {}".format(err_type.__name__, err)) + + # Interpreting + + def _interpret(self, text: str): + + self.print_line("{}{}".format(self.prompt_processor.text, text)) + + text_spaces = self.count_spaces(text) + + if text_spaces % self.INDENTATION_SPACES != 0: + self.print_line("Unexpected identation.") + self._insert_identation(self.code_indent) + return + + text_indent = text_spaces // self.INDENTATION_SPACES + + if text_indent > self.code_indent: + self.print_line("Unexpected identation.") + self._insert_identation(self.code_indent) + return + + self.code_indent = text_indent + if len(text): + self.code.append(text) + if text.rstrip().endswith(":"): + self.code_indent += 1 + self.prompt_processor.text = "... " + + if self.code_indent == 0 and len(self.code): + self.prompt_processor.text = ">>> " + eval_text = "\n".join(self.code) + self.code.clear() + try: + ret = eval(eval_text, self.globals, self.locals) + self.print_line("{}".format(ret)) + except SyntaxError: + try: + exec(eval_text, self.globals, self.locals) + except (BaseException,): + self._out_traceback() + except (BaseException,): + self._out_traceback() + + self._insert_identation(self.code_indent) + + def _input_accept(self, buffer: Buffer) -> bool: + Thread(target=lambda: self._interpret(buffer.text), daemon=True).start() + return False + + def _handle_tab(self, _e: KeyPressEvent): + if self.code_indent != 0: + self._insert_identation(1) + + def _insert_identation(self, count: int): + self.input.document = self.input.document.insert_before(" " * count * self.INDENTATION_SPACES) + + # Other + + def require_key_tab(self) -> bool: + return self.code_indent != 0 + + def __pt_container__(self): + return self.split + + # Utils + + @staticmethod + def count_spaces(line: str) -> int: + i = 0 + for c in line: + if c == " ": + i += 1 + else: + break + return i + + @staticmethod + def iter_modules_classes(*modules): + for module in modules: + mod_name = module.__name__ + for key in dir(module): + raw = getattr(module, key) + if isinstance(raw, type): + yield mod_name, key, raw + + +class DynamicDict(dict): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._dyns: 'Dict[str, Callable[[], Any]]' = {} + + def add_dyn(self, key, dyn: 'Callable[[], Any]'): + self._dyns[key] = dyn + self[key] = None + + def __getitem__(self, item): + val = super().__getitem__(item) + if val is None: + dyn = self._dyns.get(item) + if dyn is not None: + val = self[item] = dyn() + return val diff --git a/addons/scripting/server.py b/addons/scripting/server.py new file mode 100644 index 00000000..e692336c --- /dev/null +++ b/addons/scripting/server.py @@ -0,0 +1,309 @@ +from typing import Optional, Tuple, Dict, Callable +from threading import Thread, Event +import socket + +from .reflect import Runtime, Object, Class, Field, Executable, Method, Constructor, \ + AnyType, ClassNotFoundError, FieldNotFoundError, MethodNotFoundError +from .buffer import ByteBuffer + + +PACKET_GET_CLASS = 1 +PACKET_GET_FIELD = 2 +PACKET_GET_METHOD = 3 + +PACKET_FIELD_GET = 10 +PACKET_FIELD_SET = 11 + +PACKET_METHOD_INVOKE = 20 + +PACKET_OBJECT_GET_CLASS = 30 +PACKET_OBJECT_IS_INSTANCE = 31 + +PACKET_BIND_CALLBACK = 40 + +PACKET_RESULT = 100 +PACKET_RESULT_CLASS = 101 +PACKET_RESULT_BYTE = 102 + +PACKET_GENERIC_ERROR = 110 + + +class ScriptingServer(Runtime): + + def __init__(self): + + super().__init__() + + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._stop_event = Event() + self._port: Optional[int] = None + + self._client_socket: Optional[socket.socket] = None + self._tx_buf = ByteBuffer(4096) + self._rx_buf = ByteBuffer(4096) + + self._rx_recv_buf = bytearray(256) + + self._put_value_int_encoders = { + "byte": (-2, ByteBuffer.put), + "short": (-3, ByteBuffer.put_short), + "int": (-4, ByteBuffer.put_int), + "long": (-5, ByteBuffer.put_long), + "float": (-6, ByteBuffer.put_float), + "double": (-7, ByteBuffer.put_double), + "char": (-8, ByteBuffer.put_char) + } + + self._classes_cache: 'Dict[str, Class]' = {} + + def start(self): + + self._socket.bind(('127.0.0.1', 0)) + self._port = self._socket.getsockname()[1] + + thread = Thread(target=self._entry, name="PortableMC Scripting Server Thread", daemon=True) + thread.start() + + def stop(self): + self._stop_event.set() + + def get_port(self) -> int: + return self._port + + def _entry(self): + self._socket.listen(1) + self._client_socket, _ = self._socket.accept() + self._stop_event.wait() + self._socket.close() + + # Packets + + def _prepare_packet(self): + self._tx_buf.clear() + self._tx_buf.ensure_len(3) + + def _send_packet(self, packet_type: int): + length = self._tx_buf.pos + self._tx_buf.put(packet_type, offset=0) + self._tx_buf.put_short(length - 3, offset=1) + self._client_socket.sendall(self._tx_buf.data[:length]) + + def _wait_for_packet(self, expected_packet_type: int) -> 'ByteBuffer': + + next_packet_len = 0 + self._rx_buf.clear() + + while True: + + if next_packet_len == 0 and self._rx_buf.pos >= 3: + next_packet_len = self._rx_buf.get_short(offset=1, signed=False) + 3 + + if next_packet_len != 0 and next_packet_len >= self._rx_buf.pos: + packet_type = self._rx_buf.get(offset=0) + self._rx_buf.limit = next_packet_len + self._rx_buf.pos = 3 + if packet_type == expected_packet_type: + return self._rx_buf + else: + if packet_type == PACKET_GENERIC_ERROR: + raise ValueError(f"Server generic error: {self._rx_buf.get_string()}") + else: + print("[SCRIPTING] Invalid received packet type, expected {}, got {}.".format(expected_packet_type, packet_type)) + self._rx_buf.lshift(next_packet_len) + next_packet_len = 0 + else: + remaining = self._rx_buf.remaining() + read_len = self._client_socket.recv_into(self._rx_recv_buf, min(len(self._rx_recv_buf), remaining)) + self._rx_buf.put_bytes(self._rx_recv_buf, read_len) + + # Packets implementations + + def send_get_class_packet(self, name: str) -> int: + self._prepare_packet() + self._tx_buf.put_string(name) + self._send_packet(PACKET_GET_CLASS) + return self._wait_for_packet(PACKET_RESULT).get_int() + + def send_object_get_class_packet(self, obj: 'Object') -> Optional[Tuple[str, int]]: + self._prepare_packet() + self._tx_buf.put_int(obj.get_pointer()) + self._send_packet(PACKET_OBJECT_GET_CLASS) + buf = self._wait_for_packet(PACKET_RESULT_CLASS) + idx = buf.get_int() + if idx == -1: + return None + else: + return buf.get_string(), idx + + def send_object_is_instance_packet(self, cls: 'Class', obj: 'Object') -> bool: + self._prepare_packet() + self._tx_buf.put_int(cls.get_pointer()) + self._tx_buf.put_int(obj.get_pointer()) + self._send_packet(PACKET_OBJECT_IS_INSTANCE) + buf = self._wait_for_packet(PACKET_RESULT_BYTE) + return buf.get() != 0 + + def send_get_field_packet(self, cls: 'Class', name: str, field_type: 'Class') -> int: + self._prepare_packet() + self._tx_buf.put_int(cls.get_pointer()) + self._tx_buf.put_string(name) + self._tx_buf.put_int(field_type.get_pointer()) + self._send_packet(PACKET_GET_FIELD) + return self._wait_for_packet(PACKET_RESULT).get_int() + + def send_get_method_packet(self, cls: 'Class', name: str, parameter_types: 'Tuple[Class, ...]') -> int: + # Empty method name means we want a constructor + self._prepare_packet() + self._tx_buf.put_int(cls.get_pointer()) + self._tx_buf.put_string(name) + self._tx_buf.put(len(parameter_types)) + for ptype in parameter_types: + self._tx_buf.put_int(ptype.get_pointer()) + self._send_packet(PACKET_GET_METHOD) + return self._wait_for_packet(PACKET_RESULT).get_int() + + def send_field_get_packet(self, field: 'Field', owner: 'Optional[Object]') -> 'AnyType': + self._prepare_packet() + self._tx_buf.put_int(field.get_pointer()) + self._tx_buf.put_int(-1 if owner is None else owner.get_pointer()) + self._send_packet(PACKET_FIELD_GET) + return self._get_value(self._wait_for_packet(PACKET_RESULT)) + + def send_field_set_packet(self, field: 'Field', owner: 'Optional[Object]', value: 'AnyType'): + self._prepare_packet() + self._tx_buf.put_int(field.get_pointer()) + self._tx_buf.put_int(-1 if owner is None else owner.get_pointer()) + self._put_value(self._tx_buf, value, field.get_type()) + self._send_packet(PACKET_FIELD_SET) + self._wait_for_packet(PACKET_RESULT) + + def send_method_invoke_packet(self, executable: 'Executable', owner: 'Optional[Object]', parameters: 'Tuple[AnyType, ...]') -> 'AnyType': + param_types = executable.get_parameter_types() + if len(param_types) != len(parameters): + raise ValueError(f"Parameters count doesn't match, got {len(parameters)}, expected {len(param_types)}.") + self._prepare_packet() + self._tx_buf.put_int(executable.get_pointer()) + self._tx_buf.put_int(-1 if owner is None else owner.get_pointer()) + self._tx_buf.put(len(parameters)) + for idx, param in enumerate(parameters): + self._put_value(self._tx_buf, param, param_types[idx]) + self._send_packet(PACKET_METHOD_INVOKE) + return self._get_value(self._wait_for_packet(PACKET_RESULT)) + + def send_bind_callback_packet(self, cls: 'Class') -> 'Tuple[int, Object]': + self._prepare_packet() + self._tx_buf.put_int(cls.get_pointer()) + self._send_packet(PACKET_BIND_CALLBACK) + # TODO + + # Decode reflect value + + def _get_value(self, buf: 'ByteBuffer') -> 'AnyType': + idx = buf.get_int() + if idx < 0: + if idx == -2: + return buf.get() + elif idx == -3: + return buf.get_short() + elif idx == -4: + return buf.get_int() + elif idx == -5: + return buf.get_long() + elif idx == -6: + return buf.get_float() + elif idx == -7: + return buf.get_double() + elif idx == -8: + return buf.get_char() + elif idx == -9: + return buf.get_string() + elif idx == -10: + return False + elif idx == -11: + return True + else: + return None + else: + return Object(self, idx) + + def _put_value(self, buf: 'ByteBuffer', val: 'AnyType', target_type: 'Class'): + if val is None: + if target_type.is_primitive(): + raise ValueError("None value is illegal for primitive type {}.".format(target_type.get_name())) + buf.put_int(-1) + elif isinstance(val, bool): # 'bool' must be placed before 'int' because 'bool' extends 'int' + if target_type.get_name() != "boolean": + raise ValueError("Boolean {} given but expected {}.".format(val, target_type.get_name())) + buf.put_int(-11 if val else -10) + elif isinstance(val, int): + data = self._put_value_int_encoders.get(target_type.get_name()) + if data is None: + raise ValueError( + "Integer value {} is not suitable for {} type.".format(val, target_type.get_name())) + buf.put_int(data[0]) + (data[1])(buf, val) + elif isinstance(val, str): + if target_type.get_name() != "java.lang.String": + raise ValueError("String '{}' given but expected {}.".format(val, target_type.get_name())) + buf.put_int(-9) + buf.put_string(val) + else: + buf.put_int(val.get_pointer()) + + # Runtime implementations + + def get_class_from_name(self, name: str) -> 'Class': + cached = self._classes_cache.get(name) + if cached is not None: + return cached + ptr = self.send_get_class_packet(name) + if ptr < 0: + raise ClassNotFoundError(f"Class '{name}' not found.") + cached = Class(self, ptr, name) + self._classes_cache[name] = cached + return cached + + def get_class_from_object(self, obj: 'Object') -> 'Class': + res = self.send_object_get_class_packet(obj) + if res is None: + raise ClassNotFoundError(f"Illegal object, class not found.") + return Class(self, res[1], res[0]) + + def get_class_field_from_name(self, cls: 'Class', name: str, field_type: 'Class') -> 'Field': + ptr = self.send_get_field_packet(cls, name, field_type) + if ptr < 0: + raise FieldNotFoundError(f"Field '{name}' not found in class '{cls.get_name()}'.") + return Field(self, ptr, cls, name, field_type) + + def get_class_method_from_name(self, cls: 'Class', name: str, parameter_types: 'Tuple[Class, ...]') -> 'Method': + if not len(name): + raise ValueError("Class name is empty.") + ptr = self.send_get_method_packet(cls, name, parameter_types) + if ptr < 0: + raise MethodNotFoundError(f"Method '{name}' not found in class '{cls.get_name()}'.") + return Method(self, ptr, cls, name, parameter_types) + + def get_class_constructor_from_name(self, cls: 'Class', parameter_types: 'Tuple[Class, ...]') -> 'Constructor': + ptr = self.send_get_method_packet(cls, "", parameter_types) + if ptr < 0: + raise MethodNotFoundError(f"Constructor not found in class '{cls.get_name()}'.") + return Constructor(self, ptr, cls, parameter_types) + + def get_field_value(self, field: 'Field', owner: 'Optional[Object]') -> 'AnyType': + return self.send_field_get_packet(field, owner) + + def set_field_value(self, field: 'Field', owner: 'Optional[Object]', value: 'AnyType'): + self.send_field_set_packet(field, owner, value) + + def invoke_method(self, method: 'Method', owner: 'Optional[Object]', parameters: 'Tuple[AnyType, ...]') -> 'AnyType': + return self.send_method_invoke_packet(method, owner, parameters) + + def invoke_constructor(self, constructor: 'Constructor', parameters: 'Tuple[AnyType, ...]') -> 'Object': + return self.send_method_invoke_packet(constructor, None, parameters) + + def is_class_instance(self, cls: 'Class', obj: 'Object') -> bool: + return self.send_object_is_instance_packet(cls, obj) + + def bind_callback(self, cls: 'Class', func: 'Callable') -> 'Object': + + pass diff --git a/addons/scripting/std/__init__.py b/addons/scripting/std/__init__.py new file mode 100644 index 00000000..bdba1cbb --- /dev/null +++ b/addons/scripting/std/__init__.py @@ -0,0 +1,2 @@ +from .lang import * +from .util import * diff --git a/addons/scripting/std/function.py b/addons/scripting/std/function.py new file mode 100644 index 00000000..62a6a4ae --- /dev/null +++ b/addons/scripting/std/function.py @@ -0,0 +1,8 @@ +from ..reflect import Object, Wrapper, AnyTypeWrapped, MethodCache + + +class Consumer(Wrapper): + + class_name = "" + + pass \ No newline at end of file diff --git a/addons/scripting/std/lang.py b/addons/scripting/std/lang.py new file mode 100644 index 00000000..60f906a4 --- /dev/null +++ b/addons/scripting/std/lang.py @@ -0,0 +1,38 @@ +from ..reflect import Wrapper, Object, MethodCache +from typing import Optional + + +__all__ = ["String", "Enum", "Runnable"] + + +class String(Wrapper): + class_name = "java.lang.String" + + +class Enum(Wrapper): + + class_name = "java.lang.Enum" + method_name = MethodCache(lambda ctx: (Enum, "name")) + method_ordinal = MethodCache(lambda ctx: (Enum, "ordinal")) + __slots__ = "_name", "_ordinal" + + def __init__(self, raw: 'Object'): + super().__init__(raw) + self._name: Optional[str] = None + self._ordinal: Optional[int] = None + + @property + def name(self) -> str: + if self._name is None: + self._name = self.method_name.invoke(self._raw) + return self._name + + @property + def ordinal(self) -> int: + if self._ordinal is None: + self._ordinal = self.method_ordinal.invoke(self._raw) + return self._ordinal + + +class Runnable(Wrapper): + class_name = "java.lang.Runnable" diff --git a/addons/scripting/std/util.py b/addons/scripting/std/util.py new file mode 100644 index 00000000..fc5fb96b --- /dev/null +++ b/addons/scripting/std/util.py @@ -0,0 +1,170 @@ +from ..reflect import Object, Wrapper, AnyTypeWrapped, MethodCache +from typing import Callable, Optional + + +__all__ = [ + "Iterable", "Iterator", + "Collection", "List", "Queue", + "List" +] + + +class Iterable(Wrapper): + + class_name = "java.lang.Iterable" + method_iterator = MethodCache(lambda: (Iterable, "iterator")) + __slots__ = "_wrapper" + + def __init__(self, raw: 'Object', wrapper: 'Callable[[Object], Wrapper]'): + super().__init__(raw) + self._wrapper = wrapper + + def __iter__(self): + return Iterator(self.method_iterator.invoke(self._raw), self._wrapper) + + def _wrap_optional(self, val: 'Optional[Object]') -> 'Optional[Wrapper]': + return None if val is None else self._wrapper(val) + + +class Iterator(Wrapper): + + class_name = "java.util.Iterator" + method_has_next = MethodCache(lambda: (Iterator, "hasNext")) + method_next = MethodCache(lambda: (Iterator, "next")) + __slots__ = "_wrapper" + + def __init__(self, raw: 'Object', wrapper: 'Callable[[Object], Wrapper]'): + super().__init__(raw) + self._wrapper = wrapper + + def __iter__(self): + return self + + def __next__(self) -> 'Wrapper': + if self.method_has_next.invoke(self._raw): + return self._wrapper(self.method_next.invoke(self._raw)) + else: + raise StopIteration + + +class Collection(Iterable): + + class_name = "java.util.Collection" + method_size = MethodCache(lambda: (Collection, "size")) + method_contains = MethodCache(lambda: (Collection, "contains", Wrapper)) + method_add = MethodCache(lambda: (Collection, "add", Wrapper)) + method_remove = MethodCache(lambda: (Collection, "remove", Wrapper)) + method_contains_all = MethodCache(lambda: (Collection, "containsAll", Collection)) + method_add_all = MethodCache(lambda: (Collection, "addAll", Collection)) + method_remove_all = MethodCache(lambda: (Collection, "removeAll", Collection)) + method_clear = MethodCache(lambda: (Collection, "clear")) + __slots__ = () + + def __len__(self): + return self.method_size.invoke(self._raw) + + def __contains__(self, o: AnyTypeWrapped): + return self.method_contains.invoke(self._raw, Wrapper.ensure_object(o)) + + def add(self, e: AnyTypeWrapped) -> bool: + return self.method_add.invoke(self._raw, Wrapper.ensure_object(e)) + + def remove(self, e: AnyTypeWrapped) -> bool: + return self.method_remove.invoke(self._raw, Wrapper.ensure_object(e)) + + def contains_all(self, other: 'Collection') -> bool: + return self.method_contains_all.invoke(self._raw, other.raw) + + def add_all(self, other: 'Collection') -> bool: + return self.method_add_all.invoke(self._raw, other.raw) + + def remove_all(self, other: 'Collection') -> bool: + return self.method_remove_all.invoke(self._raw, other.raw) + + def clear(self): + self.method_clear.invoke(self._raw) + + +class List(Collection): + + class_name = "java.util.List" + method_get = MethodCache(lambda: (List, "get", "int")) + method_set = MethodCache(lambda: (List, "set", "int", Wrapper)) + method_add_at = MethodCache(lambda: (List, "add", "int", Wrapper)) + method_remove_at = MethodCache(lambda: (List, "remove", "int")) + method_index_of = MethodCache(lambda: (List, "indexOf", Wrapper)) + method_last_index_of = MethodCache(lambda: (List, "lastIndexOf", Wrapper)) + __slots__ = "_wrapper" + + def __init__(self, raw: 'Object', wrapper: 'Callable[[Object], Wrapper]'): + super().__init__(raw, wrapper) + + def get(self, index: int) -> 'Optional[Wrapper]': + return self._wrapper(self.method_get.invoke(self._raw, index)) + + def set(self, index: int, obj: 'AnyTypeWrapped') -> 'Optional[Wrapper]': + return self._wrap_optional(self.method_set.invoke(self._raw, index, Wrapper.ensure_object(obj))) + + def add_at(self, index: int, obj: 'AnyTypeWrapped'): + self.method_add_at.invoke(self._raw, index, Wrapper.ensure_object(obj)) + + def remove_at(self, index: int) -> 'Optional[Wrapper]': + return self._wrap_optional(self.method_remove_at.invoke(self._raw, index)) + + def index_of(self, obj: 'AnyTypeWrapped') -> int: + return self.method_index_of.invoke(self._raw, Wrapper.ensure_object(obj)) + + def last_index_of(self, obj: 'AnyTypeWrapped') -> int: + return self.method_last_index_of.invoke(self._raw, Wrapper.ensure_object(obj)) + + def __getitem__(self, item) -> 'Optional[Wrapper]': + if isinstance(item, int): + return self._wrap_optional(self.method_get.invoke(self._raw)) + else: + raise IndexError("list index out of range") + + def __setitem__(self, key, value): + if isinstance(key, int): + self.method_set.invoke(self._raw, Wrapper.ensure_object(value)) + else: + raise IndexError("list index out of range") + + +class Queue(Collection): + + class_name = "java.util.Queue" + + method_add_first = MethodCache(lambda: (Queue, "add", Wrapper)) + method_offer_first = MethodCache(lambda: (Queue, "offer", Wrapper)) + + method_remove_first = MethodCache(lambda: (Queue, "remove")) + method_poll_first = MethodCache(lambda: (Queue, "poll")) + + method_get_first = MethodCache(lambda: (Queue, "element")) + method_peek_first = MethodCache(lambda: (Queue, "peek")) + + __slots__ = () + + def add_first(self, e: 'AnyTypeWrapped') -> bool: + """ Insert element at the front, throw IllegalStateException if not possible. """ + return self.method_add_first.invoke(self._raw, Wrapper.ensure_object(e)) + + def offer_first(self, e: 'AnyTypeWrapped') -> bool: + """ Insert element at the front, return True if possible. """ + return self.method_offer_first.invoke(self._raw, Wrapper.ensure_object(e)) + + def remove_first(self) -> 'Optional[Wrapper]': + """ Remove and retreive the first element, throw NoSuchElementException if empty. """ + return self._wrap_optional(self.method_remove_first.invoke(self._raw)) + + def poll_first(self) -> 'Optional[Wrapper]': + """ Remove and retreive the first element, return None if empty. """ + return self._wrap_optional(self.method_poll_first.invoke(self._raw)) + + def get_first(self) -> 'Optional[Wrapper]': + """ Retreive the first element, throw NoSuchElementException if empty. """ + return self._wrap_optional(self.method_get_first.invoke(self._raw)) + + def peek_first(self) -> 'Optional[Wrapper]': + """ Retreive the first element, return None if empty. """ + return self._wrap_optional(self.method_peek_first.invoke(self._raw)) diff --git a/gen_core.py b/gen_core.py new file mode 100644 index 00000000..6dba78e0 --- /dev/null +++ b/gen_core.py @@ -0,0 +1,28 @@ +from os import path + + +def main(): + + print("Generating PortableMC Core script... ", end='') + + this_dir = path.dirname(__file__) + pmc_file = path.join(this_dir, "portablemc.py") + pmc_core_file = path.join(this_dir, "portablemc_core.py") + + with open(pmc_file, "rt") as src: + with open(pmc_core_file, "wt") as dst: + while True: + line = src.readline() + if not len(line) or line.startswith("if __name__ == '__main__':"): + break + dst.write(line) + if line.startswith("# encoding: utf8"): + dst.write("\n" + "# This file is not intended to be modified manually, it was generated by 'gen_core.py'.\n" + "# You must develop the 'portablemc.py' script and then start 'gen_core.py' to regenerate this lib.\n") + + print("Done.") + + +if __name__ == '__main__': + main() diff --git a/gen_packages.py b/gen_packages.py new file mode 100644 index 00000000..452f9e8a --- /dev/null +++ b/gen_packages.py @@ -0,0 +1,46 @@ +from portablemc_core import LAUNCHER_VERSION +from zipfile import ZipFile +from os import path +import os + + +PACKAGES = { + "standard": ("portablemc.py",), + "richer": ("portablemc.py", "addons/richer/*"), + "scripting": ("portablemc.py", "addons/richer/*", "addons/scripting/*") +} + + +def main(): + + print("Generating PortableMC distribution packages...") + + this_dir = path.dirname(__file__) + packages_dir = path.join(this_dir, "packages") + + if not path.isdir(packages_dir): + os.mkdir(packages_dir) + + for pkg_name, files in PACKAGES.items(): + print(f"Generating package '{pkg_name}'... ") + pkg_file = path.join(packages_dir, f"portablemc_{LAUNCHER_VERSION}_{pkg_name}.zip") + with ZipFile(pkg_file, "w") as zf: + for file in files: + is_dir = file.endswith("/*") + if is_dir: + file = file[:-2] + for root, dirs, src_files in os.walk(path.join(this_dir, file)): + for src_file in src_files: + src_file = path.join(root, src_file) + dst_file = path.relpath(src_file, this_dir) + print(f"=> Writing {src_file}") + zf.write(src_file, dst_file) + else: + src_file = path.join(this_dir, file) + print(f"=> Writing {src_file}") + zf.write(src_file, file) + print(f"Done.") + + +if __name__ == '__main__': + main() diff --git a/portablemc.py b/portablemc.py index 969cdf29..7b528ba9 100644 --- a/portablemc.py +++ b/portablemc.py @@ -1,714 +1,790 @@ #!/usr/bin/env python -from urllib.request import Request as URLRequest -from http.client import HTTPResponse -from urllib import request as urlreq -from urllib.error import HTTPError +# encoding: utf8 + +from sys import exit +import sys + + +if sys.version_info[0] < 3 or sys.version_info[1] < 6: + print("PortableMC cannot be used with Python version prior to 3.6.x") + exit(1) + +from typing import cast, Dict, Callable, Optional, Generator, Tuple, List, Iterable, Union +from urllib import request as url_request from json.decoder import JSONDecodeError -from argparse import ArgumentParser -from os import path as os_path -from datetime import datetime +from urllib.error import HTTPError from zipfile import ZipFile +from uuid import uuid4 +from os import path import subprocess import platform import hashlib -import getpass +import atexit import shutil -import uuid import json -import time -import sys import re import os -from typing import cast, Optional, Tuple, List, Dict +LAUNCHER_NAME = "portablemc" +LAUNCHER_VERSION = "1.1.0" +LAUNCHER_AUTHORS = "Théo Rozier" VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json" ASSET_BASE_URL = "https://resources.download.minecraft.net/{}/{}" AUTHSERVER_URL = "https://authserver.mojang.com/{}" -SPECIAL_VERSIONS = {"snapshot", "release"} -LAUNCHER_NAME = "portablemc" -LAUNCHER_VERSION = "1.0.0" +LOGGING_CONSOLE_REPLACEMENT = "" JVM_EXEC_DEFAULT = "java" -JVM_ARGS_DEFAULT = "-Xmx2G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M" -DOWNLOAD_BUFFER_SIZE = 32768 - -EXIT_VERSION_NOT_FOUND = 10 -EXIT_CLIENT_JAR_NOT_FOUND = 11 -EXIT_NATIVES_DIR_ALREADY_EXITS = 12 -EXIT_DOWNLOAD_FILE_CORRUPTED = 13 -EXIT_AUTHENTICATION_FAILED = 14 -EXIT_VERSION_SEARCH_NOT_FOUND = 15 -EXIT_DEPRECATED_ARGUMENT = 15 - -DECODE_RESOLUTION = lambda raw: tuple(int(size) for size in raw.split("x")) - - -def main(): - - player_uuid = str(uuid.uuid4()) - - parser = ArgumentParser( - allow_abbrev=False, - description="[PortableMC] " - "An easy to use portable Minecraft launcher in only one Python script ! " - "This single-script launcher is still compatible with the official (Mojang) Minecraft " - "Launcher stored in .minecraft and use it." - ) - parser.add_argument("-v", "--version", help="Specify Minecraft version (exact version, 'snapshot' for latest snapshot, 'release' for latest release).", default="release") - parser.add_argument("-s", "--search", help="A flag that exit the launcher after searching version, with this flag the version argument can be inexact.", default=False, action="store_true") - parser.add_argument("--nostart", help="Only download Minecraft required data, but does not launch the game.", default=False, action="store_true") - parser.add_argument("--demo", help="Start game in demo mode.", default=False, action="store_true") - parser.add_argument("--resol", help="Set a custom start resolution (x).", type=DECODE_RESOLUTION, dest="resolution") - parser.add_argument("--java", help="Set a custom JVM javaw executable path (deprecated, use --jvm).") - parser.add_argument("--jvm", help="Set a custom JVM javaw executable path.", default=JVM_EXEC_DEFAULT) - parser.add_argument("--jvm-args", help="Change the default JVM arguments.", default=JVM_ARGS_DEFAULT, dest="jvm_args") - parser.add_argument("--logout", help="Override all other arguments, does not start the game, and logout from this specific session.") - parser.add_argument("-md", "--main-dir", help="Set the main directory where libraries, assets, versions and binaries (at runtime) are stored.", dest="main_dir") - parser.add_argument("-wd", "--work-dir", help="Set the working directory where the game run and place for examples the saves (and resources for legacy versions).", dest="work_dir") - parser.add_argument("-t", "--temp-login", help="Flag used with -l (--login) to tell launcher not to cache your session if not already cached, deactivated by default.", default=False, action="store_true", dest="templogin") - parser.add_argument("-l", "--login", help="Use a email or username (legacy) to authenticate using mojang servers (you will be asked for password, it override --username and --uuid).") - parser.add_argument("-u", "--username", help="Set a custom user name to play.", default=player_uuid.split("-")[0]) - parser.add_argument("-i", "--uuid", help="Set a custom user UUID to play.", default=player_uuid) - args = parser.parse_args() - - if args.java is not None: - print("The '--java' argument is deprecated, please use --jvm and --jvm-args") - exit(EXIT_DEPRECATED_ARGUMENT) - - main_dir = get_minecraft_dir("minecraft") if args.main_dir is None else os_path.realpath(args.main_dir) - work_dir = main_dir if args.work_dir is None else os_path.realpath(args.work_dir) - os.makedirs(main_dir, 0o777, True) - - auth_db_file = os_path.join(main_dir, "portablemc_tokens") - - print("Welcome to PortableMC, the easy to use Python Minecraft Launcher.") - print("=> Main directory: {}".format(main_dir)) - print("=> Working directory: {}".format(work_dir)) - print("=> Version manifest url: {}".format(VERSION_MANIFEST_URL)) - - if not os_path.isfile(auth_db_file): - if input("Continue using this main directory? (y/N) ") != "y": - print("=> Abort") - exit(0) - - # Logging out - if args.logout is not None: - - print("=> Logging out...") - username = args.logout - auth_db = AuthDatabase(auth_db_file) - auth_db.load() - auth_entry = auth_db.get_entry(username) - - if auth_entry is None: - print("=> Session {} is not cached".format(username)) - exit(0) - - auth_invalidate(auth_entry) - auth_db.remove_entry(username) - auth_db.save() - print("=> Session {} is no longer valid".format(username)) - exit(0) - - # Searching version - mc_os = get_minecraft_os() - version_manifest = read_version_manifest() - version_name = args.version - print("Searching for version '{}' ...".format(version_name)) - - # Aliasing version - if version_name in SPECIAL_VERSIONS and version_name in version_manifest["latest"]: - version_type = version_name - version_name = version_manifest["latest"][version_type] - print("=> Latest {} is '{}'".format(version_type, version_name)) - - # If searching flag is True - if args.search: - found = False - for manifest_version in version_manifest["versions"]: - if manifest_version["id"].startswith(version_name): - found = True - print("=> {:10s} {:16s} {}".format( - manifest_version["type"], - manifest_version["id"], - format_manifest_date(manifest_version["releaseTime"]) - )) - if not found: - print("=> No version found") - exit(EXIT_VERSION_SEARCH_NOT_FOUND) - else: - exit(0) +JVM_ARGS_DEFAULT = "-Xmx2G",\ + "-XX:+UnlockExperimentalVMOptions",\ + "-XX:+UseG1GC",\ + "-XX:G1NewSizePercent=20",\ + "-XX:G1ReservePercent=20",\ + "-XX:MaxGCPauseMillis=50",\ + "-XX:G1HeapRegionSize=32M" - # Login in - auth_access_token = "" - if args.login is not None: - print("=> Logging in...") - login = args.login - auth_db = AuthDatabase(auth_db_file) - auth_db.load() - auth_entry = auth_db.get_entry(login) +# This file is split between the Core which is the lib and the CLI launcher which extends the Core. +# Check at the end of this file (in the __main__ check) for the CLI launcher. +# Addons only apply to the CLI, the core lib may be extracted and published as a python lib in the future. - if auth_entry is not None and not auth_validate_request(auth_entry): - print("=> Session {} is not validated, refreshing...".format(login)) - try: - auth_refresh_request(auth_entry) - auth_db.save() - except AuthError as auth_err: - print("=> {}".format(str(auth_err))) - auth_entry = None - if auth_entry is None: - client_uuid = uuid.uuid4().hex - password = getpass.getpass("=> Enter {} password: ".format(login)) - try: - auth_entry = auth_authenticate_request(login, password, client_uuid) - if not args.templogin: - print("=> Caching your session...") - auth_db.add_entry(login, auth_entry) - auth_db.save() - except AuthError as auth_err: - print("=> {}".format(str(auth_err))) - exit(EXIT_AUTHENTICATION_FAILED) +class CorePortableMC: - args.username = auth_entry.username - args.uuid = auth_entry.uuid - auth_access_token = auth_entry.access_token - print("=> Logged in") + def __init__(self): - elif args.uuid is not None: # Remove dashes from UUID - args.uuid = args.uuid.replace("-", "") + self._main_dir: Optional[str] = None - # Version meta file caching + self._mc_os = self.get_minecraft_os() + self._mc_arch = self.get_minecraft_arch() + self._mc_archbits = self.get_minecraft_archbits() - def ensure_version_meta(name: str) -> Tuple[dict, str]: + self._version_manifest: Optional[VersionManifest] = None + self._auth_database: Optional[AuthDatabase] = None + self._download_buffer: Optional[bytearray] = None - version_dir = os_path.join(main_dir, "versions", name) - version_meta_file = os_path.join(version_dir, "{}.json".format(name)) - content = None + # Generic methods - if os_path.isfile(version_meta_file): - print("=> Found cached version meta: {}".format(version_meta_file)) - with open(version_meta_file, "rb") as version_meta_fp: - try: - content = json.load(version_meta_fp) - except JSONDecodeError: - print("=> Failed to decode cached version meta, try updating ...") + def init_main_dir(self, main_dir: Optional[str]) -> bool: + self._main_dir = self.get_minecraft_dir() if main_dir is None else path.realpath(main_dir) + return path.isdir(self._main_dir) - if content is None: - for mf_version in version_manifest["versions"]: - if mf_version["id"] == name: - version_url = mf_version["url"] - print("=> Found version meta in manifest, caching: {}".format(version_url)) - content = read_url_json(version_url) - os.makedirs(version_dir, 0o777, True) - with open(version_meta_file, "wt") as version_meta_fp: - json.dump(content, version_meta_fp, indent=2) + def make_main_dir(self): + os.makedirs(self._main_dir, 0o777, True) - return content, version_dir + def check_main_dir(self): + if self._main_dir is None or not path.isdir(self._main_dir): + raise ValueError("Before executing this function, please use 'init_main_dir' to set the main " + "directory path (use None to select the default .minecraft). Also make sure " + "the directory is created (using 'make_main_dir' if needed).") - version_meta, version_dir = ensure_version_meta(version_name) - - if version_meta is None: - print("=> Failed to find version '{}'".format(args.version)) - exit(EXIT_VERSION_NOT_FOUND) - - while "inheritsFrom" in version_meta: - print("=> Version '{}' inherits version '{}'...".format(version_meta["id"], version_meta["inheritsFrom"])) - parent_meta, _ = ensure_version_meta(version_meta["inheritsFrom"]) - if parent_meta is None: - print("=> Failed to find parent version '{}'".format(version_meta["inheritsFrom"])) - exit(EXIT_VERSION_NOT_FOUND) - del version_meta["inheritsFrom"] - dict_merge(parent_meta, version_meta) - version_meta = parent_meta - - # Loading version dependencies - version_type = version_meta["type"] - print("Loading {} {}...".format(version_type, version_name)) - - # Common buffer to avoid realloc - buffer = bytearray(DOWNLOAD_BUFFER_SIZE) - - # JAR file loading - print("Loading jar file...") - version_jar_file = os_path.join(version_dir, "{}.jar".format(version_name)) - if not os_path.isfile(version_jar_file): - version_downloads = version_meta["downloads"] - if "client" not in version_downloads: - print("=> Can't found client download in version meta") - exit(EXIT_CLIENT_JAR_NOT_FOUND) - download_file_info_progress(version_downloads["client"], version_jar_file, buffer=buffer) - - # Assets loading - print("Loading assets...") - assets_dir = os_path.join(main_dir, "assets") - assets_indexes_dir = os_path.join(assets_dir, "indexes") - assets_index_version = version_meta["assets"] - assets_index_file = os_path.join(assets_indexes_dir, "{}.json".format(assets_index_version)) - assets_index = None - - if os_path.isfile(assets_index_file): - print("=> Found cached assets index: {}".format(assets_index_file)) - with open(assets_index_file, "rb") as assets_index_fp: - try: - assets_index = json.load(assets_index_fp) - except JSONDecodeError: - print("=> Failed to decode assets index, try updating...") - - if assets_index is None: - asset_index_info = version_meta["assetIndex"] - asset_index_url = asset_index_info["url"] - print("=> Found asset index in version meta: {}".format(asset_index_url)) - assets_index = read_url_json(asset_index_url) - if not os_path.isdir(assets_indexes_dir): - os.makedirs(assets_indexes_dir, 0o777, True) - with open(assets_index_file, "wt") as assets_index_fp: - json.dump(assets_index, assets_index_fp) - - assets_objects_dir = os_path.join(assets_dir, "objects") - assets_total_size = version_meta["assetIndex"]["totalSize"] - assets_current_size = 0 - assets_virtual_dir = os_path.join(assets_dir, "virtual", assets_index_version) - assets_mapped_to_resources = assets_index.get("map_to_resources", False) # For version <= 13w23b - assets_virtual = assets_index.get("virtual", False) # For 13w23b < version <= 13w48b (1.7.2) - - if assets_mapped_to_resources: - print("=> This version use lagacy assets, put in {}/resources".format(work_dir)) - if assets_virtual: - print("=> This version use virtual assets, put in {}".format(assets_virtual_dir)) - - for asset_id, asset_obj in assets_index["objects"].items(): - - asset_hash = asset_obj["hash"] - asset_hash_prefix = asset_hash[:2] - asset_size = asset_obj["size"] - asset_url = ASSET_BASE_URL.format(asset_hash_prefix, asset_hash) - asset_hash_dir = os_path.join(assets_objects_dir, asset_hash_prefix) - asset_file = os_path.join(asset_hash_dir, asset_hash) - - if not os_path.isfile(asset_file) or os_path.getsize(asset_file) != asset_size: - os.makedirs(asset_hash_dir, 0o777, True) - assets_current_size = download_file_progress(asset_url, asset_size, asset_hash, asset_file, - start_size=assets_current_size, - total_size=assets_total_size, - name=asset_id, - buffer=buffer) + def core_search(self, search: Optional[str], *, local: bool = False) -> list: + + no_version = (search is None) + versions_dir = path.join(self._main_dir, "versions") + # versions = [] + + if local: + if path.isdir(versions_dir): + for version_id in os.listdir(versions_dir): + if no_version or search in version_id: + version_jar_file = path.join(versions_dir, version_id, f"{version_id}.jar") + if path.isfile(version_jar_file): + yield "unknown", version_id, path.getmtime(version_jar_file), False + """versions.append(( + {"type": "unknown", "id": version_id, "releaseTime": path.getmtime(version_jar_file)}, False + ))""" else: - assets_current_size += asset_size + manifest = self.get_version_manifest() + for version_data in manifest.all_versions() if no_version else manifest.search_versions(search): + version_id = version_data["id"] + version_jar_file = path.join(versions_dir, version_id, f"{version_id}.jar") + yield version_data["type"], version_data["id"], version_data["releaseTime"], path.isfile(version_jar_file) + # versions.append((version_data, path.isfile(version_jar_file))) + + # return versions + + def core_start(self, *, + version: str, + jvm: Optional[Union[str, Iterable[str]]] = None, # Default to (JVM_EXEC_DEFAULT, *JVM_ARGS_DEFAULT) + work_dir: Optional[str] = None, # Default to main dir + uuid: Optional[str] = None, # Default to random UUID + username: Optional[str] = None, # Default to uuid[:8] + auth: 'Optional[AuthEntry]' = None, # This parameter will override uuid/username + dry_run: bool = False, + no_better_logging: bool = False, + work_dir_bin: bool = False, + resolution: 'Optional[Tuple[int, int]]' = None, + demo: bool = False, + disable_multiplayer: bool = False, + disable_chat: bool = False, + server_addr: Optional[str] = None, + server_port: Optional[int] = None, + version_meta_modifier: 'Optional[Callable[[dict], None]]' = None, + libraries_modifier: 'Optional[Callable[[List[str], List[str]], None]]' = None, + args_modifier: 'Optional[Callable[[List[str], int], None]]' = None, + args_replacement_modifier: 'Optional[Callable[[Dict[str, str]], None]]' = None, + runner: 'Optional[Callable[[list, str, dict], None]]' = None) -> None: + + # This method can raise these errors: + # - VersionNotFoundError: if the given version was not found + # - URLError: for any URL resolving error + # - DownloadCorruptedError: if a download is corrupted + + self.notice("start.welcome") + + self.check_main_dir() + if work_dir is None: + work_dir = self._main_dir + + # Resolve version metadata + version, version_alias = self.get_version_manifest().filter_latest(version) + version_meta, version_dir = self.resolve_version_meta_recursive(version) + + # Starting version dependencies resolving + version_type = version_meta["type"] + self.notice("start.loading_version", version_type, version) + + if callable(version_meta_modifier): + version_meta_modifier(version_meta) + + # JAR file loading + self.notice("start.loading_jar_file") + version_jar_file = path.join(version_dir, "{}.jar".format(version)) + if not path.isfile(version_jar_file): + version_downloads = version_meta["downloads"] + if "client" not in version_downloads: + self.notice("start.no_client_jar_file") + raise VersionNotFoundError() + download_entry = DownloadEntry.from_version_meta_info(version_downloads["client"], version_jar_file, name="{}.jar".format(version)) + self.download_file(download_entry) + + # Assets loading + self.notice("start.loading_assets") + assets_dir = path.join(self._main_dir, "assets") + assets_indexes_dir = path.join(assets_dir, "indexes") + assets_index_version = version_meta["assets"] + assets_index_file = path.join(assets_indexes_dir, "{}.json".format(assets_index_version)) + assets_index = None + + if path.isfile(assets_index_file): + with open(assets_index_file, "rb") as assets_index_fp: + try: + assets_index = json.load(assets_index_fp) + except JSONDecodeError: + self.notice("start.failed_to_decode_asset_index") + + if assets_index is None: + asset_index_info = version_meta["assetIndex"] + asset_index_url = asset_index_info["url"] + self.notice("start.found_asset_index", asset_index_url) + assets_index = self.read_url_json(asset_index_url) + if not path.isdir(assets_indexes_dir): + os.makedirs(assets_indexes_dir, 0o777, True) + with open(assets_index_file, "wt") as assets_index_fp: + json.dump(assets_index, assets_index_fp) + + assets_objects_dir = path.join(assets_dir, "objects") + assets_total_size = version_meta["assetIndex"]["totalSize"] + assets_current_size = 0 + assets_virtual_dir = path.join(assets_dir, "virtual", assets_index_version) + assets_mapped_to_resources = assets_index.get("map_to_resources", False) # For version <= 13w23b + assets_virtual = assets_index.get("virtual", False) # For 13w23b < version <= 13w48b (1.7.2) if assets_mapped_to_resources: - resources_asset_file = os_path.join(work_dir, "resources", asset_id) - if not os_path.isfile(resources_asset_file): - os.makedirs(os_path.dirname(resources_asset_file), 0o777, True) - shutil.copyfile(asset_file, resources_asset_file) - + self.notice("start.legacy_assets", path.join(work_dir, "resources")) if assets_virtual: - virtual_asset_file = os_path.join(assets_virtual_dir, asset_id) - if not os_path.isfile(virtual_asset_file): - os.makedirs(os_path.dirname(virtual_asset_file), 0o777, True) - shutil.copyfile(asset_file, virtual_asset_file) - - # Logging setup - print("Loading logger config...") - logging_arg = None - if "logging" in version_meta: - version_logging = version_meta["logging"] - if "client" in version_logging: - log_config_dir = os_path.join(assets_dir, "log_configs") - os.makedirs(log_config_dir, 0o777, True) - client_logging = version_logging["client"] - logging_file_info = client_logging["file"] - logging_file = os_path.join(log_config_dir, logging_file_info["id"]) - if not os_path.isfile(logging_file) or os_path.getsize(logging_file) != logging_file_info["size"]: - download_file_info_progress(logging_file_info, logging_file, name=logging_file_info["id"], buffer=buffer) - logging_arg = client_logging["argument"].replace("${path}", logging_file) - - # Libraries and natives loading - print("Loading libraries and natives...") - libraries_dir = os_path.join(main_dir, "libraries") - - main_class = version_meta["mainClass"] - main_class_launchwrapper = (main_class == "net.minecraft.launchwrapper.Launch") - classpath_libs = [version_jar_file] - native_libs = [] - - archbits = get_minecraft_archbits() - - for lib_obj in version_meta["libraries"]: - - if "rules" in lib_obj: - if not interpret_rule(lib_obj["rules"], mc_os): - continue - - lib_name = lib_obj["name"] # type: str - lib_type = None # type: Optional[str] - - if "downloads" in lib_obj: - - lib_dl = lib_obj["downloads"] - lib_dl_info = None - - if "natives" in lib_obj and "classifiers" in lib_dl: - lib_natives = lib_obj["natives"] - if mc_os in lib_natives: - lib_native_classifier = lib_natives[mc_os] - if archbits is not None: - lib_native_classifier = lib_native_classifier.replace("${arch}", archbits) - lib_name += ":{}".format(lib_native_classifier) - lib_dl_info = lib_dl["classifiers"][lib_native_classifier] - lib_type = "native" - elif "artifact" in lib_dl: - lib_dl_info = lib_dl["artifact"] - lib_type = "classpath" + self.notice("start.virtual_assets", assets_virtual_dir) + + self.notice("start.verifying_assets") + for asset_id, asset_obj in assets_index["objects"].items(): + + asset_hash = asset_obj["hash"] + asset_hash_prefix = asset_hash[:2] + asset_size = asset_obj["size"] + asset_hash_dir = path.join(assets_objects_dir, asset_hash_prefix) + asset_file = path.join(asset_hash_dir, asset_hash) + + if not path.isfile(asset_file) or path.getsize(asset_file) != asset_size: + os.makedirs(asset_hash_dir, 0o777, True) + asset_url = ASSET_BASE_URL.format(asset_hash_prefix, asset_hash) + download_entry = DownloadEntry(asset_url, asset_size, asset_hash, asset_file, name=asset_id) + self.download_file(download_entry, + start_size=assets_current_size, + total_size=assets_total_size) + else: + assets_current_size += asset_size + + if assets_mapped_to_resources: + resources_asset_file = path.join(work_dir, "resources", asset_id) + if not path.isfile(resources_asset_file): + os.makedirs(path.dirname(resources_asset_file), 0o777, True) + shutil.copyfile(asset_file, resources_asset_file) + + if assets_virtual: + virtual_asset_file = path.join(assets_virtual_dir, asset_id) + if not path.isfile(virtual_asset_file): + os.makedirs(path.dirname(virtual_asset_file), 0o777, True) + shutil.copyfile(asset_file, virtual_asset_file) + + # Logging configuration + self.notice("start.loading_logger") + logging_arg = None + if "logging" in version_meta: + version_logging = version_meta["logging"] + if "client" in version_logging: + log_config_dir = path.join(assets_dir, "log_configs") + os.makedirs(log_config_dir, 0o777, True) + client_logging = version_logging["client"] + logging_file_info = client_logging["file"] + logging_file = path.join(log_config_dir, logging_file_info["id"]) + logging_dirty = False + download_entry = DownloadEntry.from_version_meta_info(logging_file_info, logging_file, + name=logging_file_info["id"]) + if not path.isfile(logging_file) or path.getsize(logging_file) != download_entry.size: + self.download_file(download_entry) + logging_dirty = True + if not no_better_logging: + better_logging_file = path.join(log_config_dir, "portablemc-{}".format(logging_file_info["id"])) + if logging_dirty or not path.isfile(better_logging_file): + self.notice("start.generating_better_logging_config") + with open(logging_file, "rt") as logging_fp: + with open(better_logging_file, "wt") as custom_logging_fp: + raw = logging_fp.read() \ + .replace("", LOGGING_CONSOLE_REPLACEMENT) \ + .replace("", LOGGING_CONSOLE_REPLACEMENT) + custom_logging_fp.write(raw) + logging_file = better_logging_file + logging_arg = client_logging["argument"].replace("${path}", logging_file) + + # Libraries and natives loading + self.notice("start.loading_libraries") + libraries_dir = path.join(self._main_dir, "libraries") + classpath_libs = [version_jar_file] + native_libs = [] + + for lib_obj in version_meta["libraries"]: + + if "rules" in lib_obj: + if not self.interpret_rule(lib_obj["rules"]): + continue - if lib_dl_info is None: - print("=> Can't found library for {}".format(lib_name)) - continue + lib_name = lib_obj["name"] # type: str + lib_type = None # type: Optional[str] + + if "downloads" in lib_obj: + + lib_dl = lib_obj["downloads"] + lib_dl_info = None + + if "natives" in lib_obj and "classifiers" in lib_dl: + lib_natives = lib_obj["natives"] + if self._mc_os in lib_natives: + lib_native_classifier = lib_natives[self._mc_os] + if self._mc_archbits is not None: + lib_native_classifier = lib_native_classifier.replace("${arch}", self._mc_archbits) + lib_name += ":{}".format(lib_native_classifier) + lib_dl_info = lib_dl["classifiers"][lib_native_classifier] + lib_type = "native" + elif "artifact" in lib_dl: + lib_dl_info = lib_dl["artifact"] + lib_type = "classpath" + + if lib_dl_info is None: + self.notice("start.no_download_for_library", lib_name) + continue - lib_path = os_path.join(libraries_dir, lib_dl_info["path"]) - lib_dir = os_path.dirname(lib_path) - lib_size = lib_dl_info["size"] + lib_path = path.join(libraries_dir, lib_dl_info["path"]) + lib_dir = path.dirname(lib_path) - os.makedirs(lib_dir, 0o777, True) + os.makedirs(lib_dir, 0o777, True) + download_entry = DownloadEntry.from_version_meta_info(lib_dl_info, lib_path, name=lib_name) - if not os_path.isfile(lib_path) or os_path.getsize(lib_path) != lib_size: - download_file_info_progress(lib_dl_info, lib_path, name=lib_name, buffer=buffer) + if not path.isfile(lib_path) or path.getsize(lib_path) != download_entry.size: + self.download_file(download_entry) - else: + else: - # If no 'downloads' trying to parse the maven dependency string ":: - # to directory path. This may be used by custom configuration that do not provide download - # links like Optifine. - - lib_name_parts = lib_name.split(":") - lib_path = os_path.join(libraries_dir, *lib_name_parts[0].split("."), lib_name_parts[1], lib_name_parts[2], "{}-{}.jar".format(lib_name_parts[1], lib_name_parts[2])) - lib_type = "classpath" - - if not os_path.isfile(lib_path): - print("=> Can't found cached library for {} at {}".format(lib_name, lib_path)) - continue - - if lib_type == "classpath": - classpath_libs.append(lib_path) - elif lib_type == "native": - native_libs.append(lib_path) - - if args.nostart: - print("Not starting") - exit(0) - - # Start game - print("Starting game ...") - - # Extracting binaries - bin_dir = os_path.join(main_dir, "bin", str(uuid.uuid4())) - - if os_path.isdir(bin_dir): - print("=> Natives directory already exists at: {}".format(bin)) - exit(EXIT_NATIVES_DIR_ALREADY_EXITS) - else: - os.makedirs(bin_dir, 0o777, True) - - print("=> Extracting natives...") - for native_lib in native_libs: - with ZipFile(native_lib, 'r') as native_zip: - for native_zip_info in native_zip.infolist(): - if is_native_zip_info_valid(native_zip_info.filename): - native_zip.extract(native_zip_info, bin_dir) - - # Decode arguments - custom_resol = args.resolution - if custom_resol is None or len(custom_resol) != 2: - custom_resol = None - - raw_args = [] # type: List[str] - features = { - "is_demo_user": args.demo, - "has_custom_resolution": custom_resol is not None - } - - legacy_args = version_meta.get("minecraftArguments") # type: Optional[str] - - if legacy_args is not None: - raw_args.extend(interpret_args(LEGACY_JVM_ARGUMENTS, mc_os, features)) - else: - raw_args.extend(interpret_args(version_meta["arguments"]["jvm"], mc_os, features)) - - raw_args.extend(args.jvm_args.split(" ")) - - # Default JVM arguments : - # -Xmx2G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M - # raw_args.append("-Xmx2G") - # raw_args.append("-XX:+UnlockExperimentalVMOptions") - # raw_args.append("-XX:+UseG1GC") - # raw_args.append("-XX:G1NewSizePercent=20") - # raw_args.append("-XX:G1ReservePercent=20") - # raw_args.append("-XX:MaxGCPauseMillis=50") - # raw_args.append("-XX:G1HeapRegionSize=32M") - - if logging_arg is not None: - raw_args.append(logging_arg) - - if main_class_launchwrapper: - raw_args.append("-Dminecraft.client.jar={}".format(version_jar_file)) - - raw_args.append(main_class) - - if legacy_args is not None: - raw_args.extend(legacy_args.split(" ")) - else: - raw_args.extend(interpret_args(version_meta["arguments"]["game"], mc_os, features)) - - # Arguments replacements - start_args_replacements = { - # Game - "auth_player_name": args.username, - "version_name": version_name, - "game_directory": work_dir, - "assets_root": assets_dir, - "assets_index_name": assets_index_version, - "auth_uuid": args.uuid, - "auth_access_token": auth_access_token, - "user_type": "mojang", - "version_type": version_type, - # Game (legacy) - "auth_session": "token:{}:{}".format(auth_access_token, args.uuid) if len(auth_access_token) else "notok", - "game_assets": assets_virtual_dir, - "user_properties": "{}", - # JVM - "natives_directory": bin_dir, - "launcher_name": LAUNCHER_NAME, - "launcher_version": LAUNCHER_VERSION, - "classpath": get_classpath_separator().join(classpath_libs) - } - - if custom_resol is not None: - start_args_replacements["resolution_width"] = str(custom_resol[0]) - start_args_replacements["resolution_height"] = str(custom_resol[1]) - - jvm_path = args.jvm - start_args = [*jvm_path.split(" ")] - for arg in raw_args: - for repl_id, repl_val in start_args_replacements.items(): - arg = arg.replace("${{{}}}".format(repl_id), repl_val) - start_args.append(arg) - - print("=> Running...") - print("=> Command line: {}".format(" ".join(start_args))) - print("================================================") - os.makedirs(work_dir, 0o777, True) - subprocess.run(start_args, cwd=work_dir) - print("================================================") - print("=> Game stopped, removing bin directory...") - shutil.rmtree(bin_dir) - - -############# -## Utils ## -############# - -def read_url_json(url: str) -> dict: - return json.load(urlreq.urlopen(url)) - - -def dict_merge(dst: dict, other: dict): - for k, v in other.items(): - if k in dst: - if isinstance(dst[k], dict) and isinstance(other[k], dict): - dict_merge(dst[k], other[k]) - continue - elif isinstance(dst[k], list) and isinstance(other[k], list): - dst[k].extend(other[k]) - continue - dst[k] = other[k] - - -def read_version_manifest() -> dict: - return read_url_json(VERSION_MANIFEST_URL) - - -def get_version_info(version_name: str) -> Optional[Tuple[str, str]]: - version_manifest = read_version_manifest() - if version_name in SPECIAL_VERSIONS and version_name in version_manifest["latest"]: - version_name = version_manifest["latest"][version_name] - for version in version_manifest["versions"]: - if version["id"] == version_name: - return version["type"], version["url"] - - -def get_minecraft_dir(dirname: str) -> str: - pf = sys.platform - home = os_path.expanduser("~") - if pf.startswith("freebsd") or pf.startswith("linux") or pf.startswith("aix") or pf.startswith("cygwin"): - return os_path.join(home, ".{}".format(dirname)) - elif pf == "win32": - return os_path.join(home, "AppData", "Roaming", ".{}".format(dirname)) - elif pf == "darwin": - return os_path.join(home, "Library", "Application Support", dirname) - - -def get_classpath_separator() -> str: - return ";" if sys.platform == "win32" else ":" - - -def get_minecraft_os() -> str: - pf = sys.platform - if pf.startswith("freebsd") or pf.startswith("linux") or pf.startswith("aix") or pf.startswith("cygwin"): - return "linux" - elif pf == "win32": - return "windows" - elif pf == "darwin": - return "osx" - - -def get_minecraft_arch() -> str: - machine = platform.machine().lower() - return "x86" if machine == "i386" else "x86_64" if machine in ("x86_64", "amd64") else "unknown" - - -def get_minecraft_archbits() -> Optional[str]: - raw_bits = platform.architecture()[0] - return "64" if raw_bits == "64bit" else "32" if raw_bits == "32bit" else None - - -def interpret_rule(rules: list, mc_os: str, features: Optional[dict] = None) -> bool: - allowed = False - for rule in rules: - if "os" in rule: - ros = rule["os"] - if "name" in ros and ros["name"] != mc_os: - continue - elif "arch" in ros and ros["arch"] != get_minecraft_arch(): - continue - elif "version" in ros and re.compile(ros["version"]).search(platform.version()) is None: - continue - if "features" in rule: - feature_valid = True - for feat_name, feat_value in rule["features"].items(): - if feat_name not in features or feat_value != features[feat_name]: - feature_valid = False - break - if not feature_valid: - continue - act = rule["action"] - if act == "allow": - allowed = True - elif act == "disallow": - allowed = False - return allowed - - -def interpret_args(args: list, mc_os: str, features: dict) -> list: - ret = [] - for arg in args: - if isinstance(arg, str): - ret.append(arg) - else: - if "rules" in arg: - if not interpret_rule(arg["rules"], mc_os, features): + # If no 'downloads' trying to parse the maven dependency string ":: + # to directory path. This may be used by custom configuration that do not provide download + # links like Optifine. + + lib_name_parts = lib_name.split(":") + lib_path = path.join(libraries_dir, *lib_name_parts[0].split("."), lib_name_parts[1], + lib_name_parts[2], "{}-{}.jar".format(lib_name_parts[1], lib_name_parts[2])) + lib_type = "classpath" + + if not path.isfile(lib_path): + self.notice("start.cached_library_not_found", lib_name, lib_path) continue - arg_value = arg["value"] - if isinstance(arg_value, list): - ret.extend(arg_value) - elif isinstance(arg_value, str): - ret.append(arg_value) - return ret + if lib_type == "classpath": + classpath_libs.append(lib_path) + elif lib_type == "native": + native_libs.append(lib_path) + + if callable(libraries_modifier): + libraries_modifier(classpath_libs, native_libs) + + # Don't run if dry run + if dry_run: + self.notice("start.dry") + return + + # Start game + self.notice("start.starting") + + # Extracting binaries + bin_dir = path.join(work_dir if work_dir_bin else self._main_dir, "bin", str(uuid4())) + + @atexit.register + def _bin_dir_cleanup(): + if path.isdir(bin_dir): + shutil.rmtree(bin_dir) + + self.notice("start.extracting_natives") + for native_lib in native_libs: + with ZipFile(native_lib, 'r') as native_zip: + for native_zip_info in native_zip.infolist(): + if self.can_extract_native(native_zip_info.filename): + native_zip.extract(native_zip_info, bin_dir) + + features = { + "is_demo_user": demo, + "has_custom_resolution": resolution is not None + } + + legacy_args = version_meta.get("minecraftArguments") + + raw_args = [] + raw_args.extend( + self.interpret_args(version_meta["arguments"]["jvm"] if legacy_args is None else LEGACY_JVM_ARGUMENTS, + features)) + + if logging_arg is not None: + raw_args.append(logging_arg) + + main_class = version_meta["mainClass"] + if main_class == "net.minecraft.launchwrapper.Launch": + # raw_args.append("-Dminecraft.client.jar={}".format(version_jar_file)) + main_class = "net.minecraft.client.Minecraft" + + main_class_idx = len(raw_args) + raw_args.append(main_class) + raw_args.extend(self.interpret_args(version_meta["arguments"]["game"], features) if legacy_args is None else legacy_args.split(" ")) + + if disable_multiplayer: + raw_args.append("--disableMultiplayer") + if disable_chat: + raw_args.append("--disableChat") + + if server_addr is not None: + raw_args.extend(("--server", server_addr)) + if server_port is not None: + raw_args.extend(("--port", str(server_port))) + + if callable(args_modifier): + args_modifier(raw_args, main_class_idx) + + if auth is not None: + uuid = auth.uuid + username = auth.username + else: + uuid = uuid4().hex if uuid is None else uuid.replace("-", "") + username = uuid[:8] if username is None else username[:16] # Max username length is 16 + + # Arguments replacements + start_args_replacements = { + # Game + "auth_player_name": username, + "version_name": version, + "game_directory": work_dir, + "assets_root": assets_dir, + "assets_index_name": assets_index_version, + "auth_uuid": uuid, + "auth_access_token": "" if auth is None else auth.format_token_argument(False), + "user_type": "mojang", + "version_type": version_type, + # Game (legacy) + "auth_session": "notok" if auth is None else auth.format_token_argument(True), + "game_assets": assets_virtual_dir, + "user_properties": "{}", + # JVM + "natives_directory": bin_dir, + "launcher_name": LAUNCHER_NAME, + "launcher_version": LAUNCHER_VERSION, + "classpath": self.get_classpath_separator().join(classpath_libs) + } + + if resolution is not None: + start_args_replacements["resolution_width"] = str(resolution[0]) + start_args_replacements["resolution_height"] = str(resolution[1]) + + if callable(args_replacement_modifier): + args_replacement_modifier(start_args_replacements) + + if jvm is None: + jvm = (JVM_EXEC_DEFAULT, *JVM_ARGS_DEFAULT) + elif isinstance(jvm, str): + jvm = (jvm, *JVM_ARGS_DEFAULT) + + start_args = [*jvm] + for arg in raw_args: + for repl_id, repl_val in start_args_replacements.items(): + arg = arg.replace("${{{}}}".format(repl_id), repl_val) + start_args.append(arg) + + self.notice("start.running") + os.makedirs(work_dir, 0o777, True) + + if runner is None: + subprocess.run(start_args, cwd=work_dir) + else: + runner(start_args, work_dir, { + "version": version, + "username": username, + "uuid": uuid + }) + + self.notice("start.stopped") + + # Lazy variables getters + + def get_main_dir(self) -> str: + return self._main_dir + + def get_version_manifest(self) -> 'VersionManifest': + if self._version_manifest is None: + self._version_manifest = VersionManifest.load_from_url() + return self._version_manifest + + def get_auth_database(self) -> 'AuthDatabase': + if self._auth_database is None: + self._auth_database = AuthDatabase(path.join(self._main_dir, "portablemc_tokens")) + return self._auth_database + + def get_download_buffer(self) -> bytearray: + if self._download_buffer is None: + self._download_buffer = bytearray(32768) + return self._download_buffer + + # Public methods to be replaced by addons + + def notice(self, key: str, *args): + pass -def is_native_zip_info_valid(filename: str) -> bool: - return not filename.startswith("META-INF") and not filename.endswith(".git") and not filename.endswith(".sha1") + def mixin(self, target: str, func, owner: Optional[object] = None): + if owner is None: + owner = self + old_func = getattr(owner, target, None) + def wrapper(*args, **kwargs): + return func(old_func, *args, **kwargs) + setattr(owner, target, wrapper) + # General utilities -def download_file_progress(url: str, size: int, sha1: str, dst: str, *, start_size: int = 0, total_size: int = 0, name: Optional[str] = None, buffer: Optional[bytearray] = None) -> int: + def download_file(self, + entry: 'DownloadEntry', *, + start_size: int = 0, + total_size: int = 0, + progress_callback: Optional[Callable[[int, int, int, int], None]] = None) -> int: - base_message = "Downloading {} ... ".format(url if name is None else name) - print(base_message, end='') + with url_request.urlopen(entry.url) as req: + with open(entry.dst, "wb") as dst_fp: - success = False + dl_sha1 = hashlib.sha1() + dl_size = 0 - with urlreq.urlopen(url) as req: - with open(dst, "wb") as dst_fp: + buffer = self.get_download_buffer() - dl_sha1 = hashlib.sha1() - dl_size = 0 + while True: - if buffer is None: - buffer = bytearray(DOWNLOAD_BUFFER_SIZE) + read_len = req.readinto(buffer) + if not read_len: + break - last_time = time.monotonic() + buffer_view = buffer[:read_len] + dl_size += read_len + dl_sha1.update(buffer_view) + dst_fp.write(buffer_view) - while True: + if total_size != 0: + start_size += read_len - read_len = req.readinto(buffer) - if not read_len: - break + if progress_callback is not None: + progress_callback(dl_size, entry.size, start_size, total_size) + + if dl_size != entry.size: + raise DownloadCorruptedError("invalid_size") + elif dl_sha1.hexdigest() != entry.sha1: + raise DownloadCorruptedError("invalid_sha1") + else: + return start_size + + # Version metadata + + def resolve_version_meta(self, name: str) -> Tuple[dict, str]: + + version_dir = path.join(self._main_dir, "versions", name) + version_meta_file = path.join(version_dir, "{}.json".format(name)) + content = None + + self.notice("version.resolving", name) + + if path.isfile(version_meta_file): + self.notice("version.found_cached") + with open(version_meta_file, "rb") as version_meta_fp: + try: + content = json.load(version_meta_fp) + self.notice("version.loaded") + except JSONDecodeError: + self.notice("version.failed_to_decode_cached") - buffer_view = buffer[:read_len] - dl_size += read_len - dl_sha1.update(buffer_view) - dst_fp.write(buffer_view) - progress = dl_size / size * 100 - print("\r{}{:6.2f}%".format(base_message, progress), end='') + if content is None: + version_data = self.get_version_manifest().get_version(name) + if version_data is not None: + version_url = version_data["url"] + self.notice("version.found_in_manifest") + content = self.read_url_json(version_url) + os.makedirs(version_dir, 0o777, True) + with open(version_meta_file, "wt") as version_meta_fp: + json.dump(content, version_meta_fp, indent=2) + else: + self.notice("version.not_found_in_manifest") + raise VersionNotFoundError(name) - if total_size != 0: - start_size += read_len - progress = start_size / total_size * 100 - print(" {:6.2f}% of total".format(progress), end='') - - now_time = time.monotonic() - if now_time != last_time: - print(" {}/s ".format(format_bytes(read_len / (now_time - last_time))), end='') - last_time = now_time + return content, version_dir - if dl_size != size: - print(" => Invalid size") - elif dl_sha1.hexdigest() != sha1: - print(" => Invalid SHA1") + def resolve_version_meta_recursive(self, name: str) -> Tuple[dict, str]: + version_meta, version_dir = self.resolve_version_meta(name) + while "inheritsFrom" in version_meta: + self.notice("version.parent_version", version_meta["inheritsFrom"]) + parent_meta, _ = self.resolve_version_meta(version_meta["inheritsFrom"]) + if parent_meta is None: + self.notice("version.parent_version_not_found", version_meta["inheritsFrom"]) + raise VersionNotFoundError(version_meta["inheritsFrom"]) + del version_meta["inheritsFrom"] + self.dict_merge(parent_meta, version_meta) + version_meta = parent_meta + return version_meta, version_dir + + # Version meta rules interpretation + + def interpret_rule(self, rules: list, features: Optional[dict] = None) -> bool: + allowed = False + for rule in rules: + if "os" in rule: + ros = rule["os"] + if "name" in ros and ros["name"] != self._mc_os: + continue + elif "arch" in ros and ros["arch"] != self._mc_arch: + continue + elif "version" in ros and re.compile(ros["version"]).search(platform.version()) is None: + continue + if "features" in rule: + feature_valid = True + for feat_name, feat_value in rule["features"].items(): + if feat_name not in features or feat_value != features[feat_name]: + feature_valid = False + break + if not feature_valid: + continue + act = rule["action"] + if act == "allow": + allowed = True + elif act == "disallow": + allowed = False + return allowed + + def interpret_args(self, args: list, features: dict) -> list: + ret = [] + for arg in args: + if isinstance(arg, str): + ret.append(arg) else: - print() - success = True + if "rules" in arg: + if not self.interpret_rule(arg["rules"], features): + continue + arg_value = arg["value"] + if isinstance(arg_value, list): + ret.extend(arg_value) + elif isinstance(arg_value, str): + ret.append(arg_value) + return ret + + # Static utilities + + @staticmethod + def get_minecraft_dir() -> str: + pf = sys.platform + home = path.expanduser("~") + if pf.startswith("freebsd") or pf.startswith("linux") or pf.startswith("aix") or pf.startswith("cygwin"): + return path.join(home, ".minecraft") + elif pf == "win32": + return path.join(home, "AppData", "Roaming", ".minecraft") + elif pf == "darwin": + return path.join(home, "Library", "Application Support", "minecraft") + + @staticmethod + def get_minecraft_os() -> str: + pf = sys.platform + if pf.startswith("freebsd") or pf.startswith("linux") or pf.startswith("aix") or pf.startswith("cygwin"): + return "linux" + elif pf == "win32": + return "windows" + elif pf == "darwin": + return "osx" + + @staticmethod + def get_minecraft_arch() -> str: + machine = platform.machine().lower() + return "x86" if machine == "i386" else "x86_64" if machine in ("x86_64", "amd64") else "unknown" + + @staticmethod + def get_minecraft_archbits() -> Optional[str]: + raw_bits = platform.architecture()[0] + return "64" if raw_bits == "64bit" else "32" if raw_bits == "32bit" else None + + @staticmethod + def get_classpath_separator() -> str: + return ";" if sys.platform == "win32" else ":" + + @staticmethod + def read_url_json(url: str) -> dict: + return json.load(url_request.urlopen(url)) + + @classmethod + def dict_merge(cls, dst: dict, other: dict): + for k, v in other.items(): + if k in dst: + if isinstance(dst[k], dict) and isinstance(other[k], dict): + cls.dict_merge(dst[k], other[k]) + continue + elif isinstance(dst[k], list) and isinstance(other[k], list): + dst[k].extend(other[k]) + continue + dst[k] = other[k] - if not success: - exit(EXIT_DOWNLOAD_FILE_CORRUPTED) - else: - return start_size + @staticmethod + def can_extract_native(filename: str) -> bool: + return not filename.startswith("META-INF") and not filename.endswith(".git") and not filename.endswith(".sha1") -def download_file_info_progress(info: dict, dst: str, *, start_size: int = 0, total_size: int = 0, name: Optional[str] = None, buffer: Optional[bytearray] = None) -> int: - return download_file_progress(info["url"], info["size"], info["sha1"], dst, start_size=start_size, total_size=total_size, name=name, buffer=buffer) +class VersionManifest: + def __init__(self, data: dict): + self._data = data -def format_manifest_date(raw: str): - return datetime.strptime(raw.rsplit("+", 2)[0], "%Y-%m-%dT%H:%M:%S").strftime("%c") + @classmethod + def load_from_url(cls): + return cls(CorePortableMC.read_url_json(VERSION_MANIFEST_URL)) + def filter_latest(self, version: str) -> Tuple[Optional[str], bool]: + return (self._data["latest"][version], True) if version in self._data["latest"] else (version, False) -def format_bytes(n: float) -> str: - if n < 1000: - return "{:4.0f}B".format(int(n)) - elif n < 1000000: - return "{:4.0f}kB".format(n // 1000) - elif n < 1000000000: - return "{:4.0f}MB".format(n // 1000000) - else: - return "{:4.0f}GB".format(n // 1000000000) + def get_version(self, version: str) -> Optional[dict]: + version, _alias = self.filter_latest(version) + for version_data in self._data["versions"]: + if version_data["id"] == version: + return version_data + return None + def all_versions(self) -> list: + return self._data["versions"] + + def search_versions(self, inp: str) -> Generator[dict, None, None]: + inp, alias = self.filter_latest(inp) + for version_data in self._data["versions"]: + if (alias and version_data["id"] == inp) or (not alias and inp in version_data["id"]): + yield version_data -#################### -## Authentication ## -#################### class AuthEntry: - def __init__(self, client_token: str, username: str, _uuid: str, access_token: str): + + def __init__(self, client_token: str, username: str, uuid: str, access_token: str): self.client_token = client_token self.username = username - self.uuid = _uuid + self.uuid = uuid # No dashes self.access_token = access_token + def format_token_argument(self, legacy: bool) -> str: + if legacy: + return "token:{}:{}".format(self.access_token, self.uuid) + else: + return self.access_token + + def validate(self) -> bool: + return self.auth_request("validate", { + "accessToken": self.access_token, + "clientToken": self.client_token + }, False)[0] == 204 + + def refresh(self): + + _, res = self.auth_request("refresh", { + "accessToken": self.access_token, + "clientToken": self.client_token + }) + + self.access_token = res["accessToken"] + + def invalidate(self): + self.auth_request("invalidate", { + "accessToken": self.access_token, + "clientToken": self.client_token + }, False) + + @classmethod + def authenticate(cls, email_or_username: str, password: str) -> 'AuthEntry': + + _, res = cls.auth_request("authenticate", { + "agent": { + "name": "Minecraft", + "version": 1 + }, + "username": email_or_username, + "password": password, + "clientToken": uuid4().hex + }) + + return AuthEntry( + res["clientToken"], + res["selectedProfile"]["name"], + res["selectedProfile"]["id"], + res["accessToken"] + ) + + @staticmethod + def auth_request(req: str, payload: dict, error: bool = True) -> (int, dict): + + from http.client import HTTPResponse + from urllib.request import Request + + req_url = AUTHSERVER_URL.format(req) + data = json.dumps(payload).encode("ascii") + req = Request(req_url, data, headers={ + "Content-Type": "application/json", + "Content-Length": len(data) + }, method="POST") + + try: + res = url_request.urlopen(req) # type: HTTPResponse + except HTTPError as err: + res = cast(HTTPResponse, err.fp) + + try: + res_data = json.load(res) + except JSONDecodeError: + res_data = {} + + if error and res.status != 200: + raise AuthError(res_data["errorMessage"]) + + return res.status, res_data + class AuthDatabase: @@ -718,10 +794,9 @@ def __init__(self, filename: str): def load(self): self._entries.clear() - if os_path.isfile(self._filename): + if path.isfile(self._filename): with open(self._filename, "rt") as fp: - line = fp.readline() - if line is not None: + for line in fp.readlines(): parts = line.split(" ") if len(parts) == 5: self._entries[parts[0]] = AuthEntry( @@ -734,96 +809,45 @@ def load(self): def save(self): with open(self._filename, "wt") as fp: fp.writelines(("{} {} {} {} {}".format( - login, + email_or_username, entry.client_token, entry.username, entry.uuid, entry.access_token - ) for login, entry in self._entries.items())) - - def get_entry(self, login: str) -> Optional[AuthEntry]: - return self._entries.get(login, None) - - def add_entry(self, login: str, entry: AuthEntry): - self._entries[login] = entry - - def remove_entry(self, login: str): - if login in self._entries: - del self._entries[login] - - -def auth_request(req: str, payload: dict, error: bool = True) -> (int, dict): - - req_url = AUTHSERVER_URL.format(req) - data = json.dumps(payload).encode("ascii") - req = URLRequest(req_url, data, headers={ - "Content-Type": "application/json", - "Content-Length": len(data) - }, method="POST") + ) for email_or_username, entry in self._entries.items())) - try: - res = urlreq.urlopen(req) # type: HTTPResponse - except HTTPError as err: - res = cast(HTTPResponse, err.fp) + def get_entry(self, email_or_username: str) -> Optional[AuthEntry]: + return self._entries.get(email_or_username, None) - try: - res_data = json.load(res) - except JSONDecodeError: - res_data = {} + def add_entry(self, email_or_username: str, entry: AuthEntry): + self._entries[email_or_username] = entry - if error and res.status != 200: - raise AuthError(res_data["errorMessage"]) + def remove_entry(self, email_or_username: str): + if email_or_username in self._entries: + del self._entries[email_or_username] - return res.status, res_data +class DownloadEntry: -def auth_authenticate_request(login: str, password: str, client_token: str) -> AuthEntry: + __slots__ = "url", "size", "sha1", "dst", "name" - _, res = auth_request("authenticate", { - "agent": { - "name": "Minecraft", - "version": 1 - }, - "username": login, - "password": password, - "clientToken": client_token - }) + def __init__(self, url: str, size: int, sha1: str, dst: str, *, name: Optional[str] = None): + self.url = url + self.size = size + self.sha1 = sha1 + self.dst = dst + self.name = url if name is None else name - return AuthEntry(res["clientToken"], res["selectedProfile"]["name"], res["selectedProfile"]["id"], res["accessToken"]) + @classmethod + def from_version_meta_info(cls, info: dict, dst: str, *, name: Optional[str] = None) -> 'DownloadEntry': + return DownloadEntry(info["url"], info["size"], info["sha1"], dst, name=name) -def auth_validate_request(auth_entry: AuthEntry) -> bool: - return auth_request("validate", { - "accessToken": auth_entry.access_token, - "clientToken": auth_entry.client_token - }, False)[0] == 204 +class AuthError(Exception): ... +class VersionNotFoundError(Exception): ... +class DownloadCorruptedError(Exception): ... -def auth_refresh_request(auth_entry: AuthEntry): - - _, res = auth_request("refresh", { - "accessToken": auth_entry.access_token, - "clientToken": auth_entry.client_token - }) - - auth_entry.access_token = res["accessToken"] - - -def auth_invalidate(auth_entry: AuthEntry): - auth_request("invalidate", { - "accessToken": auth_entry.access_token, - "clientToken": auth_entry.client_token - }, False) - - -class AuthError(Exception): - pass - - -############################### -## Retro compatible JVM args ## -############################### - LEGACY_JVM_ARGUMENTS = [ { "rules": [ @@ -873,4 +897,643 @@ class AuthError(Exception): if __name__ == '__main__': - main() + + from argparse import ArgumentParser, Namespace, HelpFormatter + from urllib.error import URLError + from datetime import datetime + from typing import Any + import time + + EXIT_VERSION_NOT_FOUND = 10 + EXIT_CLIENT_JAR_NOT_FOUND = 11 + EXIT_NATIVES_DIR_ALREADY_EXITS = 12 + EXIT_DOWNLOAD_FILE_CORRUPTED = 13 + EXIT_AUTHENTICATION_FAILED = 14 + EXIT_VERSION_SEARCH_NOT_FOUND = 15 + EXIT_DEPRECATED_ARGUMENT = 16 + EXIT_LOGOUT_FAILED = 17 + EXIT_URL_ERROR = 18 + + ADDONS_DIR = "addons" + ADDONS_PKG_INIT_CONTENT = "# This file was generated by PortableMC.\n" \ + "# It's only purpose is to make this directory a valid python package.\n" \ + "# Do not modify this file unless you know what you are doing, because this file " \ + "is not intended to be shared.\n" + ADDONS_TPL_INIT_CONTENT = "# Entry module for the addon\n\n" \ + "NAME = \"{name}\"\n" \ + "VERSION = \"0.0.1\"\n" \ + "AUTHORS = ()\n" \ + "REQUIRES = ()\n\n" \ + "def addon_build(pmc):\n" \ + " return None\n" + + class PortableMC(CorePortableMC): + + def __init__(self): + + super().__init__() + + self._addons_dir = path.join(path.dirname(__file__), ADDONS_DIR) + self._addons: Dict[str, PortableAddon] = {} + + self._messages = { + + "addon.defined_twice": "The addon '{}' is defined twice, both single-file and package, loaded the package one.", + "addon.missing_requirement.module": "Addon '{}' requires module '{}' to load.", + "addon.missing_requirement.ext": "Addon '{}' requires another addon '{}' to load.", + "addon.failed_to_build": "Failed to build addon '{}' (contact addon's authors):", + + "args": "PortableMC is an easy to use portable Minecraft launcher in only one Python " + "script! This single-script launcher is still compatible with the official " + "(Mojang) Minecraft Launcher stored in .minecraft and use it.", + "args.main_dir": "Set the main directory where libraries, assets, versions and binaries (at runtime) " + "are stored. It also contains the launcher authentication database.", + "args.search": "Search for official Minecraft versions.", + "args.start": "Start a Minecraft version, default to the latest release.", + "args.start.dry": "Simulate game starting.", + "args.start.disable_multiplayer": "Disable the multiplayer buttons (>= 1.16).", + "args.start.disable_chat": "Disable the online chat (>= 1.16).", + "args.start.demo": "Start game in demo mode.", + "args.start.resol": "Set a custom start resolution (x).", + "args.start.jvm": "Set a custom JVM 'javaw' executable path.", + "args.start.jvm_args": "Change the default JVM arguments.", + "args.start.work_dir": "Set the working directory where the game run and place for examples the " + "saves (and resources for legacy versions).", + "args.start.work_dir_bin": "Flag to force temporary binaries to be copied inside working directory, " + "by default they are copied into main directory.", + "args.start.no_better_logging": "Disable the better logging configuration built by the launcher in " + "order to improve the log readability in the console.", + "args.start.temp_login": "Flag used with -l (--login) to tell launcher not to cache your session if " + "not already cached, deactivated by default.", + "args.start.login": "Use a email or username (legacy) to authenticate using mojang servers (you " + "will be asked for password, it override --username and --uuid).", + "args.start.username": "Set a custom user name to play.", + "args.start.uuid": "Set a custom user UUID to play.", + "args.start.server": "Start the game and auto-connect to this server address (since 1.6).", + "args.start.server_port": "Set the server address port (given with -s, --server, since 1.6).", + "args.login": "Login into your Mojang account, this will cache your tokens.", + "args.logout": "Logout from your Mojang account.", + "args.addon": "Addons management subcommands.", + "args.addon.list": "List addons.", + "args.addon.init": "For developpers: Given an addon's name, initialize its package if it doesn't already exists.", + "args.addon.init.single_file": "Make a single-file addon instead of a package one.", + "args.addon.show": "Show an addon details.", + + "abort": "=> Abort", + "continue_using_main_dir": "Continue using this main directory ({})? (y/N) ", + + "cmd.search.pending": "Searching for version '{}'...", + "cmd.search.pending_local": "Searching for local version '{}'...", + "cmd.search.pending_all": "Searching for all versions...", + "cmd.search.result": "=> {:10s} {:16s} {:24s} {}", + "cmd.search.result.more.local": "[LOCAL]", + "cmd.search.not_found": "=> No version found", + + "cmd.logout.pending": "Logging out from {}...", + "cmd.logout.success": "=> Logged out.", + "cmd.logout.unknown_session": "=> This session is not cached.", + + "cmd.addon.list.title": "Addons list ({}):", + "cmd.addon.list.result": "=> {}, version: {}, authors: {}", + "cmd.addon.init.already_exits": "An addon '{}' already exists at '{}'.", + "cmd.addon.init.done": "The addon '{}' was initialized at '{}'.", + "cmd.addon.show.unknown": "No addon named '{}' exists.", + "cmd.addon.show.title": "Addon {} ({}):", + "cmd.addon.show.version": "=> Version: {}", + "cmd.addon.show.authors": "=> Authors: {}", + "cmd.addon.show.description": "=> Description: {}", + "cmd.addon.show.requires": "=> Requires: {}", + + "url_error.reason": "URL error: {}", + + "download.progress": "\rDownloading {}... {:6.2f}% {}/s {}", + "download.of_total": "{:6.2f}% of total", + "download.invalid_size": " => Invalid size", + "download.invalid_sha1": " => Invalid SHA1", + + "auth.pending": "Authenticating {}...", + "auth.already_cached": "=> Session already cached, validating...", + "auth.refreshing": "=> Session failed to valid, refreshing...", + "auth.refreshed": "=> Session refreshed.", + "auth.error": "=> {}", + "auth.validated": "=> Session validated.", + "auth.caching": "=> Caching your session...", + "auth.enter_your_password": "=> Enter {} password: ", + "auth.logged_in": "=> Logged in", + + "version.resolving": "Resolving version {}", + "version.found_cached": "=> Found cached metadata, loading...", + "version.loaded": "=> Version loaded.", + "version.failed_to_decode_cached": "=> Failed to decode cached metadata, try updating...", + "version.found_in_manifest": "=> Found metadata in manifest, caching...", + "version.not_found_in_manifest": "=> Not found in manifest.", + "version.parent_version": "=> Parent version: {}", + "version.parent_version_not_found": "=> Failed to find parent version {}", + + "start.welcome": "Welcome to PortableMC, the easy to use Python Minecraft Launcher.", + "start.loading_version": "Loading {} {}...", + "start.loading_jar_file": "Loading jar file...", + "start.no_client_jar_file": "=> Can't find client download in version meta", + "start.loading_assets": "Loading assets...", + "start.failed_to_decode_asset_index": "=> Failed to decode assets index, try updating...", + "start.found_asset_index": "=> Found asset index in metadata: {}", + "start.legacy_assets": "=> This version use lagacy assets, put in {}", + "start.virtual_assets": "=> This version use virtual assets, put in {}", + "start.verifying_assets": "=> Verifying assets...", + "start.loading_logger": "Loading logger config...", + "start.generating_better_logging_config": "=> Generating better logging configuration...", + "start.loading_libraries": "Loading libraries and natives...", + "start.no_download_for_library": "=> Can't found any download for library {}", + "start.cached_library_not_found": "=> Can't found cached library {} at {}", + "start.dry": "Dry run, stopping.", + "start.starting": "Starting game...", + "start.extracting_natives": "=> Extracting natives...", + "start.running": "Running...", + "start.stopped": "Game stopped, clearing natives.", + "start.run.session": "=> Username: {}, UUID: {}", + "start.run.command_line": "=> Command line: {}" + + } + + def start(self, in_args): + + self._register_addons() + + parser = self.register_arguments() + args = parser.parse_args(in_args) + subcommand = args.subcommand + + if subcommand is None: + parser.print_help() + return + + main_dir_exists = self.init_main_dir(args.main_dir) + if "ignore_main_dir" not in args or not args.ignore_main_dir: + if not main_dir_exists: + if self.prompt("continue_using_main_dir", self._main_dir) != "y": + self.print("abort") + exit(0) + self.make_main_dir() + + exit(self.start_subcommand(subcommand, args)) + + def start_subcommand(self, subcommand: str, args: Namespace) -> int: + builtin_func_name = "cmd_{}".format(subcommand) + if hasattr(self, builtin_func_name) and callable(getattr(self, builtin_func_name)): + return getattr(self, builtin_func_name)(args) + else: + return 0 + + # Addons management + + def _prepare_addons(self, create_dir: bool): + if not path.isdir(self._addons_dir): + if not create_dir: + return + os.mkdir(self._addons_dir) + addons_init = path.join(self._addons_dir, "__init__.py") + if not path.isfile(addons_init): + with open(addons_init, "wt") as fp: + fp.write(ADDONS_PKG_INIT_CONTENT) + + def _register_addons(self): + import importlib + self._prepare_addons(False) + for addon_name in os.listdir(self._addons_dir): + if not addon_name.endswith(".dis") and addon_name not in ("__init__.py", "__pycache__"): + if addon_name.endswith(".py"): + addon_name = addon_name[:-3] + else: + addon_path = path.join(self._addons_dir, addon_name) + if path.isfile(addon_path) or not path.isfile(path.join(addon_path, "__init__.py")): + # If entry was not terminated by ".py" and is a file OR + # /__init__.py doesn't exists (maybe not a directory). + continue + if addon_name in self._addons: + self.print("addon.defined_twice", addon_name) + continue + module = importlib.import_module(f"{ADDONS_DIR}.{addon_name}") + if PortableAddon.is_valid(module): + self._addons[addon_name] = PortableAddon(module, addon_name) + for addon in self._addons.values(): + addon.build(self) + for addon in self._addons.values(): + addon.load() + + # Arguments + + def register_arguments(self) -> ArgumentParser: + + parser = ArgumentParser( + allow_abbrev=False, + prog="portablemc", + description=self.get_message("args") + ) + + # Main directory is placed here in order to know the path of the auth database. + parser.add_argument("--main-dir", help=self.get_message("args.main_dir"), dest="main_dir") + + self.register_subcommands(parser.add_subparsers(title="subcommands", dest="subcommand", )) + + return parser + + @staticmethod + def new_help_formatter(max_help_position: int): + class CustomHelpFormatter(HelpFormatter): + def __init__(self, prog): + super().__init__(prog, max_help_position=max_help_position) + + return CustomHelpFormatter + + def register_subcommands(self, subcommands): + self.register_search_arguments(subcommands.add_parser("search", help=self.get_message("args.search"))) + self.register_start_arguments(subcommands.add_parser("start", help=self.get_message("args.start"))) + self.register_login_arguments(subcommands.add_parser("login", help=self.get_message("args.login"))) + self.register_logout_arguments(subcommands.add_parser("logout", help=self.get_message("args.logout"))) + self.register_addon_arguments(subcommands.add_parser("addon", help=self.get_message("args.addon"))) + + def register_search_arguments(self, parser: ArgumentParser): + parser.add_argument("-l", "--local", default=False, action="store_true") + parser.add_argument("input", nargs="?") + parser.set_defaults(ignore_main_dir=True) + + def register_start_arguments(self, parser: ArgumentParser): + parser.formatter_class = self.new_help_formatter(32) + parser.add_argument("--dry", help=self.get_message("args.start.dry"), default=False, action="store_true") + parser.add_argument("--disable-mp", help=self.get_message("args.start.disable_multiplayer"), default=False, action="store_true") + parser.add_argument("--disable-chat", help=self.get_message("args.start.disable_chat"), default=False, action="store_true") + parser.add_argument("--demo", help=self.get_message("args.start.demo"), default=False, action="store_true") + parser.add_argument("--resol", help=self.get_message("args.start.resol"), type=self._decode_resolution, dest="resolution") + parser.add_argument("--jvm", help=self.get_message("args.start.jvm"), default=JVM_EXEC_DEFAULT) + parser.add_argument("--jvm-args", help=self.get_message("args.start.jvm_args"), default=None, dest="jvm_args") + parser.add_argument("--work-dir", help=self.get_message("args.start.work_dir"), dest="work_dir") + parser.add_argument("--work-dir-bin", help=self.get_message("args.start.work_dir_bin"), default=False, action="store_true", dest="work_dir_bin") + parser.add_argument("--no-better-logging", help=self.get_message("args.start.no_better_logging"), default=False, action="store_true", dest="no_better_logging") + parser.add_argument("-t", "--temp-login", help=self.get_message("args.start.temp_login"), default=False, action="store_true", dest="templogin") + parser.add_argument("-l", "--login", help=self.get_message("args.start.login")) + parser.add_argument("-u", "--username", help=self.get_message("args.start.username"), metavar="NAME") + parser.add_argument("-i", "--uuid", help=self.get_message("args.start.uuid")) + parser.add_argument("-s", "--server", help=self.get_message("args.start.server")) + parser.add_argument("-p", "--server-port", type=int, help=self.get_message("args.start.server_port"), metavar="PORT") + parser.add_argument("version", nargs="?", default="release") + + def register_login_arguments(self, parser: ArgumentParser): + parser.add_argument("email_or_username") + + def register_logout_arguments(self, parser: ArgumentParser): + parser.add_argument("email_or_username") + + def register_addon_arguments(self, parser: ArgumentParser): + + parser.set_defaults(ignore_main_dir=True) + + subparsers = parser.add_subparsers(title="subcommands", dest="addon_subcommand", required=True) + subparsers.add_parser("list", help=self.get_message("args.addon.list")) + + init_parser = subparsers.add_parser("init", help=self.get_message("args.addon.init")) + init_parser.add_argument("--single-file", help=self.get_message("args.addon.init.single_file"), default=False, action="store_true", dest="single_file") + init_parser.add_argument("addon_name") + + show_parser = subparsers.add_parser("show", help=self.get_message("args.addon.show")) + show_parser.add_argument("addon_name") + + # Builtin subcommands + + def cmd_search(self, args: Namespace) -> int: + + if args.input is None: + self.print("cmd.search.pending_all") + else: + self.print("cmd.search.pending_local" if args.local else "cmd.search.pending", args.input) + + found = False + for version_type, version_id, version_date, is_local in self.core_search(args.input, local=args.local): + found = True + self.print("cmd.search.result", + version_type, + version_id, + self.format_iso_date(version_date), + self.get_message("cmd.search.result.more.local") if is_local else "") + + if not found: + self.print("cmd.search.not_found") + return EXIT_VERSION_SEARCH_NOT_FOUND + else: + return 0 + + def cmd_login(self, args: Namespace) -> int: + entry = self.promp_password_and_authenticate(args.email_or_username, True) + return EXIT_AUTHENTICATION_FAILED if entry is None else 0 + + def cmd_logout(self, args: Namespace) -> int: + email_or_username = args.email_or_username + self.print("cmd.logout.pending", email_or_username) + auth_db = self.get_auth_database() + auth_db.load() + entry = auth_db.get_entry(email_or_username) + if entry is not None: + entry.invalidate() + auth_db.remove_entry(email_or_username) + auth_db.save() + self.print("cmd.logout.success") + return 0 + else: + self.print("cmd.logout.unknown_session") + return EXIT_LOGOUT_FAILED + + def cmd_addon(self, args: Namespace) -> int: + subcommand = args.addon_subcommand + if subcommand == "list": + self.print("cmd.addon.list.title", len(self._addons)) + for addon in self._addons.values(): + self.print("cmd.addon.list.result", addon.name, addon.version, ", ".join(addon.authors)) + elif subcommand == "init": + self._prepare_addons(True) + addon_file = path.join(self._addons_dir, args.addon_name) + for check_file in (addon_file, f"{addon_file}.py"): + if path.exists(check_file): + self.print("cmd.addon.init.already_exits", args.addon_name, check_file) + return 0 + if args.single_file: + addon_file = f"{addon_file}.py" + else: + os.mkdir(addon_file) + addon_file = path.join(addon_file, "__init__.py") + with open(addon_file, "wt") as fp: + fp.write(ADDONS_TPL_INIT_CONTENT.format(name=args.addon_name)) + self.print("cmd.addon.init.done", args.addon_name, addon_file) + elif subcommand == "show": + addon_name = args.addon_name + addon = self._addons.get(addon_name) + if addon is None: + self.print("cmd.addon.show.unknown") + else: + self.print("cmd.addon.show.title", addon.name, addon_name) + self.print("cmd.addon.show.version", addon.version) + self.print("cmd.addon.show.authors", ", ".join(addon.authors)) + if len(addon.description): + self.print("cmd.addon.show.description", addon.description) + if len(addon.requires): + self.print("cmd.addon.show.requires", ", ".join(addon.requires)) + return 0 + + def cmd_start(self, args: Namespace) -> int: + + # Get all arguments + work_dir = self._main_dir if args.main_dir is None else path.realpath(args.main_dir) + # uuid = None if args.uuid is None else args.uuid.replace("-", "") + # username = args.username + + # Login if needed + if args.login is not None: + auth = self.promp_password_and_authenticate(args.login, not args.templogin) + if auth is None: + return EXIT_AUTHENTICATION_FAILED + # uuid = auth.uuid + # username = auth.username + else: + auth = None + + # Setup defaut UUID and/or username if needed + # if uuid is None: uuid = uuid4().hex + # if username is None: username = uuid[:8] + + # Decode resolution + custom_resol = args.resolution # type: Optional[Tuple[int, int]] + if custom_resol is not None and len(custom_resol) != 2: + custom_resol = None + + # def args_modifier(raw_args: List[str], main_class_idx: int): + # raw_args[main_class_idx:main_class_idx] = args.jvm_args.split(" ") + + def runner(proc_args: list, proc_cwd: str, options: dict): + options["cmd_args"] = args + self.game_runner(proc_args, proc_cwd, options) + + jvm_args = JVM_ARGS_DEFAULT if args.jvm_args is None else args.jvm_args.split(" ") + + # Actual start + try: + self.game_start( + work_dir=work_dir, + version=args.version, + uuid=args.uuid, + username=args.username, + auth=auth, + jvm=(args.jvm, *jvm_args), + cmd_args=args, + dry_run=args.dry, + no_better_logging=args.no_better_logging, + work_dir_bin=args.work_dir_bin, + resolution=custom_resol, + demo=args.demo, + disable_multiplayer=args.disable_mp, + disable_chat=args.disable_chat, + server_addr=args.server, + server_port=args.server_port, + # args_modifier=args_modifier, + runner=runner + ) + except VersionNotFoundError: + return EXIT_VERSION_NOT_FOUND + except URLError as err: + self.print("url_error.reason", err.reason) + return EXIT_URL_ERROR + except DownloadCorruptedError as err: + self.print("download.{}".format(err.args[0])) + return EXIT_DOWNLOAD_FILE_CORRUPTED + + # Messages + + def get_messages(self) -> Dict[str, str]: + return self._messages + + def add_message(self, key: str, value: str): + self._messages[key] = value + + def print(self, message_key: str, *args, traceback: bool = False, end: str = "\n"): + print(self.get_message(message_key, *args), end=end) + if traceback: + import traceback + traceback.print_exc() + + def prompt(self, message_key: str, *args, password: bool = False) -> str: + print(self.get_message(message_key, *args), end="", flush=True) + if password: + import getpass + return getpass.getpass("") + else: + return input("") + + def get_message(self, message_key: str, *args) -> str: + if not len(message_key): + return args[0] + msg = self._messages.get(message_key, message_key) + try: + return msg.format(*args) + except IndexError: + return msg + + def notice(self, key: str, *args): + self.print(key, *args) + + # Addons + + def get_addons(self) -> 'Dict[str, PortableAddon]': + return self._addons + + def get_addon(self, name: str) -> 'Optional[PortableAddon]': + return self._addons.get(name) + + # Start mixin + + def game_start(self, *, cmd_args: Namespace, **kwargs) -> None: + # Define this method to accept "cmd_args" + super().core_start(**kwargs) + + def game_runner(self, proc_args: list, proc_cwd: str, options: dict): + self.print("", "====================================================") + self.print("start.run.session", options["username"], options["uuid"]) + self.print("start.run.command_line", " ".join(proc_args)) + subprocess.run(proc_args, cwd=proc_cwd) + self.print("", "====================================================") + + # Authentication + + def promp_password_and_authenticate(self, email_or_username: str, cache_in_db: bool) -> 'Optional[AuthEntry]': + + self.print("auth.pending", email_or_username) + + auth_db = self.get_auth_database() + auth_db.load() + + auth_entry = auth_db.get_entry(email_or_username) + if auth_entry is not None: + self.print("auth.already_cached") + if not auth_entry.validate(): + self.print("auth.refreshing") + try: + auth_entry.refresh() + auth_db.save() + self.print("auth.refreshed") + return auth_entry + except AuthError as auth_err: + self.print("auth.error", auth_err) + else: + self.print("auth.validated") + return auth_entry + + try: + password = self.prompt("auth.enter_your_password", email_or_username, password=True) + auth_entry = AuthEntry.authenticate(email_or_username, password) + if cache_in_db: + self.print("auth.caching") + auth_db.add_entry(email_or_username, auth_entry) + auth_db.save() + self.print("auth.logged_in") + return auth_entry + except AuthError as auth_err: + self.print("auth.error", auth_err) + return None + + # Downloading + + download_file_base = CorePortableMC.download_file + + def download_file(self, + entry: 'DownloadEntry', *, + start_size: int = 0, + total_size: int = 0, + **kwargs) -> int: # kwargs may contains a 'progress_callback', but we ignore kwargs + + start_time = time.perf_counter() + + def progress_callback(p_dl_size: int, p_size: int, p_dl_total_size: int, p_total_size: int): + nonlocal start_time + of_total = self.get_message("download.of_total", p_dl_total_size / p_total_size * 100) if p_total_size != 0 else "" + speed = self.format_bytes(p_dl_size / (time.perf_counter() - start_time)) + self.print("download.progress", entry.name, p_dl_size / p_size * 100, speed, of_total, end="") + + res = super().download_file(entry, start_size=start_size, total_size=total_size, progress_callback=progress_callback) + self.print("", "") + return res + + # Miscellenaous utilities + + @staticmethod + def _decode_resolution(raw: str): + return tuple(int(size) for size in raw.split("x")) + + @staticmethod + def format_iso_date(raw: Union[str, float]) -> str: + if isinstance(raw, float): + return datetime.fromtimestamp(raw).strftime("%c") + else: + return datetime.strptime(str(raw).rsplit("+", 2)[0], "%Y-%m-%dT%H:%M:%S").strftime("%c") + + @staticmethod + def format_bytes(n: float) -> str: + if n < 1000: + return "{:4.0f}B".format(int(n)) + elif n < 1000000: + return "{:4.0f}kB".format(n // 1000) + elif n < 1000000000: + return "{:4.0f}MB".format(n // 1000000) + else: + return "{:4.0f}GB".format(n // 1000000000) + + + class PortableAddon: + + def __init__(self, module: Any, name: str): + + if not self.is_valid(module): + raise ValueError("Missing 'addon_build' method.") + + self.module = module + self.name = str(module.NAME) if hasattr(module, "NAME") else name + self.version = str(module.VERSION) if hasattr(module, "VERSION") else "unknown" + self.authors = module.AUTHORS if hasattr(module, "AUTHORS") else tuple() + self.requires = module.REQUIRES if hasattr(module, "REQUIRES") else tuple() + self.description = str(module.DESCRIPTION) if hasattr(module, "DESCRIPTION") else "" + + if not isinstance(self.authors, tuple): + self.authors = (str(self.authors),) + + if not isinstance(self.requires, tuple): + self.requires = (str(self.requires),) + + self.built = False + self.instance: Optional[Any] = None + + @staticmethod + def is_valid(module: Any) -> bool: + return hasattr(module, "addon_build") and callable(module.addon_build) + + def build(self, pmc: PortableMC): + + from importlib import import_module + + for requirement in self.requires: + if requirement.startswith("addon:"): + requirement = requirement[6:] + if pmc.get_addon(requirement) is None: + pmc.print("addon.missing_requirement.ext", self.name, requirement) + else: + try: + import_module(requirement) + except ModuleNotFoundError: + pmc.print("addon.missing_requirement.module", self.name, requirement) + return False + + try: + self.instance = self.module.addon_build(pmc) + self.built = True + except (Exception,): + pmc.print("addon.failed_to_build", self.name, traceback=True) + + def load(self): + if self.built and hasattr(self.instance, "load") and callable(self.instance.load): + self.instance.load() + + PortableMC().start(sys.argv[1:]) diff --git a/portablemc_core.py b/portablemc_core.py new file mode 100644 index 00000000..14fc564a --- /dev/null +++ b/portablemc_core.py @@ -0,0 +1,901 @@ +#!/usr/bin/env python +# encoding: utf8 + +# This file is not intended to be modified manually, it was generated by 'gen_core.py'. +# You must develop the 'portablemc.py' script and then start 'gen_core.py' to regenerate this lib. + +from sys import exit +import sys + + +if sys.version_info[0] < 3 or sys.version_info[1] < 6: + print("PortableMC cannot be used with Python version prior to 3.6.x") + exit(1) + + +from typing import cast, Dict, Callable, Optional, Generator, Tuple, List, Iterable, Union +from urllib import request as url_request +from json.decoder import JSONDecodeError +from urllib.error import HTTPError +from zipfile import ZipFile +from uuid import uuid4 +from os import path +import subprocess +import platform +import hashlib +import atexit +import shutil +import json +import re +import os + + +LAUNCHER_NAME = "portablemc" +LAUNCHER_VERSION = "1.1.0" +LAUNCHER_AUTHORS = "Théo Rozier" + +VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json" +ASSET_BASE_URL = "https://resources.download.minecraft.net/{}/{}" +AUTHSERVER_URL = "https://authserver.mojang.com/{}" + +LOGGING_CONSOLE_REPLACEMENT = "" + +JVM_EXEC_DEFAULT = "java" +JVM_ARGS_DEFAULT = "-Xmx2G",\ + "-XX:+UnlockExperimentalVMOptions",\ + "-XX:+UseG1GC",\ + "-XX:G1NewSizePercent=20",\ + "-XX:G1ReservePercent=20",\ + "-XX:MaxGCPauseMillis=50",\ + "-XX:G1HeapRegionSize=32M" + + +# This file is split between the Core which is the lib and the CLI launcher which extends the Core. +# Check at the end of this file (in the __main__ check) for the CLI launcher. +# Addons only apply to the CLI, the core lib may be extracted and published as a python lib in the future. + + +class CorePortableMC: + + def __init__(self): + + self._main_dir: Optional[str] = None + + self._mc_os = self.get_minecraft_os() + self._mc_arch = self.get_minecraft_arch() + self._mc_archbits = self.get_minecraft_archbits() + + self._version_manifest: Optional[VersionManifest] = None + self._auth_database: Optional[AuthDatabase] = None + self._download_buffer: Optional[bytearray] = None + + # Generic methods + + def init_main_dir(self, main_dir: Optional[str]) -> bool: + self._main_dir = self.get_minecraft_dir() if main_dir is None else path.realpath(main_dir) + return path.isdir(self._main_dir) + + def make_main_dir(self): + os.makedirs(self._main_dir, 0o777, True) + + def check_main_dir(self): + if self._main_dir is None or not path.isdir(self._main_dir): + raise ValueError("Before executing this function, please use 'init_main_dir' to set the main " + "directory path (use None to select the default .minecraft). Also make sure " + "the directory is created (using 'make_main_dir' if needed).") + + def core_search(self, search: Optional[str], *, local: bool = False) -> list: + + no_version = (search is None) + versions_dir = path.join(self._main_dir, "versions") + # versions = [] + + if local: + if path.isdir(versions_dir): + for version_id in os.listdir(versions_dir): + if no_version or search in version_id: + version_jar_file = path.join(versions_dir, version_id, f"{version_id}.jar") + if path.isfile(version_jar_file): + yield "unknown", version_id, path.getmtime(version_jar_file), False + """versions.append(( + {"type": "unknown", "id": version_id, "releaseTime": path.getmtime(version_jar_file)}, False + ))""" + else: + manifest = self.get_version_manifest() + for version_data in manifest.all_versions() if no_version else manifest.search_versions(search): + version_id = version_data["id"] + version_jar_file = path.join(versions_dir, version_id, f"{version_id}.jar") + yield version_data["type"], version_data["id"], version_data["releaseTime"], path.isfile(version_jar_file) + # versions.append((version_data, path.isfile(version_jar_file))) + + # return versions + + def core_start(self, *, + version: str, + jvm: Optional[Union[str, Iterable[str]]] = None, # Default to (JVM_EXEC_DEFAULT, *JVM_ARGS_DEFAULT) + work_dir: Optional[str] = None, # Default to main dir + uuid: Optional[str] = None, # Default to random UUID + username: Optional[str] = None, # Default to uuid[:8] + auth: 'Optional[AuthEntry]' = None, # This parameter will override uuid/username + dry_run: bool = False, + no_better_logging: bool = False, + work_dir_bin: bool = False, + resolution: 'Optional[Tuple[int, int]]' = None, + demo: bool = False, + disable_multiplayer: bool = False, + disable_chat: bool = False, + server_addr: Optional[str] = None, + server_port: Optional[int] = None, + version_meta_modifier: 'Optional[Callable[[dict], None]]' = None, + libraries_modifier: 'Optional[Callable[[List[str], List[str]], None]]' = None, + args_modifier: 'Optional[Callable[[List[str], int], None]]' = None, + args_replacement_modifier: 'Optional[Callable[[Dict[str, str]], None]]' = None, + runner: 'Optional[Callable[[list, str, dict], None]]' = None) -> None: + + # This method can raise these errors: + # - VersionNotFoundError: if the given version was not found + # - URLError: for any URL resolving error + # - DownloadCorruptedError: if a download is corrupted + + self.notice("start.welcome") + + self.check_main_dir() + if work_dir is None: + work_dir = self._main_dir + + # Resolve version metadata + version, version_alias = self.get_version_manifest().filter_latest(version) + version_meta, version_dir = self.resolve_version_meta_recursive(version) + + # Starting version dependencies resolving + version_type = version_meta["type"] + self.notice("start.loading_version", version_type, version) + + if callable(version_meta_modifier): + version_meta_modifier(version_meta) + + # JAR file loading + self.notice("start.loading_jar_file") + version_jar_file = path.join(version_dir, "{}.jar".format(version)) + if not path.isfile(version_jar_file): + version_downloads = version_meta["downloads"] + if "client" not in version_downloads: + self.notice("start.no_client_jar_file") + raise VersionNotFoundError() + download_entry = DownloadEntry.from_version_meta_info(version_downloads["client"], version_jar_file, name="{}.jar".format(version)) + self.download_file(download_entry) + + # Assets loading + self.notice("start.loading_assets") + assets_dir = path.join(self._main_dir, "assets") + assets_indexes_dir = path.join(assets_dir, "indexes") + assets_index_version = version_meta["assets"] + assets_index_file = path.join(assets_indexes_dir, "{}.json".format(assets_index_version)) + assets_index = None + + if path.isfile(assets_index_file): + with open(assets_index_file, "rb") as assets_index_fp: + try: + assets_index = json.load(assets_index_fp) + except JSONDecodeError: + self.notice("start.failed_to_decode_asset_index") + + if assets_index is None: + asset_index_info = version_meta["assetIndex"] + asset_index_url = asset_index_info["url"] + self.notice("start.found_asset_index", asset_index_url) + assets_index = self.read_url_json(asset_index_url) + if not path.isdir(assets_indexes_dir): + os.makedirs(assets_indexes_dir, 0o777, True) + with open(assets_index_file, "wt") as assets_index_fp: + json.dump(assets_index, assets_index_fp) + + assets_objects_dir = path.join(assets_dir, "objects") + assets_total_size = version_meta["assetIndex"]["totalSize"] + assets_current_size = 0 + assets_virtual_dir = path.join(assets_dir, "virtual", assets_index_version) + assets_mapped_to_resources = assets_index.get("map_to_resources", False) # For version <= 13w23b + assets_virtual = assets_index.get("virtual", False) # For 13w23b < version <= 13w48b (1.7.2) + + if assets_mapped_to_resources: + self.notice("start.legacy_assets", path.join(work_dir, "resources")) + if assets_virtual: + self.notice("start.virtual_assets", assets_virtual_dir) + + self.notice("start.verifying_assets") + for asset_id, asset_obj in assets_index["objects"].items(): + + asset_hash = asset_obj["hash"] + asset_hash_prefix = asset_hash[:2] + asset_size = asset_obj["size"] + asset_hash_dir = path.join(assets_objects_dir, asset_hash_prefix) + asset_file = path.join(asset_hash_dir, asset_hash) + + if not path.isfile(asset_file) or path.getsize(asset_file) != asset_size: + os.makedirs(asset_hash_dir, 0o777, True) + asset_url = ASSET_BASE_URL.format(asset_hash_prefix, asset_hash) + download_entry = DownloadEntry(asset_url, asset_size, asset_hash, asset_file, name=asset_id) + self.download_file(download_entry, + start_size=assets_current_size, + total_size=assets_total_size) + else: + assets_current_size += asset_size + + if assets_mapped_to_resources: + resources_asset_file = path.join(work_dir, "resources", asset_id) + if not path.isfile(resources_asset_file): + os.makedirs(path.dirname(resources_asset_file), 0o777, True) + shutil.copyfile(asset_file, resources_asset_file) + + if assets_virtual: + virtual_asset_file = path.join(assets_virtual_dir, asset_id) + if not path.isfile(virtual_asset_file): + os.makedirs(path.dirname(virtual_asset_file), 0o777, True) + shutil.copyfile(asset_file, virtual_asset_file) + + # Logging configuration + self.notice("start.loading_logger") + logging_arg = None + if "logging" in version_meta: + version_logging = version_meta["logging"] + if "client" in version_logging: + log_config_dir = path.join(assets_dir, "log_configs") + os.makedirs(log_config_dir, 0o777, True) + client_logging = version_logging["client"] + logging_file_info = client_logging["file"] + logging_file = path.join(log_config_dir, logging_file_info["id"]) + logging_dirty = False + download_entry = DownloadEntry.from_version_meta_info(logging_file_info, logging_file, + name=logging_file_info["id"]) + if not path.isfile(logging_file) or path.getsize(logging_file) != download_entry.size: + self.download_file(download_entry) + logging_dirty = True + if not no_better_logging: + better_logging_file = path.join(log_config_dir, "portablemc-{}".format(logging_file_info["id"])) + if logging_dirty or not path.isfile(better_logging_file): + self.notice("start.generating_better_logging_config") + with open(logging_file, "rt") as logging_fp: + with open(better_logging_file, "wt") as custom_logging_fp: + raw = logging_fp.read() \ + .replace("", LOGGING_CONSOLE_REPLACEMENT) \ + .replace("", LOGGING_CONSOLE_REPLACEMENT) + custom_logging_fp.write(raw) + logging_file = better_logging_file + logging_arg = client_logging["argument"].replace("${path}", logging_file) + + # Libraries and natives loading + self.notice("start.loading_libraries") + libraries_dir = path.join(self._main_dir, "libraries") + classpath_libs = [version_jar_file] + native_libs = [] + + for lib_obj in version_meta["libraries"]: + + if "rules" in lib_obj: + if not self.interpret_rule(lib_obj["rules"]): + continue + + lib_name = lib_obj["name"] # type: str + lib_type = None # type: Optional[str] + + if "downloads" in lib_obj: + + lib_dl = lib_obj["downloads"] + lib_dl_info = None + + if "natives" in lib_obj and "classifiers" in lib_dl: + lib_natives = lib_obj["natives"] + if self._mc_os in lib_natives: + lib_native_classifier = lib_natives[self._mc_os] + if self._mc_archbits is not None: + lib_native_classifier = lib_native_classifier.replace("${arch}", self._mc_archbits) + lib_name += ":{}".format(lib_native_classifier) + lib_dl_info = lib_dl["classifiers"][lib_native_classifier] + lib_type = "native" + elif "artifact" in lib_dl: + lib_dl_info = lib_dl["artifact"] + lib_type = "classpath" + + if lib_dl_info is None: + self.notice("start.no_download_for_library", lib_name) + continue + + lib_path = path.join(libraries_dir, lib_dl_info["path"]) + lib_dir = path.dirname(lib_path) + + os.makedirs(lib_dir, 0o777, True) + download_entry = DownloadEntry.from_version_meta_info(lib_dl_info, lib_path, name=lib_name) + + if not path.isfile(lib_path) or path.getsize(lib_path) != download_entry.size: + self.download_file(download_entry) + + else: + + # If no 'downloads' trying to parse the maven dependency string ":: + # to directory path. This may be used by custom configuration that do not provide download + # links like Optifine. + + lib_name_parts = lib_name.split(":") + lib_path = path.join(libraries_dir, *lib_name_parts[0].split("."), lib_name_parts[1], + lib_name_parts[2], "{}-{}.jar".format(lib_name_parts[1], lib_name_parts[2])) + lib_type = "classpath" + + if not path.isfile(lib_path): + self.notice("start.cached_library_not_found", lib_name, lib_path) + continue + + if lib_type == "classpath": + classpath_libs.append(lib_path) + elif lib_type == "native": + native_libs.append(lib_path) + + if callable(libraries_modifier): + libraries_modifier(classpath_libs, native_libs) + + # Don't run if dry run + if dry_run: + self.notice("start.dry") + return + + # Start game + self.notice("start.starting") + + # Extracting binaries + bin_dir = path.join(work_dir if work_dir_bin else self._main_dir, "bin", str(uuid4())) + + @atexit.register + def _bin_dir_cleanup(): + if path.isdir(bin_dir): + shutil.rmtree(bin_dir) + + self.notice("start.extracting_natives") + for native_lib in native_libs: + with ZipFile(native_lib, 'r') as native_zip: + for native_zip_info in native_zip.infolist(): + if self.can_extract_native(native_zip_info.filename): + native_zip.extract(native_zip_info, bin_dir) + + features = { + "is_demo_user": demo, + "has_custom_resolution": resolution is not None + } + + legacy_args = version_meta.get("minecraftArguments") + + raw_args = [] + raw_args.extend( + self.interpret_args(version_meta["arguments"]["jvm"] if legacy_args is None else LEGACY_JVM_ARGUMENTS, + features)) + + if logging_arg is not None: + raw_args.append(logging_arg) + + main_class = version_meta["mainClass"] + if main_class == "net.minecraft.launchwrapper.Launch": + # raw_args.append("-Dminecraft.client.jar={}".format(version_jar_file)) + main_class = "net.minecraft.client.Minecraft" + + main_class_idx = len(raw_args) + raw_args.append(main_class) + raw_args.extend(self.interpret_args(version_meta["arguments"]["game"], features) if legacy_args is None else legacy_args.split(" ")) + + if disable_multiplayer: + raw_args.append("--disableMultiplayer") + if disable_chat: + raw_args.append("--disableChat") + + if server_addr is not None: + raw_args.extend(("--server", server_addr)) + if server_port is not None: + raw_args.extend(("--port", str(server_port))) + + if callable(args_modifier): + args_modifier(raw_args, main_class_idx) + + if auth is not None: + uuid = auth.uuid + username = auth.username + else: + uuid = uuid4().hex if uuid is None else uuid.replace("-", "") + username = uuid[:8] if username is None else username[:16] # Max username length is 16 + + # Arguments replacements + start_args_replacements = { + # Game + "auth_player_name": username, + "version_name": version, + "game_directory": work_dir, + "assets_root": assets_dir, + "assets_index_name": assets_index_version, + "auth_uuid": uuid, + "auth_access_token": "" if auth is None else auth.format_token_argument(False), + "user_type": "mojang", + "version_type": version_type, + # Game (legacy) + "auth_session": "notok" if auth is None else auth.format_token_argument(True), + "game_assets": assets_virtual_dir, + "user_properties": "{}", + # JVM + "natives_directory": bin_dir, + "launcher_name": LAUNCHER_NAME, + "launcher_version": LAUNCHER_VERSION, + "classpath": self.get_classpath_separator().join(classpath_libs) + } + + if resolution is not None: + start_args_replacements["resolution_width"] = str(resolution[0]) + start_args_replacements["resolution_height"] = str(resolution[1]) + + if callable(args_replacement_modifier): + args_replacement_modifier(start_args_replacements) + + if jvm is None: + jvm = (JVM_EXEC_DEFAULT, *JVM_ARGS_DEFAULT) + elif isinstance(jvm, str): + jvm = (jvm, *JVM_ARGS_DEFAULT) + + start_args = [*jvm] + for arg in raw_args: + for repl_id, repl_val in start_args_replacements.items(): + arg = arg.replace("${{{}}}".format(repl_id), repl_val) + start_args.append(arg) + + self.notice("start.running") + os.makedirs(work_dir, 0o777, True) + + if runner is None: + subprocess.run(start_args, cwd=work_dir) + else: + runner(start_args, work_dir, { + "version": version, + "username": username, + "uuid": uuid + }) + + self.notice("start.stopped") + + # Lazy variables getters + + def get_main_dir(self) -> str: + return self._main_dir + + def get_version_manifest(self) -> 'VersionManifest': + if self._version_manifest is None: + self._version_manifest = VersionManifest.load_from_url() + return self._version_manifest + + def get_auth_database(self) -> 'AuthDatabase': + if self._auth_database is None: + self._auth_database = AuthDatabase(path.join(self._main_dir, "portablemc_tokens")) + return self._auth_database + + def get_download_buffer(self) -> bytearray: + if self._download_buffer is None: + self._download_buffer = bytearray(32768) + return self._download_buffer + + # Public methods to be replaced by addons + + def notice(self, key: str, *args): + pass + + def mixin(self, target: str, func, owner: Optional[object] = None): + if owner is None: + owner = self + old_func = getattr(owner, target, None) + def wrapper(*args, **kwargs): + return func(old_func, *args, **kwargs) + setattr(owner, target, wrapper) + + # General utilities + + def download_file(self, + entry: 'DownloadEntry', *, + start_size: int = 0, + total_size: int = 0, + progress_callback: Optional[Callable[[int, int, int, int], None]] = None) -> int: + + with url_request.urlopen(entry.url) as req: + with open(entry.dst, "wb") as dst_fp: + + dl_sha1 = hashlib.sha1() + dl_size = 0 + + buffer = self.get_download_buffer() + + while True: + + read_len = req.readinto(buffer) + if not read_len: + break + + buffer_view = buffer[:read_len] + dl_size += read_len + dl_sha1.update(buffer_view) + dst_fp.write(buffer_view) + + if total_size != 0: + start_size += read_len + + if progress_callback is not None: + progress_callback(dl_size, entry.size, start_size, total_size) + + if dl_size != entry.size: + raise DownloadCorruptedError("invalid_size") + elif dl_sha1.hexdigest() != entry.sha1: + raise DownloadCorruptedError("invalid_sha1") + else: + return start_size + + # Version metadata + + def resolve_version_meta(self, name: str) -> Tuple[dict, str]: + + version_dir = path.join(self._main_dir, "versions", name) + version_meta_file = path.join(version_dir, "{}.json".format(name)) + content = None + + self.notice("version.resolving", name) + + if path.isfile(version_meta_file): + self.notice("version.found_cached") + with open(version_meta_file, "rb") as version_meta_fp: + try: + content = json.load(version_meta_fp) + self.notice("version.loaded") + except JSONDecodeError: + self.notice("version.failed_to_decode_cached") + + if content is None: + version_data = self.get_version_manifest().get_version(name) + if version_data is not None: + version_url = version_data["url"] + self.notice("version.found_in_manifest") + content = self.read_url_json(version_url) + os.makedirs(version_dir, 0o777, True) + with open(version_meta_file, "wt") as version_meta_fp: + json.dump(content, version_meta_fp, indent=2) + else: + self.notice("version.not_found_in_manifest") + raise VersionNotFoundError(name) + + return content, version_dir + + def resolve_version_meta_recursive(self, name: str) -> Tuple[dict, str]: + version_meta, version_dir = self.resolve_version_meta(name) + while "inheritsFrom" in version_meta: + self.notice("version.parent_version", version_meta["inheritsFrom"]) + parent_meta, _ = self.resolve_version_meta(version_meta["inheritsFrom"]) + if parent_meta is None: + self.notice("version.parent_version_not_found", version_meta["inheritsFrom"]) + raise VersionNotFoundError(version_meta["inheritsFrom"]) + del version_meta["inheritsFrom"] + self.dict_merge(parent_meta, version_meta) + version_meta = parent_meta + return version_meta, version_dir + + # Version meta rules interpretation + + def interpret_rule(self, rules: list, features: Optional[dict] = None) -> bool: + allowed = False + for rule in rules: + if "os" in rule: + ros = rule["os"] + if "name" in ros and ros["name"] != self._mc_os: + continue + elif "arch" in ros and ros["arch"] != self._mc_arch: + continue + elif "version" in ros and re.compile(ros["version"]).search(platform.version()) is None: + continue + if "features" in rule: + feature_valid = True + for feat_name, feat_value in rule["features"].items(): + if feat_name not in features or feat_value != features[feat_name]: + feature_valid = False + break + if not feature_valid: + continue + act = rule["action"] + if act == "allow": + allowed = True + elif act == "disallow": + allowed = False + return allowed + + def interpret_args(self, args: list, features: dict) -> list: + ret = [] + for arg in args: + if isinstance(arg, str): + ret.append(arg) + else: + if "rules" in arg: + if not self.interpret_rule(arg["rules"], features): + continue + arg_value = arg["value"] + if isinstance(arg_value, list): + ret.extend(arg_value) + elif isinstance(arg_value, str): + ret.append(arg_value) + return ret + + # Static utilities + + @staticmethod + def get_minecraft_dir() -> str: + pf = sys.platform + home = path.expanduser("~") + if pf.startswith("freebsd") or pf.startswith("linux") or pf.startswith("aix") or pf.startswith("cygwin"): + return path.join(home, ".minecraft") + elif pf == "win32": + return path.join(home, "AppData", "Roaming", ".minecraft") + elif pf == "darwin": + return path.join(home, "Library", "Application Support", "minecraft") + + @staticmethod + def get_minecraft_os() -> str: + pf = sys.platform + if pf.startswith("freebsd") or pf.startswith("linux") or pf.startswith("aix") or pf.startswith("cygwin"): + return "linux" + elif pf == "win32": + return "windows" + elif pf == "darwin": + return "osx" + + @staticmethod + def get_minecraft_arch() -> str: + machine = platform.machine().lower() + return "x86" if machine == "i386" else "x86_64" if machine in ("x86_64", "amd64") else "unknown" + + @staticmethod + def get_minecraft_archbits() -> Optional[str]: + raw_bits = platform.architecture()[0] + return "64" if raw_bits == "64bit" else "32" if raw_bits == "32bit" else None + + @staticmethod + def get_classpath_separator() -> str: + return ";" if sys.platform == "win32" else ":" + + @staticmethod + def read_url_json(url: str) -> dict: + return json.load(url_request.urlopen(url)) + + @classmethod + def dict_merge(cls, dst: dict, other: dict): + for k, v in other.items(): + if k in dst: + if isinstance(dst[k], dict) and isinstance(other[k], dict): + cls.dict_merge(dst[k], other[k]) + continue + elif isinstance(dst[k], list) and isinstance(other[k], list): + dst[k].extend(other[k]) + continue + dst[k] = other[k] + + @staticmethod + def can_extract_native(filename: str) -> bool: + return not filename.startswith("META-INF") and not filename.endswith(".git") and not filename.endswith(".sha1") + + +class VersionManifest: + + def __init__(self, data: dict): + self._data = data + + @classmethod + def load_from_url(cls): + return cls(CorePortableMC.read_url_json(VERSION_MANIFEST_URL)) + + def filter_latest(self, version: str) -> Tuple[Optional[str], bool]: + return (self._data["latest"][version], True) if version in self._data["latest"] else (version, False) + + def get_version(self, version: str) -> Optional[dict]: + version, _alias = self.filter_latest(version) + for version_data in self._data["versions"]: + if version_data["id"] == version: + return version_data + return None + + def all_versions(self) -> list: + return self._data["versions"] + + def search_versions(self, inp: str) -> Generator[dict, None, None]: + inp, alias = self.filter_latest(inp) + for version_data in self._data["versions"]: + if (alias and version_data["id"] == inp) or (not alias and inp in version_data["id"]): + yield version_data + + +class AuthEntry: + + def __init__(self, client_token: str, username: str, uuid: str, access_token: str): + self.client_token = client_token + self.username = username + self.uuid = uuid # No dashes + self.access_token = access_token + + def format_token_argument(self, legacy: bool) -> str: + if legacy: + return "token:{}:{}".format(self.access_token, self.uuid) + else: + return self.access_token + + def validate(self) -> bool: + return self.auth_request("validate", { + "accessToken": self.access_token, + "clientToken": self.client_token + }, False)[0] == 204 + + def refresh(self): + + _, res = self.auth_request("refresh", { + "accessToken": self.access_token, + "clientToken": self.client_token + }) + + self.access_token = res["accessToken"] + + def invalidate(self): + self.auth_request("invalidate", { + "accessToken": self.access_token, + "clientToken": self.client_token + }, False) + + @classmethod + def authenticate(cls, email_or_username: str, password: str) -> 'AuthEntry': + + _, res = cls.auth_request("authenticate", { + "agent": { + "name": "Minecraft", + "version": 1 + }, + "username": email_or_username, + "password": password, + "clientToken": uuid4().hex + }) + + return AuthEntry( + res["clientToken"], + res["selectedProfile"]["name"], + res["selectedProfile"]["id"], + res["accessToken"] + ) + + @staticmethod + def auth_request(req: str, payload: dict, error: bool = True) -> (int, dict): + + from http.client import HTTPResponse + from urllib.request import Request + + req_url = AUTHSERVER_URL.format(req) + data = json.dumps(payload).encode("ascii") + req = Request(req_url, data, headers={ + "Content-Type": "application/json", + "Content-Length": len(data) + }, method="POST") + + try: + res = url_request.urlopen(req) # type: HTTPResponse + except HTTPError as err: + res = cast(HTTPResponse, err.fp) + + try: + res_data = json.load(res) + except JSONDecodeError: + res_data = {} + + if error and res.status != 200: + raise AuthError(res_data["errorMessage"]) + + return res.status, res_data + + +class AuthDatabase: + + def __init__(self, filename: str): + self._filename = filename + self._entries = {} # type: Dict[str, AuthEntry] + + def load(self): + self._entries.clear() + if path.isfile(self._filename): + with open(self._filename, "rt") as fp: + for line in fp.readlines(): + parts = line.split(" ") + if len(parts) == 5: + self._entries[parts[0]] = AuthEntry( + parts[1], + parts[2], + parts[3], + parts[4] + ) + + def save(self): + with open(self._filename, "wt") as fp: + fp.writelines(("{} {} {} {} {}".format( + email_or_username, + entry.client_token, + entry.username, + entry.uuid, + entry.access_token + ) for email_or_username, entry in self._entries.items())) + + def get_entry(self, email_or_username: str) -> Optional[AuthEntry]: + return self._entries.get(email_or_username, None) + + def add_entry(self, email_or_username: str, entry: AuthEntry): + self._entries[email_or_username] = entry + + def remove_entry(self, email_or_username: str): + if email_or_username in self._entries: + del self._entries[email_or_username] + + +class DownloadEntry: + + __slots__ = "url", "size", "sha1", "dst", "name" + + def __init__(self, url: str, size: int, sha1: str, dst: str, *, name: Optional[str] = None): + self.url = url + self.size = size + self.sha1 = sha1 + self.dst = dst + self.name = url if name is None else name + + @classmethod + def from_version_meta_info(cls, info: dict, dst: str, *, name: Optional[str] = None) -> 'DownloadEntry': + return DownloadEntry(info["url"], info["size"], info["sha1"], dst, name=name) + + +class AuthError(Exception): ... +class VersionNotFoundError(Exception): ... +class DownloadCorruptedError(Exception): ... + + +LEGACY_JVM_ARGUMENTS = [ + { + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ], + "value": [ + "-XstartOnFirstThread" + ] + }, + { + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ], + "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump" + }, + { + "rules": [ + { + "action": "allow", + "os": { + "name": "windows", + "version": "^10\\." + } + } + ], + "value": [ + "-Dos.name=Windows 10", + "-Dos.version=10.0" + ] + }, + "-Djava.library.path=${natives_directory}", + "-Dminecraft.launcher.brand=${launcher_name}", + "-Dminecraft.launcher.version=${launcher_version}", + "-cp", + "${classpath}" +] + +