diff --git a/.github/workflows/build-exe.yml b/.github/workflows/build-exe.yml index 444b85f..0c44610 100644 --- a/.github/workflows/build-exe.yml +++ b/.github/workflows/build-exe.yml @@ -19,7 +19,7 @@ jobs: include: - name: Windows os: windows-latest - pyinstaller: pyinstaller --onefile --name MeowLauncher --clean launcher.py + pyinstaller: pyinstaller --onefile --windowed --name MeowLauncher --clean launcher.py package: cp dist/MeowLauncher.exe MeowLauncher-windows-x86_64.exe - name: Linux os: ubuntu-latest diff --git a/README.md b/README.md index 1cd9e72..0afc591 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,63 @@ -# MeowLauncher +# MeowLauncher 🐱 -一个使用纯 Python 标准库实现的 Minecraft 启动器,零第三方依赖,跨平台(Windows / Linux / macOS)。 +一个使用纯 Python 标准库实现的跨平台 Minecraft 启动器,带图形界面,支持第三方登录。 ## 功能特性 -- 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`) -- 自动下载游戏客户端、依赖库(libraries)、原生库(natives)与资源文件(assets) -- 自动校验 SHA1,断点续传(已存在且校验通过则跳过) -- 原生库自动解压到 `versions//natives` -- 离线模式(自动生成与官方一致的离线 UUID) -- 支持新旧两版启动参数格式(1.13+ 的 `arguments` 与旧版 `minecraftArguments`) -- 自动检测 Java(支持 `--java` 指定、`JAVA_HOME`、`PATH`) +- 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致 +- 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议) +- 🎭 离线模式:自动生成与官方一致的离线 UUID +- 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`) +- ⬇️ 自动下载客户端、依赖库、原生库、资源文件,SHA1 校验 + 断点续传 +- 🧩 兼容新旧两版启动参数格式(1.13+ 的 `arguments` 与旧版 `minecraftArguments`) +- ☕ 自动检测 Java(支持手动指定、`JAVA_HOME`、`PATH`) ## 环境要求 -- Python 3.8+ +- Python 3.8+(仅运行源码时需要,使用打包版则无需) - Java(Minecraft 1.17+ 需要 Java 17+,1.12 及以前需要 Java 8) ## 使用方法 +### 图形界面(推荐) + ```bash -# 查看可用版本 -python3 launcher.py --list-versions - -# 启动最新发布版(离线模式,默认玩家 Steve) -python3 launcher.py --username Steve - -# 启动指定版本,指定内存 -python3 launcher.py --username Steve --version 1.20.4 --max-mem 4096 - -# 指定游戏目录与 Java 路径 -python3 launcher.py --username Steve --version 1.20.4 \ - --game-dir /home/steve/.minecraft \ - --java /usr/lib/jvm/java-17/bin/java +python3 launcher.py ``` -Windows 下将 `python3` 换成 `python` 即可。 +或直接运行打包好的可执行文件: +- Windows: `MeowLauncher-windows-x86_64.exe` +- Linux: `./MeowLauncher-linux-x86_64` +- macOS: 解压 `MeowLauncher-macos.zip` 后打开 `MeowLauncher.app` -## 目录结构 +界面操作: +1. 选择登录方式:离线模式(填玩家名)或第三方登录(选皮肤站、填账号密码) +2. 选择游戏版本(可勾选显示快照版) +3. 设置最大内存、Java 路径、游戏目录(一般保持默认即可) +4. 点击「启动游戏」 +### 命令行模式 + +```bash +python3 launcher.py --cli --list-versions +python3 launcher.py --cli --username Steve --version 1.20.4 --max-mem 4096 ``` -~/.minecraft/ -├── versions// # 版本 jar、json、natives -├── libraries/ # 依赖库 -└── assets/ # 资源索引与对象 -``` + +## 第三方登录说明 + +支持所有兼容 authlib-injector 的皮肤站,内置预设: + +| 皮肤站 | 地址 | +|--------|------| +| LittleSkin | https://littleskin.cn/api/yggdrasil | +| Blessing Skin | https://skin.prinzeugen.net/api/yggdrasil | + +登录时会自动下载最新版 authlib-injector.jar 并在启动时注入,无需手动配置。 ## 常见问题 -- **未找到 Java**:使用 `--java` 指定 Java 可执行文件路径。 +- **未找到 Java**:在界面「启动设置」中手动指定 Java 可执行文件路径。 - **下载慢**:首次启动需要下载数百 MB 文件,请耐心等待;再次启动会自动跳过已下载文件。 -- **正版账号**:当前版本为离线模式,正版登录接口已预留(`auth` 参数),后续版本将支持 Yggdrasil 认证。 ## 免责声明 diff --git a/launcher.py b/launcher.py index f74d492..dbff514 100644 --- a/launcher.py +++ b/launcher.py @@ -1,20 +1,18 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -MeowLauncher - 一个使用纯 Python 标准库实现的 Minecraft 启动器 +MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版) 功能特性: + - 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致 + - 支持离线账号与第三方 Yggdrasil 登录 (authlib-injector, 如 LittleSkin) - 自动获取官方版本清单并下载任意版本 - - 自动下载游戏依赖库 (libraries) 与原生库 (natives) - - 自动下载资源文件 (assets) - - 支持离线账号与正版账号 (预留 Yggdrasil 接口) - - 跨平台: Windows / Linux / macOS - - 零第三方依赖, 仅使用 Python 标准库 + - 自动下载依赖库 / 原生库 / 资源文件, SHA1 校验 + 断点续传 + - 保留命令行模式: python launcher.py --cli -用法示例: - python3 launcher.py --username Steve --version 1.20.4 - python3 launcher.py --username Steve --version latest-release --max-mem 4096 - python3 launcher.py --list-versions +用法: + python launcher.py 启动图形界面 + python launcher.py --cli ... 命令行模式 """ import argparse @@ -22,12 +20,15 @@ import hashlib import json import os import platform +import queue import re import shutil import subprocess import sys +import threading import urllib.error import urllib.request +import uuid as uuid_mod import zipfile from pathlib import Path @@ -36,38 +37,30 @@ from pathlib import Path # --------------------------------------------------------------------------- VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" -USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.0)" +AUTHLIB_INJECTOR_API = "https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest" +USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.2)" -# 操作系统名称映射 (Mojang 规则中的 os.name) OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux") -# 架构映射: 兼容新旧两套规则 _ARCH = platform.machine().lower() if _ARCH in ("x86_64", "amd64"): - ARCH_NEW = "x86_64" # 新版 (1.13+) 使用的 arch 值 - ARCH_OLD = "64" # 旧版使用的 arch 值 + ARCH_NEW, ARCH_OLD = "x86_64", "64" elif _ARCH in ("aarch64", "arm64"): - ARCH_NEW = "arm64" - ARCH_OLD = "arm64" + ARCH_NEW, ARCH_OLD = "arm64", "arm64" else: - ARCH_NEW = "x86" - ARCH_OLD = "32" + ARCH_NEW, ARCH_OLD = "x86", "32" -# 原生库分类器 (Mojang 命名) -NATIVES_CLASSIFIER = { - "linux": "natives-linux", - "osx": "natives-osx", - "windows": "natives-windows", -} +NATIVES_CLASSIFIER = {"linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows"} def log(msg: str) -> None: - """简单的彩色日志输出。""" - print(msg, flush=True) + try: + print(msg, flush=True) + except Exception: + pass def sha1_of(path: Path) -> str: - """计算文件的 SHA1 校验值。""" h = hashlib.sha1() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): @@ -75,22 +68,25 @@ def sha1_of(path: Path) -> str: return h.hexdigest() -def http_get(url: str) -> bytes: - """GET 请求并返回字节内容。""" +def http_get(url: str, timeout: int = 30) -> bytes: req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - with urllib.request.urlopen(req, timeout=30) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read() -def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> bool: - """ - 下载文件到指定路径。 - - 若目标已存在且校验通过 (或未提供校验值), 则跳过。 - - 下载后校验 SHA1, 失败则删除并抛异常。 - 返回 True 表示本次实际下载, False 表示已存在跳过。 - """ - dest.parent.mkdir(parents=True, exist_ok=True) +def http_post_json(url: str, payload: dict, timeout: int = 30) -> dict: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={ + "User-Agent": USER_AGENT, + "Content-Type": "application/json", + }) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + +def download_file(url: str, dest: Path, sha1: str = None, size: int = None, + progress_cb=None) -> bool: + dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): ok = True if sha1: @@ -103,7 +99,16 @@ def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> b req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as f: - shutil.copyfileobj(resp, f, 1024 * 1024) + total = int(resp.headers.get("Content-Length") or 0) + done = 0 + while True: + chunk = resp.read(1024 * 256) + if not chunk: + break + f.write(chunk) + done += len(chunk) + if progress_cb and total: + progress_cb(done, total) except Exception: if tmp.exists(): tmp.unlink() @@ -111,35 +116,29 @@ def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> b if sha1 and sha1_of(tmp) != sha1: tmp.unlink() - raise RuntimeError(f"SHA1 校验失败: {url}") - + raise RuntimeError("SHA1 校验失败: %s" % url) if size and tmp.stat().st_size != size: tmp.unlink() - raise RuntimeError(f"文件大小不符: {url}") + raise RuntimeError("文件大小不符: %s" % url) tmp.rename(dest) return True def rule_allowed(rule: dict) -> bool: - """判断 Mojang 下载规则 (rules) 是否允许当前平台。""" if rule.get("action") == "allow": default = False - else: # disallow + else: default = True - os_rule = rule.get("os", {}) if "name" in os_rule and os_rule["name"] != OS_NAME: return default - if "arch" in os_rule: - arch = os_rule["arch"] - if arch not in (ARCH_NEW, ARCH_OLD): - return default + if "arch" in os_rule and os_rule["arch"] not in (ARCH_NEW, ARCH_OLD): + return default return not default def library_allowed(lib: dict) -> bool: - """判断某个库是否允许当前平台下载。""" rules = lib.get("rules") if not rules: return True @@ -147,55 +146,92 @@ def library_allowed(lib: dict) -> bool: # --------------------------------------------------------------------------- -# Minecraft 启动器 +# 第三方登录 (Yggdrasil / authlib-injector) +# --------------------------------------------------------------------------- + +class YggdrasilAuth: + """authlib-injector 兼容的第三方登录 (LittleSkin 等皮肤站)。""" + + PRESETS = { + "LittleSkin": "https://littleskin.cn/api/yggdrasil", + "Blessing Skin": "https://skin.prinzeugen.net/api/yggdrasil", + "自定义": "", + } + + def __init__(self, server_url: str): + self.server_url = server_url.rstrip("/") + + def authenticate(self, username: str, password: str) -> dict: + """登录并返回 {access_token, uuid, name, client_token}。""" + client_token = str(uuid_mod.uuid4()) + payload = { + "agent": {"name": "Minecraft", "version": 1}, + "username": username, + "password": password, + "clientToken": client_token, + "requestUser": True, + } + resp = http_post_json(self.server_url + "/authserver/authenticate", payload) + profile = resp.get("selectedProfile") or {} + if not profile: + raise RuntimeError("账号没有可用的游戏档案 (请先在皮肤站创建角色)") + return { + "access_token": resp.get("accessToken", ""), + "client_token": resp.get("clientToken", client_token), + "uuid": profile.get("id", ""), + "name": profile.get("name", username), + } + + def download_injector(self, game_dir: Path) -> Path: + """下载最新版 authlib-injector.jar, 返回路径。""" + lib_dir = game_dir / "libraries" / "authlib-injector" + lib_dir.mkdir(parents=True, exist_ok=True) + jar = lib_dir / "authlib-injector.jar" + if jar.exists(): + return jar + try: + meta = json.loads(http_get(AUTHLIB_INJECTOR_API).decode("utf-8")) + url = meta["assets"][0]["browser_download_url"] + except Exception: + url = ("https://github.com/yushijinhun/authlib-injector/releases/" + "download/v1.2.5/authlib-injector-1.2.5.jar") + download_file(url, jar) + return jar + + +# --------------------------------------------------------------------------- +# 核心启动器 # --------------------------------------------------------------------------- class MinecraftLauncher: - def __init__( - self, - game_dir: str, - version_id: str, - username: str, - java_path: str = None, - min_mem: int = 1024, - max_mem: int = 2048, - auth: dict = None, - offline: bool = True, - ): + def __init__(self, game_dir, version_id, username="Steve", java_path=None, + min_mem=1024, max_mem=2048, auth=None, yggdrasil=None, + progress_cb=None): self.game_dir = Path(game_dir).resolve() self.version_id = version_id self.username = username or "Steve" self.java_path = java_path self.min_mem = min_mem self.max_mem = max_mem - self.auth = auth or {} # 正版登录信息: {access_token, uuid, name} - self.offline = offline + self.auth = auth or {} + self.yggdrasil = yggdrasil # YggdrasilAuth 实例或 None + self.progress_cb = progress_cb # callback(msg) 日志; callback2(done,total) 进度 - # 版本相关目录 self.versions_dir = self.game_dir / "versions" self.assets_dir = self.game_dir / "assets" self.libraries_dir = self.game_dir / "libraries" - # 版本信息 (解析后填充) self.version_meta = None - self.version_url = None self.asset_index = None self.client_jar = None + self._cp = [] - # ------------------------------------------------------------------ - # 版本解析 - # ------------------------------------------------------------------ + # ---------------- 版本 ---------------- def fetch_version_manifest(self) -> dict: - """获取官方版本清单。""" - data = json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8")) - return data + return json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8")) def resolve_version(self) -> None: - """ - 解析版本号 -> 版本元数据。 - 支持: latest-release / latest-snapshot / 任意版本 id。 - """ manifest = self.fetch_version_manifest() latest = manifest.get("latest", {}) versions = manifest.get("versions", []) @@ -207,89 +243,74 @@ class MinecraftLauncher: entry = next((v for v in versions if v["id"] == self.version_id), None) if not entry: - raise RuntimeError(f"找不到版本: {self.version_id}") + raise RuntimeError("找不到版本: %s" % self.version_id) - self.version_url = entry["url"] - self.version_meta = json.loads(http_get(self.version_url).decode("utf-8")) - log(f"[版本] 已解析: {self.version_meta.get('id')} " - f"(release={self.version_meta.get('type')}, " - f"java={self.version_meta.get('javaVersion', {}).get('majorVersion', '?')})") + self.version_meta = json.loads(http_get(entry["url"]).decode("utf-8")) + self._emit("[版本] %s (%s, 需要Java %s)" % ( + self.version_meta.get("id"), + self.version_meta.get("type"), + self.version_meta.get("javaVersion", {}).get("majorVersion", "?"))) - # ------------------------------------------------------------------ - # 下载: 客户端 / 库 / 资源 - # ------------------------------------------------------------------ + # ---------------- 下载 ---------------- + + def _emit(self, msg: str) -> None: + log(msg) + if self.progress_cb: + self.progress_cb(msg) def download_client(self) -> Path: - """下载客户端 jar 文件。""" vdir = self.versions_dir / self.version_id vdir.mkdir(parents=True, exist_ok=True) - jar = vdir / f"{self.version_id}.jar" - + jar = vdir / ("%s.jar" % self.version_id) dl = self.version_meta.get("downloads", {}).get("client") - if dl: - download_file(dl["url"], jar, dl.get("sha1"), dl.get("size")) - else: + if not dl: raise RuntimeError("版本清单中缺少 client 下载项") - + download_file(dl["url"], jar, dl.get("sha1"), dl.get("size")) self.client_jar = jar - log(f"[客户端] {jar.name} 就绪 ({jar.stat().st_size // 1024} KB)") + self._emit("[客户端] %s 就绪 (%d KB)" % (jar.name, jar.stat().st_size // 1024)) return jar - def _download_library_artifact(self, artifact: dict, lib_path: Path) -> None: - """下载单个库文件。""" - if not artifact: - return - download_file( - artifact["url"], - lib_path, - artifact.get("sha1"), - artifact.get("size"), - ) - def download_libraries(self) -> list: - """下载所有依赖库, 返回 classpath 列表。""" cp = [] + total = sum(1 for lib in self.version_meta.get("libraries", []) if library_allowed(lib)) + done = 0 for lib in self.version_meta.get("libraries", []): if not library_allowed(lib): continue - + done += 1 downloads = lib.get("downloads", {}) artifact = downloads.get("artifact") - - # 处理原生库 (natives) - classifier = NATIVES_CLASSIFIER.get(OS_NAME) native = None + classifier = NATIVES_CLASSIFIER.get(OS_NAME) natives_entry = downloads.get("classifiers", {}).get(classifier) if classifier else None + base = lib["name"].replace(":", "/").replace("@", "/") if artifact: - path = Path(lib["name"].replace(":", "/").replace("@", "/")) - path = self.libraries_dir / path - self._download_library_artifact(artifact, path) + path = self.libraries_dir / base + self._download_one(artifact, path, "库", done, total) cp.append(str(path)) - if natives_entry: - path = Path(lib["name"].replace(":", "/").replace("@", "/")) - path = self.libraries_dir / path - self._download_library_artifact(natives_entry, path) + path = self.libraries_dir / base + self._download_one(natives_entry, path, "库", done, total) native = path - if native: self._extract_natives(native) - log(f"[库] 依赖库准备完成, classpath 共 {len(cp)} 项") + self._emit("[库] 依赖库准备完成 (%d 项)" % len(cp)) return cp + def _download_one(self, artifact, path, tag, done, total): + try: + download_file(artifact["url"], path, artifact.get("sha1"), artifact.get("size")) + except Exception as e: + self._emit(" [警告] %s 下载失败: %s" % (path.name, e)) + def _extract_natives(self, native_jar: Path) -> None: - """解压原生库到 versions//natives 目录。""" natives_dir = self.versions_dir / self.version_id / "natives" natives_dir.mkdir(parents=True, exist_ok=True) - - ext_map = {"linux": ".so", "osx": ".dylib", "windows": ".dll"} - ext = ext_map[OS_NAME] - + ext = {"linux": ".so", "osx": ".dylib", "windows": ".dll"}[OS_NAME] with zipfile.ZipFile(native_jar) as zf: for name in zf.namelist(): - # 跳过 META-INF 与源码/javadoc if name.startswith("META-INF") or not name.endswith(ext): continue target = natives_dir / Path(name).name @@ -297,15 +318,12 @@ class MinecraftLauncher: shutil.copyfileobj(src, dst) def download_assets(self) -> None: - """下载资源索引与全部资源对象。""" index_info = self.version_meta.get("assetIndex") if not index_info: - log("[资源] 该版本无资源索引, 跳过") + self._emit("[资源] 该版本无资源索引, 跳过") return - - index_file = self.assets_dir / "indexes" / f"{index_info['id']}.json" + index_file = self.assets_dir / "indexes" / ("%s.json" % index_info["id"]) download_file(index_info["url"], index_file, index_info.get("sha1"), index_info.get("size")) - index = json.loads(index_file.read_text(encoding="utf-8")) objects = index.get("objects", {}) self.asset_index = index_info["id"] @@ -315,40 +333,36 @@ class MinecraftLauncher: for key, obj in objects.items(): h = obj["hash"] dest = self.assets_dir / "objects" / h[:2] / h - url = f"https://resources.download.minecraft.net/{h[:2]}/{h}" + url = "https://resources.download.minecraft.net/%s/%s" % (h[:2], h) try: download_file(url, dest, h, obj.get("size")) except Exception as e: - log(f" [警告] 资源下载失败 {key}: {e}") + self._emit(" [警告] 资源失败 %s: %s" % (key, e)) done += 1 - if done % 200 == 0: - log(f" [资源] {done}/{total}") + if done % 500 == 0: + self._emit(" [资源] %d/%d" % (done, total)) + self._emit("[资源] 资源文件就绪 (%d 项)" % total) - log(f"[资源] 资源文件就绪 ({total} 项)") - - # ------------------------------------------------------------------ - # 启动参数 - # ------------------------------------------------------------------ + # ---------------- 启动参数 ---------------- def _resolve_argument(self, arg: str) -> str: - """替换启动参数中的占位符。""" - uuid = self._offline_uuid() + uuid = self.auth.get("uuid") or self._offline_uuid() replace_map = { "${auth_player_name}": self.auth.get("name") or self.username, "${auth_session}": self.auth.get("access_token", "0"), "${auth_access_token}": self.auth.get("access_token", "0"), - "${auth_uuid}": self.auth.get("uuid") or uuid, + "${auth_uuid}": uuid, "${version_name}": self.version_id, "${version_type}": self.version_meta.get("type", "release"), "${game_directory}": str(self.game_dir), "${assets_root}": str(self.assets_dir), "${assets_index_name}": self.asset_index or self.version_id, "${user_properties}": "{}", - "${user_type}": "msa" if not self.offline else "legacy", + "${user_type}": "msa" if not self.yggdrasil else "mojang", "${resolution_width}": "854", "${resolution_height}": "480", "${launcher_name}": "MeowLauncher", - "${launcher_version}": "1.0", + "${launcher_version}": "1.2", "${natives_directory}": str(self.versions_dir / self.version_id / "natives"), "${classpath}": os.pathsep.join([str(self.client_jar)] + self._cp), "${library_directory}": str(self.libraries_dir), @@ -358,119 +372,367 @@ class MinecraftLauncher: return arg def _offline_uuid(self) -> str: - """根据离线用户名生成稳定的 UUID (与官方离线模式一致)。""" name = self.auth.get("name") or self.username - digest = hashlib.md5(f"OfflinePlayer:{name}".encode("utf-8")).digest() - digest = bytearray(digest) - digest[6] = (digest[6] & 0x0F) | 0x30 # version 3 - digest[8] = (digest[8] & 0x3F) | 0x80 # variant + digest = bytearray(hashlib.md5(("OfflinePlayer:" + name).encode("utf-8")).digest()) + digest[6] = (digest[6] & 0x0F) | 0x30 + digest[8] = (digest[8] & 0x3F) | 0x80 hex_str = digest.hex() - return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}" + return "%s-%s-%s-%s-%s" % (hex_str[0:8], hex_str[8:12], hex_str[12:16], + hex_str[16:20], hex_str[20:32]) + + def _build_args(self) -> tuple: + jvm_args, game_args = [], [] + arguments = self.version_meta.get("arguments") + if arguments: + for item in arguments.get("jvm", []): + if isinstance(item, str): + jvm_args.append(self._resolve_argument(item)) + elif isinstance(item, dict) and all(rule_allowed(r) for r in item.get("rules", [])): + for v in item.get("value", []): + jvm_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) + for item in arguments.get("game", []): + if isinstance(item, str): + game_args.append(self._resolve_argument(item)) + elif isinstance(item, dict) and all(rule_allowed(r) for r in item.get("rules", [])): + for v in item.get("value", []): + game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) + else: + legacy = self.version_meta.get("minecraftArguments", "") + game_args = [self._resolve_argument(a) for a in legacy.split(" ")] + return jvm_args, game_args + + def _find_java(self) -> str: + if self.java_path and os.path.isfile(self.java_path): + return self.java_path + java_home = os.environ.get("JAVA_HOME") + if java_home: + cand = Path(java_home) / "bin" / ("java.exe" if OS_NAME == "windows" else "java") + if cand.exists(): + return str(cand) + for cand in ("java", "java.exe"): + found = shutil.which(cand) + if found: + return found + raise RuntimeError("未找到 Java, 请在设置中指定 java 可执行文件路径 (1.17+ 需要 Java 17+)") def build_command(self, classpath: list) -> list: - """构建完整的 java 启动命令。""" self._cp = classpath java = self._find_java() - jvm_args, game_args = self._build_args() command = [java] - command += ["-Xms{}M".format(self.min_mem), "-Xmx{}M".format(self.max_mem)] - command += ["-Djava.library.path={}".format(self.versions_dir / self.version_id / "natives")] - command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.0"] + command += ["-Xms%dM" % self.min_mem, "-Xmx%dM" % self.max_mem] + command += ["-Djava.library.path=%s" % (self.versions_dir / self.version_id / "natives")] + command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.2"] + + # 第三方登录: 注入 authlib-injector + if self.yggdrasil: + injector = self.yggdrasil.download_injector(self.game_dir) + command += ["-javaagent:%s=%s" % (injector, self.yggdrasil.server_url)] + command += ["-cp", os.pathsep.join([str(self.client_jar)] + classpath)] command += jvm_args command += ["net.minecraft.client.main.Main"] command += game_args return command - def _build_args(self) -> tuple: - """拆分并解析 jvm 参数与游戏参数 (兼容新旧版本格式)。""" - jvm_args = [] - game_args = [] - arguments = self.version_meta.get("arguments") - - if arguments: # 1.13+ 新格式 - for item in arguments.get("jvm", []): - if isinstance(item, str): - jvm_args.append(self._resolve_argument(item)) - elif isinstance(item, dict): - if all(rule_allowed(r) for r in item.get("rules", [])): - for v in item.get("value", []): - jvm_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) - - for item in arguments.get("game", []): - if isinstance(item, str): - game_args.append(self._resolve_argument(item)) - elif isinstance(item, dict): - if all(rule_allowed(r) for r in item.get("rules", [])): - for v in item.get("value", []): - game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) - else: # 1.12 及以前旧格式 - legacy = self.version_meta.get("minecraftArguments", "") - game_args = [self._resolve_argument(a) for a in legacy.split(" ")] - - return jvm_args, game_args - - def _find_java(self) -> str: - """查找 Java 可执行文件: 优先用户指定, 其次 JAVA_HOME, 最后 PATH。""" - if self.java_path and os.path.isfile(self.java_path): - return self.java_path - - java_home = os.environ.get("JAVA_HOME") - if java_home: - cand = Path(java_home) / "bin" / ("java.exe" if OS_NAME == "windows" else "java") - if cand.exists(): - return str(cand) - - for cand in ("java", "java.exe"): - found = shutil.which(cand) - if found: - return found - - raise RuntimeError( - "未找到 Java, 请使用 --java 参数指定 java 可执行文件路径 " - "(Minecraft 1.17+ 需要 Java 17+)" - ) - - # ------------------------------------------------------------------ - # 主流程 - # ------------------------------------------------------------------ + # ---------------- 主流程 ---------------- def run(self) -> int: - """完整流程: 解析 -> 下载 -> 启动。""" - log(f"[启动] 游戏目录: {self.game_dir}") - log(f"[启动] 版本: {self.version_id} 玩家: {self.username}") + self._emit("[启动] 游戏目录: %s" % self.game_dir) + self._emit("[启动] 版本: %s 玩家: %s" % (self.version_id, + self.auth.get("name") or self.username)) self.resolve_version() self.download_client() classpath = self.download_libraries() self.download_assets() - # 保存版本元数据到本地 (供后续复用) - meta_file = self.versions_dir / self.version_id / f"{self.version_id}.json" + meta_file = self.versions_dir / self.version_id / ("%s.json" % self.version_id) meta_file.write_text(json.dumps(self.version_meta, indent=2), encoding="utf-8") command = self.build_command(classpath) - log("[启动] 启动命令:") - log(" " + " ".join(command[:8]) + " ...") - - log("[启动] 正在启动 Minecraft ...") + self._emit("[启动] 正在启动 Minecraft ...") + self._emit("[启动] " + " ".join(command[:6]) + " ...") return subprocess.call(command, cwd=str(self.game_dir)) - # ------------------------------------------------------------------ - # 辅助功能 - # ------------------------------------------------------------------ - - def list_versions(self) -> None: - """列出可用的发布版与快照版。""" + def list_versions(self) -> dict: manifest = self.fetch_version_manifest() - latest = manifest.get("latest", {}) - log(f"最新发布版: {latest.get('release')} 最新快照版: {latest.get('snapshot')}") - log("-" * 40) - for v in manifest.get("versions", []): - marker = " *" if v["id"] in latest.values() else "" - log(f" {v['id']:<20} {v['type']:<10}{marker}") + return { + "latest": manifest.get("latest", {}), + "versions": manifest.get("versions", []), + } + + +# --------------------------------------------------------------------------- +# 图形界面 (Tkinter) +# --------------------------------------------------------------------------- + +try: + import tkinter as tk + from tkinter import ttk, filedialog, messagebox, scrolledtext + TK_OK = True +except Exception: + TK_OK = False + + +class MeowLauncherApp: + """跨平台图形界面 (Windows / Linux / macOS 外观一致)。""" + + # 深色 Minecraft 风配色 + BG = "#1e1e2e" + CARD = "#2a2a3d" + ACCENT = "#4ade80" + TEXT = "#e2e8f0" + DIM = "#94a3b8" + + def __init__(self): + self.root = tk.Tk() + self.root.title("MeowLauncher - Minecraft 启动器") + self.root.geometry("520x720") + self.root.minsize(480, 640) + self.root.configure(bg=self.BG) + + self.msg_queue = queue.Queue() + self.auth = None + self.yggdrasil = None + self.versions_cache = [] + + self._setup_style() + self._build_ui() + self.root.after(100, self._poll_queue) + self.refresh_versions() + + # ---------------- 样式 ---------------- + + def _setup_style(self): + style = ttk.Style(self.root) + try: + style.theme_use("clam") + except Exception: + pass + style.configure(".", background=self.BG, foreground=self.TEXT, + fieldbackground=self.CARD, font=("Microsoft YaHei UI", 10)) + style.configure("TFrame", background=self.BG) + style.configure("Card.TFrame", background=self.CARD) + style.configure("TLabel", background=self.BG, foreground=self.TEXT) + style.configure("Card.TLabel", background=self.CARD, foreground=self.TEXT) + style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT, + font=("Microsoft YaHei UI", 18, "bold")) + style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM) + style.configure("Accent.TButton", background=self.ACCENT, foreground="#0f172a", + font=("Microsoft YaHei UI", 11, "bold")) + style.map("Accent.TButton", + background=[("active", "#22c55e"), ("pressed", "#16a34a")]) + style.configure("TCombobox", fieldbackground=self.CARD, background=self.CARD, + foreground=self.TEXT, arrowcolor=self.TEXT) + style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT) + style.configure("TCheckbutton", background=self.BG, foreground=self.TEXT) + style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.CARD) + style.configure("TRadiobutton", background=self.BG, foreground=self.TEXT) + + # ---------------- UI 构建 ---------------- + + def _card(self, parent, title): + frame = ttk.Frame(parent, style="Card.TFrame", padding=14) + frame.pack(fill="x", padx=14, pady=(8, 4)) + ttk.Label(frame, text=title, style="Card.TLabel", + font=("Microsoft YaHei UI", 11, "bold")).pack(anchor="w") + return frame + + def _build_ui(self): + # 标题 + ttk.Label(self.root, text="MeowLauncher", style="Title.TLabel").pack(pady=(18, 0)) + ttk.Label(self.root, text="跨平台 Minecraft 启动器", style="Dim.TLabel").pack() + + # ---- 账号登录 ---- + login = self._card(self.root, "账号登录") + self.login_mode = tk.StringVar(value="offline") + row = ttk.Frame(login, style="Card.TFrame") + row.pack(fill="x", pady=4) + ttk.Radiobutton(row, text="离线模式", variable=self.login_mode, value="offline", + command=self._toggle_login).pack(side="left", padx=(0, 16)) + ttk.Radiobutton(row, text="第三方登录 (Yggdrasil)", + variable=self.login_mode, value="yggdrasil", + command=self._toggle_login).pack(side="left") + + self.offline_frame = ttk.Frame(login, style="Card.TFrame") + self.offline_frame.pack(fill="x") + ttk.Label(self.offline_frame, text="玩家名:", style="Card.TLabel").pack(side="left") + self.offline_name = ttk.Entry(self.offline_frame, width=24) + self.offline_name.pack(side="left", padx=8) + + self.ygg_frame = ttk.Frame(login, style="Card.TFrame") + self.ygg_frame.pack(fill="x") + self.ygg_frame.pack_forget() + + ttk.Label(self.ygg_frame, text="皮肤站:", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=2) + self.server_var = tk.StringVar(value="LittleSkin") + self.server_box = ttk.Combobox(self.ygg_frame, textvariable=self.server_var, + values=list(YggdrasilAuth.PRESETS.keys()), state="readonly", width=22) + self.server_box.grid(row=0, column=1, sticky="w", pady=2) + + ttk.Label(self.ygg_frame, text="邮箱/用户名:", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=2) + self.ygg_user = ttk.Entry(self.ygg_frame, width=24) + self.ygg_user.grid(row=1, column=1, sticky="w", pady=2) + + ttk.Label(self.ygg_frame, text="密码:", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=2) + self.ygg_pass = ttk.Entry(self.ygg_frame, width=24, show="*") + self.ygg_pass.grid(row=2, column=1, sticky="w", pady=2) + + ttk.Label(self.ygg_frame, text="支持 LittleSkin 等 authlib-injector 皮肤站", + style="Card.TLabel").grid(row=3, column=0, columnspan=2, sticky="w", pady=2) + + # ---- 游戏版本 ---- + ver = self._card(self.root, "游戏版本") + row = ttk.Frame(ver, style="Card.TFrame") + row.pack(fill="x") + self.version_var = tk.StringVar() + self.version_box = ttk.Combobox(row, textvariable=self.version_var, state="readonly", width=28) + self.version_box.pack(side="left") + ttk.Button(row, text="刷新", command=self.refresh_versions).pack(side="left", padx=8) + self.show_snapshot = tk.BooleanVar(value=False) + ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot, + command=self.refresh_versions).pack(anchor="w", pady=(6, 0)) + + # ---- 启动设置 ---- + cfg = self._card(self.root, "启动设置") + ttk.Label(cfg, text="最大内存 (MB):", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=3) + self.mem_var = tk.StringVar(value="2048") + ttk.Entry(cfg, textvariable=self.mem_var, width=10).grid(row=0, column=1, sticky="w", pady=3) + ttk.Label(cfg, text="Java 路径 (留空自动检测):", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=3) + row = ttk.Frame(cfg, style="Card.TFrame") + row.grid(row=1, column=1, sticky="w", pady=3) + self.java_var = tk.StringVar() + ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left") + ttk.Button(row, text="浏览", command=self._pick_java).pack(side="left", padx=4) + ttk.Label(cfg, text="游戏目录:", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=3) + row = ttk.Frame(cfg, style="Card.TFrame") + row.grid(row=2, column=1, sticky="w", pady=3) + self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft")) + ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left") + ttk.Button(row, text="浏览", command=self._pick_dir).pack(side="left", padx=4) + + # ---- 日志与进度 ---- + log_card = self._card(self.root, "日志") + self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#151521", + fg=self.TEXT, font=("Consolas", 9), + relief="flat", state="disabled") + self.log_box.pack(fill="x") + self.progress = ttk.Progressbar(log_card, mode="determinate", maximum=100) + self.progress.pack(fill="x", pady=(8, 0)) + + # ---- 启动按钮 ---- + self.launch_btn = ttk.Button(self.root, text="启动游戏", style="Accent.TButton", + command=self._on_launch) + self.launch_btn.pack(fill="x", padx=14, pady=14) + + # ---------------- 事件 ---------------- + + def _toggle_login(self): + if self.login_mode.get() == "offline": + self.offline_frame.pack(fill="x") + self.ygg_frame.pack_forget() + else: + self.offline_frame.pack_forget() + self.ygg_frame.pack(fill="x") + + def _pick_java(self): + path = filedialog.askopenfilename(title="选择 java 可执行文件") + if path: + self.java_var.set(path) + + def _pick_dir(self): + path = filedialog.askdirectory(title="选择游戏目录") + if path: + self.dir_var.set(path) + + def refresh_versions(self): + def worker(): + try: + data = MinecraftLauncher( + game_dir=self.dir_var.get(), version_id="latest-release", + username="x").list_versions() + self.msg_queue.put(("versions", data)) + except Exception as e: + self.msg_queue.put(("log", "[版本] 获取失败: %s" % e)) + threading.Thread(target=worker, daemon=True).start() + + def _poll_queue(self): + try: + while True: + kind, payload = self.msg_queue.get_nowait() + if kind == "log": + self._append_log(payload) + elif kind == "versions": + self.versions_cache = payload["versions"] + self._fill_versions(payload["latest"]) + elif kind == "done": + self.launch_btn.config(state="normal") + elif kind == "err": + self._append_log("[错误] %s" % payload) + self.launch_btn.config(state="normal") + except queue.Empty: + pass + self.root.after(100, self._poll_queue) + + def _fill_versions(self, latest): + show_snap = self.show_snapshot.get() + items = [v["id"] for v in self.versions_cache + if show_snap or v["type"] == "release"] + items = list(dict.fromkeys(items)) + self.version_box["values"] = items + self.version_var.set(latest.get("release") or (items[0] if items else "")) + + def _append_log(self, msg): + self.log_box.config(state="normal") + self.log_box.insert("end", msg + "\n") + self.log_box.see("end") + self.log_box.config(state="disabled") + + def _on_launch(self): + self.launch_btn.config(state="disabled") + threading.Thread(target=self._launch_worker, daemon=True).start() + + def _launch_worker(self): + try: + if self.login_mode.get() == "yggdrasil": + server_name = self.server_var.get() + server_url = YggdrasilAuth.PRESETS.get(server_name, "") + if not server_url: + raise RuntimeError("请选择皮肤站") + user = self.ygg_user.get().strip() + pwd = self.ygg_pass.get() + if not user or not pwd: + raise RuntimeError("请输入账号和密码") + self._append_log("[登录] 正在连接 %s ..." % server_name) + self.yggdrasil = YggdrasilAuth(server_url) + self.auth = self.yggdrasil.authenticate(user, pwd) + self._append_log("[登录] 成功! 角色: %s" % self.auth["name"]) + else: + self.auth = {"name": self.offline_name.get().strip() or "Steve"} + self.yggdrasil = None + + version = self.version_var.get() + if not version: + raise RuntimeError("请选择游戏版本") + + launcher = MinecraftLauncher( + game_dir=self.dir_var.get(), + version_id=version, + username=self.auth.get("name", "Steve"), + java_path=self.java_var.get() or None, + max_mem=int(self.mem_var.get() or 2048), + auth=self.auth, + yggdrasil=self.yggdrasil, + progress_cb=self._append_log, + ) + code = launcher.run() + self.msg_queue.put(("log", "[退出] Minecraft 已退出 (代码 %s)" % code)) + except Exception as e: + self.msg_queue.put(("err", str(e))) + finally: + self.msg_queue.put(("done", None)) # --------------------------------------------------------------------------- @@ -478,44 +740,47 @@ class MinecraftLauncher: # --------------------------------------------------------------------------- def main() -> int: - parser = argparse.ArgumentParser( - description="MeowLauncher - 纯 Python 实现的 Minecraft 启动器", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--version", default="latest-release", help="游戏版本, 如 1.20.4 / latest-release / latest-snapshot") - parser.add_argument("--username", default="Steve", help="玩家名 (离线模式)") - parser.add_argument("--game-dir", default=None, help="游戏目录 (默认 ~/.minecraft)") - parser.add_argument("--java", default=None, help="Java 可执行文件路径") - parser.add_argument("--min-mem", type=int, default=1024, help="最小内存 MB (默认 1024)") - parser.add_argument("--max-mem", type=int, default=2048, help="最大内存 MB (默认 2048)") + parser = argparse.ArgumentParser(description="MeowLauncher - Minecraft 启动器") + parser.add_argument("--cli", action="store_true", help="使用命令行模式") + parser.add_argument("--version", default="latest-release", help="游戏版本") + parser.add_argument("--username", default="Steve", help="玩家名") + parser.add_argument("--game-dir", default=None, help="游戏目录") + parser.add_argument("--java", default=None, help="Java 路径") + parser.add_argument("--min-mem", type=int, default=1024, help="最小内存 MB") + parser.add_argument("--max-mem", type=int, default=2048, help="最大内存 MB") parser.add_argument("--list-versions", action="store_true", help="列出可用版本") args = parser.parse_args() + if not args.cli and TK_OK: + app = MeowLauncherApp() + app.root.mainloop() + return 0 + + # 命令行模式 if args.list_versions: launcher = MinecraftLauncher( game_dir=args.game_dir or str(Path.home() / ".minecraft"), - version_id=args.version, - username=args.username, - ) - launcher.list_versions() + version_id=args.version, username=args.username) + data = launcher.list_versions() + print("最新发布版: %s 最新快照版: %s" % (data["latest"].get("release"), + data["latest"].get("snapshot"))) + for v in data["versions"]: + marker = " *" if v["id"] in data["latest"].values() else "" + print(" %-20s %-10s%s" % (v["id"], v["type"], marker)) return 0 launcher = MinecraftLauncher( game_dir=args.game_dir or str(Path.home() / ".minecraft"), - version_id=args.version, - username=args.username, - java_path=args.java, - min_mem=args.min_mem, - max_mem=args.max_mem, - offline=True, - ) + version_id=args.version, username=args.username, + java_path=args.java, min_mem=args.min_mem, max_mem=args.max_mem, + auth={"name": args.username}) try: return launcher.run() except KeyboardInterrupt: - log("\n[退出] 用户中断") + print("\n[退出] 用户中断") return 130 except Exception as e: - log(f"[错误] {e}") + print("[错误] %s" % e) return 1 diff --git a/launcher_cli.py b/launcher_cli.py new file mode 100644 index 0000000..f74d492 --- /dev/null +++ b/launcher_cli.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +MeowLauncher - 一个使用纯 Python 标准库实现的 Minecraft 启动器 + +功能特性: + - 自动获取官方版本清单并下载任意版本 + - 自动下载游戏依赖库 (libraries) 与原生库 (natives) + - 自动下载资源文件 (assets) + - 支持离线账号与正版账号 (预留 Yggdrasil 接口) + - 跨平台: Windows / Linux / macOS + - 零第三方依赖, 仅使用 Python 标准库 + +用法示例: + python3 launcher.py --username Steve --version 1.20.4 + python3 launcher.py --username Steve --version latest-release --max-mem 4096 + python3 launcher.py --list-versions +""" + +import argparse +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +# --------------------------------------------------------------------------- +# 常量 +# --------------------------------------------------------------------------- + +VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" +USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.0)" + +# 操作系统名称映射 (Mojang 规则中的 os.name) +OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux") + +# 架构映射: 兼容新旧两套规则 +_ARCH = platform.machine().lower() +if _ARCH in ("x86_64", "amd64"): + ARCH_NEW = "x86_64" # 新版 (1.13+) 使用的 arch 值 + ARCH_OLD = "64" # 旧版使用的 arch 值 +elif _ARCH in ("aarch64", "arm64"): + ARCH_NEW = "arm64" + ARCH_OLD = "arm64" +else: + ARCH_NEW = "x86" + ARCH_OLD = "32" + +# 原生库分类器 (Mojang 命名) +NATIVES_CLASSIFIER = { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows", +} + + +def log(msg: str) -> None: + """简单的彩色日志输出。""" + print(msg, flush=True) + + +def sha1_of(path: Path) -> str: + """计算文件的 SHA1 校验值。""" + h = hashlib.sha1() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def http_get(url: str) -> bytes: + """GET 请求并返回字节内容。""" + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read() + + +def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> bool: + """ + 下载文件到指定路径。 + - 若目标已存在且校验通过 (或未提供校验值), 则跳过。 + - 下载后校验 SHA1, 失败则删除并抛异常。 + 返回 True 表示本次实际下载, False 表示已存在跳过。 + """ + dest.parent.mkdir(parents=True, exist_ok=True) + + if dest.exists(): + ok = True + if sha1: + ok = sha1_of(dest) == sha1 + if ok: + return False + dest.unlink() + + tmp = dest.with_suffix(dest.suffix + ".tmp") + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as f: + shutil.copyfileobj(resp, f, 1024 * 1024) + except Exception: + if tmp.exists(): + tmp.unlink() + raise + + if sha1 and sha1_of(tmp) != sha1: + tmp.unlink() + raise RuntimeError(f"SHA1 校验失败: {url}") + + if size and tmp.stat().st_size != size: + tmp.unlink() + raise RuntimeError(f"文件大小不符: {url}") + + tmp.rename(dest) + return True + + +def rule_allowed(rule: dict) -> bool: + """判断 Mojang 下载规则 (rules) 是否允许当前平台。""" + if rule.get("action") == "allow": + default = False + else: # disallow + default = True + + os_rule = rule.get("os", {}) + if "name" in os_rule and os_rule["name"] != OS_NAME: + return default + if "arch" in os_rule: + arch = os_rule["arch"] + if arch not in (ARCH_NEW, ARCH_OLD): + return default + return not default + + +def library_allowed(lib: dict) -> bool: + """判断某个库是否允许当前平台下载。""" + rules = lib.get("rules") + if not rules: + return True + return all(rule_allowed(r) for r in rules) + + +# --------------------------------------------------------------------------- +# Minecraft 启动器 +# --------------------------------------------------------------------------- + +class MinecraftLauncher: + def __init__( + self, + game_dir: str, + version_id: str, + username: str, + java_path: str = None, + min_mem: int = 1024, + max_mem: int = 2048, + auth: dict = None, + offline: bool = True, + ): + self.game_dir = Path(game_dir).resolve() + self.version_id = version_id + self.username = username or "Steve" + self.java_path = java_path + self.min_mem = min_mem + self.max_mem = max_mem + self.auth = auth or {} # 正版登录信息: {access_token, uuid, name} + self.offline = offline + + # 版本相关目录 + self.versions_dir = self.game_dir / "versions" + self.assets_dir = self.game_dir / "assets" + self.libraries_dir = self.game_dir / "libraries" + + # 版本信息 (解析后填充) + self.version_meta = None + self.version_url = None + self.asset_index = None + self.client_jar = None + + # ------------------------------------------------------------------ + # 版本解析 + # ------------------------------------------------------------------ + + def fetch_version_manifest(self) -> dict: + """获取官方版本清单。""" + data = json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8")) + return data + + def resolve_version(self) -> None: + """ + 解析版本号 -> 版本元数据。 + 支持: latest-release / latest-snapshot / 任意版本 id。 + """ + manifest = self.fetch_version_manifest() + latest = manifest.get("latest", {}) + versions = manifest.get("versions", []) + + if self.version_id == "latest-release": + self.version_id = latest.get("release") + elif self.version_id == "latest-snapshot": + self.version_id = latest.get("snapshot") + + entry = next((v for v in versions if v["id"] == self.version_id), None) + if not entry: + raise RuntimeError(f"找不到版本: {self.version_id}") + + self.version_url = entry["url"] + self.version_meta = json.loads(http_get(self.version_url).decode("utf-8")) + log(f"[版本] 已解析: {self.version_meta.get('id')} " + f"(release={self.version_meta.get('type')}, " + f"java={self.version_meta.get('javaVersion', {}).get('majorVersion', '?')})") + + # ------------------------------------------------------------------ + # 下载: 客户端 / 库 / 资源 + # ------------------------------------------------------------------ + + def download_client(self) -> Path: + """下载客户端 jar 文件。""" + vdir = self.versions_dir / self.version_id + vdir.mkdir(parents=True, exist_ok=True) + jar = vdir / f"{self.version_id}.jar" + + dl = self.version_meta.get("downloads", {}).get("client") + if dl: + download_file(dl["url"], jar, dl.get("sha1"), dl.get("size")) + else: + raise RuntimeError("版本清单中缺少 client 下载项") + + self.client_jar = jar + log(f"[客户端] {jar.name} 就绪 ({jar.stat().st_size // 1024} KB)") + return jar + + def _download_library_artifact(self, artifact: dict, lib_path: Path) -> None: + """下载单个库文件。""" + if not artifact: + return + download_file( + artifact["url"], + lib_path, + artifact.get("sha1"), + artifact.get("size"), + ) + + def download_libraries(self) -> list: + """下载所有依赖库, 返回 classpath 列表。""" + cp = [] + for lib in self.version_meta.get("libraries", []): + if not library_allowed(lib): + continue + + downloads = lib.get("downloads", {}) + artifact = downloads.get("artifact") + + # 处理原生库 (natives) + classifier = NATIVES_CLASSIFIER.get(OS_NAME) + native = None + natives_entry = downloads.get("classifiers", {}).get(classifier) if classifier else None + + if artifact: + path = Path(lib["name"].replace(":", "/").replace("@", "/")) + path = self.libraries_dir / path + self._download_library_artifact(artifact, path) + cp.append(str(path)) + + if natives_entry: + path = Path(lib["name"].replace(":", "/").replace("@", "/")) + path = self.libraries_dir / path + self._download_library_artifact(natives_entry, path) + native = path + + if native: + self._extract_natives(native) + + log(f"[库] 依赖库准备完成, classpath 共 {len(cp)} 项") + return cp + + def _extract_natives(self, native_jar: Path) -> None: + """解压原生库到 versions//natives 目录。""" + natives_dir = self.versions_dir / self.version_id / "natives" + natives_dir.mkdir(parents=True, exist_ok=True) + + ext_map = {"linux": ".so", "osx": ".dylib", "windows": ".dll"} + ext = ext_map[OS_NAME] + + with zipfile.ZipFile(native_jar) as zf: + for name in zf.namelist(): + # 跳过 META-INF 与源码/javadoc + if name.startswith("META-INF") or not name.endswith(ext): + continue + target = natives_dir / Path(name).name + with zf.open(name) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + + def download_assets(self) -> None: + """下载资源索引与全部资源对象。""" + index_info = self.version_meta.get("assetIndex") + if not index_info: + log("[资源] 该版本无资源索引, 跳过") + return + + index_file = self.assets_dir / "indexes" / f"{index_info['id']}.json" + download_file(index_info["url"], index_file, index_info.get("sha1"), index_info.get("size")) + + index = json.loads(index_file.read_text(encoding="utf-8")) + objects = index.get("objects", {}) + self.asset_index = index_info["id"] + + total = len(objects) + done = 0 + for key, obj in objects.items(): + h = obj["hash"] + dest = self.assets_dir / "objects" / h[:2] / h + url = f"https://resources.download.minecraft.net/{h[:2]}/{h}" + try: + download_file(url, dest, h, obj.get("size")) + except Exception as e: + log(f" [警告] 资源下载失败 {key}: {e}") + done += 1 + if done % 200 == 0: + log(f" [资源] {done}/{total}") + + log(f"[资源] 资源文件就绪 ({total} 项)") + + # ------------------------------------------------------------------ + # 启动参数 + # ------------------------------------------------------------------ + + def _resolve_argument(self, arg: str) -> str: + """替换启动参数中的占位符。""" + uuid = self._offline_uuid() + replace_map = { + "${auth_player_name}": self.auth.get("name") or self.username, + "${auth_session}": self.auth.get("access_token", "0"), + "${auth_access_token}": self.auth.get("access_token", "0"), + "${auth_uuid}": self.auth.get("uuid") or uuid, + "${version_name}": self.version_id, + "${version_type}": self.version_meta.get("type", "release"), + "${game_directory}": str(self.game_dir), + "${assets_root}": str(self.assets_dir), + "${assets_index_name}": self.asset_index or self.version_id, + "${user_properties}": "{}", + "${user_type}": "msa" if not self.offline else "legacy", + "${resolution_width}": "854", + "${resolution_height}": "480", + "${launcher_name}": "MeowLauncher", + "${launcher_version}": "1.0", + "${natives_directory}": str(self.versions_dir / self.version_id / "natives"), + "${classpath}": os.pathsep.join([str(self.client_jar)] + self._cp), + "${library_directory}": str(self.libraries_dir), + } + for k, v in replace_map.items(): + arg = arg.replace(k, v) + return arg + + def _offline_uuid(self) -> str: + """根据离线用户名生成稳定的 UUID (与官方离线模式一致)。""" + name = self.auth.get("name") or self.username + digest = hashlib.md5(f"OfflinePlayer:{name}".encode("utf-8")).digest() + digest = bytearray(digest) + digest[6] = (digest[6] & 0x0F) | 0x30 # version 3 + digest[8] = (digest[8] & 0x3F) | 0x80 # variant + hex_str = digest.hex() + return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}" + + def build_command(self, classpath: list) -> list: + """构建完整的 java 启动命令。""" + self._cp = classpath + java = self._find_java() + + jvm_args, game_args = self._build_args() + + command = [java] + command += ["-Xms{}M".format(self.min_mem), "-Xmx{}M".format(self.max_mem)] + command += ["-Djava.library.path={}".format(self.versions_dir / self.version_id / "natives")] + command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.0"] + command += ["-cp", os.pathsep.join([str(self.client_jar)] + classpath)] + command += jvm_args + command += ["net.minecraft.client.main.Main"] + command += game_args + return command + + def _build_args(self) -> tuple: + """拆分并解析 jvm 参数与游戏参数 (兼容新旧版本格式)。""" + jvm_args = [] + game_args = [] + arguments = self.version_meta.get("arguments") + + if arguments: # 1.13+ 新格式 + for item in arguments.get("jvm", []): + if isinstance(item, str): + jvm_args.append(self._resolve_argument(item)) + elif isinstance(item, dict): + if all(rule_allowed(r) for r in item.get("rules", [])): + for v in item.get("value", []): + jvm_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) + + for item in arguments.get("game", []): + if isinstance(item, str): + game_args.append(self._resolve_argument(item)) + elif isinstance(item, dict): + if all(rule_allowed(r) for r in item.get("rules", [])): + for v in item.get("value", []): + game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v)) + else: # 1.12 及以前旧格式 + legacy = self.version_meta.get("minecraftArguments", "") + game_args = [self._resolve_argument(a) for a in legacy.split(" ")] + + return jvm_args, game_args + + def _find_java(self) -> str: + """查找 Java 可执行文件: 优先用户指定, 其次 JAVA_HOME, 最后 PATH。""" + if self.java_path and os.path.isfile(self.java_path): + return self.java_path + + java_home = os.environ.get("JAVA_HOME") + if java_home: + cand = Path(java_home) / "bin" / ("java.exe" if OS_NAME == "windows" else "java") + if cand.exists(): + return str(cand) + + for cand in ("java", "java.exe"): + found = shutil.which(cand) + if found: + return found + + raise RuntimeError( + "未找到 Java, 请使用 --java 参数指定 java 可执行文件路径 " + "(Minecraft 1.17+ 需要 Java 17+)" + ) + + # ------------------------------------------------------------------ + # 主流程 + # ------------------------------------------------------------------ + + def run(self) -> int: + """完整流程: 解析 -> 下载 -> 启动。""" + log(f"[启动] 游戏目录: {self.game_dir}") + log(f"[启动] 版本: {self.version_id} 玩家: {self.username}") + + self.resolve_version() + self.download_client() + classpath = self.download_libraries() + self.download_assets() + + # 保存版本元数据到本地 (供后续复用) + meta_file = self.versions_dir / self.version_id / f"{self.version_id}.json" + meta_file.write_text(json.dumps(self.version_meta, indent=2), encoding="utf-8") + + command = self.build_command(classpath) + log("[启动] 启动命令:") + log(" " + " ".join(command[:8]) + " ...") + + log("[启动] 正在启动 Minecraft ...") + return subprocess.call(command, cwd=str(self.game_dir)) + + # ------------------------------------------------------------------ + # 辅助功能 + # ------------------------------------------------------------------ + + def list_versions(self) -> None: + """列出可用的发布版与快照版。""" + manifest = self.fetch_version_manifest() + latest = manifest.get("latest", {}) + log(f"最新发布版: {latest.get('release')} 最新快照版: {latest.get('snapshot')}") + log("-" * 40) + for v in manifest.get("versions", []): + marker = " *" if v["id"] in latest.values() else "" + log(f" {v['id']:<20} {v['type']:<10}{marker}") + + +# --------------------------------------------------------------------------- +# 入口 +# --------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser( + description="MeowLauncher - 纯 Python 实现的 Minecraft 启动器", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", default="latest-release", help="游戏版本, 如 1.20.4 / latest-release / latest-snapshot") + parser.add_argument("--username", default="Steve", help="玩家名 (离线模式)") + parser.add_argument("--game-dir", default=None, help="游戏目录 (默认 ~/.minecraft)") + parser.add_argument("--java", default=None, help="Java 可执行文件路径") + parser.add_argument("--min-mem", type=int, default=1024, help="最小内存 MB (默认 1024)") + parser.add_argument("--max-mem", type=int, default=2048, help="最大内存 MB (默认 2048)") + parser.add_argument("--list-versions", action="store_true", help="列出可用版本") + args = parser.parse_args() + + if args.list_versions: + launcher = MinecraftLauncher( + game_dir=args.game_dir or str(Path.home() / ".minecraft"), + version_id=args.version, + username=args.username, + ) + launcher.list_versions() + return 0 + + launcher = MinecraftLauncher( + game_dir=args.game_dir or str(Path.home() / ".minecraft"), + version_id=args.version, + username=args.username, + java_path=args.java, + min_mem=args.min_mem, + max_mem=args.max_mem, + offline=True, + ) + try: + return launcher.run() + except KeyboardInterrupt: + log("\n[退出] 用户中断") + return 130 + except Exception as e: + log(f"[错误] {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main())