// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "chrome/browser/ui/cocoa/apps/app_shim_menu_controller_mac.h" #include "base/command_line.h" #include "base/mac/scoped_nsautorelease_pool.h" #include "base/strings/sys_string_conversions.h" #include "base/strings/utf_string_conversions.h" #include "chrome/app/chrome_command_ids.h" #include "chrome/browser/apps/app_shim/extension_app_shim_handler_mac.h" #include "chrome/browser/apps/app_window_registry_util.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/ui/browser.h" #include "chrome/browser/ui/browser_finder.h" #import "chrome/browser/ui/cocoa/apps/native_app_window_cocoa.h" #include "chrome/common/chrome_switches.h" #include "chrome/grit/generated_resources.h" #include "extensions/browser/app_window/app_window.h" #include "extensions/common/extension.h" #include "ui/base/l10n/l10n_util.h" #include "ui/base/l10n/l10n_util_mac.h" using extensions::Extension; namespace { // When an app window loses main status, AppKit may make another app window main // instead. Rather than trying to predict what AppKit will do (which is hard), // just protect against changes in the event queue that will clobber each other. int g_window_cycle_sequence_number = 0; // Whether Custom Cmd+` window cycling is enabled for apps. bool IsAppWindowCyclingEnabled() { base::CommandLine* command_line = base::CommandLine::ForCurrentProcess(); if (command_line->HasSwitch(switches::kDisableAppWindowCycling)) return false; if (command_line->HasSwitch(switches::kEnableAppWindowCycling)) return true; return false; // Current default. } // Gets an item from the main menu given the tag of the top level item // |menu_tag| and the tag of the item |item_tag|. NSMenuItem* GetItemByTag(NSInteger menu_tag, NSInteger item_tag) { return [[[[NSApp mainMenu] itemWithTag:menu_tag] submenu] itemWithTag:item_tag]; } // Finds a top level menu item using |menu_tag| and creates a new NSMenuItem // with the same title. NSMenuItem* NewTopLevelItemFrom(NSInteger menu_tag) { NSMenuItem* original = [[NSApp mainMenu] itemWithTag:menu_tag]; base::scoped_nsobject<NSMenuItem> item([[NSMenuItem alloc] initWithTitle:[original title] action:nil keyEquivalent:@""]); DCHECK([original hasSubmenu]); base::scoped_nsobject<NSMenu> sub_menu([[NSMenu alloc] initWithTitle:[[original submenu] title]]); [item setSubmenu:sub_menu]; return item.autorelease(); } // Finds an item using |menu_tag| and |item_tag| and adds a duplicate of it to // the submenu of |top_level_item|. void AddDuplicateItem(NSMenuItem* top_level_item, NSInteger menu_tag, NSInteger item_tag) { base::scoped_nsobject<NSMenuItem> item( [GetItemByTag(menu_tag, item_tag) copy]); DCHECK(item); [[top_level_item submenu] addItem:item]; } // Finds an item with |item_tag| and removes it from the submenu of // |top_level_item|. void RemoveMenuItemWithTag(NSMenuItem* top_level_item, NSInteger item_tag, bool remove_following_separator) { NSMenu* submenu = [top_level_item submenu]; NSInteger index = [submenu indexOfItemWithTag:item_tag]; if (index < 0) return; [submenu removeItemAtIndex:index]; if (!remove_following_separator || index == [submenu numberOfItems]) return; NSMenuItem* nextItem = [submenu itemAtIndex:index]; if ([nextItem isSeparatorItem]) [submenu removeItem:nextItem]; } // Sets the menu item with |item_tag| in |top_level_item| visible. // If |has_alternate| is true, the item immediately following |item_tag| is // assumed to be its (only) alternate. Since AppKit is unable to hide items // with alternates, |has_alternate| will cause -[NSMenuItem alternate] to be // removed when hiding and restored when showing. void SetItemWithTagVisible(NSMenuItem* top_level_item, NSInteger item_tag, bool visible, bool has_alternate) { NSMenu* submenu = [top_level_item submenu]; NSMenuItem* menu_item = [submenu itemWithTag:item_tag]; DCHECK(menu_item); if (visible != [menu_item isHidden]) return; if (!has_alternate) { [menu_item setHidden:!visible]; return; } NSInteger next_index = [submenu indexOfItem:menu_item] + 1; DCHECK_LT(next_index, [submenu numberOfItems]); NSMenuItem* alternate_item = [submenu itemAtIndex:next_index]; if (!visible) { // When hiding (only), we can verify the assumption that the item following // |item_tag| is actually an alternate. DCHECK([alternate_item isAlternate]); } // The alternate item visibility should always be in sync. DCHECK_EQ([alternate_item isHidden], [menu_item isHidden]); [alternate_item setAlternate:visible]; [alternate_item setHidden:!visible]; [menu_item setHidden:!visible]; } // Return the Extension (if any) associated with the given window. If it is not // a platform app nor hosted app, but it is a browser, |is_browser| will be set // to true (otherwise false). const Extension* GetExtensionForNSWindow(NSWindow* window, bool* is_browser) { const Extension* extension = nullptr; Browser* browser = nullptr; extensions::AppWindow* app_window = AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile(window); if (app_window) { extension = app_window->GetExtension(); } else { // If there is no corresponding AppWindow, this could be a hosted app, so // check for a browser. browser = chrome::FindBrowserWithWindow(window); extension = apps::ExtensionAppShimHandler::MaybeGetAppForBrowser(browser); } *is_browser = extension == nullptr && browser != nullptr; return extension; } // Sets or clears NSWindowCollectionBehaviorIgnoresCycle for |window|. Does not // change NSWindowCollectionBehaviorParticipatesInCycle. That exists, e.g, for // an NSPanel to override its default behavior, but this should only ever be // called for Browser windows and App windows (which are not panels). bool SetWindowParticipatesInCycle(NSWindow* window, bool participates) { const NSWindowCollectionBehavior past_behavior = [window collectionBehavior]; NSWindowCollectionBehavior behavior = past_behavior; if (participates) behavior &= ~NSWindowCollectionBehaviorIgnoresCycle; else behavior |= NSWindowCollectionBehaviorIgnoresCycle; // Often, there is no change. AppKit has no early exit since the value is // derived partially from styleMask and other things, so do our own. if (behavior == past_behavior) return false; [window setCollectionBehavior:behavior]; return true; } // Sets the window cycle list to |app_id|'s windows only. void SetAppCyclesWindows(const std::string& app_id, int sequence_number) { if (g_window_cycle_sequence_number != sequence_number) return; bool any_change = false; for (NSWindow* window : [NSApp windows]) { bool is_browser; const Extension* extension = GetExtensionForNSWindow(window, &is_browser); if (extension && extension->id() == app_id) any_change |= SetWindowParticipatesInCycle(window, true); else if (extension || is_browser) any_change |= SetWindowParticipatesInCycle(window, false); } // Without the following, -[NSApplication _getLockedWindowListForCycle] will // happily return windows that were just set to ignore window cycling. Doing // this seems to trick AppKit into updating the window cycle list. But it is a // bit scary, so avoid it when there is no change. These attempts were based // on the observation that clicking a window twice to switch focus would // always work. Also tried (without luck): // - [NSApp setWindowsNeedUpdate:YES], // - Creating a deferred NSWindow and immediately releasing it, // - Calling private methods like [NSApp _unlockWindowListForCycle], // - [NSApp postEvent:[NSEvent otherEventWithType:NSApplicationDefined... // (an attempt to tickle AppKit into an update of some kind), // - Calling synchronously (i.e. not via PostTask) <- this was actually the // initial attempt. Then, switching to PostTask didn't help with this // quirk, but was useful for the sequence number stuff, and // - Re-ordering collection behavior changes to ensure one window was always // participating (i.e. all 'adds' before any 'removes'). if (any_change) [[NSApp keyWindow] makeKeyAndOrderFront:nil]; } // Sets the window cycle list to Chrome browser windows only. void SetChromeCyclesWindows(int sequence_number) { if (g_window_cycle_sequence_number != sequence_number) return; bool any_change = false; for (NSWindow* window : [NSApp windows]) { bool is_browser; const Extension* extension = GetExtensionForNSWindow(window, &is_browser); if (extension || is_browser) any_change |= SetWindowParticipatesInCycle(window, is_browser); } if (any_change) [[NSApp keyWindow] makeKeyAndOrderFront:nil]; } } // namespace // Used by AppShimMenuController to manage menu items that are a copy of a // Chrome menu item but with a different action. This manages unsetting and // restoring the original item's key equivalent, so that we can use the same // key equivalent in the copied item with a different action. If |resourceId_| // is non-zero, this will also update the title to include the app name. // If the copy (menuItem) has no key equivalent, and the title does not have the // app name, then enableForApp and disable do not need to be called. I.e. the // doppelganger just copies the item and sets a new action. @interface DoppelgangerMenuItem : NSObject { @private base::scoped_nsobject<NSMenuItem> menuItem_; base::scoped_nsobject<NSMenuItem> sourceItem_; base::scoped_nsobject<NSString> sourceKeyEquivalent_; int resourceId_; } @property(readonly, nonatomic) NSMenuItem* menuItem; // Get the source item using the tags and create the menu item. - (id)initWithController:(AppShimMenuController*)controller menuTag:(NSInteger)menuTag itemTag:(NSInteger)itemTag resourceId:(int)resourceId action:(SEL)action keyEquivalent:(NSString*)keyEquivalent; // Retain the source item given |menuTag| and |sourceItemTag|. Copy // the menu item given |menuTag| and |targetItemTag|. // This is useful when we want a doppelganger with a different source item. // For example, if there are conflicting key equivalents. - (id)initWithMenuTag:(NSInteger)menuTag sourceItemTag:(NSInteger)sourceItemTag targetItemTag:(NSInteger)targetItemTag keyEquivalent:(NSString*)keyEquivalent; // Set the title using |resourceId_| and unset the source item's key equivalent. - (void)enableForApp:(const Extension*)app; // Restore the source item's key equivalent. - (void)disable; @end @implementation DoppelgangerMenuItem - (NSMenuItem*)menuItem { return menuItem_; } - (id)initWithController:(AppShimMenuController*)controller menuTag:(NSInteger)menuTag itemTag:(NSInteger)itemTag resourceId:(int)resourceId action:(SEL)action keyEquivalent:(NSString*)keyEquivalent { if ((self = [super init])) { sourceItem_.reset([GetItemByTag(menuTag, itemTag) retain]); DCHECK(sourceItem_); sourceKeyEquivalent_.reset([[sourceItem_ keyEquivalent] copy]); menuItem_.reset([[NSMenuItem alloc] initWithTitle:[sourceItem_ title] action:action keyEquivalent:keyEquivalent]); [menuItem_ setTarget:controller]; [menuItem_ setTag:itemTag]; resourceId_ = resourceId; } return self; } - (id)initWithMenuTag:(NSInteger)menuTag sourceItemTag:(NSInteger)sourceItemTag targetItemTag:(NSInteger)targetItemTag keyEquivalent:(NSString*)keyEquivalent { if ((self = [super init])) { menuItem_.reset([GetItemByTag(menuTag, targetItemTag) copy]); sourceItem_.reset([GetItemByTag(menuTag, sourceItemTag) retain]); DCHECK(menuItem_); DCHECK(sourceItem_); sourceKeyEquivalent_.reset([[sourceItem_ keyEquivalent] copy]); } return self; } - (void)enableForApp:(const Extension*)app { // It seems that two menu items that have the same key equivalent must also // have the same action for the keyboard shortcut to work. (This refers to the // original keyboard shortcut, regardless of any overrides set in OSX). // In order to let the app menu items have a different action, we remove the // key equivalent of the original items and restore them later. [sourceItem_ setKeyEquivalent:@""]; if (!resourceId_) return; [menuItem_ setTitle:l10n_util::GetNSStringF(resourceId_, base::UTF8ToUTF16(app->name()))]; } - (void)disable { // Restore the keyboard shortcut to Chrome. This just needs to be set back to // the original keyboard shortcut, regardless of any overrides in OSX. The // overrides still work as they are based on the title of the menu item. [sourceItem_ setKeyEquivalent:sourceKeyEquivalent_]; } @end @interface AppShimMenuController () // Construct the NSMenuItems for apps. - (void)buildAppMenuItems; // Register for NSWindow notifications. - (void)registerEventHandlers; // If the window is an app window, add or remove menu items. - (void)windowMainStatusChanged:(NSNotification*)notification; // Called when |app| becomes the main window in the Chrome process. - (void)appBecameMain:(const Extension*)app; // Called when there is no main window, or if the main window is not an app. - (void)chromeBecameMain; // Add menu items for an app and hide Chrome menu items. - (void)addMenuItems:(const Extension*)app; // If the window belongs to the currently focused app, remove the menu items and // unhide Chrome menu items. - (void)removeMenuItems; // If the currently focused window belongs to a platform app, quit the app. - (void)quitCurrentPlatformApp; // If the currently focused window belongs to a platform app, hide the app. - (void)hideCurrentPlatformApp; // If the currently focused window belongs to a platform app, focus the app. - (void)focusCurrentPlatformApp; @end @implementation AppShimMenuController - (id)init { if ((self = [super init])) { [self buildAppMenuItems]; [self registerEventHandlers]; } return self; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [super dealloc]; } - (void)buildAppMenuItems { aboutDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithController:self menuTag:IDC_CHROME_MENU itemTag:IDC_ABOUT resourceId:IDS_ABOUT_MAC action:nil keyEquivalent:@""]); hideDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithController:self menuTag:IDC_CHROME_MENU itemTag:IDC_HIDE_APP resourceId:IDS_HIDE_APP_MAC action:@selector(hideCurrentPlatformApp) keyEquivalent:@"h"]); quitDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithController:self menuTag:IDC_CHROME_MENU itemTag:IDC_EXIT resourceId:IDS_EXIT_MAC action:@selector(quitCurrentPlatformApp) keyEquivalent:@"q"]); newDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithController:self menuTag:IDC_FILE_MENU itemTag:IDC_NEW_WINDOW resourceId:0 action:nil keyEquivalent:@"n"]); // Since the "Close Window" menu item will have the same shortcut as "Close // Tab" on the Chrome menu, we need to create a doppelganger. closeWindowDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithMenuTag:IDC_FILE_MENU sourceItemTag:IDC_CLOSE_TAB targetItemTag:IDC_CLOSE_WINDOW keyEquivalent:@"w"]); // For apps, the "Window" part of "New Window" is dropped to match the default // menu set given to Cocoa Apps. [[newDoppelganger_ menuItem] setTitle:l10n_util::GetNSString(IDS_NEW_MAC)]; openDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithController:self menuTag:IDC_FILE_MENU itemTag:IDC_OPEN_FILE resourceId:0 action:nil keyEquivalent:@"o"]); allToFrontDoppelganger_.reset([[DoppelgangerMenuItem alloc] initWithController:self menuTag:IDC_WINDOW_MENU itemTag:IDC_ALL_WINDOWS_FRONT resourceId:0 action:@selector(focusCurrentPlatformApp) keyEquivalent:@""]); // The app's menu. appMenuItem_.reset([[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]); base::scoped_nsobject<NSMenu> appMenu([[NSMenu alloc] initWithTitle:@""]); [appMenuItem_ setSubmenu:appMenu]; [appMenu setAutoenablesItems:NO]; [appMenu addItem:[aboutDoppelganger_ menuItem]]; [[aboutDoppelganger_ menuItem] setEnabled:NO]; // Not implemented yet. [appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItem:[hideDoppelganger_ menuItem]]; [appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItem:[quitDoppelganger_ menuItem]]; // File menu. fileMenuItem_.reset([NewTopLevelItemFrom(IDC_FILE_MENU) retain]); [[fileMenuItem_ submenu] addItem:[newDoppelganger_ menuItem]]; [[fileMenuItem_ submenu] addItem:[openDoppelganger_ menuItem]]; [[fileMenuItem_ submenu] addItem:[NSMenuItem separatorItem]]; [[fileMenuItem_ submenu] addItem:[closeWindowDoppelganger_ menuItem]]; // Edit menu. We copy the menu because the last two items, "Start Dictation" // and "Special Characters" are added by OSX, so we can't copy them // explicitly. editMenuItem_.reset([[[NSApp mainMenu] itemWithTag:IDC_EDIT_MENU] copy]); // View menu. Remove "Always Show Bookmark Bar" and separator. viewMenuItem_.reset([[[NSApp mainMenu] itemWithTag:IDC_VIEW_MENU] copy]); RemoveMenuItemWithTag(viewMenuItem_, IDC_SHOW_BOOKMARK_BAR, YES); // History menu. historyMenuItem_.reset([NewTopLevelItemFrom(IDC_HISTORY_MENU) retain]); AddDuplicateItem(historyMenuItem_, IDC_HISTORY_MENU, IDC_BACK); AddDuplicateItem(historyMenuItem_, IDC_HISTORY_MENU, IDC_FORWARD); // Window menu. windowMenuItem_.reset([NewTopLevelItemFrom(IDC_WINDOW_MENU) retain]); AddDuplicateItem(windowMenuItem_, IDC_WINDOW_MENU, IDC_MINIMIZE_WINDOW); AddDuplicateItem(windowMenuItem_, IDC_WINDOW_MENU, IDC_MAXIMIZE_WINDOW); [[windowMenuItem_ submenu] addItem:[NSMenuItem separatorItem]]; [[windowMenuItem_ submenu] addItem:[allToFrontDoppelganger_ menuItem]]; } - (void)registerEventHandlers { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowMainStatusChanged:) name:NSWindowDidBecomeMainNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowMainStatusChanged:) name:NSWindowDidResignMainNotification object:nil]; } - (void)windowMainStatusChanged:(NSNotification*)notification { // A Yosemite AppKit bug causes this notification to be sent during the // -dealloc for a specific NSWindow. Any autoreleases sent to that window // must be drained before the window finishes -dealloc. In this method, an // autorelease is sent by the invocation of [NSApp windows]. // http://crbug.com/406944. base::mac::ScopedNSAutoreleasePool pool; NSString* name = [notification name]; if ([name isEqualToString:NSWindowDidBecomeMainNotification]) { id window = [notification object]; bool is_browser; const Extension* extension = GetExtensionForNSWindow(window, &is_browser); // Ignore is_browser: if a window becomes main that does not belong to an // extension or browser, treat it the same as switching to a browser. if (extension) [self appBecameMain:extension]; else [self chromeBecameMain]; } else if ([name isEqualToString:NSWindowDidResignMainNotification]) { // When a window resigns main status, reset back to the Chrome menu. // In the past we've tried: // - Only doing this when a window closes, but this would not be triggered // when an app becomes hidden (Cmd+h), and there are no Chrome windows to // become main. // - Scanning [NSApp windows] to predict whether we could // expect another Chrome window to become main, and skip the reset. However, // panels need to do strange things during window close to ensure panels // never get chosen for key status over a browser window (which is likely // because they are given an elevated [NSWindow level]). Trying to handle // this case is not robust. // // Unfortunately, resetting the menu to Chrome // unconditionally means that if another packaged app window becomes key, // the menu will flicker. TODO(tapted): Investigate restoring the logic when // the panel code is removed. [self chromeBecameMain]; } else { NOTREACHED(); } } - (void)appBecameMain:(const Extension*)app { if (appId_ == app->id()) return; if (!appId_.empty()) [self removeMenuItems]; appId_ = app->id(); [self addMenuItems:app]; if (IsAppWindowCyclingEnabled()) { base::MessageLoop::current()->PostTask( FROM_HERE, base::Bind(&SetAppCyclesWindows, appId_, ++g_window_cycle_sequence_number)); } } - (void)chromeBecameMain { if (appId_.empty()) return; appId_.clear(); [self removeMenuItems]; if (IsAppWindowCyclingEnabled()) { base::MessageLoop::current()->PostTask( FROM_HERE, base::Bind(&SetChromeCyclesWindows, ++g_window_cycle_sequence_number)); } } - (void)addMenuItems:(const Extension*)app { DCHECK_EQ(appId_, app->id()); NSString* title = base::SysUTF8ToNSString(app->name()); // Hide Chrome menu items. NSMenu* mainMenu = [NSApp mainMenu]; for (NSMenuItem* item in [mainMenu itemArray]) [item setHidden:YES]; [aboutDoppelganger_ enableForApp:app]; [hideDoppelganger_ enableForApp:app]; [quitDoppelganger_ enableForApp:app]; [newDoppelganger_ enableForApp:app]; [openDoppelganger_ enableForApp:app]; [closeWindowDoppelganger_ enableForApp:app]; [appMenuItem_ setTitle:base::SysUTF8ToNSString(appId_)]; [[appMenuItem_ submenu] setTitle:title]; [mainMenu addItem:appMenuItem_]; [mainMenu addItem:fileMenuItem_]; SetItemWithTagVisible(editMenuItem_, IDC_CONTENT_CONTEXT_PASTE_AND_MATCH_STYLE, app->is_hosted_app(), true); SetItemWithTagVisible(editMenuItem_, IDC_FIND_MENU, app->is_hosted_app(), false); [mainMenu addItem:editMenuItem_]; if (app->is_hosted_app()) { [mainMenu addItem:viewMenuItem_]; [mainMenu addItem:historyMenuItem_]; } [mainMenu addItem:windowMenuItem_]; } - (void)removeMenuItems { NSMenu* mainMenu = [NSApp mainMenu]; [mainMenu removeItem:appMenuItem_]; [mainMenu removeItem:fileMenuItem_]; if ([mainMenu indexOfItem:viewMenuItem_] >= 0) [mainMenu removeItem:viewMenuItem_]; if ([mainMenu indexOfItem:historyMenuItem_] >= 0) [mainMenu removeItem:historyMenuItem_]; [mainMenu removeItem:editMenuItem_]; [mainMenu removeItem:windowMenuItem_]; // Restore the Chrome main menu bar. for (NSMenuItem* item in [mainMenu itemArray]) [item setHidden:NO]; [aboutDoppelganger_ disable]; [hideDoppelganger_ disable]; [quitDoppelganger_ disable]; [newDoppelganger_ disable]; [openDoppelganger_ disable]; [closeWindowDoppelganger_ disable]; } - (void)quitCurrentPlatformApp { extensions::AppWindow* appWindow = AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile( [NSApp keyWindow]); if (appWindow) { apps::ExtensionAppShimHandler::QuitAppForWindow(appWindow); } else { Browser* browser = chrome::FindBrowserWithWindow([NSApp keyWindow]); const Extension* extension = apps::ExtensionAppShimHandler::MaybeGetAppForBrowser(browser); if (extension) apps::ExtensionAppShimHandler::QuitHostedAppForWindow(browser->profile(), extension->id()); } } - (void)hideCurrentPlatformApp { extensions::AppWindow* appWindow = AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile( [NSApp keyWindow]); if (appWindow) { apps::ExtensionAppShimHandler::HideAppForWindow(appWindow); } else { Browser* browser = chrome::FindBrowserWithWindow([NSApp keyWindow]); const Extension* extension = apps::ExtensionAppShimHandler::MaybeGetAppForBrowser(browser); if (extension) apps::ExtensionAppShimHandler::HideHostedApp(browser->profile(), extension->id()); } } - (void)focusCurrentPlatformApp { extensions::AppWindow* appWindow = AppWindowRegistryUtil::GetAppWindowForNativeWindowAnyProfile( [NSApp keyWindow]); if (appWindow) apps::ExtensionAppShimHandler::FocusAppForWindow(appWindow); } @end