555 lines
19 KiB
Python
555 lines
19 KiB
Python
"""
|
|
PluginMarketplace Plugin - Browse and Install Community Plugins
|
|
|
|
Provides a browsable marketplace for discovering, installing, and managing
|
|
community-contributed plugins for EU-Utility.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import urllib.request
|
|
import urllib.error
|
|
import zipfile
|
|
import shutil
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, List, Callable
|
|
from dataclasses import dataclass, asdict
|
|
from enum import Enum
|
|
from datetime import datetime
|
|
|
|
from core.base_plugin import BasePlugin
|
|
|
|
|
|
class PluginStatus(Enum):
|
|
"""Installation status of a marketplace plugin."""
|
|
NOT_INSTALLED = "not_installed"
|
|
INSTALLED = "installed"
|
|
UPDATE_AVAILABLE = "update_available"
|
|
INSTALLING = "installing"
|
|
ERROR = "error"
|
|
|
|
|
|
@dataclass
|
|
class MarketplacePlugin:
|
|
"""Represents a plugin available in the marketplace."""
|
|
id: str
|
|
name: str
|
|
description: str
|
|
version: str
|
|
author: str
|
|
author_url: Optional[str] = None
|
|
download_url: str = ""
|
|
icon_url: Optional[str] = None
|
|
screenshots: List[str] = None
|
|
tags: List[str] = None
|
|
category: str = "general"
|
|
license: str = "MIT"
|
|
downloads: int = 0
|
|
rating: float = 0.0
|
|
rating_count: int = 0
|
|
release_date: Optional[str] = None
|
|
last_updated: Optional[str] = None
|
|
dependencies: List[str] = None
|
|
min_app_version: str = "1.0.0"
|
|
checksum: str = ""
|
|
|
|
def __post_init__(self):
|
|
if self.screenshots is None:
|
|
self.screenshots = []
|
|
if self.tags is None:
|
|
self.tags = []
|
|
if self.dependencies is None:
|
|
self.dependencies = []
|
|
|
|
|
|
@dataclass
|
|
class InstalledPluginInfo:
|
|
"""Information about an installed plugin."""
|
|
id: str
|
|
name: str
|
|
version: str
|
|
installed_version: str
|
|
install_date: str
|
|
install_path: str
|
|
enabled: bool = True
|
|
auto_update: bool = False
|
|
|
|
|
|
class PluginMarketplacePlugin(BasePlugin):
|
|
"""
|
|
Plugin marketplace browser and installer.
|
|
|
|
Features:
|
|
- Browse plugins by category, rating, popularity
|
|
- Search plugins by name, tag, or author
|
|
- One-click install/uninstall
|
|
- Automatic update checking for installed plugins
|
|
- Plugin ratings and reviews
|
|
- Dependency resolution
|
|
"""
|
|
|
|
name = "plugin_marketplace"
|
|
description = "Browse and install community plugins"
|
|
version = "1.0.0"
|
|
author = "EU-Utility"
|
|
|
|
DEFAULT_CONFIG = {
|
|
"marketplace_url": "https://marketplace.eu-utility.app/api",
|
|
"cache_duration_minutes": 60,
|
|
"plugins_dir": "plugins",
|
|
"temp_dir": "data/temp/marketplace",
|
|
"auto_check_updates": True,
|
|
"check_interval_hours": 24,
|
|
}
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._config = self.DEFAULT_CONFIG.copy()
|
|
self._cache: Dict[str, Any] = {}
|
|
self._cache_timestamp: Optional[datetime] = None
|
|
self._installed_plugins: Dict[str, InstalledPluginInfo] = {}
|
|
self._listeners: List[Callable] = []
|
|
self._data_dir = Path("data")
|
|
self._data_dir.mkdir(exist_ok=True)
|
|
self._load_installed_plugins()
|
|
|
|
def on_start(self) -> None:
|
|
"""Start the marketplace service."""
|
|
print(f"[{self.name}] Starting marketplace...")
|
|
|
|
# Ensure directories exist
|
|
Path(self._config["temp_dir"]).mkdir(parents=True, exist_ok=True)
|
|
Path(self._config["plugins_dir"]).mkdir(exist_ok=True)
|
|
|
|
# Check for updates to installed plugins
|
|
if self._config["auto_check_updates"]:
|
|
self.check_installed_updates()
|
|
|
|
def on_stop(self) -> None:
|
|
"""Stop the marketplace service."""
|
|
print(f"[{self.name}] Stopping marketplace...")
|
|
self._save_installed_plugins()
|
|
|
|
# Cache Management
|
|
|
|
def _is_cache_valid(self) -> bool:
|
|
"""Check if the cache is still valid."""
|
|
if not self._cache_timestamp:
|
|
return False
|
|
elapsed = (datetime.now() - self._cache_timestamp).total_seconds() / 60
|
|
return elapsed < self._config["cache_duration_minutes"]
|
|
|
|
def _update_cache(self, data: Dict[str, Any]) -> None:
|
|
"""Update the cache with new data."""
|
|
self._cache = data
|
|
self._cache_timestamp = datetime.now()
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear the marketplace cache."""
|
|
self._cache = {}
|
|
self._cache_timestamp = None
|
|
print(f"[{self.name}] Cache cleared")
|
|
|
|
# API Methods
|
|
|
|
def fetch_plugins(self, force_refresh: bool = False) -> List[MarketplacePlugin]:
|
|
"""
|
|
Fetch all plugins from the marketplace.
|
|
|
|
Args:
|
|
force_refresh: Ignore cache and fetch fresh data
|
|
|
|
Returns:
|
|
List of marketplace plugins
|
|
"""
|
|
if not force_refresh and self._is_cache_valid() and "plugins" in self._cache:
|
|
return [MarketplacePlugin(**p) for p in self._cache["plugins"]]
|
|
|
|
try:
|
|
url = f"{self._config['marketplace_url']}/plugins"
|
|
req = urllib.request.Request(url, headers={"User-Agent": "EU-Utility/Marketplace"})
|
|
|
|
with urllib.request.urlopen(req, timeout=30) as response:
|
|
data = json.loads(response.read().decode('utf-8'))
|
|
|
|
plugins = [MarketplacePlugin(**p) for p in data.get("plugins", [])]
|
|
self._update_cache({"plugins": [asdict(p) for p in plugins]})
|
|
|
|
print(f"[{self.name}] Fetched {len(plugins)} plugins")
|
|
return plugins
|
|
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to fetch plugins: {e}")
|
|
return []
|
|
|
|
def search_plugins(self, query: str, category: Optional[str] = None) -> List[MarketplacePlugin]:
|
|
"""
|
|
Search for plugins by query string.
|
|
|
|
Args:
|
|
query: Search query
|
|
category: Optional category filter
|
|
|
|
Returns:
|
|
List of matching plugins
|
|
"""
|
|
plugins = self.fetch_plugins()
|
|
query_lower = query.lower()
|
|
|
|
results = []
|
|
for plugin in plugins:
|
|
# Check category filter
|
|
if category and plugin.category != category:
|
|
continue
|
|
|
|
# Search in name, description, tags, and author
|
|
if (query_lower in plugin.name.lower() or
|
|
query_lower in plugin.description.lower() or
|
|
query_lower in plugin.author.lower() or
|
|
any(query_lower in tag.lower() for tag in plugin.tags)):
|
|
results.append(plugin)
|
|
|
|
# Sort by relevance (downloads + rating)
|
|
results.sort(key=lambda p: (p.downloads * 0.5 + p.rating * p.rating_count * 10), reverse=True)
|
|
|
|
return results
|
|
|
|
def get_plugin_by_id(self, plugin_id: str) -> Optional[MarketplacePlugin]:
|
|
"""Get a specific plugin by ID."""
|
|
plugins = self.fetch_plugins()
|
|
for plugin in plugins:
|
|
if plugin.id == plugin_id:
|
|
return plugin
|
|
return None
|
|
|
|
def get_categories(self) -> List[str]:
|
|
"""Get list of available categories."""
|
|
plugins = self.fetch_plugins()
|
|
categories = set(p.category for p in plugins)
|
|
return sorted(list(categories))
|
|
|
|
def get_featured_plugins(self, limit: int = 10) -> List[MarketplacePlugin]:
|
|
"""Get featured/popular plugins."""
|
|
plugins = self.fetch_plugins()
|
|
# Sort by downloads and rating
|
|
plugins.sort(key=lambda p: (p.downloads, p.rating), reverse=True)
|
|
return plugins[:limit]
|
|
|
|
def get_new_plugins(self, limit: int = 10) -> List[MarketplacePlugin]:
|
|
"""Get newest plugins."""
|
|
plugins = self.fetch_plugins()
|
|
# Sort by release date (newest first)
|
|
plugins.sort(key=lambda p: p.release_date or "", reverse=True)
|
|
return plugins[:limit]
|
|
|
|
# Installation Management
|
|
|
|
def get_plugin_status(self, plugin_id: str) -> PluginStatus:
|
|
"""Get installation status of a plugin."""
|
|
if plugin_id not in self._installed_plugins:
|
|
return PluginStatus.NOT_INSTALLED
|
|
|
|
installed = self._installed_plugins[plugin_id]
|
|
marketplace_plugin = self.get_plugin_by_id(plugin_id)
|
|
|
|
if not marketplace_plugin:
|
|
return PluginStatus.ERROR
|
|
|
|
if marketplace_plugin.version != installed.installed_version:
|
|
return PluginStatus.UPDATE_AVAILABLE
|
|
|
|
return PluginStatus.INSTALLED
|
|
|
|
def install_plugin(self, plugin_id: str, auto_enable: bool = True) -> bool:
|
|
"""
|
|
Install a plugin from the marketplace.
|
|
|
|
Args:
|
|
plugin_id: ID of the plugin to install
|
|
auto_enable: Whether to enable the plugin after installation
|
|
|
|
Returns:
|
|
True if installation successful
|
|
"""
|
|
plugin = self.get_plugin_by_id(plugin_id)
|
|
if not plugin:
|
|
print(f"[{self.name}] Plugin not found: {plugin_id}")
|
|
return False
|
|
|
|
print(f"[{self.name}] Installing {plugin.name} v{plugin.version}...")
|
|
|
|
try:
|
|
# Check dependencies
|
|
if not self._check_dependencies(plugin):
|
|
return False
|
|
|
|
# Download plugin
|
|
temp_dir = Path(self._config["temp_dir"])
|
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
download_path = temp_dir / f"{plugin.id}_{plugin.version}.zip"
|
|
|
|
req = urllib.request.Request(
|
|
plugin.download_url,
|
|
headers={"User-Agent": "EU-Utility/Marketplace"}
|
|
)
|
|
|
|
with urllib.request.urlopen(req, timeout=120) as response:
|
|
with open(download_path, 'wb') as f:
|
|
f.write(response.read())
|
|
|
|
# Verify checksum
|
|
if plugin.checksum and not self._verify_checksum(download_path, plugin.checksum):
|
|
print(f"[{self.name}] Checksum verification failed")
|
|
download_path.unlink(missing_ok=True)
|
|
return False
|
|
|
|
# Extract to plugins directory
|
|
plugins_dir = Path(self._config["plugins_dir"])
|
|
extract_dir = plugins_dir / plugin.id
|
|
|
|
if extract_dir.exists():
|
|
shutil.rmtree(extract_dir)
|
|
|
|
with zipfile.ZipFile(download_path, 'r') as zip_ref:
|
|
zip_ref.extractall(extract_dir)
|
|
|
|
# Record installation
|
|
installed_info = InstalledPluginInfo(
|
|
id=plugin.id,
|
|
name=plugin.name,
|
|
version=plugin.version,
|
|
installed_version=plugin.version,
|
|
install_date=datetime.now().isoformat(),
|
|
install_path=str(extract_dir),
|
|
enabled=auto_enable,
|
|
)
|
|
|
|
self._installed_plugins[plugin_id] = installed_info
|
|
self._save_installed_plugins()
|
|
|
|
# Cleanup
|
|
download_path.unlink(missing_ok=True)
|
|
|
|
print(f"[{self.name}] ✓ Installed {plugin.name}")
|
|
self._notify_listeners("installed", plugin_id)
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"[{self.name}] Installation failed: {e}")
|
|
return False
|
|
|
|
def uninstall_plugin(self, plugin_id: str) -> bool:
|
|
"""
|
|
Uninstall a plugin.
|
|
|
|
Args:
|
|
plugin_id: ID of the plugin to uninstall
|
|
|
|
Returns:
|
|
True if uninstallation successful
|
|
"""
|
|
if plugin_id not in self._installed_plugins:
|
|
print(f"[{self.name}] Plugin not installed: {plugin_id}")
|
|
return False
|
|
|
|
try:
|
|
installed = self._installed_plugins[plugin_id]
|
|
install_path = Path(installed.install_path)
|
|
|
|
if install_path.exists():
|
|
shutil.rmtree(install_path)
|
|
|
|
del self._installed_plugins[plugin_id]
|
|
self._save_installed_plugins()
|
|
|
|
print(f"[{self.name}] ✓ Uninstalled {installed.name}")
|
|
self._notify_listeners("uninstalled", plugin_id)
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"[{self.name}] Uninstall failed: {e}")
|
|
return False
|
|
|
|
def update_plugin(self, plugin_id: str) -> bool:
|
|
"""
|
|
Update an installed plugin to the latest version.
|
|
|
|
Args:
|
|
plugin_id: ID of the plugin to update
|
|
|
|
Returns:
|
|
True if update successful
|
|
"""
|
|
status = self.get_plugin_status(plugin_id)
|
|
if status != PluginStatus.UPDATE_AVAILABLE:
|
|
print(f"[{self.name}] No update available for {plugin_id}")
|
|
return False
|
|
|
|
# Uninstall old version
|
|
if not self.uninstall_plugin(plugin_id):
|
|
return False
|
|
|
|
# Install new version
|
|
return self.install_plugin(plugin_id)
|
|
|
|
def check_installed_updates(self) -> List[MarketplacePlugin]:
|
|
"""
|
|
Check for updates to installed plugins.
|
|
|
|
Returns:
|
|
List of plugins with available updates
|
|
"""
|
|
updates = []
|
|
|
|
for plugin_id in self._installed_plugins:
|
|
status = self.get_plugin_status(plugin_id)
|
|
if status == PluginStatus.UPDATE_AVAILABLE:
|
|
plugin = self.get_plugin_by_id(plugin_id)
|
|
if plugin:
|
|
updates.append(plugin)
|
|
|
|
if updates:
|
|
print(f"[{self.name}] {len(updates)} update(s) available")
|
|
for plugin in updates:
|
|
installed = self._installed_plugins[plugin.id]
|
|
print(f" - {plugin.name}: {installed.installed_version} → {plugin.version}")
|
|
|
|
return updates
|
|
|
|
def update_all(self) -> Dict[str, bool]:
|
|
"""
|
|
Update all installed plugins.
|
|
|
|
Returns:
|
|
Dictionary mapping plugin IDs to success status
|
|
"""
|
|
updates = self.check_installed_updates()
|
|
results = {}
|
|
|
|
for plugin in updates:
|
|
results[plugin.id] = self.update_plugin(plugin.id)
|
|
|
|
return results
|
|
|
|
def enable_plugin(self, plugin_id: str) -> bool:
|
|
"""Enable an installed plugin."""
|
|
if plugin_id in self._installed_plugins:
|
|
self._installed_plugins[plugin_id].enabled = True
|
|
self._save_installed_plugins()
|
|
return True
|
|
return False
|
|
|
|
def disable_plugin(self, plugin_id: str) -> bool:
|
|
"""Disable an installed plugin."""
|
|
if plugin_id in self._installed_plugins:
|
|
self._installed_plugins[plugin_id].enabled = False
|
|
self._save_installed_plugins()
|
|
return True
|
|
return False
|
|
|
|
# Private Methods
|
|
|
|
def _check_dependencies(self, plugin: MarketplacePlugin) -> bool:
|
|
"""Check if all dependencies are satisfied."""
|
|
for dep in plugin.dependencies:
|
|
if dep not in self._installed_plugins:
|
|
print(f"[{self.name}] Missing dependency: {dep}")
|
|
return False
|
|
return True
|
|
|
|
def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool:
|
|
"""Verify file checksum (SHA256)."""
|
|
sha256 = hashlib.sha256()
|
|
with open(file_path, 'rb') as f:
|
|
for chunk in iter(lambda: f.read(8192), b''):
|
|
sha256.update(chunk)
|
|
return sha256.hexdigest().lower() == expected_checksum.lower()
|
|
|
|
def _load_installed_plugins(self) -> None:
|
|
"""Load installed plugin registry."""
|
|
registry_file = self._data_dir / "installed_plugins.json"
|
|
if registry_file.exists():
|
|
try:
|
|
with open(registry_file) as f:
|
|
data = json.load(f)
|
|
self._installed_plugins = {
|
|
k: InstalledPluginInfo(**v) for k, v in data.items()
|
|
}
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to load registry: {e}")
|
|
|
|
def _save_installed_plugins(self) -> None:
|
|
"""Save installed plugin registry."""
|
|
registry_file = self._data_dir / "installed_plugins.json"
|
|
try:
|
|
with open(registry_file, 'w') as f:
|
|
data = {k: asdict(v) for k, v in self._installed_plugins.items()}
|
|
json.dump(data, f, indent=2)
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to save registry: {e}")
|
|
|
|
def _notify_listeners(self, event: str, plugin_id: str) -> None:
|
|
"""Notify event listeners."""
|
|
for listener in self._listeners:
|
|
try:
|
|
listener(event, plugin_id)
|
|
except Exception as e:
|
|
print(f"[{self.name}] Listener error: {e}")
|
|
|
|
# Public API
|
|
|
|
def add_listener(self, callback: Callable[[str, str], None]) -> None:
|
|
"""Add an event listener. Events: 'installed', 'uninstalled', 'updated'."""
|
|
self._listeners.append(callback)
|
|
|
|
def remove_listener(self, callback: Callable) -> None:
|
|
"""Remove an event listener."""
|
|
if callback in self._listeners:
|
|
self._listeners.remove(callback)
|
|
|
|
def get_installed_plugins(self) -> List[InstalledPluginInfo]:
|
|
"""Get list of installed plugins."""
|
|
return list(self._installed_plugins.values())
|
|
|
|
def get_installed_plugin(self, plugin_id: str) -> Optional[InstalledPluginInfo]:
|
|
"""Get info about a specific installed plugin."""
|
|
return self._installed_plugins.get(plugin_id)
|
|
|
|
def submit_rating(self, plugin_id: str, rating: int, review: Optional[str] = None) -> bool:
|
|
"""
|
|
Submit a rating for a plugin.
|
|
|
|
Args:
|
|
plugin_id: Plugin ID
|
|
rating: Rating from 1-5
|
|
review: Optional review text
|
|
|
|
Returns:
|
|
True if submission successful
|
|
"""
|
|
try:
|
|
url = f"{self._config['marketplace_url']}/plugins/{plugin_id}/rate"
|
|
data = {
|
|
"rating": max(1, min(5, rating)),
|
|
"review": review,
|
|
}
|
|
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(data).encode('utf-8'),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "EU-Utility/Marketplace",
|
|
},
|
|
method="POST",
|
|
)
|
|
|
|
with urllib.request.urlopen(req, timeout=30) as response:
|
|
return response.status == 200
|
|
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to submit rating: {e}")
|
|
return False
|