summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs')
-rw-r--r--toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs465
1 files changed, 465 insertions, 0 deletions
diff --git a/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs b/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs
new file mode 100644
index 0000000000..e916fdad4c
--- /dev/null
+++ b/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs
@@ -0,0 +1,465 @@
+/* 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/. */
+
+// Constants
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs";
+
+const DIALOG_URL_APP_CHOOSER =
+ "chrome://mozapps/content/handling/appChooser.xhtml";
+const DIALOG_URL_PERMISSION =
+ "chrome://mozapps/content/handling/permissionDialog.xhtml";
+
+const gPrefs = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ gPrefs,
+ "promptForExternal",
+ "network.protocol-handler.prompt-from-external",
+ true
+);
+
+const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler";
+const PERMISSION_KEY_DELIMITER = "^";
+
+export class nsContentDispatchChooser {
+ /**
+ * Prompt the user to open an external application.
+ * If the triggering principal doesn't have permission to open apps for the
+ * protocol of aURI, we show a permission prompt first.
+ * If the caller has permission and a preferred handler is set, we skip the
+ * dialogs and directly open the handler.
+ * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers.
+ * @param {nsIURI} aURI - URI to be handled.
+ * @param {nsIPrincipal} [aPrincipal] - Principal which triggered the load.
+ * @param {BrowsingContext} [aBrowsingContext] - Context of the load.
+ * @param {bool} [aTriggeredExternally] - Whether the load came from outside
+ * this application.
+ */
+ async handleURI(
+ aHandler,
+ aURI,
+ aPrincipal,
+ aBrowsingContext,
+ aTriggeredExternally = false
+ ) {
+ let callerHasPermission = this._hasProtocolHandlerPermission(
+ aHandler.type,
+ aPrincipal,
+ aTriggeredExternally
+ );
+
+ // Force showing the dialog for links passed from outside the application.
+ // This avoids infinite loops, see bug 1678255, bug 1667468, etc.
+ if (
+ aTriggeredExternally &&
+ gPrefs.promptForExternal &&
+ // ... unless we intend to open the link with a website or extension:
+ !(
+ aHandler.preferredAction == Ci.nsIHandlerInfo.useHelperApp &&
+ aHandler.preferredApplicationHandler instanceof Ci.nsIWebHandlerApp
+ )
+ ) {
+ aHandler.alwaysAskBeforeHandling = true;
+ }
+
+ if ("mailto" === aURI.scheme) {
+ Glean.protocolhandlerMailto.visit.record({
+ triggered_externally: aTriggeredExternally,
+ });
+ }
+
+ // Skip the dialog if a preferred application is set and the caller has
+ // permission.
+ if (
+ callerHasPermission &&
+ !aHandler.alwaysAskBeforeHandling &&
+ (aHandler.preferredAction == Ci.nsIHandlerInfo.useHelperApp ||
+ aHandler.preferredAction == Ci.nsIHandlerInfo.useSystemDefault)
+ ) {
+ try {
+ aHandler.launchWithURI(aURI, aBrowsingContext);
+ return;
+ } catch (error) {
+ // We are not supposed to ask, but when file not found the user most likely
+ // uninstalled the application which handles the uri so we will continue
+ // by application chooser dialog.
+ if (error.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
+ aHandler.alwaysAskBeforeHandling = true;
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ let shouldOpenHandler = false;
+
+ try {
+ shouldOpenHandler = await this._prompt(
+ aHandler,
+ aPrincipal,
+ callerHasPermission,
+ aBrowsingContext,
+ aURI
+ );
+ } catch (error) {
+ console.error(error.message);
+ }
+
+ if (!shouldOpenHandler) {
+ return;
+ }
+
+ // Site was granted permission and user chose to open application.
+ // Launch the external handler.
+ aHandler.launchWithURI(aURI, aBrowsingContext);
+ }
+
+ /**
+ * Get the name of the application set to handle the the protocol.
+ * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers.
+ * @returns {string|null} - Human readable handler name or null if the user
+ * is expected to set a handler.
+ */
+ _getHandlerName(aHandler) {
+ if (aHandler.alwaysAskBeforeHandling) {
+ return null;
+ }
+ if (
+ aHandler.preferredAction == Ci.nsIHandlerInfo.useSystemDefault &&
+ aHandler.hasDefaultHandler
+ ) {
+ return aHandler.defaultDescription;
+ }
+ return aHandler.preferredApplicationHandler?.name;
+ }
+
+ /**
+ * Show permission or/and app chooser prompt.
+ * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers.
+ * @param {nsIPrincipal} aPrincipal - Principal which triggered the load.
+ * @param {boolean} aHasPermission - Whether the caller has permission to
+ * open the protocol.
+ * @param {BrowsingContext} [aBrowsingContext] - Context associated with the
+ * protocol navigation.
+ */
+ async _prompt(aHandler, aPrincipal, aHasPermission, aBrowsingContext, aURI) {
+ let shouldOpenHandler = false;
+ let resetHandlerChoice = false;
+ let updateHandlerData = false;
+
+ const isStandardProtocol = E10SUtils.STANDARD_SAFE_PROTOCOLS.includes(
+ aURI.scheme
+ );
+ const {
+ hasDefaultHandler,
+ preferredApplicationHandler,
+ alwaysAskBeforeHandling,
+ } = aHandler;
+
+ // This will skip the app chooser dialog flow unless the user explicitly opts to choose
+ // another app in the permission dialog.
+ if (
+ !isStandardProtocol &&
+ hasDefaultHandler &&
+ preferredApplicationHandler == null &&
+ alwaysAskBeforeHandling
+ ) {
+ aHandler.alwaysAskBeforeHandling = false;
+ updateHandlerData = true;
+ }
+
+ // If caller does not have permission, prompt the user.
+ if (!aHasPermission) {
+ let canPersistPermission = this._isSupportedPrincipal(aPrincipal);
+
+ let outArgs = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ // Whether the permission request was granted
+ outArgs.setProperty("granted", false);
+ // If the user wants to select a new application for the protocol.
+ // This will cause us to show the chooser dialog, even if an app is set.
+ outArgs.setProperty("resetHandlerChoice", null);
+ // If the we should store the permission and not prompt again for it.
+ outArgs.setProperty("remember", null);
+
+ await this._openDialog(
+ DIALOG_URL_PERMISSION,
+ {
+ handler: aHandler,
+ principal: aPrincipal,
+ browsingContext: aBrowsingContext,
+ outArgs,
+ canPersistPermission,
+ preferredHandlerName: this._getHandlerName(aHandler),
+ },
+ aBrowsingContext
+ );
+ if (!outArgs.getProperty("granted")) {
+ // User denied request
+ return false;
+ }
+
+ // Check if user wants to set a new application to handle the protocol.
+ resetHandlerChoice = outArgs.getProperty("resetHandlerChoice");
+
+ // If the user wants to select a new app we don't persist the permission.
+ if (!resetHandlerChoice && aPrincipal) {
+ let remember = outArgs.getProperty("remember");
+ this._updatePermission(aPrincipal, aHandler.type, remember);
+ }
+
+ shouldOpenHandler = true;
+ }
+
+ // Prompt if the user needs to make a handler choice for the protocol.
+ if (aHandler.alwaysAskBeforeHandling || resetHandlerChoice) {
+ // User has not set a preferred application to handle this protocol scheme.
+ // Open the application chooser dialog
+ let outArgs = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ outArgs.setProperty("openHandler", false);
+ outArgs.setProperty("preferredAction", aHandler.preferredAction);
+ outArgs.setProperty(
+ "preferredApplicationHandler",
+ aHandler.preferredApplicationHandler
+ );
+ outArgs.setProperty(
+ "alwaysAskBeforeHandling",
+ aHandler.alwaysAskBeforeHandling
+ );
+ let usePrivateBrowsing = aBrowsingContext?.usePrivateBrowsing;
+ await this._openDialog(
+ DIALOG_URL_APP_CHOOSER,
+ {
+ handler: aHandler,
+ outArgs,
+ usePrivateBrowsing,
+ enableButtonDelay: aHasPermission,
+ },
+ aBrowsingContext
+ );
+
+ shouldOpenHandler = outArgs.getProperty("openHandler");
+
+ // If the user accepted the dialog, apply their selection.
+ if (shouldOpenHandler) {
+ for (let prop of [
+ "preferredAction",
+ "preferredApplicationHandler",
+ "alwaysAskBeforeHandling",
+ ]) {
+ aHandler[prop] = outArgs.getProperty(prop);
+ }
+ updateHandlerData = true;
+ }
+ }
+
+ if (updateHandlerData) {
+ // Store handler data
+ Cc["@mozilla.org/uriloader/handler-service;1"]
+ .getService(Ci.nsIHandlerService)
+ .store(aHandler);
+ }
+
+ return shouldOpenHandler;
+ }
+
+ /**
+ * Test if a given principal has the open-protocol-handler permission for a
+ * specific protocol.
+ * @param {string} scheme - Scheme of the protocol.
+ * @param {nsIPrincipal} aPrincipal - Principal to test for permission.
+ * @returns {boolean} - true if permission is set, false otherwise.
+ */
+ _hasProtocolHandlerPermission(scheme, aPrincipal, aTriggeredExternally) {
+ // Permission disabled by pref
+ if (!nsContentDispatchChooser.isPermissionEnabled) {
+ return true;
+ }
+
+ // If a handler is set to open externally by default we skip the dialog.
+ if (
+ Services.prefs.getBoolPref(
+ "network.protocol-handler.external." + scheme,
+ false
+ )
+ ) {
+ return true;
+ }
+
+ if (
+ !aPrincipal ||
+ (aPrincipal.isSystemPrincipal && !aTriggeredExternally)
+ ) {
+ return false;
+ }
+
+ let key = this._getSkipProtoDialogPermissionKey(scheme);
+ return (
+ Services.perms.testPermissionFromPrincipal(aPrincipal, key) ===
+ Services.perms.ALLOW_ACTION
+ );
+ }
+
+ /**
+ * Get open-protocol-handler permission key for a protocol.
+ * @param {string} aProtocolScheme - Scheme of the protocol.
+ * @returns {string} - Permission key.
+ */
+ _getSkipProtoDialogPermissionKey(aProtocolScheme) {
+ return (
+ PROTOCOL_HANDLER_OPEN_PERM_KEY +
+ PERMISSION_KEY_DELIMITER +
+ aProtocolScheme
+ );
+ }
+
+ /**
+ * Opens a dialog as a SubDialog on tab level.
+ * If we don't have a BrowsingContext or tab level dialogs are not supported,
+ * we will fallback to a standalone window.
+ * @param {string} aDialogURL - URL of the dialog to open.
+ * @param {Object} aDialogArgs - Arguments passed to the dialog.
+ * @param {BrowsingContext} [aBrowsingContext] - BrowsingContext associated
+ * with the tab the dialog is associated with.
+ */
+ async _openDialog(aDialogURL, aDialogArgs, aBrowsingContext) {
+ // Make the app chooser dialog resizable
+ let resizable = `resizable=${
+ aDialogURL == DIALOG_URL_APP_CHOOSER ? "yes" : "no"
+ }`;
+
+ if (aBrowsingContext) {
+ let window = aBrowsingContext.topChromeWindow;
+ if (!window) {
+ throw new Error(
+ "Can't show external protocol dialog. BrowsingContext has no chrome window associated."
+ );
+ }
+
+ let { topFrameElement } = aBrowsingContext;
+ if (topFrameElement?.tagName != "browser") {
+ throw new Error(
+ "Can't show external protocol dialog. BrowsingContext has no browser associated."
+ );
+ }
+
+ // If the app does not support window.gBrowser or getTabDialogBox(),
+ // fallback to the standalone application chooser window.
+ let getTabDialogBox = window.gBrowser?.getTabDialogBox;
+ if (getTabDialogBox) {
+ return getTabDialogBox(topFrameElement).open(
+ aDialogURL,
+ {
+ features: resizable,
+ allowDuplicateDialogs: false,
+ keepOpenSameOriginNav: true,
+ },
+ aDialogArgs
+ ).closedPromise;
+ }
+ }
+
+ // If we don't have a BrowsingContext, we need to show a standalone window.
+ let win = Services.ww.openWindow(
+ null,
+ aDialogURL,
+ null,
+ `chrome,dialog=yes,centerscreen,${resizable}`,
+ aDialogArgs
+ );
+
+ // Wait until window is closed.
+ return new Promise(resolve => {
+ win.addEventListener("unload", function onUnload(event) {
+ if (event.target.location != aDialogURL) {
+ return;
+ }
+ win.removeEventListener("unload", onUnload);
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Update the open-protocol-handler permission for the site which triggered
+ * the dialog. Sites with this permission may skip this dialog.
+ * @param {nsIPrincipal} aPrincipal - subject to update the permission for.
+ * @param {string} aScheme - Scheme of protocol to allow.
+ * @param {boolean} aAllow - Whether to set / unset the permission.
+ */
+ _updatePermission(aPrincipal, aScheme, aAllow) {
+ // If enabled, store open-protocol-handler permission for content principals.
+ if (
+ !nsContentDispatchChooser.isPermissionEnabled ||
+ aPrincipal.isSystemPrincipal ||
+ !this._isSupportedPrincipal(aPrincipal)
+ ) {
+ return;
+ }
+
+ let principal = aPrincipal;
+
+ // If this action was triggered by an extension content script then set the
+ // permission on the extension's principal.
+ let addonPolicy = aPrincipal.contentScriptAddonPolicy;
+ if (addonPolicy) {
+ principal = Services.scriptSecurityManager.principalWithOA(
+ addonPolicy.extension.principal,
+ principal.originAttributes
+ );
+ }
+
+ let permKey = this._getSkipProtoDialogPermissionKey(aScheme);
+ if (aAllow) {
+ Services.perms.addFromPrincipal(
+ principal,
+ permKey,
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ } else {
+ Services.perms.removeFromPrincipal(principal, permKey);
+ }
+ }
+
+ /**
+ * Determine if we can use a principal to store permissions.
+ * @param {nsIPrincipal} aPrincipal - Principal to test.
+ * @returns {boolean} - true if we can store permissions, false otherwise.
+ */
+ _isSupportedPrincipal(aPrincipal) {
+ if (!aPrincipal) {
+ return false;
+ }
+
+ // If this is an add-on content script then we will be able to store
+ // permissions against the add-on's principal.
+ if (aPrincipal.contentScriptAddonPolicy) {
+ return true;
+ }
+
+ return ["http", "https", "moz-extension", "file"].some(scheme =>
+ aPrincipal.schemeIs(scheme)
+ );
+ }
+}
+
+nsContentDispatchChooser.prototype.classID = Components.ID(
+ "e35d5067-95bc-4029-8432-e8f1e431148d"
+);
+nsContentDispatchChooser.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIContentDispatchChooser",
+]);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ nsContentDispatchChooser,
+ "isPermissionEnabled",
+ "security.external_protocol_requires_permission",
+ true
+);