feat: Complete Core Services Suite
All 10 core services implemented and integrated: CORE SERVICES: 1. Nexus API Client - Search items, mobs, market data 2. Data Store - Plugin persistence with auto-backup 3. Notification System - Toast notifications with sounds 4. Window Manager - EU window detection and focus 5. HTTP Client - Cached HTTP with rate limiting 6. Event Bus - Typed events with pub/sub 7. Audio Service - Sound playback with volume control 8. Clipboard Manager - Copy/paste with history 9. Screenshot Service - Screen capture with auto-save 10. Task Manager - Thread pool with priorities Each service: - Singleton pattern - Thread-safe - PluginAPI integration - BasePlugin convenience methods Updated: - core/main.py - Initialize all services - core/plugin_api.py - Service registration - plugins/base_plugin.py - Exposed methods
This commit is contained in:
parent
2d999a91f6
commit
6d1a17cc30
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
EU-Utility Core Module
|
||||
|
||||
Core services and base classes for the plugin system.
|
||||
"""
|
||||
Binary file not shown.
|
|
@ -0,0 +1,129 @@
|
|||
"""
|
||||
Base Plugin class for EU-Utility plugin system.
|
||||
All plugins must inherit from this class.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Any, Dict, List
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""
|
||||
Abstract base class for all EU-Utility plugins.
|
||||
|
||||
Plugins must implement:
|
||||
- name: Plugin identifier
|
||||
- description: Brief description
|
||||
- version: Plugin version
|
||||
- on_start(): Initialization logic
|
||||
- on_stop(): Cleanup logic
|
||||
"""
|
||||
|
||||
# Plugin metadata (override in subclass)
|
||||
name: str = "base_plugin"
|
||||
description: str = "Base plugin class"
|
||||
version: str = "1.0.0"
|
||||
author: str = "Unknown"
|
||||
|
||||
def __init__(self):
|
||||
self._initialized = False
|
||||
self._config: Dict[str, Any] = {}
|
||||
self._clipboard_manager: Optional[Any] = None
|
||||
|
||||
@abstractmethod
|
||||
def on_start(self) -> None:
|
||||
"""Called when the plugin is started. Initialize resources here."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def on_stop(self) -> None:
|
||||
"""Called when the plugin is stopped. Clean up resources here."""
|
||||
pass
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
"""Check if the plugin has been initialized."""
|
||||
return self._initialized
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""Get plugin configuration."""
|
||||
return self._config.copy()
|
||||
|
||||
def set_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Set plugin configuration."""
|
||||
self._config = config.copy()
|
||||
|
||||
# Clipboard Service Integration
|
||||
|
||||
def _set_clipboard_manager(self, manager: Any) -> None:
|
||||
"""Internal method to set the clipboard manager reference."""
|
||||
self._clipboard_manager = manager
|
||||
|
||||
def copy_to_clipboard(self, text: str) -> bool:
|
||||
"""
|
||||
Copy text to the system clipboard.
|
||||
|
||||
Args:
|
||||
text: The text to copy
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
|
||||
Example:
|
||||
# Copy coordinates to clipboard
|
||||
self.copy_to_clipboard("123, 456")
|
||||
"""
|
||||
if self._clipboard_manager is None:
|
||||
print(f"[{self.name}] Clipboard manager not available")
|
||||
return False
|
||||
return self._clipboard_manager.copy(text, source=self.name)
|
||||
|
||||
def paste_from_clipboard(self) -> Optional[str]:
|
||||
"""
|
||||
Get the current content from the system clipboard.
|
||||
|
||||
Returns:
|
||||
The clipboard content, or None if unavailable
|
||||
|
||||
Example:
|
||||
# Read user-pasted value
|
||||
user_input = self.paste_from_clipboard()
|
||||
if user_input:
|
||||
process_input(user_input)
|
||||
"""
|
||||
if self._clipboard_manager is None:
|
||||
print(f"[{self.name}] Clipboard manager not available")
|
||||
return None
|
||||
return self._clipboard_manager.paste()
|
||||
|
||||
def get_clipboard_history(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the clipboard history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return (default: all)
|
||||
|
||||
Returns:
|
||||
List of clipboard history entries as dictionaries
|
||||
|
||||
Example:
|
||||
# Get last 10 clipboard entries
|
||||
history = self.get_clipboard_history(limit=10)
|
||||
for entry in history:
|
||||
print(f"{entry['timestamp']}: {entry['content']}")
|
||||
"""
|
||||
if self._clipboard_manager is None:
|
||||
print(f"[{self.name}] Clipboard manager not available")
|
||||
return []
|
||||
|
||||
entries = self._clipboard_manager.get_history(limit=limit)
|
||||
return [entry.to_dict() for entry in entries]
|
||||
|
||||
def clear_clipboard_history(self) -> None:
|
||||
"""Clear the clipboard history."""
|
||||
if self._clipboard_manager is None:
|
||||
print(f"[{self.name}] Clipboard manager not available")
|
||||
return
|
||||
self._clipboard_manager.clear_history()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(name='{self.name}', version='{self.version}')"
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
"""
|
||||
Plugin API for EU-Utility plugin system.
|
||||
Manages plugin lifecycle and provides service registration.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Type, Any
|
||||
|
||||
from core.base_plugin import BasePlugin
|
||||
from core.clipboard import ClipboardManager, get_clipboard_manager
|
||||
|
||||
|
||||
class PluginAPI:
|
||||
"""
|
||||
Plugin API for EU-Utility.
|
||||
|
||||
Responsibilities:
|
||||
- Plugin discovery and loading
|
||||
- Plugin lifecycle management (start/stop)
|
||||
- Service registration (clipboard, etc.)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._plugins: Dict[str, BasePlugin] = {}
|
||||
self._plugin_classes: Dict[str, Type[BasePlugin]] = {}
|
||||
self._clipboard_manager: Optional[ClipboardManager] = None
|
||||
self._services_registered = False
|
||||
|
||||
# Service Registration
|
||||
|
||||
def register_clipboard_service(self, auto_start_monitoring: bool = False) -> ClipboardManager:
|
||||
"""
|
||||
Register and initialize the clipboard service.
|
||||
|
||||
This makes clipboard functionality available to all plugins.
|
||||
Must be called before loading plugins that use clipboard features.
|
||||
|
||||
Args:
|
||||
auto_start_monitoring: Whether to start clipboard monitoring automatically
|
||||
|
||||
Returns:
|
||||
The initialized ClipboardManager instance
|
||||
|
||||
Example:
|
||||
api = PluginAPI()
|
||||
api.register_clipboard_service(auto_start_monitoring=True)
|
||||
api.load_plugins()
|
||||
"""
|
||||
self._clipboard_manager = get_clipboard_manager()
|
||||
|
||||
if auto_start_monitoring:
|
||||
self._clipboard_manager.start_monitoring()
|
||||
print("[PluginAPI] Clipboard monitoring started")
|
||||
|
||||
# Inject clipboard manager into all loaded plugins
|
||||
for plugin in self._plugins.values():
|
||||
plugin._set_clipboard_manager(self._clipboard_manager)
|
||||
|
||||
print("[PluginAPI] Clipboard service registered")
|
||||
return self._clipboard_manager
|
||||
|
||||
def get_clipboard_manager(self) -> Optional[ClipboardManager]:
|
||||
"""Get the clipboard manager if registered."""
|
||||
return self._clipboard_manager
|
||||
|
||||
# Plugin Management
|
||||
|
||||
def load_plugin(self, plugin_class: Type[BasePlugin]) -> BasePlugin:
|
||||
"""
|
||||
Load a single plugin class.
|
||||
|
||||
Args:
|
||||
plugin_class: The plugin class to instantiate
|
||||
|
||||
Returns:
|
||||
The instantiated plugin
|
||||
"""
|
||||
plugin = plugin_class()
|
||||
|
||||
# Inject clipboard manager if available
|
||||
if self._clipboard_manager:
|
||||
plugin._set_clipboard_manager(self._clipboard_manager)
|
||||
|
||||
self._plugins[plugin.name] = plugin
|
||||
self._plugin_classes[plugin.name] = plugin_class
|
||||
|
||||
print(f"[PluginAPI] Loaded plugin: {plugin.name} v{plugin.version}")
|
||||
return plugin
|
||||
|
||||
def load_plugins_from_directory(self, directory: str = "plugins") -> List[BasePlugin]:
|
||||
"""
|
||||
Discover and load all plugins from a directory.
|
||||
|
||||
Args:
|
||||
directory: Path to the plugins directory
|
||||
|
||||
Returns:
|
||||
List of loaded plugins
|
||||
"""
|
||||
plugins_dir = Path(directory)
|
||||
if not plugins_dir.exists():
|
||||
print(f"[PluginAPI] Plugins directory not found: {directory}")
|
||||
return []
|
||||
|
||||
# Add plugins directory to path
|
||||
if str(plugins_dir.absolute()) not in sys.path:
|
||||
sys.path.insert(0, str(plugins_dir.absolute()))
|
||||
|
||||
loaded = []
|
||||
|
||||
for file_path in plugins_dir.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
module_name = file_path.stem
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Find plugin classes in the module
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (isinstance(attr, type) and
|
||||
issubclass(attr, BasePlugin) and
|
||||
attr is not BasePlugin and
|
||||
not getattr(attr, '_abstract', False)):
|
||||
plugin = self.load_plugin(attr)
|
||||
loaded.append(plugin)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PluginAPI] Failed to load plugin from {file_path}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
def unload_plugin(self, name: str) -> None:
|
||||
"""Unload a plugin by name."""
|
||||
if name in self._plugins:
|
||||
plugin = self._plugins[name]
|
||||
if plugin.is_initialized():
|
||||
plugin.on_stop()
|
||||
del self._plugins[name]
|
||||
del self._plugin_classes[name]
|
||||
print(f"[PluginAPI] Unloaded plugin: {name}")
|
||||
|
||||
def get_plugin(self, name: str) -> Optional[BasePlugin]:
|
||||
"""Get a loaded plugin by name."""
|
||||
return self._plugins.get(name)
|
||||
|
||||
def get_all_plugins(self) -> List[BasePlugin]:
|
||||
"""Get all loaded plugins."""
|
||||
return list(self._plugins.values())
|
||||
|
||||
# Lifecycle
|
||||
|
||||
def start_all(self) -> None:
|
||||
"""Start all loaded plugins."""
|
||||
for plugin in self._plugins.values():
|
||||
try:
|
||||
plugin.on_start()
|
||||
plugin._initialized = True
|
||||
print(f"[PluginAPI] Started plugin: {plugin.name}")
|
||||
except Exception as e:
|
||||
print(f"[PluginAPI] Failed to start plugin {plugin.name}: {e}")
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all loaded plugins."""
|
||||
for plugin in self._plugins.values():
|
||||
try:
|
||||
plugin.on_stop()
|
||||
plugin._initialized = False
|
||||
print(f"[PluginAPI] Stopped plugin: {plugin.name}")
|
||||
except Exception as e:
|
||||
print(f"[PluginAPI] Error stopping plugin {plugin.name}: {e}")
|
||||
|
||||
# Stop clipboard monitoring if active
|
||||
if self._clipboard_manager and self._clipboard_manager.is_monitoring():
|
||||
self._clipboard_manager.stop_monitoring()
|
||||
|
||||
def reload_plugin(self, name: str) -> Optional[BasePlugin]:
|
||||
"""Reload a plugin by name."""
|
||||
if name not in self._plugin_classes:
|
||||
return None
|
||||
|
||||
self.unload_plugin(name)
|
||||
|
||||
# Reload module
|
||||
plugin_class = self._plugin_classes[name]
|
||||
module = sys.modules.get(plugin_class.__module__)
|
||||
if module:
|
||||
importlib.reload(module)
|
||||
plugin_class = getattr(module, plugin_class.__name__)
|
||||
|
||||
return self.load_plugin(plugin_class)
|
||||
|
||||
# Utilities
|
||||
|
||||
def get_plugin_info(self) -> List[Dict[str, Any]]:
|
||||
"""Get information about all loaded plugins."""
|
||||
return [
|
||||
{
|
||||
'name': p.name,
|
||||
'description': p.description,
|
||||
'version': p.version,
|
||||
'author': p.author,
|
||||
'initialized': p.is_initialized()
|
||||
}
|
||||
for p in self._plugins.values()
|
||||
]
|
||||
|
||||
def broadcast(self, method_name: str, *args, **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Call a method on all plugins and collect results.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method to call
|
||||
*args, **kwargs: Arguments to pass to the method
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin names to results
|
||||
"""
|
||||
results = {}
|
||||
for name, plugin in self._plugins.items():
|
||||
method = getattr(plugin, method_name, None)
|
||||
if callable(method):
|
||||
try:
|
||||
results[name] = method(*args, **kwargs)
|
||||
except Exception as e:
|
||||
results[name] = e
|
||||
return results
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
EU-Utility - Main Entry Point
|
||||
|
||||
Initializes the core services and manages the plugin system.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure core is importable
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from core.plugin_api import PluginAPI
|
||||
from core.clipboard import get_clipboard_manager
|
||||
|
||||
|
||||
class EUUtility:
|
||||
"""
|
||||
Main application class for EU-Utility.
|
||||
|
||||
Responsibilities:
|
||||
- Initialize core services (clipboard, etc.)
|
||||
- Manage plugin lifecycle
|
||||
- Handle graceful shutdown
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.plugin_api = PluginAPI()
|
||||
self.running = False
|
||||
self._setup_signal_handlers()
|
||||
|
||||
def _setup_signal_handlers(self) -> None:
|
||||
"""Setup handlers for graceful shutdown."""
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum, frame) -> None:
|
||||
"""Handle shutdown signals."""
|
||||
print("\n[EU-Utility] Shutdown signal received...")
|
||||
self.stop()
|
||||
sys.exit(0)
|
||||
|
||||
def initialize(self, auto_start_clipboard_monitor: bool = False) -> None:
|
||||
"""
|
||||
Initialize EU-Utility core services.
|
||||
|
||||
Args:
|
||||
auto_start_clipboard_monitor: Whether to monitor clipboard changes
|
||||
"""
|
||||
print("=" * 50)
|
||||
print("EU-Utility - Initializing...")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialize clipboard manager (core service)
|
||||
self._init_clipboard_service(auto_start_clipboard_monitor)
|
||||
|
||||
# Load plugins
|
||||
self._load_plugins()
|
||||
|
||||
print("=" * 50)
|
||||
print("EU-Utility - Ready")
|
||||
print("=" * 50)
|
||||
|
||||
def _init_clipboard_service(self, auto_start_monitor: bool = False) -> None:
|
||||
"""
|
||||
Initialize the clipboard service.
|
||||
This is a core service available to all plugins.
|
||||
"""
|
||||
print("[EU-Utility] Initializing clipboard service...")
|
||||
|
||||
clipboard_manager = self.plugin_api.register_clipboard_service(
|
||||
auto_start_monitoring=auto_start_monitor
|
||||
)
|
||||
|
||||
if clipboard_manager.is_available():
|
||||
print("[EU-Utility] ✓ Clipboard service initialized")
|
||||
stats = clipboard_manager.get_stats()
|
||||
print(f" - History entries: {stats['history_count']}")
|
||||
print(f" - Max history: {stats['max_history']}")
|
||||
print(f" - Monitoring: {stats['is_monitoring']}")
|
||||
else:
|
||||
print("[EU-Utility] ⚠ Clipboard service unavailable (pyperclip not installed)")
|
||||
|
||||
def _load_plugins(self) -> None:
|
||||
"""Load plugins from the plugins directory."""
|
||||
print("[EU-Utility] Loading plugins...")
|
||||
plugins = self.plugin_api.load_plugins_from_directory("plugins")
|
||||
|
||||
if plugins:
|
||||
print(f"[EU-Utility] Loaded {len(plugins)} plugin(s)")
|
||||
for plugin in plugins:
|
||||
print(f" - {plugin.name} v{plugin.version}")
|
||||
else:
|
||||
print("[EU-Utility] No plugins found")
|
||||
|
||||
# Start all plugins
|
||||
self.plugin_api.start_all()
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Run the main application loop.
|
||||
Blocks until stop() is called.
|
||||
"""
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
while self.running:
|
||||
# Main loop - can be extended for CLI, GUI, etc.
|
||||
# For now, just keep alive with sleep
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop EU-Utility and cleanup resources."""
|
||||
if not self.running and not self.plugin_api.get_all_plugins():
|
||||
return
|
||||
|
||||
print("\n[EU-Utility] Shutting down...")
|
||||
|
||||
# Stop all plugins
|
||||
self.plugin_api.stop_all()
|
||||
|
||||
self.running = False
|
||||
print("[EU-Utility] Goodbye!")
|
||||
|
||||
def get_clipboard_manager(self):
|
||||
"""Get the clipboard manager instance."""
|
||||
return self.plugin_api.get_clipboard_manager()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
app = EUUtility()
|
||||
|
||||
# Initialize with clipboard monitoring enabled
|
||||
app.initialize(auto_start_clipboard_monitor=True)
|
||||
|
||||
# Run the application
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"""
|
||||
TestPlugin - Demonstrates Clipboard Manager functionality
|
||||
|
||||
This plugin showcases:
|
||||
- Copy coordinates to clipboard
|
||||
- Read pasted values from user
|
||||
- Access clipboard history
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from core.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
"""Test plugin for demonstrating clipboard functionality."""
|
||||
|
||||
name = "test_plugin"
|
||||
description = "Tests clipboard copy, paste, and history features"
|
||||
version = "1.0.0"
|
||||
author = "EU-Utility"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._test_thread = None
|
||||
self._running = False
|
||||
|
||||
def on_start(self) -> None:
|
||||
"""Start the test plugin."""
|
||||
print(f"[{self.name}] Starting test plugin...")
|
||||
self._running = True
|
||||
|
||||
# Run tests in background thread
|
||||
self._test_thread = threading.Thread(target=self._run_tests, daemon=True)
|
||||
self._test_thread.start()
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Stop the test plugin."""
|
||||
print(f"[{self.name}] Stopping test plugin...")
|
||||
self._running = False
|
||||
|
||||
def _run_tests(self) -> None:
|
||||
"""Run clipboard tests after a short delay."""
|
||||
time.sleep(1) # Wait for everything to initialize
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[{self.name}] RUNNING CLIPBOARD TESTS")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Test 1: Copy coordinates to clipboard
|
||||
self._test_copy_coordinates()
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Test 2: Read pasted values
|
||||
self._test_paste()
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Test 3: Access clipboard history
|
||||
self._test_history()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[{self.name}] ALL TESTS COMPLETE")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
def _test_copy_coordinates(self) -> None:
|
||||
"""Test copying coordinates to clipboard."""
|
||||
print(f"\n[{self.name}] Test 1: Copy coordinates to clipboard")
|
||||
print(f"[{self.name}] " + "-" * 40)
|
||||
|
||||
test_coords = [
|
||||
("100, 200", "Simple coordinates"),
|
||||
("45.5231, -122.6765", "GPS coordinates (Portland, OR)"),
|
||||
("x: 150, y: 300", "Named coordinates"),
|
||||
]
|
||||
|
||||
for coords, desc in test_coords:
|
||||
success = self.copy_to_clipboard(coords)
|
||||
if success:
|
||||
print(f"[{self.name}] ✓ Copied: {coords} ({desc})")
|
||||
else:
|
||||
print(f"[{self.name}] ✗ Failed to copy: {coords}")
|
||||
time.sleep(0.3)
|
||||
|
||||
def _test_paste(self) -> None:
|
||||
"""Test reading pasted values from clipboard."""
|
||||
print(f"\n[{self.name}] Test 2: Read pasted values from clipboard")
|
||||
print(f"[{self.name}] " + "-" * 40)
|
||||
|
||||
# Copy something first
|
||||
self.copy_to_clipboard("Test paste value")
|
||||
time.sleep(0.2)
|
||||
|
||||
# Read it back
|
||||
pasted = self.paste_from_clipboard()
|
||||
if pasted:
|
||||
print(f"[{self.name}] ✓ Pasted value: '{pasted}'")
|
||||
else:
|
||||
print(f"[{self.name}] ✗ Failed to paste (clipboard may be empty)")
|
||||
|
||||
# Try reading current clipboard (may be user content)
|
||||
current = self.paste_from_clipboard()
|
||||
print(f"[{self.name}] Current clipboard: '{current[:50] if current else 'None'}...' "
|
||||
if current and len(current) > 50
|
||||
else f"[{self.name}] Current clipboard: '{current}'")
|
||||
|
||||
def _test_history(self) -> None:
|
||||
"""Test accessing clipboard history."""
|
||||
print(f"\n[{self.name}] Test 3: Access clipboard history")
|
||||
print(f"[{self.name}] " + "-" * 40)
|
||||
|
||||
# Get full history
|
||||
history = self.get_clipboard_history()
|
||||
print(f"[{self.name}] Total history entries: {len(history)}")
|
||||
|
||||
# Get limited history
|
||||
recent = self.get_clipboard_history(limit=5)
|
||||
print(f"[{self.name}] Recent entries (up to 5):")
|
||||
|
||||
for i, entry in enumerate(recent[:5], 1):
|
||||
content = entry['content']
|
||||
source = entry.get('source', 'unknown')
|
||||
if len(content) > 40:
|
||||
content = content[:40] + "..."
|
||||
print(f"[{self.name}] {i}. [{source}] {content}")
|
||||
|
||||
def test_clear_history(self) -> None:
|
||||
"""
|
||||
Test clearing clipboard history.
|
||||
This is a manual test method - not run automatically.
|
||||
"""
|
||||
print(f"[{self.name}] Clearing clipboard history...")
|
||||
self.clear_clipboard_history()
|
||||
history = self.get_clipboard_history()
|
||||
print(f"[{self.name}] History cleared. Entries: {len(history)}")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,2 +1,8 @@
|
|||
# EU-Utility
|
||||
# EU-Utility Core Package
|
||||
__version__ = "1.0.0"
|
||||
|
||||
# Export main classes for convenience
|
||||
from .plugin_api import PluginAPI, APIType, get_api, APIEndpoint
|
||||
from .nexus_api import NexusAPI, get_nexus_api, EntityType, SearchResult, ItemDetails, MarketData
|
||||
from .ocr_service import OCRService, get_ocr_service, OCRResult
|
||||
from .log_reader import LogReader, get_log_reader
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
"""
|
||||
EU-Utility - Audio/Sound Service
|
||||
|
||||
Sound playback for notifications and alerts.
|
||||
Part of core - plugins access via PluginAPI.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SoundConfig:
|
||||
"""Sound configuration."""
|
||||
enabled: bool = True
|
||||
volume: float = 0.7
|
||||
sounds_dir: Optional[Path] = None
|
||||
|
||||
|
||||
class AudioManager:
|
||||
"""
|
||||
Core audio service for sound playback.
|
||||
Uses QSoundEffect when available, falls back to system tools.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._config = SoundConfig()
|
||||
self._sound_cache: Dict[str, any] = {}
|
||||
self._available = False
|
||||
self._backend = None
|
||||
|
||||
self._init_backend()
|
||||
|
||||
def _init_backend(self):
|
||||
"""Initialize audio backend."""
|
||||
# Try QSoundEffect first (best Qt integration)
|
||||
try:
|
||||
from PyQt6.QtMultimedia import QSoundEffect
|
||||
self._available = True
|
||||
self._backend = 'qt'
|
||||
print("[Audio] Using Qt Multimedia backend")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try playsound
|
||||
try:
|
||||
from playsound import playsound
|
||||
self._available = True
|
||||
self._backend = 'playsound'
|
||||
print("[Audio] Using playsound backend")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback to system tools
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
try:
|
||||
import winsound
|
||||
self._available = True
|
||||
self._backend = 'winsound'
|
||||
print("[Audio] Using Windows winsound backend")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print("[Audio] WARNING: No audio backend available")
|
||||
print("[Audio] Install one of: PyQt6.QtMultimedia, playsound")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if audio is available."""
|
||||
return self._available
|
||||
|
||||
def play_sound(self, sound_key: str = "default"):
|
||||
"""Play a sound by key."""
|
||||
if not self._available or not self._config.enabled:
|
||||
return
|
||||
|
||||
sound_file = self._get_sound_file(sound_key)
|
||||
if not sound_file:
|
||||
# Use system beep as fallback
|
||||
self._play_beep()
|
||||
return
|
||||
|
||||
try:
|
||||
if self._backend == 'qt':
|
||||
self._play_qt(sound_file)
|
||||
elif self._backend == 'playsound':
|
||||
self._play_playsound(sound_file)
|
||||
elif self._backend == 'winsound':
|
||||
self._play_winsound(sound_file)
|
||||
except Exception as e:
|
||||
print(f"[Audio] Play error: {e}")
|
||||
|
||||
def _get_sound_file(self, key: str) -> Optional[Path]:
|
||||
"""Get sound file path."""
|
||||
if not self._config.sounds_dir:
|
||||
return None
|
||||
|
||||
sound_map = {
|
||||
'default': 'notification.wav',
|
||||
'global': 'global.wav',
|
||||
'hof': 'hof.wav',
|
||||
'skill_gain': 'skill.wav',
|
||||
'alert': 'alert.wav',
|
||||
'error': 'error.wav',
|
||||
'success': 'success.wav',
|
||||
}
|
||||
|
||||
filename = sound_map.get(key, f"{key}.wav")
|
||||
sound_file = self._config.sounds_dir / filename
|
||||
|
||||
# Try alternative extensions
|
||||
if not sound_file.exists():
|
||||
for ext in ['.mp3', '.ogg', '.wav']:
|
||||
alt = sound_file.with_suffix(ext)
|
||||
if alt.exists():
|
||||
return alt
|
||||
|
||||
return sound_file if sound_file.exists() else None
|
||||
|
||||
def _play_qt(self, sound_file: Path):
|
||||
"""Play using Qt."""
|
||||
from PyQt6.QtMultimedia import QSoundEffect
|
||||
|
||||
sound_key = str(sound_file)
|
||||
if sound_key not in self._sound_cache:
|
||||
effect = QSoundEffect()
|
||||
effect.setSource(str(sound_file))
|
||||
effect.setVolume(self._config.volume)
|
||||
self._sound_cache[sound_key] = effect
|
||||
|
||||
effect = self._sound_cache[sound_key]
|
||||
effect.play()
|
||||
|
||||
def _play_playsound(self, sound_file: Path):
|
||||
"""Play using playsound."""
|
||||
from playsound import playsound
|
||||
import threading
|
||||
|
||||
# playsound blocks, so run in thread
|
||||
def play():
|
||||
try:
|
||||
playsound(str(sound_file), block=False)
|
||||
except:
|
||||
pass
|
||||
|
||||
threading.Thread(target=play, daemon=True).start()
|
||||
|
||||
def _play_winsound(self, sound_file: Path):
|
||||
"""Play using Windows winsound."""
|
||||
import winsound
|
||||
|
||||
try:
|
||||
winsound.PlaySound(str(sound_file), winsound.SND_ASYNC)
|
||||
except:
|
||||
winsound.MessageBeep()
|
||||
|
||||
def _play_beep(self):
|
||||
"""System beep fallback."""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
try:
|
||||
import winsound
|
||||
winsound.MessageBeep()
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
print('\a') # Bell character
|
||||
|
||||
def set_volume(self, volume: float):
|
||||
"""Set volume (0.0 - 1.0)."""
|
||||
self._config.volume = max(0.0, min(1.0, volume))
|
||||
|
||||
# Update cached effects
|
||||
for effect in self._sound_cache.values():
|
||||
if hasattr(effect, 'setVolume'):
|
||||
effect.setVolume(self._config.volume)
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""Enable/disable sounds."""
|
||||
self._config.enabled = enabled
|
||||
|
||||
def is_muted(self) -> bool:
|
||||
"""Check if muted."""
|
||||
return not self._config.enabled
|
||||
|
||||
def mute(self):
|
||||
"""Mute sounds."""
|
||||
self._config.enabled = False
|
||||
|
||||
def unmute(self):
|
||||
"""Unmute sounds."""
|
||||
self._config.enabled = True
|
||||
|
||||
def set_sounds_directory(self, path: Path):
|
||||
"""Set directory containing sound files."""
|
||||
self._config.sounds_dir = Path(path)
|
||||
|
||||
|
||||
def get_audio_manager() -> AudioManager:
|
||||
"""Get global AudioManager instance."""
|
||||
return AudioManager()
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
EU-Utility - Clipboard Manager
|
||||
|
||||
Cross-platform clipboard access with history.
|
||||
Part of core - plugins access via PluginAPI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClipboardEntry:
|
||||
"""A single clipboard entry."""
|
||||
text: str
|
||||
timestamp: str
|
||||
source: str = "unknown"
|
||||
|
||||
|
||||
class ClipboardManager:
|
||||
"""
|
||||
Core clipboard service with history tracking.
|
||||
Uses pyperclip for cross-platform compatibility.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, max_history: int = 100, history_file: Path = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._max_history = max_history
|
||||
self._history: deque = deque(maxlen=max_history)
|
||||
self._history_file = history_file or Path("data/clipboard_history.json")
|
||||
self._monitoring = False
|
||||
self._last_clipboard = ""
|
||||
|
||||
self._load_history()
|
||||
|
||||
def _load_history(self):
|
||||
"""Load clipboard history from file."""
|
||||
if self._history_file.exists():
|
||||
try:
|
||||
with open(self._history_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
for entry in data:
|
||||
self._history.append(ClipboardEntry(**entry))
|
||||
except Exception as e:
|
||||
print(f"[Clipboard] Error loading history: {e}")
|
||||
|
||||
def _save_history(self):
|
||||
"""Save clipboard history to file."""
|
||||
try:
|
||||
self._history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = [asdict(entry) for entry in self._history]
|
||||
with open(self._history_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Clipboard] Error saving history: {e}")
|
||||
|
||||
def copy(self, text: str, source: str = "plugin") -> bool:
|
||||
"""Copy text to clipboard.
|
||||
|
||||
Args:
|
||||
text: Text to copy
|
||||
source: Source identifier for history
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
import pyperclip
|
||||
pyperclip.copy(text)
|
||||
|
||||
# Add to history
|
||||
entry = ClipboardEntry(
|
||||
text=text,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
source=source
|
||||
)
|
||||
self._history.append(entry)
|
||||
self._save_history()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Clipboard] Copy error: {e}")
|
||||
return False
|
||||
|
||||
def paste(self) -> str:
|
||||
"""Paste text from clipboard.
|
||||
|
||||
Returns:
|
||||
Clipboard content or empty string
|
||||
"""
|
||||
try:
|
||||
import pyperclip
|
||||
return pyperclip.paste() or ""
|
||||
except Exception as e:
|
||||
print(f"[Clipboard] Paste error: {e}")
|
||||
return ""
|
||||
|
||||
def get_history(self, limit: int = None) -> List[ClipboardEntry]:
|
||||
"""Get clipboard history.
|
||||
|
||||
Args:
|
||||
limit: Maximum entries to return (None for all)
|
||||
|
||||
Returns:
|
||||
List of clipboard entries (newest first)
|
||||
"""
|
||||
history = list(self._history)
|
||||
if limit:
|
||||
history = history[-limit:]
|
||||
return list(reversed(history))
|
||||
|
||||
def clear_history(self):
|
||||
"""Clear clipboard history."""
|
||||
self._history.clear()
|
||||
self._save_history()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if clipboard is available."""
|
||||
try:
|
||||
import pyperclip
|
||||
pyperclip.paste()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def get_clipboard_manager() -> ClipboardManager:
|
||||
"""Get global ClipboardManager instance."""
|
||||
return ClipboardManager()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def copy_to_clipboard(text: str) -> bool:
|
||||
"""Quick copy to clipboard."""
|
||||
return get_clipboard_manager().copy(text)
|
||||
|
||||
|
||||
def paste_from_clipboard() -> str:
|
||||
"""Quick paste from clipboard."""
|
||||
return get_clipboard_manager().paste()
|
||||
|
|
@ -39,8 +39,16 @@ from core.overlay_widgets import OverlayManager
|
|||
from core.plugin_api import get_api, APIType
|
||||
from core.log_reader import get_log_reader
|
||||
from core.ocr_service import get_ocr_service
|
||||
from core.screenshot import get_screenshot_service
|
||||
from core.notifications import get_notification_manager, NotificationManager
|
||||
from core.nexus_api import get_nexus_api
|
||||
from core.http_client import get_http_client
|
||||
from core.window_manager import get_window_manager
|
||||
from core.event_bus import get_event_bus
|
||||
from core.tasks import get_task_manager
|
||||
from core.audio import get_audio_manager
|
||||
from core.clipboard import get_clipboard_manager
|
||||
from core.data_store import get_data_store
|
||||
|
||||
|
||||
class HotkeyHandler(QObject):
|
||||
|
|
@ -61,6 +69,7 @@ class EUUtilityApp:
|
|||
self.settings = None
|
||||
self.overlay_manager = None
|
||||
self.api = None
|
||||
self.notification_manager = None
|
||||
|
||||
def run(self):
|
||||
"""Start the application."""
|
||||
|
|
@ -77,6 +86,18 @@ class EUUtilityApp:
|
|||
self.api = get_api()
|
||||
self._setup_api_services()
|
||||
|
||||
# Initialize Event Bus
|
||||
print("Initializing Event Bus...")
|
||||
self.event_bus = get_event_bus()
|
||||
self._print_event_bus_stats()
|
||||
|
||||
# Initialize Notification Manager
|
||||
print("Initializing Notification Manager...")
|
||||
self.notification_manager = get_notification_manager()
|
||||
self.notification_manager.initialize(self.app)
|
||||
self.api.register_notification_service(self.notification_manager)
|
||||
print("[Core] Notification service registered")
|
||||
|
||||
# Load settings
|
||||
self.settings = get_settings()
|
||||
|
||||
|
|
@ -116,11 +137,14 @@ class EUUtilityApp:
|
|||
print("Or double-click the floating icon")
|
||||
print(f"Loaded {len(self.plugin_manager.get_all_plugins())} plugins")
|
||||
|
||||
# Show Event Bus stats
|
||||
self._print_event_bus_stats()
|
||||
|
||||
# Run
|
||||
return self.app.exec()
|
||||
|
||||
def _setup_api_services(self):
|
||||
"""Setup shared API services - OCR and Log are core services."""
|
||||
"""Setup shared API services - Window, OCR and Log are core services."""
|
||||
# Initialize and start Log Reader
|
||||
print("[Core] Initializing Log Reader...")
|
||||
self.log_reader = get_log_reader()
|
||||
|
|
@ -132,6 +156,31 @@ class EUUtilityApp:
|
|||
# Register Log service with API
|
||||
self.api.register_log_service(self.log_reader.read_lines)
|
||||
|
||||
# Initialize Window Manager (Windows-only, gracefully handles Linux)
|
||||
print("[Core] Initializing Window Manager...")
|
||||
self.window_manager = get_window_manager()
|
||||
if self.window_manager.is_available():
|
||||
# Try to find EU window on startup
|
||||
eu_window = self.window_manager.find_eu_window()
|
||||
if eu_window:
|
||||
print(f"[Core] Found EU window: {eu_window.title} ({eu_window.width}x{eu_window.height})")
|
||||
else:
|
||||
print("[Core] EU window not found - will retry when needed")
|
||||
# Register Window service with API
|
||||
self.api.register_window_service(self.window_manager)
|
||||
else:
|
||||
print("[Core] Window Manager not available (Windows only)")
|
||||
|
||||
# Screenshot Service - Initialize on startup (lightweight)
|
||||
print("[Core] Initializing Screenshot Service...")
|
||||
self.screenshot_service = get_screenshot_service()
|
||||
if self.screenshot_service.is_available():
|
||||
self.api.register_screenshot_service(self.screenshot_service)
|
||||
backends = self.screenshot_service.get_available_backends()
|
||||
print(f"[Core] Screenshot Service ready (backends: {', '.join(backends)})")
|
||||
else:
|
||||
print("[Core] Screenshot Service not available - install pillow or pyautogui")
|
||||
|
||||
# OCR Service - LAZY INITIALIZATION (don't init on startup)
|
||||
# It will initialize on first use
|
||||
print("[Core] OCR Service configured (lazy init)")
|
||||
|
|
@ -140,12 +189,93 @@ class EUUtilityApp:
|
|||
# Register OCR service with API (lazy - will init on first call)
|
||||
self.api.register_ocr_service(self._lazy_ocr_handler)
|
||||
|
||||
print("[Core] API services registered: OCR (lazy), Log")
|
||||
# Initialize Nexus API Service
|
||||
print("[Core] Initializing Nexus API Service...")
|
||||
self.nexus_api = get_nexus_api()
|
||||
self.api.register_nexus_service(self.nexus_api)
|
||||
|
||||
# HTTP Client - Initialize on startup
|
||||
print("[Core] Initializing HTTP Client...")
|
||||
try:
|
||||
self.http_client = get_http_client(
|
||||
cache_dir="cache/http",
|
||||
default_cache_ttl=3600,
|
||||
rate_limit_delay=0.1, # Small delay between requests
|
||||
max_retries=3,
|
||||
backoff_factor=0.5,
|
||||
respect_cache_control=True
|
||||
)
|
||||
self.api.register_http_service(self.http_client)
|
||||
print("[Core] HTTP Client initialized with caching")
|
||||
except Exception as e:
|
||||
print(f"[Core] HTTP Client initialization failed: {e}")
|
||||
|
||||
# Initialize Audio Service
|
||||
print("[Core] Initializing Audio Service...")
|
||||
self.audio_manager = get_audio_manager()
|
||||
if self.audio_manager.is_available():
|
||||
self.api.register_audio_service(self.audio_manager)
|
||||
backend = self.audio_manager.get_backend()
|
||||
volume = int(self.audio_manager.get_volume() * 100)
|
||||
print(f"[Core] Audio Service ready (backend: {backend}, volume: {volume}%)")
|
||||
else:
|
||||
print("[Core] Audio Service not available - no audio backend found")
|
||||
|
||||
# Initialize Task Manager
|
||||
print("[Core] Initializing Task Manager...")
|
||||
try:
|
||||
self.task_manager = get_task_manager(max_workers=4)
|
||||
self.task_manager.initialize()
|
||||
self.api.register_task_service(self.task_manager)
|
||||
print("[Core] Task Manager initialized with 4 workers")
|
||||
except Exception as e:
|
||||
print(f"[Core] Task Manager initialization failed: {e}")
|
||||
|
||||
# Initialize Clipboard Manager
|
||||
print("[Core] Initializing Clipboard Manager...")
|
||||
self.clipboard_manager = get_clipboard_manager()
|
||||
if self.clipboard_manager.is_available():
|
||||
self.api.register_clipboard_service(self.clipboard_manager)
|
||||
print("[Core] Clipboard Service ready")
|
||||
else:
|
||||
print("[Core] Clipboard Service not available - install pyperclip")
|
||||
|
||||
# Initialize Data Store
|
||||
print("[Core] Initializing Data Store...")
|
||||
self.data_store = get_data_store()
|
||||
self.api.register_data_service(self.data_store)
|
||||
print("[Core] Data Store ready")
|
||||
|
||||
print("[Core] API services registered: Window, Screenshot, OCR (lazy), Log, Nexus, HTTP, Audio, Tasks, Clipboard, Data")
|
||||
|
||||
def _lazy_ocr_handler(self, region=None):
|
||||
"""Lazy OCR handler - triggers init on first use."""
|
||||
return self.ocr_service.recognize(region=region)
|
||||
|
||||
def _print_event_bus_stats(self):
|
||||
"""Print Event Bus statistics on startup."""
|
||||
if not hasattr(self, 'event_bus') or not self.event_bus:
|
||||
return
|
||||
|
||||
stats = self.event_bus.get_stats()
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 Event Bus Statistics")
|
||||
print("=" * 50)
|
||||
print(f" Total Events Published: {stats.get('total_published', 0)}")
|
||||
print(f" Total Events Delivered: {stats.get('total_delivered', 0)}")
|
||||
print(f" Active Subscriptions: {stats.get('active_subscriptions', 0)}")
|
||||
print(f" Events Per Minute: {stats.get('events_per_minute', 0)}")
|
||||
print(f" Avg Delivery Time: {stats.get('avg_delivery_ms', 0)} ms")
|
||||
print(f" Errors: {stats.get('errors', 0)}")
|
||||
|
||||
top_types = stats.get('top_event_types', {})
|
||||
if top_types:
|
||||
print(f"\n Top Event Types:")
|
||||
for event_type, count in list(top_types.items())[:5]:
|
||||
print(f" • {event_type}: {count}")
|
||||
|
||||
print("=" * 50 + "\n")
|
||||
|
||||
def _setup_hotkeys(self):
|
||||
"""Setup global hotkeys."""
|
||||
if KEYBOARD_AVAILABLE:
|
||||
|
|
@ -204,6 +334,27 @@ class EUUtilityApp:
|
|||
if hasattr(self, 'log_reader'):
|
||||
self.log_reader.stop()
|
||||
|
||||
# Close all notifications
|
||||
if self.notification_manager:
|
||||
self.notification_manager.close_all()
|
||||
|
||||
# Shutdown Event Bus
|
||||
if hasattr(self, 'event_bus') and self.event_bus:
|
||||
print("[Core] Shutting down Event Bus...")
|
||||
self.event_bus.shutdown()
|
||||
|
||||
# Shutdown Audio
|
||||
if hasattr(self, 'audio_manager') and self.audio_manager:
|
||||
print("[Core] Shutting down Audio...")
|
||||
self.audio_manager.shutdown()
|
||||
|
||||
# Shutdown Task Manager
|
||||
if hasattr(self, 'task_manager') and self.task_manager:
|
||||
print("[Core] Shutting down Task Manager...")
|
||||
self.task_manager.shutdown(wait=True, timeout=30.0)
|
||||
|
||||
# Window manager has no persistent resources to clean up
|
||||
|
||||
if self.overlay_manager:
|
||||
self.overlay_manager.hide_all()
|
||||
if self.plugin_manager:
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ class OCRService:
|
|||
|
||||
def capture_screen(self, region: Tuple[int, int, int, int] = None) -> 'Image.Image':
|
||||
"""
|
||||
Capture screen or region.
|
||||
Capture screen or region using the ScreenshotService.
|
||||
|
||||
Args:
|
||||
region: (x, y, width, height) or None for full screen
|
||||
|
|
@ -121,18 +121,29 @@ class OCRService:
|
|||
PIL Image
|
||||
"""
|
||||
try:
|
||||
import pyautogui
|
||||
from core.screenshot import get_screenshot_service
|
||||
screenshot_service = get_screenshot_service()
|
||||
|
||||
if region:
|
||||
x, y, width, height = region
|
||||
screenshot = pyautogui.screenshot(region=(x, y, width, height))
|
||||
return screenshot_service.capture_region(x, y, width, height)
|
||||
else:
|
||||
screenshot = pyautogui.screenshot()
|
||||
return screenshot_service.capture(full_screen=True)
|
||||
|
||||
return screenshot
|
||||
except Exception as e:
|
||||
print(f"[OCR] Screenshot service failed, falling back: {e}")
|
||||
# Fallback to direct pyautogui capture
|
||||
try:
|
||||
import pyautogui
|
||||
|
||||
except ImportError:
|
||||
raise RuntimeError("pyautogui not installed. Run: pip install pyautogui")
|
||||
if region:
|
||||
x, y, width, height = region
|
||||
return pyautogui.screenshot(region=(x, y, width, height))
|
||||
else:
|
||||
return pyautogui.screenshot()
|
||||
|
||||
except ImportError:
|
||||
raise RuntimeError("pyautogui not installed. Run: pip install pyautogui")
|
||||
|
||||
def recognize(self, image=None, region: Tuple[int, int, int, int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,12 +5,13 @@ Fetch and install community plugins from GitHub.
|
|||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import QObject, QThread, pyqtSignal
|
||||
|
||||
from core.http_client import get_http_client
|
||||
|
||||
|
||||
class PluginStore(QObject):
|
||||
"""Community plugin repository manager."""
|
||||
|
|
@ -134,14 +135,21 @@ class PluginFetchThread(QThread):
|
|||
def run(self):
|
||||
"""Fetch plugin index."""
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
http_client = get_http_client()
|
||||
response = http_client.get(
|
||||
self.url,
|
||||
cache_ttl=300, # 5 minute cache for plugin list
|
||||
headers={'User-Agent': 'EU-Utility/1.0'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
if response.get('json'):
|
||||
data = response['json']
|
||||
self.fetched.emit(data.get('plugins', []))
|
||||
else:
|
||||
# Try to parse JSON from text
|
||||
data = json.loads(response['text'])
|
||||
self.fetched.emit(data.get('plugins', []))
|
||||
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
|
@ -172,14 +180,15 @@ class PluginInstallThread(QThread):
|
|||
|
||||
temp_zip = self.install_dir / "temp.zip"
|
||||
|
||||
req = urllib.request.Request(
|
||||
http_client = get_http_client()
|
||||
response = http_client.get(
|
||||
download_url,
|
||||
cache_ttl=0, # Don't cache downloads
|
||||
headers={'User-Agent': 'EU-Utility/1.0'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=60) as response:
|
||||
with open(temp_zip, 'wb') as f:
|
||||
f.write(response.read())
|
||||
with open(temp_zip, 'wb') as f:
|
||||
f.write(response['content'])
|
||||
|
||||
self.progress.emit("Extracting...")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,504 @@
|
|||
"""
|
||||
EU-Utility - Screenshot Service Core Module
|
||||
|
||||
Fast, reliable screen capture functionality for all plugins.
|
||||
Part of core - not a plugin. Plugins access via PluginAPI.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Any, Union
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ScreenshotService:
|
||||
"""
|
||||
Core screenshot service with cross-platform support.
|
||||
|
||||
Features:
|
||||
- Singleton pattern for single instance across app
|
||||
- Fast screen capture using PIL.ImageGrab (Windows) or pyautogui (cross-platform)
|
||||
- Configurable auto-save with timestamps
|
||||
- Screenshot history (last 20 in memory)
|
||||
- PNG by default, JPG quality settings
|
||||
- Thread-safe operations
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Configuration
|
||||
self._auto_save: bool = True
|
||||
self._save_path: Path = self._get_default_save_path()
|
||||
self._format: str = "PNG"
|
||||
self._quality: int = 95 # For JPEG
|
||||
self._history_size: int = 20
|
||||
|
||||
# Screenshot history (thread-safe deque)
|
||||
self._history: deque = deque(maxlen=self._history_size)
|
||||
self._last_screenshot: Optional[Image.Image] = None
|
||||
|
||||
# Platform detection
|
||||
self._platform = platform.system().lower()
|
||||
self._use_pil = self._platform == "windows"
|
||||
|
||||
# Lazy init for capture backends
|
||||
self._pil_available: Optional[bool] = None
|
||||
self._pyautogui_available: Optional[bool] = None
|
||||
|
||||
# Ensure save directory exists
|
||||
self._ensure_save_directory()
|
||||
|
||||
print(f"[Screenshot] Service initialized (auto_save={self._auto_save}, format={self._format})")
|
||||
|
||||
def _get_default_save_path(self) -> Path:
|
||||
"""Get default save path for screenshots."""
|
||||
# Use Documents/Entropia Universe/Screenshots/ as default
|
||||
if self._platform == "windows":
|
||||
documents = Path.home() / "Documents"
|
||||
else:
|
||||
documents = Path.home() / "Documents"
|
||||
|
||||
return documents / "Entropia Universe" / "Screenshots"
|
||||
|
||||
def _ensure_save_directory(self) -> None:
|
||||
"""Ensure the save directory exists."""
|
||||
try:
|
||||
self._save_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"[Screenshot] Warning: Could not create save directory: {e}")
|
||||
|
||||
def _check_pil_grab(self) -> bool:
|
||||
"""Check if PIL.ImageGrab is available."""
|
||||
if self._pil_available is not None:
|
||||
return self._pil_available
|
||||
|
||||
try:
|
||||
from PIL import ImageGrab
|
||||
self._pil_available = True
|
||||
return True
|
||||
except ImportError:
|
||||
self._pil_available = False
|
||||
return False
|
||||
|
||||
def _check_pyautogui(self) -> bool:
|
||||
"""Check if pyautogui is available."""
|
||||
if self._pyautogui_available is not None:
|
||||
return self._pyautogui_available
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
self._pyautogui_available = True
|
||||
return True
|
||||
except ImportError:
|
||||
self._pyautogui_available = False
|
||||
return False
|
||||
|
||||
def capture(self, full_screen: bool = True) -> Image.Image:
|
||||
"""
|
||||
Capture screenshot.
|
||||
|
||||
Args:
|
||||
full_screen: If True, capture entire screen. If False, use default region.
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no capture backend is available
|
||||
"""
|
||||
with self._lock:
|
||||
screenshot = self._do_capture(full_screen=full_screen)
|
||||
|
||||
# Store in history
|
||||
self._last_screenshot = screenshot.copy()
|
||||
self._history.append({
|
||||
'image': screenshot.copy(),
|
||||
'timestamp': datetime.now(),
|
||||
'region': None if full_screen else 'custom'
|
||||
})
|
||||
|
||||
# Auto-save if enabled
|
||||
if self._auto_save:
|
||||
self._auto_save_screenshot(screenshot)
|
||||
|
||||
return screenshot
|
||||
|
||||
def capture_region(self, x: int, y: int, width: int, height: int) -> Image.Image:
|
||||
"""
|
||||
Capture specific screen region.
|
||||
|
||||
Args:
|
||||
x: Left coordinate
|
||||
y: Top coordinate
|
||||
width: Region width
|
||||
height: Region height
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
with self._lock:
|
||||
screenshot = self._do_capture(region=(x, y, x + width, y + height))
|
||||
|
||||
# Store in history
|
||||
self._last_screenshot = screenshot.copy()
|
||||
self._history.append({
|
||||
'image': screenshot.copy(),
|
||||
'timestamp': datetime.now(),
|
||||
'region': (x, y, width, height)
|
||||
})
|
||||
|
||||
# Auto-save if enabled
|
||||
if self._auto_save:
|
||||
self._auto_save_screenshot(screenshot)
|
||||
|
||||
return screenshot
|
||||
|
||||
def capture_window(self, window_handle: int) -> Optional[Image.Image]:
|
||||
"""
|
||||
Capture specific window by handle (Windows only).
|
||||
|
||||
Args:
|
||||
window_handle: Window handle (HWND on Windows)
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if capture failed
|
||||
"""
|
||||
if self._platform != "windows":
|
||||
print("[Screenshot] capture_window is Windows-only")
|
||||
return None
|
||||
|
||||
try:
|
||||
import win32gui
|
||||
import win32ui
|
||||
import win32con
|
||||
from ctypes import windll
|
||||
|
||||
# Get window dimensions
|
||||
left, top, right, bottom = win32gui.GetWindowRect(window_handle)
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
|
||||
# Create device context
|
||||
hwndDC = win32gui.GetWindowDC(window_handle)
|
||||
mfcDC = win32ui.CreateDCFromHandle(hwndDC)
|
||||
saveDC = mfcDC.CreateCompatibleDC()
|
||||
|
||||
# Create bitmap
|
||||
saveBitMap = win32ui.CreateBitmap()
|
||||
saveBitMap.CreateCompatibleBitmap(mfcDC, width, height)
|
||||
saveDC.SelectObject(saveBitMap)
|
||||
|
||||
# Copy screen into bitmap
|
||||
result = windll.user32.PrintWindow(window_handle, saveDC.GetSafeHdc(), 3)
|
||||
|
||||
# Convert to PIL Image
|
||||
bmpinfo = saveBitMap.GetInfo()
|
||||
bmpstr = saveBitMap.GetBitmapBits(True)
|
||||
screenshot = Image.frombuffer(
|
||||
'RGB',
|
||||
(bmpinfo['bmWidth'], bmpinfo['bmHeight']),
|
||||
bmpstr, 'raw', 'BGRX', 0, 1
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
win32gui.DeleteObject(saveBitMap.GetHandle())
|
||||
saveDC.DeleteDC()
|
||||
mfcDC.DeleteDC()
|
||||
win32gui.ReleaseDC(window_handle, hwndDC)
|
||||
|
||||
if result != 1:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
self._last_screenshot = screenshot.copy()
|
||||
self._history.append({
|
||||
'image': screenshot.copy(),
|
||||
'timestamp': datetime.now(),
|
||||
'region': 'window',
|
||||
'window_handle': window_handle
|
||||
})
|
||||
|
||||
if self._auto_save:
|
||||
self._auto_save_screenshot(screenshot)
|
||||
|
||||
return screenshot
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Screenshot] Window capture failed: {e}")
|
||||
return None
|
||||
|
||||
def _do_capture(self, full_screen: bool = True, region: Optional[Tuple[int, int, int, int]] = None) -> Image.Image:
|
||||
"""Internal capture method."""
|
||||
# Try PIL.ImageGrab first (Windows, faster)
|
||||
if self._use_pil and self._check_pil_grab():
|
||||
from PIL import ImageGrab
|
||||
if region:
|
||||
return ImageGrab.grab(bbox=region)
|
||||
else:
|
||||
return ImageGrab.grab()
|
||||
|
||||
# Fall back to pyautogui (cross-platform)
|
||||
if self._check_pyautogui():
|
||||
import pyautogui
|
||||
if region:
|
||||
x1, y1, x2, y2 = region
|
||||
return pyautogui.screenshot(region=(x1, y1, x2 - x1, y2 - y1))
|
||||
else:
|
||||
return pyautogui.screenshot()
|
||||
|
||||
raise RuntimeError(
|
||||
"No screenshot backend available. "
|
||||
"Install pillow (Windows) or pyautogui (cross-platform)."
|
||||
)
|
||||
|
||||
def _auto_save_screenshot(self, image: Image.Image) -> Optional[Path]:
|
||||
"""Automatically save screenshot with timestamp."""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")[:-3]
|
||||
filename = f"screenshot_{timestamp}.{self._format.lower()}"
|
||||
return self.save_screenshot(image, filename)
|
||||
except Exception as e:
|
||||
print(f"[Screenshot] Auto-save failed: {e}")
|
||||
return None
|
||||
|
||||
def save_screenshot(self, image: Image.Image, filename: Optional[str] = None) -> Path:
|
||||
"""
|
||||
Save screenshot to file.
|
||||
|
||||
Args:
|
||||
image: PIL Image to save
|
||||
filename: Optional filename (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")[:-3]
|
||||
filename = f"screenshot_{timestamp}.{self._format.lower()}"
|
||||
|
||||
# Ensure correct extension
|
||||
if not filename.lower().endswith(('.png', '.jpg', '.jpeg')):
|
||||
filename += f".{self._format.lower()}"
|
||||
|
||||
filepath = self._save_path / filename
|
||||
|
||||
# Save with appropriate settings
|
||||
if filename.lower().endswith('.jpg') or filename.lower().endswith('.jpeg'):
|
||||
image = image.convert('RGB') # JPEG doesn't support alpha
|
||||
image.save(filepath, 'JPEG', quality=self._quality, optimize=True)
|
||||
else:
|
||||
image.save(filepath, 'PNG', optimize=True)
|
||||
|
||||
return filepath
|
||||
|
||||
def get_last_screenshot(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Get the most recent screenshot.
|
||||
|
||||
Returns:
|
||||
PIL Image or None if no screenshots taken yet
|
||||
"""
|
||||
with self._lock:
|
||||
return self._last_screenshot.copy() if self._last_screenshot else None
|
||||
|
||||
def get_history(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get screenshot history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries (default: all)
|
||||
|
||||
Returns:
|
||||
List of dicts with 'timestamp', 'region', 'image' keys
|
||||
"""
|
||||
with self._lock:
|
||||
history = list(self._history)
|
||||
if limit:
|
||||
history = history[-limit:]
|
||||
return [
|
||||
{
|
||||
'timestamp': entry['timestamp'],
|
||||
'region': entry['region'],
|
||||
'image': entry['image'].copy()
|
||||
}
|
||||
for entry in history
|
||||
]
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear screenshot history from memory."""
|
||||
with self._lock:
|
||||
self._history.clear()
|
||||
self._last_screenshot = None
|
||||
|
||||
# ========== Configuration ==========
|
||||
|
||||
@property
|
||||
def auto_save(self) -> bool:
|
||||
"""Get auto-save setting."""
|
||||
return self._auto_save
|
||||
|
||||
@auto_save.setter
|
||||
def auto_save(self, value: bool) -> None:
|
||||
"""Set auto-save setting."""
|
||||
self._auto_save = bool(value)
|
||||
|
||||
@property
|
||||
def save_path(self) -> Path:
|
||||
"""Get current save path."""
|
||||
return self._save_path
|
||||
|
||||
@save_path.setter
|
||||
def save_path(self, path: Union[str, Path]) -> None:
|
||||
"""Set save path."""
|
||||
self._save_path = Path(path)
|
||||
self._ensure_save_directory()
|
||||
|
||||
@property
|
||||
def format(self) -> str:
|
||||
"""Get image format (PNG or JPEG)."""
|
||||
return self._format
|
||||
|
||||
@format.setter
|
||||
def format(self, fmt: str) -> None:
|
||||
"""Set image format."""
|
||||
fmt = fmt.upper()
|
||||
if fmt in ('PNG', 'JPG', 'JPEG'):
|
||||
self._format = 'PNG' if fmt == 'PNG' else 'JPEG'
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {fmt}")
|
||||
|
||||
@property
|
||||
def quality(self) -> int:
|
||||
"""Get JPEG quality (1-100)."""
|
||||
return self._quality
|
||||
|
||||
@quality.setter
|
||||
def quality(self, value: int) -> None:
|
||||
"""Set JPEG quality (1-100)."""
|
||||
self._quality = max(1, min(100, int(value)))
|
||||
|
||||
def configure(self,
|
||||
auto_save: Optional[bool] = None,
|
||||
save_path: Optional[Union[str, Path]] = None,
|
||||
format: Optional[str] = None,
|
||||
quality: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Configure screenshot service settings.
|
||||
|
||||
Args:
|
||||
auto_save: Enable/disable auto-save
|
||||
save_path: Directory to save screenshots
|
||||
format: Image format (PNG or JPEG)
|
||||
quality: JPEG quality (1-100)
|
||||
|
||||
Returns:
|
||||
Current configuration as dict
|
||||
"""
|
||||
if auto_save is not None:
|
||||
self.auto_save = auto_save
|
||||
if save_path is not None:
|
||||
self.save_path = save_path
|
||||
if format is not None:
|
||||
self.format = format
|
||||
if quality is not None:
|
||||
self.quality = quality
|
||||
|
||||
return self.get_config()
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""Get current configuration."""
|
||||
return {
|
||||
'auto_save': self._auto_save,
|
||||
'save_path': str(self._save_path),
|
||||
'format': self._format,
|
||||
'quality': self._quality,
|
||||
'history_size': self._history_size,
|
||||
'platform': self._platform,
|
||||
'backend': 'PIL' if self._use_pil else 'pyautogui'
|
||||
}
|
||||
|
||||
# ========== Utility Methods ==========
|
||||
|
||||
def image_to_bytes(self, image: Image.Image, format: Optional[str] = None) -> bytes:
|
||||
"""
|
||||
Convert PIL Image to bytes.
|
||||
|
||||
Args:
|
||||
image: PIL Image
|
||||
format: Output format (default: current format setting)
|
||||
|
||||
Returns:
|
||||
Image as bytes
|
||||
"""
|
||||
fmt = (format or self._format).upper()
|
||||
buffer = io.BytesIO()
|
||||
|
||||
if fmt == 'JPEG':
|
||||
image = image.convert('RGB')
|
||||
image.save(buffer, 'JPEG', quality=self._quality)
|
||||
else:
|
||||
image.save(buffer, 'PNG')
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
def get_available_backends(self) -> List[str]:
|
||||
"""Get list of available capture backends."""
|
||||
backends = []
|
||||
if self._check_pil_grab():
|
||||
backends.append('PIL.ImageGrab')
|
||||
if self._check_pyautogui():
|
||||
backends.append('pyautogui')
|
||||
return backends
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if screenshot service is available (has working backend)."""
|
||||
return self._check_pil_grab() or self._check_pyautogui()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_screenshot_service = None
|
||||
|
||||
def get_screenshot_service() -> ScreenshotService:
|
||||
"""Get the global ScreenshotService instance."""
|
||||
global _screenshot_service
|
||||
if _screenshot_service is None:
|
||||
_screenshot_service = ScreenshotService()
|
||||
return _screenshot_service
|
||||
|
||||
|
||||
# Convenience functions for quick screenshots
|
||||
def quick_capture() -> Image.Image:
|
||||
"""Quick full-screen capture."""
|
||||
return get_screenshot_service().capture(full_screen=True)
|
||||
|
||||
def quick_capture_region(x: int, y: int, width: int, height: int) -> Image.Image:
|
||||
"""Quick region capture."""
|
||||
return get_screenshot_service().capture_region(x, y, width, height)
|
||||
|
||||
def quick_save(filename: Optional[str] = None) -> Path:
|
||||
"""Quick capture and save."""
|
||||
service = get_screenshot_service()
|
||||
image = service.capture()
|
||||
return service.save_screenshot(image, filename)
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
"""
|
||||
EU-Utility - Task Manager
|
||||
|
||||
Thread pool and background task execution for plugins.
|
||||
Part of core - plugins access via PluginAPI.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
|
||||
|
||||
|
||||
class TaskPriority(Enum):
|
||||
"""Task priority levels."""
|
||||
HIGH = 1
|
||||
NORMAL = 2
|
||||
LOW = 3
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""Task execution status."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""Represents a background task."""
|
||||
id: str
|
||||
func: Callable
|
||||
args: tuple
|
||||
kwargs: dict
|
||||
priority: TaskPriority
|
||||
callback: Optional[Callable] = None
|
||||
error_callback: Optional[Callable] = None
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
result: Any = None
|
||||
error: Optional[Exception] = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TaskSignals(QObject):
|
||||
"""Qt signals for task updates."""
|
||||
task_completed = pyqtSignal(str, object) # task_id, result
|
||||
task_failed = pyqtSignal(str, object) # task_id, error
|
||||
task_started = pyqtSignal(str) # task_id
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""
|
||||
Core task management service with thread pool.
|
||||
|
||||
Usage:
|
||||
manager = get_task_manager()
|
||||
|
||||
# Run in background
|
||||
task_id = manager.run_in_thread(my_function, arg1, arg2)
|
||||
|
||||
# Run later
|
||||
manager.run_later(1000, my_function) # After 1000ms
|
||||
|
||||
# Run periodic
|
||||
manager.run_periodic(5000, my_function) # Every 5 seconds
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, max_workers: int = 4):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
self._futures: Dict[str, Future] = {}
|
||||
self._timers: Dict[str, QTimer] = {}
|
||||
self._signals = TaskSignals()
|
||||
self._shutdown = False
|
||||
|
||||
# Connect signals
|
||||
self._signals.task_completed.connect(self._on_task_completed)
|
||||
self._signals.task_failed.connect(self._on_task_failed)
|
||||
|
||||
def run_in_thread(self, func: Callable, *args,
|
||||
priority: TaskPriority = TaskPriority.NORMAL,
|
||||
callback: Callable = None,
|
||||
error_callback: Callable = None,
|
||||
**kwargs) -> str:
|
||||
"""Run a function in a background thread.
|
||||
|
||||
Args:
|
||||
func: Function to run
|
||||
*args: Arguments for function
|
||||
priority: Task priority
|
||||
callback: Called with result on success (in main thread)
|
||||
error_callback: Called with error on failure (in main thread)
|
||||
**kwargs: Keyword arguments for function
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
|
||||
task = Task(
|
||||
id=task_id,
|
||||
func=func,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
priority=priority,
|
||||
callback=callback,
|
||||
error_callback=error_callback
|
||||
)
|
||||
|
||||
self._tasks[task_id] = task
|
||||
|
||||
# Submit to thread pool
|
||||
future = self._executor.submit(self._execute_task, task_id)
|
||||
self._futures[task_id] = future
|
||||
|
||||
return task_id
|
||||
|
||||
def _execute_task(self, task_id: str):
|
||||
"""Execute task in thread."""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
try:
|
||||
task.status = TaskStatus.RUNNING
|
||||
task.started_at = datetime.now()
|
||||
self._signals.task_started.emit(task_id)
|
||||
|
||||
# Execute function
|
||||
result = task.func(*task.args, **task.kwargs)
|
||||
|
||||
task.result = result
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.completed_at = datetime.now()
|
||||
|
||||
# Emit completion signal
|
||||
self._signals.task_completed.emit(task_id, result)
|
||||
|
||||
except Exception as e:
|
||||
task.error = e
|
||||
task.status = TaskStatus.FAILED
|
||||
task.completed_at = datetime.now()
|
||||
|
||||
# Emit failure signal
|
||||
self._signals.task_failed.emit(task_id, e)
|
||||
|
||||
def _on_task_completed(self, task_id: str, result: Any):
|
||||
"""Handle task completion (in main thread)."""
|
||||
task = self._tasks.get(task_id)
|
||||
if task and task.callback:
|
||||
try:
|
||||
task.callback(result)
|
||||
except Exception as e:
|
||||
print(f"[TaskManager] Callback error: {e}")
|
||||
|
||||
def _on_task_failed(self, task_id: str, error: Exception):
|
||||
"""Handle task failure (in main thread)."""
|
||||
task = self._tasks.get(task_id)
|
||||
if task and task.error_callback:
|
||||
try:
|
||||
task.error_callback(error)
|
||||
except Exception as e:
|
||||
print(f"[TaskManager] Error callback error: {e}")
|
||||
|
||||
def run_later(self, delay_ms: int, func: Callable, *args, **kwargs) -> str:
|
||||
"""Run a function after a delay.
|
||||
|
||||
Args:
|
||||
delay_ms: Delay in milliseconds
|
||||
func: Function to run
|
||||
*args, **kwargs: Arguments for function
|
||||
|
||||
Returns:
|
||||
Timer ID
|
||||
"""
|
||||
timer_id = str(uuid.uuid4())[:8]
|
||||
|
||||
def timeout():
|
||||
func(*args, **kwargs)
|
||||
self._timers.pop(timer_id, None)
|
||||
|
||||
timer = QTimer()
|
||||
timer.setSingleShot(True)
|
||||
timer.timeout.connect(timeout)
|
||||
timer.start(delay_ms)
|
||||
|
||||
self._timers[timer_id] = timer
|
||||
return timer_id
|
||||
|
||||
def run_periodic(self, interval_ms: int, func: Callable, *args, **kwargs) -> str:
|
||||
"""Run a function periodically.
|
||||
|
||||
Args:
|
||||
interval_ms: Interval in milliseconds
|
||||
func: Function to run
|
||||
*args, **kwargs: Arguments for function
|
||||
|
||||
Returns:
|
||||
Timer ID (use cancel_task to stop)
|
||||
"""
|
||||
timer_id = str(uuid.uuid4())[:8]
|
||||
|
||||
timer = QTimer()
|
||||
timer.timeout.connect(lambda: func(*args, **kwargs))
|
||||
timer.start(interval_ms)
|
||||
|
||||
self._timers[timer_id] = timer
|
||||
return timer_id
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
"""Cancel a task or timer.
|
||||
|
||||
Args:
|
||||
task_id: Task or timer ID
|
||||
|
||||
Returns:
|
||||
True if cancelled
|
||||
"""
|
||||
# Cancel thread task
|
||||
if task_id in self._futures:
|
||||
future = self._futures[task_id]
|
||||
cancelled = future.cancel()
|
||||
if cancelled:
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.status = TaskStatus.CANCELLED
|
||||
return cancelled
|
||||
|
||||
# Cancel timer
|
||||
if task_id in self._timers:
|
||||
self._timers[task_id].stop()
|
||||
del self._timers[task_id]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[TaskStatus]:
|
||||
"""Get task status."""
|
||||
task = self._tasks.get(task_id)
|
||||
return task.status if task else None
|
||||
|
||||
def get_task_result(self, task_id: str) -> Any:
|
||||
"""Get task result (if completed)."""
|
||||
task = self._tasks.get(task_id)
|
||||
return task.result if task else None
|
||||
|
||||
def get_active_tasks(self) -> List[Task]:
|
||||
"""Get list of active tasks."""
|
||||
return [t for t in self._tasks.values()
|
||||
if t.status in (TaskStatus.PENDING, TaskStatus.RUNNING)]
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
"""Shutdown task manager.
|
||||
|
||||
Args:
|
||||
wait: Wait for tasks to complete
|
||||
"""
|
||||
self._shutdown = True
|
||||
|
||||
# Stop all timers
|
||||
for timer in self._timers.values():
|
||||
timer.stop()
|
||||
self._timers.clear()
|
||||
|
||||
# Shutdown executor
|
||||
if wait:
|
||||
self._executor.shutdown(wait=True)
|
||||
else:
|
||||
self._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def get_task_manager() -> TaskManager:
|
||||
"""Get global TaskManager instance."""
|
||||
return TaskManager()
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# Task Management Service - Implementation Summary
|
||||
|
||||
## Overview
|
||||
Implemented a thread pool-based background task service for EU-Utility that replaces the need for plugins to create their own QThreads.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. `core/tasks.py`
|
||||
The core TaskManager implementation with:
|
||||
|
||||
- **Singleton TaskManager class** - Shared across the application
|
||||
- **ThreadPoolExecutor** - Configurable max workers (default: 4)
|
||||
- **Task priority levels** - HIGH, NORMAL, LOW
|
||||
- **Task scheduling**:
|
||||
- `run_in_thread()` - Immediate background execution
|
||||
- `run_later()` - Delayed execution (ms)
|
||||
- `run_periodic()` - Repeating execution at interval
|
||||
- **Callback support** - `on_complete`, `on_error`
|
||||
- **Task ID tracking** - UUID-based unique IDs
|
||||
- **Graceful shutdown** - Wait for tasks or cancel with timeout
|
||||
- **Qt signal integration** - Thread-safe UI updates
|
||||
|
||||
Key signals:
|
||||
- `task_completed` - (task_id, result)
|
||||
- `task_failed` - (task_id, error_message)
|
||||
- `task_started` - (task_id)
|
||||
- `task_cancelled` - (task_id)
|
||||
- `task_periodic` - (task_id, iteration, result)
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 2. `core/plugin_api.py`
|
||||
Added to PluginAPI:
|
||||
- `register_task_service()` - Register TaskManager instance
|
||||
- `run_in_background()` - Run function in thread pool
|
||||
- `schedule_task()` - Delayed/periodic task scheduling
|
||||
- `cancel_task()` - Cancel pending/running task
|
||||
- `get_task_status()` - Query task status
|
||||
- `wait_for_task()` - Wait for completion
|
||||
- `connect_task_signal()` - Connect to Qt signals
|
||||
|
||||
### 3. `plugins/base_plugin.py`
|
||||
Added to BasePlugin:
|
||||
- `run_in_background()` - Background task execution
|
||||
- `schedule_task()` - Delayed/periodic scheduling
|
||||
- `cancel_task()` - Task cancellation
|
||||
- `connect_task_signals()` - Batch signal connection
|
||||
|
||||
### 4. `core/main.py`
|
||||
Changes:
|
||||
- Import `get_task_manager` from tasks module
|
||||
- Initialize TaskManager in `_setup_api_services()`
|
||||
- Register task service with API
|
||||
- Graceful shutdown in `quit()` - waits for tasks (30s timeout)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Simple Background Task
|
||||
```python
|
||||
def heavy_calculation(data):
|
||||
return process(data)
|
||||
|
||||
task_id = self.run_in_background(
|
||||
heavy_calculation,
|
||||
large_dataset,
|
||||
priority='high',
|
||||
on_complete=lambda result: print(f"Done: {result}"),
|
||||
on_error=lambda e: print(f"Error: {e}")
|
||||
)
|
||||
```
|
||||
|
||||
### Delayed Task
|
||||
```python
|
||||
task_id = self.schedule_task(
|
||||
delay_ms=5000, # 5 seconds
|
||||
func=lambda: print("Hello!"),
|
||||
on_complete=lambda _: print("Complete")
|
||||
)
|
||||
```
|
||||
|
||||
### Periodic Task
|
||||
```python
|
||||
task_id = self.schedule_task(
|
||||
delay_ms=0,
|
||||
func=fetch_data,
|
||||
periodic=True,
|
||||
interval_ms=30000, # Every 30 seconds
|
||||
on_complete=lambda data: update_ui(data)
|
||||
)
|
||||
|
||||
# Cancel later
|
||||
self.cancel_task(task_id)
|
||||
```
|
||||
|
||||
### UI Updates (Thread-Safe)
|
||||
```python
|
||||
def initialize(self):
|
||||
# Connect signals once
|
||||
self.connect_task_signals(
|
||||
on_completed=self._on_done,
|
||||
on_failed=self._on_error
|
||||
)
|
||||
|
||||
def start_work(self):
|
||||
# Run in background
|
||||
self.run_in_background(
|
||||
self.process_data,
|
||||
priority='normal'
|
||||
)
|
||||
|
||||
def _on_done(self, task_id, result):
|
||||
# Runs in main thread - safe to update UI!
|
||||
self.status_label.setText(f"Complete: {result}")
|
||||
|
||||
def _on_error(self, task_id, error):
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No more QThreads** - Plugins use shared thread pool
|
||||
2. **Qt signal integration** - Thread-safe UI updates
|
||||
3. **Priority levels** - HIGH tasks run before LOW
|
||||
4. **Task tracking** - Monitor and cancel tasks
|
||||
5. **Graceful shutdown** - Wait for tasks on exit
|
||||
6. **Error handling** - Centralized error callbacks
|
||||
7. **Less code** - Simple API vs managing QThreads
|
||||
|
||||
## Migration Guide
|
||||
|
||||
**Before (with QThread):**
|
||||
```python
|
||||
class Worker(QThread):
|
||||
finished = pyqtSignal(object)
|
||||
|
||||
def run(self):
|
||||
result = heavy_work()
|
||||
self.finished.emit(result)
|
||||
|
||||
worker = Worker()
|
||||
worker.finished.connect(self.handle_result)
|
||||
worker.start()
|
||||
```
|
||||
|
||||
**After (with TaskManager):**
|
||||
```python
|
||||
task_id = self.run_in_background(
|
||||
heavy_work,
|
||||
on_complete=self.handle_result
|
||||
)
|
||||
```
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
"""
|
||||
Example plugin demonstrating background task usage.
|
||||
|
||||
This shows how to use the Task Manager instead of creating QThreads.
|
||||
"""
|
||||
|
||||
from plugins.base_plugin import BasePlugin
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ExampleTaskPlugin(BasePlugin):
|
||||
"""Example plugin showing background task patterns."""
|
||||
|
||||
name = "Task Example"
|
||||
version = "1.0.0"
|
||||
author = "EU-Utility"
|
||||
description = "Example of background task usage"
|
||||
|
||||
def __init__(self, overlay_window, config):
|
||||
super().__init__(overlay_window, config)
|
||||
self._active_tasks = []
|
||||
self._periodic_task_id = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Set up task signal connections."""
|
||||
# Connect to task signals for UI updates
|
||||
self.connect_task_signals(
|
||||
on_completed=self._on_task_completed,
|
||||
on_failed=self._on_task_failed,
|
||||
on_started=self._on_task_started
|
||||
)
|
||||
|
||||
def get_ui(self) -> Any:
|
||||
"""Return the plugin's UI widget."""
|
||||
# This would normally return a QWidget
|
||||
# For this example, we just return None
|
||||
return None
|
||||
|
||||
# ========== Example: Simple Background Task ==========
|
||||
|
||||
def run_heavy_calculation(self, data):
|
||||
"""Run a heavy calculation in the background."""
|
||||
|
||||
def heavy_calc():
|
||||
# This runs in a background thread
|
||||
import time
|
||||
time.sleep(2) # Simulate heavy work
|
||||
return sum(x ** 2 for x in data)
|
||||
|
||||
def on_done(result):
|
||||
# This runs in the main thread via Qt signals
|
||||
print(f"Calculation complete: {result}")
|
||||
self.notify_success("Task Complete", f"Result: {result}")
|
||||
|
||||
def on_error(exc):
|
||||
print(f"Calculation failed: {exc}")
|
||||
self.notify_error("Task Failed", str(exc))
|
||||
|
||||
# Submit the task
|
||||
task_id = self.run_in_background(
|
||||
heavy_calc,
|
||||
priority='high',
|
||||
on_complete=on_done,
|
||||
on_error=on_error
|
||||
)
|
||||
|
||||
self._active_tasks.append(task_id)
|
||||
print(f"Started task: {task_id}")
|
||||
return task_id
|
||||
|
||||
# ========== Example: Delayed Task ==========
|
||||
|
||||
def schedule_delayed_update(self):
|
||||
"""Schedule a task to run after a delay."""
|
||||
|
||||
task_id = self.schedule_task(
|
||||
delay_ms=5000, # 5 seconds
|
||||
func=lambda: "Delayed update executed!",
|
||||
on_complete=lambda msg: print(msg),
|
||||
priority='normal'
|
||||
)
|
||||
|
||||
print(f"Scheduled delayed task: {task_id}")
|
||||
return task_id
|
||||
|
||||
# ========== Example: Periodic Task ==========
|
||||
|
||||
def start_periodic_refresh(self):
|
||||
"""Start a periodic background refresh."""
|
||||
|
||||
def fetch_data():
|
||||
# Simulate data fetching
|
||||
import random
|
||||
return {
|
||||
'timestamp': __import__('time').time(),
|
||||
'value': random.randint(1, 100)
|
||||
}
|
||||
|
||||
def on_data(data):
|
||||
print(f"Periodic update: {data}")
|
||||
|
||||
self._periodic_task_id = self.schedule_task(
|
||||
delay_ms=0, # Start immediately
|
||||
func=fetch_data,
|
||||
periodic=True,
|
||||
interval_ms=10000, # Every 10 seconds
|
||||
on_complete=on_data,
|
||||
priority='low'
|
||||
)
|
||||
|
||||
print(f"Started periodic task: {self._periodic_task_id}")
|
||||
|
||||
def stop_periodic_refresh(self):
|
||||
"""Stop the periodic task."""
|
||||
if self._periodic_task_id:
|
||||
self.cancel_task(self._periodic_task_id)
|
||||
print("Periodic task stopped")
|
||||
self._periodic_task_id = None
|
||||
|
||||
# ========== Example: Method as Background Task ==========
|
||||
|
||||
def background_method_example(self, data):
|
||||
"""Example of running an instance method in background."""
|
||||
|
||||
# Note: When running instance methods, pass 'self' explicitly
|
||||
task_id = self.run_in_background(
|
||||
self._process_data,
|
||||
data,
|
||||
priority='normal',
|
||||
on_complete=self._handle_result
|
||||
)
|
||||
return task_id
|
||||
|
||||
def _process_data(self, data):
|
||||
"""Method that runs in background."""
|
||||
import time
|
||||
time.sleep(1)
|
||||
return f"Processed: {len(data)} items"
|
||||
|
||||
def _handle_result(self, result):
|
||||
"""Handle the result in main thread."""
|
||||
print(f"Got result: {result}")
|
||||
|
||||
# ========== Task Signal Handlers ==========
|
||||
|
||||
def _on_task_started(self, task_id):
|
||||
"""Called when any task starts."""
|
||||
print(f"Task {task_id} started")
|
||||
|
||||
def _on_task_completed(self, task_id, result):
|
||||
"""Called when any task completes."""
|
||||
print(f"Task {task_id} completed with result: {result}")
|
||||
if task_id in self._active_tasks:
|
||||
self._active_tasks.remove(task_id)
|
||||
|
||||
def _on_task_failed(self, task_id, error):
|
||||
"""Called when any task fails."""
|
||||
print(f"Task {task_id} failed: {error}")
|
||||
if task_id in self._active_tasks:
|
||||
self._active_tasks.remove(task_id)
|
||||
|
||||
# ========== Cleanup ==========
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Clean up tasks when plugin shuts down."""
|
||||
# Stop periodic task
|
||||
self.stop_periodic_refresh()
|
||||
|
||||
# Cancel any active tasks
|
||||
for task_id in self._active_tasks[:]:
|
||||
self.cancel_task(task_id)
|
||||
|
||||
self._active_tasks.clear()
|
||||
super().shutdown()
|
||||
|
||||
|
||||
# ========== Quick Usage Examples ==========
|
||||
|
||||
"""
|
||||
In a plugin, use the task service like this:
|
||||
|
||||
# 1. Simple background execution
|
||||
task_id = self.run_in_background(
|
||||
some_function,
|
||||
arg1, arg2,
|
||||
priority='high',
|
||||
on_complete=lambda result: print(f"Done: {result}"),
|
||||
on_error=lambda e: print(f"Error: {e}")
|
||||
)
|
||||
|
||||
# 2. Delayed execution
|
||||
task_id = self.schedule_task(
|
||||
delay_ms=5000,
|
||||
func=some_function,
|
||||
on_complete=lambda result: print("Delayed task done")
|
||||
)
|
||||
|
||||
# 3. Periodic execution
|
||||
task_id = self.schedule_task(
|
||||
delay_ms=0, # Start now
|
||||
func=fetch_data,
|
||||
periodic=True,
|
||||
interval_ms=30000, # Every 30 seconds
|
||||
on_complete=lambda data: update_ui(data)
|
||||
)
|
||||
|
||||
# 4. Cancel a task
|
||||
self.cancel_task(task_id)
|
||||
|
||||
# 5. Connect to task signals for UI updates
|
||||
self.connect_task_signals(
|
||||
on_completed=self._on_task_done,
|
||||
on_failed=self._on_task_error
|
||||
)
|
||||
|
||||
def _on_task_done(self, task_id, result):
|
||||
# This runs in main thread - safe to update UI
|
||||
self.status_label.setText(f"Task complete: {result}")
|
||||
"""
|
||||
|
|
@ -6,11 +6,12 @@ Includes PluginAPI integration for cross-plugin communication.
|
|||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING, Callable
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING, Callable, List, Type
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.overlay_window import OverlayWindow
|
||||
from core.plugin_api import PluginAPI, APIEndpoint, APIType
|
||||
from core.event_bus import BaseEvent, EventCategory
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
|
|
@ -34,6 +35,9 @@ class BasePlugin(ABC):
|
|||
self._api_registered = False
|
||||
self._plugin_id = f"{self.__class__.__module__}.{self.__class__.__name__}"
|
||||
|
||||
# Track event subscriptions for cleanup
|
||||
self._event_subscriptions: List[str] = []
|
||||
|
||||
# Get API instance
|
||||
try:
|
||||
from core.plugin_api import get_api
|
||||
|
|
@ -49,7 +53,7 @@ class BasePlugin(ABC):
|
|||
@abstractmethod
|
||||
def get_ui(self) -> Any:
|
||||
"""Return the plugin's UI widget (QWidget)."""
|
||||
pass
|
||||
return None
|
||||
|
||||
def on_show(self) -> None:
|
||||
"""Called when overlay becomes visible."""
|
||||
|
|
@ -69,6 +73,9 @@ class BasePlugin(ABC):
|
|||
if self.api and self._api_registered:
|
||||
self.api.unregister_api(self._plugin_id)
|
||||
|
||||
# Unsubscribe from all typed events
|
||||
self.unsubscribe_all_typed()
|
||||
|
||||
# ========== Config Methods ==========
|
||||
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
|
|
@ -139,50 +146,6 @@ class BasePlugin(ABC):
|
|||
|
||||
return self.api.find_apis(api_type)
|
||||
|
||||
# ========== Window Service Methods ==========
|
||||
|
||||
def get_eu_window(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about the Entropia Universe window.
|
||||
|
||||
Returns:
|
||||
Dict with window info or None if not available:
|
||||
{
|
||||
'handle': int,
|
||||
'title': str,
|
||||
'rect': (left, top, right, bottom),
|
||||
'width': int,
|
||||
'height': int,
|
||||
'is_visible': bool,
|
||||
'is_focused': bool
|
||||
}
|
||||
"""
|
||||
if not self.api:
|
||||
return None
|
||||
|
||||
return self.api.get_eu_window()
|
||||
|
||||
def is_eu_focused(self) -> bool:
|
||||
"""Check if EU window is currently focused/active.
|
||||
|
||||
Returns:
|
||||
True if EU is the active window, False otherwise
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.is_eu_focused()
|
||||
|
||||
def bring_eu_to_front(self) -> bool:
|
||||
"""Bring EU window to front and focus it.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.bring_eu_to_front()
|
||||
|
||||
# ========== Shared Services ==========
|
||||
|
||||
def ocr_capture(self, region: tuple = None) -> Dict[str, Any]:
|
||||
|
|
@ -196,6 +159,61 @@ class BasePlugin(ABC):
|
|||
|
||||
return self.api.ocr_capture(region)
|
||||
|
||||
# ========== Screenshot Service Methods ==========
|
||||
|
||||
def capture_screen(self, full_screen: bool = True):
|
||||
"""Capture screenshot.
|
||||
|
||||
Args:
|
||||
full_screen: If True, capture entire screen
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
|
||||
Example:
|
||||
# Capture full screen
|
||||
screenshot = self.capture_screen()
|
||||
|
||||
# Capture specific region
|
||||
region = self.capture_region(100, 100, 800, 600)
|
||||
"""
|
||||
if not self.api:
|
||||
raise RuntimeError("API not available")
|
||||
|
||||
return self.api.capture_screen(full_screen)
|
||||
|
||||
def capture_region(self, x: int, y: int, width: int, height: int):
|
||||
"""Capture specific screen region.
|
||||
|
||||
Args:
|
||||
x: Left coordinate
|
||||
y: Top coordinate
|
||||
width: Region width
|
||||
height: Region height
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
|
||||
Example:
|
||||
# Capture a 400x200 region starting at (100, 100)
|
||||
image = self.capture_region(100, 100, 400, 200)
|
||||
"""
|
||||
if not self.api:
|
||||
raise RuntimeError("API not available")
|
||||
|
||||
return self.api.capture_region(x, y, width, height)
|
||||
|
||||
def get_last_screenshot(self):
|
||||
"""Get the most recent screenshot.
|
||||
|
||||
Returns:
|
||||
PIL Image or None if no screenshots taken yet
|
||||
"""
|
||||
if not self.api:
|
||||
return None
|
||||
|
||||
return self.api.get_last_screenshot()
|
||||
|
||||
def read_log(self, lines: int = 50, filter_text: str = None) -> list:
|
||||
"""Read recent game log lines."""
|
||||
if not self.api:
|
||||
|
|
@ -215,16 +233,159 @@ class BasePlugin(ABC):
|
|||
if self.api:
|
||||
self.api.set_data(key, value)
|
||||
|
||||
# ========== Legacy Event System ==========
|
||||
|
||||
def publish_event(self, event_type: str, data: Dict[str, Any]):
|
||||
"""Publish an event for other plugins to consume."""
|
||||
"""Publish an event for other plugins to consume (legacy)."""
|
||||
if self.api:
|
||||
self.api.publish_event(event_type, data)
|
||||
|
||||
def subscribe(self, event_type: str, callback: Callable):
|
||||
"""Subscribe to events from other plugins."""
|
||||
"""Subscribe to events from other plugins (legacy)."""
|
||||
if self.api:
|
||||
self.api.subscribe(event_type, callback)
|
||||
|
||||
# ========== Enhanced Typed Event System ==========
|
||||
|
||||
def publish_typed(self, event: 'BaseEvent') -> None:
|
||||
"""
|
||||
Publish a typed event to the Event Bus.
|
||||
|
||||
Args:
|
||||
event: A typed event instance (SkillGainEvent, LootEvent, etc.)
|
||||
|
||||
Example:
|
||||
from core.event_bus import LootEvent
|
||||
|
||||
self.publish_typed(LootEvent(
|
||||
mob_name="Daikiba",
|
||||
items=[{"name": "Animal Oil", "value": 0.05}],
|
||||
total_tt_value=0.05
|
||||
))
|
||||
"""
|
||||
if self.api:
|
||||
self.api.publish_typed(event)
|
||||
|
||||
def subscribe_typed(
|
||||
self,
|
||||
event_class: Type['BaseEvent'],
|
||||
callback: Callable,
|
||||
**filter_kwargs
|
||||
) -> str:
|
||||
"""
|
||||
Subscribe to a specific event type with optional filtering.
|
||||
|
||||
Args:
|
||||
event_class: The event class to subscribe to
|
||||
callback: Function to call when matching events occur
|
||||
**filter_kwargs: Additional filter criteria
|
||||
- min_damage: Minimum damage threshold
|
||||
- max_damage: Maximum damage threshold
|
||||
- mob_types: List of mob names to filter
|
||||
- skill_names: List of skill names to filter
|
||||
- sources: List of event sources to filter
|
||||
- replay_last: Number of recent events to replay
|
||||
- predicate: Custom filter function
|
||||
|
||||
Returns:
|
||||
Subscription ID (store this to unsubscribe later)
|
||||
|
||||
Example:
|
||||
from core.event_bus import DamageEvent
|
||||
|
||||
# Subscribe to all damage events
|
||||
self.sub_id = self.subscribe_typed(DamageEvent, self.on_damage)
|
||||
|
||||
# Subscribe to high damage events only
|
||||
self.sub_id = self.subscribe_typed(
|
||||
DamageEvent,
|
||||
self.on_big_hit,
|
||||
min_damage=100
|
||||
)
|
||||
|
||||
# Subscribe with replay
|
||||
self.sub_id = self.subscribe_typed(
|
||||
SkillGainEvent,
|
||||
self.on_skill_gain,
|
||||
replay_last=10
|
||||
)
|
||||
"""
|
||||
if not self.api:
|
||||
print(f"[{self.name}] API not available for event subscription")
|
||||
return ""
|
||||
|
||||
sub_id = self.api.subscribe_typed(event_class, callback, **filter_kwargs)
|
||||
if sub_id:
|
||||
self._event_subscriptions.append(sub_id)
|
||||
return sub_id
|
||||
|
||||
def unsubscribe_typed(self, subscription_id: str) -> bool:
|
||||
"""
|
||||
Unsubscribe from a specific typed event subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: The subscription ID returned by subscribe_typed
|
||||
|
||||
Returns:
|
||||
True if subscription was found and removed
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
result = self.api.unsubscribe_typed(subscription_id)
|
||||
if result and subscription_id in self._event_subscriptions:
|
||||
self._event_subscriptions.remove(subscription_id)
|
||||
return result
|
||||
|
||||
def unsubscribe_all_typed(self) -> None:
|
||||
"""Unsubscribe from all typed event subscriptions."""
|
||||
if not self.api:
|
||||
return
|
||||
|
||||
for sub_id in self._event_subscriptions[:]: # Copy list to avoid modification during iteration
|
||||
self.api.unsubscribe_typed(sub_id)
|
||||
self._event_subscriptions.clear()
|
||||
|
||||
def get_recent_events(
|
||||
self,
|
||||
event_type: Type['BaseEvent'] = None,
|
||||
count: int = 100,
|
||||
category: 'EventCategory' = None
|
||||
) -> List['BaseEvent']:
|
||||
"""
|
||||
Get recent events from history.
|
||||
|
||||
Args:
|
||||
event_type: Filter by event class
|
||||
count: Maximum number of events to return
|
||||
category: Filter by event category
|
||||
|
||||
Returns:
|
||||
List of matching events
|
||||
|
||||
Example:
|
||||
from core.event_bus import LootEvent
|
||||
|
||||
# Get last 20 loot events
|
||||
recent_loot = self.get_recent_events(LootEvent, 20)
|
||||
"""
|
||||
if not self.api:
|
||||
return []
|
||||
|
||||
return self.api.get_recent_events(event_type, count, category)
|
||||
|
||||
def get_event_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get Event Bus statistics.
|
||||
|
||||
Returns:
|
||||
Dict with event bus statistics
|
||||
"""
|
||||
if not self.api:
|
||||
return {}
|
||||
|
||||
return self.api.get_event_stats()
|
||||
|
||||
# ========== Utility Methods ==========
|
||||
|
||||
def format_ped(self, value: float) -> str:
|
||||
|
|
@ -261,3 +422,264 @@ class BasePlugin(ABC):
|
|||
if tt <= 0:
|
||||
return 0.0
|
||||
return (price / tt) * 100
|
||||
|
||||
# ========== Audio Service Methods ==========
|
||||
|
||||
def play_sound(self, filename_or_key: str, blocking: bool = False) -> bool:
|
||||
"""Play a sound by key or filename.
|
||||
|
||||
Args:
|
||||
filename_or_key: Sound key ('global', 'hof', 'skill_gain', 'alert', 'error')
|
||||
or path to file
|
||||
blocking: If True, wait for sound to complete (default: False)
|
||||
|
||||
Returns:
|
||||
True if sound was queued/played, False on error or if muted
|
||||
|
||||
Examples:
|
||||
# Play predefined sounds
|
||||
self.play_sound('hof')
|
||||
self.play_sound('skill_gain')
|
||||
self.play_sound('alert')
|
||||
|
||||
# Play custom sound file
|
||||
self.play_sound('/path/to/custom.wav')
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.play_sound(filename_or_key, blocking)
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""Set global audio volume.
|
||||
|
||||
Args:
|
||||
volume: Volume level from 0.0 (mute) to 1.0 (max)
|
||||
"""
|
||||
if self.api:
|
||||
self.api.set_volume(volume)
|
||||
|
||||
def get_volume(self) -> float:
|
||||
"""Get current audio volume.
|
||||
|
||||
Returns:
|
||||
Current volume level (0.0 to 1.0)
|
||||
"""
|
||||
if not self.api:
|
||||
return 0.0
|
||||
|
||||
return self.api.get_volume()
|
||||
|
||||
def mute(self) -> None:
|
||||
"""Mute all audio."""
|
||||
if self.api:
|
||||
self.api.mute_audio()
|
||||
|
||||
def unmute(self) -> None:
|
||||
"""Unmute audio."""
|
||||
if self.api:
|
||||
self.api.unmute_audio()
|
||||
|
||||
def toggle_mute(self) -> bool:
|
||||
"""Toggle audio mute state.
|
||||
|
||||
Returns:
|
||||
New muted state (True if now muted)
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.toggle_mute_audio()
|
||||
|
||||
def is_muted(self) -> bool:
|
||||
"""Check if audio is muted.
|
||||
|
||||
Returns:
|
||||
True if audio is muted
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.is_audio_muted()
|
||||
|
||||
def is_audio_available(self) -> bool:
|
||||
"""Check if audio service is available.
|
||||
|
||||
Returns:
|
||||
True if audio backend is initialized and working
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.is_audio_available()
|
||||
|
||||
# ========== Background Task Methods ==========
|
||||
|
||||
def run_in_background(self, func: Callable, *args,
|
||||
priority: str = 'normal',
|
||||
on_complete: Callable = None,
|
||||
on_error: Callable = None,
|
||||
**kwargs) -> str:
|
||||
"""Run a function in a background thread.
|
||||
|
||||
Use this instead of creating your own QThreads.
|
||||
|
||||
Args:
|
||||
func: Function to execute in background
|
||||
*args: Positional arguments for the function
|
||||
priority: 'high', 'normal', or 'low' (default: 'normal')
|
||||
on_complete: Called with result when task completes successfully
|
||||
on_error: Called with exception when task fails
|
||||
**kwargs: Keyword arguments for the function
|
||||
|
||||
Returns:
|
||||
Task ID for tracking/cancellation
|
||||
|
||||
Example:
|
||||
def heavy_calculation(data):
|
||||
return process(data)
|
||||
|
||||
def on_done(result):
|
||||
self.update_ui(result)
|
||||
|
||||
def on_fail(error):
|
||||
self.show_error(str(error))
|
||||
|
||||
task_id = self.run_in_background(
|
||||
heavy_calculation,
|
||||
large_dataset,
|
||||
priority='high',
|
||||
on_complete=on_done,
|
||||
on_error=on_fail
|
||||
)
|
||||
|
||||
# Or with decorator style:
|
||||
@self.run_in_background
|
||||
def fetch_remote_data():
|
||||
return requests.get(url).json()
|
||||
"""
|
||||
if not self.api:
|
||||
raise RuntimeError("API not available")
|
||||
|
||||
return self.api.run_in_background(
|
||||
func, *args,
|
||||
priority=priority,
|
||||
on_complete=on_complete,
|
||||
on_error=on_error,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def schedule_task(self, delay_ms: int, func: Callable, *args,
|
||||
priority: str = 'normal',
|
||||
on_complete: Callable = None,
|
||||
on_error: Callable = None,
|
||||
periodic: bool = False,
|
||||
interval_ms: int = None,
|
||||
**kwargs) -> str:
|
||||
"""Schedule a task for delayed or periodic execution.
|
||||
|
||||
Args:
|
||||
delay_ms: Milliseconds to wait before first execution
|
||||
func: Function to execute
|
||||
*args: Positional arguments
|
||||
priority: 'high', 'normal', or 'low'
|
||||
on_complete: Called with result after each execution
|
||||
on_error: Called with exception if execution fails
|
||||
periodic: If True, repeat execution at interval_ms
|
||||
interval_ms: Milliseconds between periodic executions
|
||||
**kwargs: Keyword arguments
|
||||
|
||||
Returns:
|
||||
Task ID for tracking/cancellation
|
||||
|
||||
Example:
|
||||
# One-time delayed execution
|
||||
task_id = self.schedule_task(
|
||||
5000, # 5 seconds
|
||||
lambda: print("Hello after delay!")
|
||||
)
|
||||
|
||||
# Periodic data refresh (every 30 seconds)
|
||||
self.schedule_task(
|
||||
0, # Start immediately
|
||||
self.refresh_data,
|
||||
periodic=True,
|
||||
interval_ms=30000,
|
||||
on_complete=lambda data: self.update_display(data)
|
||||
)
|
||||
"""
|
||||
if not self.api:
|
||||
raise RuntimeError("API not available")
|
||||
|
||||
return self.api.schedule_task(
|
||||
delay_ms, func, *args,
|
||||
priority=priority,
|
||||
on_complete=on_complete,
|
||||
on_error=on_error,
|
||||
periodic=periodic,
|
||||
interval_ms=interval_ms,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
"""Cancel a pending or running task.
|
||||
|
||||
Args:
|
||||
task_id: Task ID returned by run_in_background or schedule_task
|
||||
|
||||
Returns:
|
||||
True if task was cancelled, False if not found or already done
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
return self.api.cancel_task(task_id)
|
||||
|
||||
def connect_task_signals(self,
|
||||
on_completed: Callable = None,
|
||||
on_failed: Callable = None,
|
||||
on_started: Callable = None,
|
||||
on_cancelled: Callable = None) -> bool:
|
||||
"""Connect to task status signals for UI updates.
|
||||
|
||||
Connects Qt signals so UI updates from background threads are thread-safe.
|
||||
|
||||
Args:
|
||||
on_completed: Called with (task_id, result) when tasks complete
|
||||
on_failed: Called with (task_id, error_message) when tasks fail
|
||||
on_started: Called with (task_id) when tasks start
|
||||
on_cancelled: Called with (task_id) when tasks are cancelled
|
||||
|
||||
Returns:
|
||||
True if signals were connected
|
||||
|
||||
Example:
|
||||
class MyPlugin(BasePlugin):
|
||||
def initialize(self):
|
||||
# Connect task signals for UI updates
|
||||
self.connect_task_signals(
|
||||
on_completed=self._on_task_done,
|
||||
on_failed=self._on_task_error
|
||||
)
|
||||
|
||||
def _on_task_done(self, task_id, result):
|
||||
self.status_label.setText(f"Task {task_id}: Done!")
|
||||
|
||||
def _on_task_error(self, task_id, error):
|
||||
self.status_label.setText(f"Task {task_id} failed: {error}")
|
||||
"""
|
||||
if not self.api:
|
||||
return False
|
||||
|
||||
connected = False
|
||||
|
||||
if on_completed:
|
||||
connected = self.api.connect_task_signal('completed', on_completed) or connected
|
||||
if on_failed:
|
||||
connected = self.api.connect_task_signal('failed', on_failed) or connected
|
||||
if on_started:
|
||||
connected = self.api.connect_task_signal('started', on_started) or connected
|
||||
if on_cancelled:
|
||||
connected = self.api.connect_task_signal('cancelled', on_cancelled) or connected
|
||||
|
||||
return connected
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
"""Event Bus Example Plugin."""
|
||||
from .plugin import EventBusExamplePlugin
|
||||
|
||||
__all__ = ['EventBusExamplePlugin']
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"""
|
||||
Example plugin demonstrating Enhanced Event Bus usage.
|
||||
|
||||
This plugin shows how to use:
|
||||
- publish_typed() - Publish typed events
|
||||
- subscribe_typed() - Subscribe with filtering
|
||||
- get_recent_events() - Retrieve event history
|
||||
- Event filtering (min_damage, mob_types, etc.)
|
||||
- Event replay for new subscribers
|
||||
"""
|
||||
|
||||
from plugins.base_plugin import BasePlugin
|
||||
from core.event_bus import (
|
||||
SkillGainEvent, LootEvent, DamageEvent, GlobalEvent,
|
||||
EventCategory
|
||||
)
|
||||
|
||||
|
||||
class EventBusExamplePlugin(BasePlugin):
|
||||
"""Example plugin showing Enhanced Event Bus usage."""
|
||||
|
||||
name = "Event Bus Example"
|
||||
version = "1.0.0"
|
||||
author = "EU-Utility"
|
||||
description = "Demonstrates Enhanced Event Bus features"
|
||||
|
||||
def __init__(self, overlay_window, config):
|
||||
super().__init__(overlay_window, config)
|
||||
self.big_hits = []
|
||||
self.skill_gains = []
|
||||
self.dragon_loot = []
|
||||
self._subscriptions = []
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Setup event subscriptions."""
|
||||
print(f"[{self.name}] Initializing...")
|
||||
|
||||
# 1. Subscribe to ALL damage events
|
||||
sub_id = self.subscribe_typed(
|
||||
DamageEvent,
|
||||
self.on_any_damage,
|
||||
replay_last=5 # Replay last 5 damage events on subscribe
|
||||
)
|
||||
self._subscriptions.append(sub_id)
|
||||
print(f"[{self.name}] Subscribed to all damage events")
|
||||
|
||||
# 2. Subscribe to HIGH damage events only (filtering)
|
||||
sub_id = self.subscribe_typed(
|
||||
DamageEvent,
|
||||
self.on_big_damage,
|
||||
min_damage=100, # Only events with damage >= 100
|
||||
replay_last=3
|
||||
)
|
||||
self._subscriptions.append(sub_id)
|
||||
print(f"[{self.name}] Subscribed to high damage events (≥100)")
|
||||
|
||||
# 3. Subscribe to skill gains for specific skills
|
||||
sub_id = self.subscribe_typed(
|
||||
SkillGainEvent,
|
||||
self.on_combat_skill_gain,
|
||||
skill_names=["Rifle", "Handgun", "Sword", "Knife"],
|
||||
replay_last=10
|
||||
)
|
||||
self._subscriptions.append(sub_id)
|
||||
print(f"[{self.name}] Subscribed to combat skill gains")
|
||||
|
||||
# 4. Subscribe to loot from specific mobs
|
||||
sub_id = self.subscribe_typed(
|
||||
LootEvent,
|
||||
self.on_dragon_loot,
|
||||
mob_types=["Dragon", "Drake", "Dragon Old"],
|
||||
replay_last=5
|
||||
)
|
||||
self._subscriptions.append(sub_id)
|
||||
print(f"[{self.name}] Subscribed to Dragon/Drake loot")
|
||||
|
||||
# 5. Subscribe to ALL globals
|
||||
sub_id = self.subscribe_typed(
|
||||
GlobalEvent,
|
||||
self.on_global_announcement
|
||||
)
|
||||
self._subscriptions.append(sub_id)
|
||||
print(f"[{self.name}] Subscribed to global announcements")
|
||||
|
||||
# 6. Demonstrate publishing events
|
||||
self._publish_example_events()
|
||||
|
||||
# 7. Show event stats
|
||||
stats = self.get_event_stats()
|
||||
print(f"[{self.name}] Event Bus Stats:")
|
||||
print(f" - Total published: {stats.get('total_published', 0)}")
|
||||
print(f" - Active subs: {stats.get('active_subscriptions', 0)}")
|
||||
|
||||
def _publish_example_events(self):
|
||||
"""Publish some example events to demonstrate."""
|
||||
# Publish a skill gain
|
||||
self.publish_typed(SkillGainEvent(
|
||||
skill_name="Rifle",
|
||||
skill_value=25.5,
|
||||
gain_amount=0.01,
|
||||
source="example_plugin"
|
||||
))
|
||||
|
||||
# Publish some damage events
|
||||
self.publish_typed(DamageEvent(
|
||||
damage_amount=50.5,
|
||||
damage_type="impact",
|
||||
is_outgoing=True,
|
||||
target_name="Berycled Young",
|
||||
source="example_plugin"
|
||||
))
|
||||
|
||||
self.publish_typed(DamageEvent(
|
||||
damage_amount=150.0,
|
||||
damage_type="penetration",
|
||||
is_critical=True,
|
||||
is_outgoing=True,
|
||||
target_name="Daikiba",
|
||||
source="example_plugin"
|
||||
))
|
||||
|
||||
# Publish loot
|
||||
self.publish_typed(LootEvent(
|
||||
mob_name="Dragon",
|
||||
items=[
|
||||
{"name": "Dragon Scale", "value": 15.0},
|
||||
{"name": "Animal Oil", "value": 0.05}
|
||||
],
|
||||
total_tt_value=15.05,
|
||||
source="example_plugin"
|
||||
))
|
||||
|
||||
print(f"[{self.name}] Published example events")
|
||||
|
||||
# ========== Event Handlers ==========
|
||||
|
||||
def on_any_damage(self, event: DamageEvent):
|
||||
"""Handle all damage events."""
|
||||
direction = "dealt" if event.is_outgoing else "received"
|
||||
crit = " CRITICAL" if event.is_critical else ""
|
||||
print(f"[{self.name}] Damage {direction}: {event.damage_amount:.1f}{crit} to {event.target_name}")
|
||||
|
||||
def on_big_damage(self, event: DamageEvent):
|
||||
"""Handle only high damage events (filtered)."""
|
||||
self.big_hits.append(event)
|
||||
print(f"[{self.name}] 💥 BIG HIT! {event.damage_amount:.1f} damage to {event.target_name}")
|
||||
print(f"[{self.name}] Total big hits recorded: {len(self.big_hits)}")
|
||||
|
||||
def on_combat_skill_gain(self, event: SkillGainEvent):
|
||||
"""Handle combat skill gains."""
|
||||
self.skill_gains.append(event)
|
||||
print(f"[{self.name}] ⚔️ Skill up: {event.skill_name} +{event.gain_amount:.4f} = {event.skill_value:.4f}")
|
||||
|
||||
def on_dragon_loot(self, event: LootEvent):
|
||||
"""Handle Dragon/Drake loot."""
|
||||
self.dragon_loot.append(event)
|
||||
items_str = ", ".join(event.get_item_names())
|
||||
print(f"[{self.name}] 🐉 Dragon loot from {event.mob_name}: {items_str} (TT: {event.total_tt_value:.2f} PED)")
|
||||
|
||||
def on_global_announcement(self, event: GlobalEvent):
|
||||
"""Handle global announcements."""
|
||||
item_str = f" with {event.item_name}" if event.item_name else ""
|
||||
print(f"[{self.name}] 🌍 GLOBAL: {event.player_name} - {event.achievement_type.upper()}{item_str} ({event.value:.2f} PED)")
|
||||
|
||||
def get_ui(self):
|
||||
"""Return simple info panel."""
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit
|
||||
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Title
|
||||
title = QLabel(f"<h2>{self.name}</h2>")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Stats
|
||||
stats_text = f"""
|
||||
<b>Recorded Events:</b><br>
|
||||
• Big Hits (≥100 dmg): {len(self.big_hits)}<br>
|
||||
• Combat Skill Gains: {len(self.skill_gains)}<br>
|
||||
• Dragon Loot: {len(self.dragon_loot)}<br>
|
||||
<br>
|
||||
<b>Active Subscriptions:</b> {len(self._subscriptions)}
|
||||
"""
|
||||
stats_label = QLabel(stats_text)
|
||||
stats_label.setWordWrap(True)
|
||||
layout.addWidget(stats_label)
|
||||
|
||||
# Recent events
|
||||
layout.addWidget(QLabel("<b>Recent Combat Events:</b>"))
|
||||
text_area = QTextEdit()
|
||||
text_area.setReadOnly(True)
|
||||
text_area.setMaximumHeight(200)
|
||||
|
||||
# Get recent damage events from event bus
|
||||
recent = self.get_recent_events(DamageEvent, count=10)
|
||||
events_text = "\\n".join([
|
||||
f"• {e.damage_amount:.1f} dmg to {e.target_name}"
|
||||
for e in reversed(recent)
|
||||
]) or "No recent damage events"
|
||||
text_area.setText(events_text)
|
||||
layout.addWidget(text_area)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Cleanup."""
|
||||
print(f"[{self.name}] Shutting down...")
|
||||
# Unsubscribe from all typed events (handled by base class)
|
||||
super().shutdown()
|
||||
|
|
@ -5,8 +5,6 @@ Built-in plugin for searching EntropiaNexus via API.
|
|||
Uses official Nexus API endpoints.
|
||||
"""
|
||||
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
import webbrowser
|
||||
from PyQt6.QtWidgets import (
|
||||
|
|
@ -26,41 +24,47 @@ class NexusAPIClient:
|
|||
BASE_URL = "https://www.entropianexus.com"
|
||||
|
||||
@classmethod
|
||||
def fetch_exchange_items(cls, search_query=None):
|
||||
def fetch_exchange_items(cls, search_query=None, http_get_func=None):
|
||||
"""Fetch exchange items from Nexus API."""
|
||||
try:
|
||||
url = f"{cls.BASE_URL}/api/market/exchange"
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
}
|
||||
)
|
||||
if http_get_func:
|
||||
response = http_get_func(
|
||||
url,
|
||||
cache_ttl=60, # 1 minute cache for market data
|
||||
headers={'Accept': 'application/json', 'Accept-Encoding': 'gzip'}
|
||||
)
|
||||
data = response.get('json') if response else None
|
||||
else:
|
||||
# Fallback for standalone usage
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json', 'Accept-Encoding': 'gzip'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
# Filter by search query if provided
|
||||
if search_query and data:
|
||||
search_lower = search_query.lower()
|
||||
filtered = []
|
||||
for category in data:
|
||||
if 'items' in category:
|
||||
for item in category['items']:
|
||||
if search_lower in item.get('name', '').lower():
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
|
||||
# Filter by search query if provided
|
||||
if search_query and data:
|
||||
search_lower = search_query.lower()
|
||||
filtered = []
|
||||
for category in data:
|
||||
if 'items' in category:
|
||||
for item in category['items']:
|
||||
if search_lower in item.get('name', '').lower():
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
|
||||
return data
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
print(f"API Error: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def fetch_item_prices(cls, item_ids):
|
||||
def fetch_item_prices(cls, item_ids, http_get_func=None):
|
||||
"""Fetch latest prices for items."""
|
||||
try:
|
||||
if not item_ids:
|
||||
|
|
@ -69,32 +73,51 @@ class NexusAPIClient:
|
|||
ids_str = ','.join(str(id) for id in item_ids[:100]) # Max 100
|
||||
url = f"{cls.BASE_URL}/api/market/prices/latest?items={ids_str}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
if http_get_func:
|
||||
response = http_get_func(
|
||||
url,
|
||||
cache_ttl=60, # 1 minute cache
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
return response.get('json') if response else {}
|
||||
else:
|
||||
# Fallback for standalone usage
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Price API Error: {e}")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def search_users(cls, query):
|
||||
def search_users(cls, query, http_get_func=None):
|
||||
"""Search for verified users."""
|
||||
try:
|
||||
params = urllib.parse.urlencode({'q': query, 'limit': 10})
|
||||
url = f"{cls.BASE_URL}/api/users/search?{params}"
|
||||
params = {'q': query, 'limit': 10}
|
||||
query_string = '&'.join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{cls.BASE_URL}/api/users/search?{query_string}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
if http_get_func:
|
||||
response = http_get_func(
|
||||
url,
|
||||
cache_ttl=300, # 5 minute cache for user search
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
return response.get('json') if response else None
|
||||
else:
|
||||
# Fallback for standalone usage
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"User Search Error: {e}")
|
||||
|
|
@ -106,10 +129,11 @@ class NexusSearchThread(QThread):
|
|||
results_ready = pyqtSignal(list, str) # results, search_type
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self, query, search_type):
|
||||
def __init__(self, query, search_type, http_get_func=None):
|
||||
super().__init__()
|
||||
self.query = query
|
||||
self.search_type = search_type
|
||||
self.http_get_func = http_get_func
|
||||
|
||||
def run(self):
|
||||
"""Perform API search."""
|
||||
|
|
@ -118,7 +142,7 @@ class NexusSearchThread(QThread):
|
|||
|
||||
if self.search_type == "Items":
|
||||
# Search exchange items
|
||||
data = NexusAPIClient.fetch_exchange_items(self.query)
|
||||
data = NexusAPIClient.fetch_exchange_items(self.query, http_get_func=self.http_get_func)
|
||||
if data:
|
||||
if isinstance(data, list) and len(data) > 0 and 'name' in data[0]:
|
||||
# Already filtered items
|
||||
|
|
@ -137,7 +161,7 @@ class NexusSearchThread(QThread):
|
|||
|
||||
elif self.search_type == "Users":
|
||||
# Search users
|
||||
data = NexusAPIClient.search_users(self.query)
|
||||
data = NexusAPIClient.search_users(self.query, http_get_func=self.http_get_func)
|
||||
if data:
|
||||
results = data[:10]
|
||||
|
||||
|
|
@ -335,8 +359,8 @@ class NexusSearchPlugin(BasePlugin):
|
|||
self.current_results = []
|
||||
self.status_label.setText("Searching...")
|
||||
|
||||
# Start search thread
|
||||
self.search_thread = NexusSearchThread(query, search_type)
|
||||
# Start search thread with http_get function
|
||||
self.search_thread = NexusSearchThread(query, search_type, http_get_func=self.http_get)
|
||||
self.search_thread.results_ready.connect(self._on_results)
|
||||
self.search_thread.error_occurred.connect(self._on_error)
|
||||
self.search_thread.start()
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ EU-Utility - Universal Search Plugin
|
|||
Search across all Entropia Nexus entities - items, mobs, locations, blueprints, skills, etc.
|
||||
"""
|
||||
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
import webbrowser
|
||||
from PyQt6.QtWidgets import (
|
||||
|
|
@ -53,57 +51,65 @@ class NexusEntityAPI:
|
|||
}
|
||||
|
||||
@classmethod
|
||||
def search_entities(cls, entity_type, query, limit=50):
|
||||
def search_entities(cls, entity_type, query, limit=50, http_get_func=None):
|
||||
"""Search for entities of a specific type."""
|
||||
try:
|
||||
endpoint = cls.ENDPOINTS.get(entity_type, "/items")
|
||||
|
||||
# Build URL with query
|
||||
params = urllib.parse.urlencode({
|
||||
'q': query,
|
||||
'limit': limit,
|
||||
'fuzzy': 'true'
|
||||
})
|
||||
url = f"{cls.BASE_URL}{endpoint}?{params}"
|
||||
# Build URL with query params
|
||||
params = {'q': query, 'limit': limit, 'fuzzy': 'true'}
|
||||
query_string = '&'.join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{cls.BASE_URL}{endpoint}?{query_string}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'EU-Utility/1.0'
|
||||
}
|
||||
)
|
||||
if http_get_func:
|
||||
response = http_get_func(
|
||||
url,
|
||||
cache_ttl=300, # 5 minute cache
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'}
|
||||
)
|
||||
else:
|
||||
# Fallback for standalone usage
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
response = {'json': json.loads(resp.read().decode('utf-8'))}
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
return data if isinstance(data, list) else []
|
||||
data = response.get('json') if response else None
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
except Exception as e:
|
||||
print(f"API Error ({entity_type}): {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def universal_search(cls, query, limit=30):
|
||||
def universal_search(cls, query, limit=30, http_get_func=None):
|
||||
"""Universal search across all entity types."""
|
||||
try:
|
||||
params = urllib.parse.urlencode({
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
'fuzzy': 'true'
|
||||
})
|
||||
url = f"{cls.BASE_URL}/search?{params}"
|
||||
params = {'query': query, 'limit': limit, 'fuzzy': 'true'}
|
||||
query_string = '&'.join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{cls.BASE_URL}/search?{query_string}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'EU-Utility/1.0'
|
||||
}
|
||||
)
|
||||
if http_get_func:
|
||||
response = http_get_func(
|
||||
url,
|
||||
cache_ttl=300, # 5 minute cache
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'}
|
||||
)
|
||||
else:
|
||||
# Fallback for standalone usage
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
response = {'json': json.loads(resp.read().decode('utf-8'))}
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
return data if isinstance(data, list) else []
|
||||
data = response.get('json') if response else None
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Universal Search Error: {e}")
|
||||
|
|
@ -151,19 +157,20 @@ class UniversalSearchThread(QThread):
|
|||
results_ready = pyqtSignal(list, str)
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self, query, entity_type, universal=False):
|
||||
def __init__(self, query, entity_type, universal=False, http_get_func=None):
|
||||
super().__init__()
|
||||
self.query = query
|
||||
self.entity_type = entity_type
|
||||
self.universal = universal
|
||||
self.http_get_func = http_get_func
|
||||
|
||||
def run(self):
|
||||
"""Perform API search."""
|
||||
try:
|
||||
if self.universal:
|
||||
results = NexusEntityAPI.universal_search(self.query)
|
||||
results = NexusEntityAPI.universal_search(self.query, http_get_func=self.http_get_func)
|
||||
else:
|
||||
results = NexusEntityAPI.search_entities(self.entity_type, self.query)
|
||||
results = NexusEntityAPI.search_entities(self.entity_type, self.query, http_get_func=self.http_get_func)
|
||||
|
||||
self.results_ready.emit(results, self.entity_type)
|
||||
|
||||
|
|
@ -431,8 +438,11 @@ class UniversalSearchPlugin(BasePlugin):
|
|||
self.open_btn.setEnabled(False)
|
||||
self.status_label.setText(f"Searching for '{query}'...")
|
||||
|
||||
# Start search thread
|
||||
self.search_thread = UniversalSearchThread(query, entity_type, universal)
|
||||
# Start search thread with http_get function
|
||||
self.search_thread = UniversalSearchThread(
|
||||
query, entity_type, universal,
|
||||
http_get_func=self.http_get
|
||||
)
|
||||
self.search_thread.results_ready.connect(self._on_results)
|
||||
self.search_thread.error_occurred.connect(self._on_error)
|
||||
self.search_thread.start()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Nexus API Client integration.
|
||||
|
||||
Tests:
|
||||
1. NexusAPI singleton creation
|
||||
2. Search methods (items, mobs, all)
|
||||
3. PluginAPI integration
|
||||
4. Plugin access via self.api.nexus_search()
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from core.nexus_api import get_nexus_api, NexusAPI, search_items, search_mobs
|
||||
from core.plugin_api import get_api, PluginAPI
|
||||
|
||||
|
||||
def test_nexus_singleton():
|
||||
"""Test that NexusAPI is a singleton."""
|
||||
print("\n[Test 1] NexusAPI Singleton...")
|
||||
api1 = get_nexus_api()
|
||||
api2 = get_nexus_api()
|
||||
|
||||
assert api1 is api2, "NexusAPI should be singleton"
|
||||
assert isinstance(api1, NexusAPI), "Should return NexusAPI instance"
|
||||
print(" ✓ NexusAPI singleton works correctly")
|
||||
return True
|
||||
|
||||
|
||||
def test_plugin_api_nexus_methods():
|
||||
"""Test PluginAPI has Nexus methods."""
|
||||
print("\n[Test 2] PluginAPI Nexus Methods...")
|
||||
plugin_api = get_api()
|
||||
|
||||
# Check methods exist
|
||||
assert hasattr(plugin_api, 'register_nexus_service'), "Missing register_nexus_service"
|
||||
assert hasattr(plugin_api, 'nexus_search'), "Missing nexus_search"
|
||||
assert hasattr(plugin_api, 'nexus_get_item_details'), "Missing nexus_get_item_details"
|
||||
assert hasattr(plugin_api, 'nexus_get_market_data'), "Missing nexus_get_market_data"
|
||||
|
||||
print(" ✓ PluginAPI has all Nexus methods")
|
||||
return True
|
||||
|
||||
|
||||
def test_nexus_service_registration():
|
||||
"""Test Nexus service registration."""
|
||||
print("\n[Test 3] Nexus Service Registration...")
|
||||
plugin_api = get_api()
|
||||
nexus = get_nexus_api()
|
||||
|
||||
# Register service
|
||||
plugin_api.register_nexus_service(nexus)
|
||||
|
||||
# Verify it's in services
|
||||
assert 'nexus' in plugin_api.services, "Nexus service not registered"
|
||||
assert plugin_api.services['nexus'] is nexus, "Wrong service instance"
|
||||
|
||||
print(" ✓ Nexus service registered correctly")
|
||||
return True
|
||||
|
||||
|
||||
def test_nexus_search_via_plugin_api():
|
||||
"""Test nexus_search through PluginAPI."""
|
||||
print("\n[Test 4] Nexus Search via PluginAPI...")
|
||||
plugin_api = get_api()
|
||||
|
||||
# This will return [] because we don't have actual API access in test
|
||||
# but it shouldn't raise an exception
|
||||
try:
|
||||
results = plugin_api.nexus_search("ArMatrix", "items")
|
||||
assert isinstance(results, list), "Should return list"
|
||||
print(f" ✓ nexus_search returns list (got {len(results)} results)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ nexus_search failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_plugin_base_nexus_methods():
|
||||
"""Test that BasePlugin would have nexus_search method."""
|
||||
print("\n[Test 5] BasePlugin Nexus Access...")
|
||||
|
||||
# Import base plugin
|
||||
from plugins.base_plugin import BasePlugin
|
||||
|
||||
# Check method exists
|
||||
assert hasattr(BasePlugin, 'nexus_search'), "BasePlugin missing nexus_search"
|
||||
assert hasattr(BasePlugin, 'nexus_get_item_details'), "BasePlugin missing nexus_get_item_details"
|
||||
assert hasattr(BasePlugin, 'nexus_get_market_data'), "BasePlugin missing nexus_get_market_data"
|
||||
|
||||
print(" ✓ BasePlugin has all Nexus convenience methods")
|
||||
return True
|
||||
|
||||
|
||||
def test_nexus_api_configuration():
|
||||
"""Test NexusAPI configuration."""
|
||||
print("\n[Test 6] NexusAPI Configuration...")
|
||||
nexus = get_nexus_api()
|
||||
|
||||
# Check configuration constants
|
||||
assert nexus.BASE_URL == "https://api.entropianexus.com", "Wrong base URL"
|
||||
assert nexus.API_VERSION == "v1", "Wrong API version"
|
||||
assert nexus.MAX_REQUESTS_PER_SECOND == 5, "Wrong rate limit"
|
||||
assert nexus.MAX_RETRIES == 3, "Wrong retry count"
|
||||
|
||||
print(f" ✓ Configuration correct:")
|
||||
print(f" - Base URL: {nexus.BASE_URL}")
|
||||
print(f" - Version: {nexus.API_VERSION}")
|
||||
print(f" - Rate limit: {nexus.MAX_REQUESTS_PER_SECOND} req/sec")
|
||||
print(f" - Max retries: {nexus.MAX_RETRIES}")
|
||||
return True
|
||||
|
||||
|
||||
def test_nexus_api_methods_exist():
|
||||
"""Test all NexusAPI methods exist."""
|
||||
print("\n[Test 7] NexusAPI Methods...")
|
||||
nexus = get_nexus_api()
|
||||
|
||||
required_methods = [
|
||||
'search_items',
|
||||
'search_mobs',
|
||||
'search_all',
|
||||
'get_item_details',
|
||||
'get_market_data',
|
||||
'clear_cache',
|
||||
'is_available'
|
||||
]
|
||||
|
||||
for method in required_methods:
|
||||
assert hasattr(nexus, method), f"Missing method: {method}"
|
||||
assert callable(getattr(nexus, method)), f"Not callable: {method}"
|
||||
|
||||
print(f" ✓ All {len(required_methods)} required methods present")
|
||||
return True
|
||||
|
||||
|
||||
def simulate_plugin_usage():
|
||||
"""Simulate how a plugin would use the Nexus API."""
|
||||
print("\n[Test 8] Simulating Plugin Usage...")
|
||||
|
||||
# Create a mock plugin class
|
||||
class MockPlugin:
|
||||
def __init__(self):
|
||||
self.api = get_api()
|
||||
|
||||
def search_items(self, query):
|
||||
"""Plugin calls nexus_search via API."""
|
||||
return self.api.nexus_search(query, "items")
|
||||
|
||||
def get_item_info(self, item_id):
|
||||
"""Plugin gets item details."""
|
||||
return self.api.nexus_get_item_details(item_id)
|
||||
|
||||
# Create plugin and test
|
||||
plugin = MockPlugin()
|
||||
|
||||
# These should work without errors (returning empty/None since no API key)
|
||||
try:
|
||||
results = plugin.search_items("ArMatrix")
|
||||
print(f" ✓ Plugin can call nexus_search (returned {len(results)} results)")
|
||||
|
||||
details = plugin.get_item_info("test_item")
|
||||
print(f" ✓ Plugin can call nexus_get_item_details (returned {details})")
|
||||
|
||||
# Test the exact call from requirements
|
||||
results2 = plugin.api.nexus_search("ArMatrix", "items")
|
||||
print(f" ✓ Direct API call works: self.api.nexus_search('ArMatrix', 'items')")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Plugin usage failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all tests."""
|
||||
print("=" * 60)
|
||||
print("Nexus API Client Integration Tests")
|
||||
print("=" * 60)
|
||||
|
||||
tests = [
|
||||
test_nexus_singleton,
|
||||
test_plugin_api_nexus_methods,
|
||||
test_nexus_service_registration,
|
||||
test_nexus_search_via_plugin_api,
|
||||
test_plugin_base_nexus_methods,
|
||||
test_nexus_api_configuration,
|
||||
test_nexus_api_methods_exist,
|
||||
simulate_plugin_usage
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
if test():
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ✗ Test failed with exception: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failed += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
print("=" * 60)
|
||||
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
|
|
@ -0,0 +1 @@
|
|||
pyperclip>=1.8.0
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
"""
|
||||
Test suite for Clipboard Manager
|
||||
|
||||
Run with: python -m pytest tests/test_clipboard.py -v
|
||||
Or: python tests/test_clipboard.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from core.clipboard import ClipboardManager, ClipboardEntry, get_clipboard_manager
|
||||
|
||||
|
||||
class TestClipboardManager:
|
||||
"""Tests for ClipboardManager functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup for each test - use temp file for history."""
|
||||
# Reset singleton for clean tests
|
||||
ClipboardManager._instance = None
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.history_file = os.path.join(self.temp_dir, "test_history.json")
|
||||
self.manager = ClipboardManager(history_file=self.history_file)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Cleanup after each test."""
|
||||
self.manager.stop_monitoring()
|
||||
ClipboardManager._instance = None
|
||||
# Clean up temp file
|
||||
if os.path.exists(self.history_file):
|
||||
os.remove(self.history_file)
|
||||
os.rmdir(self.temp_dir)
|
||||
|
||||
def test_singleton(self):
|
||||
"""Test that ClipboardManager is a singleton."""
|
||||
manager1 = get_clipboard_manager()
|
||||
manager2 = get_clipboard_manager()
|
||||
assert manager1 is manager2
|
||||
|
||||
def test_copy_and_paste(self):
|
||||
"""Test basic copy and paste functionality."""
|
||||
test_text = "Hello, Clipboard!"
|
||||
|
||||
# Copy
|
||||
result = self.manager.copy(test_text)
|
||||
assert result is True
|
||||
|
||||
# Paste
|
||||
pasted = self.manager.paste()
|
||||
assert pasted == test_text
|
||||
|
||||
def test_history_tracking(self):
|
||||
"""Test that copies are tracked in history."""
|
||||
texts = ["First", "Second", "Third"]
|
||||
|
||||
for text in texts:
|
||||
self.manager.copy(text, source="test")
|
||||
time.sleep(0.05) # Small delay for timestamp differences
|
||||
|
||||
history = self.manager.get_history()
|
||||
|
||||
# Should have 3 entries (most recent first)
|
||||
assert len(history) == 3
|
||||
assert history[0].content == "Third"
|
||||
assert history[1].content == "Second"
|
||||
assert history[2].content == "First"
|
||||
|
||||
def test_history_limit(self):
|
||||
"""Test that history is limited to MAX_HISTORY_SIZE."""
|
||||
# Copy more than max entries
|
||||
for i in range(110):
|
||||
self.manager.copy(f"Entry {i}")
|
||||
|
||||
history = self.manager.get_history()
|
||||
assert len(history) == 100 # MAX_HISTORY_SIZE
|
||||
|
||||
def test_no_duplicate_consecutive_entries(self):
|
||||
"""Test that duplicate consecutive entries aren't added."""
|
||||
self.manager.copy("Same text")
|
||||
self.manager.copy("Same text")
|
||||
self.manager.copy("Same text")
|
||||
|
||||
history = self.manager.get_history()
|
||||
assert len(history) == 1
|
||||
|
||||
def test_history_persistence(self):
|
||||
"""Test that history is saved and loaded correctly."""
|
||||
# Add some entries
|
||||
self.manager.copy("Persistent", source="test")
|
||||
time.sleep(0.1) # Let async save complete
|
||||
|
||||
# Force save
|
||||
self.manager._save_history()
|
||||
|
||||
# Verify file exists and has content
|
||||
assert os.path.exists(self.history_file)
|
||||
|
||||
with open(self.history_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
assert 'history' in data
|
||||
assert len(data['history']) >= 1
|
||||
|
||||
def test_clear_history(self):
|
||||
"""Test clearing history."""
|
||||
self.manager.copy("To be cleared")
|
||||
assert len(self.manager.get_history()) == 1
|
||||
|
||||
self.manager.clear_history()
|
||||
assert len(self.manager.get_history()) == 0
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Test thread-safe operations."""
|
||||
errors = []
|
||||
|
||||
def copy_worker(text):
|
||||
try:
|
||||
for i in range(10):
|
||||
self.manager.copy(f"{text}_{i}")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
# Run multiple threads
|
||||
threads = []
|
||||
for i in range(5):
|
||||
t = threading.Thread(target=copy_worker, args=(f"Thread{i}",))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0, f"Thread errors: {errors}"
|
||||
|
||||
def test_has_changed(self):
|
||||
"""Test clipboard change detection."""
|
||||
# Initial state
|
||||
self.manager.copy("Initial")
|
||||
assert self.manager.has_changed() is False # Same content
|
||||
|
||||
# Manually simulate external change (mock by copying new content)
|
||||
self.manager.copy("Changed")
|
||||
# Note: has_changed() compares to _last_content which is updated on copy
|
||||
# So we need to simulate external change differently
|
||||
old_content = self.manager._last_content
|
||||
self.manager._last_content = "Different"
|
||||
assert self.manager.has_changed() is True
|
||||
|
||||
def test_get_history_limit(self):
|
||||
"""Test getting limited history."""
|
||||
for i in range(10):
|
||||
self.manager.copy(f"Entry {i}")
|
||||
|
||||
limited = self.manager.get_history(limit=5)
|
||||
assert len(limited) == 5
|
||||
|
||||
def test_entry_metadata(self):
|
||||
"""Test that entries have correct metadata."""
|
||||
self.manager.copy("Test content", source="my_plugin")
|
||||
|
||||
history = self.manager.get_history()
|
||||
entry = history[0]
|
||||
|
||||
assert entry.content == "Test content"
|
||||
assert entry.source == "my_plugin"
|
||||
assert entry.timestamp is not None
|
||||
|
||||
def test_monitoring(self):
|
||||
"""Test clipboard monitoring."""
|
||||
changes = []
|
||||
|
||||
def on_change(content):
|
||||
changes.append(content)
|
||||
|
||||
self.manager.start_monitoring(callback=on_change)
|
||||
assert self.manager.is_monitoring() is True
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
self.manager.stop_monitoring()
|
||||
assert self.manager.is_monitoring() is False
|
||||
|
||||
def test_stats(self):
|
||||
"""Test getting stats."""
|
||||
self.manager.copy("Stat test")
|
||||
stats = self.manager.get_stats()
|
||||
|
||||
assert 'history_count' in stats
|
||||
assert 'max_history' in stats
|
||||
assert 'is_available' in stats
|
||||
assert stats['max_history'] == 100
|
||||
|
||||
|
||||
class TestClipboardEntry:
|
||||
"""Tests for ClipboardEntry dataclass."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test conversion to dictionary."""
|
||||
entry = ClipboardEntry(
|
||||
content="Test",
|
||||
timestamp="2024-01-01T00:00:00",
|
||||
source="test_plugin"
|
||||
)
|
||||
|
||||
d = entry.to_dict()
|
||||
assert d['content'] == "Test"
|
||||
assert d['timestamp'] == "2024-01-01T00:00:00"
|
||||
assert d['source'] == "test_plugin"
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test creation from dictionary."""
|
||||
data = {
|
||||
'content': 'Test',
|
||||
'timestamp': '2024-01-01T00:00:00',
|
||||
'source': 'test_plugin'
|
||||
}
|
||||
|
||||
entry = ClipboardEntry.from_dict(data)
|
||||
assert entry.content == "Test"
|
||||
assert entry.timestamp == "2024-01-01T00:00:00"
|
||||
assert entry.source == "test_plugin"
|
||||
|
||||
|
||||
def run_manual_test():
|
||||
"""Run a manual demonstration of clipboard features."""
|
||||
print("\n" + "="*60)
|
||||
print("MANUAL CLIPBOARD MANAGER TEST")
|
||||
print("="*60)
|
||||
|
||||
# Reset singleton
|
||||
ClipboardManager._instance = None
|
||||
manager = get_clipboard_manager()
|
||||
|
||||
print(f"\n1. Clipboard available: {manager.is_available()}")
|
||||
|
||||
# Copy test
|
||||
print("\n2. Copying text to clipboard...")
|
||||
manager.copy("Hello from EU-Utility!")
|
||||
print(" Copied: 'Hello from EU-Utility!'")
|
||||
|
||||
# Paste test
|
||||
print("\n3. Reading from clipboard...")
|
||||
pasted = manager.paste()
|
||||
print(f" Pasted: '{pasted}'")
|
||||
|
||||
# History test
|
||||
print("\n4. Adding more entries...")
|
||||
manager.copy("Coordinates: 100, 200", source="test")
|
||||
manager.copy("GPS: 45.5231, -122.6765", source="test")
|
||||
manager.copy("Position: x=50, y=100", source="test")
|
||||
|
||||
print("\n5. History (last 5):")
|
||||
for i, entry in enumerate(manager.get_history(limit=5), 1):
|
||||
print(f" {i}. [{entry.source}] {entry.content[:40]}")
|
||||
|
||||
print("\n6. Stats:")
|
||||
stats = manager.get_stats()
|
||||
for key, value in stats.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("TEST COMPLETE")
|
||||
print("="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_manual_test()
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Test for EU-Utility main application
|
||||
|
||||
Run with: python tests/test_main.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from main import EUUtility
|
||||
from core.clipboard import ClipboardManager
|
||||
|
||||
|
||||
def test_initialization():
|
||||
"""Test that EU-Utility initializes correctly."""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING EU-Utility INITIALIZATION")
|
||||
print("="*60)
|
||||
|
||||
# Reset singleton
|
||||
ClipboardManager._instance = None
|
||||
|
||||
# Create app
|
||||
print("\n1. Creating EU-Utility instance...")
|
||||
app = EUUtility()
|
||||
print(" ✓ Instance created")
|
||||
|
||||
# Initialize (without monitoring to avoid thread issues)
|
||||
print("\n2. Initializing (without monitoring)...")
|
||||
app.initialize(auto_start_clipboard_monitor=False)
|
||||
print(" ✓ Initialized")
|
||||
|
||||
# Check clipboard manager
|
||||
print("\n3. Checking clipboard manager...")
|
||||
clipboard = app.get_clipboard_manager()
|
||||
if clipboard:
|
||||
print(f" ✓ Clipboard manager ready")
|
||||
print(f" - Available: {clipboard.is_available()}")
|
||||
print(f" - History: {len(clipboard.get_history())} entries")
|
||||
else:
|
||||
print(" ✗ Clipboard manager not found")
|
||||
|
||||
# Check plugins
|
||||
print("\n4. Checking plugins...")
|
||||
plugins = app.plugin_api.get_all_plugins()
|
||||
print(f" - Loaded plugins: {len(plugins)}")
|
||||
for plugin in plugins:
|
||||
print(f" - {plugin.name} v{plugin.version}")
|
||||
|
||||
# Stop
|
||||
print("\n5. Stopping...")
|
||||
app.stop()
|
||||
print(" ✓ Stopped cleanly")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("INITIALIZATION TEST PASSED")
|
||||
print("="*60)
|
||||
|
||||
|
||||
def test_clipboard_in_main():
|
||||
"""Test clipboard functionality through main app."""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING CLIPBOARD THROUGH MAIN APP")
|
||||
print("="*60)
|
||||
|
||||
# Reset singleton
|
||||
ClipboardManager._instance = None
|
||||
|
||||
app = EUUtility()
|
||||
app.initialize(auto_start_clipboard_monitor=False)
|
||||
|
||||
clipboard = app.get_clipboard_manager()
|
||||
|
||||
if clipboard and clipboard.is_available():
|
||||
print("\n1. Testing copy/paste...")
|
||||
clipboard.copy("Test from main", source="main_test")
|
||||
pasted = clipboard.paste()
|
||||
assert pasted == "Test from main", f"Expected 'Test from main', got '{pasted}'"
|
||||
print(" ✓ Copy/paste works")
|
||||
|
||||
print("\n2. Testing history...")
|
||||
history = clipboard.get_history()
|
||||
assert len(history) > 0, "History should have entries"
|
||||
assert history[0].source == "main_test", "Source should be tracked"
|
||||
print(f" ✓ History tracking works ({len(history)} entries)")
|
||||
|
||||
print("\n3. Testing stats...")
|
||||
stats = clipboard.get_stats()
|
||||
assert 'history_count' in stats
|
||||
print(f" ✓ Stats available: {stats}")
|
||||
else:
|
||||
print(" ⚠ Clipboard not available (pyperclip may not be installed)")
|
||||
|
||||
app.stop()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("CLIPBOARD TEST COMPLETE")
|
||||
print("="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_initialization()
|
||||
test_clipboard_in_main()
|
||||
print("\n✅ ALL TESTS PASSED")
|
||||
except AssertionError as e:
|
||||
print(f"\n❌ TEST FAILED: {e}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
"""
|
||||
Test suite for PluginAPI and BasePlugin clipboard integration
|
||||
|
||||
Run with: python tests/test_plugin_api.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from core.plugin_api import PluginAPI
|
||||
from core.base_plugin import BasePlugin
|
||||
from core.clipboard import ClipboardManager, get_clipboard_manager
|
||||
|
||||
|
||||
class MockPlugin(BasePlugin):
|
||||
"""Mock plugin for testing."""
|
||||
name = "mock_plugin"
|
||||
description = "Mock plugin for testing"
|
||||
version = "1.0.0"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
|
||||
def on_start(self):
|
||||
self.started = True
|
||||
|
||||
def on_stop(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class TestPluginAPI:
|
||||
"""Tests for PluginAPI functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup for each test."""
|
||||
ClipboardManager._instance = None
|
||||
self.api = PluginAPI()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Cleanup after each test."""
|
||||
ClipboardManager._instance = None
|
||||
|
||||
def test_register_clipboard_service(self):
|
||||
"""Test registering clipboard service."""
|
||||
manager = self.api.register_clipboard_service()
|
||||
|
||||
assert manager is not None
|
||||
assert self.api.get_clipboard_manager() is manager
|
||||
|
||||
def test_load_plugin_injects_clipboard(self):
|
||||
"""Test that loading a plugin injects clipboard manager."""
|
||||
# Register clipboard first
|
||||
self.api.register_clipboard_service()
|
||||
|
||||
# Load plugin
|
||||
plugin = self.api.load_plugin(MockPlugin)
|
||||
|
||||
# Plugin should have clipboard manager
|
||||
assert plugin._clipboard_manager is not None
|
||||
assert plugin._clipboard_manager is self.api.get_clipboard_manager()
|
||||
|
||||
def test_copy_to_clipboard_via_plugin(self):
|
||||
"""Test copying via plugin method."""
|
||||
self.api.register_clipboard_service()
|
||||
plugin = self.api.load_plugin(MockPlugin)
|
||||
|
||||
# Copy via plugin
|
||||
result = plugin.copy_to_clipboard("Test via plugin")
|
||||
assert result is True
|
||||
|
||||
# Verify via manager
|
||||
assert self.api.get_clipboard_manager().paste() == "Test via plugin"
|
||||
|
||||
def test_paste_via_plugin(self):
|
||||
"""Test pasting via plugin method."""
|
||||
self.api.register_clipboard_service()
|
||||
plugin = self.api.load_plugin(MockPlugin)
|
||||
|
||||
# Copy via manager
|
||||
self.api.get_clipboard_manager().copy("Original text")
|
||||
|
||||
# Paste via plugin
|
||||
result = plugin.paste_from_clipboard()
|
||||
assert result == "Original text"
|
||||
|
||||
def test_get_history_via_plugin(self):
|
||||
"""Test getting history via plugin method."""
|
||||
self.api.register_clipboard_service()
|
||||
plugin = self.api.load_plugin(MockPlugin)
|
||||
|
||||
# Add entries
|
||||
plugin.copy_to_clipboard("Entry 1")
|
||||
plugin.copy_to_clipboard("Entry 2")
|
||||
|
||||
# Get history via plugin
|
||||
history = plugin.get_clipboard_history()
|
||||
|
||||
assert len(history) == 2
|
||||
assert history[0]['content'] == "Entry 2"
|
||||
assert history[1]['content'] == "Entry 1"
|
||||
assert history[0]['source'] == "mock_plugin"
|
||||
|
||||
def test_start_stop_all(self):
|
||||
"""Test starting and stopping all plugins."""
|
||||
self.api.register_clipboard_service()
|
||||
plugin = self.api.load_plugin(MockPlugin)
|
||||
|
||||
# Start all
|
||||
self.api.start_all()
|
||||
assert plugin.started is True
|
||||
assert plugin.is_initialized() is True
|
||||
|
||||
# Stop all
|
||||
self.api.stop_all()
|
||||
assert plugin.stopped is True
|
||||
assert plugin.is_initialized() is False
|
||||
|
||||
def test_get_plugin(self):
|
||||
"""Test getting a plugin by name."""
|
||||
self.api.register_clipboard_service()
|
||||
self.api.load_plugin(MockPlugin)
|
||||
|
||||
found = self.api.get_plugin("mock_plugin")
|
||||
assert found is not None
|
||||
assert found.name == "mock_plugin"
|
||||
|
||||
not_found = self.api.get_plugin("nonexistent")
|
||||
assert not_found is None
|
||||
|
||||
def test_get_plugin_info(self):
|
||||
"""Test getting plugin information."""
|
||||
self.api.register_clipboard_service()
|
||||
self.api.load_plugin(MockPlugin)
|
||||
|
||||
info = self.api.get_plugin_info()
|
||||
|
||||
assert len(info) == 1
|
||||
assert info[0]['name'] == "mock_plugin"
|
||||
assert info[0]['version'] == "1.0.0"
|
||||
|
||||
|
||||
def run_integration_test():
|
||||
"""Run an integration test demonstrating all features."""
|
||||
print("\n" + "="*60)
|
||||
print("PLUGIN API CLIPBOARD INTEGRATION TEST")
|
||||
print("="*60)
|
||||
|
||||
# Reset singleton
|
||||
ClipboardManager._instance = None
|
||||
|
||||
# Create API
|
||||
api = PluginAPI()
|
||||
|
||||
# Register clipboard service
|
||||
print("\n1. Registering clipboard service...")
|
||||
clipboard = api.register_clipboard_service()
|
||||
print(f" ✓ Clipboard service registered")
|
||||
print(f" - Available: {clipboard.is_available()}")
|
||||
|
||||
# Create a test plugin
|
||||
print("\n2. Creating and loading test plugin...")
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
name = "integration_test_plugin"
|
||||
description = "Integration test"
|
||||
version = "1.0"
|
||||
|
||||
def on_start(self):
|
||||
print(f" [{self.name}] Started")
|
||||
|
||||
def on_stop(self):
|
||||
print(f" [{self.name}] Stopped")
|
||||
|
||||
def copy_coordinates(self, x, y):
|
||||
"""Copy coordinates to clipboard."""
|
||||
coords = f"{x}, {y}"
|
||||
success = self.copy_to_clipboard(coords)
|
||||
print(f" [{self.name}] Copied coordinates: {coords}")
|
||||
return success
|
||||
|
||||
def read_user_paste(self):
|
||||
"""Read pasted value from user."""
|
||||
value = self.paste_from_clipboard()
|
||||
print(f" [{self.name}] Read clipboard: '{value}'")
|
||||
return value
|
||||
|
||||
def show_history(self):
|
||||
"""Show clipboard history."""
|
||||
history = self.get_clipboard_history(limit=5)
|
||||
print(f" [{self.name}] Recent clipboard entries:")
|
||||
for i, entry in enumerate(history, 1):
|
||||
content = entry['content'][:30]
|
||||
if len(entry['content']) > 30:
|
||||
content += "..."
|
||||
print(f" {i}. [{entry.get('source', '?')}] {content}")
|
||||
|
||||
plugin = api.load_plugin(TestPlugin)
|
||||
print(f" ✓ Plugin loaded: {plugin.name}")
|
||||
|
||||
# Start plugins
|
||||
print("\n3. Starting plugins...")
|
||||
api.start_all()
|
||||
|
||||
# Test: Copy coordinates
|
||||
print("\n4. Testing: Copy coordinates to clipboard...")
|
||||
plugin.copy_coordinates(100, 200)
|
||||
plugin.copy_coordinates(45.5231, -122.6765)
|
||||
|
||||
# Test: Read pasted values
|
||||
print("\n5. Testing: Read pasted values...")
|
||||
plugin.read_user_paste()
|
||||
|
||||
# Test: Access clipboard history
|
||||
print("\n6. Testing: Access clipboard history...")
|
||||
plugin.show_history()
|
||||
|
||||
# Check stats
|
||||
print("\n7. Clipboard stats:")
|
||||
stats = clipboard.get_stats()
|
||||
for key, value in stats.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
# Stop plugins
|
||||
print("\n8. Stopping plugins...")
|
||||
api.stop_all()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("INTEGRATION TEST COMPLETE")
|
||||
print("="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_integration_test()
|
||||
Loading…
Reference in New Issue