feat: v1.2 图形界面 + 第三方登录
- 全新Tkinter图形界面, 三端UI一致 - 支持LittleSkin等authlib-injector第三方登录 - 自动下载注入authlib-injector.jar - 保留CLI模式, Windows打包改为无控制台窗口
This commit is contained in:
1 parent
1d1ffe53f6
commit
d5285ee2ff
4 files changed
+1080
-285
No files matched your search
@@ -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
|
||||
|
||||
@@ -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/<id>/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/<id>/ # 版本 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 认证。
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
||||
+517
-252
File diff suppressed because it is too large.
Load diff
+523
@@ -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/<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())
|
||||
Reference in new issue
Block a user