feat: Add system tray, settings window, and fix window detection for 'Entropia Universe Client'

This commit is contained in:
devmatrix 2026-02-17 00:38:45 +00:00
parent b6d33127e4
commit b2856639dd
3 changed files with 431 additions and 38 deletions

View File

@ -45,6 +45,10 @@ from premium.widgets.dashboard_widget import DashboardWidget
from premium.eu_integration.game_client import GameClient from premium.eu_integration.game_client import GameClient
from premium.eu_integration.log_parser import LogParser from premium.eu_integration.log_parser import LogParser
from premium.eu_integration.events import GameEvent, LootEvent, SkillEvent from premium.eu_integration.events import GameEvent, LootEvent, SkillEvent
from premium.eu_integration.window_tracker import WindowTracker
# UI
from premium.ui.tray import TrayIcon, SettingsWindow
class EUUtilityApp: class EUUtilityApp:
@ -60,16 +64,30 @@ class EUUtilityApp:
def __init__(self, config_path=None): def __init__(self, config_path=None):
self.config_path = config_path self.config_path = config_path
self.config = {}
self.plugin_manager = None self.plugin_manager = None
self.state_store = None self.state_store = None
self.event_bus = None self.event_bus = None
self.game_client = None self.game_client = None
self.dashboard = None self.dashboard = None
self.tray_icon = None
self.settings_window = None
self.qt_app = None
self._initialized = False self._initialized = False
self._running = False
def initialize(self): def initialize(self):
"""Initialize all subsystems.""" """Initialize all subsystems."""
from pathlib import Path from pathlib import Path
import json
# Load config
if self.config_path:
try:
with open(self.config_path, 'r') as f:
self.config = json.load(f)
except:
self.config = {}
# Create event bus # Create event bus
self.event_bus = EventBus() self.event_bus = EventBus()
@ -95,18 +113,36 @@ class EUUtilityApp:
) )
# Create game client # Create game client
self.game_client = GameClient(event_bus=self.event_bus) self.game_client = GameClient(
game_path=Path(self.config.get('game_path', '')) if self.config.get('game_path') else None,
event_bus=self.event_bus
)
self._initialized = True self._initialized = True
def run(self): def run(self):
"""Run the application.""" """Run the application with Qt GUI."""
from PyQt6.QtWidgets import QApplication
import sys
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
# Start event bus first (captures main event loop) # Create Qt Application
self.qt_app = QApplication(sys.argv)
self.qt_app.setQuitOnLastWindowClosed(False) # Keep running when window closed
# Start event bus
self.event_bus.start() self.event_bus.start()
# Create system tray icon
self.tray_icon = TrayIcon()
self.tray_icon.toggle_overlay.connect(self._toggle_overlay)
self.tray_icon.show_settings.connect(self._show_settings)
self.tray_icon.show_dashboard.connect(self._show_dashboard)
self.tray_icon.quit_requested.connect(self._quit)
self.tray_icon.show()
# Discover and load plugins # Discover and load plugins
self.plugin_manager.discover_all() self.plugin_manager.discover_all()
self.plugin_manager.load_all(auto_activate=True) self.plugin_manager.load_all(auto_activate=True)
@ -114,12 +150,70 @@ class EUUtilityApp:
# Start game client # Start game client
self.game_client.start() self.game_client.start()
# Run main loop self._running = True
# Update tray status periodically
self._update_status_timer()
# Run Qt event loop
try: try:
self._main_loop() sys.exit(self.qt_app.exec())
except KeyboardInterrupt: except:
self.shutdown() self.shutdown()
def _update_status_timer(self):
"""Update tray status periodically."""
if not self._running:
return
# Update status
if self.game_client.is_connected:
status = "Connected"
else:
status = "Waiting for game..."
if self.tray_icon:
self.tray_icon.update_status(status)
# Schedule next update
from PyQt6.QtCore import QTimer
QTimer.singleShot(5000, self._update_status_timer)
def _toggle_overlay(self):
"""Toggle overlay visibility."""
# TODO: Implement overlay toggle
if self.tray_icon:
self.tray_icon.notify(
"EU-Utility",
"Overlay toggle not yet implemented",
QSystemTrayIcon.MessageIcon.Warning
)
def _show_settings(self):
"""Show settings window."""
if not self.settings_window:
self.settings_window = SettingsWindow(self.config)
self.settings_window.show()
self.settings_window.raise_()
def _show_dashboard(self):
"""Show dashboard window."""
if self.tray_icon:
self.tray_icon.notify(
"EU-Utility",
"Dashboard not yet implemented",
QSystemTrayIcon.MessageIcon.Warning
)
def _quit(self):
"""Quit the application."""
from PyQt6.QtWidgets import QSystemTrayIcon
self._running = False
self.shutdown()
if self.qt_app:
self.qt_app.quit()
def shutdown(self): def shutdown(self):
"""Shutdown the application gracefully.""" """Shutdown the application gracefully."""
if self.game_client: if self.game_client:
@ -131,8 +225,6 @@ class EUUtilityApp:
def _root_reducer(self, state, action): def _root_reducer(self, state, action):
"""Root state reducer.""" """Root state reducer."""
# Default reducer - just returns state
# Plugins can register their own reducers
return state or {} return state or {}
def _get_initial_state(self): def _get_initial_state(self):
@ -151,12 +243,6 @@ class EUUtilityApp:
'plugins': {}, 'plugins': {},
} }
def _main_loop(self):
"""Main application loop."""
import time
while True:
time.sleep(0.1)
__all__ = [ __all__ = [
# Version # Version
@ -175,4 +261,7 @@ __all__ = [
'Widget', 'WidgetConfig', 'Dashboard', 'Widget', 'WidgetConfig', 'Dashboard',
# EU Integration # EU Integration
'GameClient', 'LogParser', 'GameEvent', 'LootEvent', 'SkillEvent', 'GameClient', 'LogParser', 'GameEvent', 'LootEvent', 'SkillEvent',
'WindowTracker',
# UI
'TrayIcon', 'SettingsWindow',
] ]

View File

@ -87,11 +87,24 @@ class WindowTracker:
if self._process_id: if self._process_id:
_, pid = win32process.GetWindowThreadProcessId(hwnd) _, pid = win32process.GetWindowThreadProcessId(hwnd)
return pid == self._process_id if pid == self._process_id:
else: return True
# Check window title
title = win32gui.GetWindowText(hwnd) # Check window title - supports multiple patterns
return 'entropia' in title.lower() title = win32gui.GetWindowText(hwnd)
title_lower = title.lower()
# Match various EU window titles:
# - "Entropia Universe Client (64 bit)"
# - "Entropia Universe Client (64 bit) - Planet Calypso"
# - "Entropia.exe"
patterns = [
'entropia universe client',
'entropia.exe',
'entropia universe'
]
return any(pattern in title_lower for pattern in patterns)
except ImportError: except ImportError:
pass pass

291
premium/ui/tray.py Normal file
View File

@ -0,0 +1,291 @@
"""
System Tray Integration for EU-Utility
=======================================
Provides system tray icon with menu for controlling the application.
"""
import sys
from typing import Callable, Optional
from PyQt6.QtWidgets import (
QSystemTrayIcon, QMenu, QAction, QWidget, QApplication,
QMessageBox, QInputDialog
)
from PyQt6.QtCore import pyqtSignal, QObject
from PyQt6.QtGui import QIcon, QAction as QActionGui
class TrayIcon(QObject):
"""System tray icon with menu.
Signals:
toggle_overlay: Emitted when overlay toggle is clicked
show_settings: Emitted when settings is clicked
show_dashboard: Emitted when dashboard is clicked
quit: Emitted when quit is clicked
"""
toggle_overlay = pyqtSignal()
show_settings = pyqtSignal()
show_dashboard = pyqtSignal()
quit_requested = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.tray_icon: Optional[QSystemTrayIcon] = None
self.menu: Optional[QMenu] = None
self._setup_tray()
def _setup_tray(self):
"""Setup the system tray icon."""
# Create tray icon
self.tray_icon = QSystemTrayIcon(self.parent())
# Try to use a custom icon, fallback to system icon
icon = self._load_icon()
self.tray_icon.setIcon(icon)
self.tray_icon.setToolTip("EU-Utility - Entropia Universe Overlay")
# Create menu
self.menu = QMenu()
self._build_menu()
self.tray_icon.setContextMenu(self.menu)
# Handle left click
self.tray_icon.activated.connect(self._on_activated)
def _load_icon(self) -> QIcon:
"""Load tray icon."""
from pathlib import Path
# Try custom icon first
icon_paths = [
Path(__file__).parent.parent.parent / "assets" / "icon.ico",
Path(__file__).parent.parent.parent / "assets" / "icon.png",
]
for path in icon_paths:
if path.exists():
return QIcon(str(path))
# Fallback to system icon
return QIcon.fromTheme("applications-games")
def _build_menu(self):
"""Build the context menu."""
# Toggle Overlay
toggle_action = QActionGui("Toggle Overlay (Ctrl+Shift+B)", self)
toggle_action.triggered.connect(self.toggle_overlay.emit)
self.menu.addAction(toggle_action)
self.menu.addSeparator()
# Dashboard
dashboard_action = QActionGui("Open Dashboard", self)
dashboard_action.triggered.connect(self.show_dashboard.emit)
self.menu.addAction(dashboard_action)
# Settings
settings_action = QActionGui("Settings...", self)
settings_action.triggered.connect(self.show_settings.emit)
self.menu.addAction(settings_action)
self.menu.addSeparator()
# Status
self.status_action = QActionGui("Status: Running", self)
self.status_action.setEnabled(False)
self.menu.addAction(self.status_action)
self.menu.addSeparator()
# Quit
quit_action = QActionGui("Quit", self)
quit_action.triggered.connect(self.quit_requested.emit)
self.menu.addAction(quit_action)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason):
"""Handle tray icon activation."""
if reason == QSystemTrayIcon.ActivationReason.Trigger:
# Left click - toggle overlay
self.toggle_overlay.emit()
elif reason == QSystemTrayIcon.ActivationReason.Context:
# Right click - show menu (handled automatically)
pass
def show(self):
"""Show the tray icon."""
if self.tray_icon:
self.tray_icon.show()
self.tray_icon.showMessage(
"EU-Utility",
"Application is running in system tray.\n"
"Press Ctrl+Shift+B to toggle overlay.",
QSystemTrayIcon.MessageIcon.Information,
3000
)
def hide(self):
"""Hide the tray icon."""
if self.tray_icon:
self.tray_icon.hide()
def update_status(self, status: str):
"""Update the status menu item."""
if hasattr(self, 'status_action'):
self.status_action.setText(f"Status: {status}")
def notify(self, title: str, message: str, icon=QSystemTrayIcon.MessageIcon.Information):
"""Show a tray notification."""
if self.tray_icon and self.tray_icon.isVisible():
self.tray_icon.showMessage(title, message, icon, 3000)
class SettingsWindow(QWidget):
"""Desktop settings window."""
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self.config = config
self._setup_ui()
def _setup_ui(self):
"""Setup the settings UI."""
from PyQt6.QtWidgets import (
QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QCheckBox, QComboBox, QSpinBox,
QGroupBox, QTabWidget, QFileDialog
)
self.setWindowTitle("EU-Utility Settings")
self.setMinimumSize(500, 400)
layout = QVBoxLayout(self)
# Tabs
tabs = QTabWidget()
layout.addWidget(tabs)
# General tab
general_tab = QWidget()
general_layout = QVBoxLayout(general_tab)
# Game Path
path_group = QGroupBox("Game Path")
path_layout = QHBoxLayout(path_group)
self.path_edit = QLineEdit(self.config.get('game_path', ''))
path_layout.addWidget(self.path_edit)
browse_btn = QPushButton("Browse...")
browse_btn.clicked.connect(self._browse_game_path)
path_layout.addWidget(browse_btn)
general_layout.addWidget(path_group)
# Overlay Settings
overlay_group = QGroupBox("Overlay Settings")
overlay_layout = QVBoxLayout(overlay_group)
self.overlay_checkbox = QCheckBox("Enable Overlay")
self.overlay_checkbox.setChecked(self.config.get('overlay_enabled', True))
overlay_layout.addWidget(self.overlay_checkbox)
# Hotkey
hotkey_layout = QHBoxLayout()
hotkey_layout.addWidget(QLabel("Toggle Hotkey:"))
self.hotkey_edit = QLineEdit(self.config.get('hotkey', 'ctrl+shift+b'))
hotkey_layout.addWidget(self.hotkey_edit)
overlay_layout.addLayout(hotkey_layout)
# Overlay Mode
mode_layout = QHBoxLayout()
mode_layout.addWidget(QLabel("Overlay Mode:"))
self.mode_combo = QComboBox()
self.mode_combo.addItems([
'overlay_toggle',
'overlay_game',
'overlay_always',
'overlay_temp'
])
self.mode_combo.setCurrentText(self.config.get('overlay_mode', 'overlay_toggle'))
mode_layout.addWidget(self.mode_combo)
overlay_layout.addLayout(mode_layout)
general_layout.addWidget(overlay_group)
# Theme Settings
theme_group = QGroupBox("Appearance")
theme_layout = QVBoxLayout(theme_group)
theme_layout.addWidget(QLabel("Theme:"))
self.theme_combo = QComboBox()
self.theme_combo.addItems(['dark', 'light'])
self.theme_combo.setCurrentText(self.config.get('ui', {}).get('theme', 'dark'))
theme_layout.addWidget(self.theme_combo)
general_layout.addWidget(theme_group)
general_layout.addStretch()
tabs.addTab(general_tab, "General")
# Plugins tab
plugins_tab = QWidget()
plugins_layout = QVBoxLayout(plugins_tab)
plugins_layout.addWidget(QLabel("Plugin settings will appear here"))
plugins_layout.addStretch()
tabs.addTab(plugins_tab, "Plugins")
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
save_btn = QPushButton("Save")
save_btn.clicked.connect(self._save_settings)
button_layout.addWidget(save_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.close)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _browse_game_path(self):
"""Browse for game path."""
from PyQt6.QtWidgets import QFileDialog
path = QFileDialog.getExistingDirectory(
self,
"Select Entropia Universe Directory",
self.path_edit.text() or "C:/"
)
if path:
self.path_edit.setText(path)
def _save_settings(self):
"""Save settings to config file."""
self.config['game_path'] = self.path_edit.text()
self.config['overlay_enabled'] = self.overlay_checkbox.isChecked()
self.config['hotkey'] = self.hotkey_edit.text()
self.config['overlay_mode'] = self.mode_combo.currentText()
if 'ui' not in self.config:
self.config['ui'] = {}
self.config['ui']['theme'] = self.theme_combo.currentText()
# Save to file
import json
from pathlib import Path
config_path = Path.home() / '.eu-utility' / 'config.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(self.config, f, indent=2)
self.close()