diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fde75282..884000ca0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,7 +87,7 @@ jobs: - name: Setup Tools run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg + sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg - name: Code Generation run: | @@ -179,7 +179,7 @@ jobs: - name: Setup Tools run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev webkit2gtk-4.1 keybinder-3.0 + sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev webkit2gtk-4.1 keybinder-3.0 libayatana-appindicator3-dev - name: Code Generation run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 448e7d314..db87c0266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -281,7 +281,7 @@ jobs: - name: Setup Tools run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev webkit2gtk-4.1 libasound2-dev keybinder-3.0 + sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev webkit2gtk-4.1 libasound2-dev keybinder-3.0 libayatana-appindicator3-dev - name: Code Generation run: | @@ -367,7 +367,7 @@ jobs: - name: Setup Tools run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev flatpak-builder flatpak webkit2gtk-4.1 keybinder-3.0 + sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev flatpak-builder flatpak webkit2gtk-4.1 keybinder-3.0 libayatana-appindicator3-dev - name: Code Generation run: | @@ -456,7 +456,7 @@ jobs: - name: Setup Tools run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev webkit2gtk-4.1 keybinder-3.0 + sudo apt-get install -y ninja-build libgtk-3-dev libmpv-dev mpv ffmpeg libmimalloc-dev webkit2gtk-4.1 keybinder-3.0 libayatana-appindicator3-dev - name: Code Generation run: | diff --git a/commet/lib/utils/notification_utils.dart b/commet/lib/utils/notification_utils.dart index c4716dc86..417003308 100644 --- a/commet/lib/utils/notification_utils.dart +++ b/commet/lib/utils/notification_utils.dart @@ -2,6 +2,14 @@ import 'package:commet/main.dart'; class NotificationUtils { static (int, int) getNotificationCounts() { + return _getNotificationCounts(includeSingleRooms: false); + } + + static (int, int) getTrayNotificationCounts() { + return _getNotificationCounts(includeSingleRooms: true); + } + + static (int, int) _getNotificationCounts({required bool includeSingleRooms}) { var highlightedNotificationCount = 0; var notificationCount = 0; @@ -13,8 +21,17 @@ class NotificationUtils { notificationCount += i.displayNotificationCount; } + if (includeSingleRooms) { + // Include rooms that are not part of any space and are not direct messages. + for (var room in clientManager!.singleRooms()) { + highlightedNotificationCount += + room.displayHighlightedNotificationCount; + notificationCount += room.displayNotificationCount; + } + } + for (var dm in clientManager!.directMessages.highlightedRoomsList) { - highlightedNotificationCount += dm.displayNotificationCount; + highlightedNotificationCount += dm.displayHighlightedNotificationCount; notificationCount += dm.displayNotificationCount; } diff --git a/commet/lib/utils/tray_notification_manager.dart b/commet/lib/utils/tray_notification_manager.dart new file mode 100644 index 000000000..dabcc8bdc --- /dev/null +++ b/commet/lib/utils/tray_notification_manager.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:commet/client/client_manager.dart'; +import 'package:commet/debug/log.dart'; +import 'package:commet/main.dart'; +import 'package:commet/utils/notification_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +class TrayNotificationManager { + static const String _defaultTrayIconPath = + 'assets/images/app_icon/app_icon_rounded.png'; + + static const String _cacheId = 'tray_icon_with_notification_badge.png'; + + static String? _cachedIconPath; + + static StreamSubscription? _syncNotificationSubscription; + static Future Function()? _onCloseApplication; + + static Future init({ + required ClientManager? clientManager, + required Future Function() onCloseApplication, + }) async { + _onCloseApplication = onCloseApplication; + + await TrayManager.instance.setIcon(_defaultTrayIconPath); + await _setupTrayMenu(); + TrayManager.instance.addListener(_TrayListener()); + + _registerTrayNotificationListeners(clientManager); + } + + static void _registerTrayNotificationListeners(ClientManager? clientManager) { + _syncNotificationSubscription?.cancel(); + + if (clientManager == null) { + return; + } + + _syncNotificationSubscription = + clientManager.onSync.stream.listen(_onSyncNotificationUpdated); + + _onTrayNotificationStateChanged(); + } + + // Update tray icon during sync + static void _onSyncNotificationUpdated(void _) { + _onTrayNotificationStateChanged(); + } + + static Future _onTrayNotificationStateChanged() async { + var counts = NotificationUtils.getTrayNotificationCounts(); + var notificationCount = counts.$2; + + if (notificationCount == 0) { + await TrayManager.instance.setIcon(_defaultTrayIconPath); + } else { + await _drawNotificationBadge(); + } + } + + static Future _drawNotificationBadge() async { + if (_cachedIconPath != null && await File(_cachedIconPath!).exists()) { + await TrayManager.instance.setIcon(_cachedIconPath!); + return; + } + + final cache = fileCache; + if (cache == null) { + Log.e( + 'File cache is not initialized, cannot get or create cached tray icon with notification badge'); + return; // TrayManager.instance.setIcon expects a file on disk so cannot continue without caching the icon + } + + var cached = await cache.getFile(_cacheId); + if (cached != null) { + _cachedIconPath = File.fromUri(cached).path; + await TrayManager.instance.setIcon(_cachedIconPath!); + return; + } + + Log.i( + 'No cached tray icon with notification badge found, creating new one'); + + final baseIconBytes = await rootBundle.load(_defaultTrayIconPath); + final codec = + await ui.instantiateImageCodec(baseIconBytes.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + final baseImage = frame.image; + + final size = Size(baseImage.width.toDouble(), baseImage.height.toDouble()); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + canvas.drawImage(baseImage, Offset.zero, Paint()); + + final badgeRadius = size.shortestSide / 6; + final badgeCenter = Offset(size.width - badgeRadius, badgeRadius); + final paint = Paint()..color = Colors.red; + canvas.drawCircle(badgeCenter, badgeRadius, paint); + + final picture = recorder.endRecording(); + final img = await picture.toImage(size.width.toInt(), size.height.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + Log.e('Failed to convert tray icon image to byte data'); + return; + } + final pngBytes = byteData.buffer.asUint8List(); + + var savedUri = await cache.putFile(_cacheId, pngBytes); + _cachedIconPath = File.fromUri(savedUri).path; + + Log.i('Saved new tray icon with notification badge to cache'); + + await TrayManager.instance.setIcon(_cachedIconPath!); + } + + static Future _setupTrayMenu() async { + final menu = [ + MenuItem(label: 'Commet', key: 'app_name', disabled: true), + MenuItem.separator(), + MenuItem(label: 'Open Commet', key: 'show'), + MenuItem(label: 'Quit Commet', key: 'close'), + ]; + + await TrayManager.instance.setContextMenu(Menu(items: menu)); + } +} + +class _TrayListener extends TrayListener { + @override + void onTrayIconMouseDown() { + _showWindow(); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + print(menuItem.key); + switch (menuItem.key) { + case 'show': + _showWindow(); + break; + case 'close': + _closeApplication(); + break; + } + } + + Future _showWindow() async { + await windowManager.show(); + await windowManager.focus(); + } + + Future _closeApplication() async { + await TrayNotificationManager._onCloseApplication?.call(); + } +} diff --git a/commet/lib/utils/window_management.dart b/commet/lib/utils/window_management.dart index 8f461c8f3..86281456d 100644 --- a/commet/lib/utils/window_management.dart +++ b/commet/lib/utils/window_management.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:collection/collection.dart'; @@ -5,11 +6,14 @@ import 'package:commet/client/room.dart'; import 'package:commet/client/space.dart'; import 'package:commet/config/platform_utils.dart'; import 'package:commet/main.dart'; +import 'package:commet/utils/tray_notification_manager.dart'; import 'package:commet/utils/event_bus.dart'; import 'package:flutter/services.dart'; import 'package:window_manager/window_manager.dart'; class WindowManagement { + static bool _isShuttingDown = false; + static Future init() async { if (!(PlatformUtils.isLinux || PlatformUtils.isWindows)) return; @@ -24,11 +28,41 @@ class WindowManagement { EventBus.onSelectedRoomChanged.stream.listen(_onSelectedRoomChanged); EventBus.onSelectedSpaceChanged.stream.listen(_onSelectedSpaceChanged); + _registerProcessSignalHandlers(); + + await TrayNotificationManager.init( + clientManager: clientManager, + onCloseApplication: _shutdownApplication, + ); + if (commandLineArgs.contains("--minimize")) { windowManager.minimize(); } } + static void _registerProcessSignalHandlers() { + ProcessSignal.sigint.watch().listen((_) { + _shutdownApplication(); + }); + + ProcessSignal.sigterm.watch().listen((_) { + _shutdownApplication(); + }); + } + + static Future _shutdownApplication() async { + if (_isShuttingDown) return; + _isShuttingDown = true; + + if (clientManager != null) { + for (var client in clientManager!.clients) { + await client.close(); + } + } + + exit(0); + } + static bool _onKeyEvent(KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.f11) { _toggleFullscreen(); @@ -71,15 +105,9 @@ class _WindowListener extends WindowListener { super.onWindowClose(); if (preferences.minimizeOnClose.value) { - windowManager.minimize(); + windowManager.hide(); } else { - if (clientManager != null) { - for (var client in clientManager!.clients) { - await client.close(); - } - } - - exit(0); + await WindowManagement._shutdownApplication(); } } } diff --git a/commet/linux/flutter/generated_plugin_registrant.cc b/commet/linux/flutter/generated_plugin_registrant.cc index 2e61749cc..630af6ca3 100644 --- a/commet/linux/flutter/generated_plugin_registrant.cc +++ b/commet/linux/flutter/generated_plugin_registrant.cc @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +64,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/commet/linux/flutter/generated_plugins.cmake b/commet/linux/flutter/generated_plugins.cmake index cc45d258f..0935e8048 100644 --- a/commet/linux/flutter/generated_plugins.cmake +++ b/commet/linux/flutter/generated_plugins.cmake @@ -16,6 +16,7 @@ list(APPEND FLUTTER_PLUGIN_LIST pasteboard screen_retriever_linux sqlite3_flutter_libs + tray_manager url_launcher_linux window_manager window_to_front diff --git a/commet/macos/Flutter/GeneratedPluginRegistrant.swift b/commet/macos/Flutter/GeneratedPluginRegistrant.swift index 5d921fd69..25a845f4a 100644 --- a/commet/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/commet/macos/Flutter/GeneratedPluginRegistrant.swift @@ -26,6 +26,7 @@ import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs +import tray_manager import url_launcher_macos import wakelock_plus import window_manager @@ -53,6 +54,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/commet/pubspec.yaml b/commet/pubspec.yaml index bba9030ee..9497fdd62 100644 --- a/commet/pubspec.yaml +++ b/commet/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: url_launcher: ^6.1.11 win_toast: ^0.4.0 window_manager: ^0.5.1 + tray_manager: ^0.5.2 flutter: sdk: flutter flutter_highlighter: diff --git a/commet/windows/flutter/generated_plugin_registrant.cc b/commet/windows/flutter/generated_plugin_registrant.cc index 2a4a2499f..ae775a0dc 100644 --- a/commet/windows/flutter/generated_plugin_registrant.cc +++ b/commet/windows/flutter/generated_plugin_registrant.cc @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -60,6 +61,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WinToastPluginRegisterWithRegistrar( diff --git a/commet/windows/flutter/generated_plugins.cmake b/commet/windows/flutter/generated_plugins.cmake index f59f9b520..951b4143c 100644 --- a/commet/windows/flutter/generated_plugins.cmake +++ b/commet/windows/flutter/generated_plugins.cmake @@ -19,6 +19,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows screen_retriever_windows sqlite3_flutter_libs + tray_manager url_launcher_windows win_toast window_manager diff --git a/pubspec.lock b/pubspec.lock index fea9afeb9..96860c9d3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1286,6 +1286,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -1791,6 +1799,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" signal_sticker_api: dependency: transitive description: @@ -2046,6 +2062,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + tray_manager: + dependency: transitive + description: + name: tray_manager + sha256: c5fd83b0ae4d80be6eaedfad87aaefab8787b333b8ebd064b0e442a81006035b + url: "https://pub.dev" + source: hosted + version: "0.5.2" typed_data: dependency: transitive description: