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
modsmith/java.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Find Java runtimes from the official launcher, or download Mojang's."""
|
|
2
|
+
|
|
3
|
+
import lzma
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ._support import default_cache_dir
|
|
13
|
+
from .errors import JavaError
|
|
14
|
+
from .minecraft import GameDir, Version
|
|
15
|
+
from .net import Resource, Session, file_hash
|
|
16
|
+
from .net import download as fetch
|
|
17
|
+
from .net._session import session_scope
|
|
18
|
+
from .progress import CancelToken, ProgressCallback
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"JavaRuntime",
|
|
22
|
+
"download",
|
|
23
|
+
"find",
|
|
24
|
+
"find_all",
|
|
25
|
+
"official_runtime_dirs",
|
|
26
|
+
"platform_key",
|
|
27
|
+
"required_for",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
RUNTIME_INDEX_URL = "https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"
|
|
31
|
+
STORE_PACKAGE = "Microsoft.4297127D64EC6_8wekyb3d8bbwe"
|
|
32
|
+
LEGACY_COMPONENT = "jre-legacy"
|
|
33
|
+
# Available on every platform Mojang supports, including Apple Silicon.
|
|
34
|
+
FALLBACK_COMPONENT = "java-runtime-delta"
|
|
35
|
+
# Written last, so an interrupted download is never treated as installed.
|
|
36
|
+
COMPLETE_MARKER = ".modsmith-complete"
|
|
37
|
+
JAVA_BINARIES = ("bin/java.exe", "bin/java", "jre.bundle/Contents/Home/bin/java")
|
|
38
|
+
# The order to try runtimes in when the exact one is not installed.
|
|
39
|
+
PREFERRED_COMPONENTS = (
|
|
40
|
+
"java-runtime-delta",
|
|
41
|
+
"java-runtime-gamma",
|
|
42
|
+
"java-runtime-beta",
|
|
43
|
+
"java-runtime-epsilon",
|
|
44
|
+
"java-runtime-alpha",
|
|
45
|
+
LEGACY_COMPONENT,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class JavaRuntime:
|
|
51
|
+
"""An installed Java runtime.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
component: Mojang's name for the runtime, such as ``java-runtime-delta``.
|
|
55
|
+
executable: The path of the ``java`` binary.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
component: str
|
|
59
|
+
executable: Path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def platform_key() -> str:
|
|
63
|
+
"""Return Mojang's name for this platform, such as ``windows-x64``."""
|
|
64
|
+
machine = platform.machine().lower()
|
|
65
|
+
arm = machine in ("arm64", "aarch64")
|
|
66
|
+
if sys.platform == "win32":
|
|
67
|
+
if arm:
|
|
68
|
+
return "windows-arm64"
|
|
69
|
+
return "windows-x64" if machine in ("amd64", "x86_64") else "windows-x86"
|
|
70
|
+
if sys.platform == "darwin":
|
|
71
|
+
return "mac-os-arm64" if arm else "mac-os"
|
|
72
|
+
return "linux" if machine in ("amd64", "x86_64") else "linux-i386"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def official_runtime_dirs(game: GameDir | None = None) -> list[Path]:
|
|
76
|
+
"""List the directories where the official launcher keeps Java runtimes.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
game: The game directory. Defaults to `GameDir.default`.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The candidate directories. They may not exist.
|
|
83
|
+
"""
|
|
84
|
+
game = game or GameDir.default()
|
|
85
|
+
dirs = [game.path / "runtime"]
|
|
86
|
+
if sys.platform == "win32":
|
|
87
|
+
local = os.getenv("LOCALAPPDATA")
|
|
88
|
+
if local:
|
|
89
|
+
dirs.append(
|
|
90
|
+
Path(local, "Packages", STORE_PACKAGE, "LocalCache", "Local", "runtime")
|
|
91
|
+
)
|
|
92
|
+
program_files = os.getenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)")
|
|
93
|
+
dirs.append(Path(program_files, "Minecraft Launcher", "runtime"))
|
|
94
|
+
return dirs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def required_for(version: Version, game: GameDir | None = None) -> str:
|
|
98
|
+
"""Return the runtime component a version needs.
|
|
99
|
+
|
|
100
|
+
Loader versions usually inherit it, so this follows ``inheritsFrom``.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
version: The version.
|
|
104
|
+
game: The game directory that holds parent versions.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The component, or ``jre-legacy`` if no version in the chain declares one.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
UnsupportedError: A parent version is not installed.
|
|
111
|
+
"""
|
|
112
|
+
current = version
|
|
113
|
+
while current.java_component is None and current.inherits_from and game:
|
|
114
|
+
current = game.version(current.inherits_from)
|
|
115
|
+
return current.java_component or LEGACY_COMPONENT
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _binary_in(runtime_dir: Path, component: str) -> Path | None:
|
|
119
|
+
"""Find a component's ``java`` binary inside a runtime directory."""
|
|
120
|
+
component_dir = runtime_dir / component
|
|
121
|
+
if not component_dir.is_dir():
|
|
122
|
+
return None
|
|
123
|
+
for platform_dir in sorted(component_dir.iterdir()):
|
|
124
|
+
for binary in JAVA_BINARIES:
|
|
125
|
+
path = platform_dir / component / binary
|
|
126
|
+
if path.is_file():
|
|
127
|
+
return path
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _runtime_cache(cache_dir: Path | None) -> Path:
|
|
132
|
+
"""Return the directory where downloaded runtimes are kept."""
|
|
133
|
+
return (cache_dir or default_cache_dir()) / "java"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find(
|
|
137
|
+
component: str,
|
|
138
|
+
*,
|
|
139
|
+
game: GameDir | None = None,
|
|
140
|
+
cache_dir: Path | None = None,
|
|
141
|
+
) -> JavaRuntime | None:
|
|
142
|
+
"""Find a runtime in the official launcher or the download cache.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
component: The runtime component, such as ``java-runtime-delta``.
|
|
146
|
+
game: The game directory. Defaults to `GameDir.default`.
|
|
147
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The runtime, or None if it is not installed.
|
|
151
|
+
"""
|
|
152
|
+
for runtime_dir in official_runtime_dirs(game):
|
|
153
|
+
binary = _binary_in(runtime_dir, component)
|
|
154
|
+
if binary:
|
|
155
|
+
return JavaRuntime(component, binary)
|
|
156
|
+
cache = _runtime_cache(cache_dir)
|
|
157
|
+
if (cache / component / COMPLETE_MARKER).is_file():
|
|
158
|
+
binary = _binary_in(cache, component)
|
|
159
|
+
if binary:
|
|
160
|
+
return JavaRuntime(component, binary)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def find_all(
|
|
165
|
+
*, game: GameDir | None = None, cache_dir: Path | None = None
|
|
166
|
+
) -> list[JavaRuntime]:
|
|
167
|
+
"""Find every known runtime that is installed, in order of preference.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
game: The game directory. Defaults to `GameDir.default`.
|
|
171
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The runtimes found.
|
|
175
|
+
"""
|
|
176
|
+
found = (find(c, game=game, cache_dir=cache_dir) for c in PREFERRED_COMPONENTS)
|
|
177
|
+
return [runtime for runtime in found if runtime]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _unpack(packed: Path, path: Path, sha1: str, cancel: CancelToken | None) -> None:
|
|
181
|
+
"""Decompress an LZMA file, check its hash and delete the archive."""
|
|
182
|
+
with lzma.open(packed) as source, open(path, "wb") as dest:
|
|
183
|
+
while chunk := source.read(1 << 20):
|
|
184
|
+
if cancel is not None:
|
|
185
|
+
cancel.check()
|
|
186
|
+
dest.write(chunk)
|
|
187
|
+
packed.unlink()
|
|
188
|
+
if file_hash(path) != sha1:
|
|
189
|
+
path.unlink()
|
|
190
|
+
raise JavaError(f"Corrupt Java runtime file: {path.name}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _install_entries(
|
|
194
|
+
entries: dict[str, Any],
|
|
195
|
+
target: Path,
|
|
196
|
+
session: Session,
|
|
197
|
+
progress: ProgressCallback | None,
|
|
198
|
+
cancel: CancelToken | None,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Install a runtime manifest's files, permissions and links."""
|
|
201
|
+
resources: list[Resource] = []
|
|
202
|
+
packed_files: list[tuple[Path, Path, str]] = []
|
|
203
|
+
links: list[tuple[Path, str]] = []
|
|
204
|
+
executables: list[Path] = []
|
|
205
|
+
for name, entry in entries.items():
|
|
206
|
+
path = target / name
|
|
207
|
+
kind = entry["type"]
|
|
208
|
+
if kind == "directory":
|
|
209
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
elif kind == "link":
|
|
211
|
+
links.append((path, entry["target"]))
|
|
212
|
+
elif kind == "file":
|
|
213
|
+
raw = entry["downloads"]["raw"]
|
|
214
|
+
compressed = entry["downloads"].get("lzma")
|
|
215
|
+
if entry.get("executable"):
|
|
216
|
+
executables.append(path)
|
|
217
|
+
if path.is_file() and file_hash(path) == raw["sha1"]:
|
|
218
|
+
continue
|
|
219
|
+
if compressed:
|
|
220
|
+
packed = path.with_name(path.name + ".lzma")
|
|
221
|
+
resources.append(
|
|
222
|
+
Resource.of(
|
|
223
|
+
compressed["url"],
|
|
224
|
+
packed,
|
|
225
|
+
{"sha1": compressed["sha1"]},
|
|
226
|
+
compressed.get("size"),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
packed_files.append((packed, path, raw["sha1"]))
|
|
230
|
+
else:
|
|
231
|
+
resources.append(
|
|
232
|
+
Resource.of(
|
|
233
|
+
raw["url"], path, {"sha1": raw["sha1"]}, raw.get("size")
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
fetch(resources, session=session, progress=progress, cancel=cancel, task="java")
|
|
238
|
+
for packed, path, sha1 in packed_files:
|
|
239
|
+
_unpack(packed, path, sha1, cancel)
|
|
240
|
+
for path in executables:
|
|
241
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
242
|
+
for path, link_target in links:
|
|
243
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
path.unlink(missing_ok=True)
|
|
245
|
+
path.symlink_to(link_target)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def download(
|
|
249
|
+
component: str,
|
|
250
|
+
*,
|
|
251
|
+
cache_dir: Path | None = None,
|
|
252
|
+
session: Session | None = None,
|
|
253
|
+
progress: ProgressCallback | None = None,
|
|
254
|
+
cancel: CancelToken | None = None,
|
|
255
|
+
index_url: str = RUNTIME_INDEX_URL,
|
|
256
|
+
) -> JavaRuntime:
|
|
257
|
+
"""Download a Mojang runtime into the cache, never the launcher's directory.
|
|
258
|
+
|
|
259
|
+
Files that are already valid are kept, so an interrupted download can be run
|
|
260
|
+
again.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
component: The runtime component, such as ``java-runtime-delta``.
|
|
264
|
+
cache_dir: The cache directory. Defaults to the per-user cache.
|
|
265
|
+
session: The session to use. A temporary one is created if omitted.
|
|
266
|
+
progress: Receives download updates.
|
|
267
|
+
cancel: Stops the download.
|
|
268
|
+
index_url: Where to read Mojang's runtime index.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The installed runtime.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
JavaError: Mojang has no such runtime for this platform, or it is corrupt.
|
|
275
|
+
NetworkError: The runtime index could not be fetched.
|
|
276
|
+
DownloadError: A file could not be downloaded.
|
|
277
|
+
Cancelled: ``cancel`` was triggered.
|
|
278
|
+
"""
|
|
279
|
+
cache = _runtime_cache(cache_dir)
|
|
280
|
+
with session_scope(session) as client:
|
|
281
|
+
index = client.get_json(index_url)
|
|
282
|
+
builds: dict[str, Any] = index.get(platform_key(), {})
|
|
283
|
+
entries: list[dict[str, Any]] = builds.get(component) or []
|
|
284
|
+
if not entries:
|
|
285
|
+
raise JavaError(f"Mojang has no {component} for {platform_key()}")
|
|
286
|
+
manifest = client.get_json(entries[0]["manifest"]["url"])
|
|
287
|
+
target = cache / component / platform_key() / component
|
|
288
|
+
_install_entries(manifest["files"], target, client, progress, cancel)
|
|
289
|
+
|
|
290
|
+
(cache / component / COMPLETE_MARKER).touch()
|
|
291
|
+
binary = _binary_in(cache, component)
|
|
292
|
+
if binary is None:
|
|
293
|
+
raise JavaError(f"No java executable found in {component}")
|
|
294
|
+
return JavaRuntime(component, binary)
|
modsmith/launcher.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Read and change the official launcher's profiles."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._support import slugify, write_json
|
|
15
|
+
from .errors import LauncherError
|
|
16
|
+
from .minecraft import GameDir
|
|
17
|
+
|
|
18
|
+
__all__ = ["Launcher", "Profile", "icon_data_uri", "is_running"]
|
|
19
|
+
|
|
20
|
+
PROFILE_FILES = ("launcher_profiles.json", "launcher_profiles_microsoft_store.json")
|
|
21
|
+
BACKUP_SUFFIX = ".modsmith.bak"
|
|
22
|
+
KEY_PREFIX = "modsmith-"
|
|
23
|
+
LAUNCHER_PROCESSES = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"minecraft.exe",
|
|
26
|
+
"minecraftlauncher.exe",
|
|
27
|
+
"minecraft launcher",
|
|
28
|
+
"minecraft-launcher",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_tasklist(output: str) -> set[str]:
|
|
34
|
+
"""Extract lowercase process names from ``tasklist /FO CSV`` output."""
|
|
35
|
+
return {
|
|
36
|
+
line.split('","')[0].strip('"').lower() for line in output.splitlines() if line
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_ps(output: str) -> set[str]:
|
|
41
|
+
"""Extract lowercase process names from ``ps -o comm=`` output."""
|
|
42
|
+
return {
|
|
43
|
+
line.rsplit("/", 1)[-1].strip().lower() for line in output.splitlines() if line
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _process_names() -> set[str]:
|
|
48
|
+
"""List running process names, or nothing if they cannot be read."""
|
|
49
|
+
windows = sys.platform == "win32"
|
|
50
|
+
command = (
|
|
51
|
+
["tasklist", "/FO", "CSV", "/NH"] if windows else ["ps", "-A", "-o", "comm="]
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
output = subprocess.run(
|
|
55
|
+
command, capture_output=True, text=True, errors="replace", timeout=10
|
|
56
|
+
).stdout
|
|
57
|
+
except (OSError, subprocess.SubprocessError):
|
|
58
|
+
return set()
|
|
59
|
+
return parse_tasklist(output) if windows else parse_ps(output)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_running() -> bool:
|
|
63
|
+
"""Return whether the official launcher appears to be running.
|
|
64
|
+
|
|
65
|
+
This is best-effort: it returns False if the process list cannot be read.
|
|
66
|
+
"""
|
|
67
|
+
return bool(_process_names() & LAUNCHER_PROCESSES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def icon_data_uri(data: bytes, content_type: str = "image/png") -> str:
|
|
71
|
+
"""Encode an image as the data URI that launcher profiles use for icons.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
data: The image file's contents.
|
|
75
|
+
content_type: The image's media type.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The data URI, ready for the ``icon`` argument of `Launcher.add_profile`.
|
|
79
|
+
"""
|
|
80
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
81
|
+
return f"data:{content_type};base64,{encoded}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _timestamp() -> str:
|
|
85
|
+
"""Return the current UTC time in the launcher's format."""
|
|
86
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Profile:
|
|
91
|
+
"""One launcher profile.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
key: The profile's key in the launcher's file.
|
|
95
|
+
name: The display name.
|
|
96
|
+
version_id: The version it starts, or None.
|
|
97
|
+
game_dir: Its game directory, or None for the default.
|
|
98
|
+
java_args: Its JVM arguments, or None.
|
|
99
|
+
icon: Its icon, as a name or data URI, or None.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
key: str
|
|
103
|
+
name: str
|
|
104
|
+
version_id: str | None = None
|
|
105
|
+
game_dir: str | None = None
|
|
106
|
+
java_args: str | None = None
|
|
107
|
+
icon: str | None = None
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, key: str, data: dict[str, Any]) -> "Profile":
|
|
111
|
+
"""Build a profile from the launcher's JSON.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
key: The profile's key.
|
|
115
|
+
data: The profile's JSON.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The parsed profile.
|
|
119
|
+
"""
|
|
120
|
+
return cls(
|
|
121
|
+
key=key,
|
|
122
|
+
name=data.get("name", key),
|
|
123
|
+
version_id=data.get("lastVersionId"),
|
|
124
|
+
game_dir=data.get("gameDir"),
|
|
125
|
+
java_args=data.get("javaArgs"),
|
|
126
|
+
icon=data.get("icon"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Launcher:
|
|
131
|
+
"""The profiles of the official launcher that owns a `GameDir`.
|
|
132
|
+
|
|
133
|
+
Changes go to every profile file that exists, and each file is backed up
|
|
134
|
+
first. Keep the launcher closed while changing profiles.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
game: The game directory. Defaults to `GameDir.default`.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
game: The game directory.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, game: GameDir | None = None) -> None:
|
|
144
|
+
self.game = game or GameDir.default()
|
|
145
|
+
|
|
146
|
+
def profile_files(self) -> list[Path]:
|
|
147
|
+
"""List the launcher's profile files that exist.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The paths.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
LauncherError: There are none, because the launcher has never run here.
|
|
154
|
+
"""
|
|
155
|
+
paths = [self.game.path / name for name in PROFILE_FILES]
|
|
156
|
+
existing = [path for path in paths if path.is_file()]
|
|
157
|
+
if not existing:
|
|
158
|
+
raise LauncherError(
|
|
159
|
+
f"No launcher profile in {self.game.path}; run the launcher once first"
|
|
160
|
+
)
|
|
161
|
+
return existing
|
|
162
|
+
|
|
163
|
+
def _read(self, path: Path) -> dict[str, Any]:
|
|
164
|
+
"""Parse a profile file, allowing a byte order mark."""
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
167
|
+
except ValueError as e:
|
|
168
|
+
raise LauncherError(f"Cannot parse {path.name}: {e}") from e
|
|
169
|
+
|
|
170
|
+
def _update(self, change: Callable[[dict[str, Any]], None]) -> None:
|
|
171
|
+
"""Apply a change to the profiles in every file, with a backup."""
|
|
172
|
+
for path in self.profile_files():
|
|
173
|
+
data = self._read(path)
|
|
174
|
+
change(data.setdefault("profiles", {}))
|
|
175
|
+
shutil.copy2(path, path.with_name(path.name + BACKUP_SUFFIX))
|
|
176
|
+
write_json(path, data)
|
|
177
|
+
|
|
178
|
+
def profiles(self) -> dict[str, Profile]:
|
|
179
|
+
"""Read every profile.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The profiles by key. If files disagree, the first file wins.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
LauncherError: No profile file exists, or one cannot be parsed.
|
|
186
|
+
"""
|
|
187
|
+
result: dict[str, Profile] = {}
|
|
188
|
+
for path in self.profile_files():
|
|
189
|
+
for key, value in self._read(path).get("profiles", {}).items():
|
|
190
|
+
result.setdefault(key, Profile.from_dict(key, value))
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def add_profile(
|
|
194
|
+
self,
|
|
195
|
+
name: str,
|
|
196
|
+
version_id: str,
|
|
197
|
+
game_dir: Path | str,
|
|
198
|
+
*,
|
|
199
|
+
key: str | None = None,
|
|
200
|
+
memory_mb: int | None = None,
|
|
201
|
+
java_args: str | None = None,
|
|
202
|
+
icon: str | None = None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Create or update a profile and return its key.
|
|
205
|
+
|
|
206
|
+
Fields you do not set on an existing profile are kept.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
name: The display name.
|
|
210
|
+
version_id: The version to start.
|
|
211
|
+
game_dir: The instance's directory. It is stored as an absolute path.
|
|
212
|
+
key: The profile's key. Defaults to ``modsmith-`` and a slug of ``name``.
|
|
213
|
+
memory_mb: The maximum heap size. Ignored if ``java_args`` is set.
|
|
214
|
+
java_args: The JVM arguments.
|
|
215
|
+
icon: The icon, such as a result of `icon_data_uri`.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The profile's key.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
LauncherError: No profile file exists, or one cannot be parsed.
|
|
222
|
+
"""
|
|
223
|
+
key = key or KEY_PREFIX + slugify(name)
|
|
224
|
+
now = _timestamp()
|
|
225
|
+
values: dict[str, Any] = {
|
|
226
|
+
"name": name,
|
|
227
|
+
"type": "custom",
|
|
228
|
+
"gameDir": str(Path(game_dir).resolve()),
|
|
229
|
+
"lastVersionId": version_id,
|
|
230
|
+
"lastUsed": now,
|
|
231
|
+
}
|
|
232
|
+
if memory_mb is not None:
|
|
233
|
+
values["javaArgs"] = f"-Xmx{memory_mb}M"
|
|
234
|
+
if java_args is not None:
|
|
235
|
+
values["javaArgs"] = java_args
|
|
236
|
+
if icon is not None:
|
|
237
|
+
values["icon"] = icon
|
|
238
|
+
|
|
239
|
+
def change(profiles: dict[str, Any]) -> None:
|
|
240
|
+
existing = profiles.get(key)
|
|
241
|
+
created = {"created": now} if existing is None else {}
|
|
242
|
+
profiles[key] = {**(existing or {}), **created, **values}
|
|
243
|
+
|
|
244
|
+
self._update(change)
|
|
245
|
+
return key
|
|
246
|
+
|
|
247
|
+
def remove_profile(self, key: str) -> None:
|
|
248
|
+
"""Remove a profile. A key that does not exist is ignored.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
key: The profile's key.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
LauncherError: No profile file exists, or one cannot be parsed.
|
|
255
|
+
"""
|
|
256
|
+
self._update(lambda profiles: profiles.pop(key, None))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Install mod loaders. Subclass `Loader` and `register` it to add another."""
|
|
2
|
+
|
|
3
|
+
from ._base import Loader, create, parse, register
|
|
4
|
+
from ._fabric import Fabric, MetaLoader, Quilt
|
|
5
|
+
from ._forge import Forge, NeoForge, ProcessorLoader
|
|
6
|
+
from ._install_profile import InstallerJar, InstallProfile, Library, Processor
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Fabric",
|
|
10
|
+
"Forge",
|
|
11
|
+
"InstallProfile",
|
|
12
|
+
"InstallerJar",
|
|
13
|
+
"Library",
|
|
14
|
+
"Loader",
|
|
15
|
+
"MetaLoader",
|
|
16
|
+
"NeoForge",
|
|
17
|
+
"Processor",
|
|
18
|
+
"ProcessorLoader",
|
|
19
|
+
"Quilt",
|
|
20
|
+
"create",
|
|
21
|
+
"parse",
|
|
22
|
+
"register",
|
|
23
|
+
]
|