485 lines
16 KiB
Python
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())
|