PixelLauncher 2.1.2 — секции настроек, панель быстрых настроек, сборки Modrinth/CurseForge
This commit is contained in:
1 parent
80dc1826a1
commit
39cf752b7b
13 files changed
+971
-48
No files matched your search
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
|
||||
|
||||
LAUNCHER_NAME = "PixelLauncher"
|
||||
LAUNCHER_VERSION = "2.1.1"
|
||||
LAUNCHER_VERSION = "2.1.2"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+9
-3
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+65
-32
@@ -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)
|
||||
+49
-6
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 проверяется, если указан.
|
||||
|
||||
+380
@@ -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("<Button-1>", lambda e: command())
|
||||
lbl.bind("<Enter>", lambda e: lbl.configure(bg=HOVER, fg=TEXT))
|
||||
lbl.bind("<Leave>", 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("<FocusIn>", lambda e: ent.configure(highlightbackground=GREEN))
|
||||
ent.bind("<FocusOut>",
|
||||
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("<<ListboxSelect>>", load_files)
|
||||
lb_f.bind("<Double-Button-1>", lambda e: install_sel())
|
||||
lb.bind("<Double-Button-1>", 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):
|
||||
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")
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user