ActionsProviderContextualSearch.sys.mjs (12323B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { UrlbarUtils } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 6 7 import { 8 ActionsProvider, 9 ActionsResult, 10 } from "moz-src:///browser/components/urlbar/ActionsProvider.sys.mjs"; 11 12 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 13 14 const lazy = {}; 15 16 ChromeUtils.defineESModuleGetters(lazy, { 17 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", 18 OpenSearchEngine: 19 "moz-src:///toolkit/components/search/OpenSearchEngine.sys.mjs", 20 OpenSearchManager: 21 "moz-src:///browser/components/search/OpenSearchManager.sys.mjs", 22 loadAndParseOpenSearchEngine: 23 "moz-src:///toolkit/components/search/OpenSearchLoader.sys.mjs", 24 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 25 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 26 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 27 UrlbarSearchUtils: 28 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 29 }); 30 31 const ENABLED_PREF = "contextualSearch.enabled"; 32 33 const INSTALLED_ENGINE = "installed-engine"; 34 const OPEN_SEARCH_ENGINE = "opensearch-engine"; 35 const CONTEXTUAL_SEARCH_ENGINE = "contextual-search-engine"; 36 37 const DEFAULT_ICON = "chrome://browser/skin/search-engine-placeholder@2x.png"; 38 39 /** 40 * A provider that returns an option for using the search engine provided 41 * by the active view if it utilizes OpenSearch. 42 */ 43 class ProviderContextualSearch extends ActionsProvider { 44 // Cache the results of engines looked up by host, these can be 45 // expensive lookups and we don't want to redo the query every time 46 // the user types when the result will not change. 47 #hostEngines = new Map(); 48 // Cache the result of the query that checks whether an engines domain 49 // has been visited recently. We only want to show engines the user 50 // is using. 51 #visitedEngineDomains = new Map(); 52 53 // Store the engine returned to the user in case they select it. 54 #resultEngine = null; 55 56 #placesObserver = null; 57 58 constructor() { 59 super(); 60 61 this.#placesObserver = new PlacesWeakCallbackWrapper( 62 this.handlePlacesEvents.bind(this) 63 ); 64 65 PlacesObservers.addListener(["history-cleared"], this.#placesObserver); 66 } 67 68 get name() { 69 return "ActionsProviderContextualSearch"; 70 } 71 72 isActive(queryContext) { 73 return ( 74 queryContext.trimmedSearchString && 75 lazy.UrlbarPrefs.getScotchBonnetPref(ENABLED_PREF) && 76 !queryContext.searchMode && 77 lazy.UrlbarPrefs.get("suggest.engines") 78 ); 79 } 80 81 async queryActions(queryContext) { 82 this.#resultEngine = await this.matchEngine(queryContext); 83 if (this.#resultEngine) { 84 return [await this.#createActionResult(this.#resultEngine)]; 85 } 86 return null; 87 } 88 89 onSearchSessionEnd() { 90 // We cache the results for a host while the user is typing, clear 91 // when the search session ends as the results for the host may 92 // change by the next search session. 93 this.#hostEngines.clear(); 94 } 95 96 async #createActionResult({ type, engine, key = "contextual-search" }) { 97 let icon = engine?.icon || (await engine?.getIconURL?.()) || DEFAULT_ICON; 98 let result = { 99 key, 100 l10nId: "urlbar-result-search-with", 101 l10nArgs: { engine: engine.name || engine.title }, 102 icon, 103 onPick: (context, controller) => { 104 this.pickAction(context, controller); 105 }, 106 }; 107 108 if (type == INSTALLED_ENGINE) { 109 result.engine = engine.name; 110 result.dataset = { providesSearchMode: true }; 111 } 112 113 return new ActionsResult(result); 114 } 115 116 /* 117 * Searches for engines that we want to present to the user based on their 118 * current host and the search query they have entered. 119 */ 120 async matchEngine(queryContext) { 121 // First find currently installed engines that match the current query 122 // if the user has DuckDuckGo installed and types "duck", offer that. 123 let engine = await this.#matchTabToSearchEngine(queryContext); 124 if (engine) { 125 return engine; 126 } 127 128 // Don't match the default engine for non-query-matches. 129 let defaultEngine = queryContext.isPrivate 130 ? Services.search.defaultPrivateEngine 131 : Services.search.defaultEngine; 132 133 let browser = 134 lazy.BrowserWindowTracker.getTopWindow()?.gBrowser.selectedBrowser; 135 if (!browser) { 136 return null; 137 } 138 139 let host; 140 try { 141 host = UrlbarUtils.stripPrefixAndTrim(browser.currentURI.host, { 142 stripWww: true, 143 })[0]; 144 } catch (e) { 145 // about: pages will throw when access currentURI.host, ignore. 146 } 147 148 // Find engines based on the current host. 149 if (host && !this.#hostEngines.has(host)) { 150 // Find currently installed engines that match the current host. If 151 // the user is on wikipedia.com, offer that. 152 let hostEngine = await this.#matchInstalledEngine(host); 153 154 if (!hostEngine) { 155 // Find engines in the search configuration but not installed that match 156 // the current host. If the user is on ecosia.com and starts searching 157 // offer ecosia's search. 158 let contextualEngineConfig = 159 await Services.search.findContextualSearchEngineByHost(host); 160 if (contextualEngineConfig) { 161 hostEngine = { 162 type: CONTEXTUAL_SEARCH_ENGINE, 163 engine: contextualEngineConfig, 164 }; 165 } 166 } 167 // Cache the result against this host so we do not need to rerun 168 // the same query every keystroke. 169 this.#hostEngines.set(host, hostEngine); 170 if (hostEngine && hostEngine.engine.name != defaultEngine.name) { 171 return hostEngine; 172 } 173 } else if (host) { 174 let cachedEngine = this.#hostEngines.get(host); 175 if (cachedEngine && cachedEngine.engine.name != defaultEngine.name) { 176 return cachedEngine; 177 } 178 } 179 180 // Lastly match any openSearch 181 if (browser) { 182 let openSearchEngines = lazy.OpenSearchManager.getEngines(browser); 183 // We don't need to check if the engine has the same name as the 184 // default engine because OpenSearchManager already handles that. 185 if (openSearchEngines.length) { 186 return { type: OPEN_SEARCH_ENGINE, engine: openSearchEngines[0] }; 187 } 188 } 189 190 return null; 191 } 192 193 /** 194 * Called from `onLocationChange` in browser.js. It is used to update 195 * the cache for `visitedEngineDomains` so we can avoid expensive places 196 * queries. 197 * 198 * @param {window} window 199 * The browser window where the location change happened. 200 * @param {nsIURI} uri 201 * The URI being navigated to. 202 * @param {nsIWebProgress} _webProgress 203 * The progress object, which can have event listeners added to it. 204 * @param {number} _flags 205 * Load flags. See nsIWebProgressListener.idl for possible values. 206 */ 207 async onLocationChange(window, uri, _webProgress, _flags) { 208 try { 209 if (this.#visitedEngineDomains.has(uri.host)) { 210 this.#visitedEngineDomains.set(uri.host, true); 211 } 212 } catch (e) {} 213 } 214 215 async #matchInstalledEngine(query) { 216 let engines = await lazy.UrlbarSearchUtils.enginesForDomainPrefix(query, { 217 matchAllDomainLevels: true, 218 }); 219 if (engines.length) { 220 return { type: INSTALLED_ENGINE, engine: engines[0] }; 221 } 222 return null; 223 } 224 225 /* 226 * Matches a users search query to the name of an installed engine. 227 */ 228 async #matchTabToSearchEngine(queryContext) { 229 let searchStr = queryContext.trimmedSearchString.toLocaleLowerCase(); 230 231 for (let engine of await Services.search.getVisibleEngines()) { 232 if ( 233 engine.name.toLocaleLowerCase().startsWith(searchStr) && 234 ((await this.#shouldskipRecentVisitCheck(searchStr)) || 235 (await this.#engineDomainHasRecentVisits(engine.searchUrlDomain))) 236 ) { 237 return { 238 type: INSTALLED_ENGINE, 239 engine, 240 key: "matched-contextual-search", 241 }; 242 } 243 } 244 return null; 245 } 246 247 /* 248 * Check that an engines domain has been visited within the last 30 days 249 * before providing as a match to the users query. 250 */ 251 async #engineDomainHasRecentVisits(host) { 252 if (this.#visitedEngineDomains.has(host)) { 253 return this.#visitedEngineDomains.get(host); 254 } 255 256 let db = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); 257 let rows = await db.executeCached( 258 ` 259 SELECT 1 FROM moz_places 260 WHERE rev_host BETWEEN get_unreversed_host(:host || '.') || '.' AND get_unreversed_host(:host || '.') || '/' 261 AND (foreign_count > 0 262 OR last_visit_date > strftime('%s','now','localtime','start of day','-30 days','utc') * 1000000) 263 LIMIT 1;`, 264 { host } 265 ); 266 267 let visited = !!rows.length; 268 this.#visitedEngineDomains.set(host, visited); 269 return visited; 270 } 271 272 async #shouldskipRecentVisitCheck(query) { 273 // If the user has entered enough characters they are very likely looking for 274 // the engine, this avoids confusion for users searching for engines they have 275 // not visited. 276 if (query.length > 3) { 277 return true; 278 } 279 // If we do not store history we cannot check whether an engine has been 280 // visited, in that case we show the engines when matching. 281 return ( 282 Services.prefs.getBoolPref("places.history.enabled", true) && 283 !( 284 Services.prefs.getBoolPref("privacy.clearOnShutdown.history") || 285 lazy.PrivateBrowsingUtils.permanentPrivateBrowsing 286 ) 287 ); 288 } 289 290 async pickAction(queryContext, controller, _element) { 291 let { type, engine } = this.#resultEngine; 292 293 if (type == OPEN_SEARCH_ENGINE) { 294 let originAttributes; 295 try { 296 let currentURI = Services.io.newURI(queryContext.currentPage); 297 originAttributes = { 298 firstPartyDomain: Services.eTLD.getSchemelessSite(currentURI), 299 }; 300 } catch {} 301 let openSearchEngineData = await lazy.loadAndParseOpenSearchEngine( 302 Services.io.newURI(engine.uri), 303 null, 304 originAttributes 305 ); 306 engine = new lazy.OpenSearchEngine({ 307 engineData: openSearchEngineData, 308 originAttributes, 309 }); 310 } 311 312 this.#performSearch( 313 engine, 314 queryContext.searchString, 315 controller.input, 316 type == INSTALLED_ENGINE 317 ); 318 319 if ( 320 // Do not show the install prompt in non-private windows to have 321 // consistent behaviour with private windows and avoid linkability 322 // concerns. tor-browser#44134. 323 // Maybe re-enable as part of tor-browser#44117. 324 !AppConstants.BASE_BROWSER_VERSION && 325 !queryContext.isPrivate && 326 type != INSTALLED_ENGINE && 327 (await Services.search.shouldShowInstallPrompt(engine)) 328 ) { 329 this.#showInstallPrompt(controller, engine); 330 } 331 } 332 333 handlePlacesEvents(_events) { 334 this.#visitedEngineDomains.clear(); 335 } 336 337 async #performSearch(engine, search, input, enterSearchMode) { 338 const [url] = UrlbarUtils.getSearchQueryUrl(engine, search); 339 if (enterSearchMode) { 340 input.search(search, { searchEngine: engine }); 341 } 342 input.window.gBrowser.fixupAndLoadURIString(url, { 343 triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), 344 }); 345 input.window.gBrowser.selectedBrowser.focus(); 346 } 347 348 #showInstallPrompt(controller, engineData) { 349 let win = controller.input.window; 350 let buttons = [ 351 { 352 "l10n-id": "install-search-engine-add", 353 callback() { 354 Services.search.addSearchEngine(engineData); 355 }, 356 }, 357 { 358 "l10n-id": "install-search-engine-no", 359 callback() {}, 360 }, 361 ]; 362 363 win.gNotificationBox.appendNotification( 364 "install-search-engine", 365 { 366 label: { 367 "l10n-id": "install-search-engine", 368 "l10n-args": { engineName: engineData.name }, 369 }, 370 image: "chrome://global/skin/icons/question-64.png", 371 priority: win.gNotificationBox.PRIORITY_INFO_LOW, 372 }, 373 buttons 374 ); 375 } 376 377 QueryInterface = ChromeUtils.generateQI([Ci.nsISupportsWeakReference]); 378 } 379 380 export var ActionsProviderContextualSearch = new ProviderContextualSearch();