All checks were successful
		
		
	
	Code check / Check code (push) Successful in 1m37s
				
			Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
		
			
				
	
	
		
			339 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			339 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
import importlib.util
 | 
						||
import os
 | 
						||
import ast
 | 
						||
from portprotonqt.logger import get_logger
 | 
						||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
 | 
						||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
 | 
						||
 | 
						||
logger = get_logger(__name__)
 | 
						||
 | 
						||
# Папка, где располагаются все дополнительные темы
 | 
						||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
						||
THEMES_DIRS = [
 | 
						||
    os.path.join(xdg_data_home, "PortProtonQt", "themes"),
 | 
						||
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
						||
]
 | 
						||
_loaded_theme = None
 | 
						||
 | 
						||
# Запрещенные модули и функции
 | 
						||
FORBIDDEN_MODULES = {
 | 
						||
    "os",
 | 
						||
    "subprocess",
 | 
						||
    "shutil",
 | 
						||
    "sys",
 | 
						||
    "socket",
 | 
						||
    "ctypes",
 | 
						||
    "pathlib",
 | 
						||
    "glob",
 | 
						||
}
 | 
						||
FORBIDDEN_FUNCTIONS = {
 | 
						||
    "exec",
 | 
						||
    "eval",
 | 
						||
    "open",
 | 
						||
    "__import__",
 | 
						||
}
 | 
						||
 | 
						||
def check_theme_safety(theme_file: str) -> bool:
 | 
						||
    """
 | 
						||
    Проверяет файл темы на наличие запрещённых модулей и функций.
 | 
						||
    Возвращает True, если файл безопасен, иначе False.
 | 
						||
    """
 | 
						||
    has_errors = False
 | 
						||
    try:
 | 
						||
        with open(theme_file) as f:
 | 
						||
            content = f.read()
 | 
						||
 | 
						||
            # Проверка на опасные импорты и функции
 | 
						||
            try:
 | 
						||
                tree = ast.parse(content)
 | 
						||
                for node in ast.walk(tree):
 | 
						||
                    # Проверка импортов
 | 
						||
                    if isinstance(node, ast.Import | ast.ImportFrom):
 | 
						||
                        for name in node.names:
 | 
						||
                            if name.name in FORBIDDEN_MODULES:
 | 
						||
                                logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
 | 
						||
                                has_errors = True
 | 
						||
                    # Проверка вызовов функций
 | 
						||
                    if isinstance(node, ast.Call):
 | 
						||
                        if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
 | 
						||
                            logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
 | 
						||
                            has_errors = True
 | 
						||
            except SyntaxError as e:
 | 
						||
                logger.error(f"Syntax error in file {theme_file}: {e}")
 | 
						||
                has_errors = True
 | 
						||
    except Exception as e:
 | 
						||
        logger.error(f"Failed to check theme safety for {theme_file}: {e}")
 | 
						||
        has_errors = True
 | 
						||
 | 
						||
    return not has_errors
 | 
						||
 | 
						||
def list_themes():
 | 
						||
    """
 | 
						||
    Возвращает список доступных тем (названий папок) из каталогов THEMES_DIRS.
 | 
						||
    """
 | 
						||
    themes = []
 | 
						||
    for themes_dir in THEMES_DIRS:
 | 
						||
        if os.path.exists(themes_dir):
 | 
						||
            for entry in os.listdir(themes_dir):
 | 
						||
                theme_path = os.path.join(themes_dir, entry)
 | 
						||
                if os.path.isdir(theme_path) and os.path.exists(os.path.join(theme_path, "styles.py")):
 | 
						||
                    themes.append(entry)
 | 
						||
    return themes
 | 
						||
 | 
						||
def load_theme_screenshots(theme_name):
 | 
						||
    """
 | 
						||
    Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
 | 
						||
    Возвращает список кортежей (pixmap, filename).
 | 
						||
    Если папка отсутствует или пуста, возвращается пустой список.
 | 
						||
    """
 | 
						||
    screenshots = []
 | 
						||
    for themes_dir in THEMES_DIRS:
 | 
						||
        theme_folder = os.path.join(themes_dir, theme_name)
 | 
						||
        screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
 | 
						||
        if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
 | 
						||
            for file in os.listdir(screenshots_folder):
 | 
						||
                screenshot_path = os.path.join(screenshots_folder, file)
 | 
						||
                if os.path.isfile(screenshot_path):
 | 
						||
                    pixmap = QPixmap(screenshot_path)
 | 
						||
                    if not pixmap.isNull():
 | 
						||
                        screenshots.append((pixmap, file))
 | 
						||
    return screenshots
 | 
						||
 | 
						||
