gecko-dev/browser/modules/ExtensionsUI.sys.mjs

817 lines
25 KiB
JavaScript

/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs",
AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
ExtensionData: "resource://gre/modules/Extension.sys.mjs",
ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs",
});
ChromeUtils.defineLazyGetter(
lazy,
"l10n",
() =>
new Localization(["browser/extensionsUI.ftl", "branding/brand.ftl"], true)
);
ChromeUtils.defineLazyGetter(lazy, "logConsole", () =>
console.createInstance({
prefix: "ExtensionsUI",
maxLogLevelPref: "extensions.webextensions.log.level",
})
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"POSTINSTALL_PRIVATEBROWSING_CHECKBOX",
"extensions.ui.postInstallPrivateBrowsingCheckbox",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"SHOW_FULL_DOMAINS_LIST",
"extensions.ui.installDialogFullDomains",
true
);
const DEFAULT_EXTENSION_ICON =
"chrome://mozapps/skin/extensions/extensionGeneric.svg";
function getTabBrowser(browser) {
while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
browser = browser.ownerGlobal.docShell.chromeEventHandler;
}
let window = browser.ownerGlobal;
let viewType = browser.getAttribute("webextension-view-type");
if (viewType == "sidebar") {
window = window.browsingContext.topChromeWindow;
}
if (viewType == "popup" || viewType == "sidebar") {
browser = window.gBrowser.selectedBrowser;
}
return { browser, window };
}
export var ExtensionsUI = {
sideloaded: new Set(),
updates: new Set(),
sideloadListener: null,
pendingNotifications: new WeakMap(),
get SHOW_FULL_DOMAINS_LIST() {
return lazy.SHOW_FULL_DOMAINS_LIST;
},
get POSTINSTALL_PRIVATEBROWSING_CHECKBOX() {
return lazy.POSTINSTALL_PRIVATEBROWSING_CHECKBOX;
},
async init() {
Services.obs.addObserver(this, "webextension-permission-prompt");
Services.obs.addObserver(this, "webextension-update-permissions");
Services.obs.addObserver(this, "webextension-install-notify");
Services.obs.addObserver(this, "webextension-optional-permission-prompt");
Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
Services.obs.addObserver(this, "webextension-imported-addons-cancelled");
Services.obs.addObserver(this, "webextension-imported-addons-complete");
Services.obs.addObserver(this, "webextension-imported-addons-pending");
await Services.wm.getMostRecentWindow("navigator:browser")
.delayedStartupPromise;
this._checkForSideloaded();
},
async _checkForSideloaded() {
let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
if (!sideloaded.length) {
// No new side-loads. We're done.
return;
}
// The ordering shouldn't matter, but tests depend on notifications
// happening in a specific order.
sideloaded.sort((a, b) => a.id.localeCompare(b.id));
if (!this.sideloadListener) {
this.sideloadListener = {
onEnabled: addon => {
if (!this.sideloaded.has(addon)) {
return;
}
this.sideloaded.delete(addon);
this._updateNotifications();
if (this.sideloaded.size == 0) {
lazy.AddonManager.removeAddonListener(this.sideloadListener);
this.sideloadListener = null;
}
},
};
lazy.AddonManager.addAddonListener(this.sideloadListener);
}
for (let addon of sideloaded) {
this.sideloaded.add(addon);
}
this._updateNotifications();
},
_updateNotifications() {
const { sideloaded, updates } = this;
const { importedAddonIDs } = lazy.AMBrowserExtensionsImport;
if (importedAddonIDs.length + sideloaded.size + updates.size == 0) {
lazy.AppMenuNotifications.removeNotification("addon-alert");
} else {
lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
}
this.emit("change");
},
showAddonsManager(
tabbrowser,
strings,
icon,
addon = undefined,
shouldShowIncognitoCheckbox = false
) {
let global = tabbrowser.selectedBrowser.ownerGlobal;
return global.BrowserAddonUI.openAddonsMgr("addons://list/extension").then(
aomWin => {
let aomBrowser = aomWin.docShell.chromeEventHandler;
return this.showPermissionsPrompt(
aomBrowser,
strings,
icon,
addon,
shouldShowIncognitoCheckbox
);
}
);
},
showSideloaded(tabbrowser, addon) {
addon.markAsSeen();
this.sideloaded.delete(addon);
this._updateNotifications();
let strings = this._buildStrings({
addon,
permissions: addon.installPermissions,
type: "sideload",
});
lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
num_strings: strings.msgs.length,
});
this.showAddonsManager(
tabbrowser,
strings,
addon.iconURL,
addon,
true /* shouldShowIncognitoCheckbox */
).then(async answer => {
if (answer) {
await addon.enable();
this._updateNotifications();
// The user has just enabled a sideloaded extension, if the permission
// can be changed for the extension, show the post-install panel to
// give the user that opportunity.
if (
ExtensionsUI.POSTINSTALL_PRIVATEBROWSING_CHECKBOX &&
addon.permissions &
lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
) {
this.showInstallNotification(tabbrowser.selectedBrowser, addon);
}
}
this.emit("sideload-response");
});
},
showUpdate(browser, info) {
lazy.AMTelemetry.recordInstallEvent(info.install, {
step: "permissions_prompt",
num_strings: info.strings.msgs.length,
});
this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
answer => {
if (answer) {
info.resolve();
} else {
info.reject();
}
// At the moment, this prompt will re-appear next time we do an update
// check. See bug 1332360 for proposal to avoid this.
this.updates.delete(info);
this._updateNotifications();
}
);
},
observe(subject, topic) {
if (topic == "webextension-permission-prompt") {
let { target, info } = subject.wrappedJSObject;
let { browser, window } = getTabBrowser(target);
// Dismiss the progress notification. Note that this is bad if
// there are multiple simultaneous installs happening, see
// bug 1329884 for a longer explanation.
let progressNotification = window.PopupNotifications.getNotification(
"addon-progress",
browser
);
if (progressNotification) {
progressNotification.remove();
}
info.unsigned =
info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING;
if (
info.unsigned &&
Cu.isInAutomation &&
Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)
) {
info.unsigned = false;
}
let strings = this._buildStrings(info);
// If this is an update with no promptable permissions, just apply it
if (info.type == "update" && !strings.msgs.length) {
info.resolve();
return;
}
let icon = info.unsigned
? "chrome://global/skin/icons/warning.svg"
: info.icon;
if (info.type == "sideload") {
lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
num_strings: strings.msgs.length,
});
} else {
lazy.AMTelemetry.recordInstallEvent(info.install, {
step: "permissions_prompt",
num_strings: strings.msgs.length,
});
}
this.showPermissionsPrompt(
browser,
strings,
icon,
info.addon,
true /* shouldShowIncognitoCheckbox */
).then(answer => {
if (answer) {
info.resolve();
} else {
info.reject();
}
});
} else if (topic == "webextension-update-permissions") {
let info = subject.wrappedJSObject;
info.type = "update";
let strings = this._buildStrings(info);
// If we don't prompt for any new permissions, just apply it
if (!strings.msgs.length) {
info.resolve();
return;
}
let update = {
strings,
permissions: info.permissions,
install: info.install,
addon: info.addon,
resolve: info.resolve,
reject: info.reject,
};
this.updates.add(update);
this._updateNotifications();
} else if (topic == "webextension-install-notify") {
let { target, addon, callback } = subject.wrappedJSObject;
this.showInstallNotification(target, addon).then(() => {
if (callback) {
callback();
}
});
} else if (topic == "webextension-optional-permission-prompt") {
let { browser, name, icon, permissions, resolve } =
subject.wrappedJSObject;
let strings = this._buildStrings({
type: "optional",
addon: { name },
permissions,
});
// If we don't have any promptable permissions, just proceed
if (!strings.msgs.length) {
resolve(true);
return;
}
resolve(this.showPermissionsPrompt(browser, strings, icon));
} else if (topic == "webextension-defaultsearch-prompt") {
let { browser, name, icon, respond, currentEngine, newEngine } =
subject.wrappedJSObject;
const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([
{
id: "webext-default-search-description",
args: { addonName: "<>", currentEngine, newEngine },
},
"webext-default-search-yes",
"webext-default-search-no",
]);
const strings = { addonName: name, text: searchDesc.value };
for (let attr of searchYes.attributes) {
if (attr.name === "label") {
strings.acceptText = attr.value;
} else if (attr.name === "accesskey") {
strings.acceptKey = attr.value;
}
}
for (let attr of searchNo.attributes) {
if (attr.name === "label") {
strings.cancelText = attr.value;
} else if (attr.name === "accesskey") {
strings.cancelKey = attr.value;
}
}
this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
} else if (
[
"webextension-imported-addons-cancelled",
"webextension-imported-addons-complete",
"webextension-imported-addons-pending",
].includes(topic)
) {
this._updateNotifications();
}
},
// Create a set of formatted strings for a permission prompt
_buildStrings(info) {
const strings = lazy.ExtensionData.formatPermissionStrings(
info,
this.SHOW_FULL_DOMAINS_LIST
? { fullDomainsList: true }
: { collapseOrigins: true }
);
strings.addonName = info.addon.name;
return strings;
},
async showPermissionsPrompt(
target,
strings,
icon,
addon = undefined,
shouldShowIncognitoCheckbox = false
) {
let { browser, window } = getTabBrowser(target);
let showIncognitoCheckbox =
shouldShowIncognitoCheckbox && !lazy.POSTINSTALL_PRIVATEBROWSING_CHECKBOX;
if (showIncognitoCheckbox) {
showIncognitoCheckbox = !!(
addon.permissions &
lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
);
}
const incognitoPermissionName = "internal:privateBrowsingAllowed";
let grantPrivateBrowsingAllowed = false;
if (showIncognitoCheckbox) {
const { permissions } = await lazy.ExtensionPermissions.get(addon.id);
grantPrivateBrowsingAllowed = permissions.includes(
incognitoPermissionName
);
}
// Wait for any pending prompts to complete before showing the next one.
let pending;
while ((pending = this.pendingNotifications.get(browser))) {
await pending;
}
let promise = new Promise(resolve => {
function eventCallback(topic) {
if (topic == "swapping") {
return true;
}
if (topic == "removed") {
Services.tm.dispatchToMainThread(() => {
resolve(false);
});
}
return false;
}
// Show the SUMO link already part of the popupnotification by
// setting learnMoreURL option if there are permissions to be
// granted to the addon being installed (or if the private
// browsing checkbox is shown).
const learnMoreURL =
strings.msgs.length || showIncognitoCheckbox
? Services.urlFormatter.formatURLPref("app.support.baseURL") +
"extension-permissions"
: undefined;
let options = {
hideClose: true,
popupIconURL: icon || DEFAULT_EXTENSION_ICON,
popupIconClass: icon ? "" : "addon-warning-icon",
learnMoreURL,
persistent: true,
eventCallback,
removeOnDismissal: true,
popupOptions: {
position: "bottomright topright",
},
// Pass additional options used internally by the
// addon-webext-permissions-notification custom element
// (defined and registered by browser-addons.js).
customElementOptions: {
strings,
showIncognitoCheckbox,
grantPrivateBrowsingAllowed,
onPrivateBrowsingAllowedChanged(value) {
grantPrivateBrowsingAllowed = value;
},
},
};
// The prompt/notification machinery has a special affordance wherein
// certain subsets of the header string can be designated "names", and
// referenced symbolically as "<>" and "{}" to receive special formatting.
// That code assumes that the existence of |name| and |secondName| in the
// options object imply the presence of "<>" and "{}" (respectively) in
// in the string.
//
// At present, WebExtensions use this affordance while SitePermission
// add-ons don't, so we need to conditionally set the |name| field.
//
// NB: This could potentially be cleaned up, see bug 1799710.
if (strings.header.includes("<>")) {
options.name = strings.addonName;
}
let action = {
label: strings.acceptText,
accessKey: strings.acceptKey,
callback: () => {
resolve(true);
},
};
let secondaryActions = [
{
label: strings.cancelText,
accessKey: strings.cancelKey,
callback: () => {
resolve(false);
},
},
];
window.PopupNotifications.show(
browser,
"addon-webext-permissions",
strings.header,
browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID(
browser,
window
),
action,
secondaryActions,
options
);
});
this.pendingNotifications.set(browser, promise);
promise.finally(() => this.pendingNotifications.delete(browser));
// NOTE: this method is also called from showQuarantineConfirmation and some of its
// related test cases (from browser_ext_originControls.js) seem to be hitting a race
// if the promise returned requires an additional tick to be resolved.
// Look more into the failure and determine a better option to avoid those failures.
if (!showIncognitoCheckbox) {
return promise;
}
return promise.then(continueInstall => {
if (!continueInstall) {
return continueInstall;
}
const incognitoPermission = {
permissions: [incognitoPermissionName],
origins: [],
};
let permUpdatePromise;
if (grantPrivateBrowsingAllowed) {
permUpdatePromise = lazy.ExtensionPermissions.add(
addon.id,
incognitoPermission
).catch(err =>
lazy.logConsole.warn(
`Error on adding "${incognitoPermissionName}" permission to addon id "${addon.id}`,
err
)
);
} else {
permUpdatePromise = lazy.ExtensionPermissions.remove(
addon.id,
incognitoPermission
).catch(err =>
lazy.logConsole.warn(
`Error on removing "${incognitoPermissionName}" permission to addon id "${addon.id}`,
err
)
);
}
return permUpdatePromise.then(() => continueInstall);
});
},
showDefaultSearchPrompt(target, strings, icon) {
return new Promise(resolve => {
let options = {
hideClose: true,
popupIconURL: icon || DEFAULT_EXTENSION_ICON,
persistent: true,
removeOnDismissal: true,
eventCallback(topic) {
if (topic == "removed") {
resolve(false);
}
},
name: strings.addonName,
};
let action = {
label: strings.acceptText,
accessKey: strings.acceptKey,
callback: () => {
resolve(true);
},
};
let secondaryActions = [
{
label: strings.cancelText,
accessKey: strings.cancelKey,
callback: () => {
resolve(false);
},
},
];
let { browser, window } = getTabBrowser(target);
window.PopupNotifications.show(
browser,
"addon-webext-defaultsearch",
strings.text,
"addons-notification-icon",
action,
secondaryActions,
options
);
});
},
async showInstallNotification(target, addon) {
let { window } = getTabBrowser(target);
const message = await lazy.l10n.formatValue("addon-post-install-message", {
addonName: "<>",
});
const hideIncognitoCheckbox = !lazy.POSTINSTALL_PRIVATEBROWSING_CHECKBOX;
const permissionName = "internal:privateBrowsingAllowed";
const { permissions } = await lazy.ExtensionPermissions.get(addon.id);
const hasIncognito = permissions.includes(permissionName);
return new Promise(resolve => {
// Show or hide private permission ui based on the pref.
function setCheckbox(win) {
let checkbox = win.document.getElementById("addon-incognito-checkbox");
checkbox.checked = hasIncognito;
checkbox.hidden =
hideIncognitoCheckbox ||
!(
addon.permissions &
lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
);
}
async function actionResolve(win) {
let checkbox = win.document.getElementById("addon-incognito-checkbox");
if (hideIncognitoCheckbox || checkbox.checked == hasIncognito) {
resolve();
return;
}
let incognitoPermission = {
permissions: [permissionName],
origins: [],
};
// The checkbox has been changed at this point, otherwise we would
// have exited early above.
if (checkbox.checked) {
await lazy.ExtensionPermissions.add(addon.id, incognitoPermission);
} else if (hasIncognito) {
await lazy.ExtensionPermissions.remove(addon.id, incognitoPermission);
}
// Reload the extension if it is already enabled. This ensures any change
// on the private browsing permission is properly handled.
if (addon.isActive) {
await addon.reload();
}
resolve();
}
let action = {
callback: actionResolve,
};
let icon = addon.isWebExtension
? lazy.AddonManager.getPreferredIconURL(addon, 32, window) ||
DEFAULT_EXTENSION_ICON
: "chrome://browser/skin/addons/addon-install-installed.svg";
let options = {
name: addon.name,
message,
popupIconURL: icon,
onRefresh: setCheckbox,
onDismissed: win => {
lazy.AppMenuNotifications.removeNotification("addon-installed");
actionResolve(win);
},
};
lazy.AppMenuNotifications.showNotification(
"addon-installed",
action,
null,
options
);
});
},
async showQuarantineConfirmation(browser, policy) {
let [title, line1, line2, allow, deny] = await lazy.l10n.formatMessages([
{
id: "webext-quarantine-confirmation-title",
args: { addonName: "<>" },
},
"webext-quarantine-confirmation-line-1",
"webext-quarantine-confirmation-line-2",
"webext-quarantine-confirmation-allow",
"webext-quarantine-confirmation-deny",
]);
let attr = (msg, name) => msg.attributes.find(a => a.name === name)?.value;
let strings = {
addonName: policy.name,
header: title.value,
text: line1.value + "\n\n" + line2.value,
msgs: [],
acceptText: attr(allow, "label"),
acceptKey: attr(allow, "accesskey"),
cancelText: attr(deny, "label"),
cancelKey: attr(deny, "accesskey"),
};
let icon = policy.extension?.getPreferredIcon(32);
if (await ExtensionsUI.showPermissionsPrompt(browser, strings, icon)) {
lazy.QuarantinedDomains.setUserAllowedAddonIdPref(policy.id, true);
}
},
// Populate extension toolbar popup menu with origin controls.
originControlsMenu(popup, extensionId) {
let policy = WebExtensionPolicy.getByID(extensionId);
let win = popup.ownerGlobal;
let doc = popup.ownerDocument;
let tab = win.gBrowser.selectedTab;
let uri = tab.linkedBrowser?.currentURI;
let state = lazy.OriginControls.getState(policy, tab);
let headerItem = doc.createXULElement("menuitem");
headerItem.setAttribute("disabled", true);
let items = [headerItem];
// MV2 normally don't have controls, but we show the quarantined state.
if (!policy?.extension.originControls && !state.quarantined) {
return;
}
if (state.noAccess) {
doc.l10n.setAttributes(headerItem, "origin-controls-no-access");
} else {
doc.l10n.setAttributes(headerItem, "origin-controls-options");
}
if (state.quarantined) {
doc.l10n.setAttributes(headerItem, "origin-controls-quarantined-status");
let allowQuarantined = doc.createXULElement("menuitem");
doc.l10n.setAttributes(
allowQuarantined,
"origin-controls-quarantined-allow"
);
allowQuarantined.addEventListener("command", () => {
this.showQuarantineConfirmation(tab.linkedBrowser, policy);
});
items.push(allowQuarantined);
}
if (state.allDomains) {
let allDomains = doc.createXULElement("menuitem");
allDomains.setAttribute("type", "radio");
allDomains.setAttribute("checked", state.hasAccess);
doc.l10n.setAttributes(allDomains, "origin-controls-option-all-domains");
items.push(allDomains);
}
if (state.whenClicked) {
let whenClicked = doc.createXULElement("menuitem");
whenClicked.setAttribute("type", "radio");
whenClicked.setAttribute("checked", !state.hasAccess);
doc.l10n.setAttributes(
whenClicked,
"origin-controls-option-when-clicked"
);
whenClicked.addEventListener("command", async () => {
await lazy.OriginControls.setWhenClicked(policy, uri);
win.gUnifiedExtensions.updateAttention();
});
items.push(whenClicked);
}
if (state.alwaysOn) {
let alwaysOn = doc.createXULElement("menuitem");
alwaysOn.setAttribute("type", "radio");
alwaysOn.setAttribute("checked", state.hasAccess);
doc.l10n.setAttributes(alwaysOn, "origin-controls-option-always-on", {
domain: uri.host,
});
alwaysOn.addEventListener("command", async () => {
await lazy.OriginControls.setAlwaysOn(policy, uri);
win.gUnifiedExtensions.updateAttention();
});
items.push(alwaysOn);
}
items.push(doc.createXULElement("menuseparator"));
// Insert all items before Pin to toolbar OR Manage Extension, but after
// any extension's menu items.
let manageItem =
popup.querySelector(".customize-context-manageExtension") ||
popup.querySelector(".unified-extensions-context-menu-pin-to-toolbar");
items.forEach(item => item && popup.insertBefore(item, manageItem));
let cleanup = e => {
if (e.target === popup) {
items.forEach(item => item?.remove());
popup.removeEventListener("popuphidden", cleanup);
}
};
popup.addEventListener("popuphidden", cleanup);
},
};
EventEmitter.decorate(ExtensionsUI);