diff options
Diffstat (limited to 'toolkit/mozapps/handling')
-rw-r--r-- | toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs | 450 | ||||
-rw-r--r-- | toolkit/mozapps/handling/components.conf | 14 | ||||
-rw-r--r-- | toolkit/mozapps/handling/content/appChooser.js | 377 | ||||
-rw-r--r-- | toolkit/mozapps/handling/content/appChooser.xhtml | 66 | ||||
-rw-r--r-- | toolkit/mozapps/handling/content/handler.css | 55 | ||||
-rw-r--r-- | toolkit/mozapps/handling/content/permissionDialog.js | 210 | ||||
-rw-r--r-- | toolkit/mozapps/handling/content/permissionDialog.xhtml | 52 | ||||
-rw-r--r-- | toolkit/mozapps/handling/jar.mn | 11 | ||||
-rw-r--r-- | toolkit/mozapps/handling/moz.build | 18 |
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"] |