def load_theme_fonts(theme_name):
 | 
						||
    """
 | 
						||
    Загружает все шрифты выбранной темы, если они ещё не были загружены.
 | 
						||
    """
 | 
						||
    global _loaded_theme
 | 
						||
    if _loaded_theme == theme_name:
 | 
						||
        logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
 | 
						||
        return
 | 
						||
 | 
						||
    QFontDatabase.removeAllApplicationFonts()
 | 
						||
    fonts_folder = None
 | 
						||
    if theme_name == "standart":
 | 
						||
        base_dir = os.path.dirname(os.path.abspath(__file__))
 | 
						||
        fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
 | 
						||
    else:
 | 
						||
        for themes_dir in THEMES_DIRS:
 | 
						||
            theme_folder = os.path.join(themes_dir, theme_name)
 | 
						||
            possible_fonts_folder = os.path.join(theme_folder, "fonts")
 | 
						||
            if os.path.exists(possible_fonts_folder):
 | 
						||
                fonts_folder = possible_fonts_folder
 | 
						||
                break
 | 
						||
 | 
						||
    if not fonts_folder or not os.path.exists(fonts_folder):
 | 
						||
        logger.error(f"Fonts folder not found for theme '{theme_name}'")
 | 
						||
        return
 | 
						||
 | 
						||
    for filename in os.listdir(fonts_folder):
 | 
						||
        if filename.lower().endswith((".ttf", ".otf")):
 | 
						||
            font_path = os.path.join(fonts_folder, filename)
 | 
						||
            font_id = QFontDatabase.addApplicationFont(font_path)
 | 
						||
            if font_id != -1:
 | 
						||
                families = QFontDatabase.applicationFontFamilies(font_id)
 | 
						||
                logger.info(f"Font {filename} successfully loaded: {families}")
 | 
						||
            else:
 | 
						||
                logger.error(f"Error loading font: {filename}")
 | 
						||
 | 
						||
    _loaded_theme = theme_name
 | 
						||
 | 
						||
class ThemeWrapper:
 | 
						||
    """
 | 
						||
    Обёртка для кастомной темы с поддержкой метаинформации.
 | 
						||
    При обращении к атрибуту сначала ищется его наличие в кастомной теме,
 | 
						||
    если атрибут отсутствует, значение берётся из стандартного модуля стилей.
 | 
						||
    """
 | 
						||
    def __init__(self, custom_theme, metainfo=None):
 | 
						||
        self.custom_theme = custom_theme
 | 
						||
        self.metainfo = metainfo or {}
 | 
						||
        self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
 | 
						||
        self._default_theme = None  # Lazy-loaded default theme
 | 
						||
 | 
						||
    def __getattr__(self, name):
 | 
						||
        if hasattr(self.custom_theme, name):
 | 
						||
            return getattr(self.custom_theme, name)
 | 
						||
        if self._default_theme is None:
 | 
						||
            self._default_theme = load_theme("standart")  # Dynamically load standard theme
 | 
						||
        return getattr(self._default_theme, name)
 | 
						||
 | 
						||
