gecko-dev/browser/modules/ExtensionsUI.jsm

605 строки
18 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/. */
"use strict";
var EXPORTED_SYMBOLS = ["ExtensionsUI"];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { EventEmitter } = ChromeUtils.import(
"resource://gre/modules/EventEmitter.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
AddonManager: "resource://gre/modules/AddonManager.jsm",
AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
AMTelemetry: "resource://gre/modules/AddonManager.jsm",
AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.jsm",
ExtensionData: "resource://gre/modules/Extension.jsm",
ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
Services: "resource://gre/modules/Services.jsm",
});
XPCOMUtils.defineLazyPreferenceGetter(
this,
"WEBEXT_PERMISSION_PROMPTS",
"extensions.webextPermissionPrompts",
false
);
const DEFAULT_EXTENSION_ICON =
"chrome://mozapps/skin/extensions/extensionGeneric.svg";
const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties";
const BRAND_PROPERTIES = "chrome://branding/locale/brand.properties";
const HTML_NS = "http://www.w3.org/1999/xhtml";
function getTabBrowser(browser) {
while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
browser = browser.ownerGlobal.docShell.chromeEventHandler;
}
if (browser.getAttribute("webextension-view-type") == "popup") {
browser = browser.ownerGlobal.gBrowser.selectedBrowser;
}
return { browser, window: browser.ownerGlobal };
}
var ExtensionsUI = {
sideloaded: new Set(),
updates: new Set(),
sideloadListener: null,
histogram: null,
pendingNotifications: new WeakMap(),
async init() {
this.histogram = Services.telemetry.getHistogramById(
"EXTENSION_INSTALL_PROMPT_RESULT"
);
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");
await Services.wm.getMostRecentWindow("navigator:browser")
.delayedStartupPromise;
this._checkForSideloaded();
},
async _checkForSideloaded() {
let sideloaded = await 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 (WEBEXT_PERMISSION_PROMPTS) {
if (!this.sideloadListener) {
this.sideloadListener = {
onEnabled: addon => {
if (!this.sideloaded.has(addon)) {
return;
}
this.sideloaded.delete(addon);
this._updateNotifications();
if (this.sideloaded.size == 0) {
AddonManager.removeAddonListener(this.sideloadListener);
this.sideloadListener = null;
}
},
};
AddonManager.addAddonListener(this.sideloadListener);
}
for (let addon of sideloaded) {
this.sideloaded.add(addon);
}
this._updateNotifications();
}
},
_updateNotifications() {
if (this.sideloaded.size + this.updates.size == 0) {
AppMenuNotifications.removeNotification("addon-alert");
} else {
AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
}
this.emit("change");
},
showAddonsManager(tabbrowser, strings, icon, histkey) {
let global = tabbrowser.selectedBrowser.ownerGlobal;
return global
.BrowserOpenAddonsMgr("addons://list/extension")
.then(aomWin => {
let aomBrowser = aomWin.docShell.chromeEventHandler;
return this.showPermissionsPrompt(aomBrowser, strings, icon, histkey);
});
},
showSideloaded(tabbrowser, addon) {
addon.markAsSeen();
this.sideloaded.delete(addon);
this._updateNotifications();
let strings = this._buildStrings({
addon,
permissions: addon.userPermissions,
type: "sideload",
});
AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
num_strings: strings.msgs.length,
});
this.showAddonsManager(tabbrowser, strings, addon.iconURL, "sideload").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 (
addon.permissions &
AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
) {
this.showInstallNotification(tabbrowser.selectedBrowser, addon);
}
}
this.emit("sideload-response");
}
);
},
showUpdate(browser, info) {
AMTelemetry.recordInstallEvent(info.install, {
step: "permissions_prompt",
num_strings: info.strings.msgs.length,
});
this.showAddonsManager(
browser,
info.strings,
info.addon.iconURL,
"update"
).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, data) {
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 <= 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://browser/skin/warning.svg"
: info.icon;
let histkey;
if (info.type == "sideload") {
histkey = "sideload";
} else if (info.type == "update") {
histkey = "update";
} else if (info.source == "AMO") {
histkey = "installAmo";
} else if (info.source == "local") {
histkey = "installLocal";
} else {
histkey = "installWeb";
}
if (info.type == "sideload") {
AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
num_strings: strings.msgs.length,
});
} else {
AMTelemetry.recordInstallEvent(info.install, {
step: "permissions_prompt",
num_strings: strings.msgs.length,
});
}
this.showPermissionsPrompt(browser, strings, icon, histkey).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;
let bundle = Services.strings.createBundle(BROWSER_PROPERTIES);
let strings = {};
strings.acceptText = bundle.GetStringFromName(
"webext.defaultSearchYes.label"
);
strings.acceptKey = bundle.GetStringFromName(
"webext.defaultSearchYes.accessKey"
);
strings.cancelText = bundle.GetStringFromName(
"webext.defaultSearchNo.label"
);
strings.cancelKey = bundle.GetStringFromName(
"webext.defaultSearchNo.accessKey"
);
strings.addonName = name;
strings.text = bundle.formatStringFromName(
"webext.defaultSearch.description",
["<>", currentEngine, newEngine]
);
this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
}
},
// Create a set of formatted strings for a permission prompt
_buildStrings(info) {
let bundle = Services.strings.createBundle(BROWSER_PROPERTIES);
let brandBundle = Services.strings.createBundle(BRAND_PROPERTIES);
let appName = brandBundle.GetStringFromName("brandShortName");
let info2 = Object.assign({ appName }, info);
let strings = ExtensionData.formatPermissionStrings(info2, bundle, {
collapseOrigins: true,
});
strings.addonName = info.addon.name;
strings.learnMore = bundle.GetStringFromName("webextPerms.learnMore");
return strings;
},
async showPermissionsPrompt(target, strings, icon, histkey) {
let { browser, window } = getTabBrowser(target);
// 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) {
let doc = this.browser.ownerDocument;
if (topic == "showing") {
let textEl = doc.getElementById("addon-webext-perm-text");
textEl.textContent = strings.text;
textEl.hidden = !strings.text;
let listIntroEl = doc.getElementById("addon-webext-perm-intro");
listIntroEl.textContent = strings.listIntro;
listIntroEl.hidden = !strings.msgs.length;
let listInfoEl = doc.getElementById("addon-webext-perm-info");
listInfoEl.textContent = strings.learnMore;
listInfoEl.href =
Services.urlFormatter.formatURLPref("app.support.baseURL") +
"extension-permissions";
listInfoEl.hidden = !strings.msgs.length;
let list = doc.getElementById("addon-webext-perm-list");
while (list.firstChild) {
list.firstChild.remove();
}
for (let msg of strings.msgs) {
let item = doc.createElementNS(HTML_NS, "li");
item.textContent = msg;
list.appendChild(item);
}
} else if (topic == "swapping") {
return true;
}
if (topic == "removed") {
Services.tm.dispatchToMainThread(() => {
resolve(false);
});
}
return false;
}
let popupOptions = {
hideClose: true,
popupIconURL: icon || DEFAULT_EXTENSION_ICON,
persistent: true,
eventCallback,
name: strings.addonName,
removeOnDismissal: true,
};
let action = {
label: strings.acceptText,
accessKey: strings.acceptKey,
callback: () => {
if (histkey) {
this.histogram.add(histkey + "Accepted");
}
resolve(true);
},
};
let secondaryActions = [
{
label: strings.cancelText,
accessKey: strings.cancelKey,
callback: () => {
if (histkey) {
this.histogram.add(histkey + "Rejected");
}
resolve(false);
},
},
];
window.PopupNotifications.show(
browser,
"addon-webext-permissions",
strings.header,
"addons-notification-icon",
action,
secondaryActions,
popupOptions
);
});
this.pendingNotifications.set(browser, promise);
promise.finally(() => this.pendingNotifications.delete(browser));
return promise;
},
showDefaultSearchPrompt(target, strings, icon) {
return new Promise(resolve => {
let popupOptions = {
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,
disableHighlight: true,
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,
popupOptions
);
});
},
async showInstallNotification(target, addon) {
let { window } = getTabBrowser(target);
let brandBundle = window.document.getElementById("bundle_brand");
let appName = brandBundle.getString("brandShortName");
let bundle = window.gNavigatorBundle;
let message = bundle.getFormattedString("addonPostInstall.message1", [
"<>",
appName,
]);
const permissionName = "internal:privateBrowsingAllowed";
const { permissions } = await 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 = !(
addon.permissions &
AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
);
}
setCheckbox(window);
async function actionResolve(win) {
let checkbox = win.document.getElementById("addon-incognito-checkbox");
if (checkbox.checked == hasIncognito) {
resolve();
return;
}
let incognitoPermission = {
permissions: [permissionName],
origins: [],
};
let value;
// The checkbox has been changed at this point, otherwise we would
// have exited early above.
if (checkbox.checked) {
await ExtensionPermissions.add(addon.id, incognitoPermission);
value = "on";
} else if (hasIncognito) {
await ExtensionPermissions.remove(addon.id, incognitoPermission);
value = "off";
}
if (value !== undefined) {
AMTelemetry.recordActionEvent({
addon,
object: "doorhanger",
action: "privateBrowsingAllowed",
view: "postInstall",
value,
});
}
// 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
? 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 => {
AppMenuNotifications.removeNotification("addon-installed");
actionResolve(win);
},
};
AppMenuNotifications.showNotification(
"addon-installed",
action,
null,
options
);
});
},
};
EventEmitter.decorate(ExtensionsUI);