Files
MeowLauncher/launcher_cli.py
T
MCLauncher d5285ee2ff
Build Installers / Build Linux (push) Has been cancelled
Build Installers / Build Windows (push) Has been cancelled
Build Installers / Build macOS (push) Has been cancelled
Build Installers / 发布 Release (push) Has been cancelled
feat: v1.2 图形界面 + 第三方登录
- 全新Tkinter图形界面, 三端UI一致
- 支持LittleSkin等authlib-injector第三方登录
- 自动下载注入authlib-injector.jar
- 保留CLI模式, Windows打包改为无控制台窗口
2026-08-05 23:20:50 +08:00

524 lines
19 KiB
Python

#!/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/<id>/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())