diff options
Diffstat (limited to 'mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs')
-rw-r--r-- | mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs | 831 |
1 files changed, 831 insertions, 0 deletions
diff --git a/mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs b/mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs new file mode 100644 index 0000000000..08087a4e63 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs @@ -0,0 +1,831 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", + GeckoViewClipboardPermission: + "resource://gre/modules/GeckoViewClipboardPermission.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPrompt"); + +export class PromptFactory { + constructor() { + this.wrappedJSObject = this; + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "mozshowdropdown": + case "mozshowdropdown-sourcetouch": + this._handleSelect( + aEvent.composedTarget, + aEvent.composedTarget.isCombobox + ); + break; + case "MozOpenDateTimePicker": + this._handleDateTime(aEvent.composedTarget); + break; + case "click": + this._handleClick(aEvent); + break; + case "DOMPopupBlocked": + this._handlePopupBlocked(aEvent); + break; + } + } + + _handleClick(aEvent) { + const target = aEvent.composedTarget; + const className = ChromeUtils.getClassName(target); + if (className !== "HTMLInputElement" && className !== "HTMLSelectElement") { + return; + } + + if ( + target.isContentEditable || + target.disabled || + target.readOnly || + !target.willValidate + ) { + // target.willValidate is false when any associated fieldset is disabled, + // in which case this element is treated as disabled per spec. + return; + } + + if (className === "HTMLSelectElement") { + if (!target.isCombobox) { + this._handleSelect(target, /* aIsDropDown = */ false); + return; + } + // combobox select is handled by mozshowdropdown. + return; + } + + const type = target.type; + if (type === "month" || type === "week") { + // If there's a shadow root, the MozOpenDateTimePicker event takes care + // of this. Right now for these input types there's never a shadow root. + // Once we support UA widgets for month/week inputs (see bug 888320), we + // can remove this. + if (!target.openOrClosedShadowRoot) { + this._handleDateTime(target); + aEvent.preventDefault(); + } + } + } + + _generateSelectItems(aElement) { + const win = aElement.ownerGlobal; + let id = 0; + const map = {}; + + const items = (function enumList(elem, disabled) { + const items = []; + const children = elem.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (win.getComputedStyle(child).display === "none") { + continue; + } + const item = { + id: String(id), + disabled: disabled || child.disabled, + }; + if (win.HTMLOptGroupElement.isInstance(child)) { + item.label = child.label; + item.items = enumList(child, item.disabled); + } else if (win.HTMLOptionElement.isInstance(child)) { + item.label = child.label || child.text; + item.selected = child.selected; + } else if (win.HTMLHRElement.isInstance(child)) { + item.separator = true; + } else { + continue; + } + items.push(item); + map[id++] = child; + } + return items; + })(aElement); + + return [items, map, id]; + } + + _handleSelect(aElement, aIsDropDown) { + const win = aElement.ownerGlobal; + const [items] = this._generateSelectItems(aElement); + + if (aIsDropDown) { + aElement.openInParentProcess = true; + } + + const prompt = new lazy.GeckoViewPrompter(win); + + // Something changed the <select> while it was open. + const deferredUpdate = new lazy.DeferredTask(() => { + // Inner contents in choice prompt are updated. + const [newItems] = this._generateSelectItems(aElement); + prompt.update({ + type: "choice", + mode: aElement.multiple ? "multiple" : "single", + choices: newItems, + }); + }, 0); + const mut = new win.MutationObserver(() => { + deferredUpdate.arm(); + }); + mut.observe(aElement, { + childList: true, + subtree: true, + attributes: true, + }); + + const dismissPrompt = () => prompt.dismiss(); + aElement.addEventListener("blur", dismissPrompt, { mozSystemGroup: true }); + const hidedropdown = event => { + if (aElement === event.target) { + prompt.dismiss(); + } + }; + const chromeEventHandler = aElement.ownerGlobal.docShell.chromeEventHandler; + chromeEventHandler.addEventListener("mozhidedropdown", hidedropdown, { + mozSystemGroup: true, + }); + + prompt.asyncShowPrompt( + { + type: "choice", + mode: aElement.multiple ? "multiple" : "single", + choices: items, + }, + result => { + deferredUpdate.disarm(); + mut.disconnect(); + aElement.removeEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + chromeEventHandler.removeEventListener( + "mozhidedropdown", + hidedropdown, + { mozSystemGroup: true } + ); + + if (aIsDropDown) { + aElement.openInParentProcess = false; + } + // OK: result + // Cancel: !result + if (!result || result.choices === undefined) { + return; + } + + const [, map, id] = this._generateSelectItems(aElement); + let dispatchEvents = false; + if (!aElement.multiple) { + const elem = map[result.choices[0]]; + if (elem && win.HTMLOptionElement.isInstance(elem)) { + dispatchEvents = !elem.selected; + elem.selected = true; + } else { + console.error("Invalid id for select result: " + result.choices[0]); + } + } else { + for (let i = 0; i < id; i++) { + const elem = map[i]; + const index = result.choices.indexOf(String(i)); + if ( + win.HTMLOptionElement.isInstance(elem) && + elem.selected !== index >= 0 + ) { + // Current selected is not the same as the new selected state. + dispatchEvents = true; + elem.selected = !elem.selected; + } + result.choices[index] = undefined; + } + for (let i = 0; i < result.choices.length; i++) { + if (result.choices[i] !== undefined && result.choices[i] !== null) { + console.error( + "Invalid id for select result: " + result.choices[i] + ); + break; + } + } + } + + if (dispatchEvents) { + this._dispatchEvents(aElement); + } + } + ); + } + + _handleDateTime(aElement) { + const win = aElement.ownerGlobal; + const prompt = new lazy.GeckoViewPrompter(win); + + const chromeEventHandler = aElement.ownerGlobal.docShell.chromeEventHandler; + const dismissPrompt = () => prompt.dismiss(); + // Some controls don't have UA widget (bug 888320) + { + const dateTimeBoxElement = aElement.dateTimeBoxElement; + if (["month", "week"].includes(aElement.type) && !dateTimeBoxElement) { + aElement.addEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + } else { + chromeEventHandler.addEventListener( + "MozCloseDateTimePicker", + dismissPrompt + ); + + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: true }) + ); + } + } + + prompt.asyncShowPrompt( + { + type: "datetime", + mode: aElement.type, + value: aElement.value, + min: aElement.min, + max: aElement.max, + step: aElement.step, + }, + result => { + // Some controls don't have UA widget (bug 888320) + const dateTimeBoxElement = aElement.dateTimeBoxElement; + if (["month", "week"].includes(aElement.type) && !dateTimeBoxElement) { + aElement.removeEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + } else { + chromeEventHandler.removeEventListener( + "MozCloseDateTimePicker", + dismissPrompt + ); + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: false }) + ); + } + + // OK: result + // Cancel: !result + if ( + !result || + result.datetime === undefined || + result.datetime === aElement.value + ) { + return; + } + aElement.value = result.datetime; + this._dispatchEvents(aElement); + } + ); + } + + _dispatchEvents(aElement) { + // Fire both "input" and "change" events for <select> and <input> for + // date/time. + aElement.dispatchEvent( + new aElement.ownerGlobal.Event("input", { bubbles: true, composed: true }) + ); + aElement.dispatchEvent( + new aElement.ownerGlobal.Event("change", { bubbles: true }) + ); + } + + _handlePopupBlocked(aEvent) { + const dwi = aEvent.requestingWindow; + const popupWindowURISpec = aEvent.popupWindowURI + ? aEvent.popupWindowURI.displaySpec + : "about:blank"; + + const prompt = new lazy.GeckoViewPrompter(aEvent.requestingWindow); + prompt.asyncShowPrompt( + { + type: "popup", + targetUri: popupWindowURISpec, + }, + ({ response }) => { + if (response && dwi) { + dwi.open( + popupWindowURISpec, + aEvent.popupWindowName, + aEvent.popupWindowFeatures + ); + } + } + ); + } + + /* ---------- nsIPromptFactory ---------- */ + getPrompt(aDOMWin, aIID) { + // Delegated to login manager here, which in turn calls back into us via nsIPromptService. + if (aIID.equals(Ci.nsIAuthPrompt2) || aIID.equals(Ci.nsIAuthPrompt)) { + try { + const pwmgr = Cc[ + "@mozilla.org/passwordmanager/authpromptfactory;1" + ].getService(Ci.nsIPromptFactory); + return pwmgr.getPrompt(aDOMWin, aIID); + } catch (e) { + console.error("Delegation to password manager failed:", e); + } + } + + const p = new PromptDelegate(aDOMWin); + p.QueryInterface(aIID); + return p; + } + + /* ---------- private memebers ---------- */ + + // nsIPromptService methods proxy to our Prompt class + callProxy(aMethod, aArguments) { + const prompt = new PromptDelegate(aArguments[0]); + let promptArgs; + if (BrowsingContext.isInstance(aArguments[0])) { + // Called by BrowsingContext prompt method, strip modalType. + [, , /*browsingContext*/ /*modalType*/ ...promptArgs] = aArguments; + } else { + [, /*domWindow*/ ...promptArgs] = aArguments; + } + return prompt[aMethod].apply(prompt, promptArgs); + } + + /* ---------- nsIPromptService ---------- */ + + alert() { + return this.callProxy("alert", arguments); + } + alertBC() { + return this.callProxy("alert", arguments); + } + alertCheck() { + return this.callProxy("alertCheck", arguments); + } + alertCheckBC() { + return this.callProxy("alertCheck", arguments); + } + confirm() { + return this.callProxy("confirm", arguments); + } + confirmBC() { + return this.callProxy("confirm", arguments); + } + confirmCheck() { + return this.callProxy("confirmCheck", arguments); + } + confirmCheckBC() { + return this.callProxy("confirmCheck", arguments); + } + confirmEx() { + return this.callProxy("confirmEx", arguments); + } + confirmExBC() { + return this.callProxy("confirmEx", arguments); + } + prompt() { + return this.callProxy("prompt", arguments); + } + promptBC() { + return this.callProxy("prompt", arguments); + } + promptUsernameAndPassword() { + return this.callProxy("promptUsernameAndPassword", arguments); + } + promptUsernameAndPasswordBC() { + return this.callProxy("promptUsernameAndPassword", arguments); + } + promptPassword() { + return this.callProxy("promptPassword", arguments); + } + promptPasswordBC() { + return this.callProxy("promptPassword", arguments); + } + select() { + return this.callProxy("select", arguments); + } + selectBC() { + return this.callProxy("select", arguments); + } + promptAuth() { + return this.callProxy("promptAuth", arguments); + } + promptAuthBC() { + return this.callProxy("promptAuth", arguments); + } + asyncPromptAuth() { + return this.callProxy("asyncPromptAuth", arguments); + } + confirmUserPaste() { + return lazy.GeckoViewClipboardPermission.confirmUserPaste(...arguments); + } +} + +PromptFactory.prototype.classID = Components.ID( + "{076ac188-23c1-4390-aa08-7ef1f78ca5d9}" +); +PromptFactory.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPromptFactory", + "nsIPromptService", +]); + +class PromptDelegate { + constructor(aParent) { + this._prompter = new lazy.GeckoViewPrompter(aParent); + } + + BUTTON_TYPE_POSITIVE = 0; + BUTTON_TYPE_NEUTRAL = 1; + BUTTON_TYPE_NEGATIVE = 2; + + /* ---------- internal methods ---------- */ + + _addText(aTitle, aText, aMsg) { + return Object.assign(aMsg, { + title: aTitle, + msg: aText, + }); + } + + _addCheck(aCheckMsg, aCheckState, aMsg) { + return Object.assign(aMsg, { + hasCheck: !!aCheckMsg, + checkMsg: aCheckMsg, + checkValue: aCheckState && aCheckState.value, + }); + } + + /* ---------- nsIPrompt ---------- */ + + alert(aTitle, aText) { + this.alertCheck(aTitle, aText); + } + + alertCheck(aTitle, aText, aCheckMsg, aCheckState) { + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "alert", + }) + ) + ); + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + } + + confirm(aTitle, aText) { + // Button 0 is OK. + return this.confirmCheck(aTitle, aText); + } + + confirmCheck(aTitle, aText, aCheckMsg, aCheckState) { + // Button 0 is OK. + return ( + this.confirmEx( + aTitle, + aText, + Ci.nsIPrompt.STD_OK_CANCEL_BUTTONS, + /* aButton0 */ null, + /* aButton1 */ null, + /* aButton2 */ null, + aCheckMsg, + aCheckState + ) == 0 + ); + } + + confirmEx( + aTitle, + aText, + aButtonFlags, + aButton0, + aButton1, + aButton2, + aCheckMsg, + aCheckState + ) { + const btnMap = Array(3).fill(null); + const btnTitle = Array(3).fill(null); + const btnCustomTitle = Array(3).fill(null); + const savedButtonId = []; + for (let i = 0; i < 3; i++) { + const btnFlags = aButtonFlags >> (i * 8); + switch (btnFlags & 0xff) { + case Ci.nsIPrompt.BUTTON_TITLE_OK: + btnMap[this.BUTTON_TYPE_POSITIVE] = i; + btnTitle[this.BUTTON_TYPE_POSITIVE] = "ok"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_CANCEL: + btnMap[this.BUTTON_TYPE_NEGATIVE] = i; + btnTitle[this.BUTTON_TYPE_NEGATIVE] = "cancel"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_YES: + btnMap[this.BUTTON_TYPE_POSITIVE] = i; + btnTitle[this.BUTTON_TYPE_POSITIVE] = "yes"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_NO: + btnMap[this.BUTTON_TYPE_NEGATIVE] = i; + btnTitle[this.BUTTON_TYPE_NEGATIVE] = "no"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_IS_STRING: + // We don't know if this is positive/negative/neutral, so save for later. + savedButtonId.push(i); + break; + case Ci.nsIPrompt.BUTTON_TITLE_SAVE: + case Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE: + case Ci.nsIPrompt.BUTTON_TITLE_REVERT: + // Not supported; fall-through. + default: + break; + } + } + + // Put saved buttons into available slots. + for (let i = 0; i < 3 && savedButtonId.length; i++) { + if (btnMap[i] === null) { + btnMap[i] = savedButtonId.shift(); + btnTitle[i] = "custom"; + btnCustomTitle[i] = [aButton0, aButton1, aButton2][btnMap[i]]; + } + } + + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "button", + btnTitle, + btnCustomTitle, + }) + ) + ); + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + return result && result.button in btnMap ? btnMap[result.button] : -1; + } + + prompt(aTitle, aText, aValue, aCheckMsg, aCheckState) { + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "text", + value: aValue.value, + }) + ) + ); + // OK: result && result.text !== undefined + // Cancel: result && result.text === undefined + // Error: !result + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + if (!result || result.text === undefined) { + return false; + } + aValue.value = result.text || ""; + return true; + } + + promptPassword(aTitle, aText, aPassword) { + return this._promptUsernameAndPassword( + aTitle, + aText, + /* aUsername */ undefined, + aPassword + ); + } + + promptUsernameAndPassword(aTitle, aText, aUsername, aPassword) { + const msg = { + type: "auth", + mode: aUsername ? "auth" : "password", + options: { + flags: aUsername ? 0 : Ci.nsIAuthInformation.ONLY_PASSWORD, + username: aUsername ? aUsername.value : undefined, + password: aPassword.value, + }, + }; + const result = this._prompter.showPrompt(this._addText(aTitle, aText, msg)); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + if (!result || result.password === undefined) { + return false; + } + if (aUsername) { + aUsername.value = result.username || ""; + } + aPassword.value = result.password || ""; + return true; + } + + select(aTitle, aText, aSelectList, aOutSelection) { + const choices = Array.prototype.map.call(aSelectList, (item, index) => ({ + id: String(index), + label: item, + disabled: false, + selected: false, + })); + const result = this._prompter.showPrompt( + this._addText(aTitle, aText, { + type: "choice", + mode: "single", + choices, + }) + ); + // OK: result + // Cancel: !result + if (!result || result.choices === undefined) { + return false; + } + aOutSelection.value = Number(result.choices[0]); + return true; + } + + _getAuthMsg(aChannel, aLevel, aAuthInfo) { + let username; + if ( + aAuthInfo.flags & Ci.nsIAuthInformation.NEED_DOMAIN && + aAuthInfo.domain + ) { + username = aAuthInfo.domain + "\\" + aAuthInfo.username; + } else { + username = aAuthInfo.username; + } + return this._addText( + /* title */ null, + this._getAuthText(aChannel, aAuthInfo), + { + type: "auth", + mode: + aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD + ? "password" + : "auth", + options: { + flags: aAuthInfo.flags, + uri: aChannel && aChannel.URI.displaySpec, + level: aLevel, + username, + password: aAuthInfo.password, + }, + } + ); + } + + _fillAuthInfo(aAuthInfo, aResult) { + if (!aResult || aResult.password === undefined) { + return false; + } + + aAuthInfo.password = aResult.password || ""; + if (aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD) { + return true; + } + + const username = aResult.username || ""; + if (aAuthInfo.flags & Ci.nsIAuthInformation.NEED_DOMAIN) { + // Domain is separated from username by a backslash + var idx = username.indexOf("\\"); + if (idx >= 0) { + aAuthInfo.domain = username.substring(0, idx); + aAuthInfo.username = username.substring(idx + 1); + return true; + } + } + aAuthInfo.username = username; + return true; + } + + promptAuth(aChannel, aLevel, aAuthInfo) { + const result = this._prompter.showPrompt( + this._getAuthMsg(aChannel, aLevel, aAuthInfo) + ); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + return this._fillAuthInfo(aAuthInfo, result); + } + + async asyncPromptAuth(aChannel, aLevel, aAuthInfo) { + const result = await this._prompter.asyncShowPromptPromise( + this._getAuthMsg(aChannel, aLevel, aAuthInfo) + ); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + return this._fillAuthInfo(aAuthInfo, result); + } + + _getAuthText(aChannel, aAuthInfo) { + const isProxy = aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY; + const isPassOnly = aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD; + const isCrossOrig = + aAuthInfo.flags & Ci.nsIAuthInformation.CROSS_ORIGIN_SUB_RESOURCE; + + const username = aAuthInfo.username; + const authTarget = this._getAuthTarget(aChannel, aAuthInfo); + const { displayHost } = authTarget; + let { realm } = authTarget; + + // Suppress "the site says: $realm" when we synthesized a missing realm. + if (!aAuthInfo.realm && !isProxy) { + realm = ""; + } + + // Trim obnoxiously long realms. + if (realm.length > 50) { + realm = realm.substring(0, 50) + "\u2026"; + } + + const bundle = Services.strings.createBundle( + "chrome://global/locale/commonDialogs.properties" + ); + let text; + if (isProxy) { + text = bundle.formatStringFromName("EnterLoginForProxy3", [ + realm, + displayHost, + ]); + } else if (isPassOnly) { + text = bundle.formatStringFromName("EnterPasswordFor", [ + username, + displayHost, + ]); + } else if (isCrossOrig) { + text = bundle.formatStringFromName("EnterUserPasswordForCrossOrigin2", [ + displayHost, + ]); + } else if (!realm) { + text = bundle.formatStringFromName("EnterUserPasswordFor2", [ + displayHost, + ]); + } else { + text = bundle.formatStringFromName("EnterLoginForRealm3", [ + realm, + displayHost, + ]); + } + + return text; + } + + _getAuthTarget(aChannel, aAuthInfo) { + // If our proxy is demanding authentication, don't use the + // channel's actual destination. + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { + if (!(aChannel instanceof Ci.nsIProxiedChannel)) { + throw new Error("proxy auth needs nsIProxiedChannel"); + } + const info = aChannel.proxyInfo; + if (!info) { + throw new Error("proxy auth needs nsIProxyInfo"); + } + // Proxies don't have a scheme, but we'll use "moz-proxy://" + // so that it's more obvious what the login is for. + const idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + const displayHost = + "moz-proxy://" + + idnService.convertUTF8toACE(info.host) + + ":" + + info.port; + let realm = aAuthInfo.realm; + if (!realm) { + realm = displayHost; + } + return { displayHost, realm }; + } + + const displayHost = + aChannel.URI.scheme + "://" + aChannel.URI.displayHostPort; + // If a HTTP WWW-Authenticate header specified a realm, that value + // will be available here. If it wasn't set or wasn't HTTP, we'll use + // the formatted hostname instead. + let realm = aAuthInfo.realm; + if (!realm) { + realm = displayHost; + } + return { displayHost, realm }; + } +} + +PromptDelegate.prototype.QueryInterface = ChromeUtils.generateQI(["nsIPrompt"]); |