From 39cf752b7bcd1050a0e0bc3918e10230d5b6dbb9 Mon Sep 17 00:00:00 2001 From: kosta Date: Tue, 20 Oct 2026 17:36:35 +0700 Subject: [PATCH] =?UTF-8?q?PixelLauncher=202.1.2=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B5=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA,=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B1=D1=8B=D1=81=D1=82=D1=80=D1=8B=D1=85=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BA,=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20Modrinth/CurseForge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- installer.iss | 6 +- launcher/__init__.py | 2 +- launcher/cfpack.py | 218 +++++++++++++++++++++++ launcher/curseforge.py | 158 +++++++++++++++++ launcher/http.py | 12 +- launcher/i18n.py | 24 +++ launcher/modrinth.py | 97 +++++++---- launcher/mrpack.py | 55 +++++- launcher/packs.py | 61 +++++++ launcher/selfupdate.py | 2 +- launcher/ui.py | 382 ++++++++++++++++++++++++++++++++++++++++- launcher/version.py | 2 +- make_installer.py | 2 +- 13 files changed, 972 insertions(+), 49 deletions(-) create mode 100644 launcher/cfpack.py create mode 100644 launcher/curseforge.py create mode 100644 launcher/packs.py diff --git a/installer.iss b/installer.iss index 50f06ea..36941d5 100644 --- a/installer.iss +++ b/installer.iss @@ -1,11 +1,11 @@ ; PixelLauncher — Inno Setup 6 script ; Сборка: python make_installer.py (или: ISCC.exe installer.iss) -; Результат: PixelLauncherSetup2.1.1.exe в папке проекта. +; Результат: PixelLauncherSetup2.1.2.exe в папке проекта. ; AppId должен оставаться неизменным, иначе обновление появится ; как отдельная программа. #define MyAppName "PixelLauncher" -#define MyAppVersion "2.1.1" +#define MyAppVersion "2.1.2" #define MyAppPublisher "SuperBoys" #define MyAppURL "https://pixellauncher.ru" #define StageDir "build\stage" @@ -32,7 +32,7 @@ ArchitecturesAllowed=x64compatible SetupIconFile={#MyIcon} UninstallDisplayIcon={app}\launcher.ico UninstallDisplayName={#MyAppName} -VersionInfoVersion=2.1.1.0 +VersionInfoVersion=2.1.2.0 VersionInfoProductVersion={#MyAppVersion} LZMANumBlockThreads=2 diff --git a/launcher/__init__.py b/launcher/__init__.py index 1e60d1a..9562f83 100644 --- a/launcher/__init__.py +++ b/launcher/__init__.py @@ -1,4 +1,4 @@ """PixelLauncher — lightweight Minecraft launcher (offline mode).""" LAUNCHER_NAME = "PixelLauncher" -LAUNCHER_VERSION = "2.1.1" +LAUNCHER_VERSION = "2.1.2" diff --git a/launcher/cfpack.py b/launcher/cfpack.py new file mode 100644 index 0000000..d07da1e --- /dev/null +++ b/launcher/cfpack.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +"""Установка модпаков CurseForge (zip с manifest.json). + +Формат CurseForge (minecraftModpack): в архиве лежит manifest.json, где +указана версия Minecraft, загрузчик и список модов (projectID + fileID), +плюс папка overrides/ с готовыми файлами игры. + +Старые сборки (minecraftInstance) содержат minecraftinstance.json — там +моды перечислены с готовыми downloadUrl. Поддерживаются оба формата. +""" + +import json +import os +import tempfile +import zipfile + +from . import curseforge, forge, http, mods +from .java_fetch import _safe_join + +LOADERS = ("fabric", "quilt", "forge", "neoforge") + + +def _split_loader(lid): + """'forge-47.2.0' -> ('forge', '47.2.0').""" + lid = str(lid or "").strip() + if "-" in lid: + name, ver = lid.split("-", 1) + return name.lower(), ver + return lid.lower(), "" + + +def read_manifest(path): + """Читает manifest.json. Возвращает (данные, mc, loader, loader_ver).""" + with zipfile.ZipFile(path) as z: + names = set(z.namelist()) + if "manifest.json" not in names: + raise RuntimeError("в архиве нет manifest.json") + with z.open("manifest.json") as f: + data = json.loads(f.read().decode("utf-8")) + minecraft = data.get("minecraft") or {} + mc = minecraft.get("version") or "" + loader, loader_ver = "", "" + for ml in (minecraft.get("modLoaders") or []): + name, ver = _split_loader(ml.get("id")) + if name not in LOADERS: + continue + if not loader or ml.get("primary"): + loader, loader_ver = name, ver + if ml.get("primary"): + break + if not mc: + raise RuntimeError("в паке не указана версия Minecraft") + return data, mc, loader, loader_ver + + +def _legacy_instance(zf): + """Старый формат minecraftinstance.json -> (mods, mc, loader, loader_ver).""" + with zf.open("minecraftinstance.json") as f: + data = json.loads(f.read().decode("utf-8")) + mc = data.get("minecraftVersion") or "" + loader, loader_ver = "", "" + fv = data.get("forgeVersion") or "" + if fv: + loader, loader_ver = "forge", fv + mods_list = [] + for m in (data.get("installedMods") or []) + (data.get("addons") or []): + url = m.get("downloadUrl") or "" + name = m.get("fileName") or "" + if not url or not name: + continue + sha1 = "" + for h in (m.get("hashes") or []): + if h.get("algo") == 1: + sha1 = h.get("value") or "" + mods_list.append({"url": url, "name": name, "sha1": sha1, + "size": int(m.get("fileSize") or 0)}) + return data, mc, loader, loader_ver, mods_list + + +def _install_loader(game_dir, loader, loader_ver, mc, log): + """Ставит загрузчик. Возвращает id версии или None.""" + if not loader: + return None + if loader in ("fabric", "quilt"): + if log: + log("Пак: %s %s" % (loader, loader_ver or "")) + return mods.install(game_dir, loader, mc, loader_version=loader_ver or None) + if loader in ("forge", "neoforge"): + if log: + log("Пак: %s %s" % (loader, loader_ver or "")) + return forge.install(game_dir, loader, mc, + loader_version=loader_ver or None, log=log) + return None + + +def _extract_overrides(zf, game_dir, prefixes=("overrides/", "client-overrides/"), + log=None): + """Распаковывает overrides/ в игровую папку. Возвращает число файлов.""" + count = 0 + for name in zf.namelist(): + if name.endswith("/"): + continue + target = None + for prefix in prefixes: + if name.startswith(prefix): + target = name[len(prefix):] + break + if not target: + continue + dest = _safe_join(game_dir, target) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with zf.open(name) as src, open(dest, "wb") as out: + out.write(src.read()) + count += 1 + if log and count: + log("Пак: распаковано %d файлов overrides" % count) + return count + + +def install(game_dir, pack_path, log=None, progress=None, cancel=None): + """Ставит сборку CurseForge в game_dir. Возвращает (id_версии, файлов).""" + log = log or (lambda m, t=None: None) + with zipfile.ZipFile(pack_path) as zf: + names = set(zf.namelist()) + if "manifest.json" in names: + data, mc, loader, loader_ver = read_manifest(pack_path) + mod_list = [] + for entry in (data.get("files") or []): + if entry.get("required") is False: + continue + pid = entry.get("projectID") + fid = entry.get("fileID") + if not pid or not fid: + continue + mod_list.append({"project": pid, "file": fid}) + elif "minecraftinstance.json" in names: + data, mc, loader, loader_ver, mod_list = _legacy_instance(zf) + else: + raise RuntimeError("в архиве нет manifest.json") + + if not mc: + raise RuntimeError("в паке не указана версия Minecraft") + log("Пак: Minecraft %s%s" % (mc, (", %s %s" % (loader, loader_ver)) + if loader else "")) + + vid = _install_loader(game_dir, loader, loader_ver, mc, log) + if progress: + progress(0.15) + + # моды + mods_dir = os.path.join(game_dir, "mods") + os.makedirs(mods_dir, exist_ok=True) + total = len(mod_list) or 1 + got = 0 + for i, m in enumerate(mod_list): + if cancel is not None and cancel.is_set(): + raise http.Cancelled + if "url" in m: # старый формат + url, name, sha1, size = (m["url"], m["name"], m["sha1"], + m.get("size") or 0) + else: + info = curseforge.get_file(m["project"], m["file"]) + url = info.get("download_url") or "" + name = info.get("file_name") or "" + sha1 = info.get("sha1") or "" + size = info.get("size") or 0 + if not url or not name: + continue + name = os.path.basename(name.replace("\\", "/")) + dest = _safe_join(mods_dir, name) + log("Пак: %s" % name) + try: + http.download_file(url, dest, size=size or None, sha1=sha1 or None, + progress=None, cancel=cancel) + got += 1 + except http.Cancelled: + raise + except Exception as e: # noqa: BLE001 + log("Пак: не скачан %s (%s)" % (name, e), "warn") + if progress: + progress(0.15 + 0.75 * (i + 1) / total) + + # overrides + prefixes = ("overrides/", "client-overrides/") + ov_dir = (data.get("overrides") or "overrides") + if ov_dir and ov_dir != "overrides": + prefixes = (ov_dir.rstrip("/") + "/",) + prefixes + _extract_overrides(zf, game_dir, prefixes, log=log) + + if progress: + progress(0.97) + return vid, got + + +def install_from_url(game_dir, url, log=None, progress=None, cancel=None, + filename_hint="pack.zip"): + """Скачивает сборку CurseForge по прямой ссылке и устанавливает.""" + dest = os.path.join(tempfile.gettempdir(), "PixelLauncher", + os.path.basename(filename_hint.replace("\\", "/")) + or "pack.zip") + os.makedirs(os.path.dirname(dest), exist_ok=True) + + def prog(done_b, total_b): + if progress and total_b: + progress(min(0.15, (done_b / total_b) * 0.15)) + + http.download_file(url, dest, progress=prog, cancel=cancel) + + def p2(f): + if progress: + progress(0.15 + f * 0.85) + + vid, got = install(game_dir, dest, log=log, progress=p2, cancel=cancel) + try: + os.unlink(dest) + except OSError: + pass + return vid, got diff --git a/launcher/curseforge.py b/launcher/curseforge.py new file mode 100644 index 0000000..baface4 --- /dev/null +++ b/launcher/curseforge.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +"""Клиент CurseForge API (https://api.curseforge.com/v1). + +Официальный API требует заголовок x-api-key. Без ключа используется публичное +зеркало api.curse.tools — формат ответов там тот же (v1), так что код один. +Если ключ задан (set_api_key), запросы идут сначала на официальный API. +""" +import json +import urllib.parse + +from . import http + +OFFICIAL = "https://api.curseforge.com/v1" +MIRROR = "https://api.curse.tools/v1/cf" +HEADERS = {"Accept": "application/json", + "User-Agent": "PixelLauncher/2.1.1 (lightweight Minecraft launcher)"} + +# числовые id загрузчиков в CurseForge +LOADER_NAMES = {1: "forge", 2: "cauldron", 3: "liteloader", 4: "fabric", + 5: "quilt", 6: "neoforge"} + +_api_key = "" + + +def set_api_key(key): + """Ключ CurseForge API (необязателен — есть зеркало без ключа).""" + global _api_key + _api_key = (key or "").strip() + + +def get_api_key(): + return _api_key + + +def _bases(): + if _api_key: + return [OFFICIAL, MIRROR] + return [MIRROR] + + +def _get(path, params=None, timeout=30): + """GET API. Бросает RuntimeError с текстом последней ошибки.""" + last = None + for base in _bases(): + url = base + path + if params: + url += "?" + urllib.parse.urlencode(params) + headers = dict(HEADERS) + if base == OFFICIAL and _api_key: + headers["x-api-key"] = _api_key + try: + raw = http.get_bytes(url, timeout=timeout, headers=headers) + return json.loads(raw.decode("utf-8")) + except Exception as e: # noqa: BLE001 + last = e + raise RuntimeError(str(last) if last else "CurseForge недоступен") + + +def search_modpacks(search_filter="", game_version="", modloader_type=None, + page_size=20, index=0): + """Поиск сборок (classId 4471). Возвращает (список, всего_найдено).""" + params = { + "gameId": 432, + "classId": 4471, + "pageSize": page_size, + "index": index, + "sortField": 2, # downloads + "sortOrder": "desc", + } + if search_filter: + params["searchFilter"] = search_filter + if game_version: + params["gameVersion"] = game_version + if modloader_type: + params["modLoaderType"] = modloader_type + data = _get("/mods/search", params) + out = [] + for m in (data.get("data") or []): + logo = m.get("logo") or {} + files = m.get("latestFiles") or [] + latest = next((f for f in files if f.get("downloadUrl")), + files[0] if files else None) + latest = latest or {} + loaders = [] + for idx in (m.get("latestFilesIndexes") or []): + name = LOADER_NAMES.get(idx.get("modLoader")) + if name and name not in loaders: + loaders.append(name) + out.append({ + "id": m.get("id"), + "name": m.get("name") or "?", + "slug": m.get("slug") or "", + "summary": (m.get("summary") or "").strip(), + "downloads": int(m.get("downloadCount") or 0), + "icon": logo.get("thumbnailUrl") or logo.get("url") or "", + "file_id": latest.get("id"), + "file_name": latest.get("fileName") or "", + "download_url": latest.get("downloadUrl") or "", + "size": int(latest.get("fileLength") or 0), + "game_versions": [x.get("gameVersion") + for x in (m.get("latestFilesIndexes") or [])], + "loaders": loaders, + }) + return out, int((data.get("pagination") or {}).get("totalCount") or 0) + + +def get_files(mod_id, game_version="", modloader_type=None, page_size=50): + """Файлы сборки/мода. Возвращает список словарей.""" + params = {"pageSize": page_size} + if game_version: + params["gameVersion"] = game_version + if modloader_type: + params["modLoaderType"] = modloader_type + data = _get("/mods/%s/files" % mod_id, params) + out = [] + for f in (data.get("data") or []): + out.append(_file(f)) + return out + + +def get_file(mod_id, file_id): + """Один файл по id (нужен downloadUrl для скачивания).""" + data = _get("/mods/%s/files/%s" % (mod_id, file_id)) + f = data.get("data") if isinstance(data, dict) else None + return _file(f) if isinstance(f, dict) else {} + + +def _file(f): + """Приводит ответ API к общему виду.""" + hashes = {} + for h in (f.get("hashes") or []): + algo = h.get("algo") + if algo == 1: + hashes["sha1"] = h.get("value") or "" + elif algo == 2: + hashes["md5"] = h.get("value") or "" + return { + "id": f.get("id"), + "mod_id": f.get("modId"), + "name": f.get("displayName") or f.get("fileName") or "", + "file_name": f.get("fileName") or "", + "download_url": f.get("downloadUrl") or "", + "size": int(f.get("fileLength") or 0), + "date": (f.get("fileDate") or "")[:10], + "game_versions": f.get("gameVersions") or [], + "sha1": hashes.get("sha1", ""), + "md5": hashes.get("md5", ""), + } + + +def get_download_url(mod_id, file_id): + """Прямая ссылка на файл (downloadUrl из API).""" + return get_file(mod_id, file_id).get("download_url") or "" + + +def download_pack(url, dest, size=0, progress=None, cancel=None): + return http.download_file(url, dest, size=size, progress=progress, + cancel=cancel) diff --git a/launcher/http.py b/launcher/http.py index 6f350e2..1cb9ffa 100644 --- a/launcher/http.py +++ b/launcher/http.py @@ -13,7 +13,7 @@ import struct import time import urllib.request -USER_AGENT = "PixelLauncher/2.1.1 (lightweight Minecraft launcher)" +USER_AGENT = "PixelLauncher/2.1.2 (lightweight Minecraft launcher)" ATTEMPTS = 4 _opener = None # настроенный opener (прокси) или None — напрямую в сеть @@ -173,12 +173,18 @@ def _open(req, timeout=30): return urllib.request.urlopen(req, timeout=timeout) -def get_bytes(url, timeout=30): +def get_bytes(url, timeout=30, headers=None): last = None n = 0 + hdrs = {"User-Agent": USER_AGENT} + if headers: + try: + hdrs.update(headers) + except Exception: + pass for u, mprefix in _attempt_urls(url): try: - req = urllib.request.Request(u, headers={"User-Agent": USER_AGENT}) + req = urllib.request.Request(u, headers=hdrs) with _open(req, timeout=timeout) as r: return r.read() except Exception as e: # noqa: BLE001 diff --git a/launcher/i18n.py b/launcher/i18n.py index a571e01..3ff3ad1 100644 --- a/launcher/i18n.py +++ b/launcher/i18n.py @@ -117,6 +117,14 @@ _TABLE = { "Автозагрузка включена: ": "Autostart enabled: ", "Не удалось изменить автозагрузку": "Failed to change autostart", "Быстрые настройки": "Quick settings", + "ОЗУ": "RAM", + "Версия": "Version", + "авто": "auto", + "ГБ": "GB", + "вкл": "on", + "выкл": "off", + "Трей": "Tray", + "Бэкап": "Backup", "Светлая тема": "Light theme", "Каталог модпаков": "Modpack Catalog", "Каталог модпаков…": "Modpack Catalog…", @@ -468,6 +476,22 @@ _TABLE = { "Не удалось выполнить ссылку: ": "Failed to open link: ", "Протокол: ": "Protocol: ", "Действие плагина: ошибка: ": "Plugin action failed: ", + + # --- сборки (Modrinth / CurseForge) + "Modrinth: сборки": "Modrinth: modpacks", + "CurseForge: сборки": "CurseForge: modpacks", + "Сборки Modrinth…": "Modrinth modpacks…", + "Сборки CurseForge…": "CurseForge modpacks…", + "Поиск сборок": "Search modpacks", + "Файлы сборки": "Pack files", + "Файлы сборки: ": "Pack files: ", + "Выберите сборку": "Pick a modpack first", + "Установка сборки: ": "Installing modpack: ", + "Модпак установлен": "Modpack installed", + "Сборка": "Modpack", + "Загрузка…": "Loading…", + "КБ": "KB", + "Б": "B", } diff --git a/launcher/modrinth.py b/launcher/modrinth.py index 3a8c139..6828972 100644 --- a/launcher/modrinth.py +++ b/launcher/modrinth.py @@ -141,36 +141,69 @@ def _find_installed(mods_dir, prefixes): return False -def ensure_loader_api(game_dir, game_version, loader, log=None): - """Ставит Fabric API / Quilted Fabric API, если модов нет. +def search_modpacks(query, game_version="", loader="", limit=20, offset=0): + """Поиск сборок Modrinth (project_type=modpack).""" + facets = [["project_type:modpack"]] + if game_version: + facets.append(["versions:" + game_version]) + if loader: + facets.append(["categories:" + loader]) + params = { + "query": query or "", + "facets": json.dumps(facets, separators=(",", ":")), + "limit": limit, + "offset": offset, + "index": "relevance" if query else "downloads", + } + url = BASE + "/search?" + urllib.parse.urlencode(params) + data = _get_json(url, ttl=300) + out = [] + for h in data.get("hits") or []: + out.append({ + "id": h.get("project_id") or h.get("project_id") or h.get("slug") or "", + "slug": h.get("slug") or "", + "title": h.get("title") or h.get("slug") or "?", + "desc": (h.get("description") or "").strip(), + "downloads": int(h.get("downloads") or 0), + "icon": h.get("icon_url") or "", + "game_versions": h.get("versions") or [], + "categories": h.get("categories") or [], + }) + return out, int(data.get("total_hits") or 0) - Возвращает число скачанных файлов (0 — уже стоит или недоступно). - """ - import os - if loader not in ("fabric", "quilt"): - return 0 - mods_dir = os.path.join(game_dir, "mods") - os.makedirs(mods_dir, exist_ok=True) - slug = FABRIC_API_SLUG if loader == "fabric" else QUILTED_API_SLUG - prefixes = ["fabric-api"] if loader == "fabric" else ["quilted-fabric-api"] - if _find_installed(mods_dir, prefixes): - return 0 - if log: - log(f"Modrinth: ставлю {'Fabric API' if loader == 'fabric' else 'Quilted Fabric API'}…") - try: - pid = slug # API принимает и slug - vs = versions(pid, game_version, loader) - if not vs: - if log: - log(f"Modrinth: нет сборки {slug} для {game_version}/{loader}") - return 0 - v = vs[0] - dest = os.path.join(mods_dir, v["filename"]) - ok = download_mod(v["url"], dest, size=v["size"]) - if ok and log: - log(f"Modrinth: {v['filename']} установлен") - return 1 if ok else 0 - except Exception as e: # noqa: BLE001 — сеть/JSON не должны ронять лаунчер - if log: - log(f"Modrinth: не удалось поставить API ({e})") - return 0 + +def get_modpack_versions(pid, game_version="", loader=""): + """Версии сборки Modrinth.""" + url = BASE + "/project/" + str(pid) + "/version" + qs = [] + if game_version: + qs.append("game_versions=" + urllib.parse.quote(json.dumps([game_version]))) + if loader: + qs.append("loaders=" + urllib.parse.quote(json.dumps([loader]))) + if qs: + url += "?" + "&".join(qs) + raw = _get_json(url, ttl=300) + out = [] + for v in raw if isinstance(raw, list) else []: + files = v.get("files") or [] + if not files: + continue + primary = next((f for f in files if f.get("primary")), files[0]) + hashes = primary.get("hashes") or {} + out.append({ + "id": v.get("id") or "", + "name": (v.get("name") or v.get("version_number") or "?").strip(), + "number": (v.get("version_number") or "").strip(), + "date": (v.get("date_published") or "")[:10], + "url": primary.get("url") or "", + "filename": primary.get("filename") or "", + "size": int(primary.get("size") or 0), + "sha1": hashes.get("sha1") or "", + "game_versions": v.get("game_versions") or [], + "loaders": v.get("loaders") or [], + }) + return out + + +def download_pack(file_url, dest, size=0, progress=None, cancel=None): + return http.download_file(file_url, dest, size=size, progress=progress, cancel=cancel) diff --git a/launcher/mrpack.py b/launcher/mrpack.py index c7448dc..1b893a7 100644 --- a/launcher/mrpack.py +++ b/launcher/mrpack.py @@ -57,6 +57,29 @@ def read_index(path): return data, mc, loader, loader_ver +def _primary_download(downloads): + """Достаёт основной файл из "downloads". + + Формат в индексе Modrinth встречается в трёх видах: + ["https://..."] — просто список ссылок, + [{"hashes": {...}, "url": "...", ...}] — старый формат с объектами, + {"primary": "https://...", "alternate": [...]} — новый формат. + """ + if isinstance(downloads, dict): + downloads = ([downloads.get("primary")] + + list(downloads.get("alternate") or [])) + if not downloads: + return None + first = downloads[0] + if isinstance(first, str): + return {"url": first, "filename": "", "size": 0} + primary = next((d for d in downloads + if isinstance(d, dict) and d.get("primary")), first) + if not isinstance(primary, dict): + return None + return primary + + def _files_from_index(index): """[(путь_относительно_игры, url, filename, size)] — клиентские файлы.""" out = [] @@ -67,7 +90,9 @@ def _files_from_index(index): downloads = entry.get("downloads") or [] if not downloads: continue - primary = next((d for d in downloads if d.get("primary")), downloads[0]) + primary = _primary_download(downloads) + if not primary: + continue url = primary.get("url") or "" if not url: continue @@ -137,9 +162,27 @@ def install(game_dir, pack_path, log=None, progress=None, cancel=None): ov += 1 if log and ov: log(f"Пак: распаковано {ov} файлов overrides") - - if progress: - progress(1.0) - if log: - log(f"Пак: готово — {got} файл(ов) скачано") + return vid, got + + +def install_from_url(game_dir, url, log=None, progress=None, cancel=None, filename_hint="pack.mrpack"): + """Скачивает модпак по прямой ссылке и устанавливает.""" + import tempfile + dest = os.path.join(tempfile.gettempdir(), "PixelLauncher", + filename_hint.replace("/", "_")) + os.makedirs(os.path.dirname(dest), exist_ok=True) + + def prog(done_b, total_b): + if progress and total_b: + progress(min(0.4, (done_b / total_b) * 0.4)) + + http.download_file(url, dest, progress=prog, cancel=cancel) + def p2(f): + if progress: + progress(0.4 + f * 0.6) + vid, got = install(game_dir, dest, log=log, progress=p2, cancel=cancel) + try: + os.unlink(dest) + except OSError: + pass return vid, got diff --git a/launcher/packs.py b/launcher/packs.py new file mode 100644 index 0000000..38e0111 --- /dev/null +++ b/launcher/packs.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Утилиты для работы с модпаками Modrinth/CurseForge.""" +import os +import re +import tempfile + +from . import forge, http, mods +from .java_fetch import _safe_join +from . import modrinth, curseforge + + +def install_pack_mrpack_url(game_dir, url, log=None, progress=None, cancel=None): + """Скачивает и ставит .mrpack по URL.""" + from . import mrpack as mrpack_mod + filename = url.rsplit("/", 1)[-1] or "pack.mrpack" + return mrpack_mod.install_from_url(game_dir, url, log=log, progress=progress, + cancel=cancel, filename_hint=filename) + + +def install_pack_modrinth(project_id, game_dir, version=None, game_version="", + loader="", log=None, progress=None, cancel=None): + """Устанавливает сборку Modrinth по project_id (slug или id). + + Возвращает (id_версии, скачано_файлов). + """ + from . import mrpack as mrpack_mod + vs = modrinth.get_modpack_versions(project_id, game_version=game_version, + loader=loader) + if not vs: + raise RuntimeError("Нет версий сборки Modrinth") + v = version if version else vs[0] + if isinstance(v, str): + v = next((x for x in vs if x["id"] == v or x["filename"] == v), vs[0]) + url = v.get("url") or "" + if not url: + raise RuntimeError("У версии сборки нет ссылки на файл") + filename = v.get("filename") or "pack.mrpack" + return mrpack_mod.install_from_url(game_dir, url, log=log, progress=progress, + cancel=cancel, filename_hint=filename) + + +def install_pack_curseforge(mod_id, file_id, game_dir, log=None, progress=None, + cancel=None): + """Устанавливает сборку CurseForge (mod_id + file_id).""" + from . import cfpack + info = curseforge.get_file(mod_id, file_id) + url = info.get("download_url") or "" + if not url: + raise RuntimeError("Не удалось получить ссылку на загрузку CurseForge") + filename = info.get("file_name") or ("pack-%s.zip" % mod_id) + return cfpack.install_from_url(game_dir, url, log=log, progress=progress, + cancel=cancel, filename_hint=filename) + + +def install_pack_curseforge_url(game_dir, url, log=None, progress=None, + cancel=None): + """Устанавливает сборку CurseForge по прямой ссылке на zip.""" + from . import cfpack + filename = os.path.basename(url.split("?")[0]) or "pack.zip" + return cfpack.install_from_url(game_dir, url, log=log, progress=progress, + cancel=cancel, filename_hint=filename) diff --git a/launcher/selfupdate.py b/launcher/selfupdate.py index 9154b30..f277153 100644 --- a/launcher/selfupdate.py +++ b/launcher/selfupdate.py @@ -2,7 +2,7 @@ """Автообновление лаунчера: проверка манифеста, скачивание установщика. Манифест на сервере — /update.json: - {"version": "2.1.1", "url": "/download/PixelLauncherSetup2.1.1.exe", + {"version": "2.1.2", "url": "/download/PixelLauncherSetup2.1.2.exe", "sha256": "…", "size": 15418378, "notes": "…"} url относительный (база — UPDATE_BASE), sha256/size необязательны, но sha256 проверяется, если указан. diff --git a/launcher/ui.py b/launcher/ui.py index 8381c1d..08db1f9 100644 --- a/launcher/ui.py +++ b/launcher/ui.py @@ -24,12 +24,14 @@ from ctypes import wintypes from tkinter import filedialog, messagebox, ttk from . import config, crash as crash_mod, downloader, forge as forge_mod, http +from . import curseforge as curseforge_mod from . import java as java_mod, java_fetch from . import launch as launch_mod from . import manifest as manifest_mod from . import mods as mods_mod from . import modrinth as modrinth_mod from . import mrpack as mrpack_mod +from . import packs as packs_mod from . import plugins as plugins_mod from . import protocol as protocol_mod from . import selfupdate @@ -952,6 +954,12 @@ class App(tk.Tk): self.news_rows.pack() self._fill_news(self._news) + # --- Быстрые настройки прямо на главном экране: частые переключатели + # не требуют ни меню ⚙, ни диалога «Настройки». + self.quick_bar = tk.Frame(main, bg=BG) + self.quick_bar.pack(pady=(10, 0)) + self._build_quick_bar(self.quick_bar) + self.lbl_status = tk.Label(main, text=T("Готово к запуску"), bg=BG, fg=DIM, font=("Segoe UI", 8)) self.lbl_status.pack(pady=(12, 0)) @@ -1657,6 +1665,8 @@ class App(tk.Tk): m.add_command(label=T("Моды…"), command=self._open_mods) m.add_command(label=T("Импорт модпака…"), command=self._import_pack) m.add_command(label=T("Каталог модпаков…"), command=self._open_packs) + m.add_command(label=T("Сборки Modrinth…"), command=self._open_modrinth_packs) + m.add_command(label=T("Сборки CurseForge…"), command=self._open_curseforge_packs) m.add_command(label=T("Плагины…"), command=self._open_plugins) m.add_command(label=T("Магазин плагинов…"), command=self._open_store) m.add_command(label=T("Скин-превью…"), command=self._open_skin) @@ -2469,6 +2479,117 @@ class App(tk.Tk): self._finish_dialog(d) return d + def _build_quick_bar(self, parent): + """Кликабельные чипы быстрых настроек на главном экране. + + Дублируют подменю ⚙ → «Быстрые настройки»: язык, масштаб, + автозагрузка, трей и авто-бэкап меняются одним кликом. + """ + def chip(text, command, active=False): + bg = GREEN_BD if active else PILL + fg = TEXT if active else DIM + lbl = tk.Label(parent, text=text, bg=bg, fg=fg, + font=("Segoe UI", 8), padx=9, pady=3, + cursor="hand2") + lbl.pack(side="left", padx=3) + lbl.bind("", lambda e: command()) + lbl.bind("", lambda e: lbl.configure(bg=HOVER, fg=TEXT)) + lbl.bind("", lambda e: lbl.configure(bg=bg, fg=fg)) + return lbl + + s = self.settings + on, off = T("вкл"), T("выкл") + cur_lang = s.get("lang", "ru") + + ram_txt = (T("авто") if s.get("auto_ram") + else self._fmt_ram(s.get("ram", 2048))) + chip(T("ОЗУ") + ": " + ram_txt, self._cycle_ram, + active=bool(s.get("auto_ram"))) + + self.quick_ver_chip = chip(self._ver_chip_text(), + self._open_versions) + + chip("RU" if cur_lang == "ru" else "EN", + lambda: self._quick_lang("en" if cur_lang == "ru" else "ru")) + + chip("%d%%" % int(round(float(s.get("ui_scale", 1.0)) * 100)), + self._cycle_scale) + + chip(T("Автозагрузка") + ": " + (on if s.get("autostart") else off), + self._toggle_autostart, active=bool(s.get("autostart"))) + + chip(T("Трей") + ": " + (on if s.get("minimize_to_tray_on_launch") + else off), + self._toggle_tray, + active=bool(s.get("minimize_to_tray_on_launch"))) + + chip(T("Бэкап") + ": " + (on if s.get("auto_backup") else off), + self._toggle_backup, active=bool(s.get("auto_backup"))) + + @staticmethod + def _fmt_ram(mb): + try: + gb = float(mb) / 1024 + except (TypeError, ValueError): + gb = 2.0 + return ("%.1f" % gb).rstrip("0").rstrip(".") + " " + T("ГБ") + + def _ver_chip_text(self): + vid = self._selected_version_id() + if len(vid) > 14: + vid = vid[:13] + "…" + return T("Версия") + ": " + vid + + def _cycle_ram(self): + steps = [None, 2048, 4096, 6144, 8192] + if self.settings.get("auto_ram"): + cur = None + else: + try: + cur = int(self.settings.get("ram", 2048)) + except (TypeError, ValueError): + cur = 2048 + try: + nxt = steps[(steps.index(cur) + 1) % len(steps)] + except ValueError: + nxt = steps[0] + if nxt is None: + self.settings["auto_ram"] = True + else: + self.settings["auto_ram"] = False + self.settings["ram"] = nxt + config.save_settings(self.settings) + self.after_idle(self._rebuild_ui) + + def _cycle_scale(self): + """Масштаб по кругу: 100 → 125 → 150 → 100 %.""" + steps = (100, 125, 150) + cur = int(round(float(self.settings.get("ui_scale", 1.0)) * 100)) + try: + nxt = steps[(steps.index(cur) + 1) % len(steps)] + except ValueError: + nxt = 100 + self._quick_scale(nxt) + + def _toggle_autostart(self): + on = not bool(self.settings.get("autostart")) + if not self._set_autostart(on): + return + self.settings["autostart"] = on + config.save_settings(self.settings) + self.after_idle(self._rebuild_ui) + + def _toggle_tray(self): + self.settings["minimize_to_tray_on_launch"] = not bool( + self.settings.get("minimize_to_tray_on_launch")) + config.save_settings(self.settings) + self.after_idle(self._rebuild_ui) + + def _toggle_backup(self): + self.settings["auto_backup"] = not bool(self.settings.get("auto_backup")) + config.save_settings(self.settings) + self.after_idle(self._rebuild_ui) + def _quick_settings(self, parent): """Подменю быстрых настроек в главном меню. @@ -2810,6 +2931,253 @@ class App(tk.Tk): self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) self._finish_dialog(d) + def _open_modrinth_packs(self): + """Сборки Modrinth: поиск, выбор версии, установка.""" + self._open_pack_browser("modrinth") + + def _open_curseforge_packs(self): + """Сборки CurseForge: поиск, выбор файла, установка.""" + self._open_pack_browser("curseforge") + + def _open_pack_browser(self, source): + """Диалог сборок: Modrinth (source='modrinth') или CurseForge.""" + is_mr = source == "modrinth" + d = self._dialog(T("Modrinth: сборки") if is_mr + else T("CurseForge: сборки"), width=700) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=12, pady=10) + + top = tk.Frame(body, bg=BG) + top.pack(fill="x") + var_q = tk.StringVar() + ent = tk.Entry(top, textvariable=var_q, width=42, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightbackground=INPUT_BD, highlightthickness=1) + ent.pack(side="left", ipady=2) + ent.bind("", lambda e: ent.configure(highlightbackground=GREEN)) + ent.bind("", + lambda e: ent.configure(highlightbackground=INPUT_BD)) + tk.Label(top, text=T("Поиск сборок"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(8, 0)) + + status = tk.Label(body, text=T("Загрузка…"), bg=BG, fg=DIM, anchor="w", + font=("Segoe UI", 8)) + status.pack(fill="x", pady=(8, 0)) + + lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN, + selectforeground="#0d1a0d", relief="solid", bd=1, + highlightthickness=0, font=("Segoe UI", 9), + exportselection=False) + lb.pack(fill="both", expand=True, pady=(4, 0)) + + lbl_files = tk.Label(body, text=T("Файлы сборки"), bg=BG, fg=DIM, + anchor="w", font=("Segoe UI", 8)) + lbl_files.pack(fill="x", pady=(8, 0)) + lb_f = tk.Listbox(body, height=6, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Segoe UI", 9), exportselection=False) + lb_f.pack(fill="both", expand=True, pady=(4, 0)) + + packs = [] + files = [] + timer = {"id": None} + + def run(fn, ok, err): + """Выполняет fn в фоне, результат отдаёт в GUI-поток.""" + state = {"done": False, "data": None, "error": None} + + def go(): + try: + state["data"] = fn() + except Exception as e: # noqa: BLE001 + state["error"] = e + state["done"] = True + + threading.Thread(target=go, daemon=True).start() + + def wait(): + try: + if not d.winfo_exists(): + return + except tk.TclError: + return + if not state["done"]: + d.after(120, wait) + return + if state["error"] is not None: + err(state["error"]) + else: + ok(state["data"]) + wait() + + def fmt_size(n): + n = int(n or 0) + if n <= 0: + return "" + if n >= 1024 * 1024: + return "%.1f %s" % (n / (1024.0 * 1024.0), T("МБ")) + if n >= 1024: + return "%d %s" % (n // 1024, T("КБ")) + return "%d %s" % (n, T("Б")) + + def search_packs(): + q = var_q.get().strip() + status.configure(text=T("Загрузка…"), fg=DIM) + + def ok(res): + nonlocal packs + out, total = res + packs = out + lb.delete(0, "end") + for it in out: + title = it.get("title") or it.get("name") or "?" + desc = it.get("desc") or it.get("summary") or "" + bits = [] + if it.get("downloads"): + bits.append(str(it["downloads"])) + if it.get("loaders"): + bits.append(", ".join(it["loaders"][:2])) + tail = (" [" + " · ".join(bits) + "]") if bits else "" + lb.insert("end", "%s — %s%s" % (title, desc, tail)) + status.configure(text=T("Найдено: ") + str(total), fg=DIM) + + def err(e): + status.configure(text=T("Ошибка: ") + str(e), fg=ERR) + + if is_mr: + api = lambda: modrinth_mod.search_modpacks(q, limit=30) + else: + api = lambda: curseforge_mod.search_modpacks(q, page_size=30) + run(api, ok, err) + + def schedule_search(*_a): + if timer["id"] is not None: + try: + d.after_cancel(timer["id"]) + except tk.TclError: + pass + timer["id"] = d.after(400, search_packs) + + def load_files(*_a): + sel = lb.curselection() + lb_f.delete(0, "end") + files.clear() + if not sel or sel[0] >= len(packs): + lbl_files.configure(text=T("Файлы сборки"), fg=DIM) + return + p = packs[sel[0]] + status.configure(text=T("Загрузка…"), fg=DIM) + + def ok(res): + files.clear() + files.extend(res) + lb_f.delete(0, "end") + for f in res: + bits = [] + gv = f.get("game_versions") or [] + ld = f.get("loaders") or [] + if gv: + bits.append(", ".join(str(x) for x in gv[:3])) + if ld: + bits.append(", ".join(str(x) for x in ld[:2])) + if f.get("size"): + bits.append(fmt_size(f["size"])) + if f.get("date"): + bits.append(str(f["date"])) + name = f.get("name") or f.get("file_name") or "?" + tail = (" [" + " · ".join(bits) + "]") if bits else "" + lb_f.insert("end", "%s%s" % (name, tail)) + lbl_files.configure( + text=T("Файлы сборки") + ": " + str(len(files)), fg=DIM) + status.configure(text=T("Найдено: ") + str(len(files)), fg=DIM) + + def err(e): + status.configure(text=T("Ошибка: ") + str(e), fg=ERR) + + if is_mr: + pid = p.get("slug") or p.get("id") + api = lambda: modrinth_mod.get_modpack_versions(pid) + else: + api = lambda: curseforge_mod.get_files(p.get("id")) + run(api, ok, err) + + def install_sel(): + sel = lb.curselection() + if not sel or sel[0] >= len(packs): + status.configure(text=T("Выберите сборку"), fg=ERR) + return + p = packs[sel[0]] + fsel = lb_f.curselection() + f = files[fsel[0]] if (fsel and fsel[0] < len(files)) else None + title = p.get("title") or p.get("name") or "?" + d.destroy() + if is_mr: + pid = p.get("slug") or p.get("id") + ver = (f or {}).get("id") + + def worker(log, prog, cancel): + return packs_mod.install_pack_modrinth( + pid, game_dir=self._game_dir(), version=ver, log=log, + progress=prog, cancel=cancel) + else: + mid = p.get("id") + fid = (f or {}).get("id") or p.get("file_id") + + def worker(log, prog, cancel): + return packs_mod.install_pack_curseforge( + mid, fid, game_dir=self._game_dir(), log=log, + progress=prog, cancel=cancel) + self._install_pack_async( + worker, T("Установка сборки: ") + str(title)) + + var_q.trace_add("write", schedule_search) + lb.bind("<>", load_files) + lb_f.bind("", lambda e: install_sel()) + lb.bind("", lambda e: install_sel()) + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Установить"), install_sel, primary=True).pack( + side="left") + self._btn(btns, T("Обновить список"), search_packs).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Закрыть"), d.destroy).pack(side="right") + search_packs() + self._finish_dialog(d) + + def _install_pack_async(self, worker, label): + """Ставит сборку в фоне: прогресс и лог — в главном окне.""" + if self.busy: + return + self.busy = True + self._cancel = threading.Event() + cancel = self._cancel + self.btn_play.set_state("disabled") + self.btn_cancel.configure(state="normal") + self.btn_cancel.pack() + self.progress.set(0) + + def bg(): + log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731 + + def prog(f): + self.ui_q.put(("progress", f * 100, 100, T("Сборка"))) + try: + log(label) + vid, got = worker(log, prog, cancel) + if vid: + self.ui_q.put(("use_version", vid)) + self.ui_q.put(("reload_versions",)) + log(T("Модпак установлен") + (f" ({got})" if got else ""), "ok") + except http.Cancelled: + log(T("Загрузка отменена"), "warn") + except Exception as e: # noqa: BLE001 + log(T("Ошибка: ") + str(e), "err") + finally: + self.ui_q.put(("done",)) + threading.Thread(target=bg, daemon=True).start() + def _rebuild_ui(self): """Пересобирает главное окно (после смены языка/настроек).""" for c in self.winfo_children(): @@ -4854,9 +5222,21 @@ class App(tk.Tk): if prof: text = f"{prof} · {text}" self.pill.set_text(text) + if hasattr(self, "quick_ver_chip") and self.quick_ver_chip.winfo_exists(): + try: + self.quick_ver_chip.configure(text=self._ver_chip_text()) + except tk.TclError: + pass + def _selected_version_id(self): - vsel = self.var_ver.get().strip() + if hasattr(self, "var_ver") and self.var_ver is not None: + try: + vsel = self.var_ver.get().strip() + except Exception: + vsel = "" + else: + vsel = str(self.settings.get("version", "latest-release") or "") latest = self._latest or {} if vsel.startswith("latest-release"): return latest.get("release", "latest-release") diff --git a/launcher/version.py b/launcher/version.py index efe6d2e..68e000c 100644 --- a/launcher/version.py +++ b/launcher/version.py @@ -198,7 +198,7 @@ class Version: "version_type": self.type, "natives_directory": self.natives_dir(), "launcher_name": "PixelLauncher", - "launcher_version": "2.1.1", + "launcher_version": "2.1.2", "classpath": self.classpath_str(), "classpath_separator": os.pathsep, "library_directory": self.libraries_dir(), diff --git a/make_installer.py b/make_installer.py index 68c232e..f846674 100644 --- a/make_installer.py +++ b/make_installer.py @@ -26,7 +26,7 @@ STAGE = os.path.join(BUILD, "stage") ISCC = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PYVER = "%d.%d.%d" % sys.version_info[:3] -APP_VERSION = "2.1.1" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py +APP_VERSION = "2.1.2" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py SETUP_NAME = "PixelLauncherSetup%s.exe" % APP_VERSION