extension_installed_bubble_controller.mm revision 5821806d5e7f356e8fa4b058a389a808ea183019
1// Copyright (c) 2012 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/extensions/extension_installed_bubble_controller.h"
6
7#include "base/i18n/rtl.h"
8#include "base/mac/bundle_locations.h"
9#include "base/mac/mac_util.h"
10#include "base/sys_string_conversions.h"
11#include "base/utf_string_conversions.h"
12#include "chrome/browser/extensions/api/commands/command_service.h"
13#include "chrome/browser/extensions/api/commands/command_service_factory.h"
14#include "chrome/browser/extensions/bundle_installer.h"
15#include "chrome/browser/extensions/extension_action.h"
16#include "chrome/browser/extensions/extension_action_manager.h"
17#include "chrome/browser/ui/browser.h"
18#include "chrome/browser/ui/browser_navigator.h"
19#include "chrome/browser/ui/browser_window.h"
20#include "chrome/browser/ui/cocoa/browser_window_cocoa.h"
21#include "chrome/browser/ui/cocoa/browser_window_controller.h"
22#include "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h"
23#include "chrome/browser/ui/cocoa/hover_close_button.h"
24#include "chrome/browser/ui/cocoa/info_bubble_view.h"
25#include "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h"
26#include "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h"
27#include "chrome/browser/ui/singleton_tabs.h"
28#include "chrome/common/chrome_notification_types.h"
29#include "chrome/common/extensions/extension.h"
30#include "chrome/common/url_constants.h"
31#include "content/public/browser/notification_details.h"
32#include "content/public/browser/notification_registrar.h"
33#include "content/public/browser/notification_source.h"
34#include "grit/chromium_strings.h"
35#include "grit/generated_resources.h"
36#import "skia/ext/skia_utils_mac.h"
37#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h"
38#include "ui/base/l10n/l10n_util.h"
39
40using content::BrowserThread;
41using extensions::BundleInstaller;
42using extensions::Extension;
43using extensions::UnloadedExtensionInfo;
44
45// C++ class that receives EXTENSION_LOADED notifications and proxies them back
46// to |controller|.
47class ExtensionLoadedNotificationObserver
48    : public content::NotificationObserver {
49 public:
50  ExtensionLoadedNotificationObserver(
51      ExtensionInstalledBubbleController* controller, Profile* profile)
52          : controller_(controller) {
53    registrar_.Add(this, chrome::NOTIFICATION_EXTENSION_LOADED,
54        content::Source<Profile>(profile));
55    registrar_.Add(this, chrome::NOTIFICATION_EXTENSION_UNLOADED,
56        content::Source<Profile>(profile));
57  }
58
59 private:
60  // NotificationObserver implementation. Tells the controller to start showing
61  // its window on the main thread when the extension has finished loading.
62  void Observe(int type,
63               const content::NotificationSource& source,
64               const content::NotificationDetails& details) {
65    if (type == chrome::NOTIFICATION_EXTENSION_LOADED) {
66      const Extension* extension =
67          content::Details<const Extension>(details).ptr();
68      if (extension == [controller_ extension]) {
69        [controller_ performSelectorOnMainThread:@selector(showWindow:)
70                                      withObject:controller_
71                                   waitUntilDone:NO];
72      }
73    } else if (type == chrome::NOTIFICATION_EXTENSION_UNLOADED) {
74      const Extension* extension =
75          content::Details<const UnloadedExtensionInfo>(details)->extension;
76      if (extension == [controller_ extension]) {
77        [controller_ performSelectorOnMainThread:@selector(extensionUnloaded:)
78                                      withObject:controller_
79                                   waitUntilDone:NO];
80      }
81    } else {
82      NOTREACHED() << "Received unexpected notification.";
83    }
84  }
85
86  content::NotificationRegistrar registrar_;
87  ExtensionInstalledBubbleController* controller_;  // weak, owns us
88};
89
90@implementation ExtensionInstalledBubbleController
91
92@synthesize extension = extension_;
93@synthesize bundle = bundle_;
94// Exposed for unit test.
95@synthesize pageActionPreviewShowing = pageActionPreviewShowing_;
96
97- (id)initWithParentWindow:(NSWindow*)parentWindow
98                 extension:(const Extension*)extension
99                    bundle:(const BundleInstaller*)bundle
100                   browser:(Browser*)browser
101                      icon:(SkBitmap)icon {
102  NSString* nibName = bundle ? @"ExtensionInstalledBubbleBundle" :
103                               @"ExtensionInstalledBubble";
104  if ((self = [super initWithWindowNibPath:nibName
105                              parentWindow:parentWindow
106                                anchoredAt:NSZeroPoint])) {
107    extension_ = extension;
108    bundle_ = bundle;
109    DCHECK(browser);
110    browser_ = browser;
111    icon_.reset([gfx::SkBitmapToNSImage(icon) retain]);
112    pageActionPreviewShowing_ = NO;
113
114    extensions::ExtensionActionManager* extension_action_manager =
115        extensions::ExtensionActionManager::Get(browser_->profile());
116
117    if (bundle_) {
118      type_ = extension_installed_bubble::kBundle;
119    } else if (!extension->omnibox_keyword().empty()) {
120      type_ = extension_installed_bubble::kOmniboxKeyword;
121    } else if (extension_action_manager->GetBrowserAction(*extension)) {
122      type_ = extension_installed_bubble::kBrowserAction;
123    } else if (extension_action_manager->GetPageAction(*extension) &&
124             extension->is_verbose_install_message()) {
125      type_ = extension_installed_bubble::kPageAction;
126    } else {
127      type_ = extension_installed_bubble::kGeneric;
128    }
129
130    if (type_ == extension_installed_bubble::kBundle) {
131      [self showWindow:self];
132    } else {
133      // Start showing window only after extension has fully loaded.
134      extensionObserver_.reset(new ExtensionLoadedNotificationObserver(
135          self, browser->profile()));
136    }
137  }
138  return self;
139}
140
141- (void)windowWillClose:(NSNotification*)notification {
142  // Turn off page action icon preview when the window closes, unless we
143  // already removed it when the window resigned key status.
144  [self removePageActionPreviewIfNecessary];
145  extension_ = NULL;
146  browser_ = NULL;
147
148  [super windowWillClose:notification];
149}
150
151// The controller is the delegate of the window, so it receives "did resign
152// key" notifications.  When key is resigned, close the window.
153- (void)windowDidResignKey:(NSNotification*)notification {
154  // If the browser window is closing, we need to remove the page action
155  // immediately, otherwise the closing animation may overlap with
156  // browser destruction.
157  [self removePageActionPreviewIfNecessary];
158  [super windowDidResignKey:notification];
159}
160
161- (IBAction)closeWindow:(id)sender {
162  DCHECK([[self window] isVisible]);
163  [self close];
164}
165
166// Extracted to a function here so that it can be overridden for unit testing.
167- (void)removePageActionPreviewIfNecessary {
168  if (!extension_ || !pageActionPreviewShowing_)
169    return;
170  ExtensionAction* page_action =
171      extensions::ExtensionActionManager::Get(browser_->profile())->
172      GetPageAction(*extension_);
173  if (!page_action)
174    return;
175  pageActionPreviewShowing_ = NO;
176
177  BrowserWindowCocoa* window =
178      static_cast<BrowserWindowCocoa*>(browser_->window());
179  LocationBarViewMac* locationBarView =
180      [window->cocoa_controller() locationBarBridge];
181  locationBarView->SetPreviewEnabledPageAction(page_action,
182                                               false);  // disables preview.
183}
184
185// The extension installed bubble points at the browser action icon or the
186// page action icon (shown as a preview), depending on the extension type.
187// We need to calculate the location of these icons and the size of the
188// message itself (which varies with the title of the extension) in order
189// to figure out the origin point for the extension installed bubble.
190// TODO(mirandac): add framework to easily test extension UI components!
191- (NSPoint)calculateArrowPoint {
192  BrowserWindowCocoa* window =
193      static_cast<BrowserWindowCocoa*>(browser_->window());
194  NSPoint arrowPoint = NSZeroPoint;
195
196  switch(type_) {
197    case extension_installed_bubble::kOmniboxKeyword: {
198      LocationBarViewMac* locationBarView =
199          [window->cocoa_controller() locationBarBridge];
200      arrowPoint = locationBarView->GetPageInfoBubblePoint();
201      break;
202    }
203    case extension_installed_bubble::kBrowserAction: {
204      BrowserActionsController* controller =
205          [[window->cocoa_controller() toolbarController]
206              browserActionsController];
207      arrowPoint = [controller popupPointForBrowserAction:extension_];
208      break;
209    }
210    case extension_installed_bubble::kPageAction: {
211      LocationBarViewMac* locationBarView =
212          [window->cocoa_controller() locationBarBridge];
213
214      ExtensionAction* page_action =
215          extensions::ExtensionActionManager::Get(browser_->profile())->
216          GetPageAction(*extension_);
217
218      // Tell the location bar to show a preview of the page action icon, which
219      // would ordinarily only be displayed on a page of the appropriate type.
220      // We remove this preview when the extension installed bubble closes.
221      locationBarView->SetPreviewEnabledPageAction(page_action, true);
222      pageActionPreviewShowing_ = YES;
223
224      // Find the center of the bottom of the page action icon.
225      arrowPoint =
226          locationBarView->GetPageActionBubblePoint(page_action);
227      break;
228    }
229    case extension_installed_bubble::kBundle:
230    case extension_installed_bubble::kGeneric: {
231      // Point at the bottom of the wrench menu.
232      NSView* wrenchButton =
233          [[window->cocoa_controller() toolbarController] wrenchButton];
234      const NSRect bounds = [wrenchButton bounds];
235      NSPoint anchor = NSMakePoint(NSMidX(bounds), NSMaxY(bounds));
236      arrowPoint = [wrenchButton convertPoint:anchor toView:nil];
237      break;
238    }
239    default: {
240      NOTREACHED();
241    }
242  }
243  return arrowPoint;
244}
245
246// Override -[BaseBubbleController showWindow:] to tweak bubble location and
247// set up UI elements.
248- (void)showWindow:(id)sender {
249  DCHECK(BrowserThread::CurrentlyOn(BrowserThread::UI));
250
251  // Load nib and calculate height based on messages to be shown.
252  NSWindow* window = [self initializeWindow];
253  int newWindowHeight = [self calculateWindowHeight];
254  [self.bubble setFrameSize:NSMakeSize(
255      NSWidth([[window contentView] bounds]), newWindowHeight)];
256  NSSize windowDelta = NSMakeSize(
257      0, newWindowHeight - NSHeight([[window contentView] bounds]));
258  windowDelta = [[window contentView] convertSize:windowDelta toView:nil];
259  NSRect newFrame = [window frame];
260  newFrame.size.height += windowDelta.height;
261  [window setFrame:newFrame display:NO];
262
263  // Now that we have resized the window, adjust y pos of the messages.
264  [self setMessageFrames:newWindowHeight];
265
266  // Find window origin, taking into account bubble size and arrow location.
267  self.anchorPoint =
268      [self.parentWindow convertBaseToScreen:[self calculateArrowPoint]];
269  [super showWindow:sender];
270}
271
272// Finish nib loading, set arrow location and load icon into window.  This
273// function is exposed for unit testing.
274- (NSWindow*)initializeWindow {
275  NSWindow* window = [self window];  // completes nib load
276
277  if (type_ == extension_installed_bubble::kOmniboxKeyword) {
278    [self.bubble setArrowLocation:info_bubble::kTopLeft];
279  } else {
280    [self.bubble setArrowLocation:info_bubble::kTopRight];
281  }
282
283  if (type_ == extension_installed_bubble::kBundle)
284    return window;
285
286  // Set appropriate icon, resizing if necessary.
287  if ([icon_ size].width > extension_installed_bubble::kIconSize) {
288    [icon_ setSize:NSMakeSize(extension_installed_bubble::kIconSize,
289                              extension_installed_bubble::kIconSize)];
290  }
291  [iconImage_ setImage:icon_];
292  [iconImage_ setNeedsDisplay:YES];
293  return window;
294}
295
296- (bool)hasActivePageAction:(extensions::Command*)command {
297  extensions::CommandService* command_service =
298      extensions::CommandServiceFactory::GetForProfile(browser_->profile());
299  if (type_ == extension_installed_bubble::kPageAction) {
300    if (extension_->page_action_command() &&
301        command_service->GetPageActionCommand(
302            extension_->id(),
303            extensions::CommandService::ACTIVE_ONLY,
304            command,
305            NULL)) {
306      return true;
307    }
308  }
309
310  return false;
311}
312
313- (bool)hasActiveBrowserAction:(extensions::Command*)command {
314  extensions::CommandService* command_service =
315      extensions::CommandServiceFactory::GetForProfile(browser_->profile());
316  if (type_ == extension_installed_bubble::kBrowserAction) {
317    if (extension_->browser_action_command() &&
318        command_service->GetBrowserActionCommand(
319            extension_->id(),
320            extensions::CommandService::ACTIVE_ONLY,
321            command,
322            NULL)) {
323      return true;
324    }
325  }
326
327  return false;
328}
329
330- (NSString*)installMessageForCurrentExtensionAction {
331  if (type_ == extension_installed_bubble::kPageAction) {
332    extensions::Command page_action_command;
333    if ([self hasActivePageAction:&page_action_command]) {
334      return l10n_util::GetNSStringF(
335          IDS_EXTENSION_INSTALLED_PAGE_ACTION_INFO_WITH_SHORTCUT,
336          page_action_command.accelerator().GetShortcutText());
337    } else {
338      return l10n_util::GetNSString(
339          IDS_EXTENSION_INSTALLED_PAGE_ACTION_INFO);
340    }
341  } else {
342    CHECK_EQ(extension_installed_bubble::kBrowserAction, type_);
343    extensions::Command browser_action_command;
344    if ([self hasActiveBrowserAction:&browser_action_command]) {
345      return l10n_util::GetNSStringF(
346          IDS_EXTENSION_INSTALLED_BROWSER_ACTION_INFO_WITH_SHORTCUT,
347          browser_action_command.accelerator().GetShortcutText());
348    } else {
349      return l10n_util::GetNSString(
350          IDS_EXTENSION_INSTALLED_BROWSER_ACTION_INFO);
351    }
352  }
353}
354
355// Calculate the height of each install message, resizing messages in their
356// frames to fit window width.  Return the new window height, based on the
357// total of all message heights.
358- (int)calculateWindowHeight {
359  // Adjust the window height to reflect the sum height of all messages
360  // and vertical padding.
361  int newWindowHeight = 2 * extension_installed_bubble::kOuterVerticalMargin;
362
363  // First part of extension installed message.
364  if (type_ != extension_installed_bubble::kBundle) {
365    string16 extension_name = UTF8ToUTF16(extension_->name().c_str());
366    base::i18n::AdjustStringForLocaleDirection(&extension_name);
367    [extensionInstalledMsg_ setStringValue:l10n_util::GetNSStringF(
368        IDS_EXTENSION_INSTALLED_HEADING, extension_name)];
369    [GTMUILocalizerAndLayoutTweaker
370      sizeToFitFixedWidthTextField:extensionInstalledMsg_];
371    newWindowHeight += [extensionInstalledMsg_ frame].size.height +
372        extension_installed_bubble::kInnerVerticalMargin;
373  }
374
375  // If type is page action, include a special message about page actions.
376  if (type_ == extension_installed_bubble::kBrowserAction ||
377      type_ == extension_installed_bubble::kPageAction) {
378    [extraInfoMsg_ setStringValue:[self
379        installMessageForCurrentExtensionAction]];
380    [extraInfoMsg_ setHidden:NO];
381    [[extraInfoMsg_ cell]
382        setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
383    [GTMUILocalizerAndLayoutTweaker
384        sizeToFitFixedWidthTextField:extraInfoMsg_];
385    newWindowHeight += [extraInfoMsg_ frame].size.height +
386        extension_installed_bubble::kInnerVerticalMargin;
387  }
388
389  // If type is omnibox keyword, include a special message about the keyword.
390  if (type_ == extension_installed_bubble::kOmniboxKeyword) {
391    [extraInfoMsg_ setStringValue:l10n_util::GetNSStringF(
392        IDS_EXTENSION_INSTALLED_OMNIBOX_KEYWORD_INFO,
393        UTF8ToUTF16(extension_->omnibox_keyword()))];
394    [extraInfoMsg_ setHidden:NO];
395    [[extraInfoMsg_ cell]
396        setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
397    [GTMUILocalizerAndLayoutTweaker
398        sizeToFitFixedWidthTextField:extraInfoMsg_];
399    newWindowHeight += [extraInfoMsg_ frame].size.height +
400        extension_installed_bubble::kInnerVerticalMargin;
401  }
402
403  // If type is bundle, list the extensions that were installed and those that
404  // failed.
405  if (type_ == extension_installed_bubble::kBundle) {
406    NSInteger installedListHeight =
407        [self addExtensionList:installedHeadingMsg_
408                      itemsMsg:installedItemsMsg_
409                         state:BundleInstaller::Item::STATE_INSTALLED];
410
411    NSInteger failedListHeight =
412        [self addExtensionList:failedHeadingMsg_
413                      itemsMsg:failedItemsMsg_
414                         state:BundleInstaller::Item::STATE_FAILED];
415
416    newWindowHeight += installedListHeight + failedListHeight;
417
418    // Put some space between the lists if both are present.
419    if (installedListHeight > 0 && failedListHeight > 0)
420      newWindowHeight += extension_installed_bubble::kInnerVerticalMargin;
421  } else {
422    // Second part of extension installed message.
423    [[extensionInstalledInfoMsg_ cell]
424        setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
425    [GTMUILocalizerAndLayoutTweaker
426        sizeToFitFixedWidthTextField:extensionInstalledInfoMsg_];
427    newWindowHeight += [extensionInstalledInfoMsg_ frame].size.height;
428  }
429
430  extensions::Command command;
431  if ([self hasActivePageAction:&command] ||
432      [self hasActiveBrowserAction:&command]) {
433    [manageShortcutLink_ setHidden:NO];
434    [[manageShortcutLink_ cell]
435        setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
436    newWindowHeight += 2 * extension_installed_bubble::kInnerVerticalMargin;
437    newWindowHeight += [GTMUILocalizerAndLayoutTweaker
438                            sizeToFitView:manageShortcutLink_].height;
439    newWindowHeight += extension_installed_bubble::kInnerVerticalMargin;
440  }
441
442  return newWindowHeight;
443}
444
445- (NSInteger)addExtensionList:(NSTextField*)headingMsg
446                     itemsMsg:(NSTextField*)itemsMsg
447                        state:(BundleInstaller::Item::State)state {
448  string16 heading = bundle_->GetHeadingTextFor(state);
449  bool hidden = heading.empty();
450  [headingMsg setHidden:hidden];
451  [itemsMsg setHidden:hidden];
452  if (hidden)
453    return 0;
454
455  [headingMsg setStringValue:base::SysUTF16ToNSString(heading)];
456  [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:headingMsg];
457
458  NSMutableString* joinedItems = [NSMutableString string];
459  BundleInstaller::ItemList items = bundle_->GetItemsWithState(state);
460  for (size_t i = 0; i < items.size(); ++i) {
461    if (i > 0)
462      [joinedItems appendString:@"\n"];
463    [joinedItems appendString:base::SysUTF16ToNSString(
464        items[i].GetNameForDisplay())];
465  }
466
467  [itemsMsg setStringValue:joinedItems];
468  [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:itemsMsg];
469
470  return [headingMsg frame].size.height +
471      extension_installed_bubble::kInnerVerticalMargin +
472      [itemsMsg frame].size.height;
473}
474
475// Adjust y-position of messages to sit properly in new window height.
476- (void)setMessageFrames:(int)newWindowHeight {
477  if (type_ == extension_installed_bubble::kBundle) {
478    // Layout the messages from the bottom up.
479    NSTextField* msgs[] = { failedItemsMsg_, failedHeadingMsg_,
480                            installedItemsMsg_, installedHeadingMsg_ };
481    NSInteger offsetFromBottom = 0;
482    BOOL isFirstVisible = YES;
483    for (size_t i = 0; i < arraysize(msgs); ++i) {
484      if ([msgs[i] isHidden])
485        continue;
486
487      NSRect frame = [msgs[i] frame];
488      NSInteger margin = isFirstVisible ?
489          extension_installed_bubble::kOuterVerticalMargin :
490          extension_installed_bubble::kInnerVerticalMargin;
491
492      frame.origin.y = offsetFromBottom + margin;
493      [msgs[i] setFrame:frame];
494      offsetFromBottom += frame.size.height + margin;
495
496      isFirstVisible = NO;
497    }
498
499    // Move the close button a bit to vertically align it with the heading.
500    NSInteger closeButtonFudge = 1;
501    NSRect frame = [closeButton_ frame];
502    frame.origin.y = newWindowHeight - (frame.size.height + closeButtonFudge +
503         extension_installed_bubble::kOuterVerticalMargin);
504    [closeButton_ setFrame:frame];
505
506    return;
507  }
508
509  NSRect extensionMessageFrame1 = [extensionInstalledMsg_ frame];
510  NSRect extensionMessageFrame2 = [extensionInstalledInfoMsg_ frame];
511
512  extensionMessageFrame1.origin.y = newWindowHeight - (
513      extensionMessageFrame1.size.height +
514      extension_installed_bubble::kOuterVerticalMargin);
515  [extensionInstalledMsg_ setFrame:extensionMessageFrame1];
516  if (extension_->is_verbose_install_message()) {
517    // The extra message is only shown when appropriate.
518    NSRect extraMessageFrame = [extraInfoMsg_ frame];
519    extraMessageFrame.origin.y = extensionMessageFrame1.origin.y - (
520        extraMessageFrame.size.height +
521        extension_installed_bubble::kInnerVerticalMargin);
522    [extraInfoMsg_ setFrame:extraMessageFrame];
523    extensionMessageFrame2.origin.y = extraMessageFrame.origin.y - (
524        extensionMessageFrame2.size.height +
525        extension_installed_bubble::kInnerVerticalMargin);
526  } else {
527    extensionMessageFrame2.origin.y = extensionMessageFrame1.origin.y - (
528        extensionMessageFrame2.size.height +
529        extension_installed_bubble::kInnerVerticalMargin);
530  }
531  [extensionInstalledInfoMsg_ setFrame:extensionMessageFrame2];
532
533  extensions::Command command;
534  if (![manageShortcutLink_ isHidden]) {
535    NSRect manageShortcutFrame = [manageShortcutLink_ frame];
536    manageShortcutFrame.origin.y = NSMinY(extensionMessageFrame2) - (
537        NSHeight(manageShortcutFrame) +
538        extension_installed_bubble::kInnerVerticalMargin);
539    // Right-align the link.
540    manageShortcutFrame.origin.x = NSMaxX(extensionMessageFrame2) -
541                                   NSWidth(manageShortcutFrame);
542    [manageShortcutLink_ setFrame:manageShortcutFrame];
543  }
544}
545
546// Exposed for unit testing.
547- (NSRect)getExtensionInstalledMsgFrame {
548  return [extensionInstalledMsg_ frame];
549}
550
551- (NSRect)getExtraInfoMsgFrame {
552  return [extraInfoMsg_ frame];
553}
554
555- (NSRect)getExtensionInstalledInfoMsgFrame {
556  return [extensionInstalledInfoMsg_ frame];
557}
558
559- (void)extensionUnloaded:(id)sender {
560  extension_ = NULL;
561}
562
563- (IBAction)onManageShortcutClicked:(id)sender {
564  [self close];
565  std::string configure_url = chrome::kChromeUIExtensionsURL;
566  configure_url += chrome::kExtensionConfigureCommandsSubPage;
567  chrome::NavigateParams params(chrome::GetSingletonTabNavigateParams(
568      browser_, GURL(configure_url)));
569  chrome::Navigate(&params);
570}
571
572@end
573