/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const STRING_BUNDLE_URI = "chrome://browser/locale/feeds/subscribe.properties"; export function WebProtocolHandlerRegistrar() {} const lazy = {}; XPCOMUtils.defineLazyServiceGetters(lazy, { ExternalProtocolService: [ "@mozilla.org/uriloader/external-protocol-service;1", "nsIExternalProtocolService", ], }); ChromeUtils.defineESModuleGetters(lazy, { NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "log", () => { let { ConsoleAPI } = ChromeUtils.importESModule( "resource://gre/modules/Console.sys.mjs" ); let consoleOptions = { // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create // detailed messages during development. See LOG_LEVELS in Console.sys.mjs // for details. maxLogLevel: "warning", maxLogLevelPref: "browser.protocolhandler.loglevel", prefix: "WebProtocolHandlerRegistrar.sys.mjs", }; return new ConsoleAPI(consoleOptions); }); WebProtocolHandlerRegistrar.prototype = { get stringBundle() { let sb = Services.strings.createBundle(STRING_BUNDLE_URI); delete WebProtocolHandlerRegistrar.prototype.stringBundle; return (WebProtocolHandlerRegistrar.prototype.stringBundle = sb); }, _getFormattedString(key, params) { return this.stringBundle.formatStringFromName(key, params); }, _getString(key) { return this.stringBundle.GetStringFromName(key); }, /* Because we want to iterate over the known webmailers in the observe method * and with each site visited, we want to check as fast as possible if the * current site is already registered as a mailto handler. Using the sites * domain name as a key ensures that we can use Map.has(...) later to find * it. */ _addedObservers: 0, _knownWebmailerCache: new Map(), _ensureWebmailerCache() { this._knownWebmailerCache = new Map(); const handler = lazy.ExternalProtocolService.getProtocolHandlerInfo("mailto"); for (const h of handler.possibleApplicationHandlers.enumerate()) { // Services.io.newURI could fail for broken handlers in which case we // simply leave them out, but write a debug message (just in case) try { if (h instanceof Ci.nsIWebHandlerApp && h.uriTemplate) { const mailerUri = Services.io.newURI(h.uriTemplate); if (mailerUri.scheme == "https") { this._knownWebmailerCache.set( Services.io.newURI(h.uriTemplate).host, { uriPath: Services.io.newURI(h.uriTemplate).resolve("."), uriTemplate: Services.io.newURI(h.uriTemplate), name: h.name, } ); } } } catch (e) { lazy.log.debug(`Could not add ${h.uriTemplate} to cache: ${e.message}`); } } }, /* This function can be called multiple times and (re-)initializes the cache * if the feature is toggled on. If called with the feature off it will also * unregister the observers. * * @param {boolean} firstInit * */ init(firstInit = false) { if (firstInit) { lazy.NimbusFeatures.mailto.onUpdate(() => // make firstInit explicitly false to avoid multiple registrations. this.init(false) ); } const observers = ["mailto::onLocationChange", "mailto::onClearCache"]; if ( lazy.NimbusFeatures.mailto.getVariable("dualPrompt") && lazy.NimbusFeatures.mailto.getVariable("dualPrompt.onLocationChange") ) { this._ensureWebmailerCache(); // Make sure, that our local observers are never registered twice: if (0 == this._addedObservers) { observers.forEach(o => { this._addedObservers++; Services.obs.addObserver(this, o); }); lazy.log.debug(`mailto observers activated: [${observers}]`); } } else { // With `dualPrompt` and `dualPrompt.onLocationChange` toggled on we get // up to two notifications when we turn the feature off again, but we // only want to unregister the observers once. // // Using `hasObservers` would allow us to loop over all observers as long // as there are more, but hasObservers is not implemented hence why we // use `enumerateObservers` here to create the loop and `hasMoreElements` // to return true or false as `hasObservers` would if it existed. observers.forEach(o => { if ( 0 < this._addedObservers && Services.obs.enumerateObservers(o).hasMoreElements() ) { Services.obs.removeObserver(this, o); this._addedObservers--; lazy.log.debug(`mailto observer "${o}" deactivated.`); } }); } }, async observe(aBrowser, aTopic) { try { switch (aTopic) { case "mailto::onLocationChange": { // registerProtocolHandler only works for https const uri = aBrowser.currentURI; if (!uri?.schemeIs("https")) { return; } const host = uri.host; if (this._knownWebmailerCache.has(host)) { // second: search the cache for an entry which starts with the path // of the current uri. If it exists we identified the current page as // webmailer (again). const value = this._knownWebmailerCache.get(host); this._askUserToSetMailtoHandler( aBrowser, "mailto", value.uriTemplate, value.name ); } break; // the switch(topic) statement } case "mailto::onClearCache": // clear the cache for now. We could try to dynamically update the // cache, which is easy if a webmailer is added to the settings, but // becomes more complicated when webmailers are removed, because then // the store gets rewritten and we would require an event to deal with // that as well. So instead we recreate it entirely. this._ensureWebmailerCache(); break; default: lazy.log.debug(`observe reached with unknown topic: ${aTopic}`); } } catch (e) { lazy.log.debug(`Problem in observer: ${e}`); } }, /** * See nsIWebProtocolHandlerRegistrar */ removeProtocolHandler(aProtocol, aURITemplate) { let handlerInfo = lazy.ExternalProtocolService.getProtocolHandlerInfo(aProtocol); let handlers = handlerInfo.possibleApplicationHandlers; for (let i = 0; i < handlers.length; i++) { try { // We only want to test web handlers let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp); if (handler.uriTemplate == aURITemplate) { handlers.removeElementAt(i); let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( Ci.nsIHandlerService ); hs.store(handlerInfo); return; } } catch (e) { /* it wasn't a web handler */ } } }, /** * Determines if a web handler is already registered. * * @param {string} aProtocol * The scheme of the web handler we are checking for. * @param {string} aURITemplate * The URI template that the handler uses to handle the protocol. * @returns {boolean} true if it is already registered, false otherwise. */ _protocolHandlerRegistered(aProtocol, aURITemplate) { let handlerInfo = lazy.ExternalProtocolService.getProtocolHandlerInfo(aProtocol); let handlers = handlerInfo.possibleApplicationHandlers; for (let handler of handlers.enumerate()) { // We only want to test web handlers if ( handler instanceof Ci.nsIWebHandlerApp && handler.uriTemplate == aURITemplate ) { return true; } } return false; }, /** * Returns true if aURITemplate.spec points to the currently configured * handler for aProtocol links and the OS default is also readily configured. * Returns false if some of it can be made default. * * @param {string} aProtocol * The scheme of the web handler we are checking for. * @param {string} aURITemplate * The URI template that the handler uses to handle the protocol. */ _isProtocolHandlerDefault(aProtocol, aURITemplate) { const handlerInfo = lazy.ExternalProtocolService.getProtocolHandlerInfo(aProtocol); if ( handlerInfo.preferredAction == Ci.nsIHandlerInfo.useHelperApp && handlerInfo.preferredApplicationHandler instanceof Ci.nsIWebHandlerApp ) { let webHandlerApp = handlerInfo.preferredApplicationHandler.QueryInterface( Ci.nsIWebHandlerApp ); // If we are already configured as default, we cannot set a new default // and if the current site is already registered as default webmailer we // are fully set up as the default app for webmail. if ( !this._canSetOSDefault(aProtocol) && webHandlerApp.uriTemplate == aURITemplate.spec ) { return true; } } return false; }, /** * Private method to return the installHash, which is important for app * registration on OS level. Without it apps cannot be default/handler apps * under Windows. Because this value is used to check if its possible to reset * the default and to actually set it as well, this function puts the * acquisition of the installHash in one place in the hopes that check and set * conditions will never deviate. * * @returns {string} installHash */ _getInstallHash() { const xreDirProvider = Cc[ "@mozilla.org/xre/directory-provider;1" ].getService(Ci.nsIXREDirProvider); return xreDirProvider.getInstallHash(); }, /* Private method to check if we are already the default protocolhandler * for `protocol`. * * @param {string} protocol name, e.g. mailto (without ://) * @returns {boolean} */ _isOsDefault(protocol) { let shellService = Cc[ "@mozilla.org/browser/shell-service;1" ].createInstance(Ci.nsIWindowsShellService); if (shellService.isDefaultHandlerFor(protocol)) { lazy.log.debug("_isOsDefault returns true."); return true; } lazy.log.debug("_isOsDefault returns false."); return false; }, /** * Private method to determine if we can set a new OS default for a certain * protocol. * * @param {string} protocol name, e.g. mailto (without ://) * @returns {boolean} */ _canSetOSDefault(protocol) { // an installHash is required for the association with a scheme handler, // also see _setOSDefault() if ("" == this._getInstallHash()) { lazy.log.debug("_canSetOSDefault returns false."); return false; } if (this._isOsDefault(protocol)) { return false; } return true; }, /** * Private method to reset the OS default for a certain protocol/uri scheme. * We basically ignore, that setDefaultExtensionHandlersUserChoice can fail * when the installHash is wrong or cannot be determined. * * @param {string} protocol name, e.g. mailto (without ://) * @returns {boolean} */ _setOSDefault(protocol) { try { let defaultAgent = Cc["@mozilla.org/default-agent;1"].createInstance( Ci.nsIDefaultAgent ); defaultAgent.setDefaultExtensionHandlersUserChoice( this._getInstallHash(), [protocol, "FirefoxURL"] ); return true; } catch (e) { // TODO: why could not we just add the installHash and promote the running // install to be a properly installed one? lazy.log.debug( "Could not set Firefox as default application for " + protocol + ", because: " + e.message ); } return false; }, /** * Private method to set the default uri to handle a certain protocol. This * automates in a way what a user can do in settings under applications, * where different 'actions' can be chosen for different 'content types'. * * @param {string} protocol * @param {handler} handler */ _setProtocolHandlerDefault(protocol, handler) { let handlerInfo = lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol); handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; handlerInfo.preferredApplicationHandler = handler; handlerInfo.alwaysAskBeforeHandling = false; let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( Ci.nsIHandlerService ); hs.store(handlerInfo); return handlerInfo; }, /** * Private method to add a ProtocolHandler of type nsIWebHandlerApp to the * list of possible handlers for a protocol. * * @param {string} protocol - e.g. 'mailto', so again without :// * @param {string} name - the protocol associated 'Action' * @param {string} uri - the uri (compare 'use other...' in the preferences) * @returns {handler} handler - either the existing one or a newly created */ _addWebProtocolHandler(protocol, name, uri) { let phi = lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol); // not adding duplicates and bail out with the existing entry for (let h of phi.possibleApplicationHandlers.enumerate()) { if (h instanceof Ci.nsIWebHandlerApp && h.uriTemplate === uri) { return h; } } let handler = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( Ci.nsIWebHandlerApp ); handler.name = name; handler.uriTemplate = uri; let handlerInfo = lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol); handlerInfo.possibleApplicationHandlers.appendElement(handler); let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( Ci.nsIHandlerService ); hs.store(handlerInfo); return handler; }, /* * Function to store a value associated to a domain using the content pref * service. * * @param {string} domain: the domain for this setting * @param {string} setting: the name of the setting * @param {string} value: the actual setting to be stored * @param {string} context (optional): private window or not * @returns {string} the stored preference (see: nsIContentPrefService2.idl) */ async _saveSiteSpecificSetting(domain, setting, value, context = null) { const gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( Ci.nsIContentPrefService2 ); return new Promise((resolve, reject) => { gContentPrefs.set(domain, setting, value, context, { handleResult(pref) { resolve(pref); }, handleCompletion() {}, handleError(err) { reject(err); }, }); }); }, /* * Function to return a stored value from the content pref service. Returns * a promise, so await can be used to synchonize the retrieval. * * @param {string} domain: the domain for this setting * @param {string} setting: the name of the setting * @param {string} context (optional): private window or not * @param {string} def (optional): the default value to return * @returns {string} either stored value or "" */ async _getSiteSpecificSetting(domain, setting, context = null, def = null) { const gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( Ci.nsIContentPrefService2 ); return await new Promise((resolve, reject) => { gContentPrefs.getByDomainAndName(domain, setting, context, { _result: def, handleResult(pref) { this._result = pref.value; }, handleCompletion(_) { resolve(this._result); }, handleError(err) { reject(err); }, }); }); }, /** * See nsIWebProtocolHandlerRegistrar */ registerProtocolHandler( aProtocol, aURI, aTitle, aDocumentURI, aBrowserOrWindow ) { // first mitigation: check if the API call comes from another domain aProtocol = (aProtocol || "").toLowerCase(); if (!aURI || !aDocumentURI) { return; } let browser = aBrowserOrWindow; // This is the e10s case. if (aBrowserOrWindow instanceof Ci.nsIDOMWindow) { // In the non-e10s case, grab the browser off the same-process window. let rootDocShell = aBrowserOrWindow.docShell.sameTypeRootTreeItem; browser = rootDocShell.QueryInterface(Ci.nsIDocShell).chromeEventHandler; } let browserWindow = browser.ownerGlobal; try { browserWindow.navigator.checkProtocolHandlerAllowed( aProtocol, aURI, aDocumentURI ); } catch (ex) { // We should have already shown the user an error. return; } if (lazy.NimbusFeatures.mailto.getVariable("dualPrompt")) { if ("mailto" === aProtocol) { lazy.NimbusFeatures.mailto.recordExposureEvent(); this._askUserToSetMailtoHandler(browser, aProtocol, aURI, aTitle); return; } } // If the protocol handler is already registered, just return early. if (this._protocolHandlerRegistered(aProtocol, aURI.spec)) { return; } // Now Ask the user and provide the proper callback let message = this._getFormattedString("addProtocolHandlerMessage", [ aURI.host, aProtocol, ]); let notificationIcon = aURI.prePath + "/favicon.ico"; let notificationValue = "Protocol Registration: " + aProtocol; let addButton = { label: this._getString("addProtocolHandlerAddButton"), accessKey: this._getString("addProtocolHandlerAddButtonAccesskey"), protocolInfo: { protocol: aProtocol, uri: aURI.spec, name: aTitle }, primary: true, callback(aNotification, aButtonInfo) { let protocol = aButtonInfo.protocolInfo.protocol; let name = aButtonInfo.protocolInfo.name; let handler = Cc[ "@mozilla.org/uriloader/web-handler-app;1" ].createInstance(Ci.nsIWebHandlerApp); handler.name = name; handler.uriTemplate = aButtonInfo.protocolInfo.uri; let handlerInfo = lazy.ExternalProtocolService.getProtocolHandlerInfo(protocol); handlerInfo.possibleApplicationHandlers.appendElement(handler); // Since the user has agreed to add a new handler, chances are good // that the next time they see a handler of this type, they're going // to want to use it. Reset the handlerInfo to ask before the next // use. handlerInfo.alwaysAskBeforeHandling = true; let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( Ci.nsIHandlerService ); hs.store(handlerInfo); }, }; let notificationBox = browser.getTabBrowser().getNotificationBox(browser); // check if the notification box is already shown if (notificationBox.getNotificationWithValue(notificationValue)) { return; } notificationBox.appendNotification( notificationValue, { label: message, image: notificationIcon, priority: notificationBox.PRIORITY_INFO_LOW, }, [addButton] ); }, /* * Special implementation for mailto: A prompt (notificationbox.js) is only * shown if there is a realistic chance that we can really set the OS default, * e.g. if we have been properly installed and the current page is not already * the default and we have not asked users too often the same question. * * @param {string} browser * @param {string} aProtocol * @param {nsIURI} aURI * @param {string} aTitle */ async _askUserToSetMailtoHandler(browser, aProtocol, aURI, aTitle) { let notificationId = "OS Protocol Registration: " + aProtocol; // guard: we do not want to reconfigure settings in private browsing mode if (lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal)) { lazy.log.debug("prompt not shown, because this is a private window."); return; } // guard: check if everything has been configured to use the current site // as default webmailer and bail out if so. if (this._isProtocolHandlerDefault(aProtocol, aURI)) { lazy.log.debug( `prompt not shown, because ${aTitle} is already configured to` + ` handle ${aProtocol}-links under ${aURI.spec}.` ); return; } // guard: bail out if this site has been dismissed before (either by // clicking the 'x' button or the 'not now' button. let principal = browser.getTabBrowser().contentPrincipal; if ( Ci.nsIPermissionManager.DENY_ACTION == Services.perms.testExactPermissionFromPrincipal( principal, "mailto-infobar-dismissed" ) ) { let expiry = Services.perms.getPermissionObject( principal, "mailto-infobar-dismissed", true ).expireTime - Date.now(); lazy.log.debug( `prompt not shown, because it is still dismissed for` + ` ${principal.host} and will be shown in` + ` ${(expiry / 1000).toFixed()} seconds again.` ); return; } // Now show the prompt if there is not already one... let osDefaultNotificationBox = browser .getTabBrowser() .getNotificationBox(browser); if (!osDefaultNotificationBox.getNotificationWithValue(notificationId)) { let win = browser.ownerGlobal; win.MozXULElement.insertFTLIfNeeded("browser/webProtocolHandler.ftl"); let notification = await osDefaultNotificationBox.appendNotification( notificationId, { label: { "l10n-id": "protocolhandler-mailto-handler-set", "l10n-args": { url: aURI.host }, }, priority: osDefaultNotificationBox.PRIORITY_INFO_LOW, eventCallback: eventType => { if (eventType === "dismissed") { // after a click on 'X' save a timestamp after which we can show // the prompt again Services.perms.addFromPrincipal( principal, "mailto-infobar-dismissed", Ci.nsIPermissionManager.DENY_ACTION, Ci.nsIPermissionManager.EXPIRE_TIME, lazy.NimbusFeatures.mailto.getVariable( "dualPrompt.dismissXClickMinutes" ) * 60 * 1000 + Date.now() ); Glean.protocolhandlerMailto.promptClicked.dismiss_os_default.add(); } }, }, [ { "l10n-id": "protocolhandler-mailto-os-handler-yes-button", primary: true, callback: newitem => { let currentHandler = this._addWebProtocolHandler( aProtocol, aTitle, aURI.spec ); this._setProtocolHandlerDefault(aProtocol, currentHandler); Glean.protocolhandlerMailto.promptClicked.set_local_default.add(); if (this._canSetOSDefault(aProtocol)) { if (this._setOSDefault(aProtocol)) { Glean.protocolhandlerMailto.promptClicked.set_os_default.add(); newitem.messageL10nId = "protocolhandler-mailto-handler-confirm"; newitem.removeChild(newitem.buttonContainer); newitem.setAttribute("type", "success"); // from moz-message-bar.css newitem.eventCallback = null; // disable show only once per day for success return true; // `true` does not hide the bar } // if anything goes wrong with setting the OS default, we want // to be informed so that we can fix it. Glean.protocolhandlerMailto.promptClicked.set_os_default_error.add(); return false; } // if the installation does not have an install hash, we cannot // set the OS default, but mailto links from within the browser // should still work. Glean.protocolhandlerMailto.promptClicked.set_os_default_impossible.add(); return false; }, }, { "l10n-id": "protocolhandler-mailto-os-handler-no-button", callback: () => { // after a click on 'Not Now' save a timestamp after which we can // show the prompt again Services.perms.addFromPrincipal( principal, "mailto-infobar-dismissed", Ci.nsIPermissionManager.DENY_ACTION, Ci.nsIPermissionManager.EXPIRE_TIME, lazy.NimbusFeatures.mailto.getVariable( "dualPrompt.dismissNotNowMinutes" ) * 60 * 1000 + Date.now() ); Glean.protocolhandlerMailto.promptClicked.dismiss_os_default.add(); return false; }, }, ] ); Glean.protocolhandlerMailto.handlerPromptShown.os_default.add(); // remove the icon from the infobar, which is automatically assigned // after its priority, because the priority is also an indicator which // type of bar it is, e.g. a warning or error: notification.setAttribute("type", "system"); } }, /** * See nsISupports */ QueryInterface: ChromeUtils.generateQI(["nsIWebProtocolHandlerRegistrar"]), };