def load_theme(theme_name):
 | 
						||
    """
 | 
						||
    Динамически загружает модуль стилей выбранной темы и метаинформацию.
 | 
						||
    Все темы, включая стандартную, проходят проверку безопасности.
 | 
						||
    Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
 | 
						||
    """
 | 
						||
    for themes_dir in THEMES_DIRS:
 | 
						||
        theme_folder = os.path.join(themes_dir, theme_name)
 | 
						||
        styles_file = os.path.join(theme_folder, "styles.py")
 | 
						||
        if os.path.exists(styles_file):
 | 
						||
            # Проверяем безопасность темы перед загрузкой
 | 
						||
            if not check_theme_safety(styles_file):
 | 
						||
                logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
 | 
						||
                raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
 | 
						||
 | 
						||
            spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
 | 
						||
            if spec is None or spec.loader is None:
 | 
						||
                continue
 | 
						||
            custom_theme = importlib.util.module_from_spec(spec)
 | 
						||
            spec.loader.exec_module(custom_theme)
 | 
						||
            if theme_name == "standart":
 | 
						||
                return custom_theme
 | 
						||
            meta = load_theme_metainfo(theme_name)
 | 
						||
            wrapper = ThemeWrapper(custom_theme, metainfo=meta)
 | 
						||
            wrapper.screenshots = load_theme_screenshots(theme_name)
 | 
						||
            return wrapper
 | 
						||
    raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
 | 
						||
 | 
						||
