chromium/chrome/browser/ui/cocoa/profiles/profile_menu_controller.mm

// Copyright 2014 The Chromium Authors
// 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/profiles/profile_menu_controller.h"

#include <AppKit/AppKit.h>
#include <stddef.h>

#include <memory>
#include <optional>

#include "base/feature_list.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/sys_string_conversions.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/profiles/avatar_menu.h"
#include "chrome/browser/profiles/avatar_menu_observer.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/profiles/profile_attributes_storage.h"
#include "chrome/browser/profiles/profile_avatar_icon_util.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/profiles/profile_metrics.h"
#include "chrome/browser/profiles/profile_window.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/browser_list.h"
#include "chrome/browser/ui/browser_list_observer.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/common/pref_names.h"
#include "chrome/grit/generated_resources.h"
#include "components/prefs/pref_service.h"
#include "ui/base/l10n/l10n_util_mac.h"
#include "ui/gfx/image/image.h"

namespace {

NSString* GetProfileMenuTitle() {
  return l10n_util::GetNSStringWithFixup(IDS_PROFILES_MENU_NAME);
}

}  // namespace

@interface ProfileMenuController (Private)
- (void)initializeMenuWithProfileAttributesStorage:
    (ProfileAttributesStorage*)storage;
- (void)rebuildMenu;
@end

namespace ProfileMenuControllerInternal {

class Observer : public BrowserListObserver, public AvatarMenuObserver {
 public:
  explicit Observer(ProfileMenuController* controller)
      : controller_(controller) {
    BrowserList::AddObserver(this);
  }

  ~Observer() override { BrowserList::RemoveObserver(this); }

  // BrowserListObserver:
  void OnBrowserAdded(Browser* browser) override {}
  void OnBrowserRemoved(Browser* browser) override {
    [controller_ activeBrowserChangedTo:chrome::FindLastActive()];
  }
  void OnBrowserSetLastActive(Browser* browser) override {
    [controller_ activeBrowserChangedTo:browser];
  }

  // AvatarMenuObserver:
  void OnAvatarMenuChanged(AvatarMenu* menu) override {
    [controller_ rebuildMenu];
  }

 private:
  ProfileMenuController* controller_;  // Weak; owns this.
};

}  // namespace ProfileMenuControllerInternal

////////////////////////////////////////////////////////////////////////////////

@implementation ProfileMenuController {
  // An observer to be notified when the active browser changes and when the
  // menu model changes.
  std::unique_ptr<ProfileMenuControllerInternal::Observer> _observer;

  // The controller for the profile submenu.
  std::unique_ptr<AvatarMenu> _avatarMenu;

  // The main menu item to which the profile menu is attached.
  NSMenuItem* __strong _mainMenuItem;
}

- (instancetype)initWithMainMenuItem:(NSMenuItem*)item
            profileAttributesStorage:(ProfileAttributesStorage*)storage {
  if ((self = [super init])) {
    _mainMenuItem = item;

    _mainMenuItem.submenu =
        [[NSMenu alloc] initWithTitle:GetProfileMenuTitle()];

    // When this object is constructed in non-test code, right after the main
    // menu is created, that happens before the message loop starts and thus
    // `g_browser_process` is not yet available. In that case, schedule
    // initialization on the loop to do work when the browser is ready. For test
    // code, the required object is available, so initialize immediately to
    // allow test code to avoid loop spinning calls, which could cause
    // flakiness.

    if (storage) {
      [self initializeMenuWithProfileAttributesStorage:storage];
    } else {
      dispatch_async(dispatch_get_main_queue(), ^{
        [self initializeMenuWithProfileAttributesStorage:
                  &g_browser_process->profile_manager()
                       ->GetProfileAttributesStorage()];
      });
    }
  }
  return self;
}

- (instancetype)initWithMainMenuItem:(NSMenuItem*)item {
  return [self initWithMainMenuItem:item profileAttributesStorage:nullptr];
}

- (instancetype)initSynchronouslyForTestingWithMainMenuItem:(NSMenuItem*)item
                                   profileAttributesStorage:
                                       (ProfileAttributesStorage*)storage {
  return [self initWithMainMenuItem:item profileAttributesStorage:storage];
}

- (void)deinitialize {
  _avatarMenu.reset();
  _observer.reset();
}

- (IBAction)switchToProfileFromMenu:(id)sender {
  _avatarMenu->SwitchToProfile([sender tag], false);
}

- (IBAction)switchToProfileFromDock:(id)sender {
  // Explicitly bring to the foreground when taking action from the dock.
  [NSApp activateIgnoringOtherApps:YES];
  _avatarMenu->SwitchToProfile([sender tag], false);
}

- (IBAction)editProfile:(id)sender {
  std::optional<size_t> active_profile_index =
      _avatarMenu->GetActiveProfileIndex();
  DCHECK(active_profile_index);
  _avatarMenu->EditProfile(*active_profile_index);
}

- (IBAction)newProfile:(id)sender {
  _avatarMenu->AddNewProfile();
}

