fune/browser/base/content/browser-addons.js

1647 lines
49 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
* 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/. */
// This file is loaded into the browser window scope.
/* eslint-env mozilla/browser-window */
const lazy = {};
ChromeUtils.defineModuleGetter(
lazy,
"ExtensionParent",
"resource://gre/modules/ExtensionParent.jsm"
);
ChromeUtils.defineModuleGetter(
lazy,
"OriginControls",
"resource://gre/modules/ExtensionPermissions.jsm"
);
ChromeUtils.defineModuleGetter(
lazy,
"ExtensionPermissions",
"resource://gre/modules/ExtensionPermissions.jsm"
);
customElements.define(
"addon-progress-notification",
class MozAddonProgressNotification extends customElements.get(
"popupnotification"
) {
show() {
super.show();
this.progressmeter = document.getElementById(
"addon-progress-notification-progressmeter"
);
this.progresstext = document.getElementById(
"addon-progress-notification-progresstext"
);
if (!this.notification) {
return;
}
this.notification.options.installs.forEach(function(aInstall) {
aInstall.addListener(this);
}, this);
// Calling updateProgress can sometimes cause this notification to be
// removed in the middle of refreshing the notification panel which
// makes the panel get refreshed again. Just initialise to the
// undetermined state and then schedule a proper check at the next
// opportunity
this.setProgress(0, -1);
this._updateProgressTimeout = setTimeout(
this.updateProgress.bind(this),
0
);
}
disconnectedCallback() {
this.destroy();
}
destroy() {
if (!this.notification) {
return;
}
this.notification.options.installs.forEach(function(aInstall) {
aInstall.removeListener(this);
}, this);
clearTimeout(this._updateProgressTimeout);
}
setProgress(aProgress, aMaxProgress) {
if (aMaxProgress == -1) {
this.progressmeter.removeAttribute("value");
} else {
this.progressmeter.setAttribute(
"value",
(aProgress * 100) / aMaxProgress
);
}
let now = Date.now();
if (!this.notification.lastUpdate) {
this.notification.lastUpdate = now;
this.notification.lastProgress = aProgress;
return;
}
let delta = now - this.notification.lastUpdate;
if (delta < 400 && aProgress < aMaxProgress) {
return;
}
// Set min. time delta to avoid division by zero in the upcoming speed calculation
delta = Math.max(delta, 400);
delta /= 1000;
// This algorithm is the same used by the downloads code.
let speed = (aProgress - this.notification.lastProgress) / delta;
if (this.notification.speed) {
speed = speed * 0.9 + this.notification.speed * 0.1;
}
this.notification.lastUpdate = now;
this.notification.lastProgress = aProgress;
this.notification.speed = speed;
let status = null;
[status, this.notification.last] = DownloadUtils.getDownloadStatus(
aProgress,
aMaxProgress,
speed,
this.notification.last
);
this.progresstext.setAttribute("value", status);
this.progresstext.setAttribute("tooltiptext", status);
}
cancel() {
let installs = this.notification.options.installs;
installs.forEach(function(aInstall) {
try {
aInstall.cancel();
} catch (e) {
// Cancel will throw if the download has already failed
}
}, this);
PopupNotifications.remove(this.notification);
}
updateProgress() {
if (!this.notification) {
return;
}
let downloadingCount = 0;
let progress = 0;
let maxProgress = 0;
this.notification.options.installs.forEach(function(aInstall) {
if (aInstall.maxProgress == -1) {
maxProgress = -1;
}
progress += aInstall.progress;
if (maxProgress >= 0) {
maxProgress += aInstall.maxProgress;
}
if (aInstall.state < AddonManager.STATE_DOWNLOADED) {
downloadingCount++;
}
});
if (downloadingCount == 0) {
this.destroy();
this.progressmeter.removeAttribute("value");
let status = gNavigatorBundle.getString("addonDownloadVerifying");
this.progresstext.setAttribute("value", status);
this.progresstext.setAttribute("tooltiptext", status);
} else {
this.setProgress(progress, maxProgress);
}
}
onDownloadProgress() {
this.updateProgress();
}
onDownloadFailed() {
this.updateProgress();
}
onDownloadCancelled() {
this.updateProgress();
}
onDownloadEnded() {
this.updateProgress();
}
}
);
// Removes a doorhanger notification if all of the installs it was notifying
// about have ended in some way.
function removeNotificationOnEnd(notification, installs) {
let count = installs.length;
function maybeRemove(install) {
install.removeListener(this);
if (--count == 0) {
// Check that the notification is still showing
let current = PopupNotifications.getNotification(
notification.id,
notification.browser
);
if (current === notification) {
notification.remove();
}
}
}
for (let install of installs) {
install.addListener({
onDownloadCancelled: maybeRemove,
onDownloadFailed: maybeRemove,
onInstallFailed: maybeRemove,
onInstallEnded: maybeRemove,
});
}
}
var gXPInstallObserver = {
_findChildShell(aDocShell, aSoughtShell) {
if (aDocShell == aSoughtShell) {
return aDocShell;
}
var node = aDocShell.QueryInterface(Ci.nsIDocShellTreeItem);
for (var i = 0; i < node.childCount; ++i) {
var docShell = node.getChildAt(i);
docShell = this._findChildShell(docShell, aSoughtShell);
if (docShell == aSoughtShell) {
return docShell;
}
}
return null;
},
_getBrowser(aDocShell) {
for (let browser of gBrowser.browsers) {
if (this._findChildShell(browser.docShell, aDocShell)) {
return browser;
}
}
return null;
},
pendingInstalls: new WeakMap(),
showInstallConfirmation(browser, installInfo, height = undefined) {
// If the confirmation notification is already open cache the installInfo
// and the new confirmation will be shown later
if (
PopupNotifications.getNotification("addon-install-confirmation", browser)
) {
let pending = this.pendingInstalls.get(browser);
if (pending) {
pending.push(installInfo);
} else {
this.pendingInstalls.set(browser, [installInfo]);
}
return;
}
let showNextConfirmation = () => {
// Make sure the browser is still alive.
if (!gBrowser.browsers.includes(browser)) {
return;
}
let pending = this.pendingInstalls.get(browser);
if (pending && pending.length) {
this.showInstallConfirmation(browser, pending.shift());
}
};
// If all installs have already been cancelled in some way then just show
// the next confirmation
if (
installInfo.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED)
) {
showNextConfirmation();
return;
}
// Make notifications persistent
var options = {
displayURI: installInfo.originatingURI,
persistent: true,
hideClose: true,
};
if (gUnifiedExtensions.isEnabled) {
options.popupOptions = {
position: "bottomright topright",
};
}
let acceptInstallation = () => {
for (let install of installInfo.installs) {
install.install();
}
installInfo = null;
Services.telemetry
.getHistogramById("SECURITY_UI")
.add(
Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH
);
};
let cancelInstallation = () => {
if (installInfo) {
for (let install of installInfo.installs) {
// The notification may have been closed because the add-ons got
// cancelled elsewhere, only try to cancel those that are still
// pending install.
if (install.state != AddonManager.STATE_CANCELLED) {
install.cancel();
}
}
}
showNextConfirmation();
};
let unsigned = installInfo.installs.filter(
i => i.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
);
let someUnsigned =
!!unsigned.length && unsigned.length < installInfo.installs.length;
options.eventCallback = aEvent => {
switch (aEvent) {
case "removed":
cancelInstallation();
break;
case "shown":
let addonList = document.getElementById(
"addon-install-confirmation-content"
);
while (addonList.firstChild) {
addonList.firstChild.remove();
}
for (let install of installInfo.installs) {
let container = document.createXULElement("hbox");
let name = document.createXULElement("label");
name.setAttribute("value", install.addon.name);
name.setAttribute("class", "addon-install-confirmation-name");
container.appendChild(name);
if (
someUnsigned &&
install.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
) {
let unsignedLabel = document.createXULElement("label");
unsignedLabel.setAttribute(
"value",
gNavigatorBundle.getString("addonInstall.unsigned")
);
unsignedLabel.setAttribute(
"class",
"addon-install-confirmation-unsigned"
);
container.appendChild(unsignedLabel);
}
addonList.appendChild(container);
}
break;
}
};
options.learnMoreURL = Services.urlFormatter.formatURLPref(
"app.support.baseURL"
);
let messageString;
let notification = document.getElementById(
"addon-install-confirmation-notification"
);
if (unsigned.length == installInfo.installs.length) {
// None of the add-ons are verified
messageString = gNavigatorBundle.getString(
"addonConfirmInstallUnsigned.message"
);
notification.setAttribute("warning", "true");
options.learnMoreURL += "unsigned-addons";
} else if (!unsigned.length) {
// All add-ons are verified or don't need to be verified
messageString = gNavigatorBundle.getString("addonConfirmInstall.message");
notification.removeAttribute("warning");
options.learnMoreURL += "find-and-install-add-ons";
} else {
// Some of the add-ons are unverified, the list of names will indicate
// which
messageString = gNavigatorBundle.getString(
"addonConfirmInstallSomeUnsigned.message"
);
notification.setAttribute("warning", "true");
options.learnMoreURL += "unsigned-addons";
}
let brandBundle = document.getElementById("bundle_brand");
let brandShortName = brandBundle.getString("brandShortName");
messageString = PluralForm.get(installInfo.installs.length, messageString);
messageString = messageString.replace("#1", brandShortName);
messageString = messageString.replace("#2", installInfo.installs.length);
let action = {
label: gNavigatorBundle.getString("addonInstall.acceptButton2.label"),
accessKey: gNavigatorBundle.getString(
"addonInstall.acceptButton2.accesskey"
),
callback: acceptInstallation,
};
let secondaryAction = {
label: gNavigatorBundle.getString("addonInstall.cancelButton.label"),
accessKey: gNavigatorBundle.getString(
"addonInstall.cancelButton.accesskey"
),
callback: () => {},
};
if (height) {
notification.style.minHeight = height + "px";
}
let tab = gBrowser.getTabForBrowser(browser);
if (tab) {
gBrowser.selectedTab = tab;
}
let popup = PopupNotifications.show(
browser,
"addon-install-confirmation",
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
action,
[secondaryAction],
options
);
removeNotificationOnEnd(popup, installInfo.installs);
Services.telemetry
.getHistogramById("SECURITY_UI")
.add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
},
// IDs of addon install related notifications
NOTIFICATION_IDS: [
"addon-install-blocked",
"addon-install-complete",
"addon-install-confirmation",
"addon-install-failed",
"addon-install-origin-blocked",
"addon-install-webapi-blocked",
"addon-install-policy-blocked",
"addon-progress",
"addon-webext-permissions",
"xpinstall-disabled",
],
/**
* Remove all opened addon installation notifications
*
* @param {*} browser - Browser to remove notifications for
* @returns {boolean} - true if notifications have been removed.
*/
removeAllNotifications(browser) {
let notifications = this.NOTIFICATION_IDS.map(id =>
PopupNotifications.getNotification(id, browser)
).filter(notification => notification != null);
PopupNotifications.remove(notifications, true);
return !!notifications.length;
},
logWarningFullScreenInstallBlocked() {
// If notifications have been removed, log a warning to the website console
let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(
Ci.nsIScriptError
);
let message = gBrowserBundle.GetStringFromName(
"addonInstallFullScreenBlocked"
);
consoleMsg.initWithWindowID(
message,
gBrowser.currentURI.spec,
null,
0,
0,
Ci.nsIScriptError.warningFlag,
"FullScreen",
gBrowser.selectedBrowser.innerWindowID
);
Services.console.logMessage(consoleMsg);
},
observe(aSubject, aTopic, aData) {
var brandBundle = document.getElementById("bundle_brand");
var installInfo = aSubject.wrappedJSObject;
var browser = installInfo.browser;
// Make sure the browser is still alive.
if (!browser || !gBrowser.browsers.includes(browser)) {
return;
}
var messageString, action;
var brandShortName = brandBundle.getString("brandShortName");
var notificationID = aTopic;
// Make notifications persistent
var options = {
displayURI: installInfo.originatingURI,
persistent: true,
hideClose: true,
timeout: Date.now() + 30000,
};
if (gUnifiedExtensions.isEnabled) {
options.popupOptions = {
position: "bottomright topright",
};
}
switch (aTopic) {
case "addon-install-disabled": {
notificationID = "xpinstall-disabled";
let secondaryActions = null;
if (Services.prefs.prefIsLocked("xpinstall.enabled")) {
messageString = gNavigatorBundle.getString(
"xpinstallDisabledMessageLocked"
);
} else {
messageString = gNavigatorBundle.getString(
"xpinstallDisabledMessage"
);
action = {
label: gNavigatorBundle.getString("xpinstallDisabledButton"),
accessKey: gNavigatorBundle.getString(
"xpinstallDisabledButton.accesskey"
),
callback: function editPrefs() {
Services.prefs.setBoolPref("xpinstall.enabled", true);
},
};
secondaryActions = [
{
label: gNavigatorBundle.getString(
"addonInstall.cancelButton.label"
),
accessKey: gNavigatorBundle.getString(
"addonInstall.cancelButton.accesskey"
),
callback: () => {},
},
];
}
PopupNotifications.show(
browser,
notificationID,
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
action,
secondaryActions,
options
);
break;
}
case "addon-install-fullscreen-blocked": {
// AddonManager denied installation because we are in DOM fullscreen
this.logWarningFullScreenInstallBlocked();
break;
}
case "addon-install-webapi-blocked":
case "addon-install-policy-blocked":
case "addon-install-origin-blocked": {
if (aTopic == "addon-install-policy-blocked") {
messageString = gNavigatorBundle.getString(
"addonDomainBlockedByPolicy"
);
} else {
messageString = gNavigatorBundle.getFormattedString(
"xpinstallPromptMessage",
[brandShortName]
);
}
if (Services.policies) {
let extensionSettings = Services.policies.getExtensionSettings("*");
if (
extensionSettings &&
"blocked_install_message" in extensionSettings
) {
messageString += " " + extensionSettings.blocked_install_message;
}
}
options.removeOnDismissal = true;
options.persistent = false;
let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
secHistogram.add(
Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
);
let popup = PopupNotifications.show(
browser,
notificationID,
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
null,
null,
options
);
removeNotificationOnEnd(popup, installInfo.installs);
break;
}
case "addon-install-blocked": {
// 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 = PopupNotifications.getNotification(
"addon-progress",
browser
);
if (progressNotification) {
progressNotification.remove();
}
let hasHost = !!options.displayURI;
if (hasHost) {
messageString = gNavigatorBundle.getFormattedString(
"xpinstallPromptMessage.header",
["<>"]
);
options.name = options.displayURI.displayHost;
} else {
messageString = gNavigatorBundle.getString(
"xpinstallPromptMessage.header.unknown"
);
}
// displayURI becomes it's own label, so we unset it for this panel. It will become part of the
// messageString above.
options.displayURI = undefined;
options.eventCallback = topic => {
if (topic !== "showing") {
return;
}
let doc = browser.ownerDocument;
let message = doc.getElementById("addon-install-blocked-message");
// We must remove any prior use of this panel message in this window.
while (message.firstChild) {
message.firstChild.remove();
}
if (hasHost) {
let text = gNavigatorBundle.getString(
"xpinstallPromptMessage.message"
);
let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b");
b.textContent = options.name;
let fragment = BrowserUIUtils.getLocalizedFragment(doc, text, b);
message.appendChild(fragment);
} else {
message.textContent = gNavigatorBundle.getString(
"xpinstallPromptMessage.message.unknown"
);
}
let learnMore = doc.getElementById("addon-install-blocked-info");
learnMore.textContent = gNavigatorBundle.getString(
"xpinstallPromptMessage.learnMore"
);
learnMore.setAttribute(
"href",
Services.urlFormatter.formatURLPref("app.support.baseURL") +
"unlisted-extensions-risks"
);
};
let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
action = {
label: gNavigatorBundle.getString("xpinstallPromptMessage.install"),
accessKey: gNavigatorBundle.getString(
"xpinstallPromptMessage.install.accesskey"
),
callback() {
secHistogram.add(
Ci.nsISecurityUITelemetry
.WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH
);
installInfo.install();
},
};
let dontAllowAction = {
label: gNavigatorBundle.getString("xpinstallPromptMessage.dontAllow"),
accessKey: gNavigatorBundle.getString(
"xpinstallPromptMessage.dontAllow.accesskey"
),
callback: () => {
for (let install of installInfo.installs) {
if (install.state != AddonManager.STATE_CANCELLED) {
install.cancel();
}
}
if (installInfo.cancel) {
installInfo.cancel();
}
},
};
let neverAllowAction = {
label: gNavigatorBundle.getString(
"xpinstallPromptMessage.neverAllow"
),
accessKey: gNavigatorBundle.getString(
"xpinstallPromptMessage.neverAllow.accesskey"
),
callback: () => {
SitePermissions.setForPrincipal(
browser.contentPrincipal,
"install",
SitePermissions.BLOCK
);
for (let install of installInfo.installs) {
if (install.state != AddonManager.STATE_CANCELLED) {
install.cancel();
}
}
if (installInfo.cancel) {
installInfo.cancel();
}
},
};
secHistogram.add(
Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
);
let popup = PopupNotifications.show(
browser,
notificationID,
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
action,
[dontAllowAction, neverAllowAction],
options
);
removeNotificationOnEnd(popup, installInfo.installs);
break;
}
case "addon-install-started": {
let needsDownload = function needsDownload(aInstall) {
return aInstall.state != AddonManager.STATE_DOWNLOADED;
};
// If all installs have already been downloaded then there is no need to
// show the download progress
if (!installInfo.installs.some(needsDownload)) {
return;
}
notificationID = "addon-progress";
messageString = gNavigatorBundle.getString(
"addonDownloadingAndVerifying"
);
messageString = PluralForm.get(
installInfo.installs.length,
messageString
);
messageString = messageString.replace(
"#1",
installInfo.installs.length
);
options.installs = installInfo.installs;
options.contentWindow = browser.contentWindow;
options.sourceURI = browser.currentURI;
options.eventCallback = function(aEvent) {
switch (aEvent) {
case "removed":
options.contentWindow = null;
options.sourceURI = null;
break;
}
};
action = {
label: gNavigatorBundle.getString("addonInstall.acceptButton2.label"),
accessKey: gNavigatorBundle.getString(
"addonInstall.acceptButton2.accesskey"
),
disabled: true,
callback: () => {},
};
let secondaryAction = {
label: gNavigatorBundle.getString("addonInstall.cancelButton.label"),
accessKey: gNavigatorBundle.getString(
"addonInstall.cancelButton.accesskey"
),
callback: () => {
for (let install of installInfo.installs) {
if (install.state != AddonManager.STATE_CANCELLED) {
install.cancel();
}
}
},
};
let notification = PopupNotifications.show(
browser,
notificationID,
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
action,
[secondaryAction],
options
);
notification._startTime = Date.now();
break;
}
case "addon-install-failed": {
options.removeOnDismissal = true;
options.persistent = false;
// TODO This isn't terribly ideal for the multiple failure case
for (let install of installInfo.installs) {
let host;
try {
host = options.displayURI.host;
} catch (e) {
// displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
}
if (!host) {
host =
install.sourceURI instanceof Ci.nsIStandardURL &&
install.sourceURI.host;
}
// Construct the l10n ID for the error, e.g. "addonInstallError-3"
let error =
host || install.error == 0
? "addonInstallError"
: "addonLocalInstallError";
let args;
if (install.error < 0) {
// Append the error code for the installation failure to get the
// matching translation of the error. The error code is defined in
// AddonManager's _errors Map. Not all error codes listed there are
// translated, since errors that are only triggered during updates
// will never reach this code.
error += install.error;
args = [brandShortName, install.name];
} else if (
install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED
) {
error += "Blocklisted";
args = [install.name];
} else {
error += "Incompatible";
args = [brandShortName, Services.appinfo.version, install.name];
}
if (
install.addon &&
!Services.policies.mayInstallAddon(install.addon)
) {
error = "addonInstallBlockedByPolicy";
let extensionSettings = Services.policies.getExtensionSettings(
install.addon.id
);
let message = "";
if (
extensionSettings &&
"blocked_install_message" in extensionSettings
) {
message = " " + extensionSettings.blocked_install_message;
}
args = [install.name, install.addon.id, message];
}
// Add Learn More link when refusing to install an unsigned add-on
if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) {
options.learnMoreURL =
Services.urlFormatter.formatURLPref("app.support.baseURL") +
"unsigned-addons";
}
messageString = gNavigatorBundle.getFormattedString(error, args);
PopupNotifications.show(
browser,
notificationID,
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
action,
null,
options
);
// Can't have multiple notifications with the same ID, so stop here.
break;
}
this._removeProgressNotification(browser);
break;
}
case "addon-install-confirmation": {
let showNotification = () => {
let height = undefined;
if (PopupNotifications.isPanelOpen) {
let rect = document
.getElementById("addon-progress-notification")
.getBoundingClientRect();
height = rect.height;
}
this._removeProgressNotification(browser);
this.showInstallConfirmation(browser, installInfo, height);
};
let progressNotification = PopupNotifications.getNotification(
"addon-progress",
browser
);
if (progressNotification) {
let downloadDuration = Date.now() - progressNotification._startTime;
let securityDelay =
Services.prefs.getIntPref("security.dialog_enable_delay") -
downloadDuration;
if (securityDelay > 0) {
setTimeout(() => {
// The download may have been cancelled during the security delay
if (
PopupNotifications.getNotification("addon-progress", browser)
) {
showNotification();
}
}, securityDelay);
break;
}
}
showNotification();
break;
}
case "addon-install-complete": {
let secondaryActions = null;
let numAddons = installInfo.installs.length;
if (numAddons == 1) {
messageString = gNavigatorBundle.getFormattedString(
"addonInstalled",
[installInfo.installs[0].name]
);
} else {
messageString = gNavigatorBundle.getString("addonsGenericInstalled");
messageString = PluralForm.get(numAddons, messageString);
messageString = messageString.replace("#1", numAddons);
}
action = null;
options.removeOnDismissal = true;
options.persistent = false;
PopupNotifications.show(
browser,
notificationID,
messageString,
gUnifiedExtensions.getPopupAnchorID(browser, window),
action,
secondaryActions,
options
);
break;
}
}
},
_removeProgressNotification(aBrowser) {
let notification = PopupNotifications.getNotification(
"addon-progress",
aBrowser
);
if (notification) {
notification.remove();
}
},
};
var gExtensionsNotifications = {
initialized: false,
init() {
this.updateAlerts();
this.boundUpdate = this.updateAlerts.bind(this);
ExtensionsUI.on("change", this.boundUpdate);
this.initialized = true;
},
uninit() {
// uninit() can race ahead of init() in some cases, if that happens,
// we have no handler to remove.
if (!this.initialized) {
return;
}
ExtensionsUI.off("change", this.boundUpdate);
},
_createAddonButton(text, icon, callback) {
let button = document.createXULElement("toolbarbutton");
button.setAttribute("wrap", "true");
button.setAttribute("label", text);
button.setAttribute("tooltiptext", text);
const DEFAULT_EXTENSION_ICON =
"chrome://mozapps/skin/extensions/extensionGeneric.svg";
button.setAttribute("image", icon || DEFAULT_EXTENSION_ICON);
button.className = "addon-banner-item subviewbutton";
button.addEventListener("command", callback);
PanelUI.addonNotificationContainer.appendChild(button);
},
updateAlerts() {
let sideloaded = ExtensionsUI.sideloaded;
let updates = ExtensionsUI.updates;
let container = PanelUI.addonNotificationContainer;
while (container.firstChild) {
container.firstChild.remove();
}
let items = 0;
for (let update of updates) {
if (++items > 4) {
break;
}
let text = gNavigatorBundle.getFormattedString(
"webextPerms.updateMenuItem",
[update.addon.name]
);
this._createAddonButton(text, update.addon.iconURL, evt => {
ExtensionsUI.showUpdate(gBrowser, update);
});
}
let appName;
for (let addon of sideloaded) {
if (++items > 4) {
break;
}
if (!appName) {
let brandBundle = document.getElementById("bundle_brand");
appName = brandBundle.getString("brandShortName");
}
let text = gNavigatorBundle.getFormattedString(
"webextPerms.sideloadMenuItem",
[addon.name, appName]
);
this._createAddonButton(text, addon.iconURL, evt => {
// We need to hide the main menu manually because the toolbarbutton is
// removed immediately while processing this event, and PanelUI is
// unable to identify which panel should be closed automatically.
PanelUI.hide();
ExtensionsUI.showSideloaded(gBrowser, addon);
});
}
},
};
var BrowserAddonUI = {
async promptRemoveExtension(addon) {
let { name } = addon;
let title = await document.l10n.formatValue("addon-removal-title", {
name,
});
let { getFormattedString, getString } = gNavigatorBundle;
let btnTitle = getString("webext.remove.confirmation.button");
let {
BUTTON_TITLE_IS_STRING: titleString,
BUTTON_TITLE_CANCEL: titleCancel,
BUTTON_POS_0,
BUTTON_POS_1,
confirmEx,
} = Services.prompt;
let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel;
let checkboxState = { value: false };
let checkboxMessage = null;
// Enable abuse report checkbox in the remove extension dialog,
// if enabled by the about:config prefs and the addon type
// is currently supported.
if (
gAddonAbuseReportEnabled &&
["extension", "theme"].includes(addon.type)
) {
checkboxMessage = await document.l10n.formatValue(
"addon-removal-abuse-report-checkbox"
);
}
let message = null;
if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) {
message = getFormattedString("webext.remove.confirmation.message", [
name,
document.getElementById("bundle_brand").getString("brandShorterName"),
]);
}
let result = confirmEx(
window,
title,
message,
btnFlags,
btnTitle,
/* button1 */ null,
/* button2 */ null,
checkboxMessage,
checkboxState
);
return { remove: result === 0, report: checkboxState.value };
},
async reportAddon(addonId, reportEntryPoint) {
let addon = addonId && (await AddonManager.getAddonByID(addonId));
if (!addon) {
return;
}
const win = await BrowserOpenAddonsMgr("addons://list/extension");
win.openAbuseReport({ addonId, reportEntryPoint });
},
async removeAddon(addonId, eventObject) {
let addon = addonId && (await AddonManager.getAddonByID(addonId));
if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
return;
}
let { remove, report } = await this.promptRemoveExtension(addon);
AMTelemetry.recordActionEvent({
object: eventObject,
action: "uninstall",
value: remove ? "accepted" : "cancelled",
extra: { addonId },
});
if (remove) {
// Leave the extension in pending uninstall if we are also reporting the
// add-on.
await addon.uninstall(report);
if (report) {
await this.reportAddon(addon.id, "uninstall");
}
}
},
async manageAddon(addonId, eventObject) {
let addon = addonId && (await AddonManager.getAddonByID(addonId));
if (!addon) {
return;
}
BrowserOpenAddonsMgr("addons://detail/" + encodeURIComponent(addon.id));
AMTelemetry.recordActionEvent({
object: eventObject,
action: "manage",
extra: { addonId: addon.id },
});
},
};
/**
* The `unified-extensions-item` custom element is used to manage an extension
* in the list of extensions, which is displayed when users click the unified
* extensions (toolbar) button.
*
* This custom element must be initialized with `setAddon()`:
*
* ```
* let item = document.createElement("unified-extensions-item");
* item.setAddon(addon);
* document.body.appendChild(item);
* ```
*/
customElements.define(
"unified-extensions-item",
class extends HTMLElement {
/**
* Set the add-on for this item. The item will be populated based on the
* add-on when it is rendered into the DOM.
*
* @param {AddonWrapper} addon The add-on to use.
*/
setAddon(addon) {
this.addon = addon;
}
connectedCallback() {
if (this._openMenuButton) {
return;
}
const template = document.getElementById(
"unified-extensions-item-template"
);
this.appendChild(template.content.cloneNode(true));
this._actionButton = this.querySelector(
".unified-extensions-item-action"
);
this._openMenuButton = this.querySelector(
".unified-extensions-item-open-menu"
);
this._openMenuButton.addEventListener("blur", this);
this._openMenuButton.addEventListener("focus", this);
this.addEventListener("command", this);
this.addEventListener("mouseout", this);
this.addEventListener("mouseover", this);
this.render();
}
handleEvent(event) {
const { target } = event;
switch (event.type) {
case "command":
if (target === this._openMenuButton) {
const popup = target.ownerDocument.getElementById(
"unified-extensions-context-menu"
);
popup.openPopup(
target,
"after_end",
0,
0,
true /* isContextMenu */,
false /* attributesOverride */,
event
);
} else if (target === this._actionButton) {
const extension = WebExtensionPolicy.getByID(this.addon.id)
?.extension;
if (!extension) {
return;
}
const win = event.target.ownerGlobal;
const tab = win.gBrowser.selectedTab;
extension.tabManager.addActiveTabPermission(tab);
extension.tabManager.activateScripts(tab);
}
break;
case "blur":
case "mouseout":
if (target === this._openMenuButton) {
this.removeAttribute("secondary-button-hovered");
} else if (target === this._actionButton) {
this._updateStateMessage();
}
break;
case "focus":
case "mouseover":
if (target === this._openMenuButton) {
this.setAttribute("secondary-button-hovered", true);
} else if (target === this._actionButton) {
this._updateStateMessage({ hover: true });
}
break;
}
}
async _updateStateMessage({ hover = false } = {}) {
const policy = WebExtensionPolicy.getByID(this.addon.id);
const messages = lazy.OriginControls.getStateMessageIDs(
policy,
this.ownerGlobal.gBrowser.currentURI
);
if (!messages) {
return;
}
const messageElement = this.querySelector(
".unified-extensions-item-message-default"
);
// We only want to adjust the height of an item in the panel when we
// first draw it, and not on hover (even if the hover message is longer,
// which shouldn't happen in practice but even if it was, we don't want
// to change the height on hover).
let adjustMinHeight = false;
if (hover && messages.onHover) {
this.ownerDocument.l10n.setAttributes(messageElement, messages.onHover);
} else if (messages.default) {
this.ownerDocument.l10n.setAttributes(messageElement, messages.default);
adjustMinHeight = true;
}
await document.l10n.translateElements([messageElement]);
if (adjustMinHeight) {
const contentsElement = this.querySelector(
".unified-extensions-item-contents"
);
const { height } = getComputedStyle(contentsElement);
contentsElement.style.minHeight = height;
}
}
_hasAction() {
const policy = WebExtensionPolicy.getByID(this.addon.id);
const state = lazy.OriginControls.getState(
policy,
this.ownerGlobal.gBrowser.currentURI
);
return state && state.whenClicked && !state.hasAccess;
}
render() {
if (!this.addon) {
throw new Error(
"unified-extensions-item requires an add-on, forgot to call setAddon()?"
);
}
this.setAttribute("extension-id", this.addon.id);
let policy = WebExtensionPolicy.getByID(this.addon.id);
this.setAttribute(
"attention",
lazy.OriginControls.getAttention(policy, this.ownerGlobal)
);
this.querySelector(
".unified-extensions-item-name"
).textContent = this.addon.name;
const iconURL = AddonManager.getPreferredIconURL(this.addon, 32, window);
if (iconURL) {
this.querySelector(".unified-extensions-item-icon").setAttribute(
"src",
iconURL
);
}
this._actionButton.disabled = !this._hasAction();
this._openMenuButton.dataset.extensionId = this.addon.id;
this._openMenuButton.setAttribute(
"data-l10n-args",
JSON.stringify({ extensionName: this.addon.name })
);
this._updateStateMessage();
}
}
);
// We must declare `gUnifiedExtensions` using `var` below to avoid a
// "redeclaration" syntax error.
var gUnifiedExtensions = {
_initialized: false,
init() {
if (this._initialized) {
return;
}
if (this.isEnabled) {
MozXULElement.insertFTLIfNeeded("preview/originControls.ftl");
MozXULElement.insertFTLIfNeeded("preview/unifiedExtensions.ftl");
this._button = document.getElementById("unified-extensions-button");
// TODO: Bug 1778684 - Auto-hide button when there is no active extension.
this._button.hidden = false;
document
.getElementById("nav-bar")
.setAttribute("unifiedextensionsbuttonshown", true);
gBrowser.addTabsProgressListener(this);
window.addEventListener("TabSelect", () => this.updateAttention());
this.permListener = () => this.updateAttention();
lazy.ExtensionPermissions.addListener(this.permListener);
}
this._initialized = true;
},
uninit() {
if (this.permListener) {
lazy.ExtensionPermissions.removeListener(this.permListener);
this.permListener = null;
}
},
get isEnabled() {
return Services.prefs.getBoolPref(
"extensions.unifiedExtensions.enabled",
false
);
},
onLocationChange(browser, webProgress, _request, _uri, flags) {
// Only update on top-level cross-document navigations in the selected tab.
if (
webProgress.isTopLevel &&
browser === gBrowser.selectedBrowser &&
!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
) {
this.updateAttention();
}
},
// Update the attention indicator for the whole unified extensions button.
async updateAttention() {
for (let addon of await this.getActiveExtensions()) {
let policy = WebExtensionPolicy.getByID(addon.id);
let widget = this.browserActionFor(policy)?.widget;
// Only show for extensions which are not already visible in the toolbar.
if (!widget || widget.areaType !== CustomizableUI.TYPE_TOOLBAR) {
if (lazy.OriginControls.getAttention(policy, window)) {
this.button.setAttribute("attention", true);
return;
}
}
}
this.button.setAttribute("attention", false);
},
getPopupAnchorID(aBrowser, aWindow) {
if (this.isEnabled) {
const anchorID = "unified-extensions-button";
const attr = anchorID + "popupnotificationanchor";
if (!aBrowser[attr]) {
// A hacky way of setting the popup anchor outside the usual url bar
// icon box, similar to how it was done for CFR.
// See: https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/PopupNotifications.sys.mjs#40
aBrowser[attr] = aWindow.document.getElementById(
anchorID
// Anchor on the toolbar icon to position the popup right below the
// button.
).firstElementChild;
}
return anchorID;
}
return "addons-notification-icon";
},
get button() {
return this._button;
},
/**
* Gets a list of active AddonWrapper instances of type "extension", sorted
* alphabetically based on add-on's names.
*
* @return {Array<AddonWrapper>} An array of active extensions.
*/
async getActiveExtensions() {
// TODO: Bug 1778682 - Use a new method on `AddonManager` so that we get
// the same list of extensions as the one in `about:addons`.
// We only want to display active and visible extensions, and we want to
// list them alphabetically.
let addons = await AddonManager.getAddonsByTypes(["extension"]);
addons = addons.filter(addon => !addon.hidden && addon.isActive);
addons.sort((a1, a2) => a1.name.localeCompare(a2.name));
return addons;
},
handleEvent(event) {
switch (event.type) {
case "ViewShowing": {
this.onPanelViewShowing(event.target);
break;
}
case "ViewHiding": {
this.onPanelViewHiding(event.target);
}
}
},
async onPanelViewShowing(panelview) {
const list = panelview.querySelector(".unified-extensions-list");
const extensions = await this.getActiveExtensions();
for (const extension of extensions) {
const item = document.createElement("unified-extensions-item");
item.setAddon(extension);
list.appendChild(item);
}
},
onPanelViewHiding(panelview) {
const list = panelview.querySelector(".unified-extensions-list");
while (list.lastChild) {
list.lastChild.remove();
}
},
_panel: null,
get panel() {
// Lazy load the unified-extensions-panel panel the first time we need to display it.
if (!this._panel) {
let template = document.getElementById(
"unified-extensions-panel-template"
);
template.replaceWith(template.content);
this._panel = document.getElementById("unified-extensions-panel");
CustomizableUI.addPanelCloseListeners(this._panel);
}
return this._panel;
},
async togglePanel(aEvent) {
if (!CustomizationHandler.isCustomizing()) {
let panel = this.panel;
// The button should directly open `about:addons` when there is no active
// extension to show in the panel.
if ((await this.getActiveExtensions()).length === 0) {
await BrowserOpenAddonsMgr("addons://discover/");
return;
}
if (!this._listView) {
this._listView = PanelMultiView.getViewNode(
document,
"unified-extensions-view"
);
this._listView.addEventListener("ViewShowing", this);
this._listView.addEventListener("ViewHiding", this);
// Lazy-load the l10n strings.
document
.getElementById("unified-extensions-context-menu")
.querySelectorAll("[data-lazy-l10n-id]")
.forEach(el => {
el.setAttribute(
"data-l10n-id",
el.getAttribute("data-lazy-l10n-id")
);
el.removeAttribute("data-lazy-l10n-id");
});
}
if (this._button.open) {
PanelMultiView.hidePopup(panel);
this._button.open = false;
} else {
panel.hidden = false;
PanelMultiView.openPopup(panel, this._button, {
triggerEvent: aEvent,
});
}
}
// We always dispatch an event (useful for testing purposes).
window.dispatchEvent(new CustomEvent("UnifiedExtensionsTogglePanel"));
},
async updateContextMenu(menu, event) {
// When the context menu is open, `onpopupshowing` is called when menu
// items open sub-menus. We don't want to update the context menu in this
// case.
if (event.target.id !== "unified-extensions-context-menu") {
return;
}
const id = this._getExtensionId(menu);
const addon = await AddonManager.getAddonByID(id);
const removeButton = menu.querySelector(
".unified-extensions-context-menu-remove-extension"
);
const reportButton = menu.querySelector(
".unified-extensions-context-menu-report-extension"
);
reportButton.hidden = !gAddonAbuseReportEnabled;
removeButton.disabled = !(
addon.permissions & AddonManager.PERM_CAN_UNINSTALL
);
ExtensionsUI.originControlsMenu(menu, id);
const browserAction = this.browserActionFor(WebExtensionPolicy.getByID(id));
if (browserAction) {
browserAction.updateContextMenu(menu);
}
},
browserActionFor(policy) {
// Ideally, we wouldn't do that because `browserActionFor()` will only be
// defined in `global` when at least one extension has required loading the
// `ext-browserAction` code.
let method = lazy.ExtensionParent.apiManager.global.browserActionFor;
return method?.(policy?.extension);
},
async manageExtension(menu) {
const id = this._getExtensionId(menu);
await this.togglePanel();
await BrowserAddonUI.manageAddon(id, "unifiedExtensions");
},
async removeExtension(menu) {
const id = this._getExtensionId(menu);
await this.togglePanel();
await BrowserAddonUI.removeAddon(id, "unifiedExtensions");
},
async reportExtension(menu) {
const id = this._getExtensionId(menu);
await this.togglePanel();
await BrowserAddonUI.reportAddon(id, "unified_context_menu");
},
_getExtensionId(menu) {
const { triggerNode } = menu;
return triggerNode.dataset.extensionId;
},
};