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
|
; PixelLauncher — Inno Setup 6 script
|
||||||
; Сборка: python make_installer.py (или: ISCC.exe installer.iss)
|
; Сборка: python make_installer.py (или: ISCC.exe installer.iss)
|
||||||
; Результат: PixelLauncherSetup2.1.1.exe в папке проекта.
|
; Результат: PixelLauncherSetup2.1.2.exe в папке проекта.
|
||||||
; AppId должен оставаться неизменным, иначе обновление появится
|
; AppId должен оставаться неизменным, иначе обновление появится
|
||||||
; как отдельная программа.
|
; как отдельная программа.
|
||||||
|
|
||||||
#define MyAppName "PixelLauncher"
|
#define MyAppName "PixelLauncher"
|
||||||
#define MyAppVersion "2.1.1"
|
#define MyAppVersion "2.1.2"
|
||||||
#define MyAppPublisher "SuperBoys"
|
#define MyAppPublisher "SuperBoys"
|
||||||
#define MyAppURL "https://pixellauncher.ru"
|
#define MyAppURL "https://pixellauncher.ru"
|
||||||
#define StageDir "build\stage"
|
#define StageDir "build\stage"
|
||||||
@@ -32,7 +32,7 @@ ArchitecturesAllowed=x64compatible
|
|||||||
SetupIconFile={#MyIcon}
|
SetupIconFile={#MyIcon}
|
||||||
UninstallDisplayIcon={app}\launcher.ico
|
UninstallDisplayIcon={app}\launcher.ico
|
||||||
UninstallDisplayName={#MyAppName}
|
UninstallDisplayName={#MyAppName}
|
||||||
VersionInfoVersion=2.1.1.0
|
VersionInfoVersion=2.1.2.0
|
||||||
VersionInfoProductVersion={#MyAppVersion}
|
VersionInfoProductVersion={#MyAppVersion}
|
||||||
LZMANumBlockThreads=2
|
LZMANumBlockThreads=2
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
|
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
|
||||||
|
|
||||||
LAUNCHER_NAME = "PixelLauncher"
|
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 time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
USER_AGENT = "PixelLauncher/2.1.1 (lightweight Minecraft launcher)"
|
USER_AGENT = "PixelLauncher/2.1.2 (lightweight Minecraft launcher)"
|
||||||
ATTEMPTS = 4
|
ATTEMPTS = 4
|
||||||
|
|
||||||
_opener = None # настроенный opener (прокси) или None — напрямую в сеть
|
_opener = None # настроенный opener (прокси) или None — напрямую в сеть
|
||||||
@@ -173,12 +173,18 @@ def _open(req, timeout=30):
|
|||||||
return urllib.request.urlopen(req, timeout=timeout)
|
return urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def get_bytes(url, timeout=30):
|
def get_bytes(url, timeout=30, headers=None):
|
||||||
last = None
|
last = None
|
||||||
n = 0
|
n = 0
|
||||||
|
hdrs = {"User-Agent": USER_AGENT}
|
||||||
|
if headers:
|
||||||
|
try:
|
||||||
|
hdrs.update(headers)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for u, mprefix in _attempt_urls(url):
|
for u, mprefix in _attempt_urls(url):
|
||||||
try:
|
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:
|
with _open(req, timeout=timeout) as r:
|
||||||
return r.read()
|
return r.read()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ _TABLE = {
|
|||||||
"Автозагрузка включена: ": "Autostart enabled: ",
|
"Автозагрузка включена: ": "Autostart enabled: ",
|
||||||
"Не удалось изменить автозагрузку": "Failed to change autostart",
|
"Не удалось изменить автозагрузку": "Failed to change autostart",
|
||||||
"Быстрые настройки": "Quick settings",
|
"Быстрые настройки": "Quick settings",
|
||||||
|
"ОЗУ": "RAM",
|
||||||
|
"Версия": "Version",
|
||||||
|
"авто": "auto",
|
||||||
|
"ГБ": "GB",
|
||||||
|
"вкл": "on",
|
||||||
|
"выкл": "off",
|
||||||
|
"Трей": "Tray",
|
||||||
|
"Бэкап": "Backup",
|
||||||
"Светлая тема": "Light theme",
|
"Светлая тема": "Light theme",
|
||||||
"Каталог модпаков": "Modpack Catalog",
|
"Каталог модпаков": "Modpack Catalog",
|
||||||
"Каталог модпаков…": "Modpack Catalog…",
|
"Каталог модпаков…": "Modpack Catalog…",
|
||||||
@@ -468,6 +476,22 @@ _TABLE = {
|
|||||||
"Не удалось выполнить ссылку: ": "Failed to open link: ",
|
"Не удалось выполнить ссылку: ": "Failed to open link: ",
|
||||||
"Протокол: ": "Protocol: ",
|
"Протокол: ": "Protocol: ",
|
||||||
"Действие плагина: ошибка: ": "Plugin action failed: ",
|
"Действие плагина: ошибка: ": "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
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ensure_loader_api(game_dir, game_version, loader, log=None):
|
def search_modpacks(query, game_version="", loader="", limit=20, offset=0):
|
||||||
"""Ставит Fabric API / Quilted Fabric API, если модов нет.
|
"""Поиск сборок 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 — уже стоит или недоступно).
|
|
||||||
"""
|
def get_modpack_versions(pid, game_version="", loader=""):
|
||||||
import os
|
"""Версии сборки Modrinth."""
|
||||||
if loader not in ("fabric", "quilt"):
|
url = BASE + "/project/" + str(pid) + "/version"
|
||||||
return 0
|
qs = []
|
||||||
mods_dir = os.path.join(game_dir, "mods")
|
if game_version:
|
||||||
os.makedirs(mods_dir, exist_ok=True)
|
qs.append("game_versions=" + urllib.parse.quote(json.dumps([game_version])))
|
||||||
slug = FABRIC_API_SLUG if loader == "fabric" else QUILTED_API_SLUG
|
if loader:
|
||||||
prefixes = ["fabric-api"] if loader == "fabric" else ["quilted-fabric-api"]
|
qs.append("loaders=" + urllib.parse.quote(json.dumps([loader])))
|
||||||
if _find_installed(mods_dir, prefixes):
|
if qs:
|
||||||
return 0
|
url += "?" + "&".join(qs)
|
||||||
if log:
|
raw = _get_json(url, ttl=300)
|
||||||
log(f"Modrinth: ставлю {'Fabric API' if loader == 'fabric' else 'Quilted Fabric API'}…")
|
out = []
|
||||||
try:
|
for v in raw if isinstance(raw, list) else []:
|
||||||
pid = slug # API принимает и slug
|
files = v.get("files") or []
|
||||||
vs = versions(pid, game_version, loader)
|
if not files:
|
||||||
if not vs:
|
continue
|
||||||
if log:
|
primary = next((f for f in files if f.get("primary")), files[0])
|
||||||
log(f"Modrinth: нет сборки {slug} для {game_version}/{loader}")
|
hashes = primary.get("hashes") or {}
|
||||||
return 0
|
out.append({
|
||||||
v = vs[0]
|
"id": v.get("id") or "",
|
||||||
dest = os.path.join(mods_dir, v["filename"])
|
"name": (v.get("name") or v.get("version_number") or "?").strip(),
|
||||||
ok = download_mod(v["url"], dest, size=v["size"])
|
"number": (v.get("version_number") or "").strip(),
|
||||||
if ok and log:
|
"date": (v.get("date_published") or "")[:10],
|
||||||
log(f"Modrinth: {v['filename']} установлен")
|
"url": primary.get("url") or "",
|
||||||
return 1 if ok else 0
|
"filename": primary.get("filename") or "",
|
||||||
except Exception as e: # noqa: BLE001 — сеть/JSON не должны ронять лаунчер
|
"size": int(primary.get("size") or 0),
|
||||||
if log:
|
"sha1": hashes.get("sha1") or "",
|
||||||
log(f"Modrinth: не удалось поставить API ({e})")
|
"game_versions": v.get("game_versions") or [],
|
||||||
return 0
|
"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
|
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):
|
def _files_from_index(index):
|
||||||
"""[(путь_относительно_игры, url, filename, size)] — клиентские файлы."""
|
"""[(путь_относительно_игры, url, filename, size)] — клиентские файлы."""
|
||||||
out = []
|
out = []
|
||||||
@@ -67,7 +90,9 @@ def _files_from_index(index):
|
|||||||
downloads = entry.get("downloads") or []
|
downloads = entry.get("downloads") or []
|
||||||
if not downloads:
|
if not downloads:
|
||||||
continue
|
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 ""
|
url = primary.get("url") or ""
|
||||||
if not url:
|
if not url:
|
||||||
continue
|
continue
|
||||||
@@ -137,9 +162,27 @@ def install(game_dir, pack_path, log=None, progress=None, cancel=None):
|
|||||||
ov += 1
|
ov += 1
|
||||||
if log and ov:
|
if log and ov:
|
||||||
log(f"Пак: распаковано {ov} файлов overrides")
|
log(f"Пак: распаковано {ov} файлов overrides")
|
||||||
|
return vid, got
|
||||||
if progress:
|
|
||||||
progress(1.0)
|
|
||||||
if log:
|
def install_from_url(game_dir, url, log=None, progress=None, cancel=None, filename_hint="pack.mrpack"):
|
||||||
log(f"Пак: готово — {got} файл(ов) скачано")
|
"""Скачивает модпак по прямой ссылке и устанавливает."""
|
||||||
|
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
|
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:
|
Манифест на сервере — /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": "…"}
|
"sha256": "…", "size": 15418378, "notes": "…"}
|
||||||
url относительный (база — UPDATE_BASE), sha256/size необязательны,
|
url относительный (база — UPDATE_BASE), sha256/size необязательны,
|
||||||
но sha256 проверяется, если указан.
|
но sha256 проверяется, если указан.
|
||||||
|
|||||||
+380
@@ -24,12 +24,14 @@ from ctypes import wintypes
|
|||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
from . import config, crash as crash_mod, downloader, forge as forge_mod, http
|
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 java as java_mod, java_fetch
|
||||||
from . import launch as launch_mod
|
from . import launch as launch_mod
|
||||||
from . import manifest as manifest_mod
|
from . import manifest as manifest_mod
|
||||||
from . import mods as mods_mod
|
from . import mods as mods_mod
|
||||||
from . import modrinth as modrinth_mod
|
from . import modrinth as modrinth_mod
|
||||||
from . import mrpack as mrpack_mod
|
from . import mrpack as mrpack_mod
|
||||||
|
from . import packs as packs_mod
|
||||||
from . import plugins as plugins_mod
|
from . import plugins as plugins_mod
|
||||||
from . import protocol as protocol_mod
|
from . import protocol as protocol_mod
|
||||||
from . import selfupdate
|
from . import selfupdate
|
||||||
@@ -952,6 +954,12 @@ class App(tk.Tk):
|
|||||||
self.news_rows.pack()
|
self.news_rows.pack()
|
||||||
self._fill_news(self._news)
|
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,
|
self.lbl_status = tk.Label(main, text=T("Готово к запуску"), bg=BG, fg=DIM,
|
||||||
font=("Segoe UI", 8))
|
font=("Segoe UI", 8))
|
||||||
self.lbl_status.pack(pady=(12, 0))
|
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._open_mods)
|
||||||
m.add_command(label=T("Импорт модпака…"), command=self._import_pack)
|
m.add_command(label=T("Импорт модпака…"), command=self._import_pack)
|
||||||
m.add_command(label=T("Каталог модпаков…"), command=self._open_packs)
|
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_plugins)
|
||||||
m.add_command(label=T("Магазин плагинов…"), command=self._open_store)
|
m.add_command(label=T("Магазин плагинов…"), command=self._open_store)
|
||||||
m.add_command(label=T("Скин-превью…"), command=self._open_skin)
|
m.add_command(label=T("Скин-превью…"), command=self._open_skin)
|
||||||
@@ -2469,6 +2479,117 @@ class App(tk.Tk):
|
|||||||
self._finish_dialog(d)
|
self._finish_dialog(d)
|
||||||
return 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):
|
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._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
|
||||||
self._finish_dialog(d)
|
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):
|
def _rebuild_ui(self):
|
||||||
"""Пересобирает главное окно (после смены языка/настроек)."""
|
"""Пересобирает главное окно (после смены языка/настроек)."""
|
||||||
for c in self.winfo_children():
|
for c in self.winfo_children():
|
||||||
@@ -4854,9 +5222,21 @@ class App(tk.Tk):
|
|||||||
if prof:
|
if prof:
|
||||||
text = f"{prof} · {text}"
|
text = f"{prof} · {text}"
|
||||||
self.pill.set_text(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):
|
def _selected_version_id(self):
|
||||||
|
if hasattr(self, "var_ver") and self.var_ver is not None:
|
||||||
|
try:
|
||||||
vsel = self.var_ver.get().strip()
|
vsel = self.var_ver.get().strip()
|
||||||
|
except Exception:
|
||||||
|
vsel = ""
|
||||||
|
else:
|
||||||
|
vsel = str(self.settings.get("version", "latest-release") or "")
|
||||||
latest = self._latest or {}
|
latest = self._latest or {}
|
||||||
if vsel.startswith("latest-release"):
|
if vsel.startswith("latest-release"):
|
||||||
return latest.get("release", "latest-release")
|
return latest.get("release", "latest-release")
|
||||||
|
|||||||
+1
-1
@@ -198,7 +198,7 @@ class Version:
|
|||||||
"version_type": self.type,
|
"version_type": self.type,
|
||||||
"natives_directory": self.natives_dir(),
|
"natives_directory": self.natives_dir(),
|
||||||
"launcher_name": "PixelLauncher",
|
"launcher_name": "PixelLauncher",
|
||||||
"launcher_version": "2.1.1",
|
"launcher_version": "2.1.2",
|
||||||
"classpath": self.classpath_str(),
|
"classpath": self.classpath_str(),
|
||||||
"classpath_separator": os.pathsep,
|
"classpath_separator": os.pathsep,
|
||||||
"library_directory": self.libraries_dir(),
|
"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"
|
ISCC = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||||
|
|
||||||
PYVER = "%d.%d.%d" % sys.version_info[:3]
|
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
|
SETUP_NAME = "PixelLauncherSetup%s.exe" % APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user