Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
226 lines
8.3 KiB
Python
226 lines
8.3 KiB
Python
"""Скачивание файлов игры: client jar, библиотеки, нативы, ассеты."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import zipfile
|
|
from concurrent.futures import as_completed, ThreadPoolExecutor
|
|
|
|
from . import http
|
|
|
|
LIB_WORKERS = 16
|
|
ASSET_WORKERS = 32
|
|
ASSET_BASE = "https://resources.download.minecraft.net/"
|
|
|
|
|
|
def _mirror_urls(mirrors, default_base, url):
|
|
"""Кандидаты для скачивания: зеркала (если подходят) + официальный URL."""
|
|
urls = []
|
|
for m in mirrors or []:
|
|
m = (m or "").strip().rstrip("/")
|
|
if m and url.startswith(default_base):
|
|
urls.append(m + "/" + url[len(default_base):])
|
|
urls.append(url)
|
|
return urls
|
|
|
|
|
|
def _download_many(tasks, workers=LIB_WORKERS, progress=None, verify=False,
|
|
cancel=None):
|
|
"""tasks: список (urls, path, sha1, size) — urls по приоритету (зеркала).
|
|
|
|
progress(done, total). verify=True — полная проверка SHA-1.
|
|
cancel — threading.Event: досрочная оставка (http.Cancelled).
|
|
"""
|
|
total = len(tasks)
|
|
errors = []
|
|
if not tasks:
|
|
return errors
|
|
|
|
def work(task):
|
|
urls, path, sha1, size = task
|
|
last = None
|
|
for url in urls:
|
|
if cancel is not None and cancel.is_set():
|
|
raise http.Cancelled()
|
|
try:
|
|
return http.download_file(url, path, sha1, size, verify,
|
|
cancel=cancel)
|
|
except http.Cancelled:
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
last = e
|
|
return RuntimeError(f"{urls[-1] if urls else '?'}: {last}")
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
futs = [ex.submit(work, t) for t in tasks]
|
|
done = 0
|
|
for fut in as_completed(futs):
|
|
if cancel is not None and cancel.is_set():
|
|
for f2 in futs:
|
|
f2.cancel()
|
|
raise http.Cancelled()
|
|
done += 1
|
|
try:
|
|
res = fut.result()
|
|
if isinstance(res, Exception):
|
|
errors.append(str(res))
|
|
except http.Cancelled:
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
errors.append(str(e))
|
|
if progress:
|
|
progress(done, total)
|
|
return errors
|
|
|
|
|
|
def extract_natives(version, libs_dir):
|
|
"""Распаковывает natives-jar'ы в папку нативов версии.
|
|
|
|
Если набор jar'ов не менялся (сигнатура в .stamp), распаковка
|
|
пропускается — на повторных запусках это экономит время.
|
|
"""
|
|
dest = version.natives_dir()
|
|
os.makedirs(dest, exist_ok=True)
|
|
stamp_path = os.path.join(dest, ".stamp")
|
|
|
|
jars, sig_parts = [], []
|
|
for rel in version.natives_jars:
|
|
jar = os.path.join(libs_dir, *rel.split("/"))
|
|
if not os.path.isfile(jar):
|
|
continue
|
|
jars.append(jar)
|
|
st = os.stat(jar)
|
|
sig_parts.append(f"{rel}:{st.st_size}:{int(st.st_mtime)}")
|
|
sig = ";".join(sig_parts)
|
|
|
|
try:
|
|
with open(stamp_path, encoding="utf-8") as f:
|
|
if f.read() == sig:
|
|
return dest
|
|
except OSError:
|
|
pass
|
|
|
|
for jar in jars:
|
|
try:
|
|
with zipfile.ZipFile(jar) as z:
|
|
for info in z.infolist():
|
|
name = info.filename
|
|
if name.endswith("/") or name.upper().startswith("META-INF/"):
|
|
continue
|
|
target = os.path.join(dest, *name.split("/"))
|
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
with z.open(info) as src, open(target, "wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
except (zipfile.BadZipFile, OSError):
|
|
continue
|
|
|
|
try:
|
|
with open(stamp_path, "w", encoding="utf-8") as f:
|
|
f.write(sig)
|
|
except OSError:
|
|
pass
|
|
return dest
|
|
|
|
|
|
def _ensure_assets(version, progress=None, verify=False, cancel=None,
|
|
mirrors=None):
|
|
ai = version.asset_index
|
|
if not ai.get("url"):
|
|
return
|
|
assets = version.assets_dir()
|
|
idx_id = ai.get("id", "legacy")
|
|
idx_path = os.path.join(assets, "indexes", idx_id + ".json")
|
|
http.download_file(ai["url"], idx_path, ai.get("sha1"), ai.get("size"),
|
|
cancel=cancel)
|
|
|
|
with open(idx_path, encoding="utf-8") as f:
|
|
index = json.load(f)
|
|
|
|
base = ASSET_BASE
|
|
tasks = []
|
|
objects = index.get("objects") or {}
|
|
for name, obj in objects.items():
|
|
h = obj["hash"]
|
|
url = base + h[:2] + "/" + h
|
|
path = os.path.join(assets, "objects", h[:2], h)
|
|
tasks.append((_mirror_urls(mirrors, base, url), path, h,
|
|
obj.get("size")))
|
|
|
|
errors = _download_many(tasks, workers=ASSET_WORKERS, progress=progress,
|
|
verify=verify, cancel=cancel)
|
|
|
|
# Виртуальные ассеты для старых версий (1.6 и раньше: virtual / map_to_resources)
|
|
if index.get("virtual"):
|
|
vdir = os.path.join(assets, "virtual", idx_id)
|
|
for name, obj in objects.items():
|
|
src = os.path.join(assets, "objects", obj["hash"][:2], obj["hash"])
|
|
dst = os.path.join(vdir, *name.split("/"))
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
if not os.path.isfile(dst):
|
|
try:
|
|
shutil.copy2(src, dst)
|
|
except OSError:
|
|
pass
|
|
if index.get("map_to_resources"):
|
|
rdir = os.path.join(version.game_dir, "resources")
|
|
for name, obj in objects.items():
|
|
src = os.path.join(assets, "objects", obj["hash"][:2], obj["hash"])
|
|
dst = os.path.join(rdir, *name.split("/"))
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
if not os.path.isfile(dst):
|
|
try:
|
|
shutil.copy2(src, dst)
|
|
except OSError:
|
|
pass
|
|
return errors
|
|
|
|
|
|
def ensure(version, progress=None, asset_progress=None, log=None, verify=False,
|
|
cancel=None, asset_mirrors=None, lib_mirrors=None):
|
|
"""Скачивает всё необходимое для запуска. Возвращает (errors, natives_dir).
|
|
|
|
verify=True — полная проверка SHA-1 уже скачанных файлов
|
|
(иначе достаточно совпадения размера).
|
|
cancel — threading.Event: досрочная остановка (бросает http.Cancelled).
|
|
asset_mirrors / lib_mirrors — списки зеркал (пусто = официальные серверы).
|
|
"""
|
|
libs_dir = version.libraries_dir()
|
|
errors = []
|
|
lib_base = "https://libraries.minecraft.net/"
|
|
|
|
tasks = []
|
|
if version.client_url:
|
|
tasks.append(([version.client_url], version.client_jar_path(),
|
|
version.client_sha1, version.client_size))
|
|
for lib in version.libraries:
|
|
path = os.path.join(libs_dir, *lib["path"].split("/"))
|
|
tasks.append((_mirror_urls(lib_mirrors, lib_base, lib["url"]), path,
|
|
lib.get("sha1"), lib.get("size")))
|
|
|
|
if log:
|
|
log(f"Скачивание библиотек ({len(tasks)} шт.)...")
|
|
errors += _download_many(tasks, progress=progress, verify=verify,
|
|
cancel=cancel)
|
|
|
|
natives = extract_natives(version, libs_dir)
|
|
|
|
lf = version.logging.get("file") or {}
|
|
if lf.get("url") and lf.get("id"):
|
|
cfg_dir = os.path.join(version.assets_dir(), "log_configs")
|
|
try:
|
|
http.download_file(lf["url"], os.path.join(cfg_dir, lf["id"]),
|
|
lf.get("sha1"), lf.get("size"), cancel=cancel)
|
|
except http.Cancelled:
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
errors.append(f"log config: {e}")
|
|
|
|
if version.asset_index.get("url"):
|
|
if log:
|
|
log("Скачивание ассетов...")
|
|
errors += _ensure_assets(version, progress=asset_progress,
|
|
verify=verify, cancel=cancel,
|
|
mirrors=asset_mirrors) or []
|
|
|
|
return errors, natives
|