Files
kosta2014p ec6f13ddcc PixelLauncher 2.1.0 — стабильная сборка
Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров
(Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление
модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
2026-10-18 22:44:19 +07:00

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