Compare commits
6
Commits
v1.2.0
...
v1.3.0-Alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393aa91302 | ||
|
|
f13890da5b | ||
|
|
b6e67db02f | ||
|
|
e4cc14cb9e | ||
|
|
8823b9218d | ||
|
|
84bf271447 |
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 guiyuanit
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致
|
- 🖥️ 图形界面(Tkinter),Windows / Linux / macOS 三端界面一致
|
||||||
|
- 📥 资源中心:启动器内一键下载游戏版本、Mod 与服务器插件(Modrinth)
|
||||||
- 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议)
|
- 🔑 第三方登录:支持 LittleSkin、Blessing Skin 等 authlib-injector 皮肤站(Yggdrasil 协议)
|
||||||
- 🎭 离线模式:自动生成与官方一致的离线 UUID
|
- 🎭 离线模式:自动生成与官方一致的离线 UUID
|
||||||
- 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`)
|
- 📦 自动获取官方版本清单,支持任意版本(`1.20.4`、`latest-release`、`latest-snapshot`)
|
||||||
|
|||||||
+332
-69
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
|
MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
|
||||||
|
|
||||||
功能特性:
|
功能特性:
|
||||||
- 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致
|
- 图形界面 (Tkinter), Windows / Linux / macOS 三端界面一致
|
||||||
- 支持离线账号与第三方 Yggdrasil 登录 (authlib-injector, 如 LittleSkin)
|
- 离线账号与第三方 Yggdrasil 登录 (LittleSkin 等 authlib-injector 皮肤站)
|
||||||
- 自动获取官方版本清单并下载任意版本
|
- 资源中心: 启动器内一键下载游戏版本 / Mod / 插件 (Modrinth 开放 API)
|
||||||
- 自动下载依赖库 / 原生库 / 资源文件, SHA1 校验 + 断点续传
|
- 自动下载依赖库 / 原生库 / 资源文件, SHA1 校验 + 断点续传
|
||||||
- 保留命令行模式: python launcher.py --cli
|
- 保留命令行模式: python launcher.py --cli
|
||||||
|
|
||||||
@@ -14,7 +15,6 @@ MeowLauncher - 跨平台 Minecraft 启动器 (图形界面版)
|
|||||||
python launcher.py 启动图形界面
|
python launcher.py 启动图形界面
|
||||||
python launcher.py --cli ... 命令行模式
|
python launcher.py --cli ... 命令行模式
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -27,18 +27,20 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid as uuid_mod
|
import uuid as uuid_mod
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 常量
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
|
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
|
||||||
AUTHLIB_INJECTOR_API = "https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest"
|
AUTHLIB_INJECTOR_API = "https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest"
|
||||||
USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.2)"
|
VERSION = "1.3.0-Alpha"
|
||||||
|
USER_AGENT = "Mozilla/5.0 (MeowLauncher/%s)" % VERSION
|
||||||
|
|
||||||
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux")
|
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux")
|
||||||
|
|
||||||
@@ -145,12 +147,60 @@ def library_allowed(lib: dict) -> bool:
|
|||||||
return all(rule_allowed(r) for r in rules)
|
return all(rule_allowed(r) for r in rules)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 第三方登录 (Yggdrasil / authlib-injector)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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:
|
class YggdrasilAuth:
|
||||||
"""authlib-injector 兼容的第三方登录 (LittleSkin 等皮肤站)。"""
|
|
||||||
|
|
||||||
PRESETS = {
|
PRESETS = {
|
||||||
"LittleSkin": "https://littleskin.cn/api/yggdrasil",
|
"LittleSkin": "https://littleskin.cn/api/yggdrasil",
|
||||||
@@ -159,10 +209,11 @@ class YggdrasilAuth:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, server_url: str):
|
def __init__(self, server_url: str):
|
||||||
|
"""初始化窗口、配色与资源中心状态。"""
|
||||||
self.server_url = server_url.rstrip("/")
|
self.server_url = server_url.rstrip("/")
|
||||||
|
|
||||||
def authenticate(self, username: str, password: str) -> dict:
|
def authenticate(self, username: str, password: str) -> dict:
|
||||||
"""登录并返回 {access_token, uuid, name, client_token}。"""
|
"""登录第三方皮肤站, 返回 {access_token, uuid, name} 账号信息。"""
|
||||||
client_token = str(uuid_mod.uuid4())
|
client_token = str(uuid_mod.uuid4())
|
||||||
payload = {
|
payload = {
|
||||||
"agent": {"name": "Minecraft", "version": 1},
|
"agent": {"name": "Minecraft", "version": 1},
|
||||||
@@ -183,7 +234,7 @@ class YggdrasilAuth:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def download_injector(self, game_dir: Path) -> Path:
|
def download_injector(self, game_dir: Path) -> Path:
|
||||||
"""下载最新版 authlib-injector.jar, 返回路径。"""
|
"""下载最新版 authlib-injector.jar, 启动游戏时注入用。"""
|
||||||
lib_dir = game_dir / "libraries" / "authlib-injector"
|
lib_dir = game_dir / "libraries" / "authlib-injector"
|
||||||
lib_dir.mkdir(parents=True, exist_ok=True)
|
lib_dir.mkdir(parents=True, exist_ok=True)
|
||||||
jar = lib_dir / "authlib-injector.jar"
|
jar = lib_dir / "authlib-injector.jar"
|
||||||
@@ -199,10 +250,14 @@ class YggdrasilAuth:
|
|||||||
return jar
|
return jar
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 核心启动器
|
# 核心启动器
|
||||||
|
# 负责: 版本解析 -> 文件下载 -> 启动参数构建 -> 拉起 Java 进程
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class MinecraftLauncher:
|
class MinecraftLauncher:
|
||||||
def __init__(self, game_dir, version_id, username="Steve", java_path=None,
|
def __init__(self, game_dir, version_id, username="Steve", java_path=None,
|
||||||
min_mem=1024, max_mem=2048, auth=None, yggdrasil=None,
|
min_mem=1024, max_mem=2048, auth=None, yggdrasil=None,
|
||||||
@@ -214,8 +269,8 @@ class MinecraftLauncher:
|
|||||||
self.min_mem = min_mem
|
self.min_mem = min_mem
|
||||||
self.max_mem = max_mem
|
self.max_mem = max_mem
|
||||||
self.auth = auth or {}
|
self.auth = auth or {}
|
||||||
self.yggdrasil = yggdrasil # YggdrasilAuth 实例或 None
|
self.yggdrasil = yggdrasil
|
||||||
self.progress_cb = progress_cb # callback(msg) 日志; callback2(done,total) 进度
|
self.progress_cb = progress_cb
|
||||||
|
|
||||||
self.versions_dir = self.game_dir / "versions"
|
self.versions_dir = self.game_dir / "versions"
|
||||||
self.assets_dir = self.game_dir / "assets"
|
self.assets_dir = self.game_dir / "assets"
|
||||||
@@ -226,12 +281,14 @@ class MinecraftLauncher:
|
|||||||
self.client_jar = None
|
self.client_jar = None
|
||||||
self._cp = []
|
self._cp = []
|
||||||
|
|
||||||
# ---------------- 版本 ----------------
|
|
||||||
|
|
||||||
def fetch_version_manifest(self) -> dict:
|
def fetch_version_manifest(self) -> dict:
|
||||||
|
"""拉取 Mojang 官方版本清单。"""
|
||||||
return json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
|
return json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
|
||||||
|
|
||||||
def resolve_version(self) -> None:
|
def resolve_version(self) -> None:
|
||||||
|
"""把版本号解析成版本元数据, 支持 latest-release / latest-snapshot。"""
|
||||||
manifest = self.fetch_version_manifest()
|
manifest = self.fetch_version_manifest()
|
||||||
latest = manifest.get("latest", {})
|
latest = manifest.get("latest", {})
|
||||||
versions = manifest.get("versions", [])
|
versions = manifest.get("versions", [])
|
||||||
@@ -251,14 +308,16 @@ class MinecraftLauncher:
|
|||||||
self.version_meta.get("type"),
|
self.version_meta.get("type"),
|
||||||
self.version_meta.get("javaVersion", {}).get("majorVersion", "?")))
|
self.version_meta.get("javaVersion", {}).get("majorVersion", "?")))
|
||||||
|
|
||||||
# ---------------- 下载 ----------------
|
|
||||||
|
|
||||||
def _emit(self, msg: str) -> None:
|
def _emit(self, msg: str) -> None:
|
||||||
|
"""输出日志: 同时打印到控制台和回调 GUI。"""
|
||||||
log(msg)
|
log(msg)
|
||||||
if self.progress_cb:
|
if self.progress_cb:
|
||||||
self.progress_cb(msg)
|
self.progress_cb(msg)
|
||||||
|
|
||||||
def download_client(self) -> Path:
|
def download_client(self) -> Path:
|
||||||
|
"""下载客户端 jar 文件到 versions/<id>/。"""
|
||||||
vdir = self.versions_dir / self.version_id
|
vdir = self.versions_dir / self.version_id
|
||||||
vdir.mkdir(parents=True, exist_ok=True)
|
vdir.mkdir(parents=True, exist_ok=True)
|
||||||
jar = vdir / ("%s.jar" % self.version_id)
|
jar = vdir / ("%s.jar" % self.version_id)
|
||||||
@@ -271,6 +330,7 @@ class MinecraftLauncher:
|
|||||||
return jar
|
return jar
|
||||||
|
|
||||||
def download_libraries(self) -> list:
|
def download_libraries(self) -> list:
|
||||||
|
"""下载全部依赖库与原生库, 返回 classpath 列表。"""
|
||||||
cp = []
|
cp = []
|
||||||
total = sum(1 for lib in self.version_meta.get("libraries", []) if library_allowed(lib))
|
total = sum(1 for lib in self.version_meta.get("libraries", []) if library_allowed(lib))
|
||||||
done = 0
|
done = 0
|
||||||
@@ -300,12 +360,14 @@ class MinecraftLauncher:
|
|||||||
return cp
|
return cp
|
||||||
|
|
||||||
def _download_one(self, artifact, path, tag, done, total):
|
def _download_one(self, artifact, path, tag, done, total):
|
||||||
|
"""下载单个库文件, 失败只警告不中断。"""
|
||||||
try:
|
try:
|
||||||
download_file(artifact["url"], path, artifact.get("sha1"), artifact.get("size"))
|
download_file(artifact["url"], path, artifact.get("sha1"), artifact.get("size"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._emit(" [警告] %s 下载失败: %s" % (path.name, e))
|
self._emit(" [警告] %s 下载失败: %s" % (path.name, e))
|
||||||
|
|
||||||
def _extract_natives(self, native_jar: Path) -> None:
|
def _extract_natives(self, native_jar: Path) -> None:
|
||||||
|
"""把原生库 (natives) 解压到 versions/<id>/natives 目录。"""
|
||||||
natives_dir = self.versions_dir / self.version_id / "natives"
|
natives_dir = self.versions_dir / self.version_id / "natives"
|
||||||
natives_dir.mkdir(parents=True, exist_ok=True)
|
natives_dir.mkdir(parents=True, exist_ok=True)
|
||||||
ext = {"linux": ".so", "osx": ".dylib", "windows": ".dll"}[OS_NAME]
|
ext = {"linux": ".so", "osx": ".dylib", "windows": ".dll"}[OS_NAME]
|
||||||
@@ -318,6 +380,7 @@ class MinecraftLauncher:
|
|||||||
shutil.copyfileobj(src, dst)
|
shutil.copyfileobj(src, dst)
|
||||||
|
|
||||||
def download_assets(self) -> None:
|
def download_assets(self) -> None:
|
||||||
|
"""下载资源索引与全部资源对象到 assets/。"""
|
||||||
index_info = self.version_meta.get("assetIndex")
|
index_info = self.version_meta.get("assetIndex")
|
||||||
if not index_info:
|
if not index_info:
|
||||||
self._emit("[资源] 该版本无资源索引, 跳过")
|
self._emit("[资源] 该版本无资源索引, 跳过")
|
||||||
@@ -343,9 +406,10 @@ class MinecraftLauncher:
|
|||||||
self._emit(" [资源] %d/%d" % (done, total))
|
self._emit(" [资源] %d/%d" % (done, total))
|
||||||
self._emit("[资源] 资源文件就绪 (%d 项)" % total)
|
self._emit("[资源] 资源文件就绪 (%d 项)" % total)
|
||||||
|
|
||||||
# ---------------- 启动参数 ----------------
|
|
||||||
|
|
||||||
def _resolve_argument(self, arg: str) -> str:
|
def _resolve_argument(self, arg: str) -> str:
|
||||||
|
"""替换启动参数里的 ${变量} 占位符。"""
|
||||||
uuid = self.auth.get("uuid") or self._offline_uuid()
|
uuid = self.auth.get("uuid") or self._offline_uuid()
|
||||||
replace_map = {
|
replace_map = {
|
||||||
"${auth_player_name}": self.auth.get("name") or self.username,
|
"${auth_player_name}": self.auth.get("name") or self.username,
|
||||||
@@ -372,6 +436,7 @@ class MinecraftLauncher:
|
|||||||
return arg
|
return arg
|
||||||
|
|
||||||
def _offline_uuid(self) -> str:
|
def _offline_uuid(self) -> str:
|
||||||
|
"""按官方离线模式规则, 由玩家名生成稳定 UUID。"""
|
||||||
name = self.auth.get("name") or self.username
|
name = self.auth.get("name") or self.username
|
||||||
digest = bytearray(hashlib.md5(("OfflinePlayer:" + name).encode("utf-8")).digest())
|
digest = bytearray(hashlib.md5(("OfflinePlayer:" + name).encode("utf-8")).digest())
|
||||||
digest[6] = (digest[6] & 0x0F) | 0x30
|
digest[6] = (digest[6] & 0x0F) | 0x30
|
||||||
@@ -381,6 +446,7 @@ class MinecraftLauncher:
|
|||||||
hex_str[16:20], hex_str[20:32])
|
hex_str[16:20], hex_str[20:32])
|
||||||
|
|
||||||
def _build_args(self) -> tuple:
|
def _build_args(self) -> tuple:
|
||||||
|
"""拆分 JVM 参数与游戏参数, 兼容 1.13+ 与旧版两种格式。"""
|
||||||
jvm_args, game_args = [], []
|
jvm_args, game_args = [], []
|
||||||
arguments = self.version_meta.get("arguments")
|
arguments = self.version_meta.get("arguments")
|
||||||
if arguments:
|
if arguments:
|
||||||
@@ -402,6 +468,7 @@ class MinecraftLauncher:
|
|||||||
return jvm_args, game_args
|
return jvm_args, game_args
|
||||||
|
|
||||||
def _find_java(self) -> str:
|
def _find_java(self) -> str:
|
||||||
|
"""查找 Java: 优先用户指定 -> JAVA_HOME -> PATH。"""
|
||||||
if self.java_path and os.path.isfile(self.java_path):
|
if self.java_path and os.path.isfile(self.java_path):
|
||||||
return self.java_path
|
return self.java_path
|
||||||
java_home = os.environ.get("JAVA_HOME")
|
java_home = os.environ.get("JAVA_HOME")
|
||||||
@@ -416,6 +483,7 @@ class MinecraftLauncher:
|
|||||||
raise RuntimeError("未找到 Java, 请在设置中指定 java 可执行文件路径 (1.17+ 需要 Java 17+)")
|
raise RuntimeError("未找到 Java, 请在设置中指定 java 可执行文件路径 (1.17+ 需要 Java 17+)")
|
||||||
|
|
||||||
def build_command(self, classpath: list) -> list:
|
def build_command(self, classpath: list) -> list:
|
||||||
|
"""组装完整 java 启动命令, 含第三方登录时注入 authlib-injector。"""
|
||||||
self._cp = classpath
|
self._cp = classpath
|
||||||
java = self._find_java()
|
java = self._find_java()
|
||||||
jvm_args, game_args = self._build_args()
|
jvm_args, game_args = self._build_args()
|
||||||
@@ -425,7 +493,7 @@ class MinecraftLauncher:
|
|||||||
command += ["-Djava.library.path=%s" % (self.versions_dir / self.version_id / "natives")]
|
command += ["-Djava.library.path=%s" % (self.versions_dir / self.version_id / "natives")]
|
||||||
command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.2"]
|
command += ["-Dminecraft.launcher.brand=MeowLauncher", "-Dminecraft.launcher.version=1.2"]
|
||||||
|
|
||||||
# 第三方登录: 注入 authlib-injector
|
|
||||||
if self.yggdrasil:
|
if self.yggdrasil:
|
||||||
injector = self.yggdrasil.download_injector(self.game_dir)
|
injector = self.yggdrasil.download_injector(self.game_dir)
|
||||||
command += ["-javaagent:%s=%s" % (injector, self.yggdrasil.server_url)]
|
command += ["-javaagent:%s=%s" % (injector, self.yggdrasil.server_url)]
|
||||||
@@ -436,9 +504,10 @@ class MinecraftLauncher:
|
|||||||
command += game_args
|
command += game_args
|
||||||
return command
|
return command
|
||||||
|
|
||||||
# ---------------- 主流程 ----------------
|
|
||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
|
"""主流程: 解析版本 -> 下载文件 -> 构建命令 -> 启动游戏。"""
|
||||||
self._emit("[启动] 游戏目录: %s" % self.game_dir)
|
self._emit("[启动] 游戏目录: %s" % self.game_dir)
|
||||||
self._emit("[启动] 版本: %s 玩家: %s" % (self.version_id,
|
self._emit("[启动] 版本: %s 玩家: %s" % (self.version_id,
|
||||||
self.auth.get("name") or self.username))
|
self.auth.get("name") or self.username))
|
||||||
@@ -457,6 +526,7 @@ class MinecraftLauncher:
|
|||||||
return subprocess.call(command, cwd=str(self.game_dir))
|
return subprocess.call(command, cwd=str(self.game_dir))
|
||||||
|
|
||||||
def list_versions(self) -> dict:
|
def list_versions(self) -> dict:
|
||||||
|
"""获取版本清单 (供 GUI 和 CLI 使用)。"""
|
||||||
manifest = self.fetch_version_manifest()
|
manifest = self.fetch_version_manifest()
|
||||||
return {
|
return {
|
||||||
"latest": manifest.get("latest", {}),
|
"latest": manifest.get("latest", {}),
|
||||||
@@ -464,9 +534,9 @@ class MinecraftLauncher:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 图形界面 (Tkinter)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -476,36 +546,45 @@ except Exception:
|
|||||||
TK_OK = False
|
TK_OK = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 图形界面 (Tkinter)
|
||||||
|
# 两个标签页: [启动器] 登录/版本/设置/日志, [资源中心] 版本与Mod插件下载
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
class MeowLauncherApp:
|
class MeowLauncherApp:
|
||||||
"""跨平台图形界面 (Windows / Linux / macOS 外观一致)。"""
|
|
||||||
|
|
||||||
# 深色 Minecraft 风配色
|
|
||||||
BG = "#1e1e2e"
|
BG = "#eef2f9"
|
||||||
CARD = "#2a2a3d"
|
CARD = "#ffffff"
|
||||||
ACCENT = "#4ade80"
|
ACCENT = "#2563eb"
|
||||||
TEXT = "#e2e8f0"
|
ACCENT_LIGHT = "#60a5fa"
|
||||||
DIM = "#94a3b8"
|
ACCENT_DARK = "#1d4ed8"
|
||||||
|
TEXT = "#1e293b"
|
||||||
|
DIM = "#64748b"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.root = tk.Tk()
|
self.root = tk.Tk()
|
||||||
self.root.title("MeowLauncher - Minecraft 启动器")
|
self.root.title("MeowLauncher %s - Minecraft 启动器" % VERSION)
|
||||||
self.root.geometry("520x720")
|
self.root.geometry("600x780")
|
||||||
self.root.minsize(480, 640)
|
self.root.minsize(540, 680)
|
||||||
self.root.configure(bg=self.BG)
|
self.root.configure(bg=self.BG)
|
||||||
|
|
||||||
self.msg_queue = queue.Queue()
|
self.msg_queue = queue.Queue()
|
||||||
self.auth = None
|
self.auth = None
|
||||||
self.yggdrasil = None
|
self.yggdrasil = None
|
||||||
self.versions_cache = []
|
self.versions_cache = []
|
||||||
|
self.modrinth = ModrinthClient()
|
||||||
|
self.res_hits = []
|
||||||
|
|
||||||
self._setup_style()
|
self._setup_style()
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self.root.after(100, self._poll_queue)
|
self.root.after(100, self._poll_queue)
|
||||||
self.refresh_versions()
|
self.refresh_versions()
|
||||||
|
self._fade_in()
|
||||||
|
|
||||||
# ---------------- 样式 ----------------
|
|
||||||
|
|
||||||
def _setup_style(self):
|
def _setup_style(self):
|
||||||
|
"""配置 ttk 深色主题, 保证三端外观一致。"""
|
||||||
style = ttk.Style(self.root)
|
style = ttk.Style(self.root)
|
||||||
try:
|
try:
|
||||||
style.theme_use("clam")
|
style.theme_use("clam")
|
||||||
@@ -520,20 +599,32 @@ class MeowLauncherApp:
|
|||||||
style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT,
|
style.configure("Title.TLabel", background=self.BG, foreground=self.ACCENT,
|
||||||
font=("Microsoft YaHei UI", 18, "bold"))
|
font=("Microsoft YaHei UI", 18, "bold"))
|
||||||
style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM)
|
style.configure("Dim.TLabel", background=self.BG, foreground=self.DIM)
|
||||||
style.configure("Accent.TButton", background=self.ACCENT, foreground="#0f172a",
|
style.configure("Accent.TButton", background=self.ACCENT, foreground="#ffffff",
|
||||||
font=("Microsoft YaHei UI", 11, "bold"))
|
font=("Microsoft YaHei UI", 11, "bold"), padding=(16, 10))
|
||||||
style.map("Accent.TButton",
|
style.map("Accent.TButton",
|
||||||
background=[("active", "#22c55e"), ("pressed", "#16a34a")])
|
background=[("active", self.ACCENT_LIGHT), ("pressed", self.ACCENT_DARK)])
|
||||||
style.configure("TCombobox", fieldbackground=self.CARD, background=self.CARD,
|
style.configure("TCombobox", fieldbackground=self.CARD, background=self.CARD,
|
||||||
foreground=self.TEXT, arrowcolor=self.TEXT)
|
foreground=self.TEXT, arrowcolor=self.TEXT)
|
||||||
style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT)
|
style.configure("TEntry", fieldbackground=self.CARD, foreground=self.TEXT)
|
||||||
style.configure("TCheckbutton", background=self.BG, foreground=self.TEXT)
|
style.configure("TCheckbutton", background=self.BG, foreground=self.TEXT)
|
||||||
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.CARD)
|
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor="#e2e8f0")
|
||||||
style.configure("TRadiobutton", background=self.BG, foreground=self.TEXT)
|
style.configure("TRadiobutton", background=self.BG, foreground=self.TEXT)
|
||||||
|
style.configure("TNotebook", background=self.BG, borderwidth=0)
|
||||||
|
style.configure("TNotebook.Tab", background=self.CARD, foreground=self.DIM,
|
||||||
|
padding=(18, 8), font=("Microsoft YaHei UI", 10))
|
||||||
|
style.map("TNotebook.Tab",
|
||||||
|
background=[("selected", self.ACCENT)],
|
||||||
|
foreground=[("selected", "#ffffff")])
|
||||||
|
style.configure("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")])
|
||||||
|
|
||||||
# ---------------- UI 构建 ----------------
|
|
||||||
|
|
||||||
def _card(self, parent, title):
|
def _card(self, parent, title):
|
||||||
|
"""创建一个圆角卡片容器, 用于分组界面元素。"""
|
||||||
frame = ttk.Frame(parent, style="Card.TFrame", padding=14)
|
frame = ttk.Frame(parent, style="Card.TFrame", padding=14)
|
||||||
frame.pack(fill="x", padx=14, pady=(8, 4))
|
frame.pack(fill="x", padx=14, pady=(8, 4))
|
||||||
ttk.Label(frame, text=title, style="Card.TLabel",
|
ttk.Label(frame, text=title, style="Card.TLabel",
|
||||||
@@ -541,12 +632,23 @@ class MeowLauncherApp:
|
|||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
# 标题
|
"""构建主窗口: 标题 + 标签页(启动器/资源中心)。"""
|
||||||
ttk.Label(self.root, text="MeowLauncher", style="Title.TLabel").pack(pady=(18, 0))
|
ttk.Label(self.root, text="MeowLauncher", style="Title.TLabel").pack(pady=(14, 0))
|
||||||
ttk.Label(self.root, text="跨平台 Minecraft 启动器", style="Dim.TLabel").pack()
|
ttk.Label(self.root, text="跨平台 Minecraft 启动器 v%s" % VERSION, style="Dim.TLabel").pack()
|
||||||
|
|
||||||
# ---- 账号登录 ----
|
self.notebook = ttk.Notebook(self.root)
|
||||||
login = self._card(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")
|
self.login_mode = tk.StringVar(value="offline")
|
||||||
row = ttk.Frame(login, style="Card.TFrame")
|
row = ttk.Frame(login, style="Card.TFrame")
|
||||||
row.pack(fill="x", pady=4)
|
row.pack(fill="x", pady=4)
|
||||||
@@ -583,8 +685,7 @@ class MeowLauncherApp:
|
|||||||
ttk.Label(self.ygg_frame, text="支持 LittleSkin 等 authlib-injector 皮肤站",
|
ttk.Label(self.ygg_frame, text="支持 LittleSkin 等 authlib-injector 皮肤站",
|
||||||
style="Card.TLabel").grid(row=3, column=0, columnspan=2, sticky="w", pady=2)
|
style="Card.TLabel").grid(row=3, column=0, columnspan=2, sticky="w", pady=2)
|
||||||
|
|
||||||
# ---- 游戏版本 ----
|
ver = self._card(self.tab_launch, "游戏版本")
|
||||||
ver = self._card(self.root, "游戏版本")
|
|
||||||
row = ttk.Frame(ver, style="Card.TFrame")
|
row = ttk.Frame(ver, style="Card.TFrame")
|
||||||
row.pack(fill="x")
|
row.pack(fill="x")
|
||||||
self.version_var = tk.StringVar()
|
self.version_var = tk.StringVar()
|
||||||
@@ -595,41 +696,161 @@ class MeowLauncherApp:
|
|||||||
ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot,
|
ttk.Checkbutton(ver, text="显示快照版", variable=self.show_snapshot,
|
||||||
command=self.refresh_versions).pack(anchor="w", pady=(6, 0))
|
command=self.refresh_versions).pack(anchor="w", pady=(6, 0))
|
||||||
|
|
||||||
# ---- 启动设置 ----
|
cfg = self._card(self.tab_launch, "启动设置")
|
||||||
cfg = self._card(self.root, "启动设置")
|
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||||
ttk.Label(cfg, text="最大内存 (MB):", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=3)
|
row.pack(fill="x", pady=3)
|
||||||
|
ttk.Label(row, text="最大内存 (MB):", style="Card.TLabel").pack(side="left")
|
||||||
self.mem_var = tk.StringVar(value="2048")
|
self.mem_var = tk.StringVar(value="2048")
|
||||||
ttk.Entry(cfg, textvariable=self.mem_var, width=10).grid(row=0, column=1, sticky="w", pady=3)
|
ttk.Entry(row, textvariable=self.mem_var, width=10).pack(side="left", padx=8)
|
||||||
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 = ttk.Frame(cfg, style="Card.TFrame")
|
||||||
row.grid(row=1, column=1, sticky="w", pady=3)
|
row.pack(fill="x", pady=3)
|
||||||
|
ttk.Label(row, text="Java 路径 (留空自动检测):", style="Card.TLabel").pack(side="left")
|
||||||
self.java_var = tk.StringVar()
|
self.java_var = tk.StringVar()
|
||||||
ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left")
|
ttk.Entry(row, textvariable=self.java_var, width=16).pack(side="left", padx=8)
|
||||||
ttk.Button(row, text="浏览", command=self._pick_java).pack(side="left", padx=4)
|
ttk.Button(row, text="浏览", command=self._pick_java).pack(side="left", padx=4)
|
||||||
ttk.Label(cfg, text="游戏目录:", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=3)
|
|
||||||
row = ttk.Frame(cfg, style="Card.TFrame")
|
row = ttk.Frame(cfg, style="Card.TFrame")
|
||||||
row.grid(row=2, column=1, sticky="w", pady=3)
|
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"))
|
self.dir_var = tk.StringVar(value=str(Path.home() / ".minecraft"))
|
||||||
ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left")
|
ttk.Entry(row, textvariable=self.dir_var, width=16).pack(side="left", padx=8)
|
||||||
ttk.Button(row, text="浏览", command=self._pick_dir).pack(side="left", padx=4)
|
ttk.Button(row, text="浏览", command=self._pick_dir).pack(side="left", padx=4)
|
||||||
|
|
||||||
# ---- 日志与进度 ----
|
log_card = self._card(self.tab_launch, "日志")
|
||||||
log_card = self._card(self.root, "日志")
|
self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#f8fafc",
|
||||||
self.log_box = scrolledtext.ScrolledText(log_card, height=8, bg="#151521",
|
|
||||||
fg=self.TEXT, font=("Consolas", 9),
|
fg=self.TEXT, font=("Consolas", 9),
|
||||||
relief="flat", state="disabled")
|
relief="flat", state="disabled")
|
||||||
self.log_box.pack(fill="x")
|
self.log_box.pack(fill="x")
|
||||||
self.progress = ttk.Progressbar(log_card, mode="determinate", maximum=100)
|
self.progress = ttk.Progressbar(log_card, mode="determinate", maximum=100)
|
||||||
self.progress.pack(fill="x", pady=(8, 0))
|
self.progress.pack(fill="x", pady=(8, 0))
|
||||||
|
|
||||||
# ---- 启动按钮 ----
|
self.launch_btn = ttk.Button(self.tab_launch, text="启动游戏", style="Accent.TButton",
|
||||||
self.launch_btn = ttk.Button(self.root, text="启动游戏", style="Accent.TButton",
|
|
||||||
command=self._on_launch)
|
command=self._on_launch)
|
||||||
self.launch_btn.pack(fill="x", padx=14, pady=14)
|
self.launch_btn.pack(fill="x", padx=14, pady=14)
|
||||||
|
|
||||||
# ---------------- 事件 ----------------
|
def _build_res_tab(self):
|
||||||
|
"""构建[资源中心]页: 版本一键下载 + Mod/插件搜索下载。"""
|
||||||
|
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="#f8fafc",
|
||||||
|
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):
|
||||||
|
"""后台线程搜索 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):
|
def _toggle_login(self):
|
||||||
|
"""切换离线/第三方登录表单的显示。"""
|
||||||
if self.login_mode.get() == "offline":
|
if self.login_mode.get() == "offline":
|
||||||
self.offline_frame.pack(fill="x")
|
self.offline_frame.pack(fill="x")
|
||||||
self.ygg_frame.pack_forget()
|
self.ygg_frame.pack_forget()
|
||||||
@@ -638,16 +859,19 @@ class MeowLauncherApp:
|
|||||||
self.ygg_frame.pack(fill="x")
|
self.ygg_frame.pack(fill="x")
|
||||||
|
|
||||||
def _pick_java(self):
|
def _pick_java(self):
|
||||||
|
"""弹出文件选择框, 选择 java 可执行文件。"""
|
||||||
path = filedialog.askopenfilename(title="选择 java 可执行文件")
|
path = filedialog.askopenfilename(title="选择 java 可执行文件")
|
||||||
if path:
|
if path:
|
||||||
self.java_var.set(path)
|
self.java_var.set(path)
|
||||||
|
|
||||||
def _pick_dir(self):
|
def _pick_dir(self):
|
||||||
|
"""弹出目录选择框, 选择游戏目录。"""
|
||||||
path = filedialog.askdirectory(title="选择游戏目录")
|
path = filedialog.askdirectory(title="选择游戏目录")
|
||||||
if path:
|
if path:
|
||||||
self.dir_var.set(path)
|
self.dir_var.set(path)
|
||||||
|
|
||||||
def refresh_versions(self):
|
def refresh_versions(self):
|
||||||
|
"""后台线程拉取版本清单, 填充两个标签页的版本下拉框。"""
|
||||||
def worker():
|
def worker():
|
||||||
try:
|
try:
|
||||||
data = MinecraftLauncher(
|
data = MinecraftLauncher(
|
||||||
@@ -658,7 +882,28 @@ class MeowLauncherApp:
|
|||||||
self.msg_queue.put(("log", "[版本] 获取失败: %s" % e))
|
self.msg_queue.put(("log", "[版本] 获取失败: %s" % e))
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _fade_in(self, step=0):
|
||||||
|
"""窗口淡入动画 (alpha 0 -> 1)。"""
|
||||||
|
try:
|
||||||
|
self.root.attributes("-alpha", min(step * 0.1, 1.0))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if step < 10:
|
||||||
|
self.root.after(18, lambda: self._fade_in(step + 1))
|
||||||
|
|
||||||
|
def _busy_button(self, text):
|
||||||
|
"""启动按钮忙碌状态 (文字闪烁)。"""
|
||||||
|
state = [0]
|
||||||
|
def tick():
|
||||||
|
if str(self.launch_btn["state"]) != "disabled":
|
||||||
|
return
|
||||||
|
state[0] += 1
|
||||||
|
self.launch_btn.configure(text=text + "." * (state[0] % 4))
|
||||||
|
self.root.after(400, tick)
|
||||||
|
tick()
|
||||||
|
|
||||||
def _poll_queue(self):
|
def _poll_queue(self):
|
||||||
|
"""定时轮询消息队列, 把后台线程的日志/结果刷到界面。"""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
kind, payload = self.msg_queue.get_nowait()
|
kind, payload = self.msg_queue.get_nowait()
|
||||||
@@ -667,6 +912,17 @@ class MeowLauncherApp:
|
|||||||
elif kind == "versions":
|
elif kind == "versions":
|
||||||
self.versions_cache = payload["versions"]
|
self.versions_cache = payload["versions"]
|
||||||
self._fill_versions(payload["latest"])
|
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":
|
elif kind == "done":
|
||||||
self.launch_btn.config(state="normal")
|
self.launch_btn.config(state="normal")
|
||||||
elif kind == "err":
|
elif kind == "err":
|
||||||
@@ -677,24 +933,31 @@ class MeowLauncherApp:
|
|||||||
self.root.after(100, self._poll_queue)
|
self.root.after(100, self._poll_queue)
|
||||||
|
|
||||||
def _fill_versions(self, latest):
|
def _fill_versions(self, latest):
|
||||||
|
"""根据快照开关过滤并填充版本下拉框。"""
|
||||||
show_snap = self.show_snapshot.get()
|
show_snap = self.show_snapshot.get()
|
||||||
items = [v["id"] for v in self.versions_cache
|
items = [v["id"] for v in self.versions_cache
|
||||||
if show_snap or v["type"] == "release"]
|
if show_snap or v["type"] == "release"]
|
||||||
items = list(dict.fromkeys(items))
|
items = list(dict.fromkeys(items))
|
||||||
self.version_box["values"] = items
|
self.version_box["values"] = items
|
||||||
self.version_var.set(latest.get("release") or (items[0] if items else ""))
|
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):
|
def _append_log(self, msg):
|
||||||
|
"""向[启动器]页日志框追加一行。"""
|
||||||
self.log_box.config(state="normal")
|
self.log_box.config(state="normal")
|
||||||
self.log_box.insert("end", msg + "\n")
|
self.log_box.insert("end", msg + "\n")
|
||||||
self.log_box.see("end")
|
self.log_box.see("end")
|
||||||
self.log_box.config(state="disabled")
|
self.log_box.config(state="disabled")
|
||||||
|
|
||||||
def _on_launch(self):
|
def _on_launch(self):
|
||||||
|
"""点击启动游戏: 禁用按钮并开后台线程。"""
|
||||||
self.launch_btn.config(state="disabled")
|
self.launch_btn.config(state="disabled")
|
||||||
|
self._busy_button("启动中")
|
||||||
threading.Thread(target=self._launch_worker, daemon=True).start()
|
threading.Thread(target=self._launch_worker, daemon=True).start()
|
||||||
|
|
||||||
def _launch_worker(self):
|
def _launch_worker(self):
|
||||||
|
"""后台线程: 登录 -> 构建启动器 -> 下载 -> 启动游戏。"""
|
||||||
try:
|
try:
|
||||||
if self.login_mode.get() == "yggdrasil":
|
if self.login_mode.get() == "yggdrasil":
|
||||||
server_name = self.server_var.get()
|
server_name = self.server_var.get()
|
||||||
@@ -735,9 +998,9 @@ class MeowLauncherApp:
|
|||||||
self.msg_queue.put(("done", None))
|
self.msg_queue.put(("done", None))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 入口
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="MeowLauncher - Minecraft 启动器")
|
parser = argparse.ArgumentParser(description="MeowLauncher - Minecraft 启动器")
|
||||||
@@ -756,7 +1019,7 @@ def main() -> int:
|
|||||||
app.root.mainloop()
|
app.root.mainloop()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 命令行模式
|
|
||||||
if args.list_versions:
|
if args.list_versions:
|
||||||
launcher = MinecraftLauncher(
|
launcher = MinecraftLauncher(
|
||||||
game_dir=args.game_dir or str(Path.home() / ".minecraft"),
|
game_dir=args.game_dir or str(Path.home() / ".minecraft"),
|
||||||
|
|||||||
+46
-85
@@ -1,22 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
MeowLauncher - 一个使用纯 Python 标准库实现的 Minecraft 启动器
|
MeowLauncher 命令行版 (旧版备份)
|
||||||
|
功能已被 launcher.py 的图形界面版完全覆盖, 保留此文件供命令行使用:
|
||||||
功能特性:
|
python launcher_cli.py --username Steve --version 1.20.4
|
||||||
- 自动获取官方版本清单并下载任意版本
|
|
||||||
- 自动下载游戏依赖库 (libraries) 与原生库 (natives)
|
|
||||||
- 自动下载资源文件 (assets)
|
|
||||||
- 支持离线账号与正版账号 (预留 Yggdrasil 接口)
|
|
||||||
- 跨平台: Windows / Linux / macOS
|
|
||||||
- 零第三方依赖, 仅使用 Python 标准库
|
|
||||||
|
|
||||||
用法示例:
|
|
||||||
python3 launcher.py --username Steve --version 1.20.4
|
|
||||||
python3 launcher.py --username Steve --version latest-release --max-mem 4096
|
|
||||||
python3 launcher.py --list-versions
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -31,21 +20,21 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 常量
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
|
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
|
||||||
USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.0)"
|
USER_AGENT = "Mozilla/5.0 (MeowLauncher/1.0)"
|
||||||
|
|
||||||
# 操作系统名称映射 (Mojang 规则中的 os.name)
|
|
||||||
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux")
|
OS_NAME = {"Windows": "windows", "Linux": "linux", "Darwin": "osx"}.get(platform.system(), "linux")
|
||||||
|
|
||||||
# 架构映射: 兼容新旧两套规则
|
|
||||||
_ARCH = platform.machine().lower()
|
_ARCH = platform.machine().lower()
|
||||||
if _ARCH in ("x86_64", "amd64"):
|
if _ARCH in ("x86_64", "amd64"):
|
||||||
ARCH_NEW = "x86_64" # 新版 (1.13+) 使用的 arch 值
|
ARCH_NEW = "x86_64"
|
||||||
ARCH_OLD = "64" # 旧版使用的 arch 值
|
ARCH_OLD = "64"
|
||||||
elif _ARCH in ("aarch64", "arm64"):
|
elif _ARCH in ("aarch64", "arm64"):
|
||||||
ARCH_NEW = "arm64"
|
ARCH_NEW = "arm64"
|
||||||
ARCH_OLD = "arm64"
|
ARCH_OLD = "arm64"
|
||||||
@@ -53,7 +42,7 @@ else:
|
|||||||
ARCH_NEW = "x86"
|
ARCH_NEW = "x86"
|
||||||
ARCH_OLD = "32"
|
ARCH_OLD = "32"
|
||||||
|
|
||||||
# 原生库分类器 (Mojang 命名)
|
|
||||||
NATIVES_CLASSIFIER = {
|
NATIVES_CLASSIFIER = {
|
||||||
"linux": "natives-linux",
|
"linux": "natives-linux",
|
||||||
"osx": "natives-osx",
|
"osx": "natives-osx",
|
||||||
@@ -62,12 +51,10 @@ NATIVES_CLASSIFIER = {
|
|||||||
|
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
def log(msg: str) -> None:
|
||||||
"""简单的彩色日志输出。"""
|
|
||||||
print(msg, flush=True)
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def sha1_of(path: Path) -> str:
|
def sha1_of(path: Path) -> str:
|
||||||
"""计算文件的 SHA1 校验值。"""
|
|
||||||
h = hashlib.sha1()
|
h = hashlib.sha1()
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
@@ -76,19 +63,12 @@ def sha1_of(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def http_get(url: str) -> bytes:
|
def http_get(url: str) -> bytes:
|
||||||
"""GET 请求并返回字节内容。"""
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
return resp.read()
|
return resp.read()
|
||||||
|
|
||||||
|
|
||||||
def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> bool:
|
def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> bool:
|
||||||
"""
|
|
||||||
下载文件到指定路径。
|
|
||||||
- 若目标已存在且校验通过 (或未提供校验值), 则跳过。
|
|
||||||
- 下载后校验 SHA1, 失败则删除并抛异常。
|
|
||||||
返回 True 表示本次实际下载, False 表示已存在跳过。
|
|
||||||
"""
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
@@ -122,10 +102,9 @@ def download_file(url: str, dest: Path, sha1: str = None, size: int = None) -> b
|
|||||||
|
|
||||||
|
|
||||||
def rule_allowed(rule: dict) -> bool:
|
def rule_allowed(rule: dict) -> bool:
|
||||||
"""判断 Mojang 下载规则 (rules) 是否允许当前平台。"""
|
|
||||||
if rule.get("action") == "allow":
|
if rule.get("action") == "allow":
|
||||||
default = False
|
default = False
|
||||||
else: # disallow
|
else:
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
os_rule = rule.get("os", {})
|
os_rule = rule.get("os", {})
|
||||||
@@ -139,16 +118,15 @@ def rule_allowed(rule: dict) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def library_allowed(lib: dict) -> bool:
|
def library_allowed(lib: dict) -> bool:
|
||||||
"""判断某个库是否允许当前平台下载。"""
|
|
||||||
rules = lib.get("rules")
|
rules = lib.get("rules")
|
||||||
if not rules:
|
if not rules:
|
||||||
return True
|
return True
|
||||||
return all(rule_allowed(r) for r in rules)
|
return all(rule_allowed(r) for r in rules)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Minecraft 启动器
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class MinecraftLauncher:
|
class MinecraftLauncher:
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -168,34 +146,29 @@ class MinecraftLauncher:
|
|||||||
self.java_path = java_path
|
self.java_path = java_path
|
||||||
self.min_mem = min_mem
|
self.min_mem = min_mem
|
||||||
self.max_mem = max_mem
|
self.max_mem = max_mem
|
||||||
self.auth = auth or {} # 正版登录信息: {access_token, uuid, name}
|
self.auth = auth or {}
|
||||||
self.offline = offline
|
self.offline = offline
|
||||||
|
|
||||||
# 版本相关目录
|
|
||||||
self.versions_dir = self.game_dir / "versions"
|
self.versions_dir = self.game_dir / "versions"
|
||||||
self.assets_dir = self.game_dir / "assets"
|
self.assets_dir = self.game_dir / "assets"
|
||||||
self.libraries_dir = self.game_dir / "libraries"
|
self.libraries_dir = self.game_dir / "libraries"
|
||||||
|
|
||||||
# 版本信息 (解析后填充)
|
|
||||||
self.version_meta = None
|
self.version_meta = None
|
||||||
self.version_url = None
|
self.version_url = None
|
||||||
self.asset_index = None
|
self.asset_index = None
|
||||||
self.client_jar = None
|
self.client_jar = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 版本解析
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def fetch_version_manifest(self) -> dict:
|
def fetch_version_manifest(self) -> dict:
|
||||||
"""获取官方版本清单。"""
|
|
||||||
data = json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
|
data = json.loads(http_get(VERSION_MANIFEST_URL).decode("utf-8"))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def resolve_version(self) -> None:
|
def resolve_version(self) -> None:
|
||||||
"""
|
|
||||||
解析版本号 -> 版本元数据。
|
|
||||||
支持: latest-release / latest-snapshot / 任意版本 id。
|
|
||||||
"""
|
|
||||||
manifest = self.fetch_version_manifest()
|
manifest = self.fetch_version_manifest()
|
||||||
latest = manifest.get("latest", {})
|
latest = manifest.get("latest", {})
|
||||||
versions = manifest.get("versions", [])
|
versions = manifest.get("versions", [])
|
||||||
@@ -215,12 +188,11 @@ class MinecraftLauncher:
|
|||||||
f"(release={self.version_meta.get('type')}, "
|
f"(release={self.version_meta.get('type')}, "
|
||||||
f"java={self.version_meta.get('javaVersion', {}).get('majorVersion', '?')})")
|
f"java={self.version_meta.get('javaVersion', {}).get('majorVersion', '?')})")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 下载: 客户端 / 库 / 资源
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def download_client(self) -> Path:
|
def download_client(self) -> Path:
|
||||||
"""下载客户端 jar 文件。"""
|
|
||||||
vdir = self.versions_dir / self.version_id
|
vdir = self.versions_dir / self.version_id
|
||||||
vdir.mkdir(parents=True, exist_ok=True)
|
vdir.mkdir(parents=True, exist_ok=True)
|
||||||
jar = vdir / f"{self.version_id}.jar"
|
jar = vdir / f"{self.version_id}.jar"
|
||||||
@@ -236,7 +208,6 @@ class MinecraftLauncher:
|
|||||||
return jar
|
return jar
|
||||||
|
|
||||||
def _download_library_artifact(self, artifact: dict, lib_path: Path) -> None:
|
def _download_library_artifact(self, artifact: dict, lib_path: Path) -> None:
|
||||||
"""下载单个库文件。"""
|
|
||||||
if not artifact:
|
if not artifact:
|
||||||
return
|
return
|
||||||
download_file(
|
download_file(
|
||||||
@@ -247,7 +218,6 @@ class MinecraftLauncher:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def download_libraries(self) -> list:
|
def download_libraries(self) -> list:
|
||||||
"""下载所有依赖库, 返回 classpath 列表。"""
|
|
||||||
cp = []
|
cp = []
|
||||||
for lib in self.version_meta.get("libraries", []):
|
for lib in self.version_meta.get("libraries", []):
|
||||||
if not library_allowed(lib):
|
if not library_allowed(lib):
|
||||||
@@ -256,7 +226,7 @@ class MinecraftLauncher:
|
|||||||
downloads = lib.get("downloads", {})
|
downloads = lib.get("downloads", {})
|
||||||
artifact = downloads.get("artifact")
|
artifact = downloads.get("artifact")
|
||||||
|
|
||||||
# 处理原生库 (natives)
|
|
||||||
classifier = NATIVES_CLASSIFIER.get(OS_NAME)
|
classifier = NATIVES_CLASSIFIER.get(OS_NAME)
|
||||||
native = None
|
native = None
|
||||||
natives_entry = downloads.get("classifiers", {}).get(classifier) if classifier else None
|
natives_entry = downloads.get("classifiers", {}).get(classifier) if classifier else None
|
||||||
@@ -280,7 +250,6 @@ class MinecraftLauncher:
|
|||||||
return cp
|
return cp
|
||||||
|
|
||||||
def _extract_natives(self, native_jar: Path) -> None:
|
def _extract_natives(self, native_jar: Path) -> None:
|
||||||
"""解压原生库到 versions/<id>/natives 目录。"""
|
|
||||||
natives_dir = self.versions_dir / self.version_id / "natives"
|
natives_dir = self.versions_dir / self.version_id / "natives"
|
||||||
natives_dir.mkdir(parents=True, exist_ok=True)
|
natives_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -289,7 +258,7 @@ class MinecraftLauncher:
|
|||||||
|
|
||||||
with zipfile.ZipFile(native_jar) as zf:
|
with zipfile.ZipFile(native_jar) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
# 跳过 META-INF 与源码/javadoc
|
|
||||||
if name.startswith("META-INF") or not name.endswith(ext):
|
if name.startswith("META-INF") or not name.endswith(ext):
|
||||||
continue
|
continue
|
||||||
target = natives_dir / Path(name).name
|
target = natives_dir / Path(name).name
|
||||||
@@ -297,7 +266,6 @@ class MinecraftLauncher:
|
|||||||
shutil.copyfileobj(src, dst)
|
shutil.copyfileobj(src, dst)
|
||||||
|
|
||||||
def download_assets(self) -> None:
|
def download_assets(self) -> None:
|
||||||
"""下载资源索引与全部资源对象。"""
|
|
||||||
index_info = self.version_meta.get("assetIndex")
|
index_info = self.version_meta.get("assetIndex")
|
||||||
if not index_info:
|
if not index_info:
|
||||||
log("[资源] 该版本无资源索引, 跳过")
|
log("[资源] 该版本无资源索引, 跳过")
|
||||||
@@ -326,12 +294,11 @@ class MinecraftLauncher:
|
|||||||
|
|
||||||
log(f"[资源] 资源文件就绪 ({total} 项)")
|
log(f"[资源] 资源文件就绪 ({total} 项)")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 启动参数
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _resolve_argument(self, arg: str) -> str:
|
def _resolve_argument(self, arg: str) -> str:
|
||||||
"""替换启动参数中的占位符。"""
|
|
||||||
uuid = self._offline_uuid()
|
uuid = self._offline_uuid()
|
||||||
replace_map = {
|
replace_map = {
|
||||||
"${auth_player_name}": self.auth.get("name") or self.username,
|
"${auth_player_name}": self.auth.get("name") or self.username,
|
||||||
@@ -358,17 +325,15 @@ class MinecraftLauncher:
|
|||||||
return arg
|
return arg
|
||||||
|
|
||||||
def _offline_uuid(self) -> str:
|
def _offline_uuid(self) -> str:
|
||||||
"""根据离线用户名生成稳定的 UUID (与官方离线模式一致)。"""
|
|
||||||
name = self.auth.get("name") or self.username
|
name = self.auth.get("name") or self.username
|
||||||
digest = hashlib.md5(f"OfflinePlayer:{name}".encode("utf-8")).digest()
|
digest = hashlib.md5(f"OfflinePlayer:{name}".encode("utf-8")).digest()
|
||||||
digest = bytearray(digest)
|
digest = bytearray(digest)
|
||||||
digest[6] = (digest[6] & 0x0F) | 0x30 # version 3
|
digest[6] = (digest[6] & 0x0F) | 0x30
|
||||||
digest[8] = (digest[8] & 0x3F) | 0x80 # variant
|
digest[8] = (digest[8] & 0x3F) | 0x80
|
||||||
hex_str = digest.hex()
|
hex_str = digest.hex()
|
||||||
return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
||||||
|
|
||||||
def build_command(self, classpath: list) -> list:
|
def build_command(self, classpath: list) -> list:
|
||||||
"""构建完整的 java 启动命令。"""
|
|
||||||
self._cp = classpath
|
self._cp = classpath
|
||||||
java = self._find_java()
|
java = self._find_java()
|
||||||
|
|
||||||
@@ -385,12 +350,11 @@ class MinecraftLauncher:
|
|||||||
return command
|
return command
|
||||||
|
|
||||||
def _build_args(self) -> tuple:
|
def _build_args(self) -> tuple:
|
||||||
"""拆分并解析 jvm 参数与游戏参数 (兼容新旧版本格式)。"""
|
|
||||||
jvm_args = []
|
jvm_args = []
|
||||||
game_args = []
|
game_args = []
|
||||||
arguments = self.version_meta.get("arguments")
|
arguments = self.version_meta.get("arguments")
|
||||||
|
|
||||||
if arguments: # 1.13+ 新格式
|
if arguments:
|
||||||
for item in arguments.get("jvm", []):
|
for item in arguments.get("jvm", []):
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
jvm_args.append(self._resolve_argument(item))
|
jvm_args.append(self._resolve_argument(item))
|
||||||
@@ -406,14 +370,13 @@ class MinecraftLauncher:
|
|||||||
if all(rule_allowed(r) for r in item.get("rules", [])):
|
if all(rule_allowed(r) for r in item.get("rules", [])):
|
||||||
for v in item.get("value", []):
|
for v in item.get("value", []):
|
||||||
game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v))
|
game_args.append(self._resolve_argument(v) if isinstance(v, str) else str(v))
|
||||||
else: # 1.12 及以前旧格式
|
else:
|
||||||
legacy = self.version_meta.get("minecraftArguments", "")
|
legacy = self.version_meta.get("minecraftArguments", "")
|
||||||
game_args = [self._resolve_argument(a) for a in legacy.split(" ")]
|
game_args = [self._resolve_argument(a) for a in legacy.split(" ")]
|
||||||
|
|
||||||
return jvm_args, game_args
|
return jvm_args, game_args
|
||||||
|
|
||||||
def _find_java(self) -> str:
|
def _find_java(self) -> str:
|
||||||
"""查找 Java 可执行文件: 优先用户指定, 其次 JAVA_HOME, 最后 PATH。"""
|
|
||||||
if self.java_path and os.path.isfile(self.java_path):
|
if self.java_path and os.path.isfile(self.java_path):
|
||||||
return self.java_path
|
return self.java_path
|
||||||
|
|
||||||
@@ -433,12 +396,11 @@ class MinecraftLauncher:
|
|||||||
"(Minecraft 1.17+ 需要 Java 17+)"
|
"(Minecraft 1.17+ 需要 Java 17+)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 主流程
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
"""完整流程: 解析 -> 下载 -> 启动。"""
|
|
||||||
log(f"[启动] 游戏目录: {self.game_dir}")
|
log(f"[启动] 游戏目录: {self.game_dir}")
|
||||||
log(f"[启动] 版本: {self.version_id} 玩家: {self.username}")
|
log(f"[启动] 版本: {self.version_id} 玩家: {self.username}")
|
||||||
|
|
||||||
@@ -447,7 +409,7 @@ class MinecraftLauncher:
|
|||||||
classpath = self.download_libraries()
|
classpath = self.download_libraries()
|
||||||
self.download_assets()
|
self.download_assets()
|
||||||
|
|
||||||
# 保存版本元数据到本地 (供后续复用)
|
|
||||||
meta_file = self.versions_dir / self.version_id / f"{self.version_id}.json"
|
meta_file = self.versions_dir / self.version_id / f"{self.version_id}.json"
|
||||||
meta_file.write_text(json.dumps(self.version_meta, indent=2), encoding="utf-8")
|
meta_file.write_text(json.dumps(self.version_meta, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
@@ -458,12 +420,11 @@ class MinecraftLauncher:
|
|||||||
log("[启动] 正在启动 Minecraft ...")
|
log("[启动] 正在启动 Minecraft ...")
|
||||||
return subprocess.call(command, cwd=str(self.game_dir))
|
return subprocess.call(command, cwd=str(self.game_dir))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 辅助功能
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def list_versions(self) -> None:
|
def list_versions(self) -> None:
|
||||||
"""列出可用的发布版与快照版。"""
|
|
||||||
manifest = self.fetch_version_manifest()
|
manifest = self.fetch_version_manifest()
|
||||||
latest = manifest.get("latest", {})
|
latest = manifest.get("latest", {})
|
||||||
log(f"最新发布版: {latest.get('release')} 最新快照版: {latest.get('snapshot')}")
|
log(f"最新发布版: {latest.get('release')} 最新快照版: {latest.get('snapshot')}")
|
||||||
@@ -473,9 +434,9 @@ class MinecraftLauncher:
|
|||||||
log(f" {v['id']:<20} {v['type']:<10}{marker}")
|
log(f" {v['id']:<20} {v['type']:<10}{marker}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 入口
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
Reference in new issue
Block a user