PixelLauncher 2.1.2 — секции настроек, панель быстрых настроек, сборки Modrinth/CurseForge

This commit is contained in:
kosta committed 2026-10-20 17:36:35 +07:00
1 parent 80dc1826a1
commit 39cf752b7b
13 files changed
+972 -49

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
LAUNCHER_NAME = "PixelLauncher"
LAUNCHER_VERSION = "2.1.1"
LAUNCHER_VERSION = "2.1.2"
+218
View File
@@ -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
+158
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
+61
View File
@@ -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)
+1 -1
View File
@@ -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 проверяется, если указан.
+381 -1
View File
@@ -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):
vsel = self.var_ver.get().strip()
if hasattr(self, "var_ver") and self.var_ver is not None:
try:
vsel = self.var_ver.get().strip()
except Exception:
vsel = ""
else:
vsel = str(self.settings.get("version", "latest-release") or "")
latest = self._latest or {}
if vsel.startswith("latest-release"):
return latest.get("release", "latest-release")
+1 -1
View File
@@ -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(),