Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f13890da5b | ||
|
|
b6e67db02f | ||
|
|
e4cc14cb9e | ||
|
|
8823b9218d | ||
|
|
84bf271447 |
No files matched your search
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 guiyuanit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -5,6 +5,7 @@
|
||||
## 功能特性
|
||||
|
||||
- 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致
|
||||
- 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth)
|
||||
- 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议)
|
||||
- 🎭 离线模式:自动生成与官方一致的离线 UUID
|
||||
- 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`)
|
||||
|
||||
+290
-59
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
|
||||
"""
|
||||
MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
|
||||
|
||||
功能特性:
|
||||
- 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致
|
||||
- 支持离线账号与第三方 Yggdrasil 登录 (authlib-injector, 如 LittleSkin)
|
||||
- 自动获取官方版本清单并下载任意版本
|
||||
- 离线账号与第三方 Yggdrasil 登录 (LittleSkin 等 authlib-injector 皮肤站)
|
||||
- 资源中心: 启动器内一键下载游戏版本 / Mod / 插件 (Modrinth 开放 API)
|
||||
- 自动下载依赖库 / 原生库 / 资源文件, SHA1 校验 + 断点续传
|
||||
- 保留命令行模式: python launcher.py --cli
|
||||
|
||||
@@ -14,7 +15,6 @@ MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
|
||||
python launcher.py 启动图形界面
|
||||
python launcher.py --cli ... 命令行模式
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
@@ -27,18 +27,20 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid as uuid_mod
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
|
||||
AUTHLIB_INJECTOR_API = "https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest"
|
||||
USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.2)"
|
||||
VERSION = "1.2.926"
|
||||
USER_AGENT = "Mozilla/5.0 (MeowLauncher/%s)" % VERSION
|
||||
|
||||
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux")
|
||||
|
||||
@@ -145,12 +147,60 @@ def library_allowed(lib: dict) -> bool:
|
||||
return all(rule_allowed(r) for r in rules)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第三方登录 (Yggdrasil / authlib-injector)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modrinth 资源站客户端
|
||||
# 用于在启动器内搜索和下载 Mod / 插件
|
||||
# 使用官方开放 API (https://api.modrinth.com), 无需注册密钥
|
||||
# ---------------------------------------------------------------------------
|
||||
class ModrinthClient:
|
||||
BASE = "https://api.modrinth.com/v2"
|
||||
|
||||
def search(self, query="", game_versions=None, loaders=None, project_type=None, limit=20):
|
||||
"""搜索 Mod/插件项目。query 为关键词, game_versions 过滤MC版本, loaders 过滤加载器, project_type 为 mod 或 plugin。"""
|
||||
facets = []
|
||||
if game_versions:
|
||||
facets.append(["versions:%s" % game_versions])
|
||||
if loaders:
|
||||
facets.append(["categories:%s" % loaders])
|
||||
if project_type:
|
||||
facets.append(["project_type:%s" % project_type])
|
||||
url = "%s/search?query=%s&limit=%d" % (self.BASE, urllib.parse.quote(query), limit)
|
||||
if facets:
|
||||
url += "&facets=" + urllib.parse.quote(json.dumps(facets))
|
||||
data = json.loads(http_get(url).decode("utf-8"))
|
||||
return data.get("hits", [])
|
||||
|
||||
def project_versions(self, project_id, game_version=None, loader=None):
|
||||
"""获取某个项目适配指定 MC 版本与加载器的版本列表。"""
|
||||
url = "%s/project/%s/version" % (self.BASE, project_id)
|
||||
params = []
|
||||
if game_version:
|
||||
params.append("game_versions=%s" % urllib.parse.quote(json.dumps([game_version])))
|
||||
if loader:
|
||||
params.append("loaders=%s" % urllib.parse.quote(json.dumps([loader])))
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
return json.loads(http_get(url).decode("utf-8"))
|
||||
|
||||
def first_file(self, project_id, game_version, loader):
|
||||
"""获取第一个可下载的 jar 文件 (含下载地址/文件名/大小)。"""
|
||||
versions = self.project_versions(project_id, game_version, loader)
|
||||
for v in versions:
|
||||
for f in v.get("files", []):
|
||||
if f.get("url"):
|
||||
return f
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第三方登录 (Yggdrasil / authlib-injector 协议)
|
||||
# 支持 LittleSkin、Blessing Skin 等皮肤站, 登录获取正版角色信息
|
||||
# ---------------------------------------------------------------------------
|
||||
class YggdrasilAuth:
|
||||
"""authlib-injector 兼容的第三方登录 (LittleSkin 等皮肤站)。"""
|
||||
|
||||
PRESETS = {
|
||||
"LittleSkin": "https://littleskin.cn/api/yggdrasil",
|
||||
@@ -159,10 +209,11 @@ class YggdrasilAuth:
|
||||
}
|
||||
|
||||
def __init__(self, server_url: str):
|
||||
"""初始化窗口、配色与资源中心状态。"""
|
||||
self.server_url = server_url.rstrip("/")
|
||||
|
||||
def authenticate(self, username: str, password: str) -> dict:
|
||||
"""登录并返回 {access_token, uuid, name, client_token}。"""
|
||||
"""登录第三方皮肤站, 返回 {access_token, uuid, name} 账号信息。"""
|
||||
client_token = str(uuid_mod.uuid4())
|
||||
payload = {
|
||||
"agent": {"name": "Minecraft", "version": 1},
|
||||
@@ -183,7 +234,7 @@ class YggdrasilAuth:
|
||||
}
|
||||
|
||||
def download_injector(self, game_dir: Path) -> Path:
|
||||
"""下载最新版 authlib-injector.jar, 返回路径。"""
|
||||
"""下载最新版 authlib-injector.jar, 启动游戏时注入用。"""
|
||||
lib_dir = game_dir / "libraries" / "authlib-injector"
|
||||
lib_dir.mkdir(parents=True, exist_ok=True)
|
||||
jar = lib_dir / "authlib-injector.jar"
|
||||
@@ -199,10 +250,14 @@ class YggdrasilAuth:
|
||||
return jar
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 核心启动器
|
||||
# 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 Java 进程
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MinecraftLauncher:
|
||||
def __init__(self, game_dir, version_id, username="Steve", java_path=None,
|
||||
min_mem=1024, max_mem=2048, auth=None, yggdrasil=None,
|
||||
@@ -214,8 +269,8 @@ class MinecraftLauncher:
|
||||
self.min_mem = min_mem
|
||||
self.max_mem = max_mem
|
||||
self.auth = auth or {}
|
||||
self.yggdrasil = yggdrasil # YggdrasilAuth 实例或 None
|
||||
self.progress_cb = progress_cb # callback(msg) 日志; callback2(done,total) 进度
|
||||
self.yggdrasil = yggdrasil
|
||||
self.progress_cb = progress_cb
|
||||
|
||||
self.versions_dir = self.game_dir / "versions"
|
||||
self.assets_dir = self.game_dir / "assets"
|
||||
@@ -226,12 +281,14 @@ class MinecraftLauncher:
|
||||
self.client_jar = None
|
||||
self._cp = []
|
||||
|
||||
# ---------------- 版本 ----------------
|
||||
|
||||
|
||||
def fetch_version_manifest(self) -> dict:
|
||||
"""拉取 Mojang 官方版本清单。"""
|
||||
return json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
|
||||
|
||||
def resolve_version(self) -> None:
|
||||
"""把版本号解析成版本元数据, 支持 latest-release / latest-snapshot。"""
|
||||
manifest = self.fetch_version_manifest()
|
||||
latest = manifest.get("latest", {})
|
||||
versions = manifest.get("versions", [])
|
||||
@@ -251,14 +308,16 @@ class MinecraftLauncher:
|
||||
self.version_meta.get("type"),
|
||||
self.version_meta.get("javaVersion", {}).get("majorVersion", "?")))
|
||||
|
||||
# ---------------- 下载 ----------------
|
||||
|
||||
|
||||
def _emit(self, msg: str) -> None:
|
||||
"""输出日志: 同时打印到控制台和回调 GUI。"""
|
||||
log(msg)
|
||||
if self.progress_cb:
|
||||
self.progress_cb(msg)
|
||||
|
||||
def download_client(self) -> Path:
|
||||
"""下载客户端 jar 文件到 versions/<id>/。"""
|
||||
vdir = self.versions_dir / self.version_id
|
||||
vdir.mkdir(parents=True, exist_ok=True)
|
||||
jar = vdir / ("%s.jar" % self.version_id)
|
||||
@@ -271,6 +330,7 @@ class MinecraftLauncher:
|
||||
return jar
|
||||
|
||||
def download_libraries(self) -> list:
|
||||
"""下载全部依赖库与原生库, 返回 classpath 列表。"""
|
||||
cp = []
|
||||
total = sum(1 for lib in self.version_meta.get("libraries", []) if library_allowed(lib))
|
||||
done = 0
|
||||
@@ -300,12 +360,14 @@ class MinecraftLauncher:
|
||||
return cp
|
||||
|
||||
def _download_one(self, artifact, path, tag, done, total):
|
||||
"""下载单个库文件, 失败只警告不中断。"""
|
||||
try:
|
||||
download_file(artifact["url"], path, artifact.get("sha1"), artifact.get("size"))
|
||||
except Exception as e:
|
||||
self._emit(" [警告] %s 下载失败: %s" % (path.name, e))
|
||||
|
||||
def _extract_natives(self, native_jar: Path) -> None:
|
||||
"""把原生库 (natives) 解压到 versions/<id>/natives 目录。"""
|
||||
natives_dir = self.versions_dir / self.version_id / "natives"
|
||||
natives_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = {"linux": ".so", "osx": ".dylib", "windows": ".dll"}[OS_NAME]
|
||||
@@ -318,6 +380,7 @@ class MinecraftLauncher:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
def download_assets(self) -> None:
|
||||
"""下载资源索引与全部资源对象到 assets/。"""
|
||||
index_info = self.version_meta.get("assetIndex")
|
||||
if not index_info:
|
||||
self._emit("[资源] 该版本无资源索引, 跳过")
|
||||
@@ -343,9 +406,10 @@ class MinecraftLauncher:
|
||||
self._emit(" [资源] %d/%d" % (done, total))
|
||||
self._emit("[资源] 资源文件就绪 (%d 项)" % total)
|
||||
|
||||
# ---------------- 启动参数 ----------------
|
||||
|
||||
|
||||
def _resolve_argument(self, arg: str) -> str:
|
||||
"""替换启动参数里的 ${变量} 占位符。"""
|
||||
uuid = self.auth.get("uuid") or self._offline_uuid()
|
||||
replace_map = {
|
||||
"${auth_player_name}": self.auth.get("name") or self.username,
|
||||
@@ -372,6 +436,7 @@ class MinecraftLauncher:
|
||||
return arg
|
||||
|
||||
def _offline_uuid(self) -> str:
|
||||
"""按官方离线模式规则, 由玩家名生成稳定 UUID。"""
|
||||
name = self.auth.get("name") or self.username
|
||||
digest = bytearray(hashlib.md5(("OfflinePlayer:" + name).encode("utf-8")).digest())
|
||||
digest[6] = (digest[6] & 0x0F) | 0x30
|
||||
@@ -381,6 +446,7 @@ class MinecraftLauncher:
|
||||
hex_str[16:20], hex_str[20:32])
|
||||
|
||||
def _build_args(self) -> tuple:
|
||||
"""拆分 JVM 参数与游戏参数, 兼容 1.13+ 与旧版两种格式。"""
|
||||
jvm_args, game_args = [], []
|
||||
arguments = self.version_meta.get("arguments")
|
||||
if arguments:
|
||||
@@ -402,6 +468,7 @@ class MinecraftLauncher:
|
||||
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")
|
||||
@@ -416,6 +483,7 @@ class MinecraftLauncher:
|
||||
raise RuntimeError("未找到 Java, 请在设置中指定 java 可执行文件路径 (1.17+ 需要 Java 17+)")
|
||||
|
||||
def build_command(self, classpath: list) -> list:
|
||||
"""组装完整 java 启动命令, 含第三方登录时注入 authlib-injector。"""
|
||||
self._cp = classpath
|
||||
java = self._find_java()
|
||||
jvm_args, game_args = self._build_args()
|
||||
@@ -425,7 +493,7 @@ class MinecraftLauncher:
|
||||
command += ["-Djava.library.path=%s" % (self.versions_dir / self.version_id / "natives")]
|
||||
command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.2"]
|
||||
|
||||
# 第三方登录: 注入 authlib-injector
|
||||
|
||||
if self.yggdrasil:
|
||||
injector = self.yggdrasil.download_injector(self.game_dir)
|
||||
command += ["-javaagent:%s=%s" % (injector, self.yggdrasil.server_url)]
|
||||
@@ -436,9 +504,10 @@ class MinecraftLauncher:
|
||||
command += game_args
|
||||
return command
|
||||
|
||||
# ---------------- 主流程 ----------------
|
||||
|
||||
|
||||
def run(self) -> int:
|
||||
"""主流程: 解析版本 -> 下载文件 -> 构建命令 -> 启动游戏。"""
|
||||
self._emit("[启动] 游戏目录: %s" % self.game_dir)
|
||||
self._emit("[启动] 版本: %s 玩家: %s" % (self.version_id,
|
||||
self.auth.get("name") or self.username))
|
||||
@@ -457,6 +526,7 @@ class MinecraftLauncher:
|
||||
return subprocess.call(command, cwd=str(self.game_dir))
|
||||
|
||||
def list_versions(self) -> dict:
|
||||
"""获取版本清单 (供 GUI 和 CLI 使用)。"""
|
||||
manifest = self.fetch_version_manifest()
|
||||
return {
|
||||
"latest": manifest.get("latest", {}),
|
||||
@@ -464,9 +534,9 @@ class MinecraftLauncher:
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 图形界面 (Tkinter)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
@@ -476,10 +546,13 @@ except Exception:
|
||||
TK_OK = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 图形界面 (Tkinter)
|
||||
# 两个标签页: [启动器] 登录/版本/设置/日志, [资源中心] 版本与Mod插件下载
|
||||
# ---------------------------------------------------------------------------
|
||||
class MeowLauncherApp:
|
||||
"""跨平台图形界面 (Windows / Linux / macOS 外观一致)。"""
|
||||
|
||||
# 深色 Minecraft 风配色
|
||||
|
||||
BG = "#1e1e2e"
|
||||
CARD = "#2a2a3d"
|
||||
ACCENT = "#4ade80"
|
||||
@@ -488,24 +561,27 @@ class MeowLauncherApp:
|
||||
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("MeowLauncher - Minecraft 启动器")
|
||||
self.root.geometry("520x720")
|
||||
self.root.minsize(480, 640)
|
||||
self.root.title("MeowLauncher %s - Minecraft 启动器" % VERSION)
|
||||
self.root.geometry("600x780")
|
||||
self.root.minsize(540, 680)
|
||||
self.root.configure(bg=self.BG)
|
||||
|
||||
self.msg_queue = queue.Queue()
|
||||
self.auth = None
|
||||
self.yggdrasil = None
|
||||
self.versions_cache = []
|
||||
self.modrinth = ModrinthClient()
|
||||
self.res_hits = []
|
||||
|
||||
self._setup_style()
|
||||
self._build_ui()
|
||||
self.root.after(100, self._poll_queue)
|
||||
self.refresh_versions()
|
||||
|
||||
# ---------------- 样式 ----------------
|
||||
|
||||
|
||||
def _setup_style(self):
|
||||
"""配置 ttk 深色主题, 保证三端外观一致。"""
|
||||
style = ttk.Style(self.root)
|
||||
try:
|
||||
style.theme_use("clam")
|
||||
@@ -530,10 +606,14 @@ class MeowLauncherApp:
|
||||
style.configure("TCheckbutton", background=self.BG, foreground=self.TEXT)
|
||||
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.CARD)
|
||||
style.configure("TRadiobutton", background=self.BG, foreground=self.TEXT)
|
||||
style.configure("Treeview", background=self.CARD, fieldbackground=self.CARD,
|
||||
foreground=self.TEXT)
|
||||
style.configure("Treeview.Heading", background=self.CARD, foreground=self.TEXT)
|
||||
|
||||
# ---------------- UI 构建 ----------------
|
||||
|
||||
|
||||
def _card(self, parent, title):
|
||||
"""创建一个圆角卡片容器, 用于分组界面元素。"""
|
||||
frame = ttk.Frame(parent, style="Card.TFrame", padding=14)
|
||||
frame.pack(fill="x", padx=14, pady=(8, 4))
|
||||
ttk.Label(frame, text=title, style="Card.TLabel",
|
||||
@@ -541,12 +621,23 @@ class MeowLauncherApp:
|
||||
return frame
|
||||
|
||||
def _build_ui(self):
|
||||
# 标题
|
||||
ttk.Label(self.root, text="MeowLauncher", style="Title.TLabel").pack(pady=(18, 0))
|
||||
ttk.Label(self.root, text="跨平台 Minecraft 启动器", style="Dim.TLabel").pack()
|
||||
"""构建主窗口: 标题 + 标签页(启动器/资源中心)。"""
|
||||
ttk.Label(self.root, text="MeowLauncher", style="Title.TLabel").pack(pady=(14, 0))
|
||||
ttk.Label(self.root, text="跨平台 Minecraft 启动器 v%s" % VERSION, style="Dim.TLabel").pack()
|
||||
|
||||
# ---- 账号登录 ----
|
||||
login = self._card(self.root, "账号登录")
|
||||
self.notebook = ttk.Notebook(self.root)
|
||||
self.notebook.pack(fill="both", expand=True, padx=10, pady=8)
|
||||
|
||||
self.tab_launch = ttk.Frame(self.notebook)
|
||||
self.tab_res = ttk.Frame(self.notebook)
|
||||
self.notebook.add(self.tab_launch, text=" 启动器 ")
|
||||
self.notebook.add(self.tab_res, text=" 资源中心 ")
|
||||
self._build_launch_tab()
|
||||
self._build_res_tab()
|
||||
|
||||
def _build_launch_tab(self):
|
||||
"""构建[启动器]页: 登录、版本、设置、日志、启动按钮。"""
|
||||
login = self._card(self.tab_launch, "账号登录")
|
||||
self.login_mode = tk.StringVar(value="offline")
|
||||
row = ttk.Frame(login, style="Card.TFrame")
|
||||
row.pack(fill="x", pady=4)
|
||||
@@ -583,8 +674,7 @@ class MeowLauncherApp:
|
||||
ttk.Label(self.ygg_frame, text="支持 LittleSkin 等 authlib-injector 皮肤站",
|
||||
style="Card.TLabel").grid(row=3, column=0, columnspan=2, sticky="w", pady=2)
|
||||
|
||||
# ---- 游戏版本 ----
|
||||
ver = self._card(self.root, "游戏版本")
|
||||
ver = self._card(self.tab_launch, "游戏版本")
|
||||
row = ttk.Frame(ver, style="Card.TFrame")
|
||||
row.pack(fill="x")
|
||||
self.version_var = tk.StringVar()
|
||||
@@ -595,26 +685,28 @@ class MeowLauncherApp:
|
||||
ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot,
|
||||
command=self.refresh_versions).pack(anchor="w", pady=(6, 0))
|
||||
|
||||
# ---- 启动设置 ----
|
||||
cfg = self._card(self.root, "启动设置")
|
||||
ttk.Label(cfg, text="最大内存 (MB):", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=3)
|
||||
cfg = self._card(self.tab_launch, "启动设置")
|
||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||
row.pack(fill="x", pady=3)
|
||||
ttk.Label(row, text="最大内存 (MB):", style="Card.TLabel").pack(side="left")
|
||||
self.mem_var = tk.StringVar(value="2048")
|
||||
ttk.Entry(cfg, textvariable=self.mem_var, width=10).grid(row=0, column=1, sticky="w", pady=3)
|
||||
ttk.Label(cfg, text="Java 路径 (留空自动检测):", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=3)
|
||||
ttk.Entry(row, textvariable=self.mem_var, width=10).pack(side="left", padx=8)
|
||||
|
||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||
row.grid(row=1, column=1, sticky="w", pady=3)
|
||||
row.pack(fill="x", pady=3)
|
||||
ttk.Label(row, text="Java 路径 (留空自动检测):", style="Card.TLabel").pack(side="left")
|
||||
self.java_var = tk.StringVar()
|
||||
ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left")
|
||||
ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left", padx=8)
|
||||
ttk.Button(row, text="浏览", command=self._pick_java).pack(side="left", padx=4)
|
||||
ttk.Label(cfg, text="游戏目录:", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=3)
|
||||
|
||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||
row.grid(row=2, column=1, sticky="w", pady=3)
|
||||
row.pack(fill="x", pady=3)
|
||||
ttk.Label(row, text="游戏目录:", style="Card.TLabel").pack(side="left")
|
||||
self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft"))
|
||||
ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left")
|
||||
ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left", padx=8)
|
||||
ttk.Button(row, text="浏览", command=self._pick_dir).pack(side="left", padx=4)
|
||||
|
||||
# ---- 日志与进度 ----
|
||||
log_card = self._card(self.root, "日志")
|
||||
log_card = self._card(self.tab_launch, "日志")
|
||||
self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#151521",
|
||||
fg=self.TEXT, font=("Consolas", 9),
|
||||
relief="flat", state="disabled")
|
||||
@@ -622,14 +714,132 @@ class MeowLauncherApp:
|
||||
self.progress = ttk.Progressbar(log_card, mode="determinate", maximum=100)
|
||||
self.progress.pack(fill="x", pady=(8, 0))
|
||||
|
||||
# ---- 启动按钮 ----
|
||||
self.launch_btn = ttk.Button(self.root, text="启动游戏", style="Accent.TButton",
|
||||
self.launch_btn = ttk.Button(self.tab_launch, text="启动游戏", style="Accent.TButton",
|
||||
command=self._on_launch)
|
||||
self.launch_btn.pack(fill="x", padx=14, pady=14)
|
||||
|
||||
# ---------------- 事件 ----------------
|
||||
def _build_res_tab(self):
|
||||
"""构建[资源中心]页: 版本一键下载 + Mod/插件搜索下载。"""
|
||||
dver = self._card(self.tab_res, "游戏版本下载")
|
||||
row = ttk.Frame(dver, style="Card.TFrame")
|
||||
row.pack(fill="x")
|
||||
self.res_version_var = tk.StringVar()
|
||||
self.res_version_box = ttk.Combobox(row, textvariable=self.res_version_var, state="readonly", width=22)
|
||||
self.res_version_box.pack(side="left")
|
||||
ttk.Button(row, text="刷新列表", command=self.refresh_versions).pack(side="left", padx=6)
|
||||
ttk.Button(row, text="一键下载", command=self._download_version_only).pack(side="left", padx=6)
|
||||
|
||||
dmod = self._card(self.tab_res, "Mod / 插件下载 (Modrinth)")
|
||||
row = ttk.Frame(dmod, style="Card.TFrame")
|
||||
row.pack(fill="x")
|
||||
self.res_query = ttk.Entry(row, width=14)
|
||||
self.res_query.pack(side="left")
|
||||
self.res_type_var = tk.StringVar(value="mod")
|
||||
ttk.Combobox(row, textvariable=self.res_type_var, values=["mod", "plugin"],
|
||||
state="readonly", width=8).pack(side="left", padx=4)
|
||||
self.res_mcver_var = tk.StringVar(value="")
|
||||
ttk.Combobox(row, textvariable=self.res_mcver_var,
|
||||
values=["", "1.21", "1.20.4", "1.20.1", "1.19.4", "1.18.2", "1.16.5"],
|
||||
state="readonly", width=8).pack(side="left", padx=4)
|
||||
self.res_loader_var = tk.StringVar(value="fabric")
|
||||
ttk.Combobox(row, textvariable=self.res_loader_var,
|
||||
values=["fabric", "forge", "neoforge", "quilt", "paper", "spigot", "bukkit"],
|
||||
state="readonly", width=9).pack(side="left", padx=4)
|
||||
ttk.Button(row, text="搜索", command=self._search_res).pack(side="left", padx=4)
|
||||
|
||||
self.res_tree = ttk.Treeview(dmod, columns=("title", "author", "downloads"),
|
||||
show="headings", height=6)
|
||||
self.res_tree.heading("title", text="名称")
|
||||
self.res_tree.heading("author", text="作者")
|
||||
self.res_tree.heading("downloads", text="下载量")
|
||||
self.res_tree.column("title", width=190)
|
||||
self.res_tree.column("author", width=90)
|
||||
self.res_tree.column("downloads", width=80)
|
||||
self.res_tree.pack(fill="x", pady=6)
|
||||
ttk.Button(dmod, text="下载选中项", command=self._download_selected).pack(anchor="w")
|
||||
|
||||
self.res_log = scrolledtext.ScrolledText(self.tab_res, height=5, bg="#151521",
|
||||
fg=self.TEXT, font=("Consolas", 9),
|
||||
relief="flat", state="disabled")
|
||||
self.res_log.pack(fill="x", padx=14, pady=(4, 6))
|
||||
self.res_progress = ttk.Progressbar(self.tab_res, mode="determinate", maximum=100)
|
||||
self.res_progress.pack(fill="x", padx=14, pady=(0, 10))
|
||||
|
||||
def _res_append(self, msg):
|
||||
"""向资源中心日志框追加一行。"""
|
||||
self.res_log.config(state="normal")
|
||||
self.res_log.insert("end", msg + "\n")
|
||||
self.res_log.see("end")
|
||||
self.res_log.config(state="disabled")
|
||||
|
||||
def _search_res(self):
|
||||
"""后台线程搜索 Modrinth, 结果回填列表。"""
|
||||
def worker():
|
||||
try:
|
||||
q = self.res_query.get().strip()
|
||||
pt = self.res_type_var.get()
|
||||
mc = self.res_mcver_var.get() or None
|
||||
ld = self.res_loader_var.get() or None
|
||||
hits = self.modrinth.search(query=q, game_versions=mc, loaders=ld,
|
||||
project_type=pt, limit=20)
|
||||
self.msg_queue.put(("res_hits", hits))
|
||||
except Exception as e:
|
||||
self.msg_queue.put(("reslog", "[资源] 搜索失败: %s" % e))
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _download_version_only(self):
|
||||
"""一键下载完整游戏版本 (不启动)。"""
|
||||
def worker():
|
||||
try:
|
||||
version = self.res_version_var.get()
|
||||
if not version:
|
||||
raise RuntimeError("请先选择版本")
|
||||
self.msg_queue.put(("reslog", "[版本] 开始下载 %s ..." % version))
|
||||
l = MinecraftLauncher(game_dir=self.dir_var.get(), version_id=version,
|
||||
username="x",
|
||||
progress_cb=lambda m: self.msg_queue.put(("reslog", m)))
|
||||
l.resolve_version()
|
||||
l.download_client()
|
||||
l.download_libraries()
|
||||
l.download_assets()
|
||||
self.msg_queue.put(("reslog", "[版本] %s 下载完成!" % version))
|
||||
except Exception as e:
|
||||
self.msg_queue.put(("reslog", "[错误] %s" % e))
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _download_selected(self):
|
||||
"""下载列表中选中的 Mod/插件 jar 到 mods/ 或 plugins/ 目录。"""
|
||||
sel = self.res_tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
idx = int(sel[0])
|
||||
if idx >= len(self.res_hits):
|
||||
return
|
||||
hit = self.res_hits[idx]
|
||||
pt = self.res_type_var.get()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
mc = self.res_mcver_var.get() or None
|
||||
ld = self.res_loader_var.get() or None
|
||||
self.msg_queue.put(("reslog", "[下载] 获取 %s 的版本信息 ..." % hit.get("title", "")))
|
||||
f = self.modrinth.first_file(hit["project_id"], mc, ld)
|
||||
if not f:
|
||||
raise RuntimeError("没有匹配该MC版本/加载器的文件")
|
||||
base = Path(self.dir_var.get())
|
||||
target_dir = base / ("mods" if pt == "mod" else "plugins")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = target_dir / f["filename"]
|
||||
size = f.get("size", 0) // 1024
|
||||
self.msg_queue.put(("reslog", "[下载] %s (%d KB)" % (f["filename"], size)))
|
||||
download_file(f["url"], dest)
|
||||
self.msg_queue.put(("reslog", "[下载] 完成! 已保存到 %s" % dest))
|
||||
except Exception as e:
|
||||
self.msg_queue.put(("reslog", "[错误] %s" % e))
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _toggle_login(self):
|
||||
"""切换离线/第三方登录表单的显示。"""
|
||||
if self.login_mode.get() == "offline":
|
||||
self.offline_frame.pack(fill="x")
|
||||
self.ygg_frame.pack_forget()
|
||||
@@ -638,16 +848,19 @@ class MeowLauncherApp:
|
||||
self.ygg_frame.pack(fill="x")
|
||||
|
||||
def _pick_java(self):
|
||||
"""弹出文件选择框, 选择 java 可执行文件。"""
|
||||
path = filedialog.askopenfilename(title="选择 java 可执行文件")
|
||||
if path:
|
||||
self.java_var.set(path)
|
||||
|
||||
def _pick_dir(self):
|
||||
"""弹出目录选择框, 选择游戏目录。"""
|
||||
path = filedialog.askdirectory(title="选择游戏目录")
|
||||
if path:
|
||||
self.dir_var.set(path)
|
||||
|
||||
def refresh_versions(self):
|
||||
"""后台线程拉取版本清单, 填充两个标签页的版本下拉框。"""
|
||||
def worker():
|
||||
try:
|
||||
data = MinecraftLauncher(
|
||||
@@ -659,6 +872,7 @@ class MeowLauncherApp:
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _poll_queue(self):
|
||||
"""定时轮询消息队列, 把后台线程的日志/结果刷到界面。"""
|
||||
try:
|
||||
while True:
|
||||
kind, payload = self.msg_queue.get_nowait()
|
||||
@@ -667,6 +881,17 @@ class MeowLauncherApp:
|
||||
elif kind == "versions":
|
||||
self.versions_cache = payload["versions"]
|
||||
self._fill_versions(payload["latest"])
|
||||
elif kind == "res_hits":
|
||||
hits = payload
|
||||
self.res_hits = hits
|
||||
self.res_tree.delete(*self.res_tree.get_children())
|
||||
for i, h in enumerate(hits):
|
||||
self.res_tree.insert("", "end", iid=str(i),
|
||||
values=(h.get("title", ""), h.get("author", ""),
|
||||
h.get("downloads", 0)))
|
||||
self._res_append("[资源] 找到 %d 个结果" % len(hits))
|
||||
elif kind == "reslog":
|
||||
self._res_append(payload)
|
||||
elif kind == "done":
|
||||
self.launch_btn.config(state="normal")
|
||||
elif kind == "err":
|
||||
@@ -677,24 +902,30 @@ class MeowLauncherApp:
|
||||
self.root.after(100, self._poll_queue)
|
||||
|
||||
def _fill_versions(self, latest):
|
||||
"""根据快照开关过滤并填充版本下拉框。"""
|
||||
show_snap = self.show_snapshot.get()
|
||||
items = [v["id"] for v in self.versions_cache
|
||||
if show_snap or v["type"] == "release"]
|
||||
items = list(dict.fromkeys(items))
|
||||
self.version_box["values"] = items
|
||||
self.version_var.set(latest.get("release") or (items[0] if items else ""))
|
||||
self.res_version_box["values"] = items
|
||||
self.res_version_var.set(latest.get("release") or (items[0] if items else ""))
|
||||
|
||||
def _append_log(self, msg):
|
||||
"""向[启动器]页日志框追加一行。"""
|
||||
self.log_box.config(state="normal")
|
||||
self.log_box.insert("end", msg + "\n")
|
||||
self.log_box.see("end")
|
||||
self.log_box.config(state="disabled")
|
||||
|
||||
def _on_launch(self):
|
||||
"""点击启动游戏: 禁用按钮并开后台线程。"""
|
||||
self.launch_btn.config(state="disabled")
|
||||
threading.Thread(target=self._launch_worker, daemon=True).start()
|
||||
|
||||
def _launch_worker(self):
|
||||
"""后台线程: 登录 -> 构建启动器 -> 下载 -> 启动游戏。"""
|
||||
try:
|
||||
if self.login_mode.get() == "yggdrasil":
|
||||
server_name = self.server_var.get()
|
||||
@@ -735,9 +966,9 @@ class MeowLauncherApp:
|
||||
self.msg_queue.put(("done", None))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="MeowLauncher - Minecraft 启动器")
|
||||
@@ -756,7 +987,7 @@ def main() -> int:
|
||||
app.root.mainloop()
|
||||
return 0
|
||||
|
||||
# 命令行模式
|
||||
|
||||
if args.list_versions:
|
||||
launcher = MinecraftLauncher(
|
||||
game_dir=args.game_dir or str(Path.home() / ".minecraft"),
|
||||
|
||||
+46
-85
@@ -1,22 +1,11 @@
|
||||
#!/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
|
||||
MeowLauncher 命令行版 (旧版备份)
|
||||
功能已被 launcher.py 的图形界面版完全覆盖, 保留此文件供命令行使用:
|
||||
python launcher_cli.py --username Steve --version 1.20.4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
@@ -31,21 +20,21 @@ 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 值
|
||||
ARCH_NEW = "x86_64"
|
||||
ARCH_OLD = "64"
|
||||
elif _ARCH in ("aarch64", "arm64"):
|
||||
ARCH_NEW = "arm64"
|
||||
ARCH_OLD = "arm64"
|
||||
@@ -53,7 +42,7 @@ else:
|
||||
ARCH_NEW = "x86"
|
||||
ARCH_OLD = "32"
|
||||
|
||||
# 原生库分类器 (Mojang 命名)
|
||||
|
||||
NATIVES_CLASSIFIER = {
|
||||
"linux": "natives-linux",
|
||||
"osx": "natives-osx",
|
||||
@@ -62,12 +51,10 @@ NATIVES_CLASSIFIER = {
|
||||
|
||||
|
||||
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""):
|
||||
@@ -76,19 +63,12 @@ def sha1_of(path: Path) -> str:
|
||||
|
||||
|
||||
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():
|
||||
@@ -122,10 +102,9 @@ def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> b
|
||||
|
||||
|
||||
def rule_allowed(rule: dict) -> bool:
|
||||
"""判断 Mojang 下载规则 (rules) 是否允许当前平台。"""
|
||||
if rule.get("action") == "allow":
|
||||
default = False
|
||||
else: # disallow
|
||||
else:
|
||||
default = True
|
||||
|
||||
os_rule = rule.get("os", {})
|
||||
@@ -139,16 +118,15 @@ def rule_allowed(rule: dict) -> bool:
|
||||
|
||||
|
||||
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__(
|
||||
@@ -168,34 +146,29 @@ class MinecraftLauncher:
|
||||
self.java_path = java_path
|
||||
self.min_mem = min_mem
|
||||
self.max_mem = max_mem
|
||||
self.auth = auth or {} # 正版登录信息: {access_token, uuid, name}
|
||||
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:
|
||||
"""
|
||||
解析版本号 -> 版本元数据。
|
||||
支持: latest-release / latest-snapshot / 任意版本 id。
|
||||
"""
|
||||
manifest = self.fetch_version_manifest()
|
||||
latest = manifest.get("latest", {})
|
||||
versions = manifest.get("versions", [])
|
||||
@@ -215,12 +188,11 @@ class MinecraftLauncher:
|
||||
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"
|
||||
@@ -236,7 +208,6 @@ class MinecraftLauncher:
|
||||
return jar
|
||||
|
||||
def _download_library_artifact(self, artifact: dict, lib_path: Path) -> None:
|
||||
"""下载单个库文件。"""
|
||||
if not artifact:
|
||||
return
|
||||
download_file(
|
||||
@@ -247,7 +218,6 @@ class MinecraftLauncher:
|
||||
)
|
||||
|
||||
def download_libraries(self) -> list:
|
||||
"""下载所有依赖库, 返回 classpath 列表。"""
|
||||
cp = []
|
||||
for lib in self.version_meta.get("libraries", []):
|
||||
if not library_allowed(lib):
|
||||
@@ -256,7 +226,7 @@ class MinecraftLauncher:
|
||||
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
|
||||
@@ -280,7 +250,6 @@ class MinecraftLauncher:
|
||||
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)
|
||||
|
||||
@@ -289,7 +258,7 @@ class MinecraftLauncher:
|
||||
|
||||
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
|
||||
@@ -297,7 +266,6 @@ class MinecraftLauncher:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
def download_assets(self) -> None:
|
||||
"""下载资源索引与全部资源对象。"""
|
||||
index_info = self.version_meta.get("assetIndex")
|
||||
if not index_info:
|
||||
log("[资源] 该版本无资源索引, 跳过")
|
||||
@@ -326,12 +294,11 @@ class MinecraftLauncher:
|
||||
|
||||
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,
|
||||
@@ -358,17 +325,15 @@ class MinecraftLauncher:
|
||||
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
|
||||
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:
|
||||
"""构建完整的 java 启动命令。"""
|
||||
self._cp = classpath
|
||||
java = self._find_java()
|
||||
|
||||
@@ -385,12 +350,11 @@ class MinecraftLauncher:
|
||||
return command
|
||||
|
||||
def _build_args(self) -> tuple:
|
||||
"""拆分并解析 jvm 参数与游戏参数 (兼容新旧版本格式)。"""
|
||||
jvm_args = []
|
||||
game_args = []
|
||||
arguments = self.version_meta.get("arguments")
|
||||
|
||||
if arguments: # 1.13+ 新格式
|
||||
if arguments:
|
||||
for item in arguments.get("jvm", []):
|
||||
if isinstance(item, str):
|
||||
jvm_args.append(self._resolve_argument(item))
|
||||
@@ -406,14 +370,13 @@ class MinecraftLauncher:
|
||||
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 及以前旧格式
|
||||
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:
|
||||
"""查找 Java 可执行文件: 优先用户指定, 其次 JAVA_HOME, 最后 PATH。"""
|
||||
if self.java_path and os.path.isfile(self.java_path):
|
||||
return self.java_path
|
||||
|
||||
@@ -433,12 +396,11 @@ class MinecraftLauncher:
|
||||
"(Minecraft 1.17+ 需要 Java 17+)"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def run(self) -> int:
|
||||
"""完整流程: 解析 -> 下载 -> 启动。"""
|
||||
log(f"[启动] 游戏目录: {self.game_dir}")
|
||||
log(f"[启动] 版本: {self.version_id} 玩家: {self.username}")
|
||||
|
||||
@@ -447,7 +409,7 @@ class MinecraftLauncher:
|
||||
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")
|
||||
|
||||
@@ -458,12 +420,11 @@ class MinecraftLauncher:
|
||||
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')}")
|
||||
@@ -473,9 +434,9 @@ class MinecraftLauncher:
|
||||
log(f" {v['id']:<20} {v['type']:<10}{marker}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
|
||||
Reference in new issue
Block a user