// Copyright (c) 2012 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/extensions/extension_installed_bubble_controller.h" #include #include "base/i18n/rtl.h" #include "base/macros.h" #include "base/memory/scoped_ptr.h" #include "base/strings/sys_string_conversions.h" #include "base/strings/utf_string_conversions.h" #include "chrome/browser/extensions/bundle_installer.h" #include "chrome/browser/extensions/extension_action.h" #include "chrome/browser/extensions/extension_action_manager.h" #include "chrome/browser/ui/browser.h" #include "chrome/browser/ui/browser_navigator.h" #include "chrome/browser/ui/browser_navigator_params.h" #include "chrome/browser/ui/browser_window.h" #include "chrome/browser/ui/chrome_pages.h" #include "chrome/browser/ui/chrome_style.h" #include "chrome/browser/ui/cocoa/browser_window_cocoa.h" #include "chrome/browser/ui/cocoa/browser_window_controller.h" #import "chrome/browser/ui/cocoa/bubble_sync_promo_controller.h" #include "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h" #include "chrome/browser/ui/cocoa/extensions/bundle_util.h" #include "chrome/browser/ui/cocoa/hover_close_button.h" #include "chrome/browser/ui/cocoa/info_bubble_view.h" #include "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" #include "chrome/browser/ui/cocoa/new_tab_button.h" #include "chrome/browser/ui/cocoa/tabs/tab_strip_view.h" #include "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h" #include "chrome/browser/ui/extensions/extension_install_ui_factory.h" #include "chrome/browser/ui/extensions/extension_installed_bubble.h" #include "chrome/browser/ui/singleton_tabs.h" #include "chrome/browser/ui/sync/sync_promo_ui.h" #include "chrome/common/extensions/api/omnibox/omnibox_handler.h" #include "chrome/common/extensions/sync_helper.h" #include "chrome/common/url_constants.h" #include "chrome/grit/chromium_strings.h" #include "chrome/grit/generated_resources.h" #include "components/bubble/bubble_controller.h" #include "components/bubble/bubble_ui.h" #include "components/signin/core/browser/signin_metrics.h" #include "extensions/browser/install/extension_install_ui.h" #include "extensions/common/extension.h" #import "skia/ext/skia_utils_mac.h" #import "third_party/google_toolbox_for_mac/src/AppKit/GTMUILocalizerAndLayoutTweaker.h" #include "third_party/skia/include/core/SkBitmap.h" #include "ui/base/cocoa/cocoa_base_utils.h" #import "ui/base/cocoa/controls/hyperlink_text_view.h" #include "ui/base/l10n/l10n_util.h" using content::BrowserThread; using extensions::BundleInstaller; using extensions::Extension; @interface ExtensionInstalledBubbleController () - (const Extension*)extension; - (void)windowWillClose:(NSNotification*)notification; - (void)windowDidResignKey:(NSNotification*)notification; - (void)removePageActionPreviewIfNecessary; - (NSPoint)calculateArrowPoint; - (NSWindow*)initializeWindow; - (int)calculateWindowHeight; - (NSInteger)addExtensionList:(NSTextField*)headingMsg itemsView:(NSView*)itemsView state:(BundleInstaller::Item::State)state; - (void)setMessageFrames:(int)newWindowHeight; - (void)updateAnchorPosition; @end // ExtensionInstalledBubbleController () namespace { class ExtensionInstalledBubbleBridge : public BubbleUi { public: explicit ExtensionInstalledBubbleBridge( ExtensionInstalledBubbleController* controller); ~ExtensionInstalledBubbleBridge() override; private: // BubbleUi: void Show(BubbleReference bubble_reference) override; void Close() override; void UpdateAnchorPosition() override; // Weak reference to the controller. |controller_| will outlive the bridge. ExtensionInstalledBubbleController* controller_; DISALLOW_COPY_AND_ASSIGN(ExtensionInstalledBubbleBridge); }; ExtensionInstalledBubbleBridge::ExtensionInstalledBubbleBridge( ExtensionInstalledBubbleController* controller) : controller_(controller) { } ExtensionInstalledBubbleBridge::~ExtensionInstalledBubbleBridge() { } void ExtensionInstalledBubbleBridge::Show(BubbleReference bubble_reference) { [controller_ setBubbleReference:bubble_reference]; [controller_ showWindow:controller_]; } void ExtensionInstalledBubbleBridge::Close() { [controller_ doClose]; } void ExtensionInstalledBubbleBridge::UpdateAnchorPosition() { [controller_ updateAnchorPosition]; } } // namespace // Cocoa specific implementation. bool ExtensionInstalledBubble::ShouldShow() { return true; } // Implemented here to create the platform specific instance of the BubbleUi. scoped_ptr ExtensionInstalledBubble::BuildBubbleUi() { // |controller| is owned by the parent window. ExtensionInstalledBubbleController* controller = [[ExtensionInstalledBubbleController alloc] initWithParentWindow:browser()->window()->GetNativeWindow() extensionBubble:this]; // The bridge to the C++ object that performs shared logic across platforms. // This tells the controller when to show the bubble. return make_scoped_ptr(new ExtensionInstalledBubbleBridge(controller)); } @implementation ExtensionInstalledBubbleController @synthesize bundle = bundle_; @synthesize installedBubble = installedBubble_; // Exposed for unit tests. @synthesize heading = heading_; @synthesize closeButton = closeButton_; @synthesize howToUse = howToUse_; @synthesize howToManage = howToManage_; @synthesize appInstalledShortcutLink = appInstalledShortcutLink_; @synthesize manageShortcutLink = manageShortcutLink_; @synthesize promoContainer = promoContainer_; @synthesize iconImage = iconImage_; @synthesize pageActionPreviewShowing = pageActionPreviewShowing_; - (id)initWithParentWindow:(NSWindow*)parentWindow extensionBubble:(ExtensionInstalledBubble*)extensionBubble { if ((self = [super initWithWindowNibPath:@"ExtensionInstalledBubble" parentWindow:parentWindow anchoredAt:NSZeroPoint])) { DCHECK(extensionBubble); const extensions::Extension* extension = extensionBubble->extension(); browser_ = extensionBubble->browser(); DCHECK(browser_); icon_.reset([skia::SkBitmapToNSImage(extensionBubble->icon()) retain]); pageActionPreviewShowing_ = NO; type_ = extension->is_app() ? extension_installed_bubble::kApp : extension_installed_bubble::kExtension; installedBubble_ = extensionBubble; } return self; } - (id)initWithParentWindow:(NSWindow*)parentWindow bundle:(const BundleInstaller*)bundle browser:(Browser*)browser { if ((self = [super initWithWindowNibPath:@"ExtensionInstalledBubbleBundle" parentWindow:parentWindow anchoredAt:NSZeroPoint])) { bundle_ = bundle; DCHECK(browser); browser_ = browser; icon_.reset([skia::SkBitmapToNSImage(SkBitmap()) retain]); pageActionPreviewShowing_ = NO; type_ = extension_installed_bubble::kBundle; [self showWindow:self]; } return self; } - (const Extension*)extension { if (type_ == extension_installed_bubble::kBundle || !installedBubble_) return nullptr; return installedBubble_->extension(); } - (void)windowWillClose:(NSNotification*)notification { // Turn off page action icon preview when the window closes, unless we // already removed it when the window resigned key status. [self removePageActionPreviewIfNecessary]; browser_ = nullptr; [closeButton_ setTrackingEnabled:NO]; [super windowWillClose:notification]; } // The controller is the delegate of the window, so it receives "did resign // key" notifications. When key is resigned, close the window. - (void)windowDidResignKey:(NSNotification*)notification { // If the browser window is closing, we need to remove the page action // immediately, otherwise the closing animation may overlap with // browser destruction. [self removePageActionPreviewIfNecessary]; [super windowDidResignKey:notification]; } - (IBAction)closeWindow:(id)sender { DCHECK([[self window] isVisible]); DCHECK([self bubbleReference]); bool didClose = [self bubbleReference]->CloseBubble(BUBBLE_CLOSE_USER_DISMISSED); DCHECK(didClose); } // Extracted to a function here so that it can be overridden for unit testing. - (void)removePageActionPreviewIfNecessary { if (![self extension] || !pageActionPreviewShowing_) return; ExtensionAction* page_action = extensions::ExtensionActionManager::Get(browser_->profile())-> GetPageAction(*[self extension]); if (!page_action) return; pageActionPreviewShowing_ = NO; BrowserWindowCocoa* window = static_cast(browser_->window()); LocationBarViewMac* locationBarView = [window->cocoa_controller() locationBarBridge]; locationBarView->SetPreviewEnabledPageAction(page_action, false); // disables preview. } // The extension installed bubble points at the browser action icon or the // page action icon (shown as a preview), depending on the extension type. // We need to calculate the location of these icons and the size of the // message itself (which varies with the title of the extension) in order // to figure out the origin point for the extension installed bubble. // TODO(mirandac): add framework to easily test extension UI components! - (NSPoint)calculateArrowPoint { BrowserWindowCocoa* window = static_cast(browser_->window()); NSPoint arrowPoint = NSZeroPoint; auto getAppMenuButtonAnchorPoint = [window]() { // Point at the bottom of the app menu menu. NSView* appMenuButton = [[window->cocoa_controller() toolbarController] appMenuButton]; const NSRect bounds = [appMenuButton bounds]; NSPoint anchor = NSMakePoint(NSMidX(bounds), NSMaxY(bounds)); return [appMenuButton convertPoint:anchor toView:nil]; }; if (type_ == extension_installed_bubble::kApp) { TabStripView* view = [window->cocoa_controller() tabStripView]; NewTabButton* button = [view getNewTabButton]; NSRect bounds = [button bounds]; NSPoint anchor = NSMakePoint( NSMidX(bounds), NSMaxY(bounds) - extension_installed_bubble::kAppsBubbleArrowOffset); arrowPoint = [button convertPoint:anchor toView:nil]; } else if (type_ == extension_installed_bubble::kBundle) { arrowPoint = getAppMenuButtonAnchorPoint(); } else { DCHECK(installedBubble_); switch (installedBubble_->anchor_position()) { case ExtensionInstalledBubble::ANCHOR_BROWSER_ACTION: { BrowserActionsController* controller = [[window->cocoa_controller() toolbarController] browserActionsController]; arrowPoint = [controller popupPointForId:[self extension]->id()]; break; } case ExtensionInstalledBubble::ANCHOR_PAGE_ACTION: { LocationBarViewMac* locationBarView = [window->cocoa_controller() locationBarBridge]; ExtensionAction* page_action = extensions::ExtensionActionManager::Get(browser_->profile())-> GetPageAction(*[self extension]); // Tell the location bar to show a preview of the page action icon, // which would ordinarily only be displayed on a page of the appropriate // type. We remove this preview when the extension installed bubble // closes. locationBarView->SetPreviewEnabledPageAction(page_action, true); pageActionPreviewShowing_ = YES; // Find the center of the bottom of the page action icon. arrowPoint = locationBarView->GetPageActionBubblePoint(page_action); break; } case ExtensionInstalledBubble::ANCHOR_OMNIBOX: { LocationBarViewMac* locationBarView = [window->cocoa_controller() locationBarBridge]; arrowPoint = locationBarView->GetPageInfoBubblePoint(); break; } case ExtensionInstalledBubble::ANCHOR_APP_MENU: { arrowPoint = getAppMenuButtonAnchorPoint(); break; } } } return arrowPoint; } // Override -[BaseBubbleController showWindow:] to tweak bubble location and // set up UI elements. - (void)showWindow:(id)sender { DCHECK_CURRENTLY_ON(BrowserThread::UI); // Load nib and calculate height based on messages to be shown. NSWindow* window = [self initializeWindow]; int newWindowHeight = [self calculateWindowHeight]; [self.bubble setFrameSize:NSMakeSize( NSWidth([[window contentView] bounds]), newWindowHeight)]; NSSize windowDelta = NSMakeSize( 0, newWindowHeight - NSHeight([[window contentView] bounds])); windowDelta = [[window contentView] convertSize:windowDelta toView:nil]; NSRect newFrame = [window frame]; newFrame.size.height += windowDelta.height; [window setFrame:newFrame display:NO]; // Now that we have resized the window, adjust y pos of the messages. [self setMessageFrames:newWindowHeight]; // Find window origin, taking into account bubble size and arrow location. [self updateAnchorPosition]; [super showWindow:sender]; } // Finish nib loading, set arrow location and load icon into window. This // function is exposed for unit testing. - (NSWindow*)initializeWindow { NSWindow* window = [self window]; // completes nib load if (installedBubble_ && installedBubble_->anchor_position() == ExtensionInstalledBubble::ANCHOR_OMNIBOX) { [self.bubble setArrowLocation:info_bubble::kTopLeft]; } else { [self.bubble setArrowLocation:info_bubble::kTopRight]; } if (type_ == extension_installed_bubble::kBundle) return window; // Set appropriate icon, resizing if necessary. if ([icon_ size].width > extension_installed_bubble::kIconSize) { [icon_ setSize:NSMakeSize(extension_installed_bubble::kIconSize, extension_installed_bubble::kIconSize)]; } [iconImage_ setImage:icon_]; [iconImage_ setNeedsDisplay:YES]; return window; } // Calculate the height of each install message, resizing messages in their // frames to fit window width. Return the new window height, based on the // total of all message heights. - (int)calculateWindowHeight { // Adjust the window height to reflect the sum height of all messages // and vertical padding. // If there's few enough messages, the icon area may be larger than the // messages. int contentColumnHeight = 2 * extension_installed_bubble::kOuterVerticalMargin; int iconColumnHeight = 2 * extension_installed_bubble::kOuterVerticalMargin + NSHeight([iconImage_ frame]); // If type is bundle, list the extensions that were installed and those that // failed. if (type_ == extension_installed_bubble::kBundle) { NSInteger installedListHeight = [self addExtensionList:installedHeadingMsg_ itemsView:installedItemsView_ state:BundleInstaller::Item::STATE_INSTALLED]; NSInteger failedListHeight = [self addExtensionList:failedHeadingMsg_ itemsView:failedItemsView_ state:BundleInstaller::Item::STATE_FAILED]; contentColumnHeight += installedListHeight + failedListHeight; // Put some space between the lists if both are present. if (installedListHeight > 0 && failedListHeight > 0) contentColumnHeight += extension_installed_bubble::kInnerVerticalMargin; return std::max(contentColumnHeight, iconColumnHeight); } CGFloat syncPromoHeight = 0; if (installedBubble_->options() & ExtensionInstalledBubble::SIGN_IN_PROMO) { signin_metrics::AccessPoint accessPoint = signin_metrics::AccessPoint::ACCESS_POINT_EXTENSION_INSTALL_BUBBLE; syncPromoController_.reset( [[BubbleSyncPromoController alloc] initWithBrowser:browser_ promoStringId:IDS_EXTENSION_INSTALLED_SYNC_PROMO_NEW linkStringId:IDS_EXTENSION_INSTALLED_SYNC_PROMO_LINK_NEW accessPoint:accessPoint]); [promoContainer_ addSubview:[syncPromoController_ view]]; // Resize the sync promo and its placeholder. NSRect syncPromoPlaceholderFrame = [promoContainer_ frame]; CGFloat windowWidth = NSWidth([[self bubble] frame]); syncPromoPlaceholderFrame.size.width = windowWidth; syncPromoHeight = [syncPromoController_ preferredHeightForWidth:windowWidth]; syncPromoPlaceholderFrame.size.height = syncPromoHeight; [promoContainer_ setFrame:syncPromoPlaceholderFrame]; [[syncPromoController_ view] setFrame:syncPromoPlaceholderFrame]; } else { [promoContainer_ setHidden:YES]; } // First part of extension installed message, the heading. base::string16 extension_name = base::UTF8ToUTF16([self extension]->name().c_str()); base::i18n::AdjustStringForLocaleDirection(&extension_name); [heading_ setStringValue:l10n_util::GetNSStringF( IDS_EXTENSION_INSTALLED_HEADING, extension_name)]; [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:heading_]; contentColumnHeight += NSHeight([heading_ frame]); if (installedBubble_->options() & ExtensionInstalledBubble::HOW_TO_USE) { [howToUse_ setStringValue:base::SysUTF16ToNSString( installedBubble_->GetHowToUseDescription())]; [howToUse_ setHidden:NO]; [[howToUse_ cell] setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:howToUse_]; contentColumnHeight += NSHeight([howToUse_ frame]) + extension_installed_bubble::kInnerVerticalMargin; } // If type is app, hide howToManage_, and include a "show me" link in the // bubble. if (type_ == extension_installed_bubble::kApp) { [howToManage_ setHidden:YES]; [appShortcutLink_ setHidden:NO]; contentColumnHeight += 2 * extension_installed_bubble::kInnerVerticalMargin; contentColumnHeight += NSHeight([appShortcutLink_ frame]); } else if (installedBubble_->options() & ExtensionInstalledBubble::HOW_TO_MANAGE) { // Second part of extension installed message. [[howToManage_ cell] setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:howToManage_]; contentColumnHeight += NSHeight([howToManage_ frame]) + extension_installed_bubble::kInnerVerticalMargin; } else { [howToManage_ setHidden:YES]; } // Sync sign-in promo, if any. if (syncPromoHeight > 0) { // The sync promo goes at the bottom of the window and includes its own // bottom margin. Thus, we subtract off the one of the outer margins, and // apply it to both the icon area and content area. int syncPromoDelta = extension_installed_bubble::kInnerVerticalMargin + syncPromoHeight - extension_installed_bubble::kOuterVerticalMargin; contentColumnHeight += syncPromoDelta; iconColumnHeight += syncPromoDelta; } if (installedBubble_->options() & ExtensionInstalledBubble::SHOW_KEYBINDING) { [manageShortcutLink_ setHidden:NO]; [[manageShortcutLink_ cell] setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; [[manageShortcutLink_ cell] setTextColor:skia::SkColorToCalibratedNSColor( chrome_style::GetLinkColor())]; [GTMUILocalizerAndLayoutTweaker sizeToFitView:manageShortcutLink_]; contentColumnHeight += extension_installed_bubble::kInnerVerticalMargin; contentColumnHeight += NSHeight([manageShortcutLink_ frame]); } return std::max(contentColumnHeight, iconColumnHeight); } - (NSInteger)addExtensionList:(NSTextField*)headingMsg itemsView:(NSView*)itemsView state:(BundleInstaller::Item::State)state { base::string16 heading = bundle_->GetHeadingTextFor(state); bool hidden = heading.empty(); [headingMsg setHidden:hidden]; [itemsView setHidden:hidden]; if (hidden) return 0; [headingMsg setStringValue:base::SysUTF16ToNSString(heading)]; [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:headingMsg]; CGFloat height = PopulateBundleItemsList(bundle_->GetItemsWithState(state), itemsView); NSRect frame = [itemsView frame]; frame.size.height = height; [itemsView setFrame:frame]; return NSHeight([headingMsg frame]) + extension_installed_bubble::kInnerVerticalMargin + NSHeight([itemsView frame]); } // Adjust y-position of messages to sit properly in new window height. - (void)setMessageFrames:(int)newWindowHeight { if (type_ == extension_installed_bubble::kBundle) { // Layout the messages from the bottom up. NSView* msgs[] = { failedItemsView_, failedHeadingMsg_, installedItemsView_, installedHeadingMsg_ }; NSInteger offsetFromBottom = 0; BOOL isFirstVisible = YES; for (size_t i = 0; i < arraysize(msgs); ++i) { if ([msgs[i] isHidden]) continue; NSRect frame = [msgs[i] frame]; NSInteger margin = isFirstVisible ? extension_installed_bubble::kOuterVerticalMargin : extension_installed_bubble::kInnerVerticalMargin; frame.origin.y = offsetFromBottom + margin; [msgs[i] setFrame:frame]; offsetFromBottom += NSHeight(frame) + margin; isFirstVisible = NO; } // Move the close button a bit to vertically align it with the heading. NSInteger closeButtonFudge = 1; NSRect frame = [closeButton_ frame]; frame.origin.y = newWindowHeight - (NSHeight(frame) + closeButtonFudge + extension_installed_bubble::kOuterVerticalMargin); [closeButton_ setFrame:frame]; return; } NSRect headingFrame = [heading_ frame]; headingFrame.origin.y = newWindowHeight - ( NSHeight(headingFrame) + extension_installed_bubble::kOuterVerticalMargin); [heading_ setFrame:headingFrame]; int nextY = NSMinY(headingFrame); auto adjustView = [](NSView* view, int* nextY) { DCHECK(nextY); NSRect frame = [view frame]; frame.origin.y = *nextY - (NSHeight(frame) + extension_installed_bubble::kInnerVerticalMargin); [view setFrame:frame]; *nextY = NSMinY(frame); }; if (installedBubble_->options() & ExtensionInstalledBubble::HOW_TO_USE) adjustView(howToUse_, &nextY); if (installedBubble_->options() & ExtensionInstalledBubble::HOW_TO_MANAGE) adjustView(howToManage_, &nextY); if (installedBubble_->options() & ExtensionInstalledBubble::SHOW_KEYBINDING) adjustView(manageShortcutLink_, &nextY); if (installedBubble_->options() & ExtensionInstalledBubble::SIGN_IN_PROMO) { // The sync promo goes at the bottom of the bubble, but that might be // different than directly below the previous content if the icon is larger // than the messages. Workaround by just always setting nextY to be at the // bottom. nextY = NSHeight([promoContainer_ frame]) + extension_installed_bubble::kInnerVerticalMargin; adjustView(promoContainer_, &nextY); } } - (void)updateAnchorPosition { self.anchorPoint = ui::ConvertPointFromWindowToScreen( self.parentWindow, [self calculateArrowPoint]); } - (IBAction)onManageShortcutClicked:(id)sender { DCHECK([self bubbleReference]); bool didClose = [self bubbleReference]->CloseBubble(BUBBLE_CLOSE_ACCEPTED); DCHECK(didClose); std::string configure_url = chrome::kChromeUIExtensionsURL; configure_url += chrome::kExtensionConfigureCommandsSubPage; chrome::NavigateParams params(chrome::GetSingletonTabNavigateParams( browser_, GURL(configure_url))); chrome::Navigate(¶ms); } - (IBAction)onAppShortcutClicked:(id)sender { scoped_ptr install_ui( extensions::CreateExtensionInstallUI(browser_->profile())); install_ui->OpenAppInstalledUI([self extension]->id()); } - (void)doClose { installedBubble_ = nullptr; [self close]; } @end