Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README-ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Menu menu = Menu(
MenuItem(
key: 'show_window',
label: 'Show Window',
icon: 'images/tray_icon.png',
),
MenuItem.separator(),
MenuItem(
Expand All @@ -136,6 +137,9 @@ Menu menu = Menu(
await trayManager.setContextMenu(menu);
```

在 macOS 上,`MenuItem.icon` 可以使用 Flutter 资源路径或图像的绝对文件路径。
菜单项图像会以 16×16 显示。其他平台保持现有行为。

> 请看这个插件的示例应用,以了解完整的例子。

#### 监听事件
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Menu menu = Menu(
MenuItem(
key: 'show_window',
label: 'Show Window',
icon: 'images/tray_icon.png',
),
MenuItem.separator(),
MenuItem(
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/tray_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## NEXT

* feat(macos): support icons on tray menu items

## 0.5.3

* feat(macos): add Swift Package Manager support
Expand Down
3 changes: 3 additions & 0 deletions packages/tray_manager/example/lib/pages/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ class _HomePageState extends State<HomePage> with TrayListener {
items: [
MenuItem(
label: 'Look Up "LeanFlutter"',
icon: 'images/tray_icon.png',
),
MenuItem(
label: 'Search with Google',
Expand All @@ -169,11 +170,13 @@ class _HomePageState extends State<HomePage> 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');
Expand Down
67 changes: 67 additions & 0 deletions packages/tray_manager/lib/src/menu_serialization.dart
Original file line number Diff line number Diff line change
@@ -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<ByteData> Function(String key);

/// Internal platform-aware serialization for native tray menus.
Future<Map<String, dynamic>> serializeMenuForPlatform(
Menu menu, {
TargetPlatform? platform,
MenuIconAssetLoader? loadAsset,
}) async {
if ((platform ?? defaultTargetPlatform) != TargetPlatform.macOS) {
return menu.toJson();
}

return _serializeMenuWithIcons(
menu,
<String, String>{},
loadAsset ?? rootBundle.load,
);
}

Future<Map<String, dynamic>> _serializeMenuWithIcons(
Menu menu,
Map<String, String> iconCache,
MenuIconAssetLoader loadAsset,
) async {
final jsonItems = <Map<String, dynamic>>[];

for (final item in menu.items ?? const <MenuItem>[]) {
final jsonItem = Map<String, dynamic>.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 <String, dynamic>{'items': jsonItems};
}
9 changes: 8 additions & 1 deletion packages/tray_manager/lib/src/tray_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,6 +38,7 @@ class TrayManager {
}

Menu? _menu;
int _contextMenuRequestId = 0;

Future<void> _methodCallHandler(MethodCall call) async {
for (final TrayListener listener in _listeners) {
Expand Down Expand Up @@ -178,9 +180,14 @@ class TrayManager {

/// Sets the context menu for this icon.
Future<void> setContextMenu(Menu menu) async {
final requestId = ++_contextMenuRequestId;
_menu = menu;
final serializedMenu = await serializeMenuForPlatform(menu);
if (requestId != _contextMenuRequestId) {
return;
}
final Map<String, dynamic> arguments = {
'menu': menu.toJson(),
'menu': serializedMenu,
};
await _channel.invokeMethod('setContextMenu', arguments);
}
Expand Down
21 changes: 21 additions & 0 deletions packages/tray_manager/macos/tray_manager/Classes/TrayMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions packages/tray_manager/test/menu_serialization_test.dart
Original file line number Diff line number Diff line change
@@ -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<Map<String, dynamic>>;
final nested = (items[1]['submenu']! as Map<String, dynamic>)['items']!
as List<Map<String, dynamic>>;
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<Map<String, dynamic>>).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<ByteData>.error(StateError('not an asset')),
);

final item = (json['items']! as List<Map<String, dynamic>>).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);
});
}
58 changes: 58 additions & 0 deletions packages/tray_manager/test/set_context_menu_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <String, Completer<ByteData>>{};
final receivedMenus = <Map<dynamic, dynamic>>[];

messenger.setMockMessageHandler('flutter/assets', (message) {
final key = utf8.decode(message!.buffer.asUint8List());
final completer = Completer<ByteData>();
assetLoads[key] = completer;
return completer.future;
});
messenger.setMockMethodCallHandler(trayChannel, (call) async {
if (call.method == 'setContextMenu') {
final arguments = call.arguments as Map<dynamic, dynamic>;
receivedMenus.add(arguments['menu'] as Map<dynamic, dynamic>);
}
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<void>.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<dynamic>;
expect((items.single as Map<dynamic, dynamic>)['label'], 'Second');
});
}