PixelLauncher 2.1.0 — стабильная сборка
Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
This commit is contained in:
commit
ec6f13ddcc
33 files changed
+10445
No files matched your search
@@ -0,0 +1,199 @@
|
||||
"""Установка 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
|
||||
Reference in new issue
Block a user