1// Copyright 2013 The Chromium 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 "chrome/browser/ui/cocoa/apps/app_shim_menu_controller_mac.h"
6
7#include "base/mac/scoped_nsautorelease_pool.h"
8#include "base/strings/sys_string_conversions.h"
9#include "base/strings/utf_string_conversions.h"
10#include "chrome/app/chrome_command_ids.h"
11#include "chrome/browser/apps/app_shim/extension_app_shim_handler_mac.h"
12#include "chrome/browser/apps/app_window_registry_util.h"
13#import "chrome/browser/ui/cocoa/apps/native_app_window_cocoa.h"
14#include "chrome/grit/generated_resources.h"
15#include "extensions/browser/app_window/app_window.h"
16#include "extensions/common/extension.h"
17#include "ui/base/l10n/l10n_util.h"
18#include "ui/base/l10n/l10n_util_mac.h"
19
20namespace {
21
22// Gets an item from the main menu given the tag of the top level item
23// |menu_tag| and the tag of the item |item_tag|.
24NSMenuItem* GetItemByTag(NSInteger menu_tag, NSInteger item_tag) {
25  return [[[[NSApp mainMenu] itemWithTag:menu_tag] submenu]
26      itemWithTag:item_tag];
27}
28
29// Finds a top level menu item using |menu_tag| and creates a new NSMenuItem
30// with the same title.
31NSMenuItem* NewTopLevelItemFrom(NSInteger menu_tag) {
32  NSMenuItem* original = [[NSApp mainMenu] itemWithTag:menu_tag];
33  base::scoped_nsobject<NSMenuItem> item([[NSMenuItem alloc]
34      initWithTitle:[original title]
35             action:nil
36      keyEquivalent:@""]);
37  DCHECK([original hasSubmenu]);
38  base::scoped_nsobject<NSMenu> sub_menu([[NSMenu alloc]
39      initWithTitle:[[original submenu] title]]);
40  [item setSubmenu:sub_menu];
41  return item.autorelease();
42}
43
44// Finds an item using |menu_tag| and |item_tag| and adds a duplicate of it to
45// the submenu of |top_level_item|.
46void AddDuplicateItem(NSMenuItem* top_level_item,
47                      NSInteger menu_tag,
48                      NSInteger item_tag) {
49  base::scoped_nsobject<NSMenuItem> item(
50      [GetItemByTag(menu_tag, item_tag) copy]);
51  DCHECK(item);
52  [[top_level_item submenu] addItem:item];
53}
54
55}  // namespace
56
57// Used by AppShimMenuController to manage menu items that are a copy of a
58// Chrome menu item but with a different action. This manages unsetting and
59// restoring the original item's key equivalent, so that we can use the same
60// key equivalent in the copied item with a different action. If |resourceId_|
61// is non-zero, this will also update the title to include the app name.
62// If the copy (menuItem) has no key equivalent, and the title does not have the
63// app name, then enableForApp and disable do not need to be called. I.e. the
64// doppelganger just copies the item and sets a new action.
65@interface DoppelgangerMenuItem : NSObject {
66 @private
67  base::scoped_nsobject<NSMenuItem> menuItem_;
68  base::scoped_nsobject<NSMenuItem> sourceItem_;
69  base::scoped_nsobject<NSString> sourceKeyEquivalent_;
70  int resourceId_;
71}
72
73@property(readonly, nonatomic) NSMenuItem* menuItem;
74
75// Get the source item using the tags and create the menu item.
76- (id)initWithController:(AppShimMenuController*)controller
77                 menuTag:(NSInteger)menuTag
78                 itemTag:(NSInteger)itemTag
79              resourceId:(int)resourceId
80                  action:(SEL)action
81           keyEquivalent:(NSString*)keyEquivalent;
82// Set the title using |resourceId_| and unset the source item's key equivalent.
83- (void)enableForApp:(const extensions::Extension*)app;
84// Restore the source item's key equivalent.
85- (void)disable;
86@end
87
88@implementation DoppelgangerMenuItem
89
90- (NSMenuItem*)menuItem {
91  return menuItem_;
92}
93
94- (id)initWithController:(AppShimMenuController*)controller
95                 menuTag:(NSInteger)menuTag
96                 itemTag:(NSInteger)itemTag
97              resourceId:(int)resourceId
98                  action:(SEL)action
99           keyEquivalent:(NSString*)keyEquivalent {
100  if ((self = [super init])) {
101    sourceItem_.reset([GetItemByTag(menuTag, itemTag) retain]);
102    DCHECK(sourceItem_);
103    sourceKeyEquivalent_.reset([[sourceItem_ keyEquivalent] copy]);
104    menuItem_.reset([[NSMenuItem alloc]
105        initWithTitle:[sourceItem_ title]
106               action:action
107        keyEquivalent:keyEquivalent]);
108    [menuItem_ setTarget:controller];
109    [menuItem_ setTag:itemTag];
110    resourceId_ = resourceId;
111  }
112  return self;
113}
114
115- (void)enableForApp:(const extensions::Extension*)app {
116  // It seems that two menu items that have the same key equivalent must also
117  // have the same action for the keyboard shortcut to work. (This refers to the
118  // original keyboard shortcut, regardless of any overrides set in OSX).
119  // In order to let the app menu items have a different action, we remove the
120  // key equivalent of the original items and restore them later.
121  [sourceItem_ setKeyEquivalent:@""];
122  if (!resourceId_)
123    return;
124
125  [menuItem_ setTitle:l10n_util::GetNSStringF(resourceId_,
126                                              base::UTF8ToUTF16(app->name()))];
127}
128
129- (void)disable {
130  // Restore the keyboard shortcut to Chrome. This just needs to be set back to
131  // the original keyboard shortcut, regardless of any overrides in OSX. The
132  // overrides still work as they are based on the title of the menu item.
133  [sourceItem_ setKeyEquivalent:sourceKeyEquivalent_];
134}
135
136@end
137
138@interface AppShimMenuController ()
139// Construct the NSMenuItems for apps.
140- (void)buildAppMenuItems;
141// Register for NSWindow notifications.
142- (void)registerEventHandlers;
143// If the window is an app window, add or remove menu items.
144- (void)windowMainStatusChanged:(NSNotification*)notification;
145// Add menu items for an app and hide Chrome menu items.
146- (void)addMenuItems:(const extensions::Extension*)app;
147// If the window belongs to the currently focused app, remove the menu items and
148// unhide Chrome menu items.
149- (void)removeMenuItems;
150// If the currently focused window belongs to a platform app, quit the app.
151- (void)quitCurrentPlatformApp;
152// If the currently focused window belongs to a platform app, hide the app.
153- (void)hideCurrentPlatformApp;
154// If the currently focused window belongs to a platform app, focus the app.
155- (void)focusCurrentPlatformApp;
156@end
157
158@implementation AppShimMenuController
159
160- (id)init {
161  if ((self = [super init])) {
162    [self buildAppMenuItems];
163    [self registerEventHandlers];
164  }
165  return self;
166}
167
168- (void)dealloc {
169  [[NSNotificationCenter defaultCenter] removeObserver:self];
170  [super dealloc];
171}
172
173- (void)buildAppMenuItems {
174  aboutDoppelganger_.reset([[DoppelgangerMenuItem alloc]
175      initWithController:self
176                 menuTag:IDC_CHROME_MENU
177                 itemTag:IDC_ABOUT
178              resourceId:IDS_ABOUT_MAC
179                  action:nil
180           keyEquivalent:@""]);
181  hideDoppelganger_.reset([[DoppelgangerMenuItem alloc]
182      initWithController:self
183                 menuTag:IDC_CHROME_MENU
184                 itemTag:IDC_HIDE_APP
185              resourceId:IDS_HIDE_APP_MAC
186                  action:@selector(hideCurrentPlatformApp)
187           keyEquivalent:@"h"]);
188  quitDoppelganger_.reset([[DoppelgangerMenuItem alloc]
189      initWithController:self
190                 menuTag:IDC_CHROME_MENU
191                 itemTag:IDC_EXIT
192              resourceId:IDS_EXIT_MAC
193                  action:@selector(quitCurrentPlatformApp)
194           keyEquivalent:@"q"]);
195  newDoppelganger_.reset([[DoppelgangerMenuItem alloc]
196      initWithController:self
197                 menuTag:IDC_FILE_MENU
198                 itemTag:IDC_NEW_WINDOW
199              resourceId:0
200                  action:nil
201           keyEquivalent:@"n"]);
202  // For apps, the "Window" part of "New Window" is dropped to match the default
203  // menu set given to Cocoa Apps.
204  [[newDoppelganger_ menuItem] setTitle:l10n_util::GetNSString(IDS_NEW_MAC)];
205  openDoppelganger_.reset([[DoppelgangerMenuItem alloc]
206      initWithController:self
207                 menuTag:IDC_FILE_MENU
208                 itemTag:IDC_OPEN_FILE
209              resourceId:0
210                  action:nil
211           keyEquivalent:@"o"]);
212  allToFrontDoppelganger_.reset([[DoppelgangerMenuItem alloc]
213      initWithController:self
214                 menuTag:IDC_WINDOW_MENU
215                 itemTag:IDC_ALL_WINDOWS_FRONT
216              resourceId:0
217                  action:@selector(focusCurrentPlatformApp)
218           keyEquivalent:@""]);
219
220  // The app's menu.
221  appMenuItem_.reset([[NSMenuItem alloc] initWithTitle:@""
222                                                action:nil
223                                         keyEquivalent:@""]);
224  base::scoped_nsobject<NSMenu> appMenu([[NSMenu alloc] initWithTitle:@""]);
225  [appMenuItem_ setSubmenu:appMenu];
226  [appMenu setAutoenablesItems:NO];
227
228  [appMenu addItem:[aboutDoppelganger_ menuItem]];
229  [[aboutDoppelganger_ menuItem] setEnabled:NO];  // Not implemented yet.
230  [appMenu addItem:[NSMenuItem separatorItem]];
231  [appMenu addItem:[hideDoppelganger_ menuItem]];
232  [appMenu addItem:[NSMenuItem separatorItem]];
233  [appMenu addItem:[quitDoppelganger_ menuItem]];
234
235  // File menu.
236  fileMenuItem_.reset([NewTopLevelItemFrom(IDC_FILE_MENU) retain]);
237  [[fileMenuItem_ submenu] addItem:[newDoppelganger_ menuItem]];
238  [[fileMenuItem_ submenu] addItem:[openDoppelganger_ menuItem]];
239  [[fileMenuItem_ submenu] addItem:[NSMenuItem separatorItem]];
240  AddDuplicateItem(fileMenuItem_, IDC_FILE_MENU, IDC_CLOSE_WINDOW);
241  // Set the expected key equivalent explicitly here because
242  // -[AppControllerMac adjustCloseWindowMenuItemKeyEquivalent:] sets it to
243  // "W" (Cmd+Shift+w) when a tabbed window has focus; it will change it back
244  // to Cmd+w when a non-tabbed window has focus.
245  [[[fileMenuItem_ submenu] itemWithTag:IDC_CLOSE_WINDOW]
246      setKeyEquivalent:@"w"];
247
248  // Edit menu. This copies the menu entirely and removes
249  // "Paste and Match Style" and "Find". This is because the last two items,
250  // "Start Dictation" and "Special Characters" are added by OSX, so we can't
251  // copy them explicitly.
252  editMenuItem_.reset([[[NSApp mainMenu] itemWithTag:IDC_EDIT_MENU] copy]);
253  NSMenu* editMenu = [editMenuItem_ submenu];
254  [editMenu removeItem:[editMenu
255      itemWithTag:IDC_CONTENT_CONTEXT_PASTE_AND_MATCH_STYLE]];
256  [editMenu removeItem:[editMenu itemWithTag:IDC_FIND_MENU]];
257
258  // Window menu.
259  windowMenuItem_.reset([NewTopLevelItemFrom(IDC_WINDOW_MENU) retain]);
260  AddDuplicateItem(windowMenuItem_, IDC_WINDOW_MENU, IDC_MINIMIZE_WINDOW);
261  AddDuplicateItem(windowMenuItem_, IDC_WINDOW_MENU, IDC_MAXIMIZE_WINDOW);
262  [[windowMenuItem_ submenu] addItem:[NSMenuItem separatorItem]];
263  [[windowMenuItem_ submenu] addItem:[allToFrontDoppelganger_ menuItem]];
264}
265
266- (void)registerEventHandlers {
267  [[NSNotificationCenter defaultCenter]
268      addObserver:self
269         selector:@selector(windowMainStatusChanged:)
270             name:NSWindowDidBecomeMainNotification
271           object:nil];
272
273  [[NSNotificationCenter defaultCenter]
274      addObserver:self
275         selector:@selector(windowMainStatusChanged:)
276             name:NSWindowWillCloseNotification
277           object:nil];
278}
279
280- (void)windowMainStatusChanged:(NSNotification*)notification {
281  // A Yosemite AppKit bug causes this notification to be sent during the
282  // -dealloc for a specific NSWindow. Any autoreleases sent to that window
283  // must be drained before the window finishes -dealloc. In this method, an
284  // autorelease is sent by the invocation of [NSApp windows].
285  // http://crbug.com/406944.
286  base::mac::ScopedNSAutoreleasePool pool;
287
288  id window = [notification object];
289  NSString* name = [notification name];
290  if ([name isEqualToString:NSWindowDidBecomeMainNotification]) {
291    extensions::AppWindow* appWindow =
292        AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile(
293            window);
294
295    const extensions::Extension* extension = NULL;
296    if (appWindow)
297      extension = appWindow->GetExtension();
298
299    if (extension)
300      [self addMenuItems:extension];
301    else
302      [self removeMenuItems];
303  } else if ([name isEqualToString:NSWindowWillCloseNotification]) {
304    // If there are any other windows that can become main, leave the menu. It
305    // will be changed when another window becomes main. Otherwise, restore the
306    // Chrome menu.
307    for (NSWindow* w : [NSApp windows]) {
308      if ([w canBecomeMainWindow] && ![w isEqual:window])
309        return;
310    }
311
312    [self removeMenuItems];
313  } else {
314    NOTREACHED();
315  }
316}
317
318- (void)addMenuItems:(const extensions::Extension*)app {
319  NSString* appId = base::SysUTF8ToNSString(app->id());
320  NSString* title = base::SysUTF8ToNSString(app->name());
321
322  if ([appId_ isEqualToString:appId])
323    return;
324
325  [self removeMenuItems];
326  appId_.reset([appId copy]);
327
328  // Hide Chrome menu items.
329  NSMenu* mainMenu = [NSApp mainMenu];
330  for (NSMenuItem* item in [mainMenu itemArray])
331    [item setHidden:YES];
332
333  [aboutDoppelganger_ enableForApp:app];
334  [hideDoppelganger_ enableForApp:app];
335  [quitDoppelganger_ enableForApp:app];
336  [newDoppelganger_ enableForApp:app];
337  [openDoppelganger_ enableForApp:app];
338
339  [appMenuItem_ setTitle:appId];
340  [[appMenuItem_ submenu] setTitle:title];
341
342  [mainMenu addItem:appMenuItem_];
343  [mainMenu addItem:fileMenuItem_];
344  [mainMenu addItem:editMenuItem_];
345  [mainMenu addItem:windowMenuItem_];
346}
347
348- (void)removeMenuItems {
349  if (!appId_)
350    return;
351
352  appId_.reset();
353
354  NSMenu* mainMenu = [NSApp mainMenu];
355  [mainMenu removeItem:appMenuItem_];
356  [mainMenu removeItem:fileMenuItem_];
357  [mainMenu removeItem:editMenuItem_];
358  [mainMenu removeItem:windowMenuItem_];
359
360  // Restore the Chrome main menu bar.
361  for (NSMenuItem* item in [mainMenu itemArray])
362    [item setHidden:NO];
363
364  [aboutDoppelganger_ disable];
365  [hideDoppelganger_ disable];
366  [quitDoppelganger_ disable];
367  [newDoppelganger_ disable];
368  [openDoppelganger_ disable];
369}
370
371- (void)quitCurrentPlatformApp {
372  extensions::AppWindow* appWindow =
373      AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile(
374          [NSApp keyWindow]);
375  if (appWindow)
376    apps::ExtensionAppShimHandler::QuitAppForWindow(appWindow);
377}
378
379- (void)hideCurrentPlatformApp {
380  extensions::AppWindow* appWindow =
381      AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile(
382          [NSApp keyWindow]);
383  if (appWindow)
384    apps::ExtensionAppShimHandler::HideAppForWindow(appWindow);
385}
386
387- (void)focusCurrentPlatformApp {
388  extensions::AppWindow* appWindow =
389      AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile(
390          [NSApp keyWindow]);
391  if (appWindow)
392    apps::ExtensionAppShimHandler::FocusAppForWindow(appWindow);
393}
394
395@end
396