Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
199 lines
6.9 KiB
Python
199 lines
6.9 KiB
Python
"""Установка модлоадеров Fabric и Quilt.
|
|
|
|
Ставит JSON профиля версии (с inheritsFrom на игровую версию) — лаунчер уже
|
|
умеет собирать classpath для таких версий, а файлы лоадера докачиваются
|
|
обычным механизмом при первом запуске.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
from . import http
|
|
|
|
LOADERS = ("fabric", "quilt")
|
|
META = {
|
|
"fabric": "https://meta.fabricmc.net/v2",
|
|
"quilt": "https://meta.quiltmc.org/v3",
|
|
}
|
|
|
|
_cache = {} # loader -> {"game": [(id, stable)], "loader": [ver]}
|
|
|
|
|
|
def _base(loader):
|
|
return META.get(loader) or META["fabric"]
|
|
|
|
|
|
def game_versions(loader):
|
|
"""[(id, stable)] игровых версий, поддерживаемых лоадером (свежие первые)."""
|
|
if loader not in _cache:
|
|
data = json.loads(http.get_bytes(_base(loader) + "/versions/game",
|
|
timeout=20))
|
|
games, loads = [], []
|
|
for v in data or []:
|
|
vid = v.get("version")
|
|
if vid:
|
|
games.append((vid, bool(v.get("stable"))))
|
|
_cache[loader] = {"game": games, "loader": loads}
|
|
return _cache[loader]["game"]
|
|
|
|
|
|
def loader_versions(loader):
|
|
"""Список версий лоадера (свежая первая)."""
|
|
if loader not in _cache:
|
|
game_versions(loader) # прогревает кэш
|
|
if not _cache[loader]["loader"]:
|
|
data = json.loads(http.get_bytes(_base(loader) + "/versions/loader",
|
|
timeout=20))
|
|
_cache[loader]["loader"] = [v["version"] for v in (data or [])
|
|
if v.get("version")]
|
|
return _cache[loader]["loader"]
|
|
|
|
|
|
def install(game_dir, loader, game_version, loader_version=None):
|
|
"""Скачивает JSON профиля лоадера. Возвращает id установленной версии."""
|
|
base = _base(loader)
|
|
if not loader_version:
|
|
vs = loader_versions(loader)
|
|
if not vs:
|
|
raise RuntimeError(f"нет версий лоадера {loader}")
|
|
loader_version = vs[0]
|
|
url = (f"{base}/versions/loader/{game_version}/{loader_version}/"
|
|
"profile/json")
|
|
raw = http.get_bytes(url, timeout=30)
|
|
data = json.loads(raw)
|
|
vid = data.get("id") or f"{loader}-loader-{loader_version}-{game_version}"
|
|
path = os.path.join(game_dir, "versions", vid, vid + ".json")
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "wb") as f:
|
|
f.write(raw)
|
|
return vid
|
|
|
|
|
|
# ------------------------------------------------------- менеджер модов (UI)
|
|
|
|
def mods_dir(game_dir):
|
|
d = os.path.join(game_dir, "mods")
|
|
os.makedirs(d, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def list_mods(game_dir):
|
|
"""Файлы в mods/: [{file, name, enabled, size}] (имя без .jar/.disabled)."""
|
|
out = []
|
|
d = mods_dir(game_dir)
|
|
for name in sorted(os.listdir(d), key=str.lower):
|
|
low = name.lower()
|
|
if low.endswith(".jar.disabled"):
|
|
enabled, base = False, name[:-len(".disabled")]
|
|
elif low.endswith(".jar"):
|
|
enabled, base = True, name[:-4]
|
|
else:
|
|
continue
|
|
try:
|
|
size = os.path.getsize(os.path.join(d, name))
|
|
except OSError:
|
|
size = 0
|
|
out.append({"file": name, "name": base, "enabled": enabled,
|
|
"size": size})
|
|
return out
|
|
|
|
|
|
def set_enabled(game_dir, filename, on):
|
|
"""Вкл/выкл мода переименованием (.jar ↔ .jar.disabled). Новое имя/None."""
|
|
d = mods_dir(game_dir)
|
|
low = filename.lower()
|
|
if on:
|
|
if not low.endswith(".jar.disabled"):
|
|
return None
|
|
dst_name = filename[:-len(".disabled")]
|
|
else:
|
|
if not low.endswith(".jar"):
|
|
return None
|
|
dst_name = filename + ".disabled"
|
|
if os.path.exists(os.path.join(d, dst_name)):
|
|
return None
|
|
os.rename(os.path.join(d, filename), os.path.join(d, dst_name))
|
|
return dst_name
|
|
|
|
|
|
def remove_mod(game_dir, filename):
|
|
"""Удаляет файл мода. True при успехе."""
|
|
try:
|
|
os.remove(os.path.join(mods_dir(game_dir), filename))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def fmt_size(n):
|
|
"""3145728 → «3,0 МБ»."""
|
|
n = float(n or 0)
|
|
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
|
if n < 1024 or unit == "ГБ":
|
|
return f"{n:.1f} {unit}".replace(".", ",") if unit != "Б" \
|
|
else f"{int(n)} {unit}"
|
|
n /= 1024
|
|
return f"{int(n)} Б"
|
|
|
|
|
|
# ------------------------------------------------------- обновления модов
|
|
|
|
def check_updates(game_dir, game_version, loader="", log=None):
|
|
"""Сверяет включённые моды с Modrinth по SHA-1.
|
|
|
|
Возвращает список обновлений:
|
|
[{file, name, cur, new_ver, new_file, url, size, sha1, project_id}].
|
|
"""
|
|
from . import modrinth
|
|
enabled = [m for m in list_mods(game_dir) if m["enabled"]]
|
|
if not enabled:
|
|
return []
|
|
if log:
|
|
log(f"Проверка {len(enabled)} модов по хэшу…")
|
|
hashes = {}
|
|
for m in enabled:
|
|
try:
|
|
h = http.sha1_file(os.path.join(mods_dir(game_dir), m["file"]))
|
|
except OSError:
|
|
continue
|
|
if h:
|
|
hashes[h] = m
|
|
if not hashes:
|
|
return []
|
|
found = modrinth.lookup_files(list(hashes), "sha1")
|
|
out = []
|
|
ver_cache = {}
|
|
for h, ver in found.items():
|
|
m = hashes.get(h)
|
|
pid = ver.get("project_id") or ""
|
|
if not m or not pid:
|
|
continue
|
|
if pid not in ver_cache:
|
|
try:
|
|
ver_cache[pid] = modrinth.versions(pid, game_version, loader)
|
|
except Exception: # noqa: BLE001
|
|
ver_cache[pid] = []
|
|
vs = ver_cache[pid]
|
|
# свежая версия первая: если её хэш совпадает с файлом — мы актуальны
|
|
if not vs or not vs[0]["sha1"] or vs[0]["sha1"] == h:
|
|
continue
|
|
nv = vs[0]
|
|
out.append({"file": m["file"], "name": m["name"],
|
|
"cur": (ver.get("version_number") or "").strip(),
|
|
"new_ver": nv["name"], "new_file": nv["filename"],
|
|
"url": nv["url"], "size": nv["size"],
|
|
"sha1": nv["sha1"], "project_id": pid})
|
|
out.sort(key=lambda u: u["name"].lower())
|
|
return out
|
|
|
|
|
|
def apply_update(game_dir, u):
|
|
"""Скачивает новую версию мода и отключает старый файл. True при успехе."""
|
|
dest = os.path.join(mods_dir(game_dir), u["new_file"])
|
|
ok = http.download_file(u["url"], dest, size=u["size"])
|
|
if not ok:
|
|
return False
|
|
if u["file"] != u["new_file"]:
|
|
set_enabled(game_dir, u["file"], False)
|
|
return True
|