Backed out 3 changesets (bug 1884623) for causing multiple failures CLOSED TREE
[gecko.git] / toolkit / mozapps / extensions / internal / AddonRepository.sys.mjs
blobe854e04b3ce279c25164524075b9f5aafa917e8e
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
11   AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
12   AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
13   DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
14   NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
15   ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
16 });
18 // The current platform as specified in the AMO API:
19 // http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#addon-detail-platform
20 ChromeUtils.defineLazyGetter(lazy, "PLATFORM", () => {
21   let platform = Services.appinfo.OS;
22   switch (platform) {
23     case "Darwin":
24       return "mac";
26     case "Linux":
27       return "linux";
29     case "Android":
30       return "android";
32     case "WINNT":
33       return "windows";
34   }
35   return platform;
36 });
38 const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
40 XPCOMUtils.defineLazyPreferenceGetter(
41   lazy,
42   "getAddonsCacheEnabled",
43   PREF_GETADDONS_CACHE_ENABLED
46 const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
47 const PREF_GETADDONS_CACHE_ID_ENABLED =
48   "extensions.%ID%.getAddons.cache.enabled";
49 const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
50 const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
51 const PREF_GETADDONS_BROWSESEARCHRESULTS =
52   "extensions.getAddons.search.browseURL";
53 const PREF_GETADDONS_DB_SCHEMA = "extensions.getAddons.databaseSchema";
54 const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
55 const PREF_GET_BROWSER_MAPPINGS = "extensions.getAddons.browserMappings.url";
57 const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
58 const PREF_METADATA_UPDATETHRESHOLD_SEC =
59   "extensions.getAddons.cache.updateThreshold";
60 const DEFAULT_METADATA_UPDATETHRESHOLD_SEC = 172800; // two days
62 const DEFAULT_CACHE_TYPES = "extension,theme,locale,dictionary";
64 const FILE_DATABASE = "addons.json";
65 const DB_SCHEMA = 6;
66 const DB_MIN_JSON_SCHEMA = 5;
67 const DB_BATCH_TIMEOUT_MS = 50;
69 const BLANK_DB = function () {
70   return {
71     addons: new Map(),
72     schema: DB_SCHEMA,
73   };
76 import { Log } from "resource://gre/modules/Log.sys.mjs";
78 const LOGGER_ID = "addons.repository";
80 // Create a new logger for use by the Addons Repository
81 // (Requires AddonManager.jsm)
82 var logger = Log.repository.getLogger(LOGGER_ID);
84 function convertHTMLToPlainText(html) {
85   if (!html) {
86     return html;
87   }
88   var converter = Cc[
89     "@mozilla.org/widget/htmlformatconverter;1"
90   ].createInstance(Ci.nsIFormatConverter);
92   var input = Cc["@mozilla.org/supports-string;1"].createInstance(
93     Ci.nsISupportsString
94   );
95   input.data = html.replace(/\n/g, "<br>");
97   var output = {};
98   converter.convert("text/html", input, "text/plain", output);
100   if (output.value instanceof Ci.nsISupportsString) {
101     return output.value.data.replace(/\r\n/g, "\n");
102   }
103   return html;
106 async function getAddonsToCache(aIds) {
107   let types = Services.prefs.getStringPref(
108     PREF_GETADDONS_CACHE_TYPES,
109     DEFAULT_CACHE_TYPES
110   );
112   types = types.split(",");
114   let addons = await lazy.AddonManager.getAddonsByIDs(aIds);
115   let enabledIds = [];
117   for (let [i, addon] of addons.entries()) {
118     var preference = PREF_GETADDONS_CACHE_ID_ENABLED.replace("%ID%", aIds[i]);
119     // If the preference doesn't exist caching is enabled by default
120     if (!Services.prefs.getBoolPref(preference, true)) {
121       continue;
122     }
124     // The add-ons manager may not know about this ID yet if it is a pending
125     // install. In that case we'll just cache it regardless
127     // Don't cache add-ons of the wrong types
128     if (addon && !types.includes(addon.type)) {
129       continue;
130     }
132     // Don't cache system add-ons
133     if (addon && addon.isSystem) {
134       continue;
135     }
137     enabledIds.push(aIds[i]);
138   }
140   return enabledIds;
143 function AddonSearchResult(aId) {
144   this.id = aId;
145   this.icons = {};
146   this._unsupportedProperties = {};
149 AddonSearchResult.prototype = {
150   /**
151    * The ID of the add-on
152    */
153   id: null,
155   /**
156    * The add-on type (e.g. "extension" or "theme")
157    */
158   type: null,
160   /**
161    * The name of the add-on
162    */
163   name: null,
165   /**
166    * The version of the add-on
167    */
168   version: null,
170   /**
171    * The creator of the add-on
172    */
173   creator: null,
175   /**
176    * The developers of the add-on
177    */
178   developers: null,
180   /**
181    * A short description of the add-on
182    */
183   description: null,
185   /**
186    * The full description of the add-on
187    */
188   fullDescription: null,
190   /**
191    * The end-user licensing agreement (EULA) of the add-on
192    */
193   eula: null,
195   /**
196    * The url of the add-on's icon
197    */
198   get iconURL() {
199     return this.icons && this.icons[32];
200   },
202   /**
203    * The URLs of the add-on's icons, as an object with icon size as key
204    */
205   icons: null,
207   /**
208    * An array of screenshot urls for the add-on
209    */
210   screenshots: null,
212   /**
213    * The homepage for the add-on
214    */
215   homepageURL: null,
217   /**
218    * The support URL for the add-on
219    */
220   supportURL: null,
222   /**
223    * The contribution url of the add-on
224    */
225   contributionURL: null,
227   /**
228    * The rating of the add-on, 0-5
229    */
230   averageRating: null,
232   /**
233    * The number of reviews for this add-on
234    */
235   reviewCount: null,
237   /**
238    * The URL to the list of reviews for this add-on
239    */
240   reviewURL: null,
242   /**
243    * The number of times the add-on was downloaded the current week
244    */
245   weeklyDownloads: null,
247   /**
248    * The URL to the AMO detail page of this (listed) add-on
249    */
250   amoListingURL: null,
252   /**
253    * AddonInstall object generated from the add-on XPI url
254    */
255   install: null,
257   /**
258    * nsIURI storing where this add-on was installed from
259    */
260   sourceURI: null,
262   /**
263    * The Date that the add-on was most recently updated
264    */
265   updateDate: null,
267   toJSON() {
268     let json = {};
270     for (let property of Object.keys(this)) {
271       let value = this[property];
272       if (property.startsWith("_") || typeof value === "function") {
273         continue;
274       }
276       try {
277         switch (property) {
278           case "sourceURI":
279             json.sourceURI = value ? value.spec : "";
280             break;
282           case "updateDate":
283             json.updateDate = value ? value.getTime() : "";
284             break;
286           default:
287             json[property] = value;
288         }
289       } catch (ex) {
290         logger.warn("Error writing property value for " + property);
291       }
292     }
294     for (let property of Object.keys(this._unsupportedProperties)) {
295       let value = this._unsupportedProperties[property];
296       if (!property.startsWith("_")) {
297         json[property] = value;
298       }
299     }
301     return json;
302   },
306  * The add-on repository is a source of add-ons that can be installed. It can
307  * be searched in three ways. The first takes a list of IDs and returns a
308  * list of the corresponding add-ons. The second returns a list of add-ons that
309  * come highly recommended. This list should change frequently. The third is to
310  * search for specific search terms entered by the user. Searches are
311  * asynchronous and results should be passed to the provided callback object
312  * when complete. The results passed to the callback should only include add-ons
313  * that are compatible with the current application and are not already
314  * installed.
315  */
316 export var AddonRepository = {
317   /**
318    * The homepage for visiting this repository. If the corresponding preference
319    * is not defined, defaults to about:blank.
320    */
321   get homepageURL() {
322     let url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {});
323     return url != null ? url : "about:blank";
324   },
326   get appIsShuttingDown() {
327     return Services.startup.shuttingDown;
328   },
330   /**
331    * Retrieves the url that can be visited to see search results for the given
332    * terms. If the corresponding preference is not defined, defaults to
333    * about:blank.
334    *
335    * @param  aSearchTerms
336    *         Search terms used to search the repository
337    */
338   getSearchURL(aSearchTerms) {
339     let url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, {
340       TERMS: aSearchTerms,
341     });
342     return url != null ? url : "about:blank";
343   },
345   /**
346    * Whether caching is currently enabled
347    */
348   get cacheEnabled() {
349     return lazy.getAddonsCacheEnabled;
350   },
352   /**
353    * Shut down AddonRepository
354    * return: promise{integer} resolves with the result of flushing
355    *         the AddonRepository database
356    */
357   shutdown() {
358     return AddonDatabase.shutdown(false);
359   },
361   metadataAge() {
362     let now = Math.round(Date.now() / 1000);
363     let lastUpdate = Services.prefs.getIntPref(PREF_METADATA_LASTUPDATE, 0);
364     return Math.max(0, now - lastUpdate);
365   },
367   isMetadataStale() {
368     let threshold = Services.prefs.getIntPref(
369       PREF_METADATA_UPDATETHRESHOLD_SEC,
370       DEFAULT_METADATA_UPDATETHRESHOLD_SEC
371     );
372     return this.metadataAge() > threshold;
373   },
375   /**
376    * Asynchronously get a cached add-on by id. The add-on (or null if the
377    * add-on is not found) is passed to the specified callback. If caching is
378    * disabled, null is passed to the specified callback.
379    *
380    * The callback variant exists only for existing code in XPIProvider.sys.mjs
381    * and XPIDatabase.sys.mjs that requires a synchronous callback, yuck.
382    *
383    * @param  aId
384    *         The id of the add-on to get
385    */
386   async getCachedAddonByID(aId, aCallback) {
387     if (!aId || !this.cacheEnabled) {
388       if (aCallback) {
389         aCallback(null);
390       }
391       return null;
392     }
394     if (aCallback && AddonDatabase._loaded) {
395       let addon = AddonDatabase.getAddon(aId);
396       aCallback(addon);
397       return addon;
398     }
400     await AddonDatabase.openConnection();
402     let addon = AddonDatabase.getAddon(aId);
403     if (aCallback) {
404       aCallback(addon);
405     }
406     return addon;
407   },
409   /*
410    * Clear and delete the AddonRepository database
411    * @return Promise{null} resolves when the database is deleted
412    */
413   _clearCache() {
414     return AddonDatabase.delete().then(() =>
415       lazy.AddonManagerPrivate.updateAddonRepositoryData()
416     );
417   },
419   /*
420    * Create a ServiceRequest instance.
421    * @return ServiceRequest returns a ServiceRequest instance.
422    */
423   _createServiceRequest() {
424     return new lazy.ServiceRequest({ mozAnon: true });
425   },
427   /**
428    * Fetch data from an API where the results may span multiple "pages".
429    * This function will take care of issuing multiple requests until all
430    * the results have been fetched, and will coalesce them all into a
431    * single return value.  The handling here is specific to the way AMO
432    * implements paging (ie a JSON result with a "next" property).
433    *
434    * @param {string} pref
435    *                 The pref name that contains the API URL to call.
436    * @param {object} params
437    *                 A key-value object that contains the parameters to replace
438    *                 in the API URL.
439    * @param {function} handler
440    *                   This function will be called once per page of results,
441    *                   it should return an array of objects (the type depends
442    *                   on the particular API being called of course).
443    *
444    * @returns Promise{array} An array of all the individual results from
445    *                         the API call(s).
446    */
447   _fetchPaged(pref, params, handler) {
448     const startURL = this._formatURLPref(pref, params);
450     let results = [];
451     const fetchNextPage = url => {
452       return new Promise((resolve, reject) => {
453         if (this.appIsShuttingDown) {
454           logger.debug(
455             "Rejecting AddonRepository._fetchPaged call, shutdown already in progress"
456           );
457           reject(
458             new Error(
459               `Reject ServiceRequest for "${url}", shutdown already in progress`
460             )
461           );
462           return;
463         }
464         let request = this._createServiceRequest();
465         request.mozBackgroundRequest = true;
466         request.open("GET", url, true);
467         request.responseType = "json";
469         request.addEventListener("error", aEvent => {
470           reject(new Error(`GET ${url} failed`));
471         });
472         request.addEventListener("timeout", aEvent => {
473           reject(new Error(`GET ${url} timed out`));
474         });
475         request.addEventListener("load", aEvent => {
476           let response = request.response;
477           if (!response || (request.status != 200 && request.status != 0)) {
478             reject(new Error(`GET ${url} failed (status ${request.status})`));
479             return;
480           }
482           try {
483             results.push(...handler(response.results));
484           } catch (err) {
485             reject(err);
486           }
488           if (response.next) {
489             resolve(fetchNextPage(response.next));
490           }
492           resolve(results);
493         });
495         request.send(null);
496       });
497     };
499     return fetchNextPage(startURL);
500   },
502   /**
503    * Fetch metadata for a given set of addons from AMO.
504    *
505    * @param  aIDs
506    *         The array of ids to retrieve metadata for.
507    * @returns {array<AddonSearchResult>}
508    */
509   async getAddonsByIDs(aIDs) {
510     const idCheck = aIDs.map(id => {
511       if (id.startsWith("rta:")) {
512         return atob(id.split(":")[1]);
513       }
514       return id;
515     });
517     const addons = await this._fetchPaged(
518       PREF_GETADDONS_BYIDS,
519       { IDS: aIDs.join(",") },
520       results =>
521         results
522           .map(entry => this._parseAddon(entry))
523           // Only return the add-ons corresponding the IDs passed to this method.
524           .filter(addon => idCheck.includes(addon.id))
525     );
527     return addons;
528   },
530   /**
531    * Fetch the Firefox add-ons mapped to the list of extension IDs for the
532    * browser ID passed to this method.
533    *
534    * See: https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#browser-mappings
535    *
536    * @param browserID
537    *        The browser ID used to retrieve the mapping of IDs.
538    * @param extensionIDs
539    *        The array of browser (non-Firefox) extension IDs to retrieve
540    *        metadata for.
541    * @returns {object} result
542    *        The result of the mapping.
543    * @returns {array<AddonSearchResult>} result.addons
544    *        The AddonSearchResults for the addons that were successfully mapped.
545    * @returns {array<string>} result.matchedIDs
546    *        The IDs of the extensions that were successfully matched to
547    *        equivalents that can be installed in this browser. These are
548    *        the IDs before matching to equivalents.
549    * @returns {array<string>} result.unmatchedIDs
550    *        The IDs of the extensions that were not matched to equivalents.
551    */
552   async getMappedAddons(browserID, extensionIDs) {
553     let matchedExtensionIDs = new Set();
554     let unmatchedExtensionIDs = new Set(extensionIDs);
556     const addonIds = await this._fetchPaged(
557       PREF_GET_BROWSER_MAPPINGS,
558       { BROWSER: browserID },
559       results =>
560         results
561           // Filter out all the entries with an extension ID not in the list
562           // passed to the method.
563           .filter(entry => {
564             if (unmatchedExtensionIDs.has(entry.extension_id)) {
565               unmatchedExtensionIDs.delete(entry.extension_id);
566               matchedExtensionIDs.add(entry.extension_id);
567               return true;
568             }
569             return false;
570           })
571           // Return the add-on ID (stored as `guid` on AMO).
572           .map(entry => entry.addon_guid)
573     );
575     if (!addonIds.length) {
576       return {
577         addons: [],
578         matchedIDs: [],
579         unmatchedIDs: [...unmatchedExtensionIDs],
580       };
581     }
583     return {
584       addons: await this.getAddonsByIDs(addonIds),
585       matchedIDs: [...matchedExtensionIDs],
586       unmatchedIDs: [...unmatchedExtensionIDs],
587     };
588   },
590   /**
591    * Asynchronously add add-ons to the cache corresponding to the specified
592    * ids. If caching is disabled, the cache is unchanged.
593    *
594    * @param  aIds
595    *         The array of add-on ids to add to the cache
596    * @returns {array<AddonSearchResult>} Add-ons to add to the cache.
597    */
598   async cacheAddons(aIds) {
599     logger.debug(
600       "cacheAddons: enabled " + this.cacheEnabled + " IDs " + aIds.toSource()
601     );
602     if (!this.cacheEnabled) {
603       return [];
604     }
606     let ids = await getAddonsToCache(aIds);
608     // If there are no add-ons to cache, act as if caching is disabled
609     if (!ids.length) {
610       return [];
611     }
613     let addons = [];
614     try {
615       addons = await this.getAddonsByIDs(ids);
616     } catch (err) {
617       logger.error(`Error in addon metadata check: ${err.message}`);
618     }
619     if (addons.length) {
620       await AddonDatabase.update(addons);
621     }
622     return addons;
623   },
625   /**
626    * Get all installed addons from the AddonManager singleton.
627    *
628    * @return Promise{array<AddonWrapper>} Resolves to an array of AddonWrapper instances.
629    */
630   _getAllInstalledAddons() {
631     return lazy.AddonManager.getAllAddons();
632   },
634   /**
635    * Performs the periodic background update check.
636    *
637    * In Firefox Desktop builds, the background update check is triggered on a
638    * daily basis as part of the AOM background update check and registered
639    * from: `toolkit/mozapps/extensions/extensions.manifest`
640    *
641    * In GeckoView builds, add-ons are checked for updates individually. The
642    * `AddonRepository.backgroundUpdateCheck()` method is called by the
643    * `updateWebExtension()` method defined in `GeckoViewWebExtensions.sys.mjs`
644    * but only when `AddonRepository.isMetadataStale()` returns true.
645    *
646    * @return Promise{null} Resolves when the metadata update is complete.
647    */
648   async backgroundUpdateCheck() {
649     let shutter = (async () => {
650       if (this.appIsShuttingDown) {
651         logger.debug(
652           "Returning earlier from backgroundUpdateCheck, shutdown already in progress"
653         );
654         return;
655       }
657       let allAddons = await this._getAllInstalledAddons();
659       // Completely remove cache if caching is not enabled
660       if (!this.cacheEnabled) {
661         logger.debug("Clearing cache because it is disabled");
662         await this._clearCache();
663         return;
664       }
666       let ids = allAddons.map(a => a.id);
667       logger.debug("Repopulate add-on cache with " + ids.toSource());
669       let addonsToCache = await getAddonsToCache(ids);
671       // Completely remove cache if there are no add-ons to cache
672       if (!addonsToCache.length) {
673         logger.debug("Clearing cache because 0 add-ons were requested");
674         await this._clearCache();
675         return;
676       }
678       let addons;
679       try {
680         addons = await this.getAddonsByIDs(addonsToCache);
681       } catch (err) {
682         // This is likely to happen if the server is unreachable, e.g. when
683         // there is no network connectivity.
684         logger.error(`Error in addon metadata lookup: ${err.message}`);
685         // Return now to avoid calling repopulate with an empty array;
686         // doing so would clear the cache.
687         return;
688       }
690       AddonDatabase.repopulate(addons);
692       // Always call AddonManager updateAddonRepositoryData after we refill the cache
693       await lazy.AddonManagerPrivate.updateAddonRepositoryData();
694     })();
695     lazy.AddonManager.beforeShutdown.addBlocker(
696       "AddonRepository Background Updater",
697       shutter
698     );
699     await shutter;
700     lazy.AddonManager.beforeShutdown.removeBlocker(shutter);
701   },
703   /*
704    * Creates an AddonSearchResult by parsing an entry from the AMO API.
705    *
706    * @param  aEntry
707    *         An entry from the AMO search API to parse.
708    * @return Result object containing the parsed AddonSearchResult
709    */
710   _parseAddon(aEntry) {
711     let addon = new AddonSearchResult(aEntry.guid);
713     addon.name = aEntry.name;
714     if (typeof aEntry.current_version == "object") {
715       addon.version = String(aEntry.current_version.version);
716       if (Array.isArray(aEntry.current_version.files)) {
717         for (let file of aEntry.current_version.files) {
718           if (file.platform == "all" || file.platform == lazy.PLATFORM) {
719             if (file.url) {
720               addon.sourceURI = lazy.NetUtil.newURI(file.url);
721             }
722             break;
723           }
724         }
725       }
726     }
727     addon.homepageURL = aEntry.homepage;
728     addon.supportURL = aEntry.support_url;
729     addon.amoListingURL = aEntry.url;
731     addon.description = convertHTMLToPlainText(aEntry.summary);
732     addon.fullDescription = convertHTMLToPlainText(aEntry.description);
734     addon.weeklyDownloads = aEntry.weekly_downloads;
736     switch (aEntry.type) {
737       case "persona":
738       case "statictheme":
739         addon.type = "theme";
740         break;
742       case "language":
743         addon.type = "locale";
744         break;
746       default:
747         addon.type = aEntry.type;
748         break;
749     }
751     if (Array.isArray(aEntry.authors)) {
752       let authors = aEntry.authors.map(
753         author =>
754           new lazy.AddonManagerPrivate.AddonAuthor(author.name, author.url)
755       );
756       if (authors.length) {
757         addon.creator = authors[0];
758         addon.developers = authors.slice(1);
759       }
760     }
762     if (typeof aEntry.previews == "object") {
763       addon.screenshots = aEntry.previews.map(shot => {
764         let safeSize = orig =>
765           Array.isArray(orig) && orig.length >= 2 ? orig : [null, null];
766         let imageSize = safeSize(shot.image_size);
767         let thumbSize = safeSize(shot.thumbnail_size);
768         return new lazy.AddonManagerPrivate.AddonScreenshot(
769           shot.image_url,
770           imageSize[0],
771           imageSize[1],
772           shot.thumbnail_url,
773           thumbSize[0],
774           thumbSize[1],
775           shot.caption
776         );
777       });
778     }
780     addon.contributionURL = aEntry.contributions_url;
782     if (typeof aEntry.ratings == "object") {
783       addon.averageRating = Math.min(5, aEntry.ratings.average);
784       addon.reviewCount = aEntry.ratings.text_count;
785     }
787     addon.reviewURL = aEntry.ratings_url;
788     if (aEntry.last_updated) {
789       addon.updateDate = new Date(aEntry.last_updated);
790     }
792     addon.icons = aEntry.icons || {};
794     return addon;
795   },
797   // Create url from preference, returning null if preference does not exist
798   _formatURLPref(aPreference, aSubstitutions = {}) {
799     let url = Services.prefs.getCharPref(aPreference, "");
800     if (!url) {
801       logger.warn("_formatURLPref: Couldn't get pref: " + aPreference);
802       return null;
803     }
805     url = url.replace(/%([A-Z_]+)%/g, function (aMatch, aKey) {
806       return aKey in aSubstitutions
807         ? encodeURIComponent(aSubstitutions[aKey])
808         : aMatch;
809     });
811     return Services.urlFormatter.formatURL(url);
812   },
814   flush() {
815     return AddonDatabase.flush();
816   },
818   async getAvailableLangpacks() {
819     // This should be the API endpoint documented at:
820     // http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#language-tools
821     let url = this._formatURLPref(PREF_GET_LANGPACKS);
823     let response = await fetch(url, { credentials: "omit" });
824     if (!response.ok) {
825       throw new Error("fetching available language packs failed");
826     }
828     let data = await response.json();
830     let result = [];
831     for (let entry of data.results) {
832       if (
833         !entry.current_compatible_version ||
834         !entry.current_compatible_version.files
835       ) {
836         continue;
837       }
839       for (let file of entry.current_compatible_version.files) {
840         if (
841           file.platform == "all" ||
842           file.platform == Services.appinfo.OS.toLowerCase()
843         ) {
844           result.push({
845             target_locale: entry.target_locale,
846             url: file.url,
847             hash: file.hash,
848           });
849         }
850       }
851     }
853     return result;
854   },
857 var AddonDatabase = {
858   connectionPromise: null,
859   _loaded: false,
860   _saveTask: null,
861   _blockerAdded: false,
863   // the in-memory database
864   DB: BLANK_DB(),
866   /**
867    * A getter to retrieve the path to the DB
868    */
869   get jsonFile() {
870     return PathUtils.join(
871       Services.dirsvc.get("ProfD", Ci.nsIFile).path,
872       FILE_DATABASE
873     );
874   },
876   /**
877    * Asynchronously opens a new connection to the database file.
878    *
879    * @return {Promise} a promise that resolves to the database.
880    */
881   openConnection() {
882     if (!this.connectionPromise) {
883       this.connectionPromise = (async () => {
884         let inputDB, schema;
886         try {
887           let data = await IOUtils.readUTF8(this.jsonFile);
888           inputDB = JSON.parse(data);
890           if (
891             !inputDB.hasOwnProperty("addons") ||
892             !Array.isArray(inputDB.addons)
893           ) {
894             throw new Error("No addons array.");
895           }
897           if (!inputDB.hasOwnProperty("schema")) {
898             throw new Error("No schema specified.");
899           }
901           schema = parseInt(inputDB.schema, 10);
903           if (!Number.isInteger(schema) || schema < DB_MIN_JSON_SCHEMA) {
904             throw new Error("Invalid schema value.");
905           }
906         } catch (e) {
907           if (e.name == "NotFoundError") {
908             logger.debug("No " + FILE_DATABASE + " found.");
909           } else {
910             logger.error(
911               `Malformed ${FILE_DATABASE}: ${e} - resetting to empty`
912             );
913           }
915           // Create a blank addons.json file
916           this.save();
918           Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
919           this._loaded = true;
920           return this.DB;
921         }
923         Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
925         // Convert the addon objects as necessary
926         // and store them in our in-memory copy of the database.
927         for (let addon of inputDB.addons) {
928           let id = addon.id;
930           let entry = this._parseAddon(addon);
931           this.DB.addons.set(id, entry);
932         }
934         this._loaded = true;
935         return this.DB;
936       })();
937     }
939     return this.connectionPromise;
940   },
942   /**
943    * Asynchronously shuts down the database connection and releases all
944    * cached objects
945    *
946    * @param  aCallback
947    *         An optional callback to call once complete
948    * @param  aSkipFlush
949    *         An optional boolean to skip flushing data to disk. Useful
950    *         when the database is going to be deleted afterwards.
951    */
952   shutdown(aSkipFlush) {
953     if (!this.connectionPromise) {
954       return Promise.resolve();
955     }
957     this.connectionPromise = null;
958     this._loaded = false;
960     if (aSkipFlush) {
961       return Promise.resolve();
962     }
964     return this.flush();
965   },
967   /**
968    * Asynchronously deletes the database, shutting down the connection
969    * first if initialized
970    *
971    * @param  aCallback
972    *         An optional callback to call once complete
973    * @return Promise{null} resolves when the database has been deleted
974    */
975   delete(aCallback) {
976     this.DB = BLANK_DB();
978     if (this._saveTask) {
979       this._saveTask.disarm();
980       this._saveTask = null;
981     }
983     // shutdown(true) never rejects
984     this._deleting = this.shutdown(true)
985       .then(() => IOUtils.remove(this.jsonFile))
986       .catch(error =>
987         logger.error(
988           "Unable to delete Addon Repository file " + this.jsonFile,
989           error
990         )
991       )
992       .then(() => (this._deleting = null))
993       .then(aCallback);
995     return this._deleting;
996   },
998   async _saveNow() {
999     let json = {
1000       schema: this.DB.schema,
1001       addons: Array.from(this.DB.addons.values()),
1002     };
1004     await IOUtils.writeUTF8(this.jsonFile, JSON.stringify(json), {
1005       tmpPath: `${this.jsonFile}.tmp`,
1006     });
1007   },
1009   save() {
1010     if (!this._saveTask) {
1011       this._saveTask = new lazy.DeferredTask(
1012         () => this._saveNow(),
1013         DB_BATCH_TIMEOUT_MS
1014       );
1016       if (!this._blockerAdded) {
1017         lazy.AsyncShutdown.profileBeforeChange.addBlocker(
1018           "Flush AddonRepository",
1019           () => this.flush()
1020         );
1021         this._blockerAdded = true;
1022       }
1023     }
1024     this._saveTask.arm();
1025   },
1027   /**
1028    * Flush any pending I/O on the addons.json file
1029    * @return: Promise{null}
1030    *          Resolves when the pending I/O (writing out or deleting
1031    *          addons.json) completes
1032    */
1033   flush() {
1034     if (this._deleting) {
1035       return this._deleting;
1036     }
1038     if (this._saveTask) {
1039       let promise = this._saveTask.finalize();
1040       this._saveTask = null;
1041       return promise;
1042     }
1044     return Promise.resolve();
1045   },
1047   /**
1048    * Get an individual addon entry from the in-memory cache.
1049    * Note: calling this function before the database is read will
1050    * return undefined.
1051    *
1052    * @param {string} aId The id of the addon to retrieve.
1053    */
1054   getAddon(aId) {
1055     return this.DB.addons.get(aId);
1056   },
1058   /**
1059    * Asynchronously repopulates the database so it only contains the
1060    * specified add-ons
1061    *
1062    * @param {array<AddonSearchResult>} aAddons
1063    *              Add-ons to repopulate the database with.
1064    */
1065   repopulate(aAddons) {
1066     this.DB = BLANK_DB();
1067     this._update(aAddons);
1069     let now = Math.round(Date.now() / 1000);
1070     logger.debug(
1071       "Cache repopulated, setting " + PREF_METADATA_LASTUPDATE + " to " + now
1072     );
1073     Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, now);
1074   },
1076   /**
1077    * Asynchronously insert new addons into the database.
1078    *
1079    * @param {array<AddonSearchResult>} aAddons
1080    *              Add-ons to insert/update in the database
1081    */
1082   async update(aAddons) {
1083     await this.openConnection();
1085     this._update(aAddons);
1086   },
1088   /**
1089    * Merge the given addons into the database.
1090    *
1091    * @param {array<AddonSearchResult>} aAddons
1092    *              Add-ons to insert/update in the database
1093    */
1094   _update(aAddons) {
1095     for (let addon of aAddons) {
1096       this.DB.addons.set(addon.id, this._parseAddon(addon));
1097     }
1099     this.save();
1100   },
1102   /*
1103    * Creates an AddonSearchResult by parsing an object structure
1104    * retrieved from the DB JSON representation.
1105    *
1106    * @param  aObj
1107    *         The object to parse
1108    * @return Returns an AddonSearchResult object.
1109    */
1110   _parseAddon(aObj) {
1111     if (aObj instanceof AddonSearchResult) {
1112       return aObj;
1113     }
1115     let id = aObj.id;
1116     if (!aObj.id) {
1117       return null;
1118     }
1120     let addon = new AddonSearchResult(id);
1122     for (let expectedProperty of Object.keys(AddonSearchResult.prototype)) {
1123       if (
1124         !(expectedProperty in aObj) ||
1125         typeof aObj[expectedProperty] === "function"
1126       ) {
1127         continue;
1128       }
1130       let value = aObj[expectedProperty];
1132       try {
1133         switch (expectedProperty) {
1134           case "sourceURI":
1135             addon.sourceURI = value ? lazy.NetUtil.newURI(value) : null;
1136             break;
1138           case "creator":
1139             addon.creator = value ? this._makeDeveloper(value) : null;
1140             break;
1142           case "updateDate":
1143             addon.updateDate = value ? new Date(value) : null;
1144             break;
1146           case "developers":
1147             if (!addon.developers) {
1148               addon.developers = [];
1149             }
1150             for (let developer of value) {
1151               addon.developers.push(this._makeDeveloper(developer));
1152             }
1153             break;
1155           case "screenshots":
1156             if (!addon.screenshots) {
1157               addon.screenshots = [];
1158             }
1159             for (let screenshot of value) {
1160               addon.screenshots.push(this._makeScreenshot(screenshot));
1161             }
1162             break;
1164           case "icons":
1165             if (!addon.icons) {
1166               addon.icons = {};
1167             }
1168             for (let size of Object.keys(aObj.icons)) {
1169               addon.icons[size] = aObj.icons[size];
1170             }
1171             break;
1173           case "iconURL":
1174             break;
1176           default:
1177             addon[expectedProperty] = value;
1178         }
1179       } catch (ex) {
1180         logger.warn(
1181           "Error in parsing property value for " + expectedProperty + " | " + ex
1182         );
1183       }
1185       // delete property from obj to indicate we've already
1186       // handled it. The remaining public properties will
1187       // be stored separately and just passed through to
1188       // be written back to the DB.
1189       delete aObj[expectedProperty];
1190     }
1192     // Copy remaining properties to a separate object
1193     // to prevent accidental access on downgraded versions.
1194     // The properties will be merged in the same object
1195     // prior to being written back through toJSON.
1196     for (let remainingProperty of Object.keys(aObj)) {
1197       switch (typeof aObj[remainingProperty]) {
1198         case "boolean":
1199         case "number":
1200         case "string":
1201         case "object":
1202           // these types are accepted
1203           break;
1204         default:
1205           continue;
1206       }
1208       if (!remainingProperty.startsWith("_")) {
1209         addon._unsupportedProperties[remainingProperty] =
1210           aObj[remainingProperty];
1211       }
1212     }
1214     return addon;
1215   },
1217   /**
1218    * Make a developer object from a vanilla
1219    * JS object from the JSON database
1220    *
1221    * @param  aObj
1222    *         The JS object to use
1223    * @return The created developer
1224    */
1225   _makeDeveloper(aObj) {
1226     let name = aObj.name;
1227     let url = aObj.url;
1228     return new lazy.AddonManagerPrivate.AddonAuthor(name, url);
1229   },
1231   /**
1232    * Make a screenshot object from a vanilla
1233    * JS object from the JSON database
1234    *
1235    * @param  aObj
1236    *         The JS object to use
1237    * @return The created screenshot
1238    */
1239   _makeScreenshot(aObj) {
1240     let url = aObj.url;
1241     let width = aObj.width;
1242     let height = aObj.height;
1243     let thumbnailURL = aObj.thumbnailURL;
1244     let thumbnailWidth = aObj.thumbnailWidth;
1245     let thumbnailHeight = aObj.thumbnailHeight;
1246     let caption = aObj.caption;
1247     return new lazy.AddonManagerPrivate.AddonScreenshot(
1248       url,
1249       width,
1250       height,
1251       thumbnailURL,
1252       thumbnailWidth,
1253       thumbnailHeight,
1254       caption
1255     );
1256   },