Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464fbd1bae | ||
|
|
924f8d27cd | ||
|
|
c9cdad2917 | ||
|
|
a44d2afca5 | ||
|
|
393aa91302 |
No files matched your search
@@ -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. 点击「启动游戏」
|
||||||
|
|||||||
+307
-68
@@ -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.2.926"
|
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,
|
||||||
@@ -553,17 +659,19 @@ except Exception:
|
|||||||
class MeowLauncherApp:
|
class MeowLauncherApp:
|
||||||
|
|
||||||
|
|
||||||
BG = "#1e1e2e"
|
BG = "#eef2f9"
|
||||||
CARD = "#2a2a3d"
|
CARD = "#ffffff"
|
||||||
ACCENT = "#4ade80"
|
ACCENT = "#2563eb"
|
||||||
TEXT = "#e2e8f0"
|
ACCENT_LIGHT = "#60a5fa"
|
||||||
DIM = "#94a3b8"
|
ACCENT_DARK = "#1d4ed8"
|
||||||
|
TEXT = "#1e293b"
|
||||||
|
DIM = "#64748b"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.root = tk.Tk()
|
self.root = tk.Tk()
|
||||||
self.root.title("MeowLauncher %s - Minecraft 启动器" % VERSION)
|
self.root.title("MeowLauncher %s - Minecraft 启动器" % VERSION)
|
||||||
self.root.geometry("600x780")
|
self.root.geometry("980x620")
|
||||||
self.root.minsize(540, 680)
|
self.root.resizable(False, False)
|
||||||
self.root.configure(bg=self.BG)
|
self.root.configure(bg=self.BG)
|
||||||
|
|
||||||
self.msg_queue = queue.Queue()
|
self.msg_queue = queue.Queue()
|
||||||
@@ -577,6 +685,7 @@ class MeowLauncherApp:
|
|||||||
self._build_ui()
|
self._build_ui()
|
||||||
self.root.after(100, self._poll_queue)
|
self.root.after(100, self._poll_queue)
|
||||||
self.refresh_versions()
|
self.refresh_versions()
|
||||||
|
self._fade_in()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -596,19 +705,41 @@ class MeowLauncherApp:
|
|||||||
style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT,
|
style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT,
|
||||||
font=("Microsoft YaHei UI", 18, "bold"))
|
font=("Microsoft YaHei UI", 18, "bold"))
|
||||||
style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM)
|
style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM)
|
||||||
style.configure("Accent.TButton", background=self.ACCENT, foreground="#0f172a",
|
style.configure("Accent.TButton", background=self.ACCENT, foreground="#ffffff",
|
||||||
font=("Microsoft YaHei UI", 11, "bold"))
|
font=("Microsoft YaHei UI", 11, "bold"), padding=(16, 10))
|
||||||
style.map("Accent.TButton",
|
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,
|
style.configure("TCombobox", fieldbackground=self.CARD, background=self.CARD,
|
||||||
foreground=self.TEXT, arrowcolor=self.TEXT)
|
foreground=self.TEXT, arrowcolor=self.TEXT)
|
||||||
style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT)
|
style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT)
|
||||||
style.configure("TCheckbutton", background=self.BG, 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("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,
|
style.configure("Treeview", background=self.CARD, fieldbackground=self.CARD,
|
||||||
foreground=self.TEXT)
|
foreground=self.TEXT)
|
||||||
style.configure("Treeview.Heading", background=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")])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -621,106 +752,154 @@ class MeowLauncherApp:
|
|||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
"""构建主窗口: 标题 + 标签页(启动器/资源中心)。"""
|
"""构建主窗口: 左侧导航 + 右侧内容页。"""
|
||||||
ttk.Label(self.root, text="MeowLauncher", style="Title.TLabel").pack(pady=(14, 0))
|
self.nav = ttk.Frame(self.root, style="Nav.TFrame", width=200)
|
||||||
ttk.Label(self.root, text="跨平台 Minecraft 启动器 v%s" % VERSION, style="Dim.TLabel").pack()
|
self.nav.pack(side="left", fill="y")
|
||||||
|
self.nav.pack_propagate(False)
|
||||||
|
|
||||||
self.notebook = ttk.Notebook(self.root)
|
ttk.Label(self.nav, text="🐱", style="NavLogo.TLabel").pack(pady=(26, 0))
|
||||||
self.notebook.pack(fill="both", expand=True, padx=10, pady=8)
|
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.tab_launch = ttk.Frame(self.notebook)
|
self.nav_launch_btn = ttk.Button(self.nav, text=" 🚀 启动游戏",
|
||||||
self.tab_res = ttk.Frame(self.notebook)
|
style="NavButton.TButton",
|
||||||
self.notebook.add(self.tab_launch, text=" 启动器 ")
|
command=lambda: self._switch_page("launch"))
|
||||||
self.notebook.add(self.tab_res, text=" 资源中心 ")
|
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_launch_tab()
|
||||||
self._build_res_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):
|
def _build_launch_tab(self):
|
||||||
"""构建[启动器]页: 登录、版本、设置、日志、启动按钮。"""
|
"""构建[启动游戏]页: 左列设置 + 右列日志与启动按钮 (PCL 风格)。"""
|
||||||
login = self._card(self.tab_launch, "账号登录")
|
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")
|
self.login_mode = tk.StringVar(value="offline")
|
||||||
row = ttk.Frame(login, style="Card.TFrame")
|
row = ttk.Frame(login, style="Card.TFrame")
|
||||||
row.pack(fill="x", pady=4)
|
row.pack(fill="x", pady=4)
|
||||||
ttk.Radiobutton(row, text="离线模式", variable=self.login_mode, value="offline",
|
ttk.Radiobutton(row, text="离线模式", variable=self.login_mode, value="offline",
|
||||||
command=self._toggle_login).pack(side="left", padx=(0, 16))
|
command=self._toggle_login).pack(side="left", padx=(0, 16))
|
||||||
ttk.Radiobutton(row, text="第三方登录 (Yggdrasil)",
|
ttk.Radiobutton(row, text="第三方登录", variable=self.login_mode, value="yggdrasil",
|
||||||
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")
|
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")
|
||||||
ttk.Label(self.offline_frame, text="玩家名:", style="Card.TLabel").pack(side="left")
|
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.offline_name.pack(side="left", padx=8)
|
||||||
|
|
||||||
self.ygg_frame = ttk.Frame(login, style="Card.TFrame")
|
self.ygg_frame = ttk.Frame(login, style="Card.TFrame")
|
||||||
self.ygg_frame.pack(fill="x")
|
self.ygg_frame.pack(fill="x")
|
||||||
self.ygg_frame.pack_forget()
|
self.ygg_frame.pack_forget()
|
||||||
|
|
||||||
ttk.Label(self.ygg_frame, text="皮肤站:", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=2)
|
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_var = tk.StringVar(value="LittleSkin")
|
||||||
self.server_box = ttk.Combobox(self.ygg_frame, textvariable=self.server_var,
|
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)
|
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)
|
||||||
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 = ttk.Entry(self.ygg_frame, width=24)
|
|
||||||
self.ygg_user.grid(row=1, column=1, sticky="w", pady=2)
|
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)
|
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)
|
self.ygg_pass.grid(row=2, column=1, sticky="w", pady=2)
|
||||||
|
|
||||||
ttk.Label(self.ygg_frame, text="支持 LittleSkin 等 authlib-injector 皮肤站",
|
self.msa_frame = ttk.Frame(login, style="Card.TFrame")
|
||||||
style="Card.TLabel").grid(row=3, column=0, columnspan=2, sticky="w", pady=2)
|
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(self.tab_launch, "游戏版本")
|
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")
|
||||||
self.version_var = tk.StringVar()
|
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")
|
self.version_box.pack(side="left")
|
||||||
ttk.Button(row, text="刷新", command=self.refresh_versions).pack(side="left", padx=8)
|
ttk.Button(row, text="刷新", command=self.refresh_versions).pack(side="left", padx=8)
|
||||||
self.show_snapshot = tk.BooleanVar(value=False)
|
self.show_snapshot = tk.BooleanVar(value=False)
|
||||||
ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot,
|
ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot,
|
||||||
command=self.refresh_versions).pack(anchor="w", pady=(6, 0))
|
command=self.refresh_versions).pack(anchor="w", pady=(6, 0))
|
||||||
|
|
||||||
cfg = self._card(self.tab_launch, "启动设置")
|
cfg = self._card(left, "启动设置")
|
||||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||||
row.pack(fill="x", pady=3)
|
row.pack(fill="x", pady=3)
|
||||||
ttk.Label(row, text="最大内存 (MB):", style="Card.TLabel").pack(side="left")
|
ttk.Label(row, text="内存 (MB):", style="Card.TLabel").pack(side="left")
|
||||||
self.mem_var = tk.StringVar(value="2048")
|
self.mem_var = tk.StringVar(value="2048")
|
||||||
ttk.Entry(row, textvariable=self.mem_var, width=10).pack(side="left", padx=8)
|
ttk.Entry(row, textvariable=self.mem_var, width=8).pack(side="left", padx=8)
|
||||||
|
|
||||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||||
row.pack(fill="x", pady=3)
|
row.pack(fill="x", pady=3)
|
||||||
ttk.Label(row, text="Java 路径 (留空自动检测):", style="Card.TLabel").pack(side="left")
|
ttk.Label(row, text="Java 路径:", style="Card.TLabel").pack(side="left")
|
||||||
self.java_var = tk.StringVar()
|
self.java_var = tk.StringVar()
|
||||||
ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left", padx=8)
|
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.Button(row, text="浏览", command=self._pick_java).pack(side="left", padx=4)
|
||||||
|
|
||||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||||
row.pack(fill="x", pady=3)
|
row.pack(fill="x", pady=3)
|
||||||
ttk.Label(row, text="游戏目录:", style="Card.TLabel").pack(side="left")
|
ttk.Label(row, text="游戏目录:", style="Card.TLabel").pack(side="left")
|
||||||
self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft"))
|
self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft"))
|
||||||
ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left", padx=8)
|
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)
|
ttk.Button(row, text="浏览", command=self._pick_dir).pack(side="left", padx=4)
|
||||||
|
|
||||||
log_card = self._card(self.tab_launch, "日志")
|
log_card = self._card(right, "日志")
|
||||||
self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#151521",
|
self.log_box = scrolledtext.ScrolledText(log_card, height=10, bg="#f8fafc",
|
||||||
fg=self.TEXT, font=("Consolas", 9),
|
fg=self.TEXT, font=("Consolas", 9),
|
||||||
relief="flat", state="disabled")
|
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 = ttk.Progressbar(log_card, mode="determinate", maximum=100)
|
||||||
self.progress.pack(fill="x", pady=(8, 0))
|
self.progress.pack(fill="x", pady=(8, 0))
|
||||||
|
|
||||||
self.launch_btn = ttk.Button(self.tab_launch, text="启动游戏", style="Accent.TButton",
|
self.launch_btn = ttk.Button(right, text="启动游戏", style="Accent.TButton",
|
||||||
command=self._on_launch)
|
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):
|
def _build_res_tab(self):
|
||||||
"""构建[资源中心]页: 版本一键下载 + Mod/插件搜索下载。"""
|
"""构建[资源中心]页: 版本一键下载 + Mod/插件搜索下载。"""
|
||||||
dver = self._card(self.tab_res, "游戏版本下载")
|
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 = ttk.Frame(dver, style="Card.TFrame")
|
||||||
row.pack(fill="x")
|
row.pack(fill="x")
|
||||||
self.res_version_var = tk.StringVar()
|
self.res_version_var = tk.StringVar()
|
||||||
@@ -729,7 +908,7 @@ class MeowLauncherApp:
|
|||||||
ttk.Button(row, text="刷新列表", command=self.refresh_versions).pack(side="left", padx=6)
|
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)
|
ttk.Button(row, text="一键下载", command=self._download_version_only).pack(side="left", padx=6)
|
||||||
|
|
||||||
dmod = self._card(self.tab_res, "Mod / 插件下载 (Modrinth)")
|
dmod = self._card(body, "Mod / 插件下载 (Modrinth)")
|
||||||
row = ttk.Frame(dmod, style="Card.TFrame")
|
row = ttk.Frame(dmod, style="Card.TFrame")
|
||||||
row.pack(fill="x")
|
row.pack(fill="x")
|
||||||
self.res_query = ttk.Entry(row, width=14)
|
self.res_query = ttk.Entry(row, width=14)
|
||||||
@@ -752,18 +931,18 @@ class MeowLauncherApp:
|
|||||||
self.res_tree.heading("title", text="名称")
|
self.res_tree.heading("title", text="名称")
|
||||||
self.res_tree.heading("author", text="作者")
|
self.res_tree.heading("author", text="作者")
|
||||||
self.res_tree.heading("downloads", text="下载量")
|
self.res_tree.heading("downloads", text="下载量")
|
||||||
self.res_tree.column("title", width=190)
|
self.res_tree.column("title", width=280)
|
||||||
self.res_tree.column("author", width=90)
|
self.res_tree.column("author", width=130)
|
||||||
self.res_tree.column("downloads", width=80)
|
self.res_tree.column("downloads", width=100)
|
||||||
self.res_tree.pack(fill="x", pady=6)
|
self.res_tree.pack(fill="x", pady=6)
|
||||||
ttk.Button(dmod, text="下载选中项", command=self._download_selected).pack(anchor="w")
|
ttk.Button(dmod, text="下载选中项", command=self._download_selected).pack(anchor="w")
|
||||||
|
|
||||||
self.res_log = scrolledtext.ScrolledText(self.tab_res, height=5, bg="#151521",
|
self.res_log = scrolledtext.ScrolledText(body, height=5, bg="#f8fafc",
|
||||||
fg=self.TEXT, font=("Consolas", 9),
|
fg=self.TEXT, font=("Consolas", 9),
|
||||||
relief="flat", state="disabled")
|
relief="flat", state="disabled")
|
||||||
self.res_log.pack(fill="x", padx=14, pady=(4, 6))
|
self.res_log.pack(fill="x", pady=(6, 0))
|
||||||
self.res_progress = ttk.Progressbar(self.tab_res, mode="determinate", maximum=100)
|
self.res_progress = ttk.Progressbar(body, mode="determinate", maximum=100)
|
||||||
self.res_progress.pack(fill="x", padx=14, pady=(0, 10))
|
self.res_progress.pack(fill="x", pady=(8, 0))
|
||||||
|
|
||||||
def _res_append(self, msg):
|
def _res_append(self, msg):
|
||||||
"""向资源中心日志框追加一行。"""
|
"""向资源中心日志框追加一行。"""
|
||||||
@@ -839,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 可执行文件。"""
|
||||||
@@ -871,6 +1081,26 @@ class MeowLauncherApp:
|
|||||||
self.msg_queue.put(("log", "[版本] 获取失败: %s" % e))
|
self.msg_queue.put(("log", "[版本] 获取失败: %s" % e))
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
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):
|
def _poll_queue(self):
|
||||||
"""定时轮询消息队列, 把后台线程的日志/结果刷到界面。"""
|
"""定时轮询消息队列, 把后台线程的日志/结果刷到界面。"""
|
||||||
try:
|
try:
|
||||||
@@ -922,6 +1152,7 @@ class MeowLauncherApp:
|
|||||||
def _on_launch(self):
|
def _on_launch(self):
|
||||||
"""点击启动游戏: 禁用按钮并开后台线程。"""
|
"""点击启动游戏: 禁用按钮并开后台线程。"""
|
||||||
self.launch_btn.config(state="disabled")
|
self.launch_btn.config(state="disabled")
|
||||||
|
self._busy_button("启动中")
|
||||||
threading.Thread(target=self._launch_worker, daemon=True).start()
|
threading.Thread(target=self._launch_worker, daemon=True).start()
|
||||||
|
|
||||||
def _launch_worker(self):
|
def _launch_worker(self):
|
||||||
@@ -940,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
|
||||||
|
|||||||
Reference in new issue
Block a user