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/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
+ ]