"""Установка 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"\s*([^<]+?)\s*", 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