| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/ui/toolbar/toolbar_actions_model.h" |
| |
| #include <algorithm> |
| #include <memory> |
| #include <string> |
| #include <vector> |
| |
| #include "base/containers/contains.h" |
| #include "base/functional/bind.h" |
| #include "base/location.h" |
| #include "base/metrics/histogram_base.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/not_fatal_until.h" |
| #include "base/numerics/safe_conversions.h" |
| #include "base/observer_list.h" |
| #include "base/one_shot_event.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/task/single_thread_task_runner.h" |
| #include "chrome/browser/extensions/extension_management.h" |
| #include "chrome/browser/extensions/extension_tab_util.h" |
| #include "chrome/browser/extensions/profile_util.h" |
| #include "chrome/browser/extensions/tab_helper.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ui/browser.h" |
| #include "chrome/browser/ui/extensions/extension_action_view_controller.h" |
| #include "chrome/browser/ui/tabs/tab_strip_model.h" |
| #include "chrome/browser/ui/toolbar/toolbar_action_view_controller.h" |
| #include "chrome/browser/ui/toolbar/toolbar_actions_model_factory.h" |
| #include "chrome/browser/ui/web_applications/app_browser_controller.h" |
| #include "components/prefs/pref_service.h" |
| #include "content/public/browser/web_contents.h" |
| #include "extensions/browser/extension_action_manager.h" |
| #include "extensions/browser/extension_system.h" |
| #include "extensions/browser/extension_util.h" |
| #include "extensions/browser/permissions_manager.h" |
| #include "extensions/browser/pref_names.h" |
| #include "extensions/browser/unloaded_extension_reason.h" |
| #include "extensions/common/extension_set.h" |
| #include "extensions/common/manifest_constants.h" |
| #include "extensions/common/permissions/permissions_data.h" |
| |
| ToolbarActionsModel::ToolbarActionsModel( |
| Profile* profile, |
| extensions::ExtensionPrefs* extension_prefs) |
| : profile_(profile), |
| extension_prefs_(extension_prefs), |
| prefs_(profile_->GetPrefs()), |
| extension_action_dispatcher_( |
| extensions::ExtensionActionDispatcher::Get(profile_)), |
| extension_registry_(extensions::ExtensionRegistry::Get(profile_)), |
| extension_action_manager_( |
| extensions::ExtensionActionManager::Get(profile_)), |
| actions_initialized_(false) { |
| extensions::ExtensionSystem::Get(profile_)->ready().Post( |
| FROM_HERE, base::BindOnce(&ToolbarActionsModel::OnReady, |
| weak_ptr_factory_.GetWeakPtr())); |
| |
| // We only care about watching toolbar-order prefs if not in incognito mode. |
| pref_change_registrar_.Init(prefs_); |
| pref_change_registrar_.Add( |
| extensions::pref_names::kPinnedExtensions, |
| base::BindRepeating(&ToolbarActionsModel::UpdatePinnedActionIds, |
| base::Unretained(this))); |
| } |
| |
| ToolbarActionsModel::~ToolbarActionsModel() = default; |
| |
| // static |
| ToolbarActionsModel* ToolbarActionsModel::Get(Profile* profile) { |
| return ToolbarActionsModelFactory::GetForProfile(profile); |
| } |
| |
| void ToolbarActionsModel::AddObserver(Observer* observer) { |
| observers_.AddObserver(observer); |
| } |
| |
| void ToolbarActionsModel::RemoveObserver(Observer* observer) { |
| observers_.RemoveObserver(observer); |
| } |
| |
| void ToolbarActionsModel::OnExtensionActionUpdated( |
| extensions::ExtensionAction* extension_action, |
| content::WebContents* web_contents, |
| content::BrowserContext* browser_context) { |
| NotifyToolbarActionUpdated(extension_action->extension_id()); |
| } |
| |
| void ToolbarActionsModel::OnExtensionLoaded( |
| content::BrowserContext* browser_context, |
| const extensions::Extension* extension) { |
| // We don't want to add the same extension twice. It may have already been |
| // added by EXTENSION_BROWSER_ACTION_VISIBILITY_CHANGED below, if the user |
| // hides the browser action and then disables and enables the extension. |
| if (!HasAction(extension->id()) && ShouldAddExtension(extension)) { |
| AddAction(extension->id()); |
| } |
| } |
| |
| void ToolbarActionsModel::OnExtensionUnloaded( |
| content::BrowserContext* browser_context, |
| const extensions::Extension* extension, |
| extensions::UnloadedExtensionReason reason) { |
| RemoveAction(extension->id()); |
| } |
| |
| void ToolbarActionsModel::OnExtensionUninstalled( |
| content::BrowserContext* browser_context, |
| const extensions::Extension* extension, |
| extensions::UninstallReason reason) { |
| if (profile_->IsOffTheRecord()) { |
| // The on-the-record version will update the prefs; incognito is read-only. |
| return; |
| } |
| |
| // Remove the extension id from the ordered list, if it exists (the extension |
| // might not be represented in the list because it might not have an icon). |
| RemovePref(extension->id()); |
| } |
| |
| void ToolbarActionsModel::OnExtensionManagementSettingsChanged() { |
| UpdatePinnedActionIds(); |
| } |
| |
| void ToolbarActionsModel::OnExtensionPermissionsUpdated( |
| const extensions::Extension& extension, |
| const extensions::PermissionSet& permissions, |
| extensions::PermissionsManager::UpdateReason reason) { |
| NotifyToolbarActionUpdated(extension.id()); |
| } |
| |
| void ToolbarActionsModel::OnActiveTabPermissionGranted( |
| const extensions::Extension& extension) { |
| NotifyToolbarActionUpdated(extension.id()); |
| } |
| |
| void ToolbarActionsModel::Shutdown() { |
| permissions_manager_observation_.Reset(); |
| } |
| |
| void ToolbarActionsModel::RemovePref(const ActionId& action_id) { |
| // The extension is already unloaded at this point, and so shouldn't be in |
| // the active pinned set. |
| DCHECK(!IsActionPinned(action_id)); |
| auto stored_pinned_actions = extension_prefs_->GetPinnedExtensions(); |
| auto iter = std::ranges::find(stored_pinned_actions, action_id); |
| if (iter != stored_pinned_actions.end()) { |
| stored_pinned_actions.erase(iter); |
| extension_prefs_->SetPinnedExtensions(stored_pinned_actions); |
| } |
| } |
| |
| void ToolbarActionsModel::OnReady() { |
| InitializeActionList(); |
| |
| // Wait until the extension system is ready before observing any further |
| // changes so that the toolbar buttons can be shown in their stable ordering |
| // taken from prefs. |
| extension_registry_observation_.Observe(extension_registry_.get()); |
| extension_action_observation_.Observe(extension_action_dispatcher_.get()); |
| permissions_manager_observation_.Observe( |
| extensions::PermissionsManager::Get(profile_)); |
| |
| auto* management = |
| extensions::ExtensionManagementFactory::GetForBrowserContext(profile_); |
| extension_management_observation_.Observe(management); |
| |
| actions_initialized_ = true; |
| for (Observer& observer : observers_) { |
| observer.OnToolbarModelInitialized(); |
| } |
| } |
| |
| bool ToolbarActionsModel::ShouldAddExtension( |
| const extensions::Extension* extension) { |
| // In incognito mode, don't add any extensions that aren't incognito-enabled. |
| if (profile_->IsOffTheRecord() && |
| !extensions::util::IsIncognitoEnabled(extension->id(), profile_)) { |
| return false; |
| } |
| |
| // In this case, we don't care about the browser action visibility, because |
| // we want to show each extension regardless. |
| return extension_action_manager_->GetExtensionAction(*extension) != nullptr; |
| } |
| |
| void ToolbarActionsModel::AddAction(const ActionId& action_id) { |
| // We only use AddAction() once the system is initialized. |
| CHECK(actions_initialized_); |
| |
| action_ids_.insert(action_id); |
| |
| for (Observer& observer : observers_) { |
| observer.OnToolbarActionAdded(action_id); |
| } |
| |
| UpdatePinnedActionIds(); |
| } |
| |
| void ToolbarActionsModel::RemoveAction(const ActionId& action_id) { |
| const bool did_erase = action_ids_.erase(action_id) > 0; |
| // TODO(devlin): Can we DCHECK did_erase? |
| if (!did_erase) { |
| return; |
| } |
| |
| UpdatePinnedActionIds(); |
| |
| for (Observer& observer : observers_) { |
| observer.OnToolbarActionRemoved(action_id); |
| } |
| } |
| |
| const std::u16string ToolbarActionsModel::GetExtensionName( |
| const ActionId& action_id) const { |
| return base::UTF8ToUTF16( |
| extension_registry_->enabled_extensions().GetByID(action_id)->name()); |
| } |
| |
| bool ToolbarActionsModel::HasAction(const ActionId& action_id) const { |
| return base::Contains(action_ids_, action_id); |
| } |
| |
| bool ToolbarActionsModel::CanShowActionsInToolbar(const Browser& browser) { |
| // Pinning extensions is not available in PWAs. |
| return !web_app::AppBrowserController::IsWebApp(&browser); |
| } |
| |
| bool ToolbarActionsModel::IsRestrictedUrl(const GURL& url) const { |
| // We consider a site to be restricted if it's restricted for every |
| // extension in the toolbar. This can vary based on the extensions |
| // installed - if the user has an extension that can execute script |
| // everywhere and has an icon in the toolbar (like the non-ChromeOS version |
| // of ChromeVox), then otherwise-restricted sites may not be. |
| // If nay extension has access, we want to properly message that (since |
| // saying "No extensions can run..." is inaccurate). Other extensions |
| // will still be properly attributed in UI. |
| return std::ranges::all_of(action_ids(), [this, url](ActionId id) { |
| // action_ids() could include disabled extensions that haven't been removed |
| // yet from the set due to race conditions. Thus, we don't consider them in |
| // the restricted url computation. |
| auto* extension = GetExtensionById(id); |
| if (!extension) { |
| return true; |
| } |
| |
| return extension->permissions_data()->IsRestrictedUrl(url, |
| /*error=*/nullptr); |
| }); |
| } |
| |
| bool ToolbarActionsModel::IsPolicyBlockedHost(const GURL& url) const { |
| extensions::ManagementPolicy* policy = |
| extensions::ExtensionSystem::Get(profile_)->management_policy(); |
| auto is_enterprise_extension = |
| [policy](const extensions::Extension& extension) { |
| return !policy->UserMayModifySettings(&extension, nullptr) || |
| policy->MustRemainInstalled(&extension, nullptr); |
| }; |
| |
| // `url` is NOT a policy-blockedsite when there are no extensions installed. |
| if (action_ids().empty()) { |
| return false; |
| } |
| |
| for (auto& action_id : action_ids()) { |
| // Skip enterprise extensions since they could still access policy-blocked |
| // sites. |
| const extensions::Extension* extension = GetExtensionById(action_id); |
| if (is_enterprise_extension(*extension)) { |
| continue; |
| } |
| |
| // `url` is NOT a policy-blocked sit when it's allowed for any |
| // non-enterprise extension. |
| if (!extension->permissions_data()->IsPolicyBlockedHost(url)) { |
| return false; |
| } |
| } |
| |
| // `url` is a policy-blocked site when it's blocked for every non-enterprise |
| // extension. |
| return true; |
| } |
| |
| bool ToolbarActionsModel::IsActionPinned(const ActionId& action_id) const { |
| return base::Contains(pinned_action_ids_, action_id); |
| } |
| |
| bool ToolbarActionsModel::IsActionForcePinned(const ActionId& action_id) const { |
| auto* management = |
| extensions::ExtensionManagementFactory::GetForBrowserContext(profile_); |
| return base::Contains(management->GetForcePinnedList(), action_id); |
| } |
| |
| void ToolbarActionsModel::MovePinnedAction(const ActionId& action_id, |
| size_t target_index) { |
| // TODO(crbug.com/40204281): This code assumes all actions are in |
| // stored_pinned_actions, which force-pinned actions aren't; so, always keep |
| // them 'to the right' of other actions. Remove this guard if we ever add |
| // force-pinned actions to the pref. |
| if (IsActionForcePinned(action_id)) { |
| return; |
| } |
| |
| // If pinned actions are empty, we're going to have a real bad time (with |
| // out Keep this a hard CHECK (not a DCHECK). |
| CHECK(!pinned_action_ids_.empty()); |
| DCHECK(!profile_->IsOffTheRecord()) |
| << "Changing action position is disallowed in incognito."; |
| |
| auto current_position_on_toolbar = |
| std::ranges::find(pinned_action_ids_, action_id); |
| CHECK(current_position_on_toolbar != pinned_action_ids_.end(), |
| base::NotFatalUntil::M130); |
| size_t current_index_on_toolbar = |
| current_position_on_toolbar - pinned_action_ids_.begin(); |
| |
| if (current_index_on_toolbar == target_index) { |
| return; |
| } |
| |
| bool is_left_to_right_move = target_index > current_index_on_toolbar; |
| |
| // Moving pinned actions is a bit tricky (unless we move it to the end - in |
| // which case it's trivial). We need to store the updated state in prefs, but |
| // the prefs also contain pin state information for unloaded (but still |
| // installed) extensions. Thus, we can't just reorder the pinned_action_ids_ |
| // (which only include loaded extensions), and set those directly. |
| // |
| // Instead, we look at the destination of the action in the toolbar, and |
| // find the ID of the action to its right (if any). Then in the stored prefs, |
| // find that action, and insert the moved action to its left. |
| // |
| // To further complicate things, force-pinned actions are stored in |
| // |pinned_action_ids_| but not in the pref (crbug.com/1266952). So we have to |
| // find the ID not just of the action to its right, but the first action to |
| // its right that is *not* force-pinned. |
| // |
| // For example: |
| // Consider the pinned extension order in prefs is "A [B C] D E", where |
| // B and C are unloaded extensions. Assume we want to A to index 1 on the |
| // toolbar (swapping A and D). We would look for the new action to its |
| // right (E), and insert it in prefs to the left of it. Thus, the new pref |
| // order would be "[B C] D A E". |
| |
| // Force-pinned neighbors aren't saved in the pref, so find the preceding, |
| // non-force-pinned neighbor. This basically keeps force-pinned actions on the |
| // right at all times. |
| // |
| // TODO(crbug.com/40204281): Simplify this logic when force-pinned extensions |
| // are saved in the pref. |
| std::vector<ActionId>::iterator non_force_pinned_neighbor = |
| pinned_action_ids_.end(); |
| if (is_left_to_right_move) { |
| // LTR move. Starting with the extension to the right of the desired |
| // location, do an RTL search for the first non-force-pinned extension. |
| // Note: there's always an extension that matches these criteria (this |
| // one!). |
| |
| // Avoid array bounds shenanigans when target_index >= n. |
| auto search_start = std::max(pinned_action_ids_.rend() - target_index - 1, |
| pinned_action_ids_.rbegin()); |
| auto reverse_iter = std::find_if( |
| search_start, pinned_action_ids_.rend(), |
| [this](const ActionId& id) { return !this->IsActionForcePinned(id); }); |
| non_force_pinned_neighbor = reverse_iter.base(); |
| } else { |
| // RTL move. Starting with the extension to the left of the desired |
| // location, do an LTR search for the first non-force-pinned extension. |
| // Note: there's always an extension that matches these criteria (this |
| // one!). |
| non_force_pinned_neighbor = std::find_if( |
| pinned_action_ids_.begin() + target_index, pinned_action_ids_.end(), |
| [this](const ActionId& id) { return !this->IsActionForcePinned(id); }); |
| } |
| |
| auto stored_pinned_actions = extension_prefs_->GetPinnedExtensions(); |
| const bool move_to_end = |
| non_force_pinned_neighbor == pinned_action_ids_.end(); |
| auto target_position = move_to_end |
| ? stored_pinned_actions.end() |
| : std::ranges::find(stored_pinned_actions, |
| *non_force_pinned_neighbor); |
| |
| auto current_position_in_prefs = |
| std::ranges::find(stored_pinned_actions, action_id); |
| CHECK(current_position_in_prefs != stored_pinned_actions.end(), |
| base::NotFatalUntil::M130); |
| |
| // Rotate |action_id| to be in the target position. |
| if (is_left_to_right_move) { |
| std::rotate(current_position_in_prefs, std::next(current_position_in_prefs), |
| target_position); |
| } else { |
| std::rotate(target_position, current_position_in_prefs, |
| std::next(current_position_in_prefs)); |
| } |
| |
| extension_prefs_->SetPinnedExtensions(stored_pinned_actions); |
| // The |pinned_action_ids_| should be updated as a result of updating the |
| // preference. |
| DCHECK(pinned_action_ids_ == GetFilteredPinnedActionIds()); |
| } |
| |
| // Combine the currently enabled extensions that have browser actions (which |
| // we get from the ExtensionRegistry) with the ordering we get from the pref |
| // service. For robustness we use a somewhat inefficient process: |
| // 1. Create a vector of actions sorted by their pref values. This vector may |
| // have holes. |
| // 2. Create a vector of actions that did not have a pref value. |
| // 3. Remove holes from the sorted vector and append the unsorted vector. |
| void ToolbarActionsModel::InitializeActionList() { |
| CHECK(action_ids_.empty()); // We shouldn't have any actions yet. |
| |
| if (profile_->IsOffTheRecord()) { |
| IncognitoPopulate(); |
| } else { |
| Populate(); |
| } |
| |
| // Set |pinned_action_ids_| directly to avoid notifying observers that they |
| // have changed even though they haven't. |
| pinned_action_ids_ = GetFilteredPinnedActionIds(); |
| |
| if (!profile_->IsOffTheRecord()) { |
| // Prefixed with "ExtensionToolbarModel" rather than |
| // "Extensions.Toolbar" for historical reasons. |
| base::UmaHistogramCounts100("ExtensionToolbarModel.BrowserActionsCount", |
| action_ids_.size()); |
| if (extensions::profile_util::ProfileCanUseNonComponentExtensions( |
| profile_)) { |
| base::UmaHistogramCounts100("Extension.Toolbar.BrowserActionsCount2", |
| action_ids_.size()); |
| } |
| if (!action_ids_.empty()) { |
| base::UmaHistogramCounts100("Extensions.Toolbar.PinnedExtensionCount2", |
| pinned_action_ids_.size()); |
| double percentage_double = |
| static_cast<double>(pinned_action_ids_.size()) / action_ids_.size() * |
| 100.0; |
| base::UmaHistogramPercentageObsoleteDoNotUse( |
| "Extensions.Toolbar.PinnedExtensionPercentage3", |
| base::ClampRound(percentage_double)); |
| } |
| } |
| } |
| |
| void ToolbarActionsModel::Populate() { |
| DCHECK(!profile_->IsOffTheRecord()); |
| |
| // Add the extension action ids to all_actions. |
| const extensions::ExtensionSet& extensions = |
| extension_registry_->enabled_extensions(); |
| for (const scoped_refptr<const extensions::Extension>& extension : |
| extensions) { |
| if (!ShouldAddExtension(extension.get())) { |
| continue; |
| } |
| action_ids_.insert(extension->id()); |
| } |
| } |
| |
| void ToolbarActionsModel::IncognitoPopulate() { |
| DCHECK(profile_->IsOffTheRecord()); |
| const ToolbarActionsModel* original_model = |
| ToolbarActionsModel::Get(profile_->GetOriginalProfile()); |
| |
| // Only extensions enabled in incognito mode are added to the incognito mode |
| // toolbar. |
| base::flat_set<ActionId> incognito_ids = original_model->action_ids_; |
| base::EraseIf(incognito_ids, [this](const ActionId& id) { |
| return !ShouldAddExtension(GetExtensionById(id)); |
| }); |
| action_ids_ = std::move(incognito_ids); |
| } |
| |
| void ToolbarActionsModel::SetActionVisibility(const ActionId& action_id, |
| bool is_now_visible) { |
| DCHECK_NE(is_now_visible, IsActionPinned(action_id)); |
| DCHECK(!IsActionForcePinned(action_id)); |
| DCHECK(!profile_->IsOffTheRecord()) |
| << "Changing action pin state is disallowed in incognito."; |
| |
| auto stored_pinned_action_ids = extension_prefs_->GetPinnedExtensions(); |
| DCHECK_NE(is_now_visible, |
| base::Contains(stored_pinned_action_ids, action_id)); |
| if (is_now_visible) { |
| stored_pinned_action_ids.push_back(action_id); |
| } else { |
| std::erase(stored_pinned_action_ids, action_id); |
| } |
| extension_prefs_->SetPinnedExtensions(stored_pinned_action_ids); |
| // The |pinned_action_ids_| should be updated as a result of updating the |
| // preference. |
| DCHECK(pinned_action_ids_ == GetFilteredPinnedActionIds()); |
| |
| extension_action_dispatcher_->OnActionPinnedStateChanged(action_id, |
| is_now_visible); |
| } |
| |
| const extensions::Extension* ToolbarActionsModel::GetExtensionById( |
| const ActionId& action_id) const { |
| return extension_registry_->enabled_extensions().GetByID(action_id); |
| } |
| |
| void ToolbarActionsModel::UpdatePinnedActionIds() { |
| // If extensions are not ready, defer to later Populate() call. |
| if (!actions_initialized_) { |
| return; |
| } |
| |
| std::vector<ActionId> pinned_extensions = GetFilteredPinnedActionIds(); |
| if (pinned_extensions == pinned_action_ids_) { |
| return; |
| } |
| |
| pinned_action_ids_ = pinned_extensions; |
| for (Observer& observer : observers_) { |
| observer.OnToolbarPinnedActionsChanged(); |
| } |
| } |
| |
| std::vector<ToolbarActionsModel::ActionId> |
| ToolbarActionsModel::GetFilteredPinnedActionIds() const { |
| // Force-pinned extensions should always be present in the output vector. |
| extensions::ExtensionIdList pinned = extension_prefs_->GetPinnedExtensions(); |
| auto* management = |
| extensions::ExtensionManagementFactory::GetForBrowserContext(profile_); |
| // O(n^2), but there are typically very few force-pinned extensions. |
| std::ranges::copy_if( |
| management->GetForcePinnedList(), std::back_inserter(pinned), |
| [&pinned](const std::string& id) { return !base::Contains(pinned, id); }); |
| |
| // TODO(pbos): Make sure that the pinned IDs are pruned from ExtensionPrefs on |
| // startup so that we don't keep saving stale IDs. |
| std::vector<ActionId> filtered_action_ids; |
| for (auto& action_id : pinned) { |
| if (HasAction(action_id)) { |
| filtered_action_ids.push_back(action_id); |
| } |
| } |
| return filtered_action_ids; |
| } |
| |
| void ToolbarActionsModel::NotifyToolbarActionUpdated( |
| const ActionId& action_id) { |
| if (!HasAction(action_id)) { |
| return; |
| } |
| |
| for (Observer& observer : observers_) { |
| observer.OnToolbarActionUpdated(action_id); |
| } |
| } |