diff --git a/README.md b/README.md index e9aedf9..72788e1 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ This launcher is compatible with the standard Minecraft directories. - [Setup environment](#setup-environment) - [Contributors](#contributors) - [Sponsors](#sponsors) -- [API Documentation (v4.2) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md) +- [API Documentation (v4.3) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md) ## Installation @@ -107,13 +107,19 @@ needed by the version before launching it. If you provide no version, the latest is started, but you can specify a version to launch, or a version alias: `release` or `snapshot` for the latest version of their type. -In addition to Mojang's vanilla versions, the launcher natively supports common mod -loaders such as **Fabric**, **Forge**, **NeoForge** and **Quilt**. To start such versions, -you can prefix the version with either `fabric:`, `forge:`, `neoforge:` or `quilt:` (or -`vanilla:` to explicitly choose a vanilla version). -Depending on the mod loader, the version you put after the colon is different: -- For Fabric and Quilt, you can directly specify the vanilla version, optionally followed - by `:`. +In addition to Mojang's versions, the launcher natively supports common mod +loaders: +[Fabric](https://fabricmc.net/), +[Forge](https://minecraftforge.net/), +[NeoForge](https://neoforged.net/), +[LegacyFabric](https://legacyfabric.net/) and +[Quilt](https://quiltmc.org/). +To start such versions, you can prefix the version with either `fabric:`, `forge:`, +`neoforge:`, `legacyfabric:` or `quilt:` (or `standard:` to explicitly choose a vanilla +version). Depending on the mod loader, the version you put after the colon is different: +- For Fabric, LegacyFabric and Quilt, you can directly specify the vanilla version, + optionally followed by `:`. Note that legacy fabric start 1.13.2 + by default and does not support more recent version as it's not the goal. - For Forge and NeoForge, you can put either a vanilla game version, optionally followed by `-`. Forge also supports `-latest` and `-recommended`, but NeoForge will always take the latest loader. @@ -260,6 +266,7 @@ you can instead search for many kinds of versions using the `-k` (`--kind`) argu - `forge`, show all recommended and latest Forge loader versions *(only 1.5.2 and onward can be started)*. - `fabric`, show all available Fabric loader versions. +- `legacyfabric`, show all available LegacyFabric loader versions. - `quilt`, show all available Quilt loader versions. The search string is optional, if not specified no filter is applied on the table shown. diff --git a/doc/API.md b/doc/API.md index d2e2ad3..0d32076 100644 --- a/doc/API.md +++ b/doc/API.md @@ -3,7 +3,7 @@ This page documents the public API of the launcher. This launcher library provides high flexibility for launching Minecraft in many environments. -Documented version: `4.2`. +Documented version: `4.3`. ## Table of contents - [File structure](#file-structure) diff --git a/portablemc/__init__.py b/portablemc/__init__.py index 84cd447..b1f6883 100644 --- a/portablemc/__init__.py +++ b/portablemc/__init__.py @@ -7,7 +7,7 @@ """ LAUNCHER_NAME = "portablemc" -LAUNCHER_VERSION = "4.2.1" +LAUNCHER_VERSION = "4.3.0" LAUNCHER_AUTHORS = ["Théo Rozier ", "Contributors"] LAUNCHER_COPYRIGHT = "PortableMC Copyright (C) 2021-2024 Théo Rozier" LAUNCHER_URL = "https://github.com/mindstorm38/portablemc" diff --git a/portablemc/auth.py b/portablemc/auth.py index 6bcaae8..8a83360 100644 --- a/portablemc/auth.py +++ b/portablemc/auth.py @@ -288,8 +288,8 @@ def authenticate_base(cls, request_token_payload: dict) -> dict: # Microsoft OAuth try: res = cls.ms_request("https://login.live.com/oauth20_token.srf", request_token_payload, payload_url_encoded=True) - except HttpError: - raise OutdatedTokenError() + except HttpError as error: + raise OutdatedTokenError(error.res.text()) ms_refresh_token = res.get("refresh_token") @@ -332,12 +332,12 @@ def authenticate_base(cls, request_token_payload: dict) -> dict: res = cls.mc_request_profile(mc_access_token) except HttpError as error: if error.res.status == 404: - raise DoesNotOwnMinecraftError() + raise DoesNotOwnMinecraftError(error.res.text()) elif error.res.status == 401: - raise OutdatedTokenError() + raise OutdatedTokenError(error.res.text()) else: res = error.res.json() - raise AuthError(res.get("errorMessage", res.get("error", "Unknown error"))) + raise AuthError(res.get("errorMessage", res.get("error", "unknown error"))) return { "refresh_token": ms_refresh_token, @@ -465,7 +465,9 @@ class AuthError(Exception): pass class DoesNotOwnMinecraftError(AuthError): - pass + def __init__(self, *args) -> None: + super().__init__("does not own minecraft", *args) class OutdatedTokenError(AuthError): - pass + def __init__(self, *args) -> None: + super().__init__("outdated token", *args) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 233481e..8b86018 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -11,6 +11,7 @@ from pathlib import Path import socket import sys +import io from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs, AuthBaseNs, ShowCompletionNs @@ -67,6 +68,13 @@ def main(args: Optional[List[str]] = None): `get_command_handlers` function. """ + # Force stdout/stderr to use UTF-8 encoding, this reconfigure method + # is available for Python 3.7 and onward. + if isinstance(sys.stdout, io.TextIOWrapper): + sys.stdout.reconfigure(encoding='utf-8') + if isinstance(sys.stderr, io.TextIOWrapper): + sys.stderr.reconfigure(encoding='utf-8') + parser = register_arguments() ns: RootNs = cast(RootNs, parser.parse_args(args or sys.argv[1:])) @@ -273,17 +281,23 @@ def cmd_search_handler(ns: SearchNs, kind: str, table: OutputTable): if search is None or search in alias: table.add(alias, version) - elif kind in ("fabric", "quilt"): + elif kind in ("fabric", "quilt", "legacyfabric"): - from ..fabric import FABRIC_API, QUILT_API + from ..fabric import FABRIC_API, QUILT_API, LEGACYFABRIC_API - table.add(_("search.loader_version")) + table.add(_("search.loader_version"), _("search.flags")) table.separator() - api = FABRIC_API if kind == "fabric" else QUILT_API - for version in api.request_fabric_loader_versions(): - if search is None or search in version: - table.add(version) + if kind == "fabric": + api = FABRIC_API + elif kind == "quilt": + api = QUILT_API + else: + api = LEGACYFABRIC_API + + for loader in api._request_loaders(): + if search is None or search in loader.version: + table.add(loader.version, _("search.flags.stable") if loader.stable else "") else: raise ValueError() @@ -292,11 +306,11 @@ def cmd_search_handler(ns: SearchNs, kind: str, table: OutputTable): def cmd_start(ns: StartNs): version_parts = ns.version.split(":") - + # If no split, the kind of version is "standard": parts have at least 2 elements. if len(version_parts) == 1: version_parts = ["standard", version_parts[0]] - + # No handler means that the format is invalid. version = cmd_start_handler(ns, version_parts[0], version_parts[1:]) if version is None: @@ -362,33 +376,28 @@ def filter_libraries(libs: Dict[LibrarySpecifier, Any]) -> None: env = version.install(watcher=StartWatcher(ns)) - if ns.verbose >= 1 and len(env.fixes): - ns.out.task("INFO", "start.fixes") - ns.out.finish() + if ns.verbose >= 1: for fix, fix_value in env.fixes.items(): - ns.out.task(None, f"start.fix.{fix}", value=fix_value) + ns.out.task("INFO", f"start.fix.{fix}", value=fix_value) ns.out.finish() - # If not dry run, run it! - if not ns.dry: - - # Included binaries - if ns.include_bin is not None: - for bin_path in ns.include_bin: - if not bin_path.is_file(): - ns.out.task("FAILED", "start.additional_binary_not_found", path=bin_path) - ns.out.finish() - sys.exit(EXIT_FAILURE) - env.native_libs.append(bin_path) + # Included binaries + if ns.include_bin is not None: + for bin_path in ns.include_bin: + if not bin_path.is_file(): + ns.out.task("FAILED", "start.additional_binary_not_found", path=bin_path) + ns.out.finish() + sys.exit(EXIT_FAILURE) + env.native_libs.append(bin_path) - # Extend JVM arguments with given arguments, or defaults - if ns.jvm_args is None: - env.jvm_args.extend(DEFAULT_JVM_ARGS) - elif len(ns.jvm_args): - env.jvm_args.extend(ns.jvm_args.split()) - - env.run(CliRunner(ns)) - + # Extend JVM arguments with given arguments, or defaults + if ns.jvm_args is None: + env.jvm_args.extend(DEFAULT_JVM_ARGS) + elif len(ns.jvm_args): + env.jvm_args.extend(ns.jvm_args.split()) + + # This CliRunner will abort running if in dry mode. + env.run(CliRunner(ns)) sys.exit(EXIT_OK) except VersionNotFoundError as error: @@ -439,19 +448,39 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers version = parts[0] or "release" ns.socket_error_tips.append("version_manifest") + + if ns.verbose >= 1: + ns.out.task("INFO", "start.global_version", kind=kind, version=version, remaining=" ".join(parts[1:])) + ns.out.finish() if kind == "standard": if len(parts) != 1: return None return Version(version, context=ns.context) - elif kind in ("fabric", "quilt"): + elif kind in ("fabric", "quilt", "legacyfabric"): + if len(parts) > 2: return None - constructor = FabricVersion.with_fabric if kind == "fabric" else FabricVersion.with_quilt - prefix = ns.fabric_prefix if kind == "fabric" else ns.quilt_prefix + + # Legacy fabric has a special case because it will never be supported for + # versions past 1.13.2, it is not made for latest release version. + if kind == "legacyfabric" and version == "release": + version = "1.13.2" + + if kind == "fabric": + constructor = FabricVersion.with_fabric + prefix = ns.fabric_prefix + elif kind == "quilt": + constructor = FabricVersion.with_quilt + prefix = ns.quilt_prefix + else: + constructor = FabricVersion._with_legacyfabric + prefix = ns.legacyfabric_prefix + if len(parts) != 2: ns.socket_error_tips.append(f"{kind}_loader_version") + return constructor(version, parts[1] if len(parts) == 2 else None, context=ns.context, prefix=prefix) elif kind in ("forge", "neoforge"): @@ -770,7 +799,7 @@ def finish_task(key: str, **kwargs) -> None: ns.out.finish() def features(e: FeaturesEvent) -> None: - if ns.verbose >= 1 and len(e.features): + if ns.verbose >= 1: ns.out.task("INFO", "start.features", features=", ".join(e.features)) ns.out.finish() @@ -866,16 +895,21 @@ def download_complete(self, e: DownloadCompleteEvent) -> None: class CliRunner(StreamRunner): - def __init__(self, ns: RootNs) -> None: + def __init__(self, ns: StartNs) -> None: super().__init__() self.ns = ns - def process_create(self, args: List[str], work_dir: Path) -> Popen: + def process_create(self, args: List[str], work_dir: Path) -> Optional[Popen]: - self.ns.out.print("\n") - if self.ns.verbose >= 1: + if not self.ns.dry or self.ns.verbose >= 2: + self.ns.out.print("\n") + + if self.ns.verbose >= 2: self.ns.out.print(" ".join(args) + "\n") + if self.ns.dry: + return None + return super().process_create(args, work_dir) def process_stream_event(self, event: Any) -> None: diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 5e67e72..62668a2 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -37,8 +37,8 @@ def get(key: str, **kwargs) -> str: "args._": " A fast, reliable and cross-platform command-line Minecraft launcher and API\n" " for developers. Including fast and easy installation of common mod loaders such\n" - " as Fabric, Forge, NeoForge and Quilt. This launcher is compatible with the\n" - " standard Minecraft directories.\n\n", + " as Fabric, LegacyFabric, Forge, NeoForge and Quilt. This launcher is compatible\n" + " with the standard Minecraft directories.\n\n", "args.main_dir": "Set the main directory where libraries, assets and versions.", "args.work_dir": "Set the working directory where the game run and place for examples " "saves, screenshots (and resources for legacy versions), it also store " @@ -72,6 +72,7 @@ def get(key: str, **kwargs) -> str: "args.search.kind.comp.local": "Search for locally installed versions.", "args.search.kind.comp.forge": "Search for Forge versions.", "args.search.kind.comp.fabric": "Search for Fabric versions.", + "args.search.kind.comp.legacyfabric": "Search for LegacyFabric versions.", "args.search.kind.comp.quilt": "Search for Quilt versions.", "args.search.input": "Search input.", "args.search.input.comp.release": "Resolve version of the latest release.", @@ -81,12 +82,14 @@ def get(key: str, **kwargs) -> str: "args.start.version": "Version identifier (default to release): {formats}.", "args.start.version.standard": "release|snapshot|", "args.start.version.fabric": "fabric:[[:]]", + "args.start.version.legacyfabric": "legacyfabric:[[:]]", "args.start.version.quilt": "quilt:[[:]]", "args.start.version.forge": "forge:[] (forge-version >= 1.5.2)", "args.start.version.neoforge": "neoforge:[] (neoforge-version >= 1.20.1)", "args.start.version.comp.release": "Start the latest release (default).", "args.start.version.comp.snapshot": "Start the latest snapshot.", "args.start.version.comp.fabric": "Start Fabric mod loader with latest release.", + "args.start.version.comp.legacyfabric": "Start LegacyFabric mod loader with latest release.", "args.start.version.comp.quilt": "Start Quilt mod loader with latest release.", "args.start.version.comp.forge": "Start Forge mod loader with latest release.", "args.start.version.comp.neoforge": "Start NeoForge mod loader with latest release.", @@ -102,6 +105,7 @@ def get(key: str, **kwargs) -> str: "args.start.no_fix": "Flag that globally disable fixes (proxy for old versions), " "enabled by default.", "args.start.fabric_prefix": "Change the prefix of the version ID when starting with Fabric (--).", + "args.start.legacyfabric_prefix": "Change the prefix of the version ID when starting with LegacyFabric (--).", "args.start.quilt_prefix": "Change the prefix of the version ID when starting with Quilt (--).", "args.start.forge_prefix": "Change the prefix of the version ID when starting with Forge (-).", "args.start.neoforge_prefix": "Change the prefix of the version ID when starting with NeoForge (-).", @@ -168,6 +172,7 @@ def get(key: str, **kwargs) -> str: "error.socket": "This operation requires an operational network, but a socket error happened:", "error.socket.tip.version_manifest": "Version manifest may not be locally cached, try to run this command once with an operational network.", "error.socket.tip.fabric_loader_version": "Fabric loader version must be specified if network is not operational.", + "error.socket.tip.legacyfabric_loader_version": "Fabric loader version must be specified if network is not operational.", "error.socket.tip.quilt_loader_version": "Quilt loader version must be specified if network is not operational.", "error.cert": "Certificate verification failed, you can try installing 'certifi' package:", # Command search @@ -177,6 +182,7 @@ def get(key: str, **kwargs) -> str: "search.last_modified": "Last modified", "search.flags": "Flags", "search.flags.local": "local", + "search.flags.stable": "stable", "search.loader_version": "Loader version", # Command login "login.tip.remember_start_login": "Remember to start the game with '-l {email}' if you want to be authenticated in-game.", @@ -186,6 +192,7 @@ def get(key: str, **kwargs) -> str: "logout.success": "Logged out {email}", "logout.unknown_session": "No session for {email}", # Command start + "start.global_version": "Global version: {kind} {version} {remaining}", "start.version.invalid_id": "Invalid version id, expected: {expected}", "start.version.invalid_id_unknown_kind": "Invalid version id, unknown kind: {kind}.", "start.version.loading": "Loading version {version}... ", @@ -194,7 +201,7 @@ def get(key: str, **kwargs) -> str: "start.version.loaded.fetched": "Loaded version {version} (fetched)", "start.version.not_found": "Version {version} not found", "start.version.too_much_parents": "Too much parents while resolving versions.", - "start.features": "Features: {features}", + "start.features": "Features: [{features}]", "start.jar.found": "Checked version jar", "start.jar.not_found": "Version jar not found", "start.assets.resolving": "Checking assets version {index_version}... ", @@ -217,7 +224,6 @@ def get(key: str, **kwargs) -> str: "use --jvm argument to manually set the path to your JVM executable.", f"start.jvm.not_found_error.{JvmNotFoundError.BUILTIN_INVALID_VERSION}": f"The builtin JVM ({jvm_bin_filename}) is not compatible " "with selected game version.", - "start.fixes": "Applied the following fixes:", f"start.fix.{Version.FIX_LEGACY_PROXY}": "Using legacy proxy for online resources: {value}", f"start.fix.{Version.FIX_LEGACY_MERGE_SORT}": "Using legacy merge sort: {value}", f"start.fix.{Version.FIX_LEGACY_RESOLUTION}": "Included resolution into game arguments: {value}", diff --git a/portablemc/cli/output.py b/portablemc/cli/output.py index 9e878c4..6936049 100644 --- a/portablemc/cli/output.py +++ b/portablemc/cli/output.py @@ -175,10 +175,10 @@ def print(self, text: str) -> None: break if chosen_color is not None: - print(chosen_color, text, "\033[0m", sep="", end="") + print(chosen_color, text, "\033[0m", sep="", end="", flush=True) return - print(text, end="") + print(text, end="", flush=True) def prompt(self, password: bool = False) -> Optional[str]: try: @@ -247,7 +247,7 @@ def print(self) -> None: print(format_string.format(*format_columns), flush=False) - print("└─{}─┘".format("─┴─".join(columns_lines))) + print("└─{}─┘".format("─┴─".join(columns_lines)), flush=True) class MachineOutput(Output): diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index a301a4d..d4bb83e 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -48,6 +48,7 @@ class StartNs(AuthBaseNs): jvm_args: Optional[str] no_fix: bool fabric_prefix: str + legacyfabric_prefix: str quilt_prefix: str forge_prefix: str neoforge_prefix: str @@ -145,6 +146,7 @@ def register_start_arguments(parser: ArgumentParser) -> None: parser.add_argument("--no-fix", help=_("args.start.no_fix"), action="store_true") parser.add_argument("--fabric-prefix", help=_("args.start.fabric_prefix"), default="fabric", metavar="PREFIX") parser.add_argument("--quilt-prefix", help=_("args.start.quilt_prefix"), default="quilt", metavar="PREFIX") + parser.add_argument("--legacyfabric-prefix", help=_("args.start.legacyfabric_prefix"), default="legacyfabric", metavar="PREFIX") parser.add_argument("--forge-prefix", help=_("args.start.forge_prefix"), default="forge", metavar="PREFIX") parser.add_argument("--neoforge-prefix", help=_("args.start.neoforge_prefix"), default="neoforge", metavar="PREFIX") parser.add_argument("--lwjgl", help=_("args.start.lwjgl")) @@ -159,10 +161,10 @@ def register_start_arguments(parser: ArgumentParser) -> None: parser.add_argument("-s", "--server", help=_("args.start.server"), type=type_host) parser.add_argument("-p", "--server-port", help=_("args.start.server_port"), metavar="PORT") - version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge"))))) + version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "legacyfabric", "forge", "neoforge"))))) for standard in ("release", "snapshot"): add_completion(version_arg, standard, _(f"args.start.version.comp.{standard}")) - for loader in ("fabric", "quilt", "forge", "neoforge"): + for loader in ("fabric", "quilt", "legacyfabric", "forge", "neoforge"): add_completion(version_arg, f"{loader}:", _(f"args.start.version.comp.{loader}")) @@ -214,7 +216,7 @@ def get_outputs() -> List[str]: return ["human-color", "human", "machine"] def get_search_kinds() -> List[str]: - return ["mojang", "local", "forge", "fabric", "quilt"] + return ["mojang", "local", "forge", "fabric", "quilt", "legacyfabric"] def get_auth_services() -> List[str]: return ["microsoft", "yggdrasil"] diff --git a/portablemc/download.py b/portablemc/download.py index 4d00e4f..e6fdd84 100644 --- a/portablemc/download.py +++ b/portablemc/download.py @@ -330,17 +330,18 @@ def _download_thread( pass if res.status == 301 or res.status == 302: - - redirect_url = res.headers["location"] - redirect_entry = DownloadEntry( - redirect_url, - entry.dst, - size=entry.size, - sha1=entry.sha1, - name=entry.name) - - entries_queue.put(_DownloadEntry.from_entry(redirect_entry)) - break # Abort on redirect + # If location header is absent, consider it not found. + redirect_url = res.headers.get("location") + if redirect_url is not None: + redirect_entry = DownloadEntry( + redirect_url, + entry.dst, + size=entry.size, + sha1=entry.sha1, + name=entry.name) + + entries_queue.put(_DownloadEntry.from_entry(redirect_entry)) + break # Abort on redirect # Any other non-200 code is considered not found and we retry... last_error = DownloadResultError.NOT_FOUND diff --git a/portablemc/fabric.py b/portablemc/fabric.py index 01c3627..976de25 100644 --- a/portablemc/fabric.py +++ b/portablemc/fabric.py @@ -7,6 +7,15 @@ from typing import Optional, Any, Iterator +class _FabricApiLoader: + """This class describes a loader returned from the fabric API. (unstable API) + """ + __slots__ = "version", "stable" + def __init__(self, version: str, stable: bool) -> None: + self.version = version + self.stable = stable + + class FabricApi: """This class is internally used to defined two constant for both official Fabric backend API and Quilt API which have the same endpoints. So we use the same logic @@ -16,35 +25,64 @@ class FabricApi: def __init__(self, name: str, api_url: str) -> None: self.name = name self.api_url = api_url - + def request_fabric_meta(self, method: str) -> Any: """Generic HTTP request to the fabric's REST API. """ return http_request("GET", f"{self.api_url}{method}", accept="application/json").json() - def request_fabric_loader_version(self, vanilla_version: str) -> Optional[str]: - loaders = self.request_fabric_meta(f"versions/loader/{vanilla_version}") - return loaders[0].get("loader", {}).get("version") if len(loaders) else None - def request_version_loader_profile(self, vanilla_version: str, loader_version: str) -> dict: + """Return the version profile for the given vanilla version and loader. + """ return self.request_fabric_meta(f"versions/loader/{vanilla_version}/{loader_version}/profile/json") + def _request_loaders(self, vanilla_version: Optional[str] = None) -> Iterator[_FabricApiLoader]: + """Return an iterator of loaders available for the given vanilla version, if no + vanilla version is specified, this returned an iterator of all loaders. + """ + + def map_loader(obj) -> _FabricApiLoader: + return _FabricApiLoader(str(obj.get("version", "")), bool(obj.get("stable", False))) + + if vanilla_version is not None: + loaders = self.request_fabric_meta(f"versions/loader/{vanilla_version}") + return map(lambda obj: map_loader(obj["loader"]), loaders) + else: + return map(map_loader, self.request_fabric_meta("versions/loader")) + + def _request_latest_loader(self, vanilla_version: Optional[str] = None) -> Optional[_FabricApiLoader]: + """Return the latest loader version for the given vanilla version, if no vanilla + version is specified, this return the latest loader. + """ + try: + return next(self._request_loaders(vanilla_version)) + except StopIteration: + return None + + # DEPRECATED: + def request_fabric_loader_versions(self) -> Iterator[str]: - loaders = self.request_fabric_meta("versions/loader") - return map(lambda obj: obj["version"], loaders) + """ deprecated, will be replaced by request_loaders """ + return map(lambda loader: loader.version, self._request_loaders()) + + def request_fabric_loader_version(self, vanilla_version: str) -> Optional[str]: + """ deprecated, will be replaced by request_latest_loader """ + loader = self._request_latest_loader(vanilla_version) + return None if loader is None else loader.version FABRIC_API = FabricApi("fabric", "https://meta.fabricmc.net/v2/") QUILT_API = FabricApi("quilt", "https://meta.quiltmc.org/v3/") +LEGACYFABRIC_API = FabricApi("legacyfabric", "https://meta.legacyfabric.net/v2/") class FabricVersion(Version): - def __init__(self, api: FabricApi, vanilla_version: str, loader_version: Optional[str], + def __init__(self, api: FabricApi, vanilla_version: str, loader_version: Optional[str], prefix: str, *, context: Optional[Context] = None, ) -> None: - + super().__init__("", context=context) # Do not give a root version for now. self.api = api @@ -54,7 +92,7 @@ def __init__(self, api: FabricApi, vanilla_version: str, loader_version: Optiona @classmethod def with_fabric(cls, vanilla_version: str = "release", loader_version: Optional[str] = None, *, - context: Optional[Context] = None, + context: Optional[Context] = None, prefix: str = "fabric" ) -> "FabricVersion": """Construct a root for resolving a Fabric version. @@ -70,29 +108,38 @@ def with_quilt(cls, vanilla_version: str = "release", loader_version: Optional[s """ return cls(QUILT_API, vanilla_version, loader_version, prefix, context=context) - def _resolve_version(self, watcher: Watcher) -> None: + @classmethod + def _with_legacyfabric(cls, vanilla_version: str = "release", loader_version: Optional[str] = None, *, + context: Optional[Context] = None, + prefix="legacyfabric" + ) -> "FabricVersion": + """Construct a root for resolving a LegacyFabric version""" + return cls(LEGACYFABRIC_API, vanilla_version, loader_version, prefix, context=context) + def _resolve_version(self, watcher: Watcher) -> None: + # Vanilla version may be "release" or "snapshot" self.vanilla_version = self.manifest.filter_latest(self.vanilla_version)[0] - + # Resolve loader version if not specified. if self.loader_version is None: watcher.handle(FabricResolveEvent(self.api, self.vanilla_version, None)) try: - self.loader_version = self.api.request_fabric_loader_version(self.vanilla_version) + loader = self.api._request_latest_loader(self.vanilla_version) + self.loader_version = None if loader is None else loader.version except HttpError as error: if error.res.status not in (404, 400): raise self.loader_version = None - + if self.loader_version is None: # Correct error if the error is just a not found. raise VersionNotFoundError(f"{self.prefix}-{self.vanilla_version}-???") watcher.handle(FabricResolveEvent(self.api, self.vanilla_version, self.loader_version)) - + # Finally define the full version id. self.version = f"{self.prefix}-{self.vanilla_version}-{self.loader_version}" @@ -106,9 +153,9 @@ def _fetch_version(self, version: VersionHandle, watcher: Watcher) -> None: if version.id != self.version: return super()._fetch_version(version, watcher) - + assert self.loader_version is not None, "_resolve_fabric_loader(...) missing" - + try: version.metadata = self.api.request_version_loader_profile(self.vanilla_version, self.loader_version) except HttpError as error: @@ -116,7 +163,7 @@ def _fetch_version(self, version: VersionHandle, watcher: Watcher) -> None: raise # Correct error if the error is just a not found. raise VersionNotFoundError(version.id) - + version.metadata["id"] = version.id version.write_metadata_file() diff --git a/portablemc/standard.py b/portablemc/standard.py index f59c55d..bac001f 100644 --- a/portablemc/standard.py +++ b/portablemc/standard.py @@ -1561,17 +1561,20 @@ def run(self, env: Environment) -> None: *replace_list_vars(env.game_args, replacements) ], env.context.work_dir) - self.process_wait(process) + if process is not None: + self.process_wait(process) finally: # Any error while setting up the binary directory cause it to be deleted. shutil.rmtree(bin_dir, ignore_errors=True) - def process_create(self, args: List[str], work_dir: Path) -> Popen: + def process_create(self, args: List[str], work_dir: Path) -> Optional[Popen]: """This function is called when process needs to be created with the given arguments in the given working directory. The default implementation does nothing special but this can be used to create the process with enabled output piping, to later use in `process_wait`. + + None can be returned to abort starting the game. """ return Popen(args, cwd=work_dir) @@ -1591,13 +1594,13 @@ def process_wait(self, process: Popen) -> None: class StreamRunner(StandardRunner): - """A specialized implementation of `RunTask` which allows streaming the game's output - logs. This implementation also provides parsing of log4j XML layouts for logs. This - runner handles KeyboardInterrupt errors and properly kill the game and waits for it - completion. + """A specialized implementation of `StandardRunner` which allows streaming the game's + output logs. This implementation also provides parsing of log4j XML layouts for logs. + This runner handles KeyboardInterrupt errors and properly kill the game and waits for + its completion. """ - def process_create(self, args: List[str], work_dir: Path) -> Popen: + def process_create(self, args: List[str], work_dir: Path) -> Optional[Popen]: return Popen(args, cwd=work_dir, stdout=PIPE, stderr=STDOUT, bufsize=1, universal_newlines=True, encoding="utf-8", errors="replace") def process_wait(self, process: Popen) -> None: @@ -1622,14 +1625,14 @@ def process_stream_thread(self, process: Popen) -> None: stdout = process.stdout assert stdout is not None, "should not be none because it should be piped" - parser = None + parser = StreamParser() for line in iter(stdout.readline, ""): - if parser is None: - if line.lstrip().startswith(""] license = "GPL-3.0-only"