modsmith 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modsmith/__init__.py +36 -0
- modsmith/_support.py +81 -0
- modsmith/errors.py +115 -0
- modsmith/instances.py +524 -0
- modsmith/java.py +294 -0
- modsmith/launcher.py +256 -0
- modsmith/loaders/__init__.py +23 -0
- modsmith/loaders/_base.py +157 -0
- modsmith/loaders/_fabric.py +166 -0
- modsmith/loaders/_forge.py +312 -0
- modsmith/loaders/_install_profile.py +359 -0
- modsmith/loaders/_processors.py +223 -0
- modsmith/minecraft/__init__.py +15 -0
- modsmith/minecraft/_game_dir.py +168 -0
- modsmith/minecraft/_maven.py +75 -0
- modsmith/minecraft/_remote.py +115 -0
- modsmith/net/__init__.py +7 -0
- modsmith/net/_download.py +243 -0
- modsmith/net/_resource.py +118 -0
- modsmith/net/_session.py +241 -0
- modsmith/packs/__init__.py +18 -0
- modsmith/packs/_formats.py +262 -0
- modsmith/packs/_model.py +247 -0
- modsmith/platforms/__init__.py +25 -0
- modsmith/platforms/_base.py +312 -0
- modsmith/platforms/_curseforge.py +390 -0
- modsmith/platforms/_modrinth.py +261 -0
- modsmith/progress.py +78 -0
- modsmith/py.typed +0 -0
- modsmith-0.1.0.dist-info/METADATA +285 -0
- modsmith-0.1.0.dist-info/RECORD +33 -0
- modsmith-0.1.0.dist-info/WHEEL +4 -0
- modsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The loader interface and the registry behind `create` and `parse`."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from ..errors import UnsupportedError
|
|
9
|
+
from ..minecraft import GameDir
|
|
10
|
+
from ..net import Session
|
|
11
|
+
from ..progress import CancelToken, ProgressCallback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Loader(ABC):
|
|
16
|
+
"""A specific mod loader version. Subclass it to support another loader.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
version: The loader version.
|
|
20
|
+
minecraft: The Minecraft version it targets.
|
|
21
|
+
kind: The name it is registered under, such as ``neoforge``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
minecraft: str
|
|
26
|
+
kind: ClassVar[str]
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def version_id(
|
|
30
|
+
self, *, session: Session | None = None, cache_dir: Path | None = None
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Return the id this loader installs under in the ``versions`` directory.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
session: The session to use. A temporary one is created if omitted.
|
|
36
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The version id.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def install(
|
|
44
|
+
self,
|
|
45
|
+
game: GameDir,
|
|
46
|
+
*,
|
|
47
|
+
java: Path | None = None,
|
|
48
|
+
cache_dir: Path | None = None,
|
|
49
|
+
session: Session | None = None,
|
|
50
|
+
progress: ProgressCallback | None = None,
|
|
51
|
+
cancel: CancelToken | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Install into ``game`` if needed and return the version id.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
game: The game directory.
|
|
57
|
+
java: The Java binary to use, if the loader needs one.
|
|
58
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
59
|
+
session: The session to use. A temporary one is created if omitted.
|
|
60
|
+
progress: Receives updates for each stage.
|
|
61
|
+
cancel: Stops the install.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The installed version id.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def expected_id(self) -> str | None:
|
|
68
|
+
"""Return the version id this loader should have, without any network use.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The version id, or None if it cannot be known in advance. That is
|
|
72
|
+
the default, and `version_id` is used instead.
|
|
73
|
+
"""
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def is_installed(
|
|
77
|
+
self,
|
|
78
|
+
game: GameDir,
|
|
79
|
+
*,
|
|
80
|
+
session: Session | None = None,
|
|
81
|
+
cache_dir: Path | None = None,
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Return whether this loader is already installed in ``game``.
|
|
84
|
+
|
|
85
|
+
The built-in loaders answer this from local files, with no network use.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
game: The game directory.
|
|
89
|
+
session: The session to use, if the loader has to ask a service.
|
|
90
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if its version JSON exists.
|
|
94
|
+
"""
|
|
95
|
+
expected = self.expected_id()
|
|
96
|
+
if expected is not None:
|
|
97
|
+
return game.has_version(expected)
|
|
98
|
+
return game.has_version(self.version_id(session=session, cache_dir=cache_dir))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_registry: dict[str, type[Loader]] = {}
|
|
102
|
+
_aliases = {"fabric-loader": "fabric", "quilt-loader": "quilt"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register(cls: type[Loader]) -> type[Loader]:
|
|
106
|
+
"""Make a `Loader` subclass available to `create` and `parse`.
|
|
107
|
+
|
|
108
|
+
It can be used as a class decorator.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
cls: The subclass.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The same subclass.
|
|
115
|
+
"""
|
|
116
|
+
_registry[cls.kind] = cls
|
|
117
|
+
return cls
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def create(kind: str, version: str, minecraft: str) -> Loader:
|
|
121
|
+
"""Build a loader from a name such as ``neoforge`` or ``fabric-loader``.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
kind: The loader's name, in any case.
|
|
125
|
+
version: The loader version. A leading ``<minecraft>-`` is removed.
|
|
126
|
+
minecraft: The Minecraft version.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The loader.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
UnsupportedError: No loader is registered under that name.
|
|
133
|
+
"""
|
|
134
|
+
name = kind.lower()
|
|
135
|
+
cls = _registry.get(_aliases.get(name, name))
|
|
136
|
+
if cls is None:
|
|
137
|
+
raise UnsupportedError(f"Unsupported mod loader: {kind}")
|
|
138
|
+
return cls(version.removeprefix(f"{minecraft}-"), minecraft)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse(loader_id: str, minecraft: str) -> Loader:
|
|
142
|
+
"""Build a loader from an id such as ``forge-47.3.0``.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
loader_id: The loader's name and version, joined by a hyphen.
|
|
146
|
+
minecraft: The Minecraft version.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The loader.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
UnsupportedError: The id is malformed or the loader is unknown.
|
|
153
|
+
"""
|
|
154
|
+
kind, _, version = loader_id.partition("-")
|
|
155
|
+
if not version:
|
|
156
|
+
raise UnsupportedError(f"Unsupported mod loader: {loader_id}")
|
|
157
|
+
return create(kind, version, minecraft)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Loaders installed from a metadata service: Fabric and Quilt."""
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from ..minecraft import GameDir, install_version, maven_path
|
|
9
|
+
from ..net import Resource, Session, download
|
|
10
|
+
from ..net._session import session_scope
|
|
11
|
+
from ..progress import CancelToken, ProgressCallback
|
|
12
|
+
from ._base import Loader, register
|
|
13
|
+
|
|
14
|
+
HASH_FIELDS = ("sha512", "sha256", "sha1", "md5")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class MetaLoader(Loader):
|
|
19
|
+
"""A loader described by a version JSON from a metadata service.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
meta_url: The service's base URL.
|
|
23
|
+
default_maven: The repository for libraries that do not name one.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
meta_url: str = ""
|
|
27
|
+
default_maven: ClassVar[str]
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def profile_url(self) -> str:
|
|
31
|
+
"""Return where this loader's version JSON is served."""
|
|
32
|
+
|
|
33
|
+
def expected_id(self) -> str:
|
|
34
|
+
"""Return the version id that the metadata service gives this loader."""
|
|
35
|
+
return f"{self.kind}-loader-{self.version}-{self.minecraft}"
|
|
36
|
+
|
|
37
|
+
def profile(self, session: Session | None = None) -> dict[str, Any]:
|
|
38
|
+
"""Fetch this loader's version JSON.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
session: The session to use. A temporary one is created if omitted.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The decoded JSON.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
NetworkError: The service failed or does not know this version.
|
|
48
|
+
"""
|
|
49
|
+
with session_scope(session) as client:
|
|
50
|
+
return client.get_json(self.profile_url())
|
|
51
|
+
|
|
52
|
+
def version_id(
|
|
53
|
+
self, *, session: Session | None = None, cache_dir: Path | None = None
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Return the id from the metadata service.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
session: The session to use. A temporary one is created if omitted.
|
|
59
|
+
cache_dir: Not used by this loader.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The version id.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
NetworkError: The service failed or does not know this version.
|
|
66
|
+
"""
|
|
67
|
+
return self.profile(session)["id"]
|
|
68
|
+
|
|
69
|
+
def _resource(self, library: dict[str, Any], game: GameDir) -> Resource:
|
|
70
|
+
"""Describe where to download one of the profile's libraries."""
|
|
71
|
+
path = maven_path(library["name"])
|
|
72
|
+
base: str = library.get("url") or self.default_maven
|
|
73
|
+
hashes = {name: library[name] for name in HASH_FIELDS if library.get(name)}
|
|
74
|
+
return Resource.of(
|
|
75
|
+
f"{base.rstrip('/')}/{path}",
|
|
76
|
+
game.libraries_dir / path,
|
|
77
|
+
hashes,
|
|
78
|
+
library.get("size"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def install(
|
|
82
|
+
self,
|
|
83
|
+
game: GameDir,
|
|
84
|
+
*,
|
|
85
|
+
java: Path | None = None,
|
|
86
|
+
cache_dir: Path | None = None,
|
|
87
|
+
session: Session | None = None,
|
|
88
|
+
progress: ProgressCallback | None = None,
|
|
89
|
+
cancel: CancelToken | None = None,
|
|
90
|
+
) -> str:
|
|
91
|
+
"""Install vanilla Minecraft, the libraries and the version JSON.
|
|
92
|
+
|
|
93
|
+
This does nothing if the version is already installed.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
game: The game directory.
|
|
97
|
+
java: The Java binary to use. Not used by this loader.
|
|
98
|
+
cache_dir: The cache directory. Not used by this loader.
|
|
99
|
+
session: The session to use. A temporary one is created if omitted.
|
|
100
|
+
progress: Receives updates for each stage.
|
|
101
|
+
cancel: Stops the install.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The installed version id.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
NetworkError: The metadata service could not be reached.
|
|
108
|
+
UnsupportedError: Mojang does not publish this Minecraft version.
|
|
109
|
+
DownloadError: A file could not be downloaded.
|
|
110
|
+
Cancelled: ``cancel`` was triggered.
|
|
111
|
+
"""
|
|
112
|
+
if game.has_version(self.expected_id()):
|
|
113
|
+
return self.expected_id()
|
|
114
|
+
with session_scope(session) as client:
|
|
115
|
+
profile = self.profile(client)
|
|
116
|
+
version_id: str = profile["id"]
|
|
117
|
+
if game.has_version(version_id):
|
|
118
|
+
return version_id
|
|
119
|
+
install_version(
|
|
120
|
+
game, self.minecraft, session=client, progress=progress, cancel=cancel
|
|
121
|
+
)
|
|
122
|
+
download(
|
|
123
|
+
[self._resource(item, game) for item in profile["libraries"]],
|
|
124
|
+
session=client,
|
|
125
|
+
progress=progress,
|
|
126
|
+
cancel=cancel,
|
|
127
|
+
task="libraries",
|
|
128
|
+
)
|
|
129
|
+
game.write_version(version_id, profile)
|
|
130
|
+
return version_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@register
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Fabric(MetaLoader):
|
|
136
|
+
"""The Fabric loader.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
meta_url: The base URL of Fabric's metadata service.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
kind: ClassVar[str] = "fabric"
|
|
143
|
+
default_maven: ClassVar[str] = "https://maven.fabricmc.net/"
|
|
144
|
+
meta_url: str = "https://meta.fabricmc.net/v2"
|
|
145
|
+
|
|
146
|
+
def profile_url(self) -> str:
|
|
147
|
+
"""Return where this version's JSON is served."""
|
|
148
|
+
return f"{self.meta_url.rstrip('/')}/versions/loader/{self.minecraft}/{self.version}/profile/json"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@register
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class Quilt(MetaLoader):
|
|
154
|
+
"""The Quilt loader.
|
|
155
|
+
|
|
156
|
+
Attributes:
|
|
157
|
+
meta_url: The base URL of Quilt's metadata service.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
kind: ClassVar[str] = "quilt"
|
|
161
|
+
default_maven: ClassVar[str] = "https://maven.quiltmc.org/repository/release/"
|
|
162
|
+
meta_url: str = "https://meta.quiltmc.org/v3"
|
|
163
|
+
|
|
164
|
+
def profile_url(self) -> str:
|
|
165
|
+
"""Return where this version's JSON is served."""
|
|
166
|
+
return f"{self.meta_url.rstrip('/')}/versions/loader/{self.minecraft}/{self.version}/profile/json"
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Loaders installed by running processors: Forge and NeoForge."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tempfile
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from .. import java as java_runtime
|
|
12
|
+
from .._support import default_cache_dir
|
|
13
|
+
from ..errors import JavaError, LoaderError, NetworkError, ProcessorError
|
|
14
|
+
from ..minecraft import GameDir, install_version
|
|
15
|
+
from ..net import Resource, Session, download
|
|
16
|
+
from ..net._session import session_scope
|
|
17
|
+
from ..progress import CancelToken, ProgressCallback
|
|
18
|
+
from ._base import Loader, register
|
|
19
|
+
from ._install_profile import InstallerJar, Library, Variables
|
|
20
|
+
from ._processors import run_processors
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
LEGACY_NEOFORGE = "1.20.1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ProcessorLoader(Loader):
|
|
28
|
+
"""A loader installed by patching Minecraft with processor jars.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
repository: The Maven repository that hosts the installers.
|
|
32
|
+
java_index_url: Where to read Mojang's runtime index.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
repository: str = ""
|
|
36
|
+
java_index_url: str = java_runtime.RUNTIME_INDEX_URL
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def installer_url(self) -> str:
|
|
40
|
+
"""Return where this version's installer jar is published."""
|
|
41
|
+
|
|
42
|
+
def fetch_installer(
|
|
43
|
+
self, *, session: Session | None = None, cache_dir: Path | None = None
|
|
44
|
+
) -> InstallerJar:
|
|
45
|
+
"""Return this version's installer, downloading it if it is not cached.
|
|
46
|
+
|
|
47
|
+
The download is checked against the published SHA-1 when there is one.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
session: The session to use. A temporary one is created if omitted.
|
|
51
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The installer.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
DownloadError: The installer could not be downloaded.
|
|
58
|
+
LoaderError: The installer is malformed.
|
|
59
|
+
UnsupportedError: The installer is older than Minecraft 1.13.
|
|
60
|
+
"""
|
|
61
|
+
url = self.installer_url()
|
|
62
|
+
dest = (cache_dir or default_cache_dir()) / "installers" / url.rsplit("/", 1)[1]
|
|
63
|
+
if not dest.is_file():
|
|
64
|
+
with session_scope(session) as client:
|
|
65
|
+
try:
|
|
66
|
+
body = client.request("GET", f"{url}.sha1").text
|
|
67
|
+
hashes = {"sha1": body.split()[0]}
|
|
68
|
+
except (NetworkError, IndexError):
|
|
69
|
+
hashes = None
|
|
70
|
+
download(Resource.of(url, dest, hashes), session=client)
|
|
71
|
+
return InstallerJar(dest)
|
|
72
|
+
|
|
73
|
+
def version_id(
|
|
74
|
+
self, *, session: Session | None = None, cache_dir: Path | None = None
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Return the id declared by the installer, downloading it if needed.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
session: The session to use. A temporary one is created if omitted.
|
|
80
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The version id.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
DownloadError: The installer could not be downloaded.
|
|
87
|
+
LoaderError: The installer is malformed.
|
|
88
|
+
UnsupportedError: The installer is older than Minecraft 1.13.
|
|
89
|
+
"""
|
|
90
|
+
return self.fetch_installer(session=session, cache_dir=cache_dir).version_id
|
|
91
|
+
|
|
92
|
+
def _resource(self, library: Library, game: GameDir) -> Resource | None:
|
|
93
|
+
"""Describe a library's download, or None if the installer provided it."""
|
|
94
|
+
dest = game.libraries_dir / library.path
|
|
95
|
+
if library.url is None and dest.is_file():
|
|
96
|
+
return None
|
|
97
|
+
url = library.url or f"{self.repository.rstrip('/')}/{library.path}"
|
|
98
|
+
return Resource.of(url, dest, library.hashes, library.size)
|
|
99
|
+
|
|
100
|
+
def install(
|
|
101
|
+
self,
|
|
102
|
+
game: GameDir,
|
|
103
|
+
*,
|
|
104
|
+
java: Path | None = None,
|
|
105
|
+
cache_dir: Path | None = None,
|
|
106
|
+
session: Session | None = None,
|
|
107
|
+
progress: ProgressCallback | None = None,
|
|
108
|
+
cancel: CancelToken | None = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Install vanilla Minecraft, the libraries and the patched game files.
|
|
111
|
+
|
|
112
|
+
The version is registered last, so a failed install leaves no broken version
|
|
113
|
+
behind. This does nothing if the version is already installed.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
game: The game directory.
|
|
117
|
+
java: The Java binary to use. By default, installed runtimes are tried
|
|
118
|
+
first, then runtimes downloaded from Mojang. ``jre-legacy`` is
|
|
119
|
+
the last choice.
|
|
120
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
121
|
+
session: The session to use. A temporary one is created if omitted.
|
|
122
|
+
progress: Receives updates for each stage.
|
|
123
|
+
cancel: Stops the install.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The installed version id.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
LoaderError: The installer is malformed or targets another Minecraft
|
|
130
|
+
version.
|
|
131
|
+
ProcessorError: A processor failed.
|
|
132
|
+
JavaError: No usable Java runtime could be found or downloaded.
|
|
133
|
+
UnsupportedError: The version is not supported.
|
|
134
|
+
NetworkError: Mojang's version manifest could not be fetched.
|
|
135
|
+
DownloadError: A file could not be downloaded.
|
|
136
|
+
Cancelled: ``cancel`` was triggered.
|
|
137
|
+
"""
|
|
138
|
+
expected = self.expected_id()
|
|
139
|
+
if expected is not None and game.has_version(expected):
|
|
140
|
+
return expected
|
|
141
|
+
if cache_dir is not None:
|
|
142
|
+
cache_dir = Path(cache_dir).resolve() # processors run in another directory
|
|
143
|
+
with session_scope(session) as client:
|
|
144
|
+
installer = self.fetch_installer(session=client, cache_dir=cache_dir)
|
|
145
|
+
if game.has_version(installer.version_id):
|
|
146
|
+
return installer.version_id
|
|
147
|
+
if installer.profile.minecraft != self.minecraft:
|
|
148
|
+
raise LoaderError(
|
|
149
|
+
f"Installer targets Minecraft {installer.profile.minecraft}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
install_version(
|
|
153
|
+
game, self.minecraft, session=client, progress=progress, cancel=cancel
|
|
154
|
+
)
|
|
155
|
+
installer.extract_bundled_libraries(game.libraries_dir)
|
|
156
|
+
libraries = [
|
|
157
|
+
*installer.profile.libraries,
|
|
158
|
+
# Entries with no URL are processor outputs, which cannot be downloaded.
|
|
159
|
+
*(item for item in installer.version_libraries if item.url),
|
|
160
|
+
]
|
|
161
|
+
resources = [self._resource(item, game) for item in libraries]
|
|
162
|
+
download(
|
|
163
|
+
[item for item in resources if item],
|
|
164
|
+
session=client,
|
|
165
|
+
progress=progress,
|
|
166
|
+
cancel=cancel,
|
|
167
|
+
task="libraries",
|
|
168
|
+
)
|
|
169
|
+
self._patch(installer, game, java, cache_dir, client, progress, cancel)
|
|
170
|
+
game.write_version(installer.version_id, installer.version_json)
|
|
171
|
+
return installer.version_id
|
|
172
|
+
|
|
173
|
+
def _patch(
|
|
174
|
+
self,
|
|
175
|
+
installer: InstallerJar,
|
|
176
|
+
game: GameDir,
|
|
177
|
+
java: Path | None,
|
|
178
|
+
cache_dir: Path | None,
|
|
179
|
+
session: Session,
|
|
180
|
+
progress: ProgressCallback | None,
|
|
181
|
+
cancel: CancelToken | None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Run the client processors, trying Java runtimes until one works."""
|
|
184
|
+
processors = installer.profile.client_processors()
|
|
185
|
+
if not processors:
|
|
186
|
+
return
|
|
187
|
+
with tempfile.TemporaryDirectory(prefix="modsmith-installer-") as temp:
|
|
188
|
+
variables = Variables.build(
|
|
189
|
+
installer,
|
|
190
|
+
game.libraries_dir,
|
|
191
|
+
game.path,
|
|
192
|
+
game.client_jar(self.minecraft),
|
|
193
|
+
Path(temp),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def attempt(executable: Path) -> None:
|
|
197
|
+
run_processors(
|
|
198
|
+
processors,
|
|
199
|
+
variables,
|
|
200
|
+
executable,
|
|
201
|
+
game.libraries_dir,
|
|
202
|
+
progress,
|
|
203
|
+
cancel,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if java is not None:
|
|
207
|
+
attempt(java)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
failure: ProcessorError | None = None
|
|
211
|
+
runtimes = self._runtimes(game, cache_dir, session, progress, cancel)
|
|
212
|
+
for runtime in runtimes:
|
|
213
|
+
try:
|
|
214
|
+
attempt(runtime.executable)
|
|
215
|
+
return
|
|
216
|
+
except ProcessorError as e:
|
|
217
|
+
failure = e
|
|
218
|
+
logger.warning("Processors failed on %s: %s", runtime.component, e)
|
|
219
|
+
raise failure or JavaError(f"No usable Java runtime for {self.minecraft}")
|
|
220
|
+
|
|
221
|
+
def _runtimes(
|
|
222
|
+
self,
|
|
223
|
+
game: GameDir,
|
|
224
|
+
cache_dir: Path | None,
|
|
225
|
+
session: Session,
|
|
226
|
+
progress: ProgressCallback | None,
|
|
227
|
+
cancel: CancelToken | None,
|
|
228
|
+
) -> Iterator[java_runtime.JavaRuntime]:
|
|
229
|
+
"""Yield Java runtimes to try, installed ones first and downloads after.
|
|
230
|
+
|
|
231
|
+
Processors are install tools, so they do not need the Java that the game
|
|
232
|
+
runs on. ``jre-legacy`` is the last choice, because Mojang's Windows build
|
|
233
|
+
breaks some installs and there is no build for Apple Silicon.
|
|
234
|
+
"""
|
|
235
|
+
required = java_runtime.required_for(game.version(self.minecraft), game)
|
|
236
|
+
legacy = required == java_runtime.LEGACY_COMPONENT
|
|
237
|
+
exact = java_runtime.find(required, game=game, cache_dir=cache_dir)
|
|
238
|
+
installed = java_runtime.find_all(game=game, cache_dir=cache_dir)
|
|
239
|
+
tried: set[str] = set()
|
|
240
|
+
for runtime in [*([] if legacy or exact is None else [exact]), *installed]:
|
|
241
|
+
if runtime.component not in tried:
|
|
242
|
+
tried.add(runtime.component)
|
|
243
|
+
yield runtime
|
|
244
|
+
|
|
245
|
+
fallback = java_runtime.FALLBACK_COMPONENT
|
|
246
|
+
for component in [fallback, required] if legacy else [required, fallback]:
|
|
247
|
+
if component in tried:
|
|
248
|
+
continue
|
|
249
|
+
tried.add(component)
|
|
250
|
+
try:
|
|
251
|
+
runtime = java_runtime.download(
|
|
252
|
+
component,
|
|
253
|
+
cache_dir=cache_dir,
|
|
254
|
+
session=session,
|
|
255
|
+
progress=progress,
|
|
256
|
+
cancel=cancel,
|
|
257
|
+
index_url=self.java_index_url,
|
|
258
|
+
)
|
|
259
|
+
except JavaError as e:
|
|
260
|
+
logger.warning("Cannot use %s: %s", component, e)
|
|
261
|
+
continue
|
|
262
|
+
yield runtime
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@register
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class Forge(ProcessorLoader):
|
|
268
|
+
"""The Forge loader, for Minecraft 1.13 and later.
|
|
269
|
+
|
|
270
|
+
Attributes:
|
|
271
|
+
repository: The Maven repository that hosts the installers.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
kind: ClassVar[str] = "forge"
|
|
275
|
+
repository: str = "https://maven.minecraftforge.net"
|
|
276
|
+
|
|
277
|
+
def expected_id(self) -> str:
|
|
278
|
+
"""Return the version id that this version's installer declares."""
|
|
279
|
+
return f"{self.minecraft}-forge-{self.version}"
|
|
280
|
+
|
|
281
|
+
def installer_url(self) -> str:
|
|
282
|
+
"""Return where this version's installer jar is published."""
|
|
283
|
+
version = f"{self.minecraft}-{self.version}"
|
|
284
|
+
return f"{self.repository.rstrip('/')}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@register
|
|
288
|
+
@dataclass(frozen=True)
|
|
289
|
+
class NeoForge(ProcessorLoader):
|
|
290
|
+
"""The NeoForge loader.
|
|
291
|
+
|
|
292
|
+
Attributes:
|
|
293
|
+
repository: The Maven repository that hosts the installers.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
kind: ClassVar[str] = "neoforge"
|
|
297
|
+
repository: str = "https://maven.neoforged.net/releases"
|
|
298
|
+
|
|
299
|
+
def expected_id(self) -> str:
|
|
300
|
+
"""Return the version id that this version's installer declares."""
|
|
301
|
+
if self.minecraft == LEGACY_NEOFORGE:
|
|
302
|
+
return f"{self.minecraft}-forge-{self.version}"
|
|
303
|
+
return f"neoforge-{self.version}"
|
|
304
|
+
|
|
305
|
+
def installer_url(self) -> str:
|
|
306
|
+
"""Return where this version's installer jar is published."""
|
|
307
|
+
base = self.repository.rstrip("/")
|
|
308
|
+
# NeoForge's first release still used Forge's artifact name.
|
|
309
|
+
if self.minecraft == LEGACY_NEOFORGE:
|
|
310
|
+
version = f"{self.minecraft}-{self.version}"
|
|
311
|
+
return f"{base}/net/neoforged/forge/{version}/forge-{version}-installer.jar"
|
|
312
|
+
return f"{base}/net/neoforged/neoforge/{self.version}/neoforge-{self.version}-installer.jar"
|