Files
MeowLauncher/launcher.py
T
MCLauncher d5285ee2ff
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
feat: v1.2 图形界面 + 第三方登录
- 全新Tkinter图形界面, 三端UI一致
- 支持LittleSkin等authlib-injector第三方登录
- 自动下载注入authlib-injector.jar
- 保留CLI模式, Windows打包改为无控制台窗口
2026-08-05 23:20:50 +08:00

789 lines
32 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
功能特性:
- 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致
- 支持离线账号与第三方 Yggdrasil 登录 (authlib-injector, 如 LittleSkin)
- 自动获取官方版本清单并下载任意版本
- 自动下载依赖库 / 原生库 / 资源文件, SHA1 校验 + 断点续传
- 保留命令行模式: python launcher.py --cli
用法:
python launcher.py 启动图形界面
python launcher.py --cli ... 命令行模式
"""
import argparse
import hashlib
import json
import os
import platform
import queue
import re
import shutil
import subprocess
import sys
import threading
import urllib.error
import urllib.request
import uuid as uuid_mod
import zipfile
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)"
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux")
_ARCH = platform.machine().lower()
if _ARCH in ("x86_64", "amd64"):
ARCH_NEW, ARCH_OLD = "x86_64", "64"
elif _ARCH in ("aarch64", "arm64"):
ARCH_NEW, ARCH_OLD = "arm64", "arm64"
else:
ARCH_NEW, ARCH_OLD = "x86", "32"
NATIVES_CLASSIFIER = {"linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows"}
def log(msg: str) -> None:
try:
print(msg, flush=True)
except Exception:
pass
def sha1_of(path: Path) -> str:
h = hashlib.sha1()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def http_get(url: str, timeout: int = 30) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def http_post_json(url: str, payload: dict, timeout: int = 30) -> dict:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={
"User-Agent": USER_AGENT,
"Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def download_file(url: str, dest: Path, sha1: str = None, size: int = None,
progress_cb=None) -> bool:
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
ok = True
if sha1:
ok = sha1_of(dest) == sha1
if ok:
return False
dest.unlink()
tmp = dest.with_suffix(dest.suffix + ".tmp")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as f:
total = int(resp.headers.get("Content-Length") or 0)
done = 0
while True:
chunk = resp.read(1024 * 256)
if not chunk:
break
f.write(chunk)
done += len(chunk)
if progress_cb and total:
progress_cb(done, total)
except Exception:
if tmp.exists():
tmp.unlink()
raise
if sha1 and sha1_of(tmp) != sha1:
tmp.unlink()
raise RuntimeError("SHA1 校验失败: %s" % url)
if size and tmp.stat().st_size != size:
tmp.unlink()
raise RuntimeError("文件大小不符: %s" % url)
tmp.rename(dest)
return True
def rule_allowed(rule: dict) -> bool:
if rule.get("action") == "allow":
default = False
else:
default = True
os_rule = rule.get("os", {})
if "name" in os_rule and os_rule["name"] != OS_NAME:
return default
if "arch" in os_rule and os_rule["arch"] not in (ARCH_NEW, ARCH_OLD):
return default
return not default
def library_allowed(lib: dict) -> bool:
rules = lib.get("rules")
if not rules:
return True
return all(rule_allowed(r) for r in rules)
# ---------------------------------------------------------------------------
# 第三方登录 (Yggdrasil / authlib-injector)
# ---------------------------------------------------------------------------
class YggdrasilAuth:
"""authlib-injector 兼容的第三方登录 (LittleSkin 等皮肤站)。"""
PRESETS = {
"LittleSkin": "https://littleskin.cn/api/yggdrasil",
"Blessing Skin": "https://skin.prinzeugen.net/api/yggdrasil",
"自定义": "",
}
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}。"""
client_token = str(uuid_mod.uuid4())
payload = {
"agent": {"name": "Minecraft", "version": 1},
"username": username,
"password": password,
"clientToken": client_token,
"requestUser": True,
}
resp = http_post_json(self.server_url + "/authserver/authenticate", payload)
profile = resp.get("selectedProfile") or {}
if not profile:
raise RuntimeError("账号没有可用的游戏档案 (请先在皮肤站创建角色)")
return {
"access_token": resp.get("accessToken", ""),
"client_token": resp.get("clientToken", client_token),
"uuid": profile.get("id", ""),
"name": profile.get("name", username),
}
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"
if jar.exists():
return jar
try:
meta = json.loads(http_get(AUTHLIB_INJECTOR_API).decode("utf-8"))
url = meta["assets"][0]["browser_download_url"]
except Exception:
url = ("https://github.com/yushijinhun/authlib-injector/releases/"
"download/v1.2.5/authlib-injector-1.2.5.jar")
download_file(url, jar)
return jar
# ---------------------------------------------------------------------------
# 核心启动器
# ---------------------------------------------------------------------------
class MinecraftLauncher:
def __init__(self, game_dir, version_id, username="Steve", java_path=None,
min_mem=1024, max_mem=2048, auth=None, yggdrasil=None,
progress_cb=None):
self.game_dir = Path(game_dir).resolve()
self.version_id = version_id
self.username = username or "Steve"
self.java_path = java_path
self.min_mem = min_mem
self.max_mem = max_mem
self.auth = auth or {}
self.yggdrasil = yggdrasil # YggdrasilAuth 实例或 None
self.progress_cb = progress_cb # callback(msg) 日志; callback2(done,total) 进度
self.versions_dir = self.game_dir / "versions"
self.assets_dir = self.game_dir / "assets"
self.libraries_dir = self.game_dir / "libraries"
self.version_meta = None
self.asset_index = None
self.client_jar = None
self._cp = []
# ---------------- 版本 ----------------
def fetch_version_manifest(self) -> dict:
return json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
def resolve_version(self) -> None:
manifest = self.fetch_version_manifest()
latest = manifest.get("latest", {})
versions = manifest.get("versions", [])
if self.version_id == "latest-release":
self.version_id = latest.get("release")
elif self.version_id == "latest-snapshot":
self.version_id = latest.get("snapshot")
entry = next((v for v in versions if v["id"] == self.version_id), None)
if not entry:
raise RuntimeError("找不到版本: %s" % self.version_id)
self.version_meta = json.loads(http_get(entry["url"]).decode("utf-8"))
self._emit("[版本] %s (%s, 需要Java %s)" % (
self.version_meta.get("id"),
self.version_meta.get("type"),
self.version_meta.get("javaVersion", {}).get("majorVersion", "?")))
# ---------------- 下载 ----------------
def _emit(self, msg: str) -> None:
log(msg)
if self.progress_cb:
self.progress_cb(msg)
def download_client(self) -> Path:
vdir = self.versions_dir / self.version_id
vdir.mkdir(parents=True, exist_ok=True)
jar = vdir / ("%s.jar" % self.version_id)
dl = self.version_meta.get("downloads", {}).get("client")
if not dl:
raise RuntimeError("版本清单中缺少 client 下载项")
download_file(dl["url"], jar, dl.get("sha1"), dl.get("size"))
self.client_jar = jar
self._emit("[客户端] %s 就绪 (%d KB)" % (jar.name, jar.stat().st_size // 1024))
return jar
def download_libraries(self) -> list:
cp = []
total = sum(1 for lib in self.version_meta.get("libraries", []) if library_allowed(lib))
done = 0
for lib in self.version_meta.get("libraries", []):
if not library_allowed(lib):
continue
done += 1
downloads = lib.get("downloads", {})
artifact = downloads.get("artifact")
native = None
classifier = NATIVES_CLASSIFIER.get(OS_NAME)
natives_entry = downloads.get("classifiers", {}).get(classifier) if classifier else None
base = lib["name"].replace(":", "/").replace("@", "/")
if artifact:
path = self.libraries_dir / base
self._download_one(artifact, path, "库", done, total)
cp.append(str(path))
if natives_entry:
path = self.libraries_dir / base
self._download_one(natives_entry, path, "库", done, total)
native = path
if native:
self._extract_natives(native)
self._emit("[库] 依赖库准备完成 (%d 项)" % len(cp))
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_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]
with zipfile.ZipFile(native_jar) as zf:
for name in zf.namelist():
if name.startswith("META-INF") or not name.endswith(ext):
continue
target = natives_dir / Path(name).name
with zf.open(name) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
def download_assets(self) -> None:
index_info = self.version_meta.get("assetIndex")
if not index_info:
self._emit("[资源] 该版本无资源索引, 跳过")
return
index_file = self.assets_dir / "indexes" / ("%s.json" % index_info["id"])
download_file(index_info["url"], index_file, index_info.get("sha1"), index_info.get("size"))
index = json.loads(index_file.read_text(encoding="utf-8"))
objects = index.get("objects", {})
self.asset_index = index_info["id"]
total = len(objects)
done = 0
for key, obj in objects.items():
h = obj["hash"]
dest = self.assets_dir / "objects" / h[:2] / h
url = "https://resources.download.minecraft.net/%s/%s" % (h[:2], h)
try:
download_file(url, dest, h, obj.get("size"))
except Exception as e:
self._emit(" [警告] 资源失败 %s: %s" % (key, e))
done += 1
if done % 500 == 0:
self._emit(" [资源] %d/%d" % (done, total))
self._emit("[资源] 资源文件就绪 (%d 项)" % total)
# ---------------- 启动参数 ----------------
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,
"${auth_session}": self.auth.get("access_token", "0"),
"${auth_access_token}": self.auth.get("access_token", "0"),
"${auth_uuid}": uuid,
"${version_name}": self.version_id,
"${version_type}": self.version_meta.get("type", "release"),
"${game_directory}": str(self.game_dir),
"${assets_root}": str(self.assets_dir),
"${assets_index_name}": self.asset_index or self.version_id,
"${user_properties}": "{}",
"${user_type}": "msa" if not self.yggdrasil else "mojang",
"${resolution_width}": "854",
"${resolution_height}": "480",
"${launcher_name}": "MeowLauncher",
"${launcher_version}": "1.2",
"${natives_directory}": str(self.versions_dir / self.version_id / "natives"),
"${classpath}": os.pathsep.join([str(self.client_jar)] + self._cp),
"${library_directory}": str(self.libraries_dir),
}
for k, v in replace_map.items():
arg = arg.replace(k, v)
return arg
def _offline_uuid(self) -> str:
name = self.auth.get("name") or self.username
digest = bytearray(hashlib.md5(("OfflinePlayer:" + name).encode("utf-8")).digest())
digest[6] = (digest[6] & 0x0F) | 0x30
digest[8] = (digest[8] & 0x3F) | 0x80
hex_str = digest.hex()
return "%s-%s-%s-%s-%s" % (hex_str[0:8], hex_str[8:12], hex_str[12:16],
hex_str[16:20], hex_str[20:32])
def _build_args(self) -> tuple:
jvm_args, game_args = [], []
arguments = self.version_meta.get("arguments")
if arguments:
for item in arguments.get("jvm", []):
if isinstance(item, str):
jvm_args.append(self._resolve_argument(item))
elif isinstance(item, dict) and all(rule_allowed(r) for r in item.get("rules", [])):
for v in item.get("value", []):
jvm_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v))
for item in arguments.get("game", []):
if isinstance(item, str):
game_args.append(self._resolve_argument(item))
elif isinstance(item, dict) and all(rule_allowed(r) for r in item.get("rules", [])):
for v in item.get("value", []):
game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v))
else:
legacy = self.version_meta.get("minecraftArguments", "")
game_args = [self._resolve_argument(a) for a in legacy.split(" ")]
return jvm_args, game_args
def _find_java(self) -> str:
if self.java_path and os.path.isfile(self.java_path):
return self.java_path
java_home = os.environ.get("JAVA_HOME")
if java_home:
cand = Path(java_home) / "bin" / ("java.exe" if OS_NAME == "windows" else "java")
if cand.exists():
return str(cand)
for cand in ("java", "java.exe"):
found = shutil.which(cand)
if found:
return found
raise RuntimeError("未找到 Java, 请在设置中指定 java 可执行文件路径 (1.17+ 需要 Java 17+)")
def build_command(self, classpath: list) -> list:
self._cp = classpath
java = self._find_java()
jvm_args, game_args = self._build_args()
command = [java]
command += ["-Xms%dM" % self.min_mem, "-Xmx%dM" % self.max_mem]
command += ["-Djava.library.path=%s" % (self.versions_dir / self.version_id / "natives")]
command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.2"]
# 第三方登录: 注入 authlib-injector
if self.yggdrasil:
injector = self.yggdrasil.download_injector(self.game_dir)
command += ["-javaagent:%s=%s" % (injector, self.yggdrasil.server_url)]
command += ["-cp", os.pathsep.join([str(self.client_jar)] + classpath)]
command += jvm_args
command += ["net.minecraft.client.main.Main"]
command += game_args
return command
# ---------------- 主流程 ----------------
def run(self) -> int:
self._emit("[启动] 游戏目录: %s" % self.game_dir)
self._emit("[启动] 版本: %s 玩家: %s" % (self.version_id,
self.auth.get("name") or self.username))
self.resolve_version()
self.download_client()
classpath = self.download_libraries()
self.download_assets()
meta_file = self.versions_dir / self.version_id / ("%s.json" % self.version_id)
meta_file.write_text(json.dumps(self.version_meta, indent=2), encoding="utf-8")
command = self.build_command(classpath)
self._emit("[启动] 正在启动 Minecraft ...")
self._emit("[启动] " + " ".join(command[:6]) + " ...")
return subprocess.call(command, cwd=str(self.game_dir))
def list_versions(self) -> dict:
manifest = self.fetch_version_manifest()
return {
"latest": manifest.get("latest", {}),
"versions": manifest.get("versions", []),
}
# ---------------------------------------------------------------------------
# 图形界面 (Tkinter)
# ---------------------------------------------------------------------------
try:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
TK_OK = True
except Exception:
TK_OK = False
class MeowLauncherApp:
"""跨平台图形界面 (Windows / Linux / macOS 外观一致)。"""
# 深色 Minecraft 风配色
BG = "#1e1e2e"
CARD = "#2a2a3d"
ACCENT = "#4ade80"
TEXT = "#e2e8f0"
DIM = "#94a3b8"
def __init__(self):
self.root = tk.Tk()
self.root.title("MeowLauncher - Minecraft 启动器")
self.root.geometry("520x720")
self.root.minsize(480, 640)
self.root.configure(bg=self.BG)
self.msg_queue = queue.Queue()
self.auth = None
self.yggdrasil = None
self.versions_cache = []
self._setup_style()
self._build_ui()
self.root.after(100, self._poll_queue)
self.refresh_versions()
# ---------------- 样式 ----------------
def _setup_style(self):
style = ttk.Style(self.root)
try:
style.theme_use("clam")
except Exception:
pass
style.configure(".", background=self.BG, foreground=self.TEXT,
fieldbackground=self.CARD, font=("Microsoft YaHei UI", 10))
style.configure("TFrame", background=self.BG)
style.configure("Card.TFrame", background=self.CARD)
style.configure("TLabel", background=self.BG, foreground=self.TEXT)
style.configure("Card.TLabel", background=self.CARD, foreground=self.TEXT)
style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT,
font=("Microsoft YaHei UI", 18, "bold"))
style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM)
style.configure("Accent.TButton", background=self.ACCENT, foreground="#0f172a",
font=("Microsoft YaHei UI", 11, "bold"))
style.map("Accent.TButton",
background=[("active", "#22c55e"), ("pressed", "#16a34a")])
style.configure("TCombobox", fieldbackground=self.CARD, background=self.CARD,
foreground=self.TEXT, arrowcolor=self.TEXT)
style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT)
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)
# ---------------- UI 构建 ----------------
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",
font=("Microsoft YaHei UI", 11, "bold")).pack(anchor="w")
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()
# ---- 账号登录 ----
login = self._card(self.root, "账号登录")
self.login_mode = tk.StringVar(value="offline")
row = ttk.Frame(login, style="Card.TFrame")
row.pack(fill="x", pady=4)
ttk.Radiobutton(row, text="离线模式", variable=self.login_mode, value="offline",
command=self._toggle_login).pack(side="left", padx=(0, 16))
ttk.Radiobutton(row, text="第三方登录 (Yggdrasil)",
variable=self.login_mode, value="yggdrasil",
command=self._toggle_login).pack(side="left")
self.offline_frame = ttk.Frame(login, style="Card.TFrame")
self.offline_frame.pack(fill="x")
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.pack(side="left", padx=8)
self.ygg_frame = ttk.Frame(login, style="Card.TFrame")
self.ygg_frame.pack(fill="x")
self.ygg_frame.pack_forget()
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_box = ttk.Combobox(self.ygg_frame, textvariable=self.server_var,
values=list(YggdrasilAuth.PRESETS.keys()), state="readonly", width=22)
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)
self.ygg_user = ttk.Entry(self.ygg_frame, width=24)
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)
self.ygg_pass = ttk.Entry(self.ygg_frame, width=24, show="*")
self.ygg_pass.grid(row=2, column=1, sticky="w", pady=2)
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, "游戏版本")
row = ttk.Frame(ver, style="Card.TFrame")
row.pack(fill="x")
self.version_var = tk.StringVar()
self.version_box = ttk.Combobox(row, textvariable=self.version_var, state="readonly", width=28)
self.version_box.pack(side="left")
ttk.Button(row, text="刷新", command=self.refresh_versions).pack(side="left", padx=8)
self.show_snapshot = tk.BooleanVar(value=False)
ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot,
command=self.refresh_versions).pack(anchor="w", pady=(6, 0))
# ---- 启动设置 ----
cfg = self._card(self.root, "启动设置")
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)
ttk.Label(cfg, text="Java 路径 (留空自动检测):", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=3)
row = ttk.Frame(cfg, style="Card.TFrame")
row.grid(row=1, column=1, sticky="w", pady=3)
self.java_var = tk.StringVar()
ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left")
ttk.Button(row, text="浏览", command=self._pick_java).pack(side="left", padx=4)
ttk.Label(cfg, text="游戏目录:", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=3)
row = ttk.Frame(cfg, style="Card.TFrame")
row.grid(row=2, column=1, sticky="w", pady=3)
self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft"))
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, "日志")
self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#151521",
fg=self.TEXT, font=("Consolas", 9),
relief="flat", state="disabled")
self.log_box.pack(fill="x")
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",
command=self._on_launch)
self.launch_btn.pack(fill="x", padx=14, pady=14)
# ---------------- 事件 ----------------
def _toggle_login(self):
if self.login_mode.get() == "offline":
self.offline_frame.pack(fill="x")
self.ygg_frame.pack_forget()
else:
self.offline_frame.pack_forget()
self.ygg_frame.pack(fill="x")
def _pick_java(self):
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(
game_dir=self.dir_var.get(), version_id="latest-release",
username="x").list_versions()
self.msg_queue.put(("versions", data))
except Exception as e:
self.msg_queue.put(("log", "[版本] 获取失败: %s" % e))
threading.Thread(target=worker, daemon=True).start()
def _poll_queue(self):
try:
while True:
kind, payload = self.msg_queue.get_nowait()
if kind == "log":
self._append_log(payload)
elif kind == "versions":
self.versions_cache = payload["versions"]
self._fill_versions(payload["latest"])
elif kind == "done":
self.launch_btn.config(state="normal")
elif kind == "err":
self._append_log("[错误] %s" % payload)
self.launch_btn.config(state="normal")
except queue.Empty:
pass
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"]
items = list(dict.fromkeys(items))
self.version_box["values"] = items
self.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()
server_url = YggdrasilAuth.PRESETS.get(server_name, "")
if not server_url:
raise RuntimeError("请选择皮肤站")
user = self.ygg_user.get().strip()
pwd = self.ygg_pass.get()
if not user or not pwd:
raise RuntimeError("请输入账号和密码")
self._append_log("[登录] 正在连接 %s ..." % server_name)
self.yggdrasil = YggdrasilAuth(server_url)
self.auth = self.yggdrasil.authenticate(user, pwd)
self._append_log("[登录] 成功! 角色: %s" % self.auth["name"])
else:
self.auth = {"name": self.offline_name.get().strip() or "Steve"}
self.yggdrasil = None
version = self.version_var.get()
if not version:
raise RuntimeError("请选择游戏版本")
launcher = MinecraftLauncher(
game_dir=self.dir_var.get(),
version_id=version,
username=self.auth.get("name", "Steve"),
java_path=self.java_var.get() or None,
max_mem=int(self.mem_var.get() or 2048),
auth=self.auth,
yggdrasil=self.yggdrasil,
progress_cb=self._append_log,
)
code = launcher.run()
self.msg_queue.put(("log", "[退出] Minecraft 已退出 (代码 %s)" % code))
except Exception as e:
self.msg_queue.put(("err", str(e)))
finally:
self.msg_queue.put(("done", None))
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="MeowLauncher - Minecraft 启动器")
parser.add_argument("--cli", action="store_true", help="使用命令行模式")
parser.add_argument("--version", default="latest-release", help="游戏版本")
parser.add_argument("--username", default="Steve", help="玩家名")
parser.add_argument("--game-dir", default=None, help="游戏目录")
parser.add_argument("--java", default=None, help="Java 路径")
parser.add_argument("--min-mem", type=int, default=1024, help="最小内存 MB")
parser.add_argument("--max-mem", type=int, default=2048, help="最大内存 MB")
parser.add_argument("--list-versions", action="store_true", help="列出可用版本")
args = parser.parse_args()
if not args.cli and TK_OK:
app = MeowLauncherApp()
app.root.mainloop()
return 0
# 命令行模式
if args.list_versions:
launcher = MinecraftLauncher(
game_dir=args.game_dir or str(Path.home() / ".minecraft"),
version_id=args.version, username=args.username)
data = launcher.list_versions()
print("最新发布版: %s 最新快照版: %s" % (data["latest"].get("release"),
data["latest"].get("snapshot")))
for v in data["versions"]:
marker = " *" if v["id"] in data["latest"].values() else ""
print(" %-20s %-10s%s" % (v["id"], v["type"], marker))
return 0
launcher = MinecraftLauncher(
game_dir=args.game_dir or str(Path.home() / ".minecraft"),
version_id=args.version, username=args.username,
java_path=args.java, min_mem=args.min_mem, max_mem=args.max_mem,
auth={"name": args.username})
try:
return launcher.run()
except KeyboardInterrupt:
print("\n[退出] 用户中断")
return 130
except Exception as e:
print("[错误] %s" % e)
return 1
if __name__ == "__main__":
sys.exit(main())