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/. */
6 const DEFAULT_SECONDS_BETWEEN_CHECKS = 60 * 60 * 24;
8 import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";
10 import { Log } from "resource://gre/modules/Log.sys.mjs";
16 } from "resource://gre/modules/GMPUtils.sys.mjs";
18 import { ProductAddonChecker } from "resource://gre/modules/addons/ProductAddonChecker.sys.mjs";
22 ChromeUtils.defineESModuleGetters(lazy, {
23 CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
24 FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
25 ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
26 UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
29 function getScopedLogger(prefix) {
30 // `PARENT_LOGGER_ID.` being passed here effectively links this logger
31 // to the parentLogger.
32 return Log.repository.getLoggerWithMessagePrefix("Toolkit.GMP", prefix + " ");
35 const LOCAL_GMP_SOURCES = [
37 id: "gmp-gmpopenh264",
38 src: "chrome://global/content/gmp-sources/openh264.json",
41 id: "gmp-widevinecdm",
42 src: "chrome://global/content/gmp-sources/widevinecdm.json",
46 function downloadJSON(uri) {
47 let log = getScopedLogger("GMPInstallManager.checkForAddons");
48 log.info("fetching config from: " + uri);
49 return new Promise((resolve, reject) => {
50 let xmlHttp = new lazy.ServiceRequest({ mozAnon: true });
52 xmlHttp.onload = function (aResponse) {
53 resolve(JSON.parse(this.responseText));
56 xmlHttp.onerror = function (e) {
57 reject("Fetching " + uri + " results in error code: " + e.target.status);
60 xmlHttp.open("GET", uri);
61 xmlHttp.overrideMimeType("application/json");
67 * If downloading from the network fails (AUS server is down),
68 * load the sources from local build configuration.
70 function downloadLocalConfig() {
71 let log = getScopedLogger("GMPInstallManager.downloadLocalConfig");
73 LOCAL_GMP_SOURCES.map(conf => {
74 return downloadJSON(conf.src).then(addons => {
75 let platforms = addons.vendors[conf.id].platforms;
76 let target = Services.appinfo.OS + "_" + lazy.UpdateUtils.ABI;
80 if (!(target in platforms)) {
81 // There was no matching platform so return false, this addon
82 // will be filtered from the results below
83 log.info("no details found for: " + target);
86 // Field either has the details of the binary or is an alias
87 // to another build target key that does
88 if (platforms[target].alias) {
89 target = platforms[target].alias;
91 details = platforms[target];
95 log.info("found plugin: " + conf.id);
99 hashFunction: addons.hashFunction,
100 hashValue: details.hashValue,
101 version: addons.vendors[conf.id].version,
102 size: details.filesize,
107 // Some filters may not match this platform so
109 addons = addons.filter(x => x !== false);
119 * Provides an easy API for downloading and installing GMP Addons
121 export function GMPInstallManager() {}
124 * Temp file name used for downloading
126 GMPInstallManager.prototype = {
128 * Obtains a URL with replacement of vars
131 let log = getScopedLogger("GMPInstallManager._getURL");
132 // Use the override URL if it is specified. The override URL is just like
133 // the normal URL but it does not check the cert.
134 let url = GMPPrefs.getString(GMPPrefs.KEY_URL_OVERRIDE, "");
136 log.info("Using override url: " + url);
138 url = GMPPrefs.getString(GMPPrefs.KEY_URL);
139 log.info("Using url: " + url);
142 url = await lazy.UpdateUtils.formatUpdateURL(url);
144 log.info("Using url (with replacement): " + url);
149 * Records telemetry results on if fetching update.xml from Balrog succeeded
150 * when content signature was used to verify the response from Balrog.
151 * @param didGetAddonList
152 * A boolean indicating if an update.xml containing the addon list was
153 * successfully fetched (true) or not (false).
155 * The error that was thrown (if it exists) for the failure case. This
156 * is expected to have a addonCheckerErr member which provides further
157 * information on why the addon checker failed.
159 recordUpdateXmlTelemetryForContentSignature(didGetAddonList, err = null) {
160 let log = getScopedLogger(
161 "GMPInstallManager.recordUpdateXmlTelemetryForContentSignature"
164 let updateResultHistogram = Services.telemetry.getHistogramById(
165 "MEDIA_GMP_UPDATE_XML_FETCH_RESULT"
168 // The non-glean telemetry used here will be removed in future and just
169 // the glean data will be gathered.
170 if (didGetAddonList) {
171 updateResultHistogram.add("content_sig_ok");
172 Glean.gmp.updateXmlFetchResult.content_sig_success.add(1);
175 // All remaining cases are failure cases.
176 updateResultHistogram.add("content_sig_fail");
177 if (!err?.addonCheckerErr) {
178 // Unknown error case. If this is happening we should audit error paths
179 // to identify why we're not getting an error, or not getting it
181 Glean.gmp.updateXmlFetchResult.content_sig_unknown_error.add(1);
184 const errorToHistogramMap = {
185 [ProductAddonChecker.NETWORK_REQUEST_ERR]:
186 "content_sig_net_request_error",
187 [ProductAddonChecker.NETWORK_TIMEOUT_ERR]: "content_sig_net_timeout",
188 [ProductAddonChecker.ABORT_ERR]: "content_sig_abort",
189 [ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR]:
190 "content_sig_missing_data",
191 [ProductAddonChecker.VERIFICATION_FAILED_ERR]: "content_sig_failed",
192 [ProductAddonChecker.VERIFICATION_INVALID_ERR]: "content_sig_invalid",
193 [ProductAddonChecker.XML_PARSE_ERR]: "content_sig_xml_parse_error",
196 errorToHistogramMap[err.addonCheckerErr] ?? "content_sig_unknown_error";
197 let metric = Glean.gmp.updateXmlFetchResult[metricID];
200 // We don't expect this path to be hit, but we don't want telemetry
201 // failures to break GMP updates, so catch any issues here and let the
202 // update machinery continue.
204 `Failed to record telemetry result of getProductAddonList, got error: ${e}`
210 * Records telemetry results on if fetching update.xml from Balrog succeeded
211 * when cert pinning was used to verify the response from Balrog. This
212 * should be removed once we're no longer using cert pinning.
213 * @param didGetAddonList
214 * A boolean indicating if an update.xml containing the addon list was
215 * successfully fetched (true) or not (false).
217 * The error that was thrown (if it exists) for the failure case. This
218 * is expected to have a addonCheckerErr member which provides further
219 * information on why the addon checker failed.
221 recordUpdateXmlTelemetryForCertPinning(didGetAddonList, err = null) {
222 let log = getScopedLogger(
223 "GMPInstallManager.recordUpdateXmlTelemetryForCertPinning"
226 let updateResultHistogram = Services.telemetry.getHistogramById(
227 "MEDIA_GMP_UPDATE_XML_FETCH_RESULT"
230 // The non-glean telemetry used here will be removed in future and just
231 // the glean data will be gathered.
232 if (didGetAddonList) {
233 updateResultHistogram.add("cert_pinning_ok");
234 Glean.gmp.updateXmlFetchResult.cert_pin_success.add(1);
237 // All remaining cases are failure cases.
238 updateResultHistogram.add("cert_pinning_fail");
239 if (!err?.addonCheckerErr) {
240 // Unknown error case. If this is happening we should audit error paths
241 // to identify why we're not getting an error, or not getting it
243 Glean.gmp.updateXmlFetchResult.cert_pin_unknown_error.add(1);
246 const errorToHistogramMap = {
247 [ProductAddonChecker.NETWORK_REQUEST_ERR]: "cert_pin_net_request_error",
248 [ProductAddonChecker.NETWORK_TIMEOUT_ERR]: "cert_pin_net_timeout",
249 [ProductAddonChecker.ABORT_ERR]: "cert_pin_abort",
250 [ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR]:
251 "cert_pin_missing_data",
252 [ProductAddonChecker.VERIFICATION_FAILED_ERR]: "cert_pin_failed",
253 [ProductAddonChecker.VERIFICATION_INVALID_ERR]: "cert_pin_invalid",
254 [ProductAddonChecker.XML_PARSE_ERR]: "cert_pin_xml_parse_error",
257 errorToHistogramMap[err.addonCheckerErr] ?? "cert_pin_unknown_error";
258 let metric = Glean.gmp.updateXmlFetchResult[metricID];
261 // We don't expect this path to be hit, but we don't want telemetry
262 // failures to break GMP updates, so catch any issues here and let the
263 // update machinery continue.
265 `Failed to record telemetry result of getProductAddonList, got error: ${e}`
271 * Performs an addon check.
272 * @return a promise which will be resolved or rejected.
273 * The promise is resolved with an object with properties:
274 * addons: array of addons
275 * usedFallback: whether the data was collected from online or
276 * from fallback data within the build
277 * The promise is rejected with an object with properties:
278 * target: The XHR request object
279 * status: The HTTP status code
280 * type: Sometimes specifies type of rejection
282 async checkForAddons() {
283 let log = getScopedLogger("GMPInstallManager.checkForAddons");
284 if (this._deferred) {
285 log.error("checkForAddons already called");
286 return Promise.reject({ type: "alreadycalled" });
289 if (!GMPPrefs.getBool(GMPPrefs.KEY_UPDATE_ENABLED, true)) {
290 log.info("Updates are disabled via media.gmp-manager.updateEnabled");
291 return { usedFallback: true, addons: [] };
294 this._deferred = PromiseUtils.defer();
296 // Should content signature checking of Balrog replies be used? If so this
297 // will be done instead of the older cert pinning method.
298 let checkContentSignature = GMPPrefs.getBool(
299 GMPPrefs.KEY_CHECK_CONTENT_SIGNATURE,
303 let allowNonBuiltIn = true;
305 // Only check certificates if we're not using a custom URL, and only if
306 // we're not checking a content signature.
308 !Services.prefs.prefHasUserValue(GMPPrefs.KEY_URL_OVERRIDE) &&
309 !checkContentSignature
311 allowNonBuiltIn = !GMPPrefs.getString(
312 GMPPrefs.KEY_CERT_REQUIREBUILTIN,
315 if (GMPPrefs.getBool(GMPPrefs.KEY_CERT_CHECKATTRS, true)) {
316 certs = lazy.CertUtils.readCertPrefs(GMPPrefs.KEY_CERTS_BRANCH);
320 let url = await this._getURL();
323 `Fetching product addon list url=${url}, allowNonBuiltIn=${allowNonBuiltIn}, certs=${certs}, checkContentSignature=${checkContentSignature}`
325 let addonPromise = ProductAddonChecker.getProductAddonList(
329 checkContentSignature
332 if (checkContentSignature) {
333 this.recordUpdateXmlTelemetryForContentSignature(true);
335 this.recordUpdateXmlTelemetryForCertPinning(true);
340 if (checkContentSignature) {
341 this.recordUpdateXmlTelemetryForContentSignature(false, err);
343 this.recordUpdateXmlTelemetryForCertPinning(false, err);
345 return downloadLocalConfig();
350 if (!res || !res.addons) {
351 this._deferred.resolve({ addons: [] });
353 res.addons = res.addons.map(a => new GMPAddon(a));
354 this._deferred.resolve(res);
356 delete this._deferred;
359 this._deferred.reject(ex);
360 delete this._deferred;
363 return this._deferred.promise;
366 * Installs the specified addon and calls a callback when done.
367 * @param gmpAddon The GMPAddon object to install
368 * @return a promise which will be resolved or rejected
369 * The promise will resolve with an array of paths that were extracted
370 * The promise will reject with an error object:
371 * target: The XHR request object
372 * status: The HTTP status code
373 * type: A string to represent the type of error
374 * downloaderr, verifyerr or previouserrorencountered
376 installAddon(gmpAddon) {
377 if (this._deferred) {
378 let log = getScopedLogger("GMPInstallManager.installAddon");
379 log.error("previous error encountered");
380 return Promise.reject({ type: "previouserrorencountered" });
382 this.gmpDownloader = new GMPDownloader(gmpAddon);
383 return this.gmpDownloader.start();
385 _getTimeSinceLastCheck() {
386 let now = Math.round(Date.now() / 1000);
387 // Default to 0 here because `now - 0` will be returned later if that case
388 // is hit. We want a large value so a check will occur.
389 let lastCheck = GMPPrefs.getInt(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0);
390 // Handle clock jumps, return now since we want it to represent
391 // a lot of time has passed since the last check.
392 if (now < lastCheck) {
395 return now - lastCheck;
397 get _isEMEEnabled() {
398 return GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true);
400 _isAddonEnabled(aAddon) {
401 return GMPPrefs.getBool(GMPPrefs.KEY_PLUGIN_ENABLED, true, aAddon);
403 _isAddonUpdateEnabled(aAddon) {
405 this._isAddonEnabled(aAddon) &&
406 GMPPrefs.getBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, aAddon)
410 let now = Math.round(Date.now() / 1000);
411 GMPPrefs.setInt(GMPPrefs.KEY_UPDATE_LAST_CHECK, now);
413 _versionchangeOccurred() {
414 let savedBuildID = GMPPrefs.getString(GMPPrefs.KEY_BUILDID, "");
415 let buildID = Services.appinfo.platformBuildID || "";
416 if (savedBuildID == buildID) {
419 GMPPrefs.setString(GMPPrefs.KEY_BUILDID, buildID);
423 * Wrapper for checkForAddons and installAddon.
424 * Will only install if not already installed and will log the results.
425 * This will only install/update the OpenH264 and EME plugins
426 * @return a promise which will be resolved if all addons could be installed
427 * successfully, rejected otherwise.
429 async simpleCheckAndInstall() {
430 let log = getScopedLogger("GMPInstallManager.simpleCheckAndInstall");
432 if (this._versionchangeOccurred()) {
434 "A version change occurred. Ignoring " +
435 "media.gmp-manager.lastCheck to check immediately for " +
436 "new or updated GMPs."
439 let secondsBetweenChecks = GMPPrefs.getInt(
440 GMPPrefs.KEY_SECONDS_BETWEEN_CHECKS,
441 DEFAULT_SECONDS_BETWEEN_CHECKS
443 let secondsSinceLast = this._getTimeSinceLastCheck();
447 " seconds ago, minimum seconds: " +
450 if (secondsBetweenChecks > secondsSinceLast) {
451 log.info("Will not check for updates.");
452 return { status: "too-frequent-no-check" };
457 let { usedFallback, addons } = await this.checkForAddons();
458 this._updateLastCheck();
459 log.info("Found " + addons.length + " addons advertised.");
460 let addonsToInstall = addons.filter(function (gmpAddon) {
461 log.info("Found addon: " + gmpAddon.toString());
463 if (!gmpAddon.isValid) {
464 log.info("Addon |" + gmpAddon.id + "| is invalid.");
468 if (GMPUtils.isPluginHidden(gmpAddon)) {
469 log.info("Addon |" + gmpAddon.id + "| has been hidden.");
473 if (gmpAddon.isInstalled) {
474 log.info("Addon |" + gmpAddon.id + "| already installed.");
478 // Do not install from fallback if already installed as it
479 // may be a downgrade
480 if (usedFallback && gmpAddon.isUpdate) {
484 "| not installing updates based " +
490 let addonUpdateEnabled = false;
491 if (GMP_PLUGIN_IDS.includes(gmpAddon.id)) {
492 if (!this._isAddonEnabled(gmpAddon.id)) {
494 "GMP |" + gmpAddon.id + "| has been disabled; skipping check."
496 } else if (!this._isAddonUpdateEnabled(gmpAddon.id)) {
498 "Auto-update is off for " + gmpAddon.id + ", skipping check."
501 addonUpdateEnabled = true;
504 // Currently, we only support installs of OpenH264 and EME plugins.
506 "Auto-update is off for unknown plugin '" +
512 return addonUpdateEnabled;
515 if (!addonsToInstall.length) {
516 let now = Math.round(Date.now() / 1000);
517 GMPPrefs.setInt(GMPPrefs.KEY_UPDATE_LAST_EMPTY_CHECK, now);
518 log.info("No new addons to install, returning");
519 return { status: "nothing-new-to-install" };
522 let installResults = [];
523 let failureEncountered = false;
524 for (let addon of addonsToInstall) {
526 await this.installAddon(addon);
527 installResults.push({
532 failureEncountered = true;
533 installResults.push({
539 if (failureEncountered) {
540 // eslint-disable-next-line no-throw-literal
541 throw { status: "failed", results: installResults };
543 return { status: "succeeded", results: installResults };
545 log.error("Could not check for addons", e);
551 * Makes sure everything is cleaned up
554 let log = getScopedLogger("GMPInstallManager.uninit");
556 log.info("Aborting request");
557 this._request.abort();
559 if (this._deferred) {
560 log.info("Rejecting deferred");
561 this._deferred.reject({ type: "uninitialized" });
563 log.info("Done cleanup");
567 * If set to true, specifies to leave the temporary downloaded zip file.
568 * This is useful for tests.
570 overrideLeaveDownloadedZip: false,
574 * Used to construct a single GMP addon
575 * GMPAddon objects are returns from GMPInstallManager.checkForAddons
576 * GMPAddon objects can also be used in calls to GMPInstallManager.installAddon
578 * @param addon The ProductAddonChecker `addon` object
580 export function GMPAddon(addon) {
581 let log = getScopedLogger("GMPAddon.constructor");
582 for (let name of Object.keys(addon)) {
583 this[name] = addon[name];
585 log.info("Created new addon: " + this.toString());
588 GMPAddon.prototype = {
590 * Returns a string representation of the addon
604 (this.size !== undefined ? ", size: " + this.size : "") +
609 * If all the fields aren't specified don't consider this addon valid
610 * @return true if the addon is parsed and valid
625 GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, "", this.id) ===
627 GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_HASHVALUE, "", this.id) ===
632 return this.id == WIDEVINE_ID;
635 return this.id == "gmp-gmpopenh264";
638 * @return true if the addon has been previously installed and this is
639 * a new version, if this is a fresh install return false
644 GMPPrefs.getBool(GMPPrefs.KEY_PLUGIN_VERSION, false, this.id)
650 * Constructs a GMPExtractor object which is used to extract a GMP zip
651 * into the specified location.
652 * @param zipPath The path on disk of the zip file to extract
653 * @param relativePath The relative path components inside the profile directory
654 * to extract the zip to.
656 export function GMPExtractor(zipPath, relativeInstallPath) {
657 this.zipPath = zipPath;
658 this.relativeInstallPath = relativeInstallPath;
661 GMPExtractor.prototype = {
663 * Installs the this.zipPath contents into the directory used to store GMP
664 * addons for the current platform.
666 * @return a promise which will be resolved or rejected
667 * See GMPInstallManager.installAddon for resolve/rejected info
670 this._deferred = PromiseUtils.defer();
671 let deferredPromise = this._deferred;
672 let { zipPath, relativeInstallPath } = this;
673 // Escape the zip path since the worker will use it as a URI
674 let zipFile = new lazy.FileUtils.File(zipPath);
675 let zipURI = Services.io.newFileURI(zipFile).spec;
676 let worker = new ChromeWorker(
677 "resource://gre/modules/GMPExtractorWorker.js"
679 worker.onmessage = function (msg) {
680 let log = getScopedLogger("GMPExtractor");
682 if (msg.data.result != "success") {
683 log.error("Failed to extract zip file: " + zipURI);
684 return deferredPromise.reject({
686 status: msg.data.exception,
690 log.info("Successfully extracted zip file: " + zipURI);
691 return deferredPromise.resolve(msg.data.extractedPaths);
693 worker.postMessage({ zipURI, relativeInstallPath });
694 return this._deferred.promise;
699 * Constructs an object which downloads and initiates an install of
700 * the specified GMPAddon object.
701 * @param gmpAddon The addon to install.
703 export function GMPDownloader(gmpAddon) {
704 this._gmpAddon = gmpAddon;
707 GMPDownloader.prototype = {
709 * Starts the download process for an addon.
710 * @return a promise which will be resolved or rejected
711 * See GMPInstallManager.installAddon for resolve/rejected info
714 let log = getScopedLogger("GMPDownloader");
715 let gmpAddon = this._gmpAddon;
716 let now = Math.round(Date.now() / 1000);
717 GMPPrefs.setInt(GMPPrefs.KEY_PLUGIN_LAST_INSTALL_START, now, gmpAddon.id);
719 if (!gmpAddon.isValid) {
720 log.info("gmpAddon is not valid, will not continue");
721 return Promise.reject({
726 // If the HTTPS-Only Mode is enabled, every insecure request gets upgraded
727 // by default. This upgrade has to be prevented for openh264 downloads since
728 // the server doesn't support https://
729 const downloadOptions = {
730 httpsOnlyNoUpgrade: gmpAddon.isOpenH264,
732 return ProductAddonChecker.downloadAddon(gmpAddon, downloadOptions).then(
734 let now = Math.round(Date.now() / 1000);
735 GMPPrefs.setInt(GMPPrefs.KEY_PLUGIN_LAST_DOWNLOAD, now, gmpAddon.id);
737 `install to directory path: ${gmpAddon.id}/${gmpAddon.version}`
739 let gmpInstaller = new GMPExtractor(zipPath, [
743 let installPromise = gmpInstaller.install();
744 return installPromise.then(
746 // Success, set the prefs
747 let now = Math.round(Date.now() / 1000);
748 GMPPrefs.setInt(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, now, gmpAddon.id);
749 // Remember our ABI, so that if the profile is migrated to another
750 // platform or from 32 -> 64 bit, we notice and don't try to load the
751 // unexecutable plugin library.
752 let abi = GMPUtils._expectedABI(gmpAddon);
753 log.info("Setting ABI to '" + abi + "' for " + gmpAddon.id);
754 GMPPrefs.setString(GMPPrefs.KEY_PLUGIN_ABI, abi, gmpAddon.id);
755 // We use the combination of the hash and version to ensure we are
758 GMPPrefs.KEY_PLUGIN_HASHVALUE,
762 // Setting the version pref signals installation completion to consumers,
763 // if you need to set other prefs etc. do it before this.
765 GMPPrefs.KEY_PLUGIN_VERSION,
769 return extractedPaths;
773 GMPPrefs.KEY_PLUGIN_LAST_INSTALL_FAIL_REASON,
777 let now = Math.round(Date.now() / 1000);
779 GMPPrefs.KEY_PLUGIN_LAST_INSTALL_FAILED,
789 GMPPrefs.KEY_PLUGIN_LAST_DOWNLOAD_FAIL_REASON,
793 let now = Math.round(Date.now() / 1000);
795 GMPPrefs.KEY_PLUGIN_LAST_DOWNLOAD_FAILED,