class ThemeManager:
 | 
						||
    """
 | 
						||
    Класс для управления темами приложения.
 | 
						||
    Реализует паттерн Singleton для единого экземпляра.
 | 
						||
    """
 | 
						||
    _instance = None
 | 
						||
 | 
						||
    def __new__(cls):
 | 
						||
        if cls._instance is None:
 | 
						||
            cls._instance = super().__new__(cls)
 | 
						||
            cls._instance.current_theme_name = None
 | 
						||
            cls._instance.current_theme_module = None
 | 
						||
        return cls._instance
 | 
						||
 | 
						||
    def get_available_themes(self) -> list:
 | 
						||
        """Возвращает список доступных тем."""
 | 
						||
        return list_themes()
 | 
						||
 | 
						||
    def apply_theme(self, theme_name: str):
 | 
						||
        """
 | 
						||
        Применяет указанную тему, если она ещё не применена.
 | 
						||
        Возвращает модуль темы или обёртку.
 | 
						||
        """
 | 
						||
        if self.current_theme_name == theme_name and self.current_theme_module is not None:
 | 
						||
            logger.debug(f"Theme '{theme_name}' is already applied, skipping")
 | 
						||
            return self.current_theme_module
 | 
						||
 | 
						||
        try:
 | 
						||
            theme_module = load_theme(theme_name)
 | 
						||
        except FileNotFoundError:
 | 
						||
            logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
 | 
						||
            theme_module = load_theme("standart")
 | 
						||
            theme_name = "standart"
 | 
						||
            save_theme_to_config("standart")
 | 
						||
 | 
						||
        load_theme_fonts(theme_name)
 | 
						||
        self.current_theme_name = theme_name
 | 
						||
        self.current_theme_module = theme_module
 | 
						||
        save_theme_to_config(theme_name)
 | 
						||
        logger.info(f"Theme '{theme_name}' successfully applied")
 | 
						||
        return theme_module
 | 
						||
 | 
						||
    def get_icon(self, icon_name, theme_name=None, as_path=False):
 | 
						||
        """
 | 
						||
        Возвращает QIcon из папки icons текущей темы,
 | 
						||
        а если файл не найден, то из стандартной темы.
 | 
						||
        Если as_path=True, возвращает путь к иконке вместо QIcon.
 | 
						||
        """
 | 
						||
        icon_path = None
 | 
						||
        theme_name = theme_name or self.current_theme_name
 | 
						||
        supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
 | 
						||
        has_extension = any(icon_name.lower().endswith(ext) for ext in supported_extensions)
 | 
						||
        base_name = icon_name if has_extension else icon_name
 | 
						||
 | 
						||
        # Поиск иконки в папке текущей темы
 | 
						||
        for themes_dir in THEMES_DIRS:
 | 
						||
            theme_folder = os.path.join(str(themes_dir), str(theme_name))
 | 
						||
            icons_folder = os.path.join(theme_folder, "images", "icons")
 | 
						||
 | 
						||
            # Если передано имя с расширением, проверяем только этот файл
 | 
						||
            if has_extension:
 | 
						||
                candidate = os.path.join(icons_folder, str(base_name))
 | 
						||
                if os.path.exists(candidate):
 | 
						||
                    icon_path = candidate
 | 
						||
                    break
 | 
						||
            else:
 | 
						||
                # Проверяем все поддерживаемые расширения
 | 
						||
                for ext in supported_extensions:
 | 
						||
                    candidate = os.path.join(icons_folder, str(base_name) + str(ext))
 | 
						||
                    if os.path.exists(candidate):
 | 
						||
                        icon_path = candidate
 | 
						||
                        break
 | 
						||
                if icon_path:
 | 
						||
                    break
 | 
						||
 | 
						||
        # Если не нашли – используем стандартную тему
 | 
						||
        if not icon_path:
 | 
						||
            base_dir = os.path.dirname(os.path.abspath(__file__))
 | 
						||
            standard_icons_folder = os.path.join(base_dir, "themes", "standart", "images", "icons")
 | 
						||
 | 
						||
            # Аналогично проверяем в стандартной теме
 | 
						||
            if has_extension:
 | 
						||
                icon_path = os.path.join(standard_icons_folder, base_name)
 | 
						||
                if not os.path.exists(icon_path):
 | 
						||
                    icon_path = None
 | 
						||
            else:
 | 
						||
                for ext in supported_extensions:
 | 
						||
                    candidate = os.path.join(standard_icons_folder, base_name + ext)
 | 
						||
                    if os.path.exists(candidate):
 | 
						||
                        icon_path = candidate
 | 
						||
                        break
 | 
						||
 | 
						||
        # Если иконка всё равно не найдена
 | 
						||
        if not icon_path or not os.path.exists(icon_path):
 | 
						||
            logger.error(f"Warning: icon '{icon_name}' not found")
 | 
						||
            return QIcon() if not as_path else None
 | 
						||
 | 
						||
        if as_path:
 | 
						||
            return icon_path
 | 
						||
 | 
						||
        return QIcon(icon_path)
 | 
						||
 | 
						||
    def get_theme_image(self, image_name, theme_name=None):
 | 
						||
        """
 | 
						||
        Возвращает путь к изображению из папки текущей темы.
 | 
						||
        Если не найдено, проверяет стандартную тему.
 | 
						||
        Принимает название иконки без расширения и находит соответствующий файл
 | 
						||
        с поддерживаемым расширением (.svg, .png, .jpg и др.).
 | 
						||
        """
 | 
						||
        image_path = None
 | 
						||
        theme_name = theme_name or self.current_theme_name
 | 
						||
        supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
 | 
						||
 | 
						||
        has_extension = any(image_name.lower().endswith(ext) for ext in supported_extensions)
 | 
						||
        base_name = image_name if has_extension else image_name
 | 
						||
 | 
						||
        # Check theme-specific images
 | 
						||
        for themes_dir in THEMES_DIRS:
 | 
						||
            theme_folder = os.path.join(str(themes_dir), str(theme_name))
 | 
						||
            images_folder = os.path.join(theme_folder, "images")
 | 
						||
 | 
						||
            if has_extension:
 | 
						||
                candidate = os.path.join(images_folder, str(base_name))
 | 
						||
                if os.path.exists(candidate):
 | 
						||
                    image_path = candidate
 | 
						||
                    break
 | 
						||
            else:
 | 
						||
                for ext in supported_extensions:
 | 
						||
                    candidate = os.path.join(images_folder, str(base_name) + str(ext))
 | 
						||
                    if os.path.exists(candidate):
 | 
						||
                        image_path = candidate
 | 
						||
                        break
 | 
						||
                if image_path:
 | 
						||
                    break
 | 
						||
 | 
						||
        # Check standard theme
 | 
						||
        if not image_path:
 | 
						||
            base_dir = os.path.dirname(os.path.abspath(__file__))
 | 
						||
            standard_images_folder = os.path.join(base_dir, "themes", "standart", "images")
 | 
						||
 | 
						||
            if has_extension:
 | 
						||
                image_path = os.path.join(standard_images_folder, base_name)
 | 
						||
                if not os.path.exists(image_path):
 | 
						||
                    image_path = None
 | 
						||
            else:
 | 
						||
                for ext in supported_extensions:
 | 
						||
                    candidate = os.path.join(standard_images_folder, base_name + ext)
 | 
						||
                    if os.path.exists(candidate):
 | 
						||
                        image_path = candidate
 | 
						||
                        break
 | 
						||
 | 
						||
        return image_path
 |