diff --git a/launcher.py b/launcher.py index f247de4..f61077b 100644 --- a/launcher.py +++ b/launcher.py @@ -1,6 +1,20 @@ +""" +MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版) + +功能特性: + - 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致 + - 离线账号与第三方 Yggdrasil 登录 (LittleSkin 等 authlib-injector 皮肤站) + - 资源中心: 启动器内一键下载游戏版本 / Mod / 插件 (Modrinth 开放 API) + - 自动下载依赖库 / 原生库 / 资源文件, SHA1 校验 + 断点续传 + - 保留命令行模式: python launcher.py --cli + +用法: + python launcher.py 启动图形界面 + python launcher.py --cli ... 命令行模式 +""" import argparse import hashlib import json @@ -138,10 +152,16 @@ def library_allowed(lib: dict) -> bool: +# --------------------------------------------------------------------------- +# 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]) @@ -156,6 +176,7 @@ class ModrinthClient: 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: @@ -167,6 +188,7 @@ class ModrinthClient: 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", []): @@ -174,6 +196,10 @@ class ModrinthClient: return f return None +# --------------------------------------------------------------------------- +# 第三方登录 (Yggdrasil / authlib-injector 协议) +# 支持 LittleSkin、Blessing Skin 等皮肤站, 登录获取正版角色信息 +# --------------------------------------------------------------------------- class YggdrasilAuth: PRESETS = { @@ -183,9 +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 = str(uuid_mod.uuid4()) payload = { "agent": {"name": "Minecraft", "version": 1}, @@ -206,6 +234,7 @@ class YggdrasilAuth: } def download_injector(self, game_dir: Path) -> Path: + """下载最新版 authlib-injector.jar, 启动游戏时注入用。""" lib_dir = game_dir / "libraries" / "authlib-injector" lib_dir.mkdir(parents=True, exist_ok=True) jar = lib_dir / "authlib-injector.jar" @@ -225,6 +254,10 @@ class YggdrasilAuth: +# --------------------------------------------------------------------------- +# 核心启动器 +# 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 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, @@ -251,9 +284,11 @@ class MinecraftLauncher: 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", []) @@ -276,11 +311,13 @@ class MinecraftLauncher: def _emit(self, msg: str) -> None: + """输出日志: 同时打印到控制台和回调 GUI。""" log(msg) if self.progress_cb: self.progress_cb(msg) def download_client(self) -> Path: + """下载客户端 jar 文件到 versions//。""" vdir = self.versions_dir / self.version_id vdir.mkdir(parents=True, exist_ok=True) jar = vdir / ("%s.jar" % self.version_id) @@ -293,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 @@ -322,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//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] @@ -340,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("[资源] 该版本无资源索引, 跳过") @@ -368,6 +409,7 @@ class MinecraftLauncher: 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, @@ -394,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 @@ -403,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: @@ -424,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") @@ -438,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() @@ -461,6 +507,7 @@ class MinecraftLauncher: def run(self) -> int: + """主流程: 解析版本 -> 下载文件 -> 构建命令 -> 启动游戏。""" self._emit("[启动] 游戏目录: %s" % self.game_dir) self._emit("[启动] 版本: %s 玩家: %s" % (self.version_id, self.auth.get("name") or self.username)) @@ -479,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", {}), @@ -498,6 +546,10 @@ except Exception: TK_OK = False +# --------------------------------------------------------------------------- +# 图形界面 (Tkinter) +# 两个标签页: [启动器] 登录/版本/设置/日志, [资源中心] 版本与Mod插件下载 +# --------------------------------------------------------------------------- class MeowLauncherApp: @@ -529,6 +581,7 @@ class MeowLauncherApp: def _setup_style(self): + """配置 ttk 深色主题, 保证三端外观一致。""" style = ttk.Style(self.root) try: style.theme_use("clam") @@ -560,6 +613,7 @@ class MeowLauncherApp: 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", @@ -567,6 +621,7 @@ class MeowLauncherApp: return frame def _build_ui(self): + """构建主窗口: 标题 + 标签页(启动器/资源中心)。""" 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() @@ -581,6 +636,7 @@ class MeowLauncherApp: 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") @@ -659,6 +715,7 @@ class MeowLauncherApp: 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") @@ -705,12 +762,14 @@ class MeowLauncherApp: 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() @@ -725,6 +784,7 @@ class MeowLauncherApp: threading.Thread(target=worker, daemon=True).start() def _download_version_only(self): + """一键下载完整游戏版本 (不启动)。""" def worker(): try: version = self.res_version_var.get() @@ -744,6 +804,7 @@ class MeowLauncherApp: threading.Thread(target=worker, daemon=True).start() def _download_selected(self): + """下载列表中选中的 Mod/插件 jar 到 mods/ 或 plugins/ 目录。""" sel = self.res_tree.selection() if not sel: return @@ -774,6 +835,7 @@ class MeowLauncherApp: 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() @@ -782,16 +844,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( @@ -803,6 +868,7 @@ class MeowLauncherApp: threading.Thread(target=worker, daemon=True).start() def _poll_queue(self): + """定时轮询消息队列, 把后台线程的日志/结果刷到界面。""" try: while True: kind, payload = self.msg_queue.get_nowait() @@ -832,6 +898,7 @@ 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"] @@ -842,16 +909,19 @@ class MeowLauncherApp: 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() diff --git a/launcher_cli.py b/launcher_cli.py index 16f411e..1472acd 100644 --- a/launcher_cli.py +++ b/launcher_cli.py @@ -1,6 +1,11 @@ +""" +MeowLauncher 命令行版 (旧版备份) +功能已被 launcher.py 的图形界面版完全覆盖, 保留此文件供命令行使用: + python launcher_cli.py --username Steve --version 1.20.4 +""" import argparse import hashlib import json