From 464fbd1baed0a7cac7a917838f7c8d8e831e9bc8 Mon Sep 17 00:00:00 2001 From: MCLauncher Date: Tue, 11 Aug 2026 16:53:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81Microsoft=E6=AD=A3?= =?UTF-8?q?=E7=89=88=E7=99=BB=E5=BD=95,=20v1.3.0-Birthday?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- launcher.py | 176 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 283afdb..fbc651f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致 - 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth) - 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议) +- ✅ 正版登录:支持 Microsoft 账号正版登录(OAuth 授权流程) - 🎭 离线模式:自动生成与官方一致的离线 UUID - 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`) - ⬇️ 自动下载客户端、依赖库、原生库、资源文件,SHA1 校验 + 断点续传 @@ -35,7 +36,7 @@ python3 launcher.py - macOS: 解压 `MeowLauncher-macos.zip` 后打开 `MeowLauncher.app` 界面操作: -1. 选择登录方式:离线模式(填玩家名)或第三方登录(选皮肤站、填账号密码) +1. 选择登录方式:离线模式(填玩家名)、第三方登录(选皮肤站、填账号密码)或正版登录(微软账号,点「打开浏览器登录」→ 复制地址栏 URL 回来) 2. 选择游戏版本(可勾选显示快照版) 3. 设置最大内存、Java 路径、游戏目录(一般保持默认即可) 4. 点击「启动游戏」 diff --git a/launcher.py b/launcher.py index 2b2c525..a2ed94b 100644 --- a/launcher.py +++ b/launcher.py @@ -39,7 +39,7 @@ 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" -VERSION = "1.3.0-Alpha" +VERSION = "1.3.0-Birthday" USER_AGENT = "Mozilla/5.0 (MeowLauncher/%s)" % VERSION OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux") @@ -76,11 +76,19 @@ def http_get(url: str, timeout: int = 30) -> bytes: return resp.read() -def http_post_json(url: str, payload: dict, timeout: int = 30) -> dict: - data = json.dumps(payload).encode("utf-8") +def http_post_json(url: str, payload, timeout: int = 30, content_type: str = None) -> dict: + if content_type == "application/x-www-form-urlencoded": + if isinstance(payload, dict): + data = urllib.parse.urlencode(payload).encode("utf-8") + else: + data = payload + ctype = "application/x-www-form-urlencoded" + else: + data = json.dumps(payload).encode("utf-8") + ctype = "application/json" req = urllib.request.Request(url, data=data, headers={ "User-Agent": USER_AGENT, - "Content-Type": "application/json", + "Content-Type": ctype, }) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) @@ -258,6 +266,104 @@ class YggdrasilAuth: # 核心启动器 # 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 Java 进程 # --------------------------------------------------------------------------- + +class MicrosoftAuth: + """Microsoft 正版账号登录 (Minecraft 官方 client_id, 纯标准库实现)。""" + + CLIENT_ID = "00000000402b5328" + REDIRECT_URI = "https://login.live.com/oauth20_desktop.srf" + + def auth_url(self) -> str: + params = urllib.parse.urlencode({ + "client_id": self.CLIENT_ID, + "response_type": "code", + "scope": "XboxLive.signin offline_access", + "redirect_uri": self.REDIRECT_URI, + }) + return "https://login.live.com/oauth20_authorize.srf?" + params + + def login(self, callback_url: str, timeout: int = 30) -> dict: + """用浏览器回调地址登录, 返回 {access_token, uuid, name} 账号信息。""" + query = urllib.parse.urlparse(callback_url).query + code = urllib.parse.parse_qs(query).get("code", [""])[0] + if not code: + raise RuntimeError("回调地址里没有 code, 请完整复制浏览器地址栏的 URL") + + # 1. code -> Microsoft access_token + token_data = urllib.parse.urlencode({ + "client_id": self.CLIENT_ID, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.REDIRECT_URI, + "scope": "XboxLive.signin offline_access", + }).encode("utf-8") + resp = http_post_json( + "https://login.live.com/oauth20_token.srf", token_data, timeout, + content_type="application/x-www-form-urlencoded", + ) + ms_token = resp.get("access_token", "") + if not ms_token: + raise RuntimeError("Microsoft 令牌获取失败: %s" % resp.get("error", "未知错误")) + + # 2. Microsoft token -> Xbox Live 认证 + xbl = http_post_json( + "https://user.auth.xboxlive.com/user/authenticate", + { + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=" + ms_token, + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT", + }, timeout, + ) + xbl_token = xbl.get("Token", "") + uhs = "" + for xui in xbl.get("DisplayClaims", {}).get("xui", []): + uhs = xui.get("uhs", "") + if not xbl_token or not uhs: + raise RuntimeError("Xbox Live 认证失败") + + # 3. Xbox Live -> XSTS 认证 + xsts = http_post_json( + "https://xsts.auth.xboxlive.com/xsts/authorize", + { + "Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl_token]}, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT", + }, timeout, + ) + xsts_token = xsts.get("Token", "") + if not xsts_token: + raise RuntimeError("XSTS 认证失败 (可能没有 Xbox 账号或未注册)") + + # 4. XSTS -> Minecraft 令牌 + mc = http_post_json( + "https://api.minecraftservices.com/authentication/login_with_xbox", + {"identityToken": "XBL3.0 x=%s;%s" % (uhs, xsts_token)}, timeout, + ) + mc_token = mc.get("access_token", "") + if not mc_token: + raise RuntimeError("Minecraft 登录失败") + + # 5. 获取角色 profile + req = urllib.request.Request( + "https://api.minecraftservices.com/minecraft/profile", + headers={"Authorization": "Bearer " + mc_token}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + profile = json.loads(resp.read().decode("utf-8")) + if "error" in profile or not profile.get("id"): + raise RuntimeError("未找到正版角色 (微软账号下没有 Minecraft 角色)") + + return { + "access_token": mc_token, + "uuid": profile["id"], + "name": profile["name"], + } + + class MinecraftLauncher: def __init__(self, game_dir, version_id, username="Steve", java_path=None, min_mem=1024, max_mem=2048, auth=None, yggdrasil=None, @@ -711,6 +817,8 @@ class MeowLauncherApp: command=self._toggle_login).pack(side="left", padx=(0, 16)) ttk.Radiobutton(row, text="第三方登录", variable=self.login_mode, value="yggdrasil", command=self._toggle_login).pack(side="left") + ttk.Radiobutton(row, text="正版登录", variable=self.login_mode, value="msa", + command=self._toggle_login).pack(side="left") self.offline_frame = ttk.Frame(login, style="Card.TFrame") self.offline_frame.pack(fill="x") @@ -733,6 +841,17 @@ class MeowLauncherApp: self.ygg_pass = ttk.Entry(self.ygg_frame, width=18, show="*") self.ygg_pass.grid(row=2, column=1, sticky="w", pady=2) + self.msa_frame = ttk.Frame(login, style="Card.TFrame") + self.msa_frame.pack(fill="x") + self.msa_frame.pack_forget() + ttk.Label(self.msa_frame, text="① 点击按钮打开微软登录页", style="Card.TLabel").grid(row=0, column=0, columnspan=2, sticky="w", pady=2) + ttk.Button(self.msa_frame, text="打开浏览器登录", command=self._msa_open_browser).grid(row=1, column=0, columnspan=2, sticky="w", pady=2) + ttk.Label(self.msa_frame, text="② 登录后复制浏览器地址栏完整 URL", style="Card.TLabel").grid(row=2, column=0, columnspan=2, sticky="w", pady=2) + self.msa_callback = ttk.Entry(self.msa_frame, width=34) + self.msa_callback.grid(row=3, column=0, columnspan=2, sticky="we", pady=2) + ttk.Button(self.msa_frame, text="完成登录", command=self._msa_finish_login).grid(row=4, column=0, columnspan=2, sticky="w", pady=2) + ttk.Label(self.msa_frame, text="", style="Card.TLabel").grid(row=5, column=0, columnspan=2, sticky="w", pady=2) + ver = self._card(left, "游戏版本") row = ttk.Frame(ver, style="Card.TFrame") row.pack(fill="x") @@ -899,13 +1018,44 @@ class MeowLauncherApp: threading.Thread(target=worker, daemon=True).start() def _toggle_login(self): - """切换离线/第三方登录表单的显示。""" - if self.login_mode.get() == "offline": + """切换离线/第三方/正版登录表单的显示。""" + mode = self.login_mode.get() + self.offline_frame.pack_forget() + self.ygg_frame.pack_forget() + self.msa_frame.pack_forget() + if mode == "offline": self.offline_frame.pack(fill="x") - self.ygg_frame.pack_forget() - else: - self.offline_frame.pack_forget() + elif mode == "yggdrasil": self.ygg_frame.pack(fill="x") + else: + self.msa_frame.pack(fill="x") + + def _msa_open_browser(self): + """打开微软登录页面。""" + import webbrowser + try: + webbrowser.open(MicrosoftAuth().auth_url()) + self._append_log("[正版] 已打开浏览器, 请登录微软账号后复制地址栏 URL") + except Exception as e: + self._append_log("[正版] 打开浏览器失败: %s" % e) + + def _msa_finish_login(self): + """用回调地址完成正版登录(测试用, 正式登录在启动时)。""" + url = self.msa_callback.get().strip() + if not url: + self._append_log("[正版] 请先粘贴浏览器地址栏 URL") + return + threading.Thread(target=self._msa_login_worker, args=(url,), daemon=True).start() + + def _msa_login_worker(self, url): + """后台线程: 完成微软正版登录。""" + try: + self._append_log("[正版] 正在完成登录 ...") + self.auth = MicrosoftAuth().login(url) + self.yggdrasil = None + self._append_log("[正版] 登录成功! 正版角色: %s" % self.auth["name"]) + except Exception as e: + self._append_log("[正版] 登录失败: %s" % e) def _pick_java(self): """弹出文件选择框, 选择 java 可执行文件。""" @@ -1021,6 +1171,14 @@ class MeowLauncherApp: self.yggdrasil = YggdrasilAuth(server_url) self.auth = self.yggdrasil.authenticate(user, pwd) self._append_log("[登录] 成功! 角色: %s" % self.auth["name"]) + elif self.login_mode.get() == "msa": + url = self.msa_callback.get().strip() + if not url: + raise RuntimeError("请先点击打开浏览器登录并粘贴地址栏 URL") + self._append_log("[登录] 正在完成微软正版登录 ...") + self.auth = MicrosoftAuth().login(url) + self.yggdrasil = None + self._append_log("[登录] 正版登录成功! 角色: %s" % self.auth["name"]) else: self.auth = {"name": self.offline_name.get().strip() or "Steve"} self.yggdrasil = None