From e4cc14cb9e3a87eedae40cc7d054414da1defe16 Mon Sep 17 00:00:00 2001 From: MCLauncher Date: Thu, 6 Aug 2026 01:48:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.926=20=E8=B5=84=E6=BA=90=E4=B8=AD?= =?UTF-8?q?=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动器内一键下载游戏版本 (官方源) - Mod & 插件下载 (Modrinth API, 支持MC版本/加载器筛选) - GUI改为标签页布局: 启动器 + 资源中心 - 版本号升级至 1.2.926 --- README.md | 1 + launcher.py | 211 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 194 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0afc591..1c32c4b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 功能特性 - 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致 +- 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth) - 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议) - 🎭 离线模式:自动生成与官方一致的离线 UUID - 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`) diff --git a/launcher.py b/launcher.py index b5ef43b..f247de4 100644 --- a/launcher.py +++ b/launcher.py @@ -13,6 +13,7 @@ import subprocess import sys import threading import urllib.error +import urllib.parse import urllib.request import uuid as uuid_mod import zipfile @@ -24,7 +25,8 @@ 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") @@ -135,6 +137,43 @@ def library_allowed(lib: dict) -> bool: + +class ModrinthClient: + BASE = "https://api.modrinth.com/v2" + + def search(self, query="", game_versions=None, loaders=None, project_type=None, limit=20): + 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): + 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): + 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 + class YggdrasilAuth: PRESETS = { @@ -470,15 +509,17 @@ 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() @@ -512,6 +553,9 @@ 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) @@ -523,12 +567,21 @@ 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) @@ -565,8 +618,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() @@ -577,8 +629,7 @@ 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, "启动设置") + cfg = self._card(self.tab_launch, "启动设置") ttk.Label(cfg, text="最大内存 (MB):", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=3) 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) @@ -595,8 +646,7 @@ class MeowLauncherApp: ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left") 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") @@ -604,12 +654,124 @@ 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): + 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): + 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): + 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": @@ -649,6 +811,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": @@ -665,6 +838,8 @@ class MeowLauncherApp: 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")