- (BOOL)insertItemsIntoMenu:(NSMenu*)menu
                   atOffset:(NSInteger)offset
                   fromDock:(BOOL)dock {
  if (!_avatarMenu) {
    return NO;
  }

  // Don't show the list of profiles in the dock if only one profile exists.
  if (dock && _avatarMenu->GetNumberOfItems() <= 1) {
    return NO;
  }

  if (dock) {
    NSMenuItem* header;
    if (@available(macOS 14, *)) {
      header = [NSMenuItem sectionHeaderWithTitle:GetProfileMenuTitle()];
    } else {
      header = [[NSMenuItem alloc] initWithTitle:GetProfileMenuTitle()
                                          action:nil
                                   keyEquivalent:@""];
    }
    header.enabled = NO;
    [menu insertItem:header atIndex:offset++];
  }

  for (size_t i = 0; i < _avatarMenu->GetNumberOfItems(); ++i) {
    const AvatarMenu::Item& itemData = _avatarMenu->GetItemAt(i);
    NSString* name = base::SysUTF16ToNSString(itemData.name);
    SEL action = dock ? @selector(switchToProfileFromDock:)
                      : @selector(switchToProfileFromMenu:);
    NSMenuItem* item = [self createItemWithTitle:name action:action];
    item.tag = itemData.menu_index;
    if (!dock) {
      gfx::Image itemIcon =
          profiles::GetAvatarIconForNSMenu(itemData.profile_path);
      item.image = itemIcon.ToNSImage();
      item.state =
          itemData.active ? NSControlStateValueOn : NSControlStateValueOff;
    }
    [menu insertItem:item atIndex:i + offset];
  }

  return YES;
}

- (BOOL)validateMenuItem:(NSMenuItem*)menuItem {
  if (!_avatarMenu->ShouldShowAddNewProfileLink() &&
      menuItem.action == @selector(newProfile:)) {
    return NO;
  }

  if (!_avatarMenu->ShouldShowEditProfileLink() && menuItem.action == @selector
                                                       (editProfile:)) {
    return NO;
  }

  return YES;
}

// Private /////////////////////////////////////////////////////////////////////

- (NSMenu*)menu {
  return _mainMenuItem.submenu;
}

- (void)initializeMenuWithProfileAttributesStorage:
    (ProfileAttributesStorage*)storage {
  _observer = std::make_unique<ProfileMenuControllerInternal::Observer>(self);
  _avatarMenu = std::make_unique<AvatarMenu>(storage, _observer.get(),
                                             /*browser=*/nullptr);
  _avatarMenu->RebuildMenu();

  [self.menu addItem:[NSMenuItem separatorItem]];

  NSMenuItem* item = [self createItemWithTitle:
      l10n_util::GetNSStringWithFixup(IDS_PROFILES_MANAGE_BUTTON_LABEL)
                                        action:@selector(editProfile:)];
  [self.menu addItem:item];

  if (_avatarMenu->ShouldShowAddNewProfileLink()) {
    [self.menu addItem:[NSMenuItem separatorItem]];

    item = [self createItemWithTitle:l10n_util::GetNSStringWithFixup(
                                         IDS_PROFILES_ADD_PROFILE_LABEL)
                              action:@selector(newProfile:)];
    [self.menu addItem:item];
  }

  [self rebuildMenu];
}

// Notifies the controller that the active browser has changed and that the
// menu item and menu need to be updated to reflect that.
- (void)activeBrowserChangedTo:(Browser*)browser {
  // Tell the menu that the browser has changed.
  _avatarMenu->ActiveBrowserChanged(browser);

  // If |browser| is NULL, it may be because the current profile was deleted
  // and there are no other loaded profiles.
  //
  // An early return provides the desired behavior:
  //   a) If the profile was deleted, the menu would have been rebuilt and no
  //      profile will have a check mark.
  //   b) If the profile was not deleted, but there is no active browser, then
  //      the previous profile will remain checked.
  if (!browser) {
    return;
  }

  // Update the avatar menu to get the active item states. Don't call
  // avatarMenu_->GetActiveProfileIndex() as the index might be
  // incorrect if -activeBrowserChangedTo: is called while we deleting the
  // active profile and closing all its browser windows.
  _avatarMenu->RebuildMenu();

  // Update the state for the menu items.
  for (size_t i = 0; i < _avatarMenu->GetNumberOfItems(); ++i) {
    const AvatarMenu::Item& itemData = _avatarMenu->GetItemAt(i);
    [[self.menu itemWithTag:itemData.menu_index]
        setState:itemData.active ? NSControlStateValueOn
                                 : NSControlStateValueOff];
  }
}

- (void)rebuildMenu {
  NSMenu* menu = self.menu;

  for (NSMenuItem* item = [menu itemAtIndex:0]; !item.separatorItem;
       item = [menu itemAtIndex:0]) {
    [menu removeItemAtIndex:0];
  }

  BOOL hasContent = [self insertItemsIntoMenu:menu atOffset:0 fromDock:NO];

  _mainMenuItem.hidden = !hasContent;
}

- (NSMenuItem*)createItemWithTitle:(NSString*)title action:(SEL)sel {
  NSMenuItem* item = [[NSMenuItem alloc] initWithTitle:title
                                                action:sel
                                         keyEquivalent:@""];
  item.target = self;
  return item;
}

@end