/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
});
// The current platform as specified in the AMO API:
// http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#addon-detail-platform
ChromeUtils.defineLazyGetter(lazy, "PLATFORM", () => {
let platform = Services.appinfo.OS;
switch (platform) {
case "Darwin":
return "mac";
case "Linux":
return "linux";
case "Android":
return "android";
case "WINNT":
return "windows";
}
return platform;
});
const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"getAddonsCacheEnabled",
PREF_GETADDONS_CACHE_ENABLED
);
const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
const PREF_GETADDONS_CACHE_ID_ENABLED =
"extensions.%ID%.getAddons.cache.enabled";
const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
const PREF_GETADDONS_BROWSESEARCHRESULTS =
"extensions.getAddons.search.browseURL";
const PREF_GETADDONS_DB_SCHEMA = "extensions.getAddons.databaseSchema";
const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
const PREF_GET_BROWSER_MAPPINGS = "extensions.getAddons.browserMappings.url";
const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
const PREF_METADATA_UPDATETHRESHOLD_SEC =
"extensions.getAddons.cache.updateThreshold";
const DEFAULT_METADATA_UPDATETHRESHOLD_SEC = 172800; // two days
const DEFAULT_CACHE_TYPES = "extension,theme,locale,dictionary";
const FILE_DATABASE = "addons.json";
const DB_SCHEMA = 6;
const DB_MIN_JSON_SCHEMA = 5;
const DB_BATCH_TIMEOUT_MS = 50;
const BLANK_DB = function () {
return {
addons: new Map(),
schema: DB_SCHEMA,
};
};
import { Log } from "resource://gre/modules/Log.sys.mjs";
const LOGGER_ID = "addons.repository";
// Create a new logger for use by the Addons Repository
// (Requires AddonManager.jsm)
var logger = Log.repository.getLogger(LOGGER_ID);
function convertHTMLToPlainText(html) {
if (!html) {
return html;
}
var converter = Cc[
"@mozilla.org/widget/htmlformatconverter;1"
].createInstance(Ci.nsIFormatConverter);
var input = Cc["@mozilla.org/supports-string;1"].createInstance(
Ci.nsISupportsString
);
input.data = html.replace(/\n/g, "
");
var output = {};
converter.convert("text/html", input, "text/plain", output);
if (output.value instanceof Ci.nsISupportsString) {
return output.value.data.replace(/\r\n/g, "\n");
}
return html;
}
async function getAddonsToCache(aIds) {
let types = Services.prefs.getStringPref(
PREF_GETADDONS_CACHE_TYPES,
DEFAULT_CACHE_TYPES
);
types = types.split(",");
let addons = await lazy.AddonManager.getAddonsByIDs(aIds);
let enabledIds = [];
for (let [i, addon] of addons.entries()) {
var preference = PREF_GETADDONS_CACHE_ID_ENABLED.replace("%ID%", aIds[i]);
// If the preference doesn't exist caching is enabled by default
if (!Services.prefs.getBoolPref(preference, true)) {
continue;
}
// The add-ons manager may not know about this ID yet if it is a pending
// install. In that case we'll just cache it regardless
// Don't cache add-ons of the wrong types
if (addon && !types.includes(addon.type)) {
continue;
}
// Don't cache system add-ons
if (addon && addon.isSystem) {
continue;
}
enabledIds.push(aIds[i]);
}
return enabledIds;
}
function AddonSearchResult(aId) {
this.id = aId;
this.icons = {};
this._unsupportedProperties = {};
}
AddonSearchResult.prototype = {
/**
* The ID of the add-on
*/
id: null,
/**
* The add-on type (e.g. "extension" or "theme")
*/
type: null,
/**
* The name of the add-on
*/
name: null,
/**
* The version of the add-on
*/
version: null,
/**
* The creator of the add-on
*/
creator: null,
/**
* The developers of the add-on
*/
developers: null,
/**
* A short description of the add-on
*/
description: null,
/**
* The full description of the add-on
*/
fullDescription: null,
/**
* The end-user licensing agreement (EULA) of the add-on
*/
eula: null,
/**
* The url of the add-on's icon
*/
get iconURL() {
return this.icons && this.icons[32];
},
/**
* The URLs of the add-on's icons, as an object with icon size as key
*/
icons: null,
/**
* An array of screenshot urls for the add-on
*/
screenshots: null,
/**
* The homepage for the add-on
*/
homepageURL: null,
/**
* The support URL for the add-on
*/
supportURL: null,
/**
* The contribution url of the add-on
*/
contributionURL: null,
/**
* The rating of the add-on, 0-5
*/
averageRating: null,
/**
* The number of reviews for this add-on
*/
reviewCount: null,
/**
* The URL to the list of reviews for this add-on
*/
reviewURL: null,
/**
* The number of times the add-on was downloaded the current week
*/
weeklyDownloads: null,
/**
* The URL to the AMO detail page of this (listed) add-on
*/
amoListingURL: null,
/**
* AddonInstall object generated from the add-on XPI url
*/
install: null,
/**
* nsIURI storing where this add-on was installed from
*/
sourceURI: null,
/**
* The Date that the add-on was most recently updated
*/
updateDate: null,
toJSON() {
let json = {};
for (let property of Object.keys(this)) {
let value = this[property];
if (property.startsWith("_") || typeof value === "function") {
continue;
}
try {
switch (property) {
case "sourceURI":
json.sourceURI = value ? value.spec : "";
break;
case "updateDate":
json.updateDate = value ? value.getTime() : "";
break;
default:
json[property] = value;
}
} catch (ex) {
logger.warn("Error writing property value for " + property);
}
}
for (let property of Object.keys(this._unsupportedProperties)) {
let value = this._unsupportedProperties[property];
if (!property.startsWith("_")) {
json[property] = value;
}
}
return json;
},
};
/**
* The add-on repository is a source of add-ons that can be installed. It can
* be searched in three ways. The first takes a list of IDs and returns a
* list of the corresponding add-ons. The second returns a list of add-ons that
* come highly recommended. This list should change frequently. The third is to
* search for specific search terms entered by the user. Searches are
* asynchronous and results should be passed to the provided callback object
* when complete. The results passed to the callback should only include add-ons
* that are compatible with the current application and are not already
* installed.
*/
export var AddonRepository = {
/**
* The homepage for visiting this repository. If the corresponding preference
* is not defined, defaults to about:blank.
*/
get homepageURL() {
let url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {});
return url != null ? url : "about:blank";
},
get appIsShuttingDown() {
return Services.startup.shuttingDown;
},
/**
* Retrieves the url that can be visited to see search results for the given
* terms. If the corresponding preference is not defined, defaults to
* about:blank.
*
* @param aSearchTerms
* Search terms used to search the repository
*/
getSearchURL(aSearchTerms) {
let url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, {
TERMS: aSearchTerms,
});
return url != null ? url : "about:blank";
},
/**
* Whether caching is currently enabled
*/
get cacheEnabled() {
return lazy.getAddonsCacheEnabled;
},
/**
* Shut down AddonRepository
* return: promise{integer} resolves with the result of flushing
* the AddonRepository database
*/
shutdown() {
return AddonDatabase.shutdown(false);
},
metadataAge() {
let now = Math.round(Date.now() / 1000);
let lastUpdate = Services.prefs.getIntPref(PREF_METADATA_LASTUPDATE, 0);
return Math.max(0, now - lastUpdate);
},
isMetadataStale() {
let threshold = Services.prefs.getIntPref(
PREF_METADATA_UPDATETHRESHOLD_SEC,
DEFAULT_METADATA_UPDATETHRESHOLD_SEC
);
return this.metadataAge() > threshold;
},
/**
* Asynchronously get a cached add-on by id. The add-on (or null if the
* add-on is not found) is passed to the specified callback. If caching is
* disabled, null is passed to the specified callback.
*
* The callback variant exists only for existing code in XPIProvider.sys.mjs
* and XPIDatabase.sys.mjs that requires a synchronous callback, yuck.
*
* @param aId
* The id of the add-on to get
*/
async getCachedAddonByID(aId, aCallback) {
if (!aId || !this.cacheEnabled) {
if (aCallback) {
aCallback(null);
}
return null;
}
if (aCallback && AddonDatabase._loaded) {
let addon = AddonDatabase.getAddon(aId);
aCallback(addon);
return addon;
}
await AddonDatabase.openConnection();
let addon = AddonDatabase.getAddon(aId);
if (aCallback) {
aCallback(addon);
}
return addon;
},
/*
* Clear and delete the AddonRepository database
* @return Promise{null} resolves when the database is deleted
*/
_clearCache() {
return AddonDatabase.delete().then(() =>
lazy.AddonManagerPrivate.updateAddonRepositoryData()
);
},
/*
* Create a ServiceRequest instance.
* @return ServiceRequest returns a ServiceRequest instance.
*/
_createServiceRequest() {
return new lazy.ServiceRequest({ mozAnon: true });
},
/**
* Fetch data from an API where the results may span multiple "pages".
* This function will take care of issuing multiple requests until all
* the results have been fetched, and will coalesce them all into a
* single return value. The handling here is specific to the way AMO
* implements paging (ie a JSON result with a "next" property).
*
* @param {string} pref
* The pref name that contains the API URL to call.
* @param {object} params
* A key-value object that contains the parameters to replace
* in the API URL.
* @param {function} handler
* This function will be called once per page of results,
* it should return an array of objects (the type depends
* on the particular API being called of course).
*
* @returns Promise{array} An array of all the individual results from
* the API call(s).
*/
_fetchPaged(pref, params, handler) {
const startURL = this._formatURLPref(pref, params);
let results = [];
const fetchNextPage = url => {
return new Promise((resolve, reject) => {
if (this.appIsShuttingDown) {
logger.debug(
"Rejecting AddonRepository._fetchPaged call, shutdown already in progress"
);
reject(
new Error(
`Reject ServiceRequest for "${url}", shutdown already in progress`
)
);
return;
}
let request = this._createServiceRequest();
request.mozBackgroundRequest = true;
request.open("GET", url, true);
request.responseType = "json";
request.addEventListener("error", aEvent => {
reject(new Error(`GET ${url} failed`));
});
request.addEventListener("timeout", aEvent => {
reject(new Error(`GET ${url} timed out`));
});
request.addEventListener("load", aEvent => {
let response = request.response;
if (!response || (request.status != 200 && request.status != 0)) {
reject(new Error(`GET ${url} failed (status ${request.status})`));
return;
}
try {
results.push(...handler(response.results));
} catch (err) {
reject(err);
}
if (response.next) {
resolve(fetchNextPage(response.next));
}
resolve(results);
});
request.send(null);
});
};
return fetchNextPage(startURL);
},
/**
* Fetch metadata for a given set of addons from AMO.
*
* @param aIDs
* The array of ids to retrieve metadata for.
* @returns {array}
*/
async getAddonsByIDs(aIDs) {
const idCheck = aIDs.map(id => {
if (id.startsWith("rta:")) {
return atob(id.split(":")[1]);
}
return id;
});
const addons = await this._fetchPaged(
PREF_GETADDONS_BYIDS,
{ IDS: aIDs.join(",") },
results =>
results
.map(entry => this._parseAddon(entry))
// Only return the add-ons corresponding the IDs passed to this method.
.filter(addon => idCheck.includes(addon.id))
);
return addons;
},
/**
* Fetch the Firefox add-ons mapped to the list of extension IDs for the
* browser ID passed to this method.
*
* See: https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#browser-mappings
*
* @param browserID
* The browser ID used to retrieve the mapping of IDs.
* @param extensionIDs
* The array of browser (non-Firefox) extension IDs to retrieve
* metadata for.
* @returns {object} result
* The result of the mapping.
* @returns {array} result.addons
* The AddonSearchResults for the addons that were successfully mapped.
* @returns {array} result.matchedIDs
* The IDs of the extensions that were successfully matched to
* equivalents that can be installed in this browser. These are
* the IDs before matching to equivalents.
* @returns {array} result.unmatchedIDs
* The IDs of the extensions that were not matched to equivalents.
*/
async getMappedAddons(browserID, extensionIDs) {
let matchedExtensionIDs = new Set();
let unmatchedExtensionIDs = new Set(extensionIDs);
const addonIds = await this._fetchPaged(
PREF_GET_BROWSER_MAPPINGS,
{ BROWSER: browserID },
results =>
results
// Filter out all the entries with an extension ID not in the list
// passed to the method.
.filter(entry => {
if (unmatchedExtensionIDs.has(entry.extension_id)) {
unmatchedExtensionIDs.delete(entry.extension_id);
matchedExtensionIDs.add(entry.extension_id);
return true;
}
return false;
})
// Return the add-on ID (stored as `guid` on AMO).
.map(entry => entry.addon_guid)
);
if (!addonIds.length) {
return {
addons: [],
matchedIDs: [],
unmatchedIDs: [...unmatchedExtensionIDs],
};
}
return {
addons: await this.getAddonsByIDs(addonIds),
matchedIDs: [...matchedExtensionIDs],
unmatchedIDs: [...unmatchedExtensionIDs],
};
},
/**
* Asynchronously add add-ons to the cache corresponding to the specified
* ids. If caching is disabled, the cache is unchanged.
*
* @param aIds
* The array of add-on ids to add to the cache
* @returns {array} Add-ons to add to the cache.
*/
async cacheAddons(aIds) {
logger.debug(
"cacheAddons: enabled " + this.cacheEnabled + " IDs " + aIds.toSource()
);
if (!this.cacheEnabled) {
return [];
}
let ids = await getAddonsToCache(aIds);
// If there are no add-ons to cache, act as if caching is disabled
if (!ids.length) {
return [];
}
let addons = [];
try {
addons = await this.getAddonsByIDs(ids);
} catch (err) {
logger.error(`Error in addon metadata check: ${err.message}`);
}
if (addons.length) {
await AddonDatabase.update(addons);
}
return addons;
},
/**
* Get all installed addons from the AddonManager singleton.
*
* @return Promise{array} Resolves to an array of AddonWrapper instances.
*/
_getAllInstalledAddons() {
return lazy.AddonManager.getAllAddons();
},
/**
* Performs the periodic background update check.
*
* In Firefox Desktop builds, the background update check is triggered on a
* daily basis as part of the AOM background update check and registered
* from: `toolkit/mozapps/extensions/extensions.manifest`
*
* In GeckoView builds, add-ons are checked for updates individually. The
* `AddonRepository.backgroundUpdateCheck()` method is called by the
* `updateWebExtension()` method defined in `GeckoViewWebExtensions.sys.mjs`
* but only when `AddonRepository.isMetadataStale()` returns true.
*
* @return Promise{null} Resolves when the metadata update is complete.
*/
async backgroundUpdateCheck() {
let shutter = (async () => {
if (this.appIsShuttingDown) {
logger.debug(
"Returning earlier from backgroundUpdateCheck, shutdown already in progress"
);
return;
}
let allAddons = await this._getAllInstalledAddons();
// Completely remove cache if caching is not enabled
if (!this.cacheEnabled) {
logger.debug("Clearing cache because it is disabled");
await this._clearCache();
return;
}
let ids = allAddons.map(a => a.id);
logger.debug("Repopulate add-on cache with " + ids.toSource());
let addonsToCache = await getAddonsToCache(ids);
// Completely remove cache if there are no add-ons to cache
if (!addonsToCache.length) {
logger.debug("Clearing cache because 0 add-ons were requested");
await this._clearCache();
return;
}
let addons;
try {
addons = await this.getAddonsByIDs(addonsToCache);
} catch (err) {
// This is likely to happen if the server is unreachable, e.g. when
// there is no network connectivity.
logger.error(`Error in addon metadata lookup: ${err.message}`);
// Return now to avoid calling repopulate with an empty array;
// doing so would clear the cache.
return;
}
AddonDatabase.repopulate(addons);
// Always call AddonManager updateAddonRepositoryData after we refill the cache
await lazy.AddonManagerPrivate.updateAddonRepositoryData();
})();
lazy.AddonManager.beforeShutdown.addBlocker(
"AddonRepository Background Updater",
shutter
);
await shutter;
lazy.AddonManager.beforeShutdown.removeBlocker(shutter);
},
/*
* Creates an AddonSearchResult by parsing an entry from the AMO API.
*
* @param aEntry
* An entry from the AMO search API to parse.
* @return Result object containing the parsed AddonSearchResult
*/
_parseAddon(aEntry) {
let addon = new AddonSearchResult(aEntry.guid);
addon.name = aEntry.name;
if (typeof aEntry.current_version == "object") {
addon.version = String(aEntry.current_version.version);
if (Array.isArray(aEntry.current_version.files)) {
for (let file of aEntry.current_version.files) {
if (file.platform == "all" || file.platform == lazy.PLATFORM) {
if (file.url) {
addon.sourceURI = lazy.NetUtil.newURI(file.url);
}
break;
}
}
}
}
addon.homepageURL = aEntry.homepage;
addon.supportURL = aEntry.support_url;
addon.amoListingURL = aEntry.url;
addon.description = convertHTMLToPlainText(aEntry.summary);
addon.fullDescription = convertHTMLToPlainText(aEntry.description);
addon.weeklyDownloads = aEntry.weekly_downloads;
switch (aEntry.type) {
case "persona":
case "statictheme":
addon.type = "theme";
break;
case "language":
addon.type = "locale";
break;
default:
addon.type = aEntry.type;
break;
}
if (Array.isArray(aEntry.authors)) {
let authors = aEntry.authors.map(
author =>
new lazy.AddonManagerPrivate.AddonAuthor(author.name, author.url)
);
if (authors.length) {
addon.creator = authors[0];
addon.developers = authors.slice(1);
}
}
if (typeof aEntry.previews == "object") {
addon.screenshots = aEntry.previews.map(shot => {
let safeSize = orig =>
Array.isArray(orig) && orig.length >= 2 ? orig : [null, null];
let imageSize = safeSize(shot.image_size);
let thumbSize = safeSize(shot.thumbnail_size);
return new lazy.AddonManagerPrivate.AddonScreenshot(
shot.image_url,
imageSize[0],
imageSize[1],
shot.thumbnail_url,
thumbSize[0],
thumbSize[1],
shot.caption
);
});
}
addon.contributionURL = aEntry.contributions_url;
if (typeof aEntry.ratings == "object") {
addon.averageRating = Math.min(5, aEntry.ratings.average);
addon.reviewCount = aEntry.ratings.text_count;
}
addon.reviewURL = aEntry.ratings_url;
if (aEntry.last_updated) {
addon.updateDate = new Date(aEntry.last_updated);
}
addon.icons = aEntry.icons || {};
return addon;
},
// Create url from preference, returning null if preference does not exist
_formatURLPref(aPreference, aSubstitutions = {}) {
let url = Services.prefs.getCharPref(aPreference, "");
if (!url) {
logger.warn("_formatURLPref: Couldn't get pref: " + aPreference);
return null;
}
url = url.replace(/%([A-Z_]+)%/g, function (aMatch, aKey) {
return aKey in aSubstitutions
? encodeURIComponent(aSubstitutions[aKey])
: aMatch;
});
return Services.urlFormatter.formatURL(url);
},
flush() {
return AddonDatabase.flush();
},
async getAvailableLangpacks() {
// This should be the API endpoint documented at:
// http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#language-tools
let url = this._formatURLPref(PREF_GET_LANGPACKS);
let response = await fetch(url, { credentials: "omit" });
if (!response.ok) {
throw new Error("fetching available language packs failed");
}
let data = await response.json();
let result = [];
for (let entry of data.results) {
if (
!entry.current_compatible_version ||
!entry.current_compatible_version.files
) {
continue;
}
for (let file of entry.current_compatible_version.files) {
if (
file.platform == "all" ||
file.platform == Services.appinfo.OS.toLowerCase()
) {
result.push({
target_locale: entry.target_locale,
url: file.url,
hash: file.hash,
});
}
}
}
return result;
},
};
var AddonDatabase = {
connectionPromise: null,
_loaded: false,
_saveTask: null,
_blockerAdded: false,
// the in-memory database
DB: BLANK_DB(),
/**
* A getter to retrieve the path to the DB
*/
get jsonFile() {
return PathUtils.join(
Services.dirsvc.get("ProfD", Ci.nsIFile).path,
FILE_DATABASE
);
},
/**
* Asynchronously opens a new connection to the database file.
*
* @return {Promise} a promise that resolves to the database.
*/
openConnection() {
if (!this.connectionPromise) {
this.connectionPromise = (async () => {
let inputDB, schema;
try {
let data = await IOUtils.readUTF8(this.jsonFile);
inputDB = JSON.parse(data);
if (
!inputDB.hasOwnProperty("addons") ||
!Array.isArray(inputDB.addons)
) {
throw new Error("No addons array.");
}
if (!inputDB.hasOwnProperty("schema")) {
throw new Error("No schema specified.");
}
schema = parseInt(inputDB.schema, 10);
if (!Number.isInteger(schema) || schema < DB_MIN_JSON_SCHEMA) {
throw new Error("Invalid schema value.");
}
} catch (e) {
if (e.name == "NotFoundError") {
logger.debug("No " + FILE_DATABASE + " found.");
} else {
logger.error(
`Malformed ${FILE_DATABASE}: ${e} - resetting to empty`
);
}
// Create a blank addons.json file
this.save();
Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
this._loaded = true;
return this.DB;
}
Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
// Convert the addon objects as necessary
// and store them in our in-memory copy of the database.
for (let addon of inputDB.addons) {
let id = addon.id;
let entry = this._parseAddon(addon);
this.DB.addons.set(id, entry);
}
this._loaded = true;
return this.DB;
})();
}
return this.connectionPromise;
},
/**
* Asynchronously shuts down the database connection and releases all
* cached objects
*
* @param aCallback
* An optional callback to call once complete
* @param aSkipFlush
* An optional boolean to skip flushing data to disk. Useful
* when the database is going to be deleted afterwards.
*/
shutdown(aSkipFlush) {
if (!this.connectionPromise) {
return Promise.resolve();
}
this.connectionPromise = null;
this._loaded = false;
if (aSkipFlush) {
return Promise.resolve();
}
return this.flush();
},
/**
* Asynchronously deletes the database, shutting down the connection
* first if initialized
*
* @param aCallback
* An optional callback to call once complete
* @return Promise{null} resolves when the database has been deleted
*/
delete(aCallback) {
this.DB = BLANK_DB();
if (this._saveTask) {
this._saveTask.disarm();
this._saveTask = null;
}
// shutdown(true) never rejects
this._deleting = this.shutdown(true)
.then(() => IOUtils.remove(this.jsonFile))
.catch(error =>
logger.error(
"Unable to delete Addon Repository file " + this.jsonFile,
error
)
)
.then(() => (this._deleting = null))
.then(aCallback);
return this._deleting;
},
async _saveNow() {
let json = {
schema: this.DB.schema,
addons: Array.from(this.DB.addons.values()),
};
await IOUtils.writeUTF8(this.jsonFile, JSON.stringify(json), {
tmpPath: `${this.jsonFile}.tmp`,
});
},
save() {
if (!this._saveTask) {
this._saveTask = new lazy.DeferredTask(
() => this._saveNow(),
DB_BATCH_TIMEOUT_MS
);
if (!this._blockerAdded) {
lazy.AsyncShutdown.profileBeforeChange.addBlocker(
"Flush AddonRepository",
() => this.flush()
);
this._blockerAdded = true;
}
}
this._saveTask.arm();
},
/**
* Flush any pending I/O on the addons.json file
* @return: Promise{null}
* Resolves when the pending I/O (writing out or deleting
* addons.json) completes
*/
flush() {
if (this._deleting) {
return this._deleting;
}
if (this._saveTask) {
let promise = this._saveTask.finalize();
this._saveTask = null;
return promise;
}
return Promise.resolve();
},
/**
* Get an individual addon entry from the in-memory cache.
* Note: calling this function before the database is read will
* return undefined.
*
* @param {string} aId The id of the addon to retrieve.
*/
getAddon(aId) {
return this.DB.addons.get(aId);
},
/**
* Asynchronously repopulates the database so it only contains the
* specified add-ons
*
* @param {array} aAddons
* Add-ons to repopulate the database with.
*/
repopulate(aAddons) {
this.DB = BLANK_DB();
this._update(aAddons);
let now = Math.round(Date.now() / 1000);
logger.debug(
"Cache repopulated, setting " + PREF_METADATA_LASTUPDATE + " to " + now
);
Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, now);
},
/**
* Asynchronously insert new addons into the database.
*
* @param {array} aAddons
* Add-ons to insert/update in the database
*/
async update(aAddons) {
await this.openConnection();
this._update(aAddons);
},
/**
* Merge the given addons into the database.
*
* @param {array} aAddons
* Add-ons to insert/update in the database
*/
_update(aAddons) {
for (let addon of aAddons) {
this.DB.addons.set(addon.id, this._parseAddon(addon));
}
this.save();
},
/*
* Creates an AddonSearchResult by parsing an object structure
* retrieved from the DB JSON representation.
*
* @param aObj
* The object to parse
* @return Returns an AddonSearchResult object.
*/
_parseAddon(aObj) {
if (aObj instanceof AddonSearchResult) {
return aObj;
}
let id = aObj.id;
if (!aObj.id) {
return null;
}
let addon = new AddonSearchResult(id);
for (let expectedProperty of Object.keys(AddonSearchResult.prototype)) {
if (
!(expectedProperty in aObj) ||
typeof aObj[expectedProperty] === "function"
) {
continue;
}
let value = aObj[expectedProperty];
try {
switch (expectedProperty) {
case "sourceURI":
addon.sourceURI = value ? lazy.NetUtil.newURI(value) : null;
break;
case "creator":
addon.creator = value ? this._makeDeveloper(value) : null;
break;
case "updateDate":
addon.updateDate = value ? new Date(value) : null;
break;
case "developers":
if (!addon.developers) {
addon.developers = [];
}
for (let developer of value) {
addon.developers.push(this._makeDeveloper(developer));
}
break;
case "screenshots":
if (!addon.screenshots) {
addon.screenshots = [];
}
for (let screenshot of value) {
addon.screenshots.push(this._makeScreenshot(screenshot));
}
break;
case "icons":
if (!addon.icons) {
addon.icons = {};
}
for (let size of Object.keys(aObj.icons)) {
addon.icons[size] = aObj.icons[size];
}
break;
case "iconURL":
break;
default:
addon[expectedProperty] = value;
}
} catch (ex) {
logger.warn(
"Error in parsing property value for " + expectedProperty + " | " + ex
);
}
// delete property from obj to indicate we've already
// handled it. The remaining public properties will
// be stored separately and just passed through to
// be written back to the DB.
delete aObj[expectedProperty];
}
// Copy remaining properties to a separate object
// to prevent accidental access on downgraded versions.
// The properties will be merged in the same object
// prior to being written back through toJSON.
for (let remainingProperty of Object.keys(aObj)) {
switch (typeof aObj[remainingProperty]) {
case "boolean":
case "number":
case "string":
case "object":
// these types are accepted
break;
default:
continue;
}
if (!remainingProperty.startsWith("_")) {
addon._unsupportedProperties[remainingProperty] =
aObj[remainingProperty];
}
}
return addon;
},
/**
* Make a developer object from a vanilla
* JS object from the JSON database
*
* @param aObj
* The JS object to use
* @return The created developer
*/
_makeDeveloper(aObj) {
let name = aObj.name;
let url = aObj.url;
return new lazy.AddonManagerPrivate.AddonAuthor(name, url);
},
/**
* Make a screenshot object from a vanilla
* JS object from the JSON database
*
* @param aObj
* The JS object to use
* @return The created screenshot
*/
_makeScreenshot(aObj) {
let url = aObj.url;
let width = aObj.width;
let height = aObj.height;
let thumbnailURL = aObj.thumbnailURL;
let thumbnailWidth = aObj.thumbnailWidth;
let thumbnailHeight = aObj.thumbnailHeight;
let caption = aObj.caption;
return new lazy.AddonManagerPrivate.AddonScreenshot(
url,
width,
height,
thumbnailURL,
thumbnailWidth,
thumbnailHeight,
caption
);
},
};