Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
200 lines
8.1 KiB
Python
200 lines
8.1 KiB
Python
"""Установка Forge и NeoForge официальными инсталляторами.
|
|
|
|
Инсталлятор скачивается с maven, запускается нашей Java и сам ставит
|
|
библиотеки и JSON профиля в папку игры — готовая версия появляется в
|
|
списке так же, как Fabric/Quilt.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
|
|
from . import http, java as java_mod, java_fetch
|
|
from . import manifest as manifest_mod, version as version_mod
|
|
|
|
LOADERS = ("forge", "neoforge")
|
|
META = {
|
|
"forge": ("https://maven.minecraftforge.net/net/minecraftforge/"
|
|
"forge/maven-metadata.xml"),
|
|
"neoforge": ("https://maven.neoforged.net/releases/net/neoforged/"
|
|
"neoforge/maven-metadata.xml"),
|
|
}
|
|
INSTALLER = {
|
|
"forge": ("https://maven.minecraftforge.net/net/minecraftforge/forge/"
|
|
"{v}/forge-{v}-installer.jar"),
|
|
"neoforge": ("https://maven.neoforged.net/releases/net/neoforged/"
|
|
"neoforge/{v}/neoforge-{v}-installer.jar"),
|
|
}
|
|
|
|
_cache = {} # loader -> [версия лоадера]
|
|
|
|
|
|
def _num(s):
|
|
"""'47.2.0' -> (47, 2, 0): числа из строки для сравнения версий."""
|
|
return tuple(int(x) for x in re.findall(r"\d+", s)) or (0,)
|
|
|
|
|
|
def _meta(loader):
|
|
if loader not in _cache:
|
|
raw = http.get_bytes(META.get(loader, META["forge"]), timeout=30)
|
|
text = raw.decode("utf-8", "replace")
|
|
_cache[loader] = re.findall(r"<version>\s*([^<]+?)\s*</version>", text)
|
|
return _cache[loader]
|
|
|
|
|
|
def _mc_of(loader, v):
|
|
"""MC-версия, под которую выпущена версия лоадера (или None)."""
|
|
if loader == "forge":
|
|
# 1.20.1-47.2.0 -> 1.20.1
|
|
return v.split("-", 1)[0]
|
|
# neoforge: 21.1.99 -> 1.21.1, 21.0.17 -> 1.21, 20.1.117 -> 1.20.1
|
|
m = re.match(r"^(\d+)\.(\d+)(?:\.|$)", v)
|
|
if not m:
|
|
return None
|
|
return "1." + m.group(1) + ("" if m.group(2) == "0" else "." + m.group(2))
|
|
|
|
|
|
def game_versions(loader):
|
|
"""[(id, stable)] MC-версий, под которые есть лоадер (новые первые)."""
|
|
out = {}
|
|
for v in _meta(loader):
|
|
if loader == "neoforge" and not re.match(r"^\d+(\.\d+)+$", v):
|
|
continue # alpha/beta/+snapshot-сборки пропускаем
|
|
mc = _mc_of(loader, v)
|
|
if not mc or not re.match(r"^1\.\d+(\.\d+)?$", mc):
|
|
continue
|
|
if int(mc.split(".")[1]) < 7: # древности не показываем
|
|
continue
|
|
out.setdefault(mc, True)
|
|
return sorted(out.items(), key=lambda kv: _num(kv[0]), reverse=True)
|
|
|
|
|
|
def loader_versions(loader, game_version):
|
|
"""Версии лоадера для выбранной игры (новые первые)."""
|
|
out = []
|
|
for v in _meta(loader):
|
|
if loader == "forge":
|
|
if v.startswith(game_version + "-"):
|
|
out.append(v)
|
|
elif _mc_of(loader, v) == game_version and \
|
|
re.match(r"^\d+(\.\d+)+$", v):
|
|
out.append(v)
|
|
if loader == "forge":
|
|
out.sort(key=lambda v: _num(v[len(game_version) + 1:]), reverse=True)
|
|
else:
|
|
out.sort(key=_num, reverse=True)
|
|
return out
|
|
|
|
|
|
def _snap(ver_dir):
|
|
"""{имя версии: mtime её JSON} — снимок до запуска инсталлятора."""
|
|
out = {}
|
|
try:
|
|
for name in os.listdir(ver_dir):
|
|
try:
|
|
out[name] = os.path.getmtime(
|
|
os.path.join(ver_dir, name, name + ".json"))
|
|
except OSError:
|
|
out[name] = 0
|
|
except OSError:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _changed(ver_dir, before):
|
|
"""Имя версии, которая появилась или обновилась (или None)."""
|
|
after = _snap(ver_dir)
|
|
fresh = sorted((n for n in after if n not in before),
|
|
key=lambda n: after[n], reverse=True)
|
|
if fresh:
|
|
return fresh[0]
|
|
upd = sorted((n for n in after
|
|
if n in before and after[n] > before[n] + 0.5),
|
|
key=lambda n: after[n], reverse=True)
|
|
return upd[0] if upd else None
|
|
|
|
|
|
def _ensure_profiles(game_dir):
|
|
"""Старые инсталляторы требуют launcher_profiles.json — создаём заглушку."""
|
|
p = os.path.join(game_dir, "launcher_profiles.json")
|
|
if os.path.isfile(p):
|
|
return
|
|
data = {"profiles": {"PixelLauncher": {
|
|
"name": "PixelLauncher", "type": "custom",
|
|
"gameDir": game_dir, "lastVersionId": ""}},
|
|
"clientToken": "0" * 32, "selectedUser": "",
|
|
"authenticationDatabase": {}, "launcherVersion": "1.6.84"}
|
|
with open(p, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2)
|
|
|
|
|
|
def _java_for(game_dir, game_version, say):
|
|
"""Java под требования версии — та же логика, что при обычном запуске."""
|
|
req = 8
|
|
try:
|
|
info = version_mod.load(game_dir, game_version, net=True)
|
|
req = max(int(getattr(info, "java_major", 0) or 0), 8)
|
|
except Exception:
|
|
# JSON ванильной версии всё равно нужен инсталлятору — достаём
|
|
manifest_mod.fetch_version_json(game_dir, game_version, net=True)
|
|
runtime = os.path.join(game_dir, ".launcher", "runtime")
|
|
preferred = java_fetch.find_installed(runtime, req)
|
|
jpath, _jver, ok = java_mod.pick_java(
|
|
req, preferred,
|
|
cache_path=os.path.join(game_dir, ".launcher", "java_cache.json"))
|
|
if not jpath or not ok:
|
|
say("Скачиваю Java {r}...".format(r=req))
|
|
jpath = java_fetch.ensure_java(
|
|
req, runtime, log=lambda m, t=None: say(str(m)),
|
|
cancel=threading.Event())
|
|
if not jpath:
|
|
raise RuntimeError("не удалось подобрать Java " + str(req))
|
|
return jpath
|
|
|
|
|
|
def install(game_dir, loader, game_version, loader_version=None, log=None):
|
|
"""Скачивает и запускает официальный инсталлятор. Возвращает id версии."""
|
|
say = log or (lambda m: None)
|
|
if loader not in LOADERS:
|
|
raise RuntimeError("неизвестный лоадер " + str(loader))
|
|
vs = loader_versions(loader, game_version)
|
|
if not vs:
|
|
raise RuntimeError("нет версий {0} для {1}".format(loader, game_version))
|
|
if loader_version not in vs:
|
|
loader_version = vs[0]
|
|
|
|
jpath = _java_for(game_dir, game_version, say)
|
|
|
|
cache = os.path.join(game_dir, ".launcher", "loaders")
|
|
os.makedirs(cache, exist_ok=True)
|
|
jar = os.path.join(cache,
|
|
"{0}-{1}-installer.jar".format(loader, loader_version))
|
|
say("Скачивание инсталлятора {0} {1}...".format(loader, loader_version))
|
|
http.download_file(INSTALLER[loader].format(v=loader_version), jar)
|
|
|
|
_ensure_profiles(game_dir)
|
|
ver_dir = os.path.join(game_dir, "versions")
|
|
before = _snap(ver_dir)
|
|
say("Инсталлятор запускается — может занять несколько минут...")
|
|
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
try:
|
|
proc = subprocess.run(
|
|
[jpath, "-jar", jar, "--installClient", game_dir],
|
|
capture_output=True, text=True, errors="replace",
|
|
timeout=900, creationflags=flags)
|
|
except subprocess.TimeoutExpired:
|
|
raise RuntimeError("инсталлятор не уложился в 15 минут") from None
|
|
if proc.returncode != 0:
|
|
lines = [x.strip() for x in
|
|
((proc.stdout or "") + (proc.stderr or "")).splitlines()
|
|
if x.strip()]
|
|
tail = "; ".join(lines)[-500:]
|
|
raise RuntimeError("инсталлятор завершился с ошибкой"
|
|
+ (": " + tail if tail else ""))
|
|
vid = _changed(ver_dir, before)
|
|
if not vid:
|
|
raise RuntimeError("инсталлятор отработал, но новая версия не найдена")
|
|
return vid
|