Files
MeowLauncher/launcher.py
T
MCLauncher e4cc14cb9e feat: v1.2.926 资源中心
- 启动器内一键下载游戏版本 (官方源)
- Mod & 插件下载 (Modrinth API, 支持MC版本/加载器筛选)
- GUI改为标签页布局: 启动器 + 资源中心
- 版本号升级至 1.2.926
2026-08-06 01:48:45 +08:00

946 lines
39 KiB
Python

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.2.926"
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: 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)
class ModrinthClient:
BASE = "https://api.modrinth.com/v2"
def search(self, query="", game_versions=None, loaders=None, project_type=None, limit=20):
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):
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):
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
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:
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:
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
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:
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"]
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", []),
}
try:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
TK_OK = True
except Exception:
TK_OK = False
class MeowLauncherApp:
BG = "#1e1e2e"
CARD = "#2a2a3d"
ACCENT = "#4ade80"
TEXT = "#e2e8f0"
DIM = "#94a3b8"
def __init__(self):
self.root = tk.Tk()
self.root.title("MeowLauncher %s - Minecraft 启动器" % VERSION)
self.root.geometry("600x780")
self.root.minsize(540, 680)
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()
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)
style.configure("Treeview", background=self.CARD, fieldbackground=self.CARD,
foreground=self.TEXT)
style.configure("Treeview.Heading", background=self.CARD, foreground=self.TEXT)
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=(14, 0))
ttk.Label(self.root, text="跨平台 Minecraft 启动器 v%s" % VERSION, style="Dim.TLabel").pack()
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(fill="both", expand=True, padx=10, pady=8)
self.tab_launch = ttk.Frame(self.notebook)
self.tab_res = ttk.Frame(self.notebook)
self.notebook.add(self.tab_launch, text=" 启动器 ")
self.notebook.add(self.tab_res, text=" 资源中心 ")
self._build_launch_tab()
self._build_res_tab()
def _build_launch_tab(self):
login = self._card(self.tab_launch, "账号登录")
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.tab_launch, "游戏版本")
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.tab_launch, "启动设置")
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.tab_launch, "日志")
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.tab_launch, text="启动游戏", style="Accent.TButton",
command=self._on_launch)
self.launch_btn.pack(fill="x", padx=14, pady=14)
def _build_res_tab(self):
dver = self._card(self.tab_res, "游戏版本下载")
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(self.tab_res, "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=190)
self.res_tree.column("author", width=90)
self.res_tree.column("downloads", width=80)
self.res_tree.pack(fill="x", pady=6)
ttk.Button(dmod, text="下载选中项", command=self._download_selected).pack(anchor="w")
self.res_log = scrolledtext.ScrolledText(self.tab_res, height=5, bg="#151521",
fg=self.TEXT, font=("Consolas", 9),
relief="flat", state="disabled")
self.res_log.pack(fill="x", padx=14, pady=(4, 6))
self.res_progress = ttk.Progressbar(self.tab_res, mode="determinate", maximum=100)
self.res_progress.pack(fill="x", padx=14, pady=(0, 10))
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):
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):
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):
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 == "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")
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())