From 4b784356cd476571d568d7c95dd090183ff0df84 Mon Sep 17 00:00:00 2001 From: Marcelo Moresco Date: Mon, 3 Aug 2026 14:49:47 -0300 Subject: [PATCH] feat(macos): support tray menu item icons --- README-ZH.md | 4 + README.md | 5 + packages/tray_manager/CHANGELOG.md | 4 + .../tray_manager/example/lib/pages/home.dart | 3 + .../lib/src/menu_serialization.dart | 67 ++++++++++++++ .../tray_manager/lib/src/tray_manager.dart | 9 +- .../macos/tray_manager/Classes/TrayMenu.swift | 21 +++++ .../test/menu_serialization_test.dart | 92 +++++++++++++++++++ .../test/set_context_menu_test.dart | 58 ++++++++++++ 9 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 packages/tray_manager/lib/src/menu_serialization.dart create mode 100644 packages/tray_manager/test/menu_serialization_test.dart create mode 100644 packages/tray_manager/test/set_context_menu_test.dart diff --git a/README-ZH.md b/README-ZH.md index 2403cfe..fdf6d69 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -125,6 +125,7 @@ Menu menu = Menu( MenuItem( key: 'show_window', label: 'Show Window', + icon: 'images/tray_icon.png', ), MenuItem.separator(), MenuItem( @@ -136,6 +137,9 @@ Menu menu = Menu( await trayManager.setContextMenu(menu); ``` +在 macOS 上,`MenuItem.icon` 可以使用 Flutter 资源路径或图像的绝对文件路径。 +菜单项图像会以 16×16 显示。其他平台保持现有行为。 + > 请看这个插件的示例应用,以了解完整的例子。 #### 监听事件 diff --git a/README.md b/README.md index e8c30e1..c9698c8 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Menu menu = Menu( MenuItem( key: 'show_window', label: 'Show Window', + icon: 'images/tray_icon.png', ), MenuItem.separator(), MenuItem( @@ -135,6 +136,10 @@ Menu menu = Menu( await trayManager.setContextMenu(menu); ``` +On macOS, `MenuItem.icon` accepts a Flutter asset path or an absolute image +file path. Menu item images are displayed at 16×16. Other platforms retain +their existing behavior. + > Please see the example app of this plugin for a full example. #### Listening events diff --git a/packages/tray_manager/CHANGELOG.md b/packages/tray_manager/CHANGELOG.md index 4f276fe..825896f 100644 --- a/packages/tray_manager/CHANGELOG.md +++ b/packages/tray_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* feat(macos): support icons on tray menu items + ## 0.5.3 * feat(macos): add Swift Package Manager support diff --git a/packages/tray_manager/example/lib/pages/home.dart b/packages/tray_manager/example/lib/pages/home.dart index 2d21789..2cd5a9c 100644 --- a/packages/tray_manager/example/lib/pages/home.dart +++ b/packages/tray_manager/example/lib/pages/home.dart @@ -152,6 +152,7 @@ class _HomePageState extends State with TrayListener { items: [ MenuItem( label: 'Look Up "LeanFlutter"', + icon: 'images/tray_icon.png', ), MenuItem( label: 'Search with Google', @@ -169,11 +170,13 @@ class _HomePageState extends State with TrayListener { ), MenuItem.submenu( label: 'Share', + icon: 'images/tray_icon.png', submenu: Menu( items: [ MenuItem.checkbox( label: 'Item 1', checked: true, + icon: 'images/tray_icon.png', onClick: (menuItem) { if (kDebugMode) { print('click item 1'); diff --git a/packages/tray_manager/lib/src/menu_serialization.dart b/packages/tray_manager/lib/src/menu_serialization.dart new file mode 100644 index 0000000..4a01e76 --- /dev/null +++ b/packages/tray_manager/lib/src/menu_serialization.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:menu_base/menu_base.dart'; + +typedef MenuIconAssetLoader = Future Function(String key); + +/// Internal platform-aware serialization for native tray menus. +Future> serializeMenuForPlatform( + Menu menu, { + TargetPlatform? platform, + MenuIconAssetLoader? loadAsset, +}) async { + if ((platform ?? defaultTargetPlatform) != TargetPlatform.macOS) { + return menu.toJson(); + } + + return _serializeMenuWithIcons( + menu, + {}, + loadAsset ?? rootBundle.load, + ); +} + +Future> _serializeMenuWithIcons( + Menu menu, + Map iconCache, + MenuIconAssetLoader loadAsset, +) async { + final jsonItems = >[]; + + for (final item in menu.items ?? const []) { + final jsonItem = Map.from(item.toJson()); + final iconPath = item.icon; + + if (iconPath != null && iconPath.isNotEmpty) { + var base64Icon = iconCache[iconPath]; + if (base64Icon == null) { + try { + final data = await loadAsset(iconPath); + base64Icon = base64Encode( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + iconCache[iconPath] = base64Icon; + } catch (_) { + // Keep the original icon path so native code can try loading it as + // an absolute file path. + } + } + if (base64Icon != null) { + jsonItem['base64Icon'] = base64Icon; + } + } + + if (item.submenu case final submenu?) { + jsonItem['submenu'] = await _serializeMenuWithIcons( + submenu, + iconCache, + loadAsset, + ); + } + jsonItems.add(jsonItem); + } + + return {'items': jsonItems}; +} diff --git a/packages/tray_manager/lib/src/tray_manager.dart b/packages/tray_manager/lib/src/tray_manager.dart index f23b61c..a7e9c74 100644 --- a/packages/tray_manager/lib/src/tray_manager.dart +++ b/packages/tray_manager/lib/src/tray_manager.dart @@ -9,6 +9,7 @@ import 'package:menu_base/menu_base.dart'; import 'package:path/path.dart' as path; import 'package:shortid/shortid.dart'; import 'package:tray_manager/src/helpers/sandbox.dart'; +import 'package:tray_manager/src/menu_serialization.dart'; import 'package:tray_manager/src/tray_listener.dart'; const kEventOnTrayIconMouseDown = 'onTrayIconMouseDown'; @@ -37,6 +38,7 @@ class TrayManager { } Menu? _menu; + int _contextMenuRequestId = 0; Future _methodCallHandler(MethodCall call) async { for (final TrayListener listener in _listeners) { @@ -178,9 +180,14 @@ class TrayManager { /// Sets the context menu for this icon. Future setContextMenu(Menu menu) async { + final requestId = ++_contextMenuRequestId; _menu = menu; + final serializedMenu = await serializeMenuForPlatform(menu); + if (requestId != _contextMenuRequestId) { + return; + } final Map arguments = { - 'menu': menu.toJson(), + 'menu': serializedMenu, }; await _channel.invokeMethod('setContextMenu', arguments); } diff --git a/packages/tray_manager/macos/tray_manager/Classes/TrayMenu.swift b/packages/tray_manager/macos/tray_manager/Classes/TrayMenu.swift index 3119693..b91efde 100644 --- a/packages/tray_manager/macos/tray_manager/Classes/TrayMenu.swift +++ b/packages/tray_manager/macos/tray_manager/Classes/TrayMenu.swift @@ -9,6 +9,26 @@ import AppKit public class TrayMenu: NSMenu, NSMenuDelegate { public var onMenuItemClick:((NSMenuItem) -> Void)? + + private static let menuItemIconSize = NSSize(width: 16, height: 16) + + private static func loadMenuItemImage(from item: [String: Any]) -> NSImage? { + if let encodedIcon = item["base64Icon"] as? String, + let data = Data(base64Encoded: encodedIcon), + let image = NSImage(data: data) { + image.size = menuItemIconSize + return image + } + + if let iconPath = item["icon"] as? String, + iconPath.hasPrefix("/"), + let image = NSImage(contentsOfFile: iconPath) { + image.size = menuItemIconSize + return image + } + + return nil + } public override init(title: String) { super.init(title: title) @@ -42,6 +62,7 @@ public class TrayMenu: NSMenu, NSMenuDelegate { menuItem.tag = id menuItem.title = label menuItem.toolTip = toolTip + menuItem.image = TrayMenu.loadMenuItemImage(from: itemDict) menuItem.isEnabled = !disabled menuItem.action = !disabled ? #selector(statusItemMenuButtonClicked) : nil menuItem.target = self diff --git a/packages/tray_manager/test/menu_serialization_test.dart b/packages/tray_manager/test/menu_serialization_test.dart new file mode 100644 index 0000000..537f6d7 --- /dev/null +++ b/packages/tray_manager/test/menu_serialization_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tray_manager/src/menu_serialization.dart'; +import 'package:tray_manager/tray_manager.dart'; + +void main() { + test('embeds macOS asset icons and caches repeated nested icons', () async { + var loadCount = 0; + final menu = Menu( + items: [ + MenuItem(label: 'Top level', icon: 'images/icon.png'), + MenuItem.submenu( + label: 'Submenu', + submenu: Menu( + items: [MenuItem(label: 'Nested', icon: 'images/icon.png')], + ), + ), + ], + ); + + final json = await serializeMenuForPlatform( + menu, + platform: TargetPlatform.macOS, + loadAsset: (key) async { + loadCount++; + return ByteData.sublistView(Uint8List.fromList([9, 1, 2, 3, 9]), 1, 4); + }, + ); + + final items = json['items']! as List>; + final nested = (items[1]['submenu']! as Map)['items']! + as List>; + expect(items[0]['base64Icon'], base64Encode([1, 2, 3])); + expect(nested[0]['base64Icon'], base64Encode([1, 2, 3])); + expect(loadCount, 1); + }); + + test('ignores empty icon values', () async { + var loadCount = 0; + final menu = Menu(items: [MenuItem(label: 'Item', icon: '')]); + + final json = await serializeMenuForPlatform( + menu, + platform: TargetPlatform.macOS, + loadAsset: (_) async { + loadCount++; + return ByteData(0); + }, + ); + + final item = (json['items']! as List>).single; + expect(item, isNot(contains('base64Icon'))); + expect(loadCount, 0); + }); + + test('keeps an invalid icon path for the native file fallback', () async { + final menu = Menu( + items: [MenuItem(label: 'File', icon: '/tmp/icon.png')], + ); + + final json = await serializeMenuForPlatform( + menu, + platform: TargetPlatform.macOS, + loadAsset: (_) => Future.error(StateError('not an asset')), + ); + + final item = (json['items']! as List>).single; + expect(item['icon'], '/tmp/icon.png'); + expect(item, isNot(contains('base64Icon'))); + }); + + test('does not load or alter icons on other platforms', () async { + var loadCount = 0; + final menu = Menu( + items: [MenuItem(label: 'Item', icon: 'images/icon.png')], + ); + + final json = await serializeMenuForPlatform( + menu, + platform: TargetPlatform.windows, + loadAsset: (_) async { + loadCount++; + return ByteData(0); + }, + ); + + expect(json, menu.toJson()); + expect(loadCount, 0); + }); +} diff --git a/packages/tray_manager/test/set_context_menu_test.dart b/packages/tray_manager/test/set_context_menu_test.dart new file mode 100644 index 0000000..a46ebcd --- /dev/null +++ b/packages/tray_manager/test/set_context_menu_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tray_manager/tray_manager.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const trayChannel = MethodChannel('tray_manager'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMessageHandler('flutter/assets', null); + messenger.setMockMethodCallHandler(trayChannel, null); + }); + + test('a slow icon load cannot replace a newer context menu', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final assetLoads = >{}; + final receivedMenus = >[]; + + messenger.setMockMessageHandler('flutter/assets', (message) { + final key = utf8.decode(message!.buffer.asUint8List()); + final completer = Completer(); + assetLoads[key] = completer; + return completer.future; + }); + messenger.setMockMethodCallHandler(trayChannel, (call) async { + if (call.method == 'setContextMenu') { + final arguments = call.arguments as Map; + receivedMenus.add(arguments['menu'] as Map); + } + return null; + }); + + final first = trayManager.setContextMenu( + Menu(items: [MenuItem(label: 'First', icon: 'first.png')]), + ); + final second = trayManager.setContextMenu( + Menu(items: [MenuItem(label: 'Second', icon: 'second.png')]), + ); + await Future.delayed(Duration.zero); + + assetLoads['second.png']!.complete(ByteData(1)); + await second; + assetLoads['first.png']!.complete(ByteData(1)); + await first; + + expect(receivedMenus, hasLength(1)); + final items = receivedMenus.single['items'] as List; + expect((items.single as Map)['label'], 'Second'); + }); +}