Flutter macOS Embedder
FlutterMenuPlugin.mm
Go to the documentation of this file.
1 // Copyright 2013 The Flutter Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 #import "FlutterMenuPlugin.h"
6 
7 #include <map>
8 
12 
13 // Channel constants
14 static NSString* const kChannelName = @"flutter/menu";
15 static NSString* const kIsPluginAvailableMethod = @"Menu.isPluginAvailable";
16 static NSString* const kMenuSetMenusMethod = @"Menu.setMenus";
17 static NSString* const kMenuSelectedCallbackMethod = @"Menu.selectedCallback";
18 static NSString* const kMenuOpenedMethod = @"Menu.opened";
19 static NSString* const kMenuClosedMethod = @"Menu.closed";
20 
21 // Serialization keys for menu objects
22 static NSString* const kIdKey = @"id";
23 static NSString* const kLabelKey = @"label";
24 static NSString* const kTooltipKey = @"tooltip";
25 static NSString* const kEnabledKey = @"enabled";
26 static NSString* const kChildrenKey = @"children";
27 static NSString* const kDividerKey = @"isDivider";
28 static NSString* const kShortcutCharacterKey = @"shortcutCharacter";
29 static NSString* const kShortcutTriggerKey = @"shortcutTrigger";
30 static NSString* const kShortcutModifiersKey = @"shortcutModifiers";
31 static NSString* const kPlatformProvidedMenuKey = @"platformProvidedMenu";
32 
33 // Key shortcut constants
34 constexpr int kFlutterShortcutModifierMeta = 1 << 0;
35 constexpr int kFlutterShortcutModifierShift = 1 << 1;
36 constexpr int kFlutterShortcutModifierAlt = 1 << 2;
37 constexpr int kFlutterShortcutModifierControl = 1 << 3;
38 
39 constexpr uint64_t kFlutterKeyIdPlaneMask = 0xff00000000l;
40 constexpr uint64_t kFlutterKeyIdUnicodePlane = 0x0000000000l;
41 constexpr uint64_t kFlutterKeyIdValueMask = 0x00ffffffffl;
42 
43 static const NSDictionary* logicalKeyToKeyCode = {};
44 
45 // What to look for in menu titles to replace with the application name.
46 static NSString* const kAppName = @"APP_NAME";
47 
48 // Odd facts about AppKit key equivalents:
49 //
50 // 1) ⌃⇧1 and ⇧1 cannot exist in the same app, or the former triggers the latter’s
51 // action.
52 // 2) ⌃⌥⇧1 and ⇧1 cannot exist in the same app, or the former triggers the latter’s
53 // action.
54 // 3) ⌃⌥⇧1 and ⌃⇧1 cannot exist in the same app, or the former triggers the latter’s
55 // action.
56 // 4) ⌃⇧a is equivalent to ⌃A: If a keyEquivalent is a capitalized alphabetical
57 // letter and keyEquivalentModifierMask does not include
58 // NSEventModifierFlagShift, AppKit will add ⇧ automatically in the UI.
59 
60 /**
61  * Maps the string used by NSMenuItem for the given special key equivalent.
62  * Keys are the logical key ids of matching trigger keys.
63  */
64 static NSDictionary<NSNumber*, NSNumber*>* GetMacOsSpecialKeys() {
65  return @{
66  @0x00100000008 : [NSNumber numberWithInt:NSBackspaceCharacter],
67  @0x00100000009 : [NSNumber numberWithInt:NSTabCharacter],
68  @0x0010000000a : [NSNumber numberWithInt:NSNewlineCharacter],
69  @0x0010000000c : [NSNumber numberWithInt:NSFormFeedCharacter],
70  @0x0010000000d : [NSNumber numberWithInt:NSCarriageReturnCharacter],
71  @0x0010000007f : [NSNumber numberWithInt:NSDeleteCharacter],
72  @0x00100000801 : [NSNumber numberWithInt:NSF1FunctionKey],
73  @0x00100000802 : [NSNumber numberWithInt:NSF2FunctionKey],
74  @0x00100000803 : [NSNumber numberWithInt:NSF3FunctionKey],
75  @0x00100000804 : [NSNumber numberWithInt:NSF4FunctionKey],
76  @0x00100000805 : [NSNumber numberWithInt:NSF5FunctionKey],
77  @0x00100000806 : [NSNumber numberWithInt:NSF6FunctionKey],
78  @0x00100000807 : [NSNumber numberWithInt:NSF7FunctionKey],
79  @0x00100000808 : [NSNumber numberWithInt:NSF8FunctionKey],
80  @0x00100000809 : [NSNumber numberWithInt:NSF9FunctionKey],
81  @0x0010000080a : [NSNumber numberWithInt:NSF10FunctionKey],
82  @0x0010000080b : [NSNumber numberWithInt:NSF11FunctionKey],
83  @0x0010000080c : [NSNumber numberWithInt:NSF12FunctionKey],
84  @0x0010000080d : [NSNumber numberWithInt:NSF13FunctionKey],
85  @0x0010000080e : [NSNumber numberWithInt:NSF14FunctionKey],
86  @0x0010000080f : [NSNumber numberWithInt:NSF15FunctionKey],
87  @0x00100000810 : [NSNumber numberWithInt:NSF16FunctionKey],
88  @0x00100000811 : [NSNumber numberWithInt:NSF17FunctionKey],
89  @0x00100000812 : [NSNumber numberWithInt:NSF18FunctionKey],
90  @0x00100000813 : [NSNumber numberWithInt:NSF19FunctionKey],
91  @0x00100000814 : [NSNumber numberWithInt:NSF20FunctionKey],
92 
93  // For some reason, there don't appear to be constants for these in ObjC. In
94  // Swift, there is a class with static members for these: KeyEquivalent. The
95  // values below are taken from that (where they don't already appear above).
96  @0x00100000302 : @0xf702, // ArrowLeft
97  @0x00100000303 : @0xf703, // ArrowRight
98  @0x00100000304 : @0xf700, // ArrowUp
99  @0x00100000301 : @0xf701, // ArrowDown
100  @0x00100000306 : @0xf729, // Home
101  @0x00100000305 : @0xf72B, // End
102  @0x00100000308 : @0xf72c, // PageUp
103  @0x00100000307 : @0xf72d, // PageDown
104  @0x0010000001b : @0x001B, // Escape
105  };
106 }
107 
108 /**
109  * The mapping from the PlatformProvidedMenu enum to the macOS selectors for the provided
110  * menus.
111  */
112 static const std::map<flutter::PlatformProvidedMenu, SEL> GetMacOSProvidedMenus() {
113  return {
114  {flutter::PlatformProvidedMenu::kAbout, @selector(orderFrontStandardAboutPanel:)},
115  {flutter::PlatformProvidedMenu::kQuit, @selector(terminate:)},
116  // servicesSubmenu is handled specially below: it is assumed to be the first
117  // submenu in the preserved platform provided menus, since it doesn't have a
118  // definitive selector like the rest.
119  {flutter::PlatformProvidedMenu::kServicesSubmenu, @selector(submenuAction:)},
120  {flutter::PlatformProvidedMenu::kHide, @selector(hide:)},
121  {flutter::PlatformProvidedMenu::kHideOtherApplications, @selector(hideOtherApplications:)},
122  {flutter::PlatformProvidedMenu::kShowAllApplications, @selector(unhideAllApplications:)},
123  {flutter::PlatformProvidedMenu::kStartSpeaking, @selector(startSpeaking:)},
124  {flutter::PlatformProvidedMenu::kStopSpeaking, @selector(stopSpeaking:)},
125  {flutter::PlatformProvidedMenu::kToggleFullScreen, @selector(toggleFullScreen:)},
126  {flutter::PlatformProvidedMenu::kMinimizeWindow, @selector(performMiniaturize:)},
127  {flutter::PlatformProvidedMenu::kZoomWindow, @selector(performZoom:)},
128  {flutter::PlatformProvidedMenu::kArrangeWindowsInFront, @selector(arrangeInFront:)},
129  };
130 }
131 
132 /**
133  * Returns the NSEventModifierFlags of |modifiers|, a value from
134  * kShortcutKeyModifiers.
135  */
136 static NSEventModifierFlags KeyEquivalentModifierMaskForModifiers(NSNumber* modifiers) {
137  int flutterModifierFlags = modifiers.intValue;
138  NSEventModifierFlags flags = 0;
139  if (flutterModifierFlags & kFlutterShortcutModifierMeta) {
140  flags |= NSEventModifierFlagCommand;
141  }
142  if (flutterModifierFlags & kFlutterShortcutModifierShift) {
143  flags |= NSEventModifierFlagShift;
144  }
145  if (flutterModifierFlags & kFlutterShortcutModifierAlt) {
146  flags |= NSEventModifierFlagOption;
147  }
148  if (flutterModifierFlags & kFlutterShortcutModifierControl) {
149  flags |= NSEventModifierFlagControl;
150  }
151  // There are also modifier flags for things like the function (Fn) key, but
152  // the framework doesn't support those.
153  return flags;
154 }
155 
156 /**
157  * An NSMenuDelegate used to listen for changes in the menu when it opens and
158  * closes.
159  */
160 @interface FlutterMenuDelegate : NSObject <NSMenuDelegate>
161 /**
162  * When this delegate receives notification that the menu opened or closed, it
163  * will send a message on the given channel to that effect for the menu item
164  * with the given id (the ID comes from the data supplied by the framework to
165  * |FlutterMenuPlugin.setMenus|).
166  */
167 - (instancetype)initWithIdentifier:(int64_t)identifier channel:(FlutterMethodChannel*)channel;
168 @end
169 
170 @implementation FlutterMenuDelegate {
171  FlutterMethodChannel* _channel;
172  int64_t _identifier;
173 }
174 
175 - (instancetype)initWithIdentifier:(int64_t)identifier channel:(FlutterMethodChannel*)channel {
176  self = [super init];
177  if (self) {
178  _identifier = identifier;
179  _channel = channel;
180  }
181  return self;
182 }
183 
184 - (void)menuWillOpen:(NSMenu*)menu {
185  [_channel invokeMethod:kMenuOpenedMethod arguments:@(_identifier)];
186 }
187 
188 - (void)menuDidClose:(NSMenu*)menu {
189  [_channel invokeMethod:kMenuClosedMethod arguments:@(_identifier)];
190 }
191 @end
192 
193 @interface FlutterMenuPlugin ()
194 // Initialize the plugin with the given method channel.
195 - (instancetype)initWithChannel:(FlutterMethodChannel*)channel;
196 
197 // Iterates through the given menu hierarchy, and replaces "APP_NAME"
198 // with the localized running application name.
199 - (void)replaceAppName:(NSArray<NSMenuItem*>*)items;
200 
201 // Look up the menu item with the given selector in the list of provided menus
202 // and return it.
203 - (NSMenuItem*)findProvidedMenuItem:(NSMenu*)menu ofType:(SEL)selector;
204 
205 // Create a platform-provided menu from the given enum type.
206 - (NSMenuItem*)createPlatformProvidedMenu:(flutter::PlatformProvidedMenu)type;
207 
208 // Create an NSMenuItem from information in the dictionary sent by the framework.
209 - (NSMenuItem*)menuItemFromFlutterRepresentation:(NSDictionary*)representation;
210 
211 // Invokes kMenuSelectedCallbackMethod with the senders ID.
212 //
213 // Used as the callback for all Flutter-created menu items that have IDs.
214 - (void)flutterMenuItemSelected:(id)sender;
215 
216 // Replaces the NSApp.mainMenu with menus created from an array of top level
217 // menus sent by the framework.
218 - (void)setMenus:(nonnull NSDictionary*)representation;
219 @end
220 
221 @implementation FlutterMenuPlugin {
222  // The channel used to communicate with Flutter.
223  FlutterMethodChannel* _channel;
224 
225  // This contains a copy of the default platform provided items.
226  NSArray<NSMenuItem*>* _platformProvidedItems;
227  // These are the menu delegates that will listen to open/close events for menu
228  // items. This array is holding them so that we can deallocate them when
229  // rebuilding the menus.
230  NSMutableArray<FlutterMenuDelegate*>* _menuDelegates;
231 }
232 
233 #pragma mark - Private Methods
234 
235 - (instancetype)initWithChannel:(FlutterMethodChannel*)channel {
236  self = [super init];
237  if (self) {
238  _channel = channel;
240  _menuDelegates = [[NSMutableArray alloc] init];
241 
242  // Make a copy of all the platform provided menus for later use.
243  _platformProvidedItems = [[NSApp.mainMenu itemArray] mutableCopy];
244 
245  // As copied, these platform provided menu items don't yet have the APP_NAME
246  // string replaced in them, so this rectifies that.
247  [self replaceAppName:_platformProvidedItems];
248  }
249  return self;
250 }
251 
252 /**
253  * Iterates through the given menu hierarchy, and replaces "APP_NAME"
254  * with the localized running application name.
255  */
256 - (void)replaceAppName:(NSArray<NSMenuItem*>*)items {
257  NSString* appName = [NSRunningApplication currentApplication].localizedName;
258  for (NSMenuItem* item in items) {
259  if ([[item title] containsString:kAppName]) {
260  [item setTitle:[[item title] stringByReplacingOccurrencesOfString:kAppName
261  withString:appName]];
262  }
263  if ([[item toolTip] containsString:kAppName]) {
264  [item setToolTip:[[item toolTip] stringByReplacingOccurrencesOfString:kAppName
265  withString:appName]];
266  }
267  if ([item hasSubmenu]) {
268  [self replaceAppName:[[item submenu] itemArray]];
269  }
270  }
271 }
272 
273 - (NSMenuItem*)findProvidedMenuItem:(NSMenu*)menu ofType:(SEL)selector {
274  const NSArray<NSMenuItem*>* items = menu ? menu.itemArray : _platformProvidedItems;
275  for (NSMenuItem* item in items) {
276  if ([item action] == selector) {
277  return item;
278  }
279  if ([[item submenu] numberOfItems] > 0) {
280  NSMenuItem* foundChild = [self findProvidedMenuItem:[item submenu] ofType:selector];
281  if (foundChild) {
282  return foundChild;
283  }
284  }
285  }
286  return nil;
287 }
288 
289 - (NSMenuItem*)createPlatformProvidedMenu:(flutter::PlatformProvidedMenu)type {
290  const std::map<flutter::PlatformProvidedMenu, SEL> providedMenus = GetMacOSProvidedMenus();
291  auto found_type = providedMenus.find(type);
292  if (found_type == providedMenus.end()) {
293  return nil;
294  }
295  SEL selectorTarget = found_type->second;
296  // Since it doesn't have a definitive selector, the Services submenu is
297  // assumed to be the first item with a submenu action in the first menu item
298  // of the default menu set. We can't just get the title to check, since that
299  // is localized, and the contents of the menu aren't fixed (or even available).
300  NSMenu* startingMenu = type == flutter::PlatformProvidedMenu::kServicesSubmenu
301  ? [_platformProvidedItems[0] submenu]
302  : nil;
303  NSMenuItem* found = [self findProvidedMenuItem:startingMenu ofType:selectorTarget];
304  // Return a copy because the original menu item might not have been removed
305  // from the main menu yet, and AppKit doesn't like menu items that exist in
306  // more than one menu at a time.
307  return [found copy];
308 }
309 
310 - (NSMenuItem*)menuItemFromFlutterRepresentation:(NSDictionary*)representation {
311  if ([(NSNumber*)([representation valueForKey:kDividerKey]) intValue] == YES) {
312  return [NSMenuItem separatorItem];
313  }
314  NSNumber* platformProvidedMenuId = representation[kPlatformProvidedMenuKey];
315  NSString* keyEquivalent = @"";
316 
317  if (platformProvidedMenuId) {
318  return [self
319  createPlatformProvidedMenu:(flutter::PlatformProvidedMenu)platformProvidedMenuId.intValue];
320  } else {
321  if (representation[kShortcutCharacterKey]) {
322  keyEquivalent = representation[kShortcutCharacterKey];
323  } else {
324  NSNumber* triggerKeyId = representation[kShortcutTriggerKey];
325  const NSDictionary<NSNumber*, NSNumber*>* specialKeys = GetMacOsSpecialKeys();
326  NSNumber* trigger = specialKeys[triggerKeyId];
327  if (trigger) {
328  keyEquivalent = [NSString stringWithFormat:@"%C", [trigger unsignedShortValue]];
329  } else {
330  if (([triggerKeyId unsignedLongLongValue] & kFlutterKeyIdPlaneMask) ==
332  keyEquivalent = [[NSString
333  stringWithFormat:@"%C", (unichar)([triggerKeyId unsignedLongLongValue] &
334  kFlutterKeyIdValueMask)] lowercaseString];
335  }
336  }
337  }
338  }
339 
340  NSNumber* identifier = representation[kIdKey];
341  SEL action = (identifier ? @selector(flutterMenuItemSelected:) : NULL);
342  NSString* appName = [NSRunningApplication currentApplication].localizedName;
343  NSString* title = [representation[kLabelKey] stringByReplacingOccurrencesOfString:kAppName
344  withString:appName];
345  NSMenuItem* item = [[NSMenuItem alloc] initWithTitle:title
346  action:action
347  keyEquivalent:keyEquivalent];
348  if (representation[kTooltipKey]) {
349  item.toolTip = [representation[kTooltipKey] stringByReplacingOccurrencesOfString:kAppName
350  withString:appName];
351  }
352  if ([keyEquivalent length] > 0) {
353  item.keyEquivalentModifierMask =
355  }
356  if (identifier) {
357  item.tag = identifier.longLongValue;
358  item.target = self;
359  }
360  NSNumber* enabled = representation[kEnabledKey];
361  if (enabled) {
362  item.enabled = enabled.boolValue;
363  }
364 
365  NSArray* children = representation[kChildrenKey];
366  if (children && children.count > 0) {
367  NSMenu* submenu = [[NSMenu alloc] initWithTitle:title];
368  FlutterMenuDelegate* delegate = [[FlutterMenuDelegate alloc] initWithIdentifier:item.tag
369  channel:_channel];
370  [_menuDelegates addObject:delegate];
371  submenu.delegate = delegate;
372  submenu.autoenablesItems = NO;
373  for (NSDictionary* child in children) {
374  NSMenuItem* newItem = [self menuItemFromFlutterRepresentation:child];
375  if (newItem) {
376  [submenu addItem:newItem];
377  }
378  }
379  item.submenu = submenu;
380  }
381  return item;
382 }
383 
384 - (void)flutterMenuItemSelected:(id)sender {
385  NSMenuItem* item = sender;
386  [_channel invokeMethod:kMenuSelectedCallbackMethod arguments:@(item.tag)];
387 }
388 
389 - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
390  if ([call.method isEqualToString:kIsPluginAvailableMethod]) {
391  result(@YES);
392  } else if ([call.method isEqualToString:kMenuSetMenusMethod]) {
393  NSDictionary* menus = call.arguments;
394  [self setMenus:menus];
395  result(nil);
396  } else {
398  }
399 }
400 
401 - (void)setMenus:(NSDictionary*)representation {
402  [_menuDelegates removeAllObjects];
403  NSMenu* newMenu = [[NSMenu alloc] init];
404  // There's currently only one window, named "0", but there could be other
405  // eventually, with different menu configurations.
406  for (NSDictionary* item in representation[@"0"]) {
407  NSMenuItem* menuItem = [self menuItemFromFlutterRepresentation:item];
408  menuItem.representedObject = self;
409  NSNumber* identifier = item[kIdKey];
410  FlutterMenuDelegate* delegate =
411  [[FlutterMenuDelegate alloc] initWithIdentifier:identifier.longLongValue channel:_channel];
412  [_menuDelegates addObject:delegate];
413  [menuItem submenu].delegate = delegate;
414  [newMenu addItem:menuItem];
415  }
416  NSApp.mainMenu = newMenu;
417 }
418 
419 #pragma mark - Public Class Methods
420 
421 + (void)registerWithRegistrar:(nonnull id<FlutterPluginRegistrar>)registrar {
423  binaryMessenger:registrar.messenger];
424  FlutterMenuPlugin* instance = [[FlutterMenuPlugin alloc] initWithChannel:channel];
425  [registrar addMethodCallDelegate:instance channel:channel];
426 }
427 
428 @end
void(^ FlutterResult)(id _Nullable result)
FLUTTER_DARWIN_EXPORT NSObject const * FlutterMethodNotImplemented
constexpr int kFlutterShortcutModifierControl
constexpr uint64_t kFlutterKeyIdValueMask
constexpr int kFlutterShortcutModifierAlt
static NSString *const kAppName
static NSString *const kIsPluginAvailableMethod
static NSString *const kMenuClosedMethod
NSArray< NSMenuItem * > * _platformProvidedItems
constexpr int kFlutterShortcutModifierMeta
static NSString *const kPlatformProvidedMenuKey
constexpr uint64_t kFlutterKeyIdPlaneMask
NSMutableArray< FlutterMenuDelegate * > * _menuDelegates
static NSDictionary< NSNumber *, NSNumber * > * GetMacOsSpecialKeys()
static NSString *const kShortcutTriggerKey
static NSEventModifierFlags KeyEquivalentModifierMaskForModifiers(NSNumber *modifiers)
static const std::map< flutter::PlatformProvidedMenu, SEL > GetMacOSProvidedMenus()
static NSString *const kChannelName
static NSString *const kShortcutCharacterKey
static NSString *const kMenuOpenedMethod
static NSString *const kTooltipKey
static NSString *const kDividerKey
static NSString *const kEnabledKey
constexpr int kFlutterShortcutModifierShift
static NSString *const kMenuSetMenusMethod
constexpr uint64_t kFlutterKeyIdUnicodePlane
static const NSDictionary * logicalKeyToKeyCode
static NSString *const kShortcutModifiersKey
static NSString *const kMenuSelectedCallbackMethod
static NSString *const kChildrenKey
static NSString *const kIdKey
int64_t _identifier
static NSString *const kLabelKey
instancetype methodChannelWithName:binaryMessenger:(NSString *name,[binaryMessenger] NSObject< FlutterBinaryMessenger > *messenger)
void addMethodCallDelegate:channel:(nonnull id< FlutterPlugin > delegate,[channel] nonnull FlutterMethodChannel *channel)