/* 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 and 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"]);