3 Commits
Author SHA1 Message Date
MCLauncher 464fbd1bae feat: 支持Microsoft正版登录, v1.3.0-Birthday
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
2026-08-11 16:53:31 +08:00
MCLauncher 924f8d27cd docs: 更新macOS停止更新公告(预计1.5.x) 2026-08-11 16:49:53 +08:00
MCLauncher c9cdad2917 docs: 添加macOS停止更新公告 2026-08-06 22:27:00 +08:00
2 changed files with 172 additions and 10 deletions

No files matched your search

+5 -1
View File
@@ -1,3 +1,6 @@
> [!IMPORTANT]
> 📢 **公告**:预计在 1.5.x 版本开始,停止对 macOS 平台的更新。
# MeowLauncher 🐱 # MeowLauncher 🐱
一个使用纯 Python 标准库实现的跨平台 Minecraft 启动器,带图形界面,支持第三方登录。 一个使用纯 Python 标准库实现的跨平台 Minecraft 启动器,带图形界面,支持第三方登录。
@@ -7,6 +10,7 @@
- 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致 - 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致
- 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth) - 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth)
- 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议) - 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议)
- ✅ 正版登录:支持 Microsoft 账号正版登录(OAuth 授权流程)
- 🎭 离线模式:自动生成与官方一致的离线 UUID - 🎭 离线模式:自动生成与官方一致的离线 UUID
- 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`) - 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`)
- ⬇️ 自动下载客户端、依赖库、原生库、资源文件,SHA1 校验 + 断点续传 - ⬇️ 自动下载客户端、依赖库、原生库、资源文件,SHA1 校验 + 断点续传
@@ -32,7 +36,7 @@ python3 launcher.py
- macOS: 解压 `MeowLauncher-macos.zip` 后打开 `MeowLauncher.app` - macOS: 解压 `MeowLauncher-macos.zip` 后打开 `MeowLauncher.app`
界面操作: 界面操作:
1. 选择登录方式:离线模式(填玩家名)或第三方登录(选皮肤站、填账号密码) 1. 选择登录方式:离线模式(填玩家名)、第三方登录(选皮肤站、填账号密码)或正版登录(微软账号,点「打开浏览器登录」→ 复制地址栏 URL 回来)
2. 选择游戏版本(可勾选显示快照版) 2. 选择游戏版本(可勾选显示快照版)
3. 设置最大内存、Java 路径、游戏目录(一般保持默认即可) 3. 设置最大内存、Java 路径、游戏目录(一般保持默认即可)
4. 点击「启动游戏」 4. 点击「启动游戏」
+167 -9
View File
@@ -39,7 +39,7 @@ from pathlib import Path
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" 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" 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 USER_AGENT = "Mozilla/5.0 (MeowLauncher/%s)" % VERSION
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux") 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() return resp.read()
def http_post_json(url: str, payload: dict, timeout: int = 30) -> dict: def http_post_json(url: str, payload, timeout: int = 30, content_type: str = None) -> dict:
data = json.dumps(payload).encode("utf-8") 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={ req = urllib.request.Request(url, data=data, headers={
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
"Content-Type": "application/json", "Content-Type": ctype,
}) })
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
@@ -258,6 +266,104 @@ class YggdrasilAuth:
# 核心启动器 # 核心启动器
# 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 Java 进程 # 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 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: class MinecraftLauncher:
def __init__(self, game_dir, version_id, username="Steve", java_path=None, def __init__(self, game_dir, version_id, username="Steve", java_path=None,
min_mem=1024, max_mem=2048, auth=None, yggdrasil=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)) command=self._toggle_login).pack(side="left", padx=(0, 16))
ttk.Radiobutton(row, text="第三方登录", variable=self.login_mode, value="yggdrasil", ttk.Radiobutton(row, text="第三方登录", variable=self.login_mode, value="yggdrasil",
command=self._toggle_login).pack(side="left") 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 = ttk.Frame(login, style="Card.TFrame")
self.offline_frame.pack(fill="x") 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 = ttk.Entry(self.ygg_frame, width=18, show="*")
self.ygg_pass.grid(row=2, column=1, sticky="w", pady=2) 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, "游戏版本") ver = self._card(left, "游戏版本")
row = ttk.Frame(ver, style="Card.TFrame") row = ttk.Frame(ver, style="Card.TFrame")
row.pack(fill="x") row.pack(fill="x")
@@ -899,13 +1018,44 @@ class MeowLauncherApp:
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
def _toggle_login(self): 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.offline_frame.pack(fill="x")
self.ygg_frame.pack_forget() elif mode == "yggdrasil":
else:
self.offline_frame.pack_forget()
self.ygg_frame.pack(fill="x") 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): def _pick_java(self):
"""弹出文件选择框, 选择 java 可执行文件。""" """弹出文件选择框, 选择 java 可执行文件。"""
@@ -1021,6 +1171,14 @@ class MeowLauncherApp:
self.yggdrasil = YggdrasilAuth(server_url) self.yggdrasil = YggdrasilAuth(server_url)
self.auth = self.yggdrasil.authenticate(user, pwd) self.auth = self.yggdrasil.authenticate(user, pwd)
self._append_log("[登录] 成功! 角色: %s" % self.auth["name"]) 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: else:
self.auth = {"name": self.offline_name.get().strip() or "Steve"} self.auth = {"name": self.offline_name.get().strip() or "Steve"}
self.yggdrasil = None self.yggdrasil = None