Files
PixelLauncher/launcher/plugins.py
T
kosta2014p ec6f13ddcc PixelLauncher 2.1.0 — стабильная сборка
Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров
(Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление
модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
2026-10-18 22:44:19 +07:00

391 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Плагины 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)