Files
MeowLauncher/launcher_cli.py
T
MCLauncher b6e67db02f docs: 为源码添加完整中文注释
- 文件头说明项目功能与用法
- 每个类前注释模块职责
- 每个方法添加docstring说明用途
- 方便开源社区开发者理解和使用
2026-08-06 01:53:03 +08:00

485 lines
16 KiB
Python

"""
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())