Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ jobs:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
include:
- os: Ubuntu
image: ubuntu-22.04
image: ubuntu-latest
- os: Windows
image: windows-2022
image: windows-latest
- os: macOS
image: macos-12
image: macos-13
name: ${{ matrix.os }} / ${{ matrix.python-version }} ${{ matrix.suffix }}
runs-on: ${{ matrix.image }}
steps:
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Portable Minecraft Launcher
A fast, reliable and cross-platform command-line Minecraft launcher and API for developers.
Including fast and easy installation of common mod loaders such as Fabric, Forge, NeoForge and Quilt.
Including fast and easy installation of common mod loaders such as Fabric, Forge, NeoForge, OptiFine and Quilt.
This launcher is compatible with the standard Minecraft directories.

[![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)](https://pypi.org/project/portablemc/)
Expand Down Expand Up @@ -112,12 +112,13 @@ loaders:
[Fabric](https://fabricmc.net/),
[Forge](https://minecraftforge.net/),
[NeoForge](https://neoforged.net/),
[LegacyFabric](https://legacyfabric.net/) and
[LegacyFabric](https://legacyfabric.net/),
[OptiFine](https://optifine.net/home) 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
`neoforge:`, `legacyfabric:`, `optifine:` 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,
- For Fabric, LegacyFabric, Quilt and OptiFine, you can directly specify the vanilla version,
optionally followed by `:<loader_version>`. 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
Expand All @@ -134,11 +135,12 @@ portablemc start release
portablemc start snapshot
# Start 1.20.1
portablemc start 1.20.1
# Start latest Fabric/Quilt/Forge version
# Start latest Fabric/Quilt/Forge/OptiFine version
portablemc start fabric:
portablemc start quilt:
portablemc start forge:
portablemc start neoforge:
portablemc start optifine:
# Start Fabric for 1.20.1
portablemc start fabric:1.20.1
# Start Fabric for 1.20.1 with loader 0.11.2
Expand All @@ -150,6 +152,10 @@ portablemc start forge:1.20.1-recommended
portablemc start forge:1.20-46.0.14
# Start NeoForge for 1.20.1
portablemc start neoforge:1.20.1
# Start recommanded OptiFine for 1.21
portablemc start optifine:1.21
# Start latest recommanded OptiFine :
portablemc start optifine:latest
```

#### Authentication
Expand Down Expand Up @@ -268,6 +274,7 @@ you can instead search for many kinds of versions using the `-k` (`--kind`) argu
- `fabric`, show all available Fabric loader versions.
- `legacyfabric`, show all available LegacyFabric loader versions.
- `quilt`, show all available Quilt loader versions.
- `optifine`, show all available optifine version, with date and the forge loader which is compatible with this version (not used in portablemc)

The search string is optional, if not specified no filter is applied on the table shown.

Expand Down Expand Up @@ -356,7 +363,8 @@ following for their bug reports, suggestions and pull requests to make the launc
better:
[GoodDay360](https://github.com/GoodDay360),
[Ristovski](https://github.com/Ristovski),
[JamiKettunen](https://github.com/JamiKettunen)
[JamiKettunen](https://github.com/JamiKettunen),
[pi-dev500](https://github.com/pi-dev500)
[MisileLaboratory](https://github.com/MisileLab) and
[GooseDeveloper](https://github.com/GooseDeveloper).

Expand Down
17 changes: 15 additions & 2 deletions portablemc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import platform
import base64
import json
import hashlib

from .http import HttpError, http_request

Expand Down Expand Up @@ -87,7 +88,7 @@ class OfflineAuthSession(AuthSession):
db_type = "offline"
user_type = ""

def __init__(self, username: Optional[str], uuid: Optional[str]):
def __init__(self, username: Optional[str], uuid: Optional[str], *, _legacy_uuid: bool = False):
super().__init__()
if uuid is not None and len(uuid) == 32:
# If the UUID is already valid.
Expand All @@ -100,7 +101,19 @@ def __init__(self, username: Optional[str], uuid: Optional[str]):
self.username = self.uuid[:8]
else:
self.username = username[:16]
self.uuid = uuid5(namespace_hash, self.username).hex
if _legacy_uuid:
self.uuid = uuid5(namespace_hash, self.username).hex
else:
self.uuid = self._authlib_uuid(self.username)

@staticmethod
def _authlib_uuid(username: str) -> str:
buffer = f"OfflinePlayer:{username}".encode()
md5_hash = hashlib.md5(buffer, usedforsecurity = False)
md5_bytes = bytearray(md5_hash.digest())
md5_bytes[6] = md5_bytes[6] & 0x0f | 0x30
md5_bytes[8] = md5_bytes[8] & 0x3f | 0x80
return md5_bytes.hex()

def format_token_argument(self, legacy: bool) -> str:
return ""
Expand Down
37 changes: 36 additions & 1 deletion portablemc/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
from portablemc.fabric import FabricVersion, FabricResolveEvent
from portablemc.forge import ForgeVersion, ForgeResolveEvent, ForgePostProcessingEvent, \
ForgePostProcessedEvent, ForgeInstallError, _NeoForgeVersion
from portablemc.optifine import (get_offline_versions as optifine_get_offline_versions,
get_compatible_versions as optifine_get_compatible_versions,
OptifineVersion,
OptifinePatchEvent,
OptifineStartInstallEvent,
OptifineEndInstallEvent)


from typing import cast, Optional, List, Union, Dict, Callable, Any, Tuple

Expand Down Expand Up @@ -301,6 +308,26 @@ def cmd_search_handler(ns: SearchNs, kind: str, table: OutputTable):
if search is None or search in loader.version:
table.add(loader.version, _("search.flags.stable") if loader.stable else "")

elif kind == "optifine":
table.add(_("search.loader_version"), _("search.release_date"), _("search.flags"), _("search.optifine.forgecompatibility"))
if not ns.work_dir:
ns.work_dir = ns.context.work_dir

try:
v_list = optifine_get_compatible_versions(ns.work_dir)
except VersionNotFoundError:
v_list = optifine_get_offline_versions(ns.work_dir / "versions")
did_lines = True

for k in v_list.keys():
if did_lines and any((search is None or search in loader.mc_version or search in loader.edition) for loader in v_list[k]):
table.separator()
did_lines = False

for loader in v_list[k]:
if search is None or search in loader.mc_version or search in loader.edition:
did_lines = True
table.add(f"{loader.mc_version}-OptiFine_{loader.edition}", loader.date, _("search.flags.stable") if not loader.preview else "preview", loader.forge if loader.forge else "")
else:
raise ValueError()

Expand Down Expand Up @@ -497,7 +524,10 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers
constructor = ForgeVersion if kind == "forge" else _NeoForgeVersion
prefix = ns.forge_prefix if kind == "forge" else ns.neoforge_prefix
return constructor(version, context=ns.context, prefix=prefix)


elif kind == "optifine":
return OptifineVersion(":".join(parts) if len(parts) > 0 else "recommended", context = ns.context)

else:
return None

Expand Down Expand Up @@ -855,6 +885,11 @@ def forge_resolve(e: ForgeResolveEvent) -> None:
DownloadStartEvent: self.download_start,
DownloadProgressEvent: self.download_progress,
DownloadCompleteEvent: self.download_complete,
# Optifine install logic is particular: it needs to be done after the download of the client and installer jar
# There is no particular event for the fetching of the version, because the entire json is generated after patch
OptifineStartInstallEvent: lambda e: progress_task("start.optifine.install"),
OptifineEndInstallEvent: lambda e: finish_task("start.optifine.installed", version=e.version),
OptifinePatchEvent: lambda e: progress_task("start.optifine.patching", progress=f"{e.done}/{e.total}"),
})

self.ns = ns
Expand Down
12 changes: 10 additions & 2 deletions portablemc/cli/lang.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, LegacyFabric, Forge, NeoForge, Quilt and Babric. This launcher is\n"
" compatible with the standard Minecraft directories.\n\n",
" as Fabric, LegacyFabric, Forge, NeoForge, Quilt, Babric and Optifine. \n"
" This launcher is compatible 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 "
Expand Down Expand Up @@ -75,6 +75,7 @@ def get(key: str, **kwargs) -> str:
"args.search.kind.comp.legacyfabric": "Search for LegacyFabric versions.",
"args.search.kind.comp.quilt": "Search for Quilt versions.",
"args.search.kind.comp.babric": "Search for Babric versions.",
"args.search.kind.comp.optifine": "Search for Optifine versions.",
"args.search.input": "Search input.",
"args.search.input.comp.release": "Resolve version of the latest release.",
"args.search.input.comp.snapshot": "Resolve version of the latest snapshot.",
Expand All @@ -88,6 +89,7 @@ def get(key: str, **kwargs) -> str:
"args.start.version.babric": "babric::[<loader-version>]",
"args.start.version.forge": "forge:[<forge-version>] (forge-version >= 1.5.2)",
"args.start.version.neoforge": "neoforge:[<neoforge-version>] (neoforge-version >= 1.20.1)",
"args.start.version.optifine": "optifine:[<vanilla-version>[:<optifine-version>]]",
"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.",
Expand All @@ -96,6 +98,7 @@ def get(key: str, **kwargs) -> str:
"args.start.version.comp.babric": "Start Babric mod loader with beta 1.7.3.",
"args.start.version.comp.forge": "Start Forge mod loader with latest release.",
"args.start.version.comp.neoforge": "Start NeoForge mod loader with latest release.",
"args.start.version.comp.optifine": "Start Optifine mod loader with latest recommended 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).",
Expand Down Expand Up @@ -189,6 +192,7 @@ def get(key: str, **kwargs) -> str:
"search.flags.local": "local",
"search.flags.stable": "stable",
"search.loader_version": "Loader version",
"search.optifine.forgecompatibility": "Forge compatibility",
# Command login
"login.tip.remember_start_login": "Remember to start the game with '-l {email}' if you want to be authenticated in-game.",
# Command logout
Expand Down Expand Up @@ -249,6 +253,10 @@ def get(key: str, **kwargs) -> str:
"start.forge.post_processed": "Forge post processing done",
f"start.forge.install_error.{ForgeInstallError.INSTALL_PROFILE_NOT_FOUND}": "Install profile not found in the forge installer.",
f"start.forge.install_error.{ForgeInstallError.VERSION_METADATA_NOT_FOUND}": "Version metadata not found in the forge installer.",
# Command start (optifine)
"start.optifine.install": "Installing OptiFine...",
"start.optifine.patching": "Patching OptiFine library {progress}",
"start.optifine.installed": "OptiFine Library {version} installed",
# Pretty download
"download.threads_count": "Download threads count: {count}",
"download.start": "Download starting...",
Expand Down
4 changes: 2 additions & 2 deletions portablemc/cli/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def register_start_arguments(parser: ArgumentParser) -> None:
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", "babric", "forge", "neoforge")))))
for standard in ("release", "snapshot"):
add_completion(version_arg, standard, _(f"args.start.version.comp.{standard}"))
for loader in ("fabric", "quilt", "legacyfabric", "babric", "forge", "neoforge"):
for loader in ("fabric", "quilt", "legacyfabric", "babric", "forge", "neoforge", "optifine"):
add_completion(version_arg, f"{loader}:", _(f"args.start.version.comp.{loader}"))


Expand Down Expand Up @@ -218,7 +218,7 @@ def get_outputs() -> List[str]:
return ["human-color", "human", "machine"]

def get_search_kinds() -> List[str]:
return ["mojang", "local", "forge", "fabric", "quilt", "legacyfabric", "babric"]
return ["mojang", "local", "forge", "fabric", "quilt", "legacyfabric", "babric", "optifine"]

def get_auth_services() -> List[str]:
return ["microsoft", "yggdrasil"]
Expand Down
4 changes: 2 additions & 2 deletions portablemc/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ def from_entry(cls, entry: DownloadEntry, *, redirect: int = 0) -> "_DownloadEnt
url_parsed.scheme == "https",
url_parsed.netloc,
url_parsed.port,
url_parsed.path,
url_parsed.path + ("" if url_parsed.query == "" else "?" + url_parsed.query),
entry,
redirect=redirect)
redirect = redirect)


class DownloadResult:
Expand Down
Loading