Source code

Revision control

Copy as Markdown

Other Tools

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