PixelLauncher 2.1.0 — стабильная сборка

Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров
(Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление
модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
This commit is contained in:
kosta2014p committed 2026-10-18 22:44:19 +07:00
commit ec6f13ddcc
33 files changed
+10445

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
"""Плагины PixelLauncher: загрузка .py и событийная модель.
Формат файла (точно совпадает с верстаком на pixellauncher.ru):
# Плагин для PixelLauncher.
PLUGIN_NAME = "Мой плагин"
PLUGIN_VERSION = "1.0"
PLUGIN_AUTHOR = "Nik"
PLUGIN_DESCRIPTION = (
"Что делает."
)
def on_load(api):
api.log("привет")
def on_game_exit(profile_name, crashed, exit_code):
...
api.on("game_exit", on_game_exit)
api.add_action("Моя кнопка", action)
def on_unload(): # необязательно
...
События:
launcher_ready()
before_launch(profile_name, version)
after_launch(profile_name, version, pid)
game_exit(profile_name, crashed, exit_code)
profile_switched(profile_name)
account_switched(username)
launcher_closing()
Плагины лежат в папке plugins рядом с main.py (APP_DIR/plugins);
json-хранилище каждого плагина — в plugins/storage/<файл>.py.json.
"""
import importlib.util
import json
import os
import re
import threading
import traceback
from . import config
PLUGIN_DIR = os.path.join(config.APP_DIR, "plugins")
STORAGE_DIR = os.path.join(PLUGIN_DIR, "storage")
# каталог плагинов на сайте: магазин и pixellauncher://plugin/<файл>
STORE_BASE = "https://pixellauncher.ru/plugins"
CATALOG_URL = STORE_BASE + "/catalog.json"
EVENTS = ("launcher_ready", "before_launch", "after_launch", "game_exit",
"profile_switched", "account_switched", "launcher_closing")
# --- привязка к UI (App). Без неё api.log печатает в stdout,
# --- а окна не показываются (удобно для тестов).
_hooks = {"log": None, "message": None, "ask_text": None,
"settings": None, "game_dir": None, "game_running": None}
_hooks_lock = threading.Lock()
def bind_ui(log=None, message=None, ask_text=None, settings=None,
game_dir=None, game_running=None):
"""Подключает обработчики UI. Вызывается из App один раз."""
with _hooks_lock:
for k, v in (("log", log), ("message", message),
("ask_text", ask_text), ("settings", settings),
("game_dir", game_dir),
("game_running", game_running)):
if v is not None:
_hooks[k] = v
def _hook(name, *args):
fn = _hooks.get(name)
if fn is None:
return None
try:
return fn(*args)
except Exception: # noqa: BLE001
return None
def _call_log(msg):
if _hook("log", str(msg)) is None:
try:
print(msg)
except Exception: # noqa: BLE001
pass
def _ui_message(level, title, text):
if _hooks.get("message") is None:
_call_log("%s: %s" % (title, text))
return
_hook("message", level, str(title), str(text))
# ------------------------------------------------------------------ хранилище
_st_lock = threading.Lock()
def _storage_path(plugin_file):
safe = re.sub(r"[^\w.\-]", "_", plugin_file, flags=re.ASCII)
return os.path.join(STORAGE_DIR, safe + ".json")
def _storage_read(plugin_file):
path = _storage_path(plugin_file)
with _st_lock:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except (OSError, ValueError):
pass
return {}
def _storage_write(plugin_file, data):
if not isinstance(data, dict):
return
path = _storage_path(plugin_file)
with _st_lock:
try:
os.makedirs(STORAGE_DIR, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
except OSError:
pass
# ------------------------------------------------------------------ Api
class _Api:
"""API, которое видит плагин внутри on_load(api)."""
def __init__(self, plugin):
self._p = plugin
# --- события и кнопки
def on(self, event, fn):
self._p.on(event, fn)
def add_action(self, label, fn):
self._p.add_action(label, fn)
# --- журнал и окна
def log(self, message):
_call_log("[%s] %s" % (self._p.name, message))
def show_message(self, title, text):
_ui_message("info", title, text)
def show_warning(self, title, text):
_ui_message("warn", title, text)
def ask_text(self, title, label, default=""):
res = _hook("ask_text", str(title), str(label), str(default))
return res if isinstance(res, str) or res is None else None
# --- состояние лаунчера
def get_minecraft_dir(self):
v = _hook("game_dir")
return v if isinstance(v, str) else config.DEFAULT_GAME_DIR
def get_current_profile(self):
s = _hook("settings") or {}
v = s.get("active_profile")
return str(v) if v else None
def get_active_account(self):
s = _hook("settings") or {}
return str(s.get("active_account") or "")
def get_profiles(self):
s = _hook("settings") or {}
out = []
for p in (s.get("profiles") or []):
name = str((p or {}).get("name") or "")
if name:
out.append(name)
return out
def is_game_running(self):
return bool(_hook("game_running"))
# --- своё хранилище
def get_storage(self):
return _storage_read(self._p.file)
def save_storage(self, data):
_storage_write(self._p.file, data)
# ------------------------------------------------------------------ Plugin
class Plugin:
"""Один загруженный плагин."""
def __init__(self, path):
self.path = path
self.file = os.path.basename(path)
self.name = self.file[:-3] if self.file.endswith(".py") else self.file
self.version = ""
self.author = ""
self.description = ""
self.module = None
self.handlers = {} # event -> [callable]
self.actions = [] # [(label, callable)]
def load(self):
"""Импортирует файл и вызывает on_load(api)."""
modname = "pixelplugin_" + re.sub(r"\W", "_", self.file[:-3])
spec = importlib.util.spec_from_file_location(modname, self.path)
if spec is None or spec.loader is None:
raise ImportError("нельзя построить спецификацию модуля")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
self.module = mod
self.name = str(getattr(mod, "PLUGIN_NAME", "") or self.name)
self.version = str(getattr(mod, "PLUGIN_VERSION", "") or "")
self.author = str(getattr(mod, "PLUGIN_AUTHOR", "") or "")
self.description = str(getattr(mod, "PLUGIN_DESCRIPTION", "") or "")
on_load = getattr(mod, "on_load", None)
if callable(on_load):
on_load(_Api(self))
return self
def on(self, event, fn):
if event not in EVENTS:
_call_log("Плагин %s: неизвестное событие «%s»"
% (self.file, event))
return
if callable(fn):
self.handlers.setdefault(event, []).append(fn)
def add_action(self, label, fn):
if callable(fn):
self.actions.append((str(label), fn))
def emit(self, event, **kw):
for fn in list(self.handlers.get(event, ())):
try:
fn(**kw)
except Exception as e: # noqa: BLE001
_call_log("Плагин %s: ошибка события %s: %s"
% (self.file, event, e))
def unload(self):
fn = getattr(self.module, "on_unload", None) if self.module else None
if callable(fn):
try:
fn()
except Exception as e: # noqa: BLE001
_call_log("Плагин %s: on_unload: %s" % (self.file, e))
self.handlers.clear()
self.actions.clear()
# ------------------------------------------------------------------ менеджер
_loaded = []
_failures = []
_mgr_lock = threading.Lock()
_scan_lock = threading.Lock()
def plugin_files():
try:
return sorted(f for f in os.listdir(PLUGIN_DIR)
if f.endswith(".py") and not f.startswith(("_", ".")))
except OSError:
return []
def scan():
"""(Пере)загружает все плагины. Возвращает (загружено, ошибок)."""
with _scan_lock:
unload_all()
loaded, failures = [], []
try:
os.makedirs(PLUGIN_DIR, exist_ok=True)
except OSError:
pass
for fname in plugin_files():
p = Plugin(os.path.join(PLUGIN_DIR, fname))
try:
p.load()
loaded.append(p)
except Exception as e: # noqa: BLE001
short = "%s: %s" % (type(e).__name__, e)
failures.append((fname, short))
_call_log("Плагин %s не загрузился: %s" % (fname, short))
try:
traceback.print_exc()
except Exception: # noqa: BLE001
pass
with _mgr_lock:
_loaded[:] = loaded
_failures[:] = failures
if loaded:
_call_log("Плагины: загружено %d%s"
% (len(loaded),
", ошибок %d" % len(failures) if failures else ""))
return len(loaded), len(failures)
def unload_all():
with _mgr_lock:
old = list(_loaded)
_loaded.clear()
for p in old:
p.unload()
def loaded():
with _mgr_lock:
return list(_loaded)
def failures():
with _mgr_lock:
return list(_failures)
def emit(event, **kw):
"""Рассылает событие всем загруженным плагинам."""
for p in loaded():
p.emit(event, **kw)
def actions():
"""Кнопки плагинов: [(label, fn, plugin_name)]."""
out = []
for p in loaded():
for label, fn in list(p.actions):
out.append((label, fn, p.name))
return out
# ------------------------------------------------------------------ установка
def install_file(filename, data):
"""Сохраняет файл плагина в папку plugins. Возвращает путь."""
safe = os.path.basename(str(filename).replace("\\", "/"))
if (not safe.endswith(".py") or len(safe) > 100
or safe.startswith(("_", ".")) or "/" in safe):
raise ValueError("недопустимое имя файла плагина: %r" % filename)
if isinstance(data, str):
data = data.encode("utf-8")
os.makedirs(PLUGIN_DIR, exist_ok=True)
path = os.path.join(PLUGIN_DIR, safe)
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, path)
return path
def remove_file(filename):
"""Удаляет установленный плагин из локальной папки."""
safe = os.path.basename(str(filename).replace("\\", "/"))
path = os.path.join(PLUGIN_DIR, safe)
if os.path.isfile(path):
os.remove(path)
return True
return False
def fetch_catalog(timeout=15):
"""Каталог с сайта: [{file, title, desc, ...}, ...]."""
from . import http
raw = http.get_bytes(CATALOG_URL, timeout=timeout)
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, list):
raise ValueError("пустой каталог плагинов")
return [x for x in data
if isinstance(x, dict) and isinstance(x.get("file"), str)
and x["file"]]
def download_plugin(filename, timeout=30):
"""Скачивает плагин из магазина и ставит локально. Возвращает путь."""
from urllib.parse import quote
from . import http
safe = os.path.basename(str(filename).replace("\\", "/"))
data = http.get_bytes(STORE_BASE + "/" + quote(safe), timeout=timeout)
return install_file(safe, data)