""" MeowLauncher 命令行版 (旧版备份) 功能已被 launcher.py 的图形界面版完全覆盖, 保留此文件供命令行使用: python launcher_cli.py --username Steve --version 1.20.4 """ 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)" 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" ARCH_OLD = "64" elif _ARCH in ("aarch64", "arm64"): ARCH_NEW = "arm64" ARCH_OLD = "arm64" else: ARCH_NEW = "x86" ARCH_OLD = "32" 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: 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: 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: 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: if rule.get("action") == "allow": default = False 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 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) 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 {} 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: 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: 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: cp = [] for lib in self.version_meta.get("libraries", []): if not library_allowed(lib): continue downloads = lib.get("downloads", {}) artifact = downloads.get("artifact") 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: 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(): 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: 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 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]}" def build_command(self, classpath: list) -> list: 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_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): 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: 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 参数指定 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())