Files
MCLauncher 464fbd1bae
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: 支持Microsoft正版登录, v1.3.0-Birthday
2026-08-11 16:53:31 +08:00

1259 lines
54 KiB
Python

"""
MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
功能特性:
- 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致
- 离线账号与第三方 Yggdrasil 登录 (LittleSkin 等 authlib-injector 皮肤站)
- 资源中心: 启动器内一键下载游戏版本 / Mod / 插件 (Modrinth 开放 API)
- 自动下载依赖库 / 原生库 / 资源文件, 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.parse
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"
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")
_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, 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": ctype,
})
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)
# ---------------------------------------------------------------------------
# Modrinth 资源站客户端
# 用于在启动器内搜索和下载 Mod / 插件
# 使用官方开放 API (https://api.modrinth.com), 无需注册密钥
# ---------------------------------------------------------------------------
class ModrinthClient:
BASE = "https://api.modrinth.com/v2"
def search(self, query="", game_versions=None, loaders=None, project_type=None, limit=20):
"""搜索 Mod/插件项目。query 为关键词, game_versions 过滤MC版本, loaders 过滤加载器, project_type 为 mod 或 plugin。"""
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):
"""获取某个项目适配指定 MC 版本与加载器的版本列表。"""
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):
"""获取第一个可下载的 jar 文件 (含下载地址/文件名/大小)。"""
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
# ---------------------------------------------------------------------------
# 第三方登录 (Yggdrasil / authlib-injector 协议)
# 支持 LittleSkin、Blessing Skin 等皮肤站, 登录获取正版角色信息
# ---------------------------------------------------------------------------
class YggdrasilAuth:
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 = 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
# ---------------------------------------------------------------------------
# 核心启动器
# 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 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,
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
self.progress_cb = progress_cb
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:
"""拉取 Mojang 官方版本清单。"""
return json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
def resolve_version(self) -> None:
"""把版本号解析成版本元数据, 支持 latest-release / latest-snapshot。"""
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:
"""输出日志: 同时打印到控制台和回调 GUI。"""
log(msg)
if self.progress_cb:
self.progress_cb(msg)
def download_client(self) -> Path:
"""下载客户端 jar 文件到 versions/<id>/。"""
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:
"""下载全部依赖库与原生库, 返回 classpath 列表。"""
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) 解压到 versions/<id>/natives 目录。"""
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:
"""下载资源索引与全部资源对象到 assets/。"""
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:
"""按官方离线模式规则, 由玩家名生成稳定 UUID。"""
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 参数与游戏参数, 兼容 1.13+ 与旧版两种格式。"""
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:
"""查找 Java: 优先用户指定 -> JAVA_HOME -> PATH。"""
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:
"""组装完整 java 启动命令, 含第三方登录时注入 authlib-injector。"""
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"]
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:
"""获取版本清单 (供 GUI 和 CLI 使用)。"""
manifest = self.fetch_version_manifest()
return {
"latest": manifest.get("latest", {}),
"versions": manifest.get("versions", []),
}
try:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
TK_OK = True
except Exception:
TK_OK = False
# ---------------------------------------------------------------------------
# 图形界面 (Tkinter)
# 两个标签页: [启动器] 登录/版本/设置/日志, [资源中心] 版本与Mod插件下载
# ---------------------------------------------------------------------------
class MeowLauncherApp:
BG = "#eef2f9"
CARD = "#ffffff"
ACCENT = "#2563eb"
ACCENT_LIGHT = "#60a5fa"
ACCENT_DARK = "#1d4ed8"
TEXT = "#1e293b"
DIM = "#64748b"
def __init__(self):
self.root = tk.Tk()
self.root.title("MeowLauncher %s - Minecraft 启动器" % VERSION)
self.root.geometry("980x620")
self.root.resizable(False, False)
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()
self.root.after(100, self._poll_queue)
self.refresh_versions()
self._fade_in()
def _setup_style(self):
"""配置 ttk 深色主题, 保证三端外观一致。"""
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="#ffffff",
font=("Microsoft YaHei UI", 11, "bold"), padding=(16, 10))
style.map("Accent.TButton",
background=[("active", self.ACCENT_LIGHT), ("pressed", self.ACCENT_DARK)])
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="#e2e8f0")
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,
foreground=self.TEXT)
style.configure("Treeview.Heading", background="#eef2f9", foreground=self.TEXT)
style.map("Treeview", background=[("selected", self.ACCENT_LIGHT)],
foreground=[("selected", "#ffffff")])
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):
"""构建主窗口: 左侧导航 + 右侧内容页。"""
self.nav = ttk.Frame(self.root, style="Nav.TFrame", width=200)
self.nav.pack(side="left", fill="y")
self.nav.pack_propagate(False)
ttk.Label(self.nav, text="🐱", style="NavLogo.TLabel").pack(pady=(26, 0))
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.nav_launch_btn = ttk.Button(self.nav, text=" 🚀 启动游戏",
style="NavButton.TButton",
command=lambda: self._switch_page("launch"))
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_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):
"""构建[启动游戏]页: 左列设置 + 右列日志与启动按钮 (PCL 风格)。"""
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")
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="第三方登录", 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")
ttk.Label(self.offline_frame, text="玩家名:", style="Card.TLabel").pack(side="left")
self.offline_name = ttk.Entry(self.offline_frame, width=22)
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=18)
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=18)
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=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")
self.version_var = tk.StringVar()
self.version_box = ttk.Combobox(row, textvariable=self.version_var, state="readonly", width=22)
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(left, "启动设置")
row = ttk.Frame(cfg, style="Card.TFrame")
row.pack(fill="x", pady=3)
ttk.Label(row, text="内存 (MB):", style="Card.TLabel").pack(side="left")
self.mem_var = tk.StringVar(value="2048")
ttk.Entry(row, textvariable=self.mem_var, width=8).pack(side="left", padx=8)
row = ttk.Frame(cfg, style="Card.TFrame")
row.pack(fill="x", pady=3)
ttk.Label(row, text="Java 路径:", style="Card.TLabel").pack(side="left")
self.java_var = tk.StringVar()
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)
row = ttk.Frame(cfg, style="Card.TFrame")
row.pack(fill="x", pady=3)
ttk.Label(row, text="游戏目录:", style="Card.TLabel").pack(side="left")
self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft"))
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)
log_card = self._card(right, "日志")
self.log_box = scrolledtext.ScrolledText(log_card, height=10, bg="#f8fafc",
fg=self.TEXT, font=("Consolas", 9),
relief="flat", state="disabled")
self.log_box.pack(fill="both", expand=True)
self.progress = ttk.Progressbar(log_card, mode="determinate", maximum=100)
self.progress.pack(fill="x", pady=(8, 0))
self.launch_btn = ttk.Button(right, text="启动游戏", style="Accent.TButton",
command=self._on_launch)
self.launch_btn.pack(fill="x", pady=(12, 0))
def _build_res_tab(self):
"""构建[资源中心]页: 版本一键下载 + Mod/插件搜索下载。"""
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.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(body, "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=280)
self.res_tree.column("author", width=130)
self.res_tree.column("downloads", width=100)
self.res_tree.pack(fill="x", pady=6)
ttk.Button(dmod, text="下载选中项", command=self._download_selected).pack(anchor="w")
self.res_log = scrolledtext.ScrolledText(body, height=5, bg="#f8fafc",
fg=self.TEXT, font=("Consolas", 9),
relief="flat", state="disabled")
self.res_log.pack(fill="x", pady=(6, 0))
self.res_progress = ttk.Progressbar(body, mode="determinate", maximum=100)
self.res_progress.pack(fill="x", pady=(8, 0))
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):
"""后台线程搜索 Modrinth, 结果回填列表。"""
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):
"""下载列表中选中的 Mod/插件 jar 到 mods/ 或 plugins/ 目录。"""
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):
"""切换离线/第三方/正版登录表单的显示。"""
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")
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 可执行文件。"""
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 _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):
"""定时轮询消息队列, 把后台线程的日志/结果刷到界面。"""
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 == "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":
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 ""))
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")
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")
self._busy_button("启动中")
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"])
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
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())