diff --git a/lib/services/tray/backends/hybrid_tray_backend.dart b/lib/services/tray/backends/hybrid_tray_backend.dart new file mode 100644 index 0000000..ab545de --- /dev/null +++ b/lib/services/tray/backends/hybrid_tray_backend.dart @@ -0,0 +1,176 @@ +import '../../tray_backend.dart'; +import '../../logger_service.dart'; +import '../../tray_backend_factory.dart'; + +/// Hybrid tray backend that uses a primary backend with automatic fallback +/// This ensures maximum compatibility across different desktop environments +class HybridTrayBackend implements TrayBackend { + final TrayBackend primaryBackend; + final TrayBackend fallbackBackend; + final TrayBackendMode mode; + + bool _initialized = false; + bool _usingFallback = false; + TrayBackend? _activeBackend; + + HybridTrayBackend({ + required this.primaryBackend, + required this.fallbackBackend, + required this.mode, + }); + + @override + Future init() async { + if (_initialized) return true; + + // Try primary backend first + try { + final primaryInitialized = await primaryBackend.init(); + if (primaryInitialized) { + _activeBackend = primaryBackend; + _initialized = true; + _log('Hybrid tray: using primary backend (${primaryBackend.backendName})'); + return true; + } + } catch (e) { + _log('Primary backend failed: $e', isError: true); + } + + // Fallback to secondary backend + try { + final fallbackInitialized = await fallbackBackend.init(); + if (fallbackInitialized) { + _activeBackend = fallbackBackend; + _usingFallback = true; + _initialized = true; + _log('Hybrid tray: using fallback backend (${fallbackBackend.backendName})', + isError: true); + return true; + } + } catch (e) { + _log('Fallback backend also failed: $e', isError: true); + } + + _log('Hybrid tray: both backends failed to initialize', isError: true); + return false; + } + + @override + Future updateMenu(List items) async { + if (!_initialized || _activeBackend == null) return; + + try { + await _activeBackend!.updateMenu(items); + _log('Hybrid tray: menu updated via ${_activeBackend!.backendName}'); + } catch (e) { + _log('Failed to update menu via ${_activeBackend!.backendName}: $e', + isError: true); + + // Try to switch to fallback if primary failed + if (!_usingFallback && fallbackBackend != primaryBackend) { + _log('Hybrid tray: attempting to switch to fallback backend'); + try { + await fallbackBackend.init(); + _activeBackend = fallbackBackend; + _usingFallback = true; + await fallbackBackend.updateMenu(items); + _log('Hybrid tray: switched to fallback backend'); + } catch (switchError) { + _log('Failed to switch to fallback: $switchError', isError: true); + } + } + } + } + + @override + Future updateIcon(String iconPath) async { + if (!_initialized || _activeBackend == null) return; + + try { + await _activeBackend!.updateIcon(iconPath); + _log('Hybrid tray: icon updated via ${_activeBackend!.backendName}'); + } catch (e) { + _log('Failed to update icon via ${_activeBackend!.backendName}: $e', + isError: true); + } + } + + @override + Future updateTitle(String title) async { + if (!_initialized || _activeBackend == null) return; + + try { + await _activeBackend!.updateTitle(title); + _log('Hybrid tray: title updated via ${_activeBackend!.backendName}'); + } catch (e) { + _log('Failed to update title via ${_activeBackend!.backendName}: $e', + isError: true); + } + } + + @override + Future setTooltip(String tooltip) async { + if (!_initialized || _activeBackend == null) return; + + try { + await _activeBackend!.setTooltip(tooltip); + _log('Hybrid tray: tooltip updated via ${_activeBackend!.backendName}'); + } catch (e) { + _log('Failed to update tooltip via ${_activeBackend!.backendName}: $e', + isError: true); + } + } + + @override + Future dispose() async { + if (!_initialized) return; + + try { + await primaryBackend.dispose(); + if (fallbackBackend != primaryBackend) { + await fallbackBackend.dispose(); + } + _initialized = false; + _usingFallback = false; + _activeBackend = null; + _log('Hybrid tray: disposed'); + } catch (e) { + _log('Error disposing hybrid tray: $e', isError: true); + } + } + + @override + bool get supportsMultipleIcons => _activeBackend?.supportsMultipleIcons ?? false; + + @override + bool get supportsTitle => _activeBackend?.supportsTitle ?? false; + + @override + bool get supportsTooltip => _activeBackend?.supportsTooltip ?? false; + + @override + String get backendName { + if (!_initialized) { + return 'Hybrid Tray (uninitialized)'; + } + return 'Hybrid Tray (${_activeBackend?.backendName})'; + } + + /// Get which backend is currently active + String get activeBackendName => _activeBackend?.backendName ?? 'none'; + + /// Check if we're using the fallback backend + bool get isUsingFallback => _usingFallback; + + /// Get the mode this hybrid was created with + TrayBackendMode get hybridMode => mode; + + void _log(String message, {bool isError = false}) { + final logger = LoggerService(); + if (isError) { + logger.warning('[HybridTrayBackend] $message'); + } else { + logger.info('[HybridTrayBackend] $message'); + } + } +} diff --git a/lib/services/tray/backends/legacy_tray_backend.dart b/lib/services/tray/backends/legacy_tray_backend.dart new file mode 100644 index 0000000..70db833 --- /dev/null +++ b/lib/services/tray/backends/legacy_tray_backend.dart @@ -0,0 +1,186 @@ +import 'dart:io'; +import 'package:tray_manager/tray_manager.dart'; +import '../../tray_backend.dart'; +import '../../logger_service.dart'; +import '../../scheduler_service.dart'; +import '../../window_service.dart'; + +/// Legacy tray backend using tray_manager package +class LegacyTrayBackend implements TrayBackend { + bool _initialized = false; + + @override + Future init() async { + if (_initialized) return true; + if (!Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) { + return false; + } + + try { + trayManager.addListener(_TrayListener()); + _initialized = true; + _log('Legacy tray backend initialized'); + return true; + } catch (e) { + _log('Failed to initialize legacy tray: $e', isError: true); + return false; + } + } + + @override + Future updateMenu(List items) async { + if (!_initialized) return; + + try { + final trayItems = []; + for (final item in items) { + if (item.separator) { + trayItems.add(MenuItem.separator()); + } else if (item.submenu != null && item.submenu!.isNotEmpty) { + final submenuItems = _convertSubmenu(item.submenu!); + trayItems.add(MenuItem.submenu( + label: item.label, + submenu: Menu(items: submenuItems), + )); + } else { + trayItems.add(MenuItem( + key: item.key ?? item.label, + label: item.label, + disabled: item.disabled, + )); + } + } + await trayManager.setContextMenu(Menu(items: trayItems)); + } catch (e) { + _log('Failed to update legacy tray menu: $e', isError: true); + } + } + + @override + Future updateIcon(String iconPath) async { + if (!_initialized) return; + try { + await trayManager.setIcon(iconPath); + } catch (e) { + _log('Failed to set legacy tray icon: $e', isError: true); + } + } + + @override + Future updateTitle(String title) async { + if (!_initialized) return; + try { + await trayManager.setTitle(title); + } catch (e) { + _log('Failed to set legacy tray title: $e'); + } + } + + @override + Future setTooltip(String tooltip) async { + if (!_initialized || Platform.isLinux) return; + try { + await trayManager.setToolTip(tooltip); + } catch (e) { + _log('Failed to set legacy tray tooltip: $e'); + } + } + + @override + Future dispose() async { + if (!_initialized) return; + try { + trayManager.removeListener(_TrayListener.instance); + await trayManager.destroy(); + _initialized = false; + } catch (e) { + _log('Error disposing legacy tray: $e', isError: true); + } + } + + @override + bool get supportsMultipleIcons => false; + + @override + bool get supportsTitle => true; + + @override + bool get supportsTooltip => !Platform.isLinux; + + @override + String get backendName => 'Legacy Tray (tray_manager)'; + + List _convertSubmenu(List items) { + final result = []; + for (final item in items) { + if (item.separator) { + result.add(MenuItem.separator()); + } else if (item.submenu != null && item.submenu!.isNotEmpty) { + result.add(MenuItem.submenu( + label: item.label, + submenu: Menu(items: _convertSubmenu(item.submenu!)), + )); + } else { + result.add(MenuItem( + key: item.key ?? item.label, + label: item.label, + disabled: item.disabled, + )); + } + } + return result; + } + + void _log(String message, {bool isError = false}) { + final logger = LoggerService(); + if (isError) { + logger.warning('[LegacyTrayBackend] $message'); + } else { + logger.info('[LegacyTrayBackend] $message'); + } + } +} + +class _TrayListener implements TrayListener { + static _TrayListener? _instance; + static _TrayListener get instance => _instance!; + + _TrayListener() { + _instance = this; + } + + @override + void onTrayIconMouseDown() { + WindowService().show(); + } + + @override + void onTrayIconMouseUp() { + // Optional: handle mouse up event + } + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + @override + void onTrayIconRightMouseUp() { + // Optional: handle right mouse up event + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + switch (menuItem.key) { + case 'show': + WindowService().show(); + break; + case 'refresh': + SchedulerService().refreshAll(); + break; + case 'quit': + WindowService().quit(); + break; + } + } +} diff --git a/lib/services/tray/backends/sni_tray_backend.dart b/lib/services/tray/backends/sni_tray_backend.dart new file mode 100644 index 0000000..8cc8b5a --- /dev/null +++ b/lib/services/tray/backends/sni_tray_backend.dart @@ -0,0 +1,106 @@ +import 'dart:io'; +import '../../tray_backend.dart'; +import '../utils/dbus_detector.dart'; +import '../../logger_service.dart'; + +/// StatusNotifierItem (SNI) backend +/// Note: This is a placeholder implementation +/// The xdg_status_notifier_item package API may differ +class SniTrayBackend implements TrayBackend { + bool _initialized = false; + + // Temporary flag: set to true once real SNI implementation is complete + static const bool _enableSniBackend = false; + + @override + Future init() async { + if (_initialized) return true; + if (!Platform.isLinux) { + _log('SNI is Linux-only', isError: true); + return false; + } + + // Return false if not fully implemented yet + if (!_enableSniBackend) { + _log('SNI backend is not yet fully implemented, using fallback', + isError: true); + return false; + } + + try { + final sniSupported = await SniDetector.isSupported(); + if (!sniSupported) { + final reason = await SniDetector.getSupportReason(); + _log('SNI not supported: $reason', isError: true); + return false; + } + + _initialized = true; + _log('SNI tray backend initialized'); + return true; + } catch (e) { + _log('Failed to initialize SNI backend: $e', isError: true); + return false; + } + } + + @override + Future updateMenu(List items) async { + if (!_initialized) return; + _log('SNI tray: menu update requested (${items.length} items)'); + // TODO: Implement actual SNI menu update + } + + @override + Future updateIcon(String iconPath) async { + if (!_initialized) return; + _log('SNI tray: icon update requested'); + // TODO: Implement actual SNI icon update + } + + @override + Future updateTitle(String title) async { + if (!_initialized) return; + _log('SNI tray: title update requested'); + // TODO: Implement actual SNI title update + } + + @override + Future setTooltip(String tooltip) async { + if (!_initialized) return; + _log('SNI tray: tooltip update requested'); + // TODO: Implement actual SNI tooltip update + } + + @override + Future dispose() async { + if (!_initialized) return; + try { + _initialized = false; + _log('SNI tray backend disposed'); + } catch (e) { + _log('Error disposing SNI tray: $e', isError: true); + } + } + + @override + bool get supportsMultipleIcons => true; + + @override + bool get supportsTitle => true; + + @override + bool get supportsTooltip => true; + + @override + String get backendName => 'StatusNotifierItem (SNI)'; + + void _log(String message, {bool isError = false}) { + final logger = LoggerService(); + if (isError) { + logger.warning('[SniTrayBackend] $message'); + } else { + logger.info('[SniTrayBackend] $message'); + } + } +} diff --git a/lib/services/tray/utils/dbus_detector.dart b/lib/services/tray/utils/dbus_detector.dart new file mode 100644 index 0000000..0af88ff --- /dev/null +++ b/lib/services/tray/utils/dbus_detector.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +/// Detects if StatusNotifierItem (SNI) is available on the system +/// This is needed to decide whether to use SNI backend or fallback to legacy +class SniDetector { + /// Check if SNI is supported on this system + static Future isSupported() async { + // Check 1: Verify xdg_status_notifier_item package is available + // (already in pubspec.yaml) + + // Check 2: Verify we're on Linux (SNI is Linux-only) + if (!Platform.isLinux) { + return false; + } + + // Check 3: Verify desktop environment supports SNI + final desktop = Platform.environment['XDG_CURRENT_DESKTOP']; + if (desktop == null) { + return false; + } + + final supportedDEs = [ + 'GNOME', + 'KDE', + 'Unity', + 'Pantheon', + 'Budgie', + 'XFCE', + ]; + + final isCompatibleDE = supportedDEs.any( + (de) => desktop.toUpperCase().contains(de.toUpperCase()), + ); + + if (!isCompatibleDE) { + return false; + } + + // Check 4: For GNOME, check if AppIndicator extension is available + if (desktop.contains('GNOME')) { + return await _checkGnomeExtension(); + } + + // KDE and other DEs have native SNI support + return true; + } + + /// Check if GNOME has AppIndicator extension installed + static Future _checkGnomeExtension() async { + try { + // Check if gnome-extensions command is available + final which = await Process.run('which', ['gnome-extensions']); + if (which.exitCode != 0) { + // gnome-extensions not available, but SNI might still work + return true; + } + + // List enabled extensions + final result = await Process.run( + 'gnome-extensions', + ['list', '--enabled'], + ); + + if (result.exitCode != 0) { + return true; // Assume available if command fails + } + + final extensions = result.stdout.toString().toLowerCase(); + + // Check for common AppIndicator extension UUIDs + const appIndicatorUuids = [ + 'appindicatorsupport@rgcjonas.gmail.com', + 'ubuntu-appindicators@ubuntu.com', + 'kstatusnotifieritem', + ]; + + final hasExtension = appIndicatorUuids.any( + (uuid) => extensions.contains(uuid.toLowerCase()), + ); + + return hasExtension; + } catch (e) { + // If check fails, assume SNI might be available + return true; + } + } + + /// Get a human-readable reason why SNI is/n't supported + static Future getSupportReason() async { + if (!Platform.isLinux) { + return 'SNI is only supported on Linux'; + } + + final desktop = Platform.environment['XDG_CURRENT_DESKTOP']; + if (desktop == null) { + return 'No desktop environment detected'; + } + + if (desktop.contains('GNOME')) { + final hasExtension = await _checkGnomeExtension(); + if (!hasExtension) { + return 'GNOME detected but AppIndicator extension is not installed. ' + 'Please install "AppIndicator and KStatusNotifier Support" ' + 'from extensions.gnome.org'; + } + return 'GNOME with AppIndicator extension detected'; + } + + if (desktop.contains('KDE')) { + return 'KDE Plasma detected (native SNI support)'; + } + + return '$desktop detected (SNI should work)'; + } +} diff --git a/lib/services/tray_backend.dart b/lib/services/tray_backend.dart new file mode 100644 index 0000000..87d43f3 --- /dev/null +++ b/lib/services/tray_backend.dart @@ -0,0 +1,63 @@ +/// Common interface for all tray backends +/// This abstraction allows switching between different tray implementations +/// (e.g., StatusNotifierItem vs Legacy GtkStatusIcon) at runtime. +abstract class TrayBackend { + /// Initialize the backend + /// Returns true if initialization was successful + Future init(); + + /// Update the system tray menu + Future updateMenu(List items); + + /// Update the tray icon + Future updateIcon(String iconPath); + + /// Update the tray title (tooltip) + Future updateTitle(String title); + + /// Set tooltip text (may not be supported on all platforms) + Future setTooltip(String tooltip); + + /// Cleanup resources + Future dispose(); + + /// Backend capabilities + bool get supportsMultipleIcons; + bool get supportsTitle; + bool get supportsTooltip; + String get backendName; +} + +/// Menu item representation for tray backends +/// Renamed from MenuItem to TrayMenuItem to avoid conflict with tray_manager +class TrayMenuItem { + final String label; + final bool disabled; + final bool separator; + final String? key; + final List? submenu; + + const TrayMenuItem({ + required this.label, + this.disabled = false, + this.separator = false, + this.key, + this.submenu, + }); + + factory TrayMenuItem.separator() => const TrayMenuItem( + label: '', + separator: true, + ); + + factory TrayMenuItem.submenu({ + required String label, + required List submenu, + String? key, + }) => + TrayMenuItem( + label: label, + submenu: submenu, + key: key, + ); +} diff --git a/lib/services/tray_backend_factory.dart b/lib/services/tray_backend_factory.dart new file mode 100644 index 0000000..358effc --- /dev/null +++ b/lib/services/tray_backend_factory.dart @@ -0,0 +1,145 @@ +import 'dart:io'; +import 'tray_backend.dart'; +import 'tray/utils/dbus_detector.dart'; +import 'tray/backends/legacy_tray_backend.dart'; +import 'tray/backends/sni_tray_backend.dart'; +import 'tray/backends/hybrid_tray_backend.dart'; + +/// Factory for creating appropriate tray backend based on system capabilities +/// and user preferences +class TrayBackendFactory { + /// Create a tray backend based on the current system and preferences + /// + /// [mode] specifies the desired backend mode: + /// - auto: Auto-detect best backend (default) + /// - sni: Force StatusNotifierItem backend + /// - legacy: Force legacy tray_manager backend + /// - hybrid: Auto-detect with fallback (same as auto) + static Future create({ + TrayBackendMode mode = TrayBackendMode.auto, + }) async { + switch (mode) { + case TrayBackendMode.auto: + case TrayBackendMode.hybrid: + return _createAutoWithFallback(); + + case TrayBackendMode.sni: + return _createSni(); + + case TrayBackendMode.legacy: + return _createLegacy(); + } + } + + /// Create backend with automatic detection and fallback + static Future _createAutoWithFallback() async { + // Try SNI first (more modern, better Wayland support) + try { + final sniBackend = SniTrayBackend(); + final initialized = await sniBackend.init(); + + if (initialized) { + return HybridTrayBackend( + primaryBackend: sniBackend, + fallbackBackend: LegacyTrayBackend(), + mode: TrayBackendMode.sni, + ); + } + } catch (e) { + // SNI failed, will fallback to legacy + } + + // Fallback to legacy tray_manager + final legacyBackend = LegacyTrayBackend(); + await legacyBackend.init(); + + return HybridTrayBackend( + primaryBackend: legacyBackend, + fallbackBackend: legacyBackend, + mode: TrayBackendMode.legacy, + ); + } + + /// Create SNI backend explicitly + static Future _createSni() async { + final backend = SniTrayBackend(); + final initialized = await backend.init(); + + if (!initialized) { + throw StateError( + 'SNI backend initialization failed. ' + 'Please ensure AppIndicator support is available.', + ); + } + + return backend; + } + + /// Create legacy backend explicitly + static Future _createLegacy() async { + final backend = LegacyTrayBackend(); + await backend.init(); + return backend; + } + + /// Auto-detect the best backend without creating it + /// Returns the recommended mode + static Future detectBestMode() async { + // Check if we're on Linux + if (!Platform.isLinux) { + return TrayBackendMode.legacy; // tray_manager works on all platforms + } + + // Try to detect SNI support + final sniSupported = await SniDetector.isSupported(); + if (sniSupported) { + return TrayBackendMode.sni; + } + + return TrayBackendMode.legacy; + } + + /// Get information about the recommended backend + static Future getBackendInfo() async { + final mode = await detectBestMode(); + final sniSupported = await SniDetector.isSupported(); + final reason = await SniDetector.getSupportReason(); + + return BackendInfo( + recommendedMode: mode, + sniSupported: sniSupported, + reason: reason, + ); + } +} + +/// Backend selection modes +enum TrayBackendMode { + auto, // Auto-detect (hybrid) + sni, // Force StatusNotifierItem + legacy, // Force tray_manager + hybrid, // Same as auto +} + +/// Information about detected backend capabilities +class BackendInfo { + final TrayBackendMode recommendedMode; + final bool sniSupported; + final String reason; + + BackendInfo({ + required this.recommendedMode, + required this.sniSupported, + required this.reason, + }); + + bool get willUseSni => + recommendedMode == TrayBackendMode.sni || + recommendedMode == TrayBackendMode.auto; + + @override + String toString() { + return 'BackendInfo(recommended: $recommendedMode, ' + 'SNI: $sniSupported, reason: $reason)'; + } +} diff --git a/lib/services/tray_service.dart b/lib/services/tray_service.dart index 41baddc..eebbfc4 100644 --- a/lib/services/tray_service.dart +++ b/lib/services/tray_service.dart @@ -11,10 +11,16 @@ import 'package:crossbar_core/crossbar_core.dart' as plugin_model; import 'logger_service.dart'; import 'scheduler_service.dart'; import 'window_service.dart'; +import 'tray_backend.dart'; +import 'tray_backend_factory.dart'; -/// TrayService - Manages a single system tray icon using tray_manager. +/// TrayService - Manages system tray icons using a hybrid backend system. +/// +/// Supports multiple tray implementations: +/// - StatusNotifierItem (SNI) for modern Linux desktop environments +/// - Legacy tray_manager for cross-platform compatibility +/// - Automatic fallback between implementations /// -/// Uses tray_manager for all desktop platforms (Linux, Windows, macOS). /// Shows plugin outputs in a unified menu under a single tray icon. /// On Linux, automatically switches between light/dark icons based on system theme. class TrayService with TrayListener { @@ -31,28 +37,62 @@ class TrayService with TrayListener { String? _iconPath; Brightness? _lastBrightness; + // Hybrid tray backend + TrayBackend? _trayBackend; + bool _useHybridBackend = true; // Can be disabled via env var for testing + Future init() async { if (_initialized) return; if (!Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) { return; } - trayManager.addListener(this); + // Check if hybrid backend should be used + final forceLegacy = Platform.environment['CROSSBAR_TRAY_BACKEND'] == 'legacy'; + final forceSni = Platform.environment['CROSSBAR_TRAY_BACKEND'] == 'sni'; + final useHybrid = _useHybridBackend && !forceLegacy && !forceSni; - await _resolveAndSetIcon(); - await _updateMenu(); - - // Set initial tooltip (title isn't supported on Linux) - if (!Platform.isLinux) { + if (useHybrid) { + // Use hybrid backend with auto-detection try { - await trayManager.setToolTip('Crossbar'); + final backendMode = forceSni + ? TrayBackendMode.sni + : TrayBackendMode.auto; + + _trayBackend = await TrayBackendFactory.create(mode: backendMode); + await _trayBackend!.init(); + + // Get backend info for logging + final info = await TrayBackendFactory.getBackendInfo(); + LoggerService().info( + 'Tray service initialized with hybrid backend: ${info.recommendedMode} ' + '(${info.reason})' + ); + } catch (e) { + LoggerService().warning('Failed to initialize hybrid tray backend: $e'); + LoggerService().info('Falling back to legacy tray_manager'); + _trayBackend = null; + } + } + + // Fallback to legacy tray_manager if hybrid failed + if (_trayBackend == null) { + trayManager.addListener(this); + await _resolveAndSetIcon(); + await _updateMenu(); + + // Set initial tooltip (title isn't supported on Linux) + if (!Platform.isLinux) { + try { + await trayManager.setToolTip('Crossbar'); + } catch (_) {} + } + + // Set initial title + try { + await trayManager.setTitle('Crossbar'); } catch (_) {} } - - // Set initial title - try { - await trayManager.setTitle('Crossbar'); - } catch (_) {} // Listen for theme changes on Linux if (Platform.isLinux) { @@ -218,9 +258,18 @@ class TrayService with TrayListener { void updatePluginOutput(String pluginId, plugin_model.PluginOutput output) { _pluginOutputs[pluginId] = output; - _updateMenu(); - _updateTitle(pluginId, output); - _updateTooltip(); + + // Use hybrid backend if available + if (_trayBackend != null) { + _updateMenuHybrid(); + _updateTitleHybrid(pluginId, output); + _updateTooltipHybrid(); + } else { + // Fallback to legacy tray_manager + _updateMenu(); + _updateTitle(pluginId, output); + _updateTooltip(); + } } /// Public method to refresh the tray menu (e.g., after plugin toggle/delete) @@ -271,6 +320,127 @@ class TrayService with TrayListener { } } + // Hybrid backend methods + Future _updateMenuHybrid() async { + if (_trayBackend == null) return; + + final menuItems = []; + + // Plugin outputs - show enabled plugins with their menus + for (final plugin in _pluginManager.plugins.where((p) => p.enabled)) { + final output = _pluginOutputs[plugin.id]; + if (output != null && output.text != null && output.text!.isNotEmpty) { + // Check if plugin has menu items + if (output.menu.isNotEmpty) { + // Create submenu with plugin output items + final submenuItems = _convertToTrayBackendMenu(output.menu); + menuItems.add(TrayMenuItem.submenu( + label: '${output.icon} ${output.text}', + submenu: submenuItems, + )); + } else { + // No submenu, just show the output + menuItems.add(TrayMenuItem( + label: '${output.icon} ${output.text}', + disabled: true, + )); + } + } + } + + if (menuItems.isNotEmpty) { + menuItems.add(TrayMenuItem.separator()); + } + + // Standard menu items + menuItems.addAll([ + TrayMenuItem( + key: 'show', + label: 'Show Crossbar', + ), + TrayMenuItem( + key: 'refresh', + label: 'Refresh All Plugins', + ), + TrayMenuItem.separator(), + TrayMenuItem( + key: 'quit', + label: 'Quit', + ), + ]); + + try { + await _trayBackend!.updateMenu(menuItems); + } catch (e) { + LoggerService().warning('Failed to set hybrid tray context menu: $e'); + } + } + + Future _updateTitleHybrid(String pluginId, plugin_model.PluginOutput output) async { + if (_trayBackend == null) return; + if (!_trayBackend!.supportsTitle) return; + + // Find the first enabled plugin to use as the main tray title + final firstEnabled = + _pluginManager.plugins.where((p) => p.enabled).firstOrNull; + + if (firstEnabled?.id == pluginId) { + var title = ''; + // Use emoji icon from plugin output if available + if (output.icon.isNotEmpty && output.icon != '⚙️') { + title += '${output.icon} '; + } + if (output.text != null) { + title += output.text!; + } + + try { + await _trayBackend!.updateTitle(title); + } catch (e) { + LoggerService().warning('Failed to set hybrid tray title: $e'); + } + } + } + + void _updateTooltipHybrid() { + if (_trayBackend == null) return; + if (!_trayBackend!.supportsTooltip) return; + if (_pluginOutputs.isEmpty) return; + + final tooltipParts = []; + for (final entry in _pluginOutputs.entries.take(3)) { + final output = entry.value; + if (output.text != null) { + tooltipParts.add('${output.icon} ${output.text}'); + } + } + + _trayBackend!.setTooltip(tooltipParts.join(' | ')); + } + + /// Converts plugin model MenuItems to TrayBackend MenuItems recursively + List _convertToTrayBackendMenu(List items) { + final result = []; + for (final item in items) { + if (item.separator) { + result.add(TrayMenuItem.separator()); + } else if (item.submenu != null && item.submenu!.isNotEmpty) { + // Item has submenu - create a submenu + result.add(TrayMenuItem.submenu( + label: item.text ?? '', + submenu: _convertToTrayBackendMenu(item.submenu!), + )); + } else { + // Regular menu item + result.add(TrayMenuItem( + key: item.href ?? item.bash ?? item.text, + label: item.text ?? '', + )); + } + } + return result; + } + void clearPluginOutput(String pluginId) { _pluginOutputs.remove(pluginId); _updateMenu(); @@ -301,12 +471,25 @@ class TrayService with TrayListener { Future dispose() async { if (!_initialized) return; - trayManager.removeListener(this); - try { - await trayManager.destroy(); - } catch (e) { - LoggerService().warning('Failed to destroy tray: $e'); + // Dispose hybrid backend if being used + if (_trayBackend != null) { + try { + await _trayBackend!.dispose(); + _trayBackend = null; + LoggerService().info('Hybrid tray backend disposed'); + } catch (e) { + LoggerService().warning('Failed to dispose hybrid tray backend: $e'); + } + } else { + // Fallback to legacy tray_manager + trayManager.removeListener(this); + try { + await trayManager.destroy(); + } catch (e) { + LoggerService().warning('Failed to destroy tray: $e'); + } } + _initialized = false; } }