7 Commits
Author SHA1 Message Date
MCLauncher a44d2afca5 feat: v1.3.0-Beta PCL风格UI重构
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
- 固定窗口尺寸 980x620, 不可缩放, 启动按钮不再被截出屏幕
- 横向布局: 左侧深蓝导航栏 + 右侧内容区
- 启动页改为左右两列: 设置/版本 + 日志/启动按钮
- 导航按钮高亮切换, 模拟 PCL 风格
2026-08-06 22:04:36 +08:00
MCLauncher 393aa91302 feat: v1.3.0-Alpha 蓝色主题UI重构
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
- 黑色深色主题改为明亮蓝色主题
- 新增窗口淡入动画与启动按钮忙碌动画
- 标签页选中蓝色高亮, 表格/日志区浅色化
- 版本号升级 1.3.0-Alpha
2026-08-06 12:44:29 +08:00
MCLauncher f13890da5b fix: 修复GUI启动设置卡片pack/grid混用导致的崩溃
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
- 启动设置卡片改用统一pack布局
- 修复Windows上启动即崩溃的 TclError 问题
2026-08-06 11:57:45 +08:00
MCLauncher b6e67db02f docs: 为源码添加完整中文注释
- 文件头说明项目功能与用法
- 每个类前注释模块职责
- 每个方法添加docstring说明用途
- 方便开源社区开发者理解和使用
2026-08-06 01:53:03 +08:00
MCLauncher e4cc14cb9e feat: v1.2.926 资源中心
- 启动器内一键下载游戏版本 (官方源)
- Mod & 插件下载 (Modrinth API, 支持MC版本/加载器筛选)
- GUI改为标签页布局: 启动器 + 资源中心
- 版本号升级至 1.2.926
2026-08-06 01:48:45 +08:00
MCLauncher 8823b9218d refactor: 移除代码注释与docstring 2026-08-06 00:05:26 +08:00
MCLauncher 84bf271447 docs: 添加MIT开源许可证 2026-08-05 23:52:10 +08:00
4 changed files with 465 additions and 170 deletions

No files matched your search

+21
View File
@@ -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.
+1
View File
@@ -5,6 +5,7 @@
## 功能特性
- 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致
- 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth)
- 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议)
- 🎭 离线模式:自动生成与官方一致的离线 UUID
- 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`)
+397 -85
View File
@@ -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.3.0-Alpha"
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,36 +546,45 @@ except Exception:
TK_OK = False
# ---------------------------------------------------------------------------
# 图形界面 (Tkinter)
# 两个标签页: [启动器] 登录/版本/设置/日志, [资源中心] 版本与Mod插件下载
# ---------------------------------------------------------------------------
class MeowLauncherApp:
"""跨平台图形界面 (Windows / Linux / macOS 外观一致)。"""
# 深色 Minecraft 风配色
BG = "#1e1e2e"
CARD = "#2a2a3d"
ACCENT = "#4ade80"
TEXT = "#e2e8f0"
DIM = "#94a3b8"
BG = "#eef2f9"
CARD = "#ffffff"
ACCENT = "#2563eb"
ACCENT_LIGHT = "#60a5fa"
ACCENT_DARK = "#1d4ed8"
TEXT = "#1e293b"
DIM = "#64748b"
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("980x620")
self.root.resizable(False, False)
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()
self._fade_in()
# ---------------- 样式 ----------------
def _setup_style(self):
"""配置 ttk 深色主题, 保证三端外观一致。"""
style = ttk.Style(self.root)
try:
style.theme_use("clam")
@@ -520,20 +599,46 @@ class MeowLauncherApp:
style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT,
font=("Microsoft YaHei UI", 18, "bold"))
style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM)
style.configure("Accent.TButton", background=self.ACCENT, foreground="#0f172a",
font=("Microsoft YaHei UI", 11, "bold"))
style.configure("Accent.TButton", background=self.ACCENT, foreground="#ffffff",
font=("Microsoft YaHei UI", 11, "bold"), padding=(16, 10))
style.map("Accent.TButton",
background=[("active", "#22c55e"), ("pressed", "#16a34a")])
background=[("active", self.ACCENT_LIGHT), ("pressed", self.ACCENT_DARK)])
style.configure("TCombobox", fieldbackground=self.CARD, background=self.CARD,
foreground=self.TEXT, arrowcolor=self.TEXT)
style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT)
style.configure("TCheckbutton", background=self.BG, foreground=self.TEXT)
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.CARD)
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor="#e2e8f0")
style.configure("TRadiobutton", background=self.BG, foreground=self.TEXT)
style.configure("TNotebook", background=self.BG, borderwidth=0)
style.configure("TNotebook.Tab", background=self.CARD, foreground=self.DIM,
padding=(18, 8), font=("Microsoft YaHei UI", 10))
style.map("TNotebook.Tab",
background=[("selected", self.ACCENT)],
foreground=[("selected", "#ffffff")])
style.configure("Content.TFrame", background=self.BG)
style.configure("Nav.TFrame", background="#1e3a8a")
style.configure("NavLogo.TLabel", background="#1e3a8a", foreground="#ffffff",
font=("Microsoft YaHei UI", 30))
style.configure("NavTitle.TLabel", background="#1e3a8a", foreground="#ffffff",
font=("Microsoft YaHei UI", 15, "bold"))
style.configure("NavSub.TLabel", background="#1e3a8a", foreground="#bfdbfe",
font=("Microsoft YaHei UI", 9))
style.configure("NavButton.TButton", background="#1e3a8a", foreground="#e0e7ff",
font=("Microsoft YaHei UI", 11), borderwidth=0, padding=(14, 10))
style.map("NavButton.TButton",
background=[("active", "#2563eb"), ("pressed", "#1d4ed8")])
style.configure("NavButtonActive.TButton", background="#2563eb", foreground="#ffffff",
font=("Microsoft YaHei UI", 11, "bold"), borderwidth=0, padding=(14, 10))
style.configure("Treeview", background=self.CARD, fieldbackground=self.CARD,
foreground=self.TEXT)
style.configure("Treeview.Heading", background="#eef2f9", foreground=self.TEXT)
style.map("Treeview", background=[("selected", self.ACCENT_LIGHT)],
foreground=[("selected", "#ffffff")])
# ---------------- 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,95 +646,260 @@ 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()
"""构建主窗口: 左侧导航 + 右侧内容页。"""
self.nav = ttk.Frame(self.root, style="Nav.TFrame", width=200)
self.nav.pack(side="left", fill="y")
self.nav.pack_propagate(False)
# ---- 账号登录 ----
login = self._card(self.root, "账号登录")
ttk.Label(self.nav, text="🐱", style="NavLogo.TLabel").pack(pady=(26, 0))
ttk.Label(self.nav, text="MeowLauncher", style="NavTitle.TLabel").pack(pady=(4, 2))
ttk.Label(self.nav, text="跨平台 MC 启动器", style="NavSub.TLabel").pack(pady=(0, 26))
self.nav_launch_btn = ttk.Button(self.nav, text=" 🚀 启动游戏",
style="NavButton.TButton",
command=lambda: self._switch_page("launch"))
self.nav_launch_btn.pack(fill="x", padx=16, pady=3)
self.nav_res_btn = ttk.Button(self.nav, text=" 📦 资源中心",
style="NavButton.TButton",
command=lambda: self._switch_page("res"))
self.nav_res_btn.pack(fill="x", padx=16, pady=3)
ttk.Label(self.nav, text="v%s" % VERSION, style="NavSub.TLabel").pack(side="bottom", pady=16)
self.content = ttk.Frame(self.root, style="Content.TFrame")
self.content.pack(side="left", fill="both", expand=True)
self.page_launch = ttk.Frame(self.content, style="Content.TFrame")
self.page_res = ttk.Frame(self.content, style="Content.TFrame")
self._build_launch_tab()
self._build_res_tab()
self.page_launch.pack(fill="both", expand=True)
self._set_nav_active("launch")
def _set_nav_active(self, page):
"""导航按钮高亮切换。"""
if page == "launch":
self.nav_launch_btn.configure(style="NavButtonActive.TButton")
self.nav_res_btn.configure(style="NavButton.TButton")
else:
self.nav_launch_btn.configure(style="NavButton.TButton")
self.nav_res_btn.configure(style="NavButtonActive.TButton")
def _switch_page(self, page):
"""切换页面 (左侧导航)。"""
if page == "launch":
self.page_res.pack_forget()
self.page_launch.pack(fill="both", expand=True)
else:
self.page_launch.pack_forget()
self.page_res.pack(fill="both", expand=True)
self._set_nav_active(page)
def _build_launch_tab(self):
"""构建[启动游戏]页: 左列设置 + 右列日志与启动按钮 (PCL 风格)。"""
body = ttk.Frame(self.page_launch, style="Content.TFrame")
body.pack(fill="both", expand=True, padx=16, pady=14)
left = ttk.Frame(body, style="Content.TFrame")
left.pack(side="left", fill="both", expand=True)
right = ttk.Frame(body, style="Content.TFrame")
right.pack(side="left", fill="both", expand=True, padx=(14, 0))
login = self._card(left, "账号登录")
self.login_mode = tk.StringVar(value="offline")
row = ttk.Frame(login, style="Card.TFrame")
row.pack(fill="x", pady=4)
ttk.Radiobutton(row, text="离线模式", variable=self.login_mode, value="offline",
command=self._toggle_login).pack(side="left", padx=(0, 16))
ttk.Radiobutton(row, text="第三方登录 (Yggdrasil)",
variable=self.login_mode, value="yggdrasil",
ttk.Radiobutton(row, text="第三方登录", variable=self.login_mode, value="yggdrasil",
command=self._toggle_login).pack(side="left")
self.offline_frame = ttk.Frame(login, style="Card.TFrame")
self.offline_frame.pack(fill="x")
ttk.Label(self.offline_frame, text="玩家名:", style="Card.TLabel").pack(side="left")
self.offline_name = ttk.Entry(self.offline_frame, width=24)
self.offline_name = ttk.Entry(self.offline_frame, width=22)
self.offline_name.pack(side="left", padx=8)
self.ygg_frame = ttk.Frame(login, style="Card.TFrame")
self.ygg_frame.pack(fill="x")
self.ygg_frame.pack_forget()
ttk.Label(self.ygg_frame, text="皮肤站:", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=2)
self.server_var = tk.StringVar(value="LittleSkin")
self.server_box = ttk.Combobox(self.ygg_frame, textvariable=self.server_var,
values=list(YggdrasilAuth.PRESETS.keys()), state="readonly", width=22)
values=list(YggdrasilAuth.PRESETS.keys()), state="readonly", width=18)
self.server_box.grid(row=0, column=1, sticky="w", pady=2)
ttk.Label(self.ygg_frame, text="邮箱/用户名:", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=2)
self.ygg_user = ttk.Entry(self.ygg_frame, width=24)
ttk.Label(self.ygg_frame, text="账号:", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=2)
self.ygg_user = ttk.Entry(self.ygg_frame, width=18)
self.ygg_user.grid(row=1, column=1, sticky="w", pady=2)
ttk.Label(self.ygg_frame, text="密码:", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=2)
self.ygg_pass = ttk.Entry(self.ygg_frame, width=24, show="*")
self.ygg_pass = ttk.Entry(self.ygg_frame, width=18, show="*")
self.ygg_pass.grid(row=2, column=1, sticky="w", pady=2)
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(left, "游戏版本")
row = ttk.Frame(ver, style="Card.TFrame")
row.pack(fill="x")
self.version_var = tk.StringVar()
self.version_box = ttk.Combobox(row, textvariable=self.version_var, state="readonly", width=28)
self.version_box = ttk.Combobox(row, textvariable=self.version_var, state="readonly", width=22)
self.version_box.pack(side="left")
ttk.Button(row, text="刷新", command=self.refresh_versions).pack(side="left", padx=8)
self.show_snapshot = tk.BooleanVar(value=False)
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(left, "启动设置")
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=8).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=14).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=14).pack(side="left", padx=8)
ttk.Button(row, text="浏览", command=self._pick_dir).pack(side="left", padx=4)
# ---- 日志与进度 ----
log_card = self._card(self.root, "日志")
self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#151521",
log_card = self._card(right, "日志")
self.log_box = scrolledtext.ScrolledText(log_card, height=10, bg="#f8fafc",
fg=self.TEXT, font=("Consolas", 9),
relief="flat", state="disabled")
self.log_box.pack(fill="x")
self.log_box.pack(fill="both", expand=True)
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(right, text="启动游戏", style="Accent.TButton",
command=self._on_launch)
self.launch_btn.pack(fill="x", padx=14, pady=14)
self.launch_btn.pack(fill="x", pady=(12, 0))
# ---------------- 事件 ----------------
def _build_res_tab(self):
"""构建[资源中心]页: 版本一键下载 + Mod/插件搜索下载。"""
body = ttk.Frame(self.page_res, style="Content.TFrame")
body.pack(fill="both", expand=True, padx=16, pady=14)
dver = self._card(body, "游戏版本下载")
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(body, "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=280)
self.res_tree.column("author", width=130)
self.res_tree.column("downloads", width=100)
self.res_tree.pack(fill="x", pady=6)
ttk.Button(dmod, text="下载选中项", command=self._download_selected).pack(anchor="w")
self.res_log = scrolledtext.ScrolledText(body, height=5, bg="#f8fafc",
fg=self.TEXT, font=("Consolas", 9),
relief="flat", state="disabled")
self.res_log.pack(fill="x", pady=(6, 0))
self.res_progress = ttk.Progressbar(body, mode="determinate", maximum=100)
self.res_progress.pack(fill="x", pady=(8, 0))
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 +908,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(
@@ -658,7 +931,28 @@ class MeowLauncherApp:
self.msg_queue.put(("log", "[版本] 获取失败: %s" % e))
threading.Thread(target=worker, daemon=True).start()
def _fade_in(self, step=0):
"""窗口淡入动画 (alpha 0 -> 1)。"""
try:
self.root.attributes("-alpha", min(step * 0.1, 1.0))
except Exception:
return
if step < 10:
self.root.after(18, lambda: self._fade_in(step + 1))
def _busy_button(self, text):
"""启动按钮忙碌状态 (文字闪烁)。"""
state = [0]
def tick():
if str(self.launch_btn["state"]) != "disabled":
return
state[0] += 1
self.launch_btn.configure(text=text + "." * (state[0] % 4))
self.root.after(400, tick)
tick()
def _poll_queue(self):
"""定时轮询消息队列, 把后台线程的日志/结果刷到界面。"""
try:
while True:
kind, payload = self.msg_queue.get_nowait()
@@ -667,6 +961,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 +982,31 @@ 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")
self._busy_button("启动中")
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 +1047,9 @@ class MeowLauncherApp:
self.msg_queue.put(("done", None))
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="MeowLauncher - Minecraft 启动器")
@@ -756,7 +1068,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
View File
@@ -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(