| // 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/extensions/api/omnibox/omnibox_api.h" |
| |
| #include <stddef.h> |
| |
| #include <memory> |
| #include <optional> |
| #include <string> |
| #include <string_view> |
| #include <utility> |
| #include <vector> |
| |
| #include "base/functional/bind.h" |
| #include "base/lazy_instance.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/values.h" |
| #include "build/build_config.h" |
| #include "chrome/browser/extensions/permissions/active_tab_permission_granter.h" |
| #include "chrome/browser/omnibox/omnibox_input_watcher_factory.h" |
| #include "chrome/browser/omnibox/omnibox_suggestions_watcher_factory.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/search_engines/template_url_service_factory.h" |
| #include "chrome/common/extensions/api/omnibox.h" |
| #include "chrome/common/extensions/api/omnibox/omnibox_handler.h" |
| #include "components/omnibox/browser/omnibox_input_watcher.h" |
| #include "components/omnibox/browser/omnibox_suggestions_watcher.h" |
| #include "components/search_engines/template_url.h" |
| #include "components/search_engines/template_url_service.h" |
| #include "extensions/browser/event_router.h" |
| #include "extensions/browser/extension_prefs.h" |
| #include "extensions/browser/extension_prefs_factory.h" |
| #include "extensions/browser/icon_util.h" |
| #include "extensions/browser/install_prefs_helper.h" |
| #include "extensions/common/extension_features.h" |
| #include "extensions/common/extension_id.h" |
| #include "extensions/common/mojom/api_permission_id.mojom.h" |
| #include "extensions/common/permissions/permissions_data.h" |
| #include "ui/gfx/image/image.h" |
| |
| namespace extensions { |
| |
| namespace omnibox = api::omnibox; |
| namespace SendSuggestions = omnibox::SendSuggestions; |
| namespace SetDefaultSuggestion = omnibox::SetDefaultSuggestion; |
| |
| namespace { |
| |
| const char kSuggestionContent[] = "content"; |
| const char kCurrentTabDisposition[] = "currentTab"; |
| const char kForegroundTabDisposition[] = "newForegroundTab"; |
| const char kBackgroundTabDisposition[] = "newBackgroundTab"; |
| |
| // Pref key for omnibox.setDefaultSuggestion. |
| const char kOmniboxDefaultSuggestion[] = "omnibox_default_suggestion"; |
| |
| std::optional<omnibox::SuggestResult> GetOmniboxDefaultSuggestion( |
| Profile* profile, |
| const ExtensionId& extension_id) { |
| ExtensionPrefs* prefs = ExtensionPrefs::Get(profile); |
| if (!prefs) { |
| return std::nullopt; |
| } |
| |
| const base::Value::Dict* dict = |
| prefs->ReadPrefAsDict(extension_id, kOmniboxDefaultSuggestion); |
| if (!dict) { |
| return std::nullopt; |
| } |
| return omnibox::SuggestResult::FromValue(*dict); |
| } |
| |
| // Tries to set the omnibox default suggestion; returns true on success or |
| // false on failure. |
| bool SetOmniboxDefaultSuggestion( |
| Profile* profile, |
| const ExtensionId& extension_id, |
| const omnibox::DefaultSuggestResult& suggestion) { |
| ExtensionPrefs* prefs = ExtensionPrefs::Get(profile); |
| if (!prefs) |
| return false; |
| |
| base::Value::Dict dict = suggestion.ToValue(); |
| // Add the content field so that the dictionary can be used to populate an |
| // omnibox::SuggestResult. |
| dict.Set(kSuggestionContent, base::Value(base::Value::Type::STRING)); |
| prefs->UpdateExtensionPref(extension_id, kOmniboxDefaultSuggestion, |
| base::Value(std::move(dict))); |
| |
| return true; |
| } |
| |
| // Returns a string used as a template URL string of the extension. |
| std::string GetTemplateURLStringForExtension(const ExtensionId& extension_id) { |
| // This URL is not actually used for navigation. It holds the extension's ID. |
| return std::string(extensions::kExtensionScheme) + "://" + |
| extension_id + "/?q={searchTerms}"; |
| } |
| |
| bool IsUnscopedModeAllowed(const Extension* extension) { |
| // The extension can use unscoepd mode if the feature is enabled and the |
| // permission has been granted. |
| return base::FeatureList::IsEnabled( |
| extensions_features::kExperimentalOmniboxLabs) && |
| extension->permissions_data()->HasAPIPermission( |
| mojom::APIPermissionID::kOmniboxDirectInput); |
| } |
| |
| } // namespace |
| |
| // static |
| void ExtensionOmniboxEventRouter::OnInputStarted( |
| Profile* profile, |
| const ExtensionId& extension_id) { |
| auto event = std::make_unique<Event>(events::OMNIBOX_ON_INPUT_STARTED, |
| omnibox::OnInputStarted::kEventName, |
| base::Value::List(), profile); |
| EventRouter::Get(profile) |
| ->DispatchEventToExtension(extension_id, std::move(event)); |
| } |
| |
| // static |
| bool ExtensionOmniboxEventRouter::OnInputChanged( |
| Profile* profile, |
| const ExtensionId& extension_id, |
| const std::string& input, |
| int suggest_id) { |
| EventRouter* event_router = EventRouter::Get(profile); |
| if (!event_router->ExtensionHasEventListener( |
| extension_id, omnibox::OnInputChanged::kEventName)) |
| return false; |
| |
| base::Value::List args; |
| args.Append(input); |
| args.Append(suggest_id); |
| |
| auto event = std::make_unique<Event>(events::OMNIBOX_ON_INPUT_CHANGED, |
| omnibox::OnInputChanged::kEventName, |
| std::move(args), profile); |
| event_router->DispatchEventToExtension(extension_id, std::move(event)); |
| return true; |
| } |
| |
| // static |
| void ExtensionOmniboxEventRouter::OnInputEntered( |
| content::WebContents* web_contents, |
| const ExtensionId& extension_id, |
| const std::string& input, |
| WindowOpenDisposition disposition) { |
| Profile* profile = |
| Profile::FromBrowserContext(web_contents->GetBrowserContext()); |
| |
| const Extension* extension = |
| ExtensionRegistry::Get(profile)->enabled_extensions().GetByID( |
| extension_id); |
| CHECK(extension); |
| extensions::ActiveTabPermissionGranter::FromWebContents(web_contents) |
| ->GrantIfRequested(extension); |
| |
| base::Value::List args; |
| args.Append(input); |
| if (disposition == WindowOpenDisposition::NEW_FOREGROUND_TAB) |
| args.Append(kForegroundTabDisposition); |
| else if (disposition == WindowOpenDisposition::NEW_BACKGROUND_TAB) |
| args.Append(kBackgroundTabDisposition); |
| else |
| args.Append(kCurrentTabDisposition); |
| |
| auto event = std::make_unique<Event>(events::OMNIBOX_ON_INPUT_ENTERED, |
| omnibox::OnInputEntered::kEventName, |
| std::move(args), profile); |
| event->user_gesture = EventRouter::UserGestureState::kEnabled; |
| EventRouter::Get(profile) |
| ->DispatchEventToExtension(extension_id, std::move(event)); |
| |
| OmniboxInputWatcherFactory::GetForBrowserContext(profile) |
| ->NotifyInputEntered(); |
| } |
| |
| // static |
| void ExtensionOmniboxEventRouter::OnInputCancelled( |
| Profile* profile, |
| const ExtensionId& extension_id) { |
| auto event = std::make_unique<Event>(events::OMNIBOX_ON_INPUT_CANCELLED, |
| omnibox::OnInputCancelled::kEventName, |
| base::Value::List(), profile); |
| EventRouter::Get(profile) |
| ->DispatchEventToExtension(extension_id, std::move(event)); |
| } |
| |
| void ExtensionOmniboxEventRouter::OnDeleteSuggestion( |
| Profile* profile, |
| const ExtensionId& extension_id, |
| const std::string& suggestion_text) { |
| base::Value::List args; |
| args.Append(suggestion_text); |
| |
| auto event = std::make_unique<Event>(events::OMNIBOX_ON_DELETE_SUGGESTION, |
| omnibox::OnDeleteSuggestion::kEventName, |
| std::move(args), profile); |
| |
| EventRouter::Get(profile)->DispatchEventToExtension(extension_id, |
| std::move(event)); |
| } |
| |
| // static |
| void ExtensionOmniboxEventRouter::OnActionExecuted( |
| Profile* profile, |
| const ExtensionId& extension_id, |
| const std::string& action_name, |
| const std::string& content) { |
| EventRouter* event_router = EventRouter::Get(profile); |
| if (!event_router->ExtensionHasEventListener( |
| extension_id, omnibox::OnActionExecuted::kEventName)) { |
| return; |
| } |
| |
| omnibox::ActionExecution action_execution; |
| action_execution.action_name = action_name; |
| action_execution.content = content; |
| auto event = std::make_unique<Event>( |
| events::OMNIBOX_ON_ACTION_EXECUTED, omnibox::OnActionExecuted::kEventName, |
| omnibox::OnActionExecuted::Create(std::move(action_execution)), profile); |
| event->user_gesture = EventRouter::UserGestureState::kEnabled; |
| event_router->DispatchEventToExtension(extension_id, std::move(event)); |
| } |
| |
| OmniboxAPI::OmniboxAPI(content::BrowserContext* context) |
| : profile_(Profile::FromBrowserContext(context)), |
| url_service_(TemplateURLServiceFactory::GetForProfile(profile_)) { |
| extension_registry_observation_.Observe(ExtensionRegistry::Get(profile_)); |
| if (url_service_) { |
| template_url_subscription_ = |
| url_service_->RegisterOnLoadedCallback(base::BindOnce( |
| &OmniboxAPI::OnTemplateURLsLoaded, base::Unretained(this))); |
| } |
| |
| // Use monochrome icons for Omnibox icons. |
| omnibox_icon_manager_.set_monochrome(true); |
| |
| permissions_manager_observation_.Observe(PermissionsManager::Get(profile_)); |
| } |
| |
| void OmniboxAPI::Shutdown() { |
| template_url_subscription_ = {}; |
| permissions_manager_observation_.Reset(); |
| } |
| |
| OmniboxAPI::~OmniboxAPI() = default; |
| |
| static base::LazyInstance<BrowserContextKeyedAPIFactory<OmniboxAPI>>:: |
| DestructorAtExit g_omnibox_api_factory = LAZY_INSTANCE_INITIALIZER; |
| |
| // static |
| BrowserContextKeyedAPIFactory<OmniboxAPI>* OmniboxAPI::GetFactoryInstance() { |
| return g_omnibox_api_factory.Pointer(); |
| } |
| |
| // static |
| OmniboxAPI* OmniboxAPI::Get(content::BrowserContext* context) { |
| return BrowserContextKeyedAPIFactory<OmniboxAPI>::Get(context); |
| } |
| |
| void OmniboxAPI::OnExtensionLoaded(content::BrowserContext* browser_context, |
| const Extension* extension) { |
| const std::string& keyword = OmniboxInfo::GetKeyword(extension); |
| if (!keyword.empty()) { |
| // Load the omnibox icon so it will be ready to display in the URL bar. |
| omnibox_icon_manager_.LoadIcon(profile_, extension); |
| if (url_service_) { |
| url_service_->Load(); |
| if (url_service_->loaded()) { |
| url_service_->RegisterExtensionControlledTURL( |
| extension->id(), extension->short_name(), keyword, |
| GetTemplateURLStringForExtension(extension->id()), |
| GetLastUpdateTime(ExtensionPrefs::Get(profile_), extension->id()), |
| IsUnscopedModeAllowed(extension)); |
| } else { |
| pending_extensions_.insert(extension); |
| } |
| } |
| } |
| } |
| |
| void OmniboxAPI::OnExtensionUnloaded(content::BrowserContext* browser_context, |
| const Extension* extension, |
| UnloadedExtensionReason reason) { |
| if (!OmniboxInfo::GetKeyword(extension).empty() && url_service_) { |
| if (url_service_->loaded()) { |
| url_service_->RemoveExtensionControlledTURL( |
| extension->id(), TemplateURL::OMNIBOX_API_EXTENSION); |
| } else { |
| pending_extensions_.erase(extension); |
| } |
| } |
| } |
| |
| void OmniboxAPI::OnExtensionPermissionsUpdated( |
| const Extension& extension, |
| const PermissionSet& permissions, |
| PermissionsManager::UpdateReason reason) { |
| if (!permissions.HasAPIPermission( |
| mojom::APIPermissionID::kOmniboxDirectInput)) { |
| return; |
| } |
| |
| if (reason == PermissionsManager::UpdateReason::kAdded && |
| base::FeatureList::IsEnabled( |
| extensions_features::kExperimentalOmniboxLabs)) { |
| url_service_->AddToUnscopedModeExtensionIds(extension.id()); |
| } else if (reason == PermissionsManager::UpdateReason::kRemoved) { |
| url_service_->RemoveFromUnscopedModeExtensionIdsIfPresent(extension.id()); |
| } |
| } |
| |
| gfx::Image OmniboxAPI::GetOmniboxIcon(const ExtensionId& extension_id) { |
| return omnibox_icon_manager_.GetIcon(extension_id); |
| } |
| |
| void OmniboxAPI::OnTemplateURLsLoaded() { |
| // Register keywords for pending extensions. |
| template_url_subscription_ = {}; |
| for (const Extension* i : pending_extensions_) { |
| url_service_->RegisterExtensionControlledTURL( |
| i->id(), i->short_name(), OmniboxInfo::GetKeyword(i), |
| GetTemplateURLStringForExtension(i->id()), |
| GetLastUpdateTime(ExtensionPrefs::Get(profile_), i->id()), |
| IsUnscopedModeAllowed(i)); |
| } |
| pending_extensions_.clear(); |
| } |
| |
| template <> |
| void BrowserContextKeyedAPIFactory<OmniboxAPI>::DeclareFactoryDependencies() { |
| DependsOn(ExtensionsBrowserClient::Get()->GetExtensionSystemFactory()); |
| DependsOn(ExtensionPrefsFactory::GetInstance()); |
| DependsOn(TemplateURLServiceFactory::GetInstance()); |
| DependsOn(PermissionsManager::GetFactory()); |
| } |
| |
| OmniboxSendSuggestionsFunction::OmniboxSendSuggestionsFunction() = default; |
| OmniboxSendSuggestionsFunction::~OmniboxSendSuggestionsFunction() = default; |
| |
| ExtensionFunction::ResponseAction OmniboxSendSuggestionsFunction::Run() { |
| std::optional<api::omnibox::SendSuggestions::Params> params = |
| SendSuggestions::Params::Create(args()); |
| EXTENSION_FUNCTION_VALIDATE(params); |
| request_id_ = params->request_id; |
| |
| if (!params->suggest_results.empty()) { |
| std::vector<std::string_view> inputs; |
| inputs.reserve(params->suggest_results.size()); |
| for (const auto& suggestion : params->suggest_results) { |
| std::vector<ExtensionSuggestion::Action> actions; |
| inputs.push_back(suggestion.description); |
| if (suggestion.actions) { |
| if (!IsUnscopedModeAllowed(extension())) { |
| return RespondNow( |
| Error(ExtensionOmniboxEventRouter:: |
| kActionsRequireDirectInputPermissionError)); |
| } |
| if (suggestion.actions->size() > |
| ExtensionOmniboxEventRouter::kMaxSuggestionActions) { |
| return RespondNow(Error(base::StringPrintf( |
| ExtensionOmniboxEventRouter::kMaxSuggestionActionsExceededError, |
| suggestion.actions->size(), |
| ExtensionOmniboxEventRouter::kMaxSuggestionActions))); |
| } |
| actions.reserve(suggestion.actions->size()); |
| for (const auto& action : *suggestion.actions) { |
| base::Value::Dict canvas_set = |
| action.icon ? action.icon->ToValue() : base::Value::Dict(); |
| gfx::ImageSkia image_skia; |
| if (!canvas_set.empty()) { |
| base::Value::Dict& image_data = *canvas_set.FindDict("imageData"); |
| // The image data should have been verified by the pre-validation |
| // param update. |
| CHECK(!image_data.empty()); |
| // TODO(crbug.com/408069174): Move ParseIconFromCanvasDictionary |
| // outside `ExtensionAction` into a common file. |
| if (extensions::ParseIconFromCanvasDictionary(image_data, |
| &image_skia) != |
| extensions::IconParseResult::kSuccess) { |
| return RespondNow(Error(base::StringPrintf( |
| ExtensionOmniboxEventRouter::kActionIconError, |
| suggestion.description, action.name))); |
| } |
| } |
| actions.emplace_back(action.name, action.label, action.tooltip_text, |
| gfx::Image(image_skia)); |
| } |
| } |
| |
| const std::vector<api::omnibox::MatchClassification> empty_styles; |
| const std::vector<api::omnibox::MatchClassification>* styles_ptr = |
| suggestion.description_styles ? &suggestion.description_styles.value() |
| : &empty_styles; |
| extension_suggestions_.emplace_back( |
| suggestion.content, suggestion.description, |
| suggestion.deletable.value_or(false), |
| StyleTypesToACMatchClassifications(styles_ptr, |
| suggestion.description), |
| std::move(actions), suggestion.icon_url); |
| } |
| |
| if (is_from_service_worker()) { |
| ParseDescriptionsAndStyles( |
| inputs, |
| base::BindOnce( |
| &OmniboxSendSuggestionsFunction::OnParsedDescriptionsAndStyles, |
| this)); |
| return RespondLater(); |
| } |
| } |
| |
| NotifySuggestionsReady(); |
| return RespondNow(NoArguments()); |
| } |
| |
| void OmniboxSendSuggestionsFunction::OnParsedDescriptionsAndStyles( |
| DescriptionAndStylesResult result) { |
| DCHECK_NE(0u, extension_suggestions_.size()); |
| // Since the XML parsing happens asynchronously, the browser context can be |
| // torn down in the interim. If this happens, early-out. |
| if (!browser_context()) { |
| return; |
| } |
| |
| if (!result.error.empty()) { |
| Respond(Error(std::move(result.error))); |
| return; |
| } |
| |
| if (result.descriptions_and_styles.size() != extension_suggestions_.size()) { |
| // This can technically happen if the extension provided input that mucked |
| // with our XML parsing (see suggestion_parser_unittest.cc). This isn't a |
| // security concern, but would mean that our mapping to record the other |
| // fields in the suggestion are mismatched. Abort. Since there's no |
| // legitimate case for this happening, just emit a generic error message. |
| Respond(Error("Invalid input.")); |
| return; |
| } |
| |
| for (size_t i = 0; i < extension_suggestions_.size(); ++i) { |
| extension_suggestions_[i].description = |
| base::UTF16ToUTF8(result.descriptions_and_styles[i].description); |
| extension_suggestions_[i].match_classifications = |
| StyleTypesToACMatchClassifications( |
| &result.descriptions_and_styles[i].styles, |
| extension_suggestions_[i].description); |
| } |
| |
| NotifySuggestionsReady(); |
| Respond(NoArguments()); |
| } |
| |
| void OmniboxSendSuggestionsFunction::NotifySuggestionsReady() { |
| Profile* profile = |
| Profile::FromBrowserContext(browser_context())->GetOriginalProfile(); |
| OmniboxSuggestionsWatcherFactory::GetForBrowserContext(profile) |
| ->NotifySuggestionsReady(extension_suggestions_, request_id_, |
| extension_id()); |
| } |
| |
| ExtensionFunction::ResponseAction OmniboxSetDefaultSuggestionFunction::Run() { |
| std::optional<SetDefaultSuggestion::Params> params = |
| SetDefaultSuggestion::Params::Create(args()); |
| EXTENSION_FUNCTION_VALIDATE(params); |
| |
| if (!params->suggestion.description_styles) { |
| ParseDescriptionAndStyles( |
| params->suggestion.description, |
| base::BindOnce( |
| &OmniboxSetDefaultSuggestionFunction::OnParsedDescriptionAndStyles, |
| this)); |
| return RespondLater(); |
| } |
| |
| SetDefaultSuggestion(params->suggestion); |
| return RespondNow(NoArguments()); |
| } |
| |
| void OmniboxSetDefaultSuggestionFunction::OnParsedDescriptionAndStyles( |
| DescriptionAndStylesResult result) { |
| if (!result.error.empty()) { |
| Respond(Error(std::move(result.error))); |
| return; |
| } |
| |
| DCHECK_EQ(1u, result.descriptions_and_styles.size()); |
| DescriptionAndStyles& single_result = result.descriptions_and_styles[0]; |
| |
| omnibox::DefaultSuggestResult default_suggestion; |
| default_suggestion.description = base::UTF16ToUTF8(single_result.description); |
| default_suggestion.description_styles.emplace(); |
| default_suggestion.description_styles->swap(single_result.styles); |
| SetDefaultSuggestion(default_suggestion); |
| Respond(NoArguments()); |
| } |
| |
| void OmniboxSetDefaultSuggestionFunction::SetDefaultSuggestion( |
| const omnibox::DefaultSuggestResult& suggestion) { |
| Profile* profile = Profile::FromBrowserContext(browser_context()); |
| if (SetOmniboxDefaultSuggestion(profile, extension_id(), suggestion)) { |
| OmniboxSuggestionsWatcherFactory::GetForBrowserContext( |
| profile->GetOriginalProfile()) |
| ->NotifyDefaultSuggestionChanged(); |
| } |
| } |
| |
| // This function converts style information populated by the JSON schema |
| // compiler into an ACMatchClassifications object. |
| ACMatchClassifications StyleTypesToACMatchClassifications( |
| const std::vector<omnibox::MatchClassification>* description_styles, |
| const std::string& suggestion_description) { |
| ACMatchClassifications match_classifications; |
| if (!description_styles->empty()) { |
| std::u16string description = base::UTF8ToUTF16(suggestion_description); |
| std::vector<int> styles(description.length(), 0); |
| |
| for (const omnibox::MatchClassification& style : *description_styles) { |
| int length = style.length ? *style.length : description.length(); |
| size_t offset = style.offset >= 0 |
| ? style.offset |
| : std::max(0, static_cast<int>(description.length()) + |
| style.offset); |
| |
| int type_class; |
| switch (style.type) { |
| case omnibox::DescriptionStyleType::kUrl: |
| type_class = AutocompleteMatch::ACMatchClassification::URL; |
| break; |
| case omnibox::DescriptionStyleType::kMatch: |
| type_class = AutocompleteMatch::ACMatchClassification::MATCH; |
| break; |
| case omnibox::DescriptionStyleType::kDim: |
| type_class = AutocompleteMatch::ACMatchClassification::DIM; |
| break; |
| default: |
| type_class = AutocompleteMatch::ACMatchClassification::NONE; |
| return match_classifications; |
| } |
| |
| for (size_t j = offset; j < offset + length && j < styles.size(); ++j) |
| styles[j] |= type_class; |
| } |
| |
| for (size_t i = 0; i < styles.size(); ++i) { |
| if (i == 0 || styles[i] != styles[i-1]) |
| match_classifications.push_back( |
| ACMatchClassification(i, styles[i])); |
| } |
| } else { |
| match_classifications.push_back( |
| ACMatchClassification(0, ACMatchClassification::NONE)); |
| } |
| |
| return match_classifications; |
| } |
| |
| void ApplyDefaultSuggestionForExtensionKeyword( |
| Profile* profile, |
| const TemplateURL* keyword, |
| const std::u16string& remaining_input, |
| AutocompleteMatch* match) { |
| DCHECK(keyword->type() == TemplateURL::OMNIBOX_API_EXTENSION); |
| |
| std::optional<omnibox::SuggestResult> suggestion( |
| GetOmniboxDefaultSuggestion(profile, keyword->GetExtensionId())); |
| if (!suggestion || suggestion->description.empty()) |
| return; // fall back to the universal default |
| |
| const std::u16string kPlaceholderText(u"%s"); |
| const std::u16string kReplacementText(u"<input>"); |
| |
| std::u16string description = base::UTF8ToUTF16(suggestion->description); |
| ACMatchClassifications& description_styles = match->contents_class; |
| |
| const std::vector<api::omnibox::MatchClassification> empty_styles; |
| const std::vector<api::omnibox::MatchClassification>* styles_list = |
| suggestion->description_styles ? &suggestion->description_styles.value() |
| : &empty_styles; |
| description_styles = |
| StyleTypesToACMatchClassifications(styles_list, suggestion->description); |
| |
| // Replace "%s" with the user's input and adjust the style offsets to the |
| // new length of the description. |
| size_t placeholder(description.find(kPlaceholderText, 0)); |
| if (placeholder != std::u16string::npos) { |
| std::u16string replacement = |
| remaining_input.empty() ? kReplacementText : remaining_input; |
| description.replace(placeholder, kPlaceholderText.length(), replacement); |
| |
| for (auto& description_style : description_styles) { |
| if (description_style.offset > placeholder) |
| description_style.offset += replacement.length() - 2; |
| } |
| } |
| |
| match->contents.assign(description); |
| } |
| |
| } // namespace extensions |