summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/handling
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/handling')
-rw-r--r--toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs450
-rw-r--r--toolkit/mozapps/handling/components.conf14
-rw-r--r--toolkit/mozapps/handling/content/appChooser.js377
-rw-r--r--toolkit/mozapps/handling/content/appChooser.xhtml66
-rw-r--r--toolkit/mozapps/handling/content/handler.css55
-rw-r--r--toolkit/mozapps/handling/content/permissionDialog.js210
-rw-r--r--toolkit/mozapps/handling/content/permissionDialog.xhtml52
-rw-r--r--toolkit/mozapps/handling/jar.mn11
-rw-r--r--toolkit/mozapps/handling/moz.build18
9 files changed, 1253 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..cf9a2444a3
--- /dev/null
+++ b/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs
@@ -0,0 +1,450 @@
+/* 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
+ );
+
+ // 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;
+ }
+
+ // 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) {
+ // 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) {
+ 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 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."
+ );
+ }
+
+ let tabDialogBox = window.gBrowser.getTabDialogBox(topFrameElement);
+ return tabDialogBox.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
+);
diff --git a/toolkit/mozapps/handling/components.conf b/toolkit/mozapps/handling/components.conf
new file mode 100644
index 0000000000..95afe50a87
--- /dev/null
+++ b/toolkit/mozapps/handling/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{e35d5067-95bc-4029-8432-e8f1e431148d}',
+ 'contract_ids': ['@mozilla.org/content-dispatch-chooser;1'],
+ 'esModule': 'resource://gre/modules/ContentDispatchChooser.sys.mjs',
+ 'constructor': 'nsContentDispatchChooser',
+ },
+]
diff --git a/toolkit/mozapps/handling/content/appChooser.js b/toolkit/mozapps/handling/content/appChooser.js
new file mode 100644
index 0000000000..f12ec333a2
--- /dev/null
+++ b/toolkit/mozapps/handling/content/appChooser.js
@@ -0,0 +1,377 @@
+/* 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/. */
+
+const { PrivateBrowsingUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"
+);
+const { EnableDelayHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromptUtils.sys.mjs"
+);
+
+class MozHandler extends window.MozElements.MozRichlistitem {
+ static get markup() {
+ return `
+ <vbox pack="center">
+ <html:img height="32" width="32"/>
+ </vbox>
+ <vbox flex="1">
+ <label class="name"/>
+ <label class="description"/>
+ </vbox>
+ `;
+ }
+
+ connectedCallback() {
+ this.textContent = "";
+ this.appendChild(this.constructor.fragment);
+ this.initializeAttributeInheritance();
+ }
+
+ static get inheritedAttributes() {
+ return {
+ img: "src=image,disabled",
+ ".name": "value=name,disabled",
+ ".description": "value=description,disabled",
+ };
+ }
+
+ get label() {
+ return `${this.getAttribute("name")} ${this.getAttribute("description")}`;
+ }
+}
+
+customElements.define("mozapps-handler", MozHandler, {
+ extends: "richlistitem",
+});
+
+window.addEventListener("DOMContentLoaded", () => dialog.initialize(), {
+ once: true,
+});
+
+let loadPromise = new Promise(resolve => {
+ window.addEventListener("load", resolve, { once: true });
+});
+
+let dialog = {
+ /**
+ * This function initializes the content of the dialog.
+ */
+ initialize() {
+ let args = window.arguments[0].wrappedJSObject || window.arguments[0];
+ let { handler, outArgs, usePrivateBrowsing, enableButtonDelay } = args;
+
+ this._handlerInfo = handler.QueryInterface(Ci.nsIHandlerInfo);
+ this._outArgs = outArgs;
+
+ this.isPrivate =
+ usePrivateBrowsing ||
+ (window.opener && PrivateBrowsingUtils.isWindowPrivate(window.opener));
+
+ this._dialog = document.querySelector("dialog");
+ this._itemChoose = document.getElementById("item-choose");
+ this._rememberCheck = document.getElementById("remember");
+
+ // Register event listener for the checkbox hint.
+ this._rememberCheck.addEventListener("change", () => this.onCheck());
+
+ document.addEventListener("dialogaccept", () => {
+ this.onAccept();
+ });
+
+ // UI is ready, lets populate our list
+ this.populateList();
+
+ this.initL10n();
+
+ if (enableButtonDelay) {
+ this._delayHelper = new EnableDelayHelper({
+ disableDialog: () => {
+ this._acceptBtnDisabled = true;
+ this.updateAcceptButton();
+ },
+ enableDialog: () => {
+ this._acceptBtnDisabled = false;
+ this.updateAcceptButton();
+ },
+ focusTarget: window,
+ });
+ }
+ },
+
+ initL10n() {
+ let rememberLabel = document.getElementById("remember-label");
+ document.l10n.setAttributes(rememberLabel, "chooser-dialog-remember", {
+ scheme: this._handlerInfo.type,
+ });
+
+ let description = document.getElementById("description");
+ document.l10n.setAttributes(description, "chooser-dialog-description", {
+ scheme: this._handlerInfo.type,
+ });
+ },
+
+ /**
+ * Populates the list that a user can choose from.
+ */
+ populateList: function populateList() {
+ var items = document.getElementById("items");
+ var possibleHandlers = this._handlerInfo.possibleApplicationHandlers;
+ var preferredHandler = this._handlerInfo.preferredApplicationHandler;
+ for (let i = possibleHandlers.length - 1; i >= 0; --i) {
+ let app = possibleHandlers.queryElementAt(i, Ci.nsIHandlerApp);
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.setAttribute("name", app.name);
+ elm.obj = app;
+
+ // We defer loading the favicon so it doesn't delay load. The dialog is
+ // opened in a SubDialog which will only show on window load.
+ if (app instanceof Ci.nsILocalHandlerApp) {
+ // See if we have an nsILocalHandlerApp and set the icon
+ let uri = Services.io.newFileURI(app.executable);
+ loadPromise.then(() => {
+ elm.setAttribute("image", "moz-icon://" + uri.spec + "?size=32");
+ });
+ } else if (app instanceof Ci.nsIWebHandlerApp) {
+ let uri = Services.io.newURI(app.uriTemplate);
+ if (/^https?$/.test(uri.scheme)) {
+ // Unfortunately we can't use the favicon service to get the favicon,
+ // because the service looks for a record with the exact URL we give
+ // it, and users won't have such records for URLs they don't visit,
+ // and users won't visit the handler's URL template, they'll only
+ // visit URLs derived from that template (i.e. with %s in the template
+ // replaced by the URL of the content being handled).
+ loadPromise.then(() => {
+ elm.setAttribute("image", uri.prePath + "/favicon.ico");
+ });
+ }
+ elm.setAttribute("description", uri.prePath);
+
+ // Check for extensions needing private browsing access before
+ // creating UI elements.
+ if (this.isPrivate) {
+ let policy = WebExtensionPolicy.getByURI(uri);
+ if (policy && !policy.privateBrowsingAllowed) {
+ elm.setAttribute("disabled", true);
+ this.getPrivateBrowsingDisabledLabel().then(label => {
+ elm.setAttribute("description", label);
+ });
+ if (app == preferredHandler) {
+ preferredHandler = null;
+ }
+ }
+ }
+ } else if (app instanceof Ci.nsIDBusHandlerApp) {
+ elm.setAttribute("description", app.method);
+ } else if (!(app instanceof Ci.nsIGIOMimeApp)) {
+ // We support GIO application handler, but no action required there
+ throw new Error("unknown handler type");
+ }
+
+ items.insertBefore(elm, this._itemChoose);
+ if (preferredHandler && app == preferredHandler) {
+ this.selectedItem = elm;
+ }
+ }
+
+ if (this._handlerInfo.hasDefaultHandler) {
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.id = "os-default-handler";
+ elm.setAttribute("name", this._handlerInfo.defaultDescription);
+
+ items.insertBefore(elm, items.firstChild);
+ if (
+ this._handlerInfo.preferredAction == Ci.nsIHandlerInfo.useSystemDefault
+ ) {
+ this.selectedItem = elm;
+ }
+ }
+
+ // Add gio handlers
+ if (Cc["@mozilla.org/gio-service;1"]) {
+ let gIOSvc = Cc["@mozilla.org/gio-service;1"].getService(
+ Ci.nsIGIOService
+ );
+ var gioApps = gIOSvc.getAppsForURIScheme(this._handlerInfo.type);
+ for (let handler of gioApps.enumerate(Ci.nsIHandlerApp)) {
+ // OS handler share the same name, it's most likely the same app, skipping...
+ if (handler.name == this._handlerInfo.defaultDescription) {
+ continue;
+ }
+ // Check if the handler is already in possibleHandlers
+ let appAlreadyInHandlers = false;
+ for (let i = possibleHandlers.length - 1; i >= 0; --i) {
+ let app = possibleHandlers.queryElementAt(i, Ci.nsIHandlerApp);
+ // nsGIOMimeApp::Equals is able to compare with nsILocalHandlerApp
+ if (handler.equals(app)) {
+ appAlreadyInHandlers = true;
+ break;
+ }
+ }
+ if (!appAlreadyInHandlers) {
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.setAttribute("name", handler.name);
+ elm.obj = handler;
+ items.insertBefore(elm, this._itemChoose);
+ }
+ }
+ }
+
+ items.ensureSelectedElementIsVisible();
+ },
+
+ /**
+ * Brings up a filepicker and allows a user to choose an application.
+ */
+ async chooseApplication() {
+ let title = await this.getChooseAppWindowTitle();
+
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(window, title, Ci.nsIFilePicker.modeOpen);
+ fp.appendFilters(Ci.nsIFilePicker.filterApps);
+
+ fp.open(rv => {
+ if (rv == Ci.nsIFilePicker.returnOK && fp.file) {
+ let uri = Services.io.newFileURI(fp.file);
+
+ let handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = fp.file;
+
+ // if this application is already in the list, select it and don't add it again
+ let parent = document.getElementById("items");
+ for (let i = 0; i < parent.childNodes.length; ++i) {
+ let elm = parent.childNodes[i];
+ if (
+ elm.obj instanceof Ci.nsILocalHandlerApp &&
+ elm.obj.equals(handlerApp)
+ ) {
+ parent.selectedItem = elm;
+ parent.ensureSelectedElementIsVisible();
+ return;
+ }
+ }
+
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.setAttribute("name", fp.file.leafName);
+ elm.setAttribute("image", "moz-icon://" + uri.spec + "?size=32");
+ elm.obj = handlerApp;
+
+ parent.selectedItem = parent.insertBefore(elm, parent.firstChild);
+ parent.ensureSelectedElementIsVisible();
+ }
+ });
+ },
+
+ /**
+ * Function called when the OK button is pressed.
+ */
+ onAccept() {
+ this.updateHandlerData(this._rememberCheck.checked);
+ this._outArgs.setProperty("openHandler", true);
+ },
+
+ /**
+ * Determines if the accept button should be disabled or not
+ */
+ updateAcceptButton() {
+ this._dialog.setAttribute(
+ "buttondisabledaccept",
+ this._acceptBtnDisabled || this._itemChoose.selected
+ );
+ },
+
+ /**
+ * Update the handler info to reflect the user choice.
+ * @param {boolean} skipAsk - Whether we should persist the application
+ * choice and skip asking next time.
+ */
+ updateHandlerData(skipAsk) {
+ // We need to make sure that the default is properly set now
+ if (this.selectedItem.obj) {
+ // default OS handler doesn't have this property
+ this._outArgs.setProperty(
+ "preferredAction",
+ Ci.nsIHandlerInfo.useHelperApp
+ );
+ this._outArgs.setProperty(
+ "preferredApplicationHandler",
+ this.selectedItem.obj
+ );
+ } else {
+ this._outArgs.setProperty(
+ "preferredAction",
+ Ci.nsIHandlerInfo.useSystemDefault
+ );
+ }
+ this._outArgs.setProperty("alwaysAskBeforeHandling", !skipAsk);
+ },
+
+ /**
+ * Updates the UI based on the checkbox being checked or not.
+ */
+ onCheck() {
+ if (document.getElementById("remember").checked) {
+ document.getElementById("remember-text").setAttribute("visible", "true");
+ } else {
+ document.getElementById("remember-text").removeAttribute("visible");
+ }
+ },
+
+ /**
+ * Function called when the user double clicks on an item of the list
+ */
+ onDblClick: function onDblClick() {
+ if (this.selectedItem == this._itemChoose) {
+ this.chooseApplication();
+ } else {
+ this._dialog.acceptDialog();
+ }
+ },
+
+ // Getters / Setters
+
+ /**
+ * Returns/sets the selected element in the richlistbox
+ */
+ get selectedItem() {
+ return document.getElementById("items").selectedItem;
+ },
+ set selectedItem(aItem) {
+ document.getElementById("items").selectedItem = aItem;
+ },
+
+ /**
+ * Lazy l10n getter for the title of the app chooser window
+ */
+ async getChooseAppWindowTitle() {
+ if (!this._chooseAppWindowTitle) {
+ this._chooseAppWindowTitle = await document.l10n.formatValues([
+ "choose-other-app-window-title",
+ ]);
+ }
+ return this._chooseAppWindowTitle;
+ },
+
+ /**
+ * Lazy l10n getter for handler menu items which are disabled due to private
+ * browsing.
+ */
+ async getPrivateBrowsingDisabledLabel() {
+ if (!this._privateBrowsingDisabledLabel) {
+ this._privateBrowsingDisabledLabel = await document.l10n.formatValues([
+ "choose-dialog-privatebrowsing-disabled",
+ ]);
+ }
+ return this._privateBrowsingDisabledLabel;
+ },
+};
diff --git a/toolkit/mozapps/handling/content/appChooser.xhtml b/toolkit/mozapps/handling/content/appChooser.xhtml
new file mode 100644
index 0000000000..a87286cf12
--- /dev/null
+++ b/toolkit/mozapps/handling/content/appChooser.xhtml
@@ -0,0 +1,66 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://mozapps/content/handling/handler.css"?>
+<?xml-stylesheet href="chrome://mozapps/skin/handling/handling.css"?>
+<!-- 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/. -->
+
+<!DOCTYPE window>
+
+<window
+ persist="width height screenX screenY"
+ aria-describedby="description-text"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="chooser-window"
+ data-l10n-attrs="style"
+>
+ <dialog
+ id="handling"
+ buttons="accept,cancel"
+ defaultButton="none"
+ data-l10n-id="chooser-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept"
+ >
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="toolkit/global/handlerDialog.ftl" />
+ </linkset>
+
+ <script
+ src="chrome://mozapps/content/handling/appChooser.js"
+ type="application/javascript"
+ />
+
+ <description id="description" />
+
+ <vbox id="chooser" flex="1">
+ <richlistbox
+ id="items"
+ flex="1"
+ ondblclick="dialog.onDblClick();"
+ onselect="dialog.updateAcceptButton();"
+ >
+ <richlistitem id="item-choose" orient="horizontal" selected="true">
+ <label data-l10n-id="choose-other-app-description" flex="1" />
+ <button
+ oncommand="dialog.chooseApplication();"
+ data-l10n-id="choose-app-btn"
+ />
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+
+ <vbox id="rememberContainer">
+ <html:label class="toggle-container-with-text">
+ <html:input type="checkbox" id="remember" />
+ <html:span id="remember-label" />
+ </html:label>
+ <description
+ id="remember-text"
+ data-l10n-id="chooser-dialog-remember-extra"
+ />
+ </vbox>
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/handling/content/handler.css b/toolkit/mozapps/handling/content/handler.css
new file mode 100644
index 0000000000..d465ffa97d
--- /dev/null
+++ b/toolkit/mozapps/handling/content/handler.css
@@ -0,0 +1,55 @@
+/* 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/. */
+
+@namespace html "http://www.w3.org/1999/xhtml";
+
+#description {
+ font-weight: bold;
+}
+
+#remember-text:not([visible]) {
+ visibility: hidden;
+}
+
+dialog {
+ padding: 16px calc(16px - 4px);
+}
+
+#items,
+label, description {
+ margin: 0;
+}
+
+#items label {
+ margin-inline: 4px;
+}
+
+#description,
+#description-box,
+#rememberContainer,
+#chooser {
+ margin: 0 4px 16px;
+}
+
+/* avoid double inline margins when #description is nested: */
+#description-box > #description {
+ margin-inline: 0;
+}
+
+/* Parent selector to win on specificity against common.css */
+#rememberContainer > .toggle-container-with-text {
+ align-items: baseline;
+ color: var(--text-color-deemphasized);
+}
+
+.toggle-container-with-text > html|input[type="checkbox"] {
+ margin-inline-end: 8px;
+ /* Ensure the checkbox is properly aligned with the text: */
+ translate: 0 calc(1px + max(60% - .6em, 0px));
+}
+
+#rememberContainer:not([hidden]) {
+ /* Ensure we don't get sized to the smallest child when the checkbox text wraps. */
+ display: block;
+}
diff --git a/toolkit/mozapps/handling/content/permissionDialog.js b/toolkit/mozapps/handling/content/permissionDialog.js
new file mode 100644
index 0000000000..2cac3e5fd4
--- /dev/null
+++ b/toolkit/mozapps/handling/content/permissionDialog.js
@@ -0,0 +1,210 @@
+/* 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/. */
+
+const { EnableDelayHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromptUtils.sys.mjs"
+);
+
+let dialog = {
+ /**
+ * This function initializes the content of the dialog.
+ */
+ initialize() {
+ let args = window.arguments[0].wrappedJSObject || window.arguments[0];
+ let {
+ handler,
+ principal,
+ outArgs,
+ canPersistPermission,
+ preferredHandlerName,
+ browsingContext,
+ } = args;
+
+ this._handlerInfo = handler.QueryInterface(Ci.nsIHandlerInfo);
+ this._principal = principal?.QueryInterface(Ci.nsIPrincipal);
+ this._addonPolicy =
+ this._principal?.addonPolicy ?? this._principal?.contentScriptAddonPolicy;
+ this._browsingContext = browsingContext;
+ this._outArgs = outArgs.QueryInterface(Ci.nsIWritablePropertyBag);
+ this._preferredHandlerName = preferredHandlerName;
+
+ this._dialog = document.querySelector("dialog");
+ this._checkRemember = document.getElementById("remember");
+ this._checkRememberContainer = document.getElementById("rememberContainer");
+
+ if (!canPersistPermission) {
+ this._checkRememberContainer.hidden = true;
+ }
+
+ let changeAppLink = document.getElementById("change-app");
+ if (this._preferredHandlerName) {
+ changeAppLink.hidden = false;
+
+ changeAppLink.addEventListener("click", () => this.onChangeApp());
+ }
+
+ document.addEventListener("dialogaccept", () => this.onAccept());
+ this.initL10n();
+
+ this._delayHelper = new EnableDelayHelper({
+ disableDialog: () => {
+ this._dialog.setAttribute("buttondisabledaccept", true);
+ },
+ enableDialog: () => {
+ this._dialog.setAttribute("buttondisabledaccept", false);
+ },
+ focusTarget: window,
+ });
+ },
+
+ /**
+ * Checks whether the principal that triggered this dialog is top level
+ * (not embedded in a frame).
+ * @returns {boolean} - true if principal is top level, false otherwise.
+ * If the triggering principal is null this method always returns false.
+ */
+ triggeringPrincipalIsTop() {
+ if (!this._principal) {
+ return false;
+ }
+
+ let topContentPrincipal =
+ this._browsingContext?.top.embedderElement?.contentPrincipal;
+ if (!topContentPrincipal) {
+ return false;
+ }
+ return this._principal.equals(topContentPrincipal);
+ },
+
+ /**
+ * Determines the l10n ID to use for the dialog description, depending on
+ * the triggering principal and the preferred application handler.
+ */
+ get l10nDescriptionId() {
+ if (this._addonPolicy) {
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-extension-app";
+ }
+ return "permission-dialog-description-extension";
+ }
+
+ if (this._principal?.schemeIs("file")) {
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-file-app";
+ }
+ return "permission-dialog-description-file";
+ }
+
+ // We only show the website address if the request didn't come from the top
+ // level frame. If we can't get a host to display, fall back to the copy
+ // without host.
+ if (!this.triggeringPrincipalIsTop() && this.displayPrePath) {
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-host-app";
+ }
+ return "permission-dialog-description-host";
+ }
+
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-app";
+ }
+
+ return "permission-dialog-description";
+ },
+
+ /**
+ * Determines the l10n ID to use for the "remember permission" checkbox,
+ * depending on the triggering principal and the preferred application
+ * handler.
+ */
+ get l10nCheckboxId() {
+ if (!this._principal) {
+ return null;
+ }
+
+ if (this._addonPolicy) {
+ return "permission-dialog-remember-extension";
+ }
+ if (this._principal.schemeIs("file")) {
+ return "permission-dialog-remember-file";
+ }
+ return "permission-dialog-remember";
+ },
+
+ /**
+ * Computes the prePath to show in the prompt. It's the prePath of the site
+ * that wants to navigate to the external protocol.
+ * @returns {string|null} - prePath to show, or null if we can't derive an
+ * exposable prePath from the triggering principal.
+ */
+ get displayPrePath() {
+ if (!this._principal) {
+ return null;
+ }
+
+ // NullPrincipals don't expose a meaningful prePath. Instead use the
+ // precursorPrincipal, which the NullPrincipal was derived from.
+ if (this._principal.isNullPrincipal) {
+ return this._principal.precursorPrincipal?.exposablePrePath;
+ }
+
+ return this._principal?.exposablePrePath;
+ },
+
+ initL10n() {
+ // The UI labels depend on whether we will show the application chooser next
+ // or directly open the assigned protocol handler.
+
+ // Fluent id for dialog accept button
+ let idAcceptButton;
+ if (this._preferredHandlerName) {
+ idAcceptButton = "permission-dialog-btn-open-link";
+ } else {
+ idAcceptButton = "permission-dialog-btn-choose-app";
+
+ let descriptionExtra = document.getElementById("description-extra");
+ descriptionExtra.hidden = false;
+ }
+ let acceptButton = this._dialog.getButton("accept");
+ document.l10n.setAttributes(acceptButton, idAcceptButton);
+
+ let description = document.getElementById("description");
+
+ let host = this.displayPrePath;
+ let scheme = this._handlerInfo.type;
+
+ document.l10n.setAttributes(description, this.l10nDescriptionId, {
+ host,
+ scheme,
+ extension: this._addonPolicy?.name,
+ appName: this._preferredHandlerName,
+ });
+
+ if (!this._checkRememberContainer.hidden) {
+ let checkboxLabel = document.getElementById("remember-label");
+ document.l10n.setAttributes(checkboxLabel, this.l10nCheckboxId, {
+ host,
+ scheme,
+ });
+ }
+ },
+
+ onAccept() {
+ this._outArgs.setProperty("remember", this._checkRemember.checked);
+ this._outArgs.setProperty("granted", true);
+ },
+
+ onChangeApp() {
+ this._outArgs.setProperty("resetHandlerChoice", true);
+
+ // We can't call the dialogs accept handler here. If the accept button is
+ // still disabled, it will prevent closing.
+ this.onAccept();
+ window.close();
+ },
+};
+
+window.addEventListener("DOMContentLoaded", () => dialog.initialize(), {
+ once: true,
+});
diff --git a/toolkit/mozapps/handling/content/permissionDialog.xhtml b/toolkit/mozapps/handling/content/permissionDialog.xhtml
new file mode 100644
index 0000000000..ee84df1927
--- /dev/null
+++ b/toolkit/mozapps/handling/content/permissionDialog.xhtml
@@ -0,0 +1,52 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://mozapps/content/handling/handler.css"?>
+<!-- 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/. -->
+
+<!DOCTYPE window>
+
+<window
+ aria-describedby="description"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <dialog
+ buttons="accept,cancel"
+ defaultButton="none"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept"
+ >
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/handlerDialog.ftl" />
+ </linkset>
+
+ <script
+ src="chrome://mozapps/content/handling/permissionDialog.js"
+ type="application/javascript"
+ />
+
+ <vbox id="description-box">
+ <description id="description"></description>
+ <label
+ id="change-app"
+ hidden="true"
+ is="text-link"
+ data-l10n-id="permission-dialog-set-change-app-link"
+ ></label>
+ <description
+ id="description-extra"
+ hidden="true"
+ data-l10n-id="permission-dialog-unset-description"
+ >
+ </description>
+ </vbox>
+
+ <vbox id="rememberContainer">
+ <html:label class="toggle-container-with-text">
+ <html:input type="checkbox" id="remember" />
+ <html:span id="remember-label" />
+ </html:label>
+ </vbox>
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/handling/jar.mn b/toolkit/mozapps/handling/jar.mn
new file mode 100644
index 0000000000..769c757ed7
--- /dev/null
+++ b/toolkit/mozapps/handling/jar.mn
@@ -0,0 +1,11 @@
+# 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/.
+
+toolkit.jar:
+% content mozapps %content/mozapps/
+ content/mozapps/handling/handler.css (content/handler.css)
+ content/mozapps/handling/appChooser.xhtml (content/appChooser.xhtml)
+ content/mozapps/handling/appChooser.js (content/appChooser.js)
+ content/mozapps/handling/permissionDialog.xhtml (content/permissionDialog.xhtml)
+ content/mozapps/handling/permissionDialog.js (content/permissionDialog.js)
diff --git a/toolkit/mozapps/handling/moz.build b/toolkit/mozapps/handling/moz.build
new file mode 100644
index 0000000000..0fd8fe7d35
--- /dev/null
+++ b/toolkit/mozapps/handling/moz.build
@@ -0,0 +1,18 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "File Handling")
+
+EXTRA_JS_MODULES += [
+ "ContentDispatchChooser.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]