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
/* globals windowRoot */
import { gViewController } from "./view-controller.mjs";
const { AddonManager } = ChromeUtils.importESModule(
"resource://gre/modules/AddonManager.sys.mjs"
);
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const { AddonSettings } = ChromeUtils.importESModule(
"resource://gre/modules/addons/AddonSettings.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
ClientID: "resource://gre/modules/ClientID.sys.mjs",
Extension: "resource://gre/modules/Extension.sys.mjs",
ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"ABUSE_REPORT_ENABLED",
"extensions.abuseReport.enabled",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"XPINSTALL_ENABLED",
"xpinstall.enabled",
true
);
// When this pref is set and the add-on is already installed, we use the
// "update" flow instead of the "install" (over) flow in `about:addons`.
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"PREFER_UPDATE_OVER_INSTALL_FOR_EXISTING_ADDON",
"extensions.webextensions.prefer-update-over-install-for-existing-addon",
false
);
export const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
export const PERMISSION_MASKS = {
enable: AddonManager.PERM_CAN_ENABLE,
"always-activate": AddonManager.PERM_CAN_ENABLE,
disable: AddonManager.PERM_CAN_DISABLE,
"never-activate": AddonManager.PERM_CAN_DISABLE,
uninstall: AddonManager.PERM_CAN_UNINSTALL,
upgrade: AddonManager.PERM_CAN_UPGRADE,
"change-privatebrowsing": AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS,
};
export const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
export const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
export const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
export const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
export const PREF_TELEMETRY_ENABLED =
"datareporting.healthreport.uploadEnabled";
export const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
export const INLINE_OPTIONS_ENABLED = Services.prefs.getBoolPref(
"extensions.htmlaboutaddons.inline-options.enabled"
);
export const OPTIONS_TYPE_MAP = {
[AddonManager.OPTIONS_TYPE_TAB]: "tab",
[AddonManager.OPTIONS_TYPE_INLINE_BROWSER]: INLINE_OPTIONS_ENABLED
? "inline"
: "tab",
};
export function isDiscoverEnabled() {
try {
if (!Services.prefs.getBoolPref(PREF_DISCOVER_ENABLED)) {
return false;
}
} catch (e) {}
if (!lazy.XPINSTALL_ENABLED) {
return false;
}
return true;
}
export function getBrowserElement() {
return window.docShell.chromeEventHandler;
}
export function promiseEvent(event, target, capture = false) {
return new Promise(resolve => {
target.addEventListener(event, resolve, { capture, once: true });
});
}
// This is similar to `AddonManagerInternal.updatePromptHandler()` except it
// notifies "webextension-permission-prompt" because we want to show the
// permissions prompt directly. The `updatePromptHandler()` will notify a
// different topic and the outcome will be a notification created on the app
// menu button.
//
// to use the logic in the `AddonManager`.
function installPromptHandler(info) {
const install = this;
let oldPerms = info.existingAddon.userPermissions;
if (!oldPerms) {
// Updating from a legacy add-on, let it proceed
return Promise.resolve();
}
if (Services.policies?.isAddonRequiredByPolicy(info.existingAddon.id)) {
return Promise.resolve();
}
// When an update for an existing add-on includes data collection
// permissions, which the add-ons didn't have so far, and the manifest
// contains a flag to indicate that there was a previous consent, then we
// allow the update to just proceed, unless there are other new required
// permissions.
const updateIsMigratingToDataCollectionPerms =
!info.existingAddon.hasDataCollectionPermissions &&
info.install.addonHasPreviousConsent;
let newPerms = info.addon.userPermissions;
let difference = lazy.Extension.comparePermissions(oldPerms, newPerms);
// If there are no new permissions, just proceed
if (
!difference.origins.length &&
!difference.permissions.length &&
(updateIsMigratingToDataCollectionPerms ||
!difference.data_collection.length)
) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let subject = {
wrappedJSObject: {
target: getBrowserElement(),
info: {
type: "update",
addon: info.addon,
icon: info.addon.iconURL,
// Reference to the related AddonInstall object (used in
// AMTelemetry to link the recorded event to the other events from
// the same install flow).
install,
permissions: difference,
resolve,
reject,
},
},
};
Services.obs.notifyObservers(subject, "webextension-permission-prompt");
});
}
export function attachUpdateHandler(install) {
install.promptHandler = installPromptHandler;
}
export function detachUpdateHandler(install) {
if (install?.promptHandler === installPromptHandler) {
install.promptHandler = null;
}
}
export async function loadReleaseNotes(uri) {
const res = await fetch(uri.spec, { credentials: "omit" });
if (!res.ok) {
throw new Error("Error loading release notes");
}
// Load the content.
const text = await res.text();
// Setup the content sanitizer.
const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
Ci.nsIParserUtils
);
const flags =
ParserUtils.SanitizerDropMedia |
ParserUtils.SanitizerDropNonCSSPresentation |
ParserUtils.SanitizerDropForms;
// Sanitize and parse the content to a fragment.
const context = document.createElementNS(HTML_NS, "div");
return ParserUtils.parseFragment(text, flags, false, uri, context);
}
export function openOptionsInTab(optionsURL) {
let mainWindow = window.windowRoot.ownerGlobal;
if ("switchToTabHavingURI" in mainWindow) {
mainWindow.switchToTabHavingURI(optionsURL, true, {
relatedToCurrent: true,
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
return true;
}
return false;
}
export function shouldShowPermissionsPrompt(addon) {
if (!addon.isWebExtension || addon.seen) {
return false;
}
let perms = addon.installPermissions;
return perms?.origins.length || perms?.permissions.length;
}
export function showPermissionsPrompt(addon) {
return new Promise(resolve => {
const permissions = addon.installPermissions;
const target = getBrowserElement();
const onAddonEnabled = () => {
// 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
) {
Services.obs.notifyObservers(
{ addon, target },
"webextension-install-notify"
);
}
resolve();
};
const subject = {
wrappedJSObject: {
target,
info: {
type: "sideload",
addon,
icon: addon.iconURL,
permissions,
resolve() {
addon.markAsSeen();
addon.enable().then(onAddonEnabled);
},
reject() {
// Ignore a cancelled permission prompt.
},
},
},
};
Services.obs.notifyObservers(subject, "webextension-permission-prompt");
});
}
export function isCorrectlySigned(addon) {
// Add-ons without an "isCorrectlySigned" property are correctly signed as
// they aren't the correct type for signing.
return addon.isCorrectlySigned !== false;
}
export function isDisabledUnsigned(addon) {
let signingRequired =
addon.type == "locale"
? AddonSettings.LANGPACKS_REQUIRE_SIGNING
: AddonSettings.REQUIRE_SIGNING;
return signingRequired && !isCorrectlySigned(addon);
}
export function isPending(addon, action) {
const amAction = AddonManager["PENDING_" + action.toUpperCase()];
return !!(addon.pendingOperations & amAction);
}
export async function installAddonsFromFilePicker() {
let [dialogTitle, filterName] = await document.l10n.formatMessages([
{ id: "addon-install-from-file-dialog-title" },
{ id: "addon-install-from-file-filter-name" },
]);
const nsIFilePicker = Ci.nsIFilePicker;
var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
fp.init(
window.browsingContext,
dialogTitle.value,
nsIFilePicker.modeOpenMultiple
);
try {
fp.appendFilter(filterName.value, "*.xpi;*.jar;*.zip");
fp.appendFilters(nsIFilePicker.filterAll);
} catch (e) {}
return new Promise(resolve => {
fp.open(async result => {
if (result != nsIFilePicker.returnOK) {
return;
}
let installTelemetryInfo = {
source: "about:addons",
method: "install-from-file",
};
let browser = getBrowserElement();
let installs = [];
for (let file of fp.files) {
let install = await AddonManager.getInstallForFile(
file,
null,
installTelemetryInfo
);
AddonManager.installAddonFromAOMWithOptions(
browser,
document.documentURIObject,
install,
{
preferUpdateOverInstall:
lazy.PREFER_UPDATE_OVER_INSTALL_FOR_EXISTING_ADDON,
}
);
installs.push(install);
}
resolve(installs);
});
});
}
export function shouldSkipAnimations() {
return (
document.body.hasAttribute("skip-animations") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
export function callListeners(name, args, listeners) {
for (let listener of listeners) {
try {
if (name in listener) {
listener[name](...args);
}
} catch (e) {
Cu.reportError(e);
}
}
}
export function getUpdateInstall(addon) {
return (
// Install object for a pending update.
addon.updateInstall ||
// Install object for a postponed upgrade (only for extensions,
// because is the only addon type that can postpone their own
// updates).
(addon.type === "extension" &&
addon.pendingUpgrade &&
addon.pendingUpgrade.install)
);
}
export function isManualUpdate(install) {
const isExistingHidden = install.existingAddon?.hidden;
// install.addon can be missing if the install was retrieved from an update
// check, without having downloaded and parsed the linked xpi yet.
const isNewHidden = install.addon?.hidden;
// Not a manual update installation if both the existing and old
// addon are hidden (which also ensures we are going to hide pending
// installations for hidden add-ons from both the category button
// badge counter and from the available updates view when the new
// addon is also hidden).
if (isExistingHidden && isNewHidden) {
return false;
}
let isManual =
install.existingAddon &&
!AddonManager.shouldAutoUpdate(install.existingAddon);
let isExtension = install.existingAddon?.type == "extension";
return (
(isManual && isInState(install, "available")) ||
(isExtension && isInState(install, "postponed"))
);
}
export const AddonManagerListenerHandler = {
listeners: new Set(),
addListener(listener) {
this.listeners.add(listener);
},
removeListener(listener) {
this.listeners.delete(listener);
},
delegateEvent(name, args) {
callListeners(name, args, this.listeners);
},
startup() {
this._listener = new Proxy(
{},
{
has: () => true,
get:
(_, name) =>
(...args) =>
this.delegateEvent(name, args),
}
);
AddonManager.addAddonListener(this._listener);
AddonManager.addInstallListener(this._listener);
AddonManager.addManagerListener(this._listener);
this._permissionHandler = (type, data) => {
if (type == "change-permissions") {
this.delegateEvent("onChangePermissions", [data]);
}
};
lazy.ExtensionPermissions.addListener(this._permissionHandler);
},
shutdown() {
AddonManager.removeAddonListener(this._listener);
AddonManager.removeInstallListener(this._listener);
AddonManager.removeManagerListener(this._listener);
lazy.ExtensionPermissions.removeListener(this._permissionHandler);
},
};
/**
* This object wires the AddonManager event listeners into addon-card and
* addon-details elements rather than needing to add/remove listeners all the
* time as the view changes.
*/
export const AddonCardListenerHandler = new Proxy(
{},
{
has: () => true,
get(_, name) {
return (...args) => {
let elements = [];
let addonId;
// We expect args[0] to be of type:
// - AddonInstall, on AddonManager install events
// - AddonWrapper, on AddonManager addon events
// - undefined, on AddonManager manage events
if (args[0]) {
addonId =
args[0].addon?.id ||
args[0].existingAddon?.id ||
args[0].extensionId ||
args[0].id;
}
if (addonId) {
let cardSelector = `addon-card[addon-id="${addonId}"]`;
elements = document.querySelectorAll(
`${cardSelector}, ${cardSelector} addon-details`
);
} else if (name == "onUpdateModeChanged") {
elements = document.querySelectorAll("addon-card");
}
callListeners(name, args, elements);
};
},
}
);
AddonManagerListenerHandler.addListener(AddonCardListenerHandler);
export function isAbuseReportSupported(addon) {
return (
lazy.ABUSE_REPORT_ENABLED &&
lazy.AbuseReporter.isSupportedAddonType(addon.type) &&
!(addon.isBuiltin || addon.isSystem)
);
}
export async function isAllowedInPrivateBrowsing(addon) {
// Use the Promise directly so this function stays sync for the other case.
let perms = await lazy.ExtensionPermissions.get(addon.id);
return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME);
}
export function hasPermission(addon, permission) {
return !!(addon.permissions & PERMISSION_MASKS[permission]);
}
export function isInState(install, state) {
return install.state == AddonManager["STATE_" + state.toUpperCase()];
}
export async function getAddonMessageInfo(
addon,
{ isCardExpanded, isInDisabledSection }
) {
const { name } = addon;
const { STATE_BLOCKED, STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
if (addon.blocklistState === STATE_BLOCKED) {
let typeSuffix = addon.type === "extension" ? "extension" : "other";
return {
linkUrl: await addon.getBlocklistURL(),
linkId: "details-notification-blocked-link2",
messageId: `details-notification-hard-blocked-${typeSuffix}`,
type: "error",
};
} else if (isDisabledUnsigned(addon)) {
return {
linkSumoPage: "unsigned-addons",
messageId: "details-notification-unsigned-and-disabled2",
messageArgs: { name },
type: "error",
};
} else if (
!addon.isCompatible &&
(AddonManager.checkCompatibility ||
addon.blocklistState !== STATE_SOFTBLOCKED)
) {
return {
// NOTE: this messagebar is customized by Thunderbird to include
messageId: "details-notification-incompatible2",
messageArgs: { name, version: Services.appinfo.version },
type: "error",
};
} else if (
(Cu.isInAutomation || !lazy.AppConstants.MOZILLA_OFFICIAL) &&
Services.prefs.getBoolPref("extensions.ui.disableUnsignedWarnings", false)
) {
// In local builds, when this pref is set, pretend the file is correctly
// signed even if it isn't so that the UI looks like what users would
// normally see.
return {};
} else if (!isCorrectlySigned(addon)) {
return {
linkSumoPage: "unsigned-addons",
messageId: "details-notification-unsigned2",
messageArgs: { name },
type: "warning",
};
} else if (addon.blocklistState === STATE_SOFTBLOCKED) {
const softBlockFluentIdsMap = {
extension: {
enabled: "details-notification-soft-blocked-extension-enabled2",
disabled: "details-notification-soft-blocked-extension-disabled2",
},
other: {
enabled: "details-notification-soft-blocked-other-enabled2",
disabled: "details-notification-soft-blocked-other-disabled2",
},
};
let typeSuffix = addon.type === "extension" ? "extension" : "other";
let stateSuffix;
// If the Addon Card is not expanded, delay changing the messagebar
// string to when the Addon card is refreshed as part of moving
// it between the enabled and disabled sections.
if (isCardExpanded) {
stateSuffix = addon.isActive ? "enabled" : "disabled";
} else {
stateSuffix = !isInDisabledSection ? "enabled" : "disabled";
}
let messageId = softBlockFluentIdsMap[typeSuffix][stateSuffix];
return {
linkUrl: await addon.getBlocklistURL(),
linkId: "details-notification-softblocked-link2",
messageId,
type: "warning",
};
} else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) {
return {
messageId: "details-notification-gmp-pending2",
messageArgs: { name },
type: "warning",
};
}
return {};
}
export function checkForUpdate(addon) {
return new Promise(resolve => {
let listener = {
onUpdateAvailable(addon, install) {
if (AddonManager.shouldAutoUpdate(addon)) {
// Make sure that an update handler is attached to all the install
// objects when updated xpis are going to be installed automatically.
attachUpdateHandler(install);
let failed = () => {
detachUpdateHandler(install);
install.removeListener(updateListener);
resolve({ installed: false, pending: false, found: true });
};
let updateListener = {
onDownloadFailed: failed,
onInstallCancelled: failed,
onInstallFailed: failed,
onInstallEnded: () => {
detachUpdateHandler(install);
install.removeListener(updateListener);
resolve({ installed: true, pending: false, found: true });
},
onInstallPostponed: () => {
detachUpdateHandler(install);
install.removeListener(updateListener);
resolve({ installed: false, pending: true, found: true });
},
};
install.addListener(updateListener);
install.install();
} else {
resolve({ installed: false, pending: true, found: true });
}
},
onNoUpdateAvailable() {
resolve({ found: false });
},
};
addon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
});
}
export async function checkForUpdates() {
let addons = await AddonManager.getAddonsByTypes(null);
addons = addons.filter(addon => hasPermission(addon, "upgrade"));
let updates = await Promise.all(addons.map(addon => checkForUpdate(addon)));
gViewController.notifyEMUpdateCheckFinished();
return updates.reduce(
(counts, update) => ({
installed: counts.installed + (update.installed ? 1 : 0),
pending: counts.pending + (update.pending ? 1 : 0),
found: counts.found + (update.found ? 1 : 0),
}),
{ installed: 0, pending: 0, found: 0 }
);
}
// Check if an add-on has the provided options type, accounting for the pref
// to disable inline options.
export function getOptionsType(addon) {
return OPTIONS_TYPE_MAP[addon.optionsType];
}
// Check whether the options page can be loaded in the current browser window.
export async function isAddonOptionsUIAllowed(addon) {
if (addon.type !== "extension" || !getOptionsType(addon)) {
// Themes never have options pages.
// Some plugins have preference pages, and they can always be shown.
// Extensions do not need to be checked if they do not have options pages.
return true;
}
if (!lazy.PrivateBrowsingUtils.isContentWindowPrivate(window)) {
return true;
}
if (addon.incognito === "not_allowed") {
return false;
}
// The current page is in a private browsing window, and the add-on does not
// have the permission to access private browsing windows. Block access.
return (
// Note: This function is async because isAllowedInPrivateBrowsing is async.
isAllowedInPrivateBrowsing(addon)
);
}
export function nl2br(text) {
let frag = document.createDocumentFragment();
let hasAppended = false;
for (let part of text.split("\n")) {
if (hasAppended) {
frag.appendChild(document.createElement("br"));
}
frag.appendChild(new Text(part));
hasAppended = true;
}
return frag;
}
/**
* Select the screeenshot to display above an add-on card.
*
* @param {AddonWrapper|DiscoAddonWrapper} addon
* @returns {string|null}
* The URL of the best fitting screenshot, if any.
*/
export function getScreenshotUrlForAddon(addon) {
if (addon.id == "default-theme@mozilla.org") {
return "chrome://mozapps/content/extensions/default-theme/preview.svg";
}
const builtInThemePreview = lazy.BuiltInThemes.previewForBuiltInThemeId(
addon.id
);
if (builtInThemePreview) {
return builtInThemePreview;
}
let { screenshots } = addon;
if (!screenshots || !screenshots.length) {
return null;
}
// The image size is defined at .card-heading-image in aboutaddons.css, and
// is based on the aspect ratio for a 680x92 image. Use the image if possible,
// and otherwise fall back to the first image and hope for the best.
let screenshot = screenshots.find(s => s.width === 680 && s.height === 92);
if (!screenshot) {
console.warn(`Did not find screenshot with desired size for ${addon.id}.`);
screenshot = screenshots[0];
}
return screenshot.url;
}
/**
* Adds UTM parameters to a given URL, if it is an AMO URL.
*
* @param {string} contentAttribute
* Identifies the part of the UI with which the link is associated.
* @param {string} url
* @returns {string}
* The url with UTM parameters if it is an AMO URL.
* Otherwise the url in unmodified form.
*/
export function formatUTMParams(contentAttribute, url) {
let parsedUrl = new URL(url);
let domain = `.${parsedUrl.hostname}`;
if (
!domain.endsWith(".mozilla.org") &&
// For testing: addons-dev.allizom.org and addons.allizom.org
!domain.endsWith(".allizom.org")
) {
return url;
}
parsedUrl.searchParams.set("utm_source", "firefox-browser");
parsedUrl.searchParams.set("utm_medium", "firefox-browser");
parsedUrl.searchParams.set("utm_content", contentAttribute);
return parsedUrl.href;
}
// A wrapper around an item from the "results" array from AMO's discovery API.
export class DiscoAddonWrapper {
/**
* @param {object} details
* An item in the "results" array from AMO's discovery API.
*/
constructor(details) {
// Reuse AddonRepository._parseAddon to have the AMO response parsing logic
// in one place.
let repositoryAddon = lazy.AddonRepository._parseAddon(details.addon);
// Note: Any property used by RecommendedAddonCard should appear here.
// The property names and values should have the same semantics as
// AddonWrapper, to ease the reuse of helper functions in this file.
this.id = repositoryAddon.id;
this.type = repositoryAddon.type;
this.name = repositoryAddon.name;
this.screenshots = repositoryAddon.screenshots;
this.sourceURI = repositoryAddon.sourceURI;
this.creator = repositoryAddon.creator;
this.averageRating = repositoryAddon.averageRating;
this.dailyUsers = details.addon.average_daily_users;
this.editorialDescription = details.description_text;
this.iconURL = details.addon.icon_url;
this.amoListingUrl = details.addon.url;
this.taarRecommended = details.is_recommendation;
}
}
/**
* A helper to retrieve the list of recommended add-ons via AMO's discovery API.
*/
export var DiscoveryAPI = {
// Map<boolean, Promise> Promises from fetching the API results with or
// without a client ID. The `false` (no client ID) case could actually
// have been fetched with a client ID. See getResults() for more info.
_resultPromises: new Map(),
/**
* Fetch the list of recommended add-ons. The results are cached.
*
* Pending requests are coalesced, so there is only one request at any given
* time. If a request fails, the pending promises are rejected, but a new
* call will result in a new request. A succesful response is cached for the
* lifetime of the document.
*
* @param {boolean} preferClientId
* A boolean indicating a preference for using a client ID.
* This will not overwrite the user preference but will
* avoid sending a client ID if no request has been made yet.
* @returns {Promise<DiscoAddonWrapper[]>}
*/
async getResults(preferClientId = true) {
// Allow a caller to set preferClientId to false, but not true if discovery
// is disabled.
preferClientId = preferClientId && this.clientIdDiscoveryEnabled;
// Reuse a request for this preference first.
let resultPromise =
this._resultPromises.get(preferClientId) ||
// If the client ID isn't preferred, we can still reuse a request with the
// client ID.
(!preferClientId && this._resultPromises.get(true));
if (resultPromise) {
return resultPromise;
}
// Nothing is prepared for this preference, make a new request.
resultPromise = this._fetchRecommendedAddons(preferClientId).catch(e => {
// Delete the pending promise, so _fetchRecommendedAddons can be
// called again at the next property access.
this._resultPromises.delete(preferClientId);
Cu.reportError(e);
throw e;
});
// Store the new result for the preference.
this._resultPromises.set(preferClientId, resultPromise);
return resultPromise;
},
get clientIdDiscoveryEnabled() {
// These prefs match Discovery.sys.mjs for enabling clientId cookies.
return (
Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) &&
Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) &&
!lazy.PrivateBrowsingUtils.isContentWindowPrivate(window)
);
},
async _fetchRecommendedAddons(useClientId) {
let discoveryApiUrl = new URL(
Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL)
);
if (useClientId) {
let clientId = await lazy.ClientID.getClientIdHash();
discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
}
let res = await fetch(discoveryApiUrl.href, {
credentials: "omit",
});
if (!res.ok) {
throw new Error(`Failed to fetch recommended add-ons, ${res.status}`);
}
let { results } = await res.json();
return results.map(details => new DiscoAddonWrapper(details));
},
};
/**
* @param {Element} el The button element.
*/
export function openAmoInTab(el, path) {
let amoUrl = Services.urlFormatter.formatURLPref(
"extensions.getAddons.link.url"
);
if (path) {
amoUrl += path;
}
amoUrl = formatUTMParams("find-more-link-bottom", amoUrl);
windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
}
// DOMParser instance used by AboutAddonsElementMixin to parse the
// template markup strings provided by the custom elements inheriting
// from an AboutAddonsElementBase class.
const domParser = new DOMParser();
// This helper returns a subclass of a given `Base` class which provides
// a static `fragment` getter which is going to parses an HTML template
// from the string returned by a `markup` static getter, which is meant
// to be provided by the custom elements subclassing the returned class,
// and return its content elements imported into the current document.
export function AboutAddonsElementMixin(Base) {
let AboutAddonsElementBase = class extends Base {
static get markup() {
// This static getter method is expected to be defined
// by the subclass, throw an error when it is not.
throw new Error(`${this.name} markup static getter is missing`);
}
static get fragment() {
if (!this.hasOwnProperty("_template")) {
let doc = domParser.parseFromString(this.markup, "text/html");
this._template = doc.querySelector("template");
if (!this._template) {
throw new Error(
`${this.name} markup is missing the expected template tag`
);
}
}
// NOTE: document.importNode is used here to make sure the
// domain-specific custom elements only registered to the
// about:addons document are going to be upgraded as expected
// also when the template content is added to a parent node
// not yet connected to the document.
return document.importNode(this._template.content, true);
}
};
// Rename the class to make it easier to distinguish it.
Object.defineProperty(AboutAddonsElementBase, "name", {
value: `AboutAddons${Base.name}`,
});
return AboutAddonsElementBase;
}
// An HTMLElement subclass which includes by default the static `fragment`
// getter provided by the AboutAddonsElementMixin.
//
// This helper is used by about:addons webcomponents as in the example
// that follows:
//
// class SomeCustomElement extensions AboutAddonsHTMLElement {
// static get markup() {
// return `
// <template>
// ...
// </template>
// `;
// }
//
// connectCallback() {
// this.appendChild(SomeCustomElement.fragment);
// }
// }
export const AboutAddonsHTMLElement = AboutAddonsElementMixin(HTMLElement);