diff options
Diffstat (limited to 'components/addons/src')
-rw-r--r-- | components/addons/src/GMPInstallManager.jsm | 917 | ||||
-rw-r--r-- | components/addons/src/GMPProvider.jsm | 605 | ||||
-rw-r--r-- | components/addons/src/GMPUtils.jsm | 187 | ||||
-rw-r--r-- | components/addons/src/ProductAddonChecker.jsm | 464 |
4 files changed, 2173 insertions, 0 deletions
diff --git a/components/addons/src/GMPInstallManager.jsm b/components/addons/src/GMPInstallManager.jsm new file mode 100644 index 000000000..fe4e2de10 --- /dev/null +++ b/components/addons/src/GMPInstallManager.jsm @@ -0,0 +1,917 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = []; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = + Components; +// Chunk size for the incremental downloader +const DOWNLOAD_CHUNK_BYTES_SIZE = 300000; +// Incremental downloader interval +const DOWNLOAD_INTERVAL = 0; +// 1 day default +const DEFAULT_SECONDS_BETWEEN_CHECKS = 60 * 60 * 24; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/GMPUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["GMPInstallManager", "GMPExtractor", "GMPDownloader", + "GMPAddon"]; + +var gLocale = null; + +// Shared code for suppressing bad cert dialogs +XPCOMUtils.defineLazyGetter(this, "gCertUtils", function() { + let temp = { }; + Cu.import("resource://gre/modules/CertUtils.jsm", temp); + return temp; +}); + +XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", + "resource://gre/modules/UpdateUtils.jsm"); + +/** + * Number of milliseconds after which we need to cancel `checkForAddons`. + * + * Bug 1087674 suggests that the XHR we use in `checkForAddons` may + * never terminate in presence of network nuisances (e.g. strange + * antivirus behavior). This timeout is a defensive measure to ensure + * that we fail cleanly in such case. + */ +const CHECK_FOR_ADDONS_TIMEOUT_DELAY_MS = 20000; + +function getScopedLogger(prefix) { + // `PARENT_LOGGER_ID.` being passed here effectively links this logger + // to the parentLogger. + return Log.repository.getLoggerWithMessagePrefix("Toolkit.GMP", prefix + " "); +} + +// This is copied directly from nsUpdateService.js +// It is used for calculating the URL string w/ var replacement. +// TODO: refactor this out somewhere else +XPCOMUtils.defineLazyGetter(this, "gOSVersion", function aus_gOSVersion() { + let osVersion; + let sysInfo = Cc["@mozilla.org/system-info;1"]. + getService(Ci.nsIPropertyBag2); + try { + osVersion = sysInfo.getProperty("name") + " " + sysInfo.getProperty("version"); + } + catch (e) { + LOG("gOSVersion - OS Version unknown: updates are not possible."); + } + + if (osVersion) { +#ifdef XP_WIN + const BYTE = ctypes.uint8_t; + const WORD = ctypes.uint16_t; + const DWORD = ctypes.uint32_t; + const WCHAR = ctypes.char16_t; + const BOOL = ctypes.int; + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx + const SZCSDVERSIONLENGTH = 128; + const OSVERSIONINFOEXW = new ctypes.StructType('OSVERSIONINFOEXW', + [ + {dwOSVersionInfoSize: DWORD}, + {dwMajorVersion: DWORD}, + {dwMinorVersion: DWORD}, + {dwBuildNumber: DWORD}, + {dwPlatformId: DWORD}, + {szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH)}, + {wServicePackMajor: WORD}, + {wServicePackMinor: WORD}, + {wSuiteMask: WORD}, + {wProductType: BYTE}, + {wReserved: BYTE} + ]); + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724958%28v=vs.85%29.aspx + const SYSTEM_INFO = new ctypes.StructType('SYSTEM_INFO', + [ + {wProcessorArchitecture: WORD}, + {wReserved: WORD}, + {dwPageSize: DWORD}, + {lpMinimumApplicationAddress: ctypes.voidptr_t}, + {lpMaximumApplicationAddress: ctypes.voidptr_t}, + {dwActiveProcessorMask: DWORD.ptr}, + {dwNumberOfProcessors: DWORD}, + {dwProcessorType: DWORD}, + {dwAllocationGranularity: DWORD}, + {wProcessorLevel: WORD}, + {wProcessorRevision: WORD} + ]); + + let kernel32 = false; + try { + kernel32 = ctypes.open("Kernel32"); + } catch (e) { + LOG("gOSVersion - Unable to open kernel32! " + e); + osVersion += ".unknown (unknown)"; + } + + if(kernel32) { + try { + // Get Service pack info + try { + let GetVersionEx = kernel32.declare("GetVersionExW", + ctypes.default_abi, + BOOL, + OSVERSIONINFOEXW.ptr); + let winVer = OSVERSIONINFOEXW(); + winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size; + + if(0 !== GetVersionEx(winVer.address())) { + osVersion += "." + winVer.wServicePackMajor + + "." + winVer.wServicePackMinor; + } else { + LOG("gOSVersion - Unknown failure in GetVersionEX (returned 0)"); + osVersion += ".unknown"; + } + } catch (e) { + LOG("gOSVersion - error getting service pack information. Exception: " + e); + osVersion += ".unknown"; + } + + // Get processor architecture + let arch = "unknown"; + try { + let GetNativeSystemInfo = kernel32.declare("GetNativeSystemInfo", + ctypes.default_abi, + ctypes.void_t, + SYSTEM_INFO.ptr); + let sysInfo = SYSTEM_INFO(); + // Default to unknown + sysInfo.wProcessorArchitecture = 0xffff; + + GetNativeSystemInfo(sysInfo.address()); + switch(sysInfo.wProcessorArchitecture) { + case 9: + arch = "x64"; + break; + case 6: + arch = "IA64"; + break; + case 0: + arch = "x86"; + break; + } + } catch (e) { + LOG("gOSVersion - error getting processor architecture. Exception: " + e); + } finally { + osVersion += " (" + arch + ")"; + } + } finally { + kernel32.close(); + } + } +#endif + + try { + osVersion += " (" + sysInfo.getProperty("secondaryLibrary") + ")"; + } + catch (e) { + // Not all platforms have a secondary widget library, so an error is nothing to worry about. + } + osVersion = encodeURIComponent(osVersion); + } + return osVersion; +}); + +/** + * Provides an easy API for downloading and installing GMP Addons + */ +function GMPInstallManager() { +} +/** + * Temp file name used for downloading + */ +GMPInstallManager.prototype = { + /** + * Obtains a URL with replacement of vars + */ + _getURL: function() { + let log = getScopedLogger("GMPInstallManager._getURL"); + // Use the override URL if it is specified. The override URL is just like + // the normal URL but it does not check the cert. + let url = GMPPrefs.get(GMPPrefs.KEY_URL_OVERRIDE); + if (url) { + log.info("Using override url: " + url); + } else { + url = GMPPrefs.get(GMPPrefs.KEY_URL); + log.info("Using url: " + url); + } + + url = UpdateUtils.formatUpdateURL(url); + log.info("Using url (with replacement): " + url); + return url; + }, + /** + * Performs an addon check. + * @return a promise which will be resolved or rejected. + * The promise is resolved with an array of GMPAddons + * The promise is rejected with an object with properties: + * target: The XHR request object + * status: The HTTP status code + * type: Sometimes specifies type of rejection + */ + checkForAddons: function() { + let log = getScopedLogger("GMPInstallManager.checkForAddons"); + if (this._deferred) { + log.error("checkForAddons already called"); + return Promise.reject({type: "alreadycalled"}); + } + this._deferred = Promise.defer(); + let url = this._getURL(); + + this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsISupports); + // This is here to let unit test code override XHR + if (this._request.wrappedJSObject) { + this._request = this._request.wrappedJSObject; + } + this._request.open("GET", url, true); + let allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_CHECKATTRS, true); + this._request.channel.notificationCallbacks = + new gCertUtils.BadCertHandler(allowNonBuiltIn); + // Prevent the request from reading from the cache. + this._request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + this._request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + + this._request.overrideMimeType("text/xml"); + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + this._request.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + this._request.setRequestHeader("Pragma", "no-cache"); + + this._request.timeout = CHECK_FOR_ADDONS_TIMEOUT_DELAY_MS; + this._request.addEventListener("error", event => this.onFailXML("onErrorXML", event), false); + this._request.addEventListener("abort", event => this.onFailXML("onAbortXML", event), false); + this._request.addEventListener("timeout", event => this.onFailXML("onTimeoutXML", event), false); + this._request.addEventListener("load", event => this.onLoadXML(event), false); + + log.info("sending request to: " + url); + this._request.send(null); + + return this._deferred.promise; + }, + /** + * Installs the specified addon and calls a callback when done. + * @param gmpAddon The GMPAddon object to install + * @return a promise which will be resolved or rejected + * The promise will resolve with an array of paths that were extracted + * The promise will reject with an error object: + * target: The XHR request object + * status: The HTTP status code + * type: A string to represent the type of error + * downloaderr, verifyerr or previouserrorencountered + */ + installAddon: function(gmpAddon) { + if (this._deferred) { + log.error("previous error encountered"); + return Promise.reject({type: "previouserrorencountered"}); + } + this.gmpDownloader = new GMPDownloader(gmpAddon); + return this.gmpDownloader.start(); + }, + _getTimeSinceLastCheck: function() { + let now = Math.round(Date.now() / 1000); + // Default to 0 here because `now - 0` will be returned later if that case + // is hit. We want a large value so a check will occur. + let lastCheck = GMPPrefs.get(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0); + // Handle clock jumps, return now since we want it to represent + // a lot of time has passed since the last check. + if (now < lastCheck) { + return now; + } + return now - lastCheck; + }, + get _isEMEEnabled() { + return GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true); + }, + _isAddonUpdateEnabled: function(aAddon) { + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_ENABLED, true, aAddon) && + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, aAddon); + }, + _updateLastCheck: function() { + let now = Math.round(Date.now() / 1000); + GMPPrefs.set(GMPPrefs.KEY_UPDATE_LAST_CHECK, now); + }, + _versionchangeOccurred: function() { + let savedBuildID = GMPPrefs.get(GMPPrefs.KEY_BUILDID, null); + let buildID = Services.appinfo.platformBuildID; + if (savedBuildID == buildID) { + return false; + } + GMPPrefs.set(GMPPrefs.KEY_BUILDID, buildID); + return true; + }, + /** + * Wrapper for checkForAddons and installAddon. + * Will only install if not already installed and will log the results. + * This will only install/update the OpenH264 and EME plugins + * @return a promise which will be resolved if all addons could be installed + * successfully, rejected otherwise. + */ + simpleCheckAndInstall: Task.async(function*() { + let log = getScopedLogger("GMPInstallManager.simpleCheckAndInstall"); + + if (this._versionchangeOccurred()) { + log.info("A version change occurred. Ignoring " + + "media.gmp-manager.lastCheck to check immediately for " + + "new or updated GMPs."); + } else { + let secondsBetweenChecks = + GMPPrefs.get(GMPPrefs.KEY_SECONDS_BETWEEN_CHECKS, + DEFAULT_SECONDS_BETWEEN_CHECKS) + let secondsSinceLast = this._getTimeSinceLastCheck(); + log.info("Last check was: " + secondsSinceLast + + " seconds ago, minimum seconds: " + secondsBetweenChecks); + if (secondsBetweenChecks > secondsSinceLast) { + log.info("Will not check for updates."); + return {status: "too-frequent-no-check"}; + } + } + + try { + let gmpAddons = yield this.checkForAddons(); + this._updateLastCheck(); + log.info("Found " + gmpAddons.length + " addons advertised."); + let addonsToInstall = gmpAddons.filter(function(gmpAddon) { + log.info("Found addon: " + gmpAddon.toString()); + + if (!gmpAddon.isValid || GMPUtils.isPluginHidden(gmpAddon) || + gmpAddon.isInstalled) { + log.info("Addon invalid, hidden or already installed."); + return false; + } + + let addonUpdateEnabled = false; + if (GMP_PLUGIN_IDS.indexOf(gmpAddon.id) >= 0) { + addonUpdateEnabled = this._isAddonUpdateEnabled(gmpAddon.id); + if (!addonUpdateEnabled) { + log.info("Auto-update is off for " + gmpAddon.id + + ", skipping check."); + } + } else { + // Currently, we only support installs of OpenH264 and EME plugins. + log.info("Auto-update is off for unknown plugin '" + gmpAddon.id + + "', skipping check."); + } + + return addonUpdateEnabled; + }, this); + + if (!addonsToInstall.length) { + log.info("No new addons to install, returning"); + return {status: "nothing-new-to-install"}; + } + + let installResults = []; + let failureEncountered = false; + for (let addon of addonsToInstall) { + try { + yield this.installAddon(addon); + installResults.push({ + id: addon.id, + result: "succeeded", + }); + } catch (e) { + failureEncountered = true; + installResults.push({ + id: addon.id, + result: "failed", + }); + } + } + if (failureEncountered) { + throw {status: "failed", + results: installResults}; + } + return {status: "succeeded", + results: installResults}; + } catch(e) { + log.error("Could not check for addons", e); + throw e; + } + }), + + /** + * Makes sure everything is cleaned up + */ + uninit: function() { + let log = getScopedLogger("GMPInstallManager.uninit"); + if (this._request) { + log.info("Aborting request"); + this._request.abort(); + } + if (this._deferred) { + log.info("Rejecting deferred"); + this._deferred.reject({type: "uninitialized"}); + } + log.info("Done cleanup"); + }, + + /** + * If set to true, specifies to leave the temporary downloaded zip file. + * This is useful for tests. + */ + overrideLeaveDownloadedZip: false, + + /** + * The XMLHttpRequest succeeded and the document was loaded. + * @param event The nsIDOMEvent for the load + */ + onLoadXML: function(event) { + let log = getScopedLogger("GMPInstallManager.onLoadXML"); + try { + log.info("request completed downloading document"); + let certs = null; + if (!Services.prefs.prefHasUserValue(GMPPrefs.KEY_URL_OVERRIDE) && + GMPPrefs.get(GMPPrefs.KEY_CERT_CHECKATTRS, true)) { + certs = gCertUtils.readCertPrefs(GMPPrefs.KEY_CERTS_BRANCH); + } + + let allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_REQUIREBUILTIN, + true); + log.info("allowNonBuiltIn: " + allowNonBuiltIn); + + gCertUtils.checkCert(this._request.channel, allowNonBuiltIn, certs); + + this.parseResponseXML(); + } catch (ex) { + log.error("could not load xml: " + ex); + this._deferred.reject({ + target: event.target, + status: this._getChannelStatus(event.target), + message: "" + ex, + }); + delete this._deferred; + } + }, + + /** + * Returns the status code for the XMLHttpRequest + */ + _getChannelStatus: function(request) { + let log = getScopedLogger("GMPInstallManager._getChannelStatus"); + let status = null; + try { + status = request.status; + log.info("request.status is: " + request.status); + } + catch (e) { + } + + if (status == null) { + status = request.channel.QueryInterface(Ci.nsIRequest).status; + } + return status; + }, + + /** + * There was an error of some kind during the XMLHttpRequest. This + * error may have been caused by external factors (e.g. network + * issues) or internally (by a timeout). + * + * @param event The nsIDOMEvent for the error + */ + onFailXML: function(failure, event) { + let log = getScopedLogger("GMPInstallManager.onFailXML " + failure); + let request = event.target; + let status = this._getChannelStatus(request); + let message = "request.status: " + status + " (" + event.type + ")"; + log.warn(message); + this._deferred.reject({ + target: request, + status: status, + message: message + }); + delete this._deferred; + }, + + /** + * Returns an array of GMPAddon objects discovered by the update check. + * Or returns an empty array if there were any problems with parsing. + * If there's an error, it will be logged if logging is enabled. + */ + parseResponseXML: function() { + try { + let log = getScopedLogger("GMPInstallManager.parseResponseXML"); + let updatesElement = this._request.responseXML.documentElement; + if (!updatesElement) { + let message = "empty updates document"; + log.warn(message); + this._deferred.reject({ + target: this._request, + message: message + }); + delete this._deferred; + return; + } + + if (updatesElement.nodeName != "updates") { + let message = "got node name: " + updatesElement.nodeName + + ", expected: updates"; + log.warn(message); + this._deferred.reject({ + target: this._request, + message: message + }); + delete this._deferred; + return; + } + + const ELEMENT_NODE = Ci.nsIDOMNode.ELEMENT_NODE; + let gmpResults = []; + for (let i = 0; i < updatesElement.childNodes.length; ++i) { + let updatesChildElement = updatesElement.childNodes.item(i); + if (updatesChildElement.nodeType != ELEMENT_NODE) { + continue; + } + if (updatesChildElement.localName == "addons") { + gmpResults = GMPAddon.parseGMPAddonsNode(updatesChildElement); + } + } + this._deferred.resolve(gmpResults); + delete this._deferred; + } catch (e) { + this._deferred.reject({ + target: this._request, + message: e + }); + delete this._deferred; + } + }, +}; + +/** + * Used to construct a single GMP addon + * GMPAddon objects are returns from GMPInstallManager.checkForAddons + * GMPAddon objects can also be used in calls to GMPInstallManager.installAddon + * + * @param gmpAddon The AUS response XML's DOM element `addon` + */ +function GMPAddon(gmpAddon) { + let log = getScopedLogger("GMPAddon.constructor"); + gmpAddon.QueryInterface(Ci.nsIDOMElement); + ["id", "URL", "hashFunction", + "hashValue", "version", "size"].forEach(name => { + if (gmpAddon.hasAttribute(name)) { + this[name] = gmpAddon.getAttribute(name); + } + }); + this.size = Number(this.size) || undefined; + log.info ("Created new addon: " + this.toString()); +} +/** + * Parses an XML GMP addons node from AUS into an array + * @param addonsElement An nsIDOMElement compatible node with XML from AUS + * @return An array of GMPAddon results + */ +GMPAddon.parseGMPAddonsNode = function(addonsElement) { + let log = getScopedLogger("GMPAddon.parseGMPAddonsNode"); + let gmpResults = []; + if (addonsElement.localName !== "addons") { + return; + } + + addonsElement.QueryInterface(Ci.nsIDOMElement); + let addonCount = addonsElement.childNodes.length; + for (let i = 0; i < addonCount; ++i) { + let addonElement = addonsElement.childNodes.item(i); + if (addonElement.localName !== "addon") { + continue; + } + addonElement.QueryInterface(Ci.nsIDOMElement); + try { + gmpResults.push(new GMPAddon(addonElement)); + } catch (e) { + log.warn("invalid addon: " + e); + continue; + } + } + return gmpResults; +}; +GMPAddon.prototype = { + /** + * Returns a string representation of the addon + */ + toString: function() { + return this.id + " (" + + "isValid: " + this.isValid + + ", isInstalled: " + this.isInstalled + + ", hashFunction: " + this.hashFunction+ + ", hashValue: " + this.hashValue + + (this.size !== undefined ? ", size: " + this.size : "" ) + + ")"; + }, + /** + * If all the fields aren't specified don't consider this addon valid + * @return true if the addon is parsed and valid + */ + get isValid() { + return this.id && this.URL && this.version && + this.hashFunction && !!this.hashValue; + }, + get isInstalled() { + return this.version && + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VERSION, "", this.id) === this.version; + }, + get isEME() { + return this.id == "gmp-widevinecdm" || this.id.indexOf("gmp-eme-") == 0; + }, +}; +/** + * Constructs a GMPExtractor object which is used to extract a GMP zip + * into the specified location. (Which typically leties per platform) + * @param zipPath The path on disk of the zip file to extract + */ +function GMPExtractor(zipPath, installToDirPath) { + this.zipPath = zipPath; + this.installToDirPath = installToDirPath; +} +GMPExtractor.prototype = { + /** + * Obtains a list of all the entries in a zipfile in the format of *.*. + * This also includes files inside directories. + * + * @param zipReader the nsIZipReader to check + * @return An array of string name entries which can be used + * in nsIZipReader.extract + */ + _getZipEntries: function(zipReader) { + let entries = []; + let enumerator = zipReader.findEntries("*.*"); + while (enumerator.hasMore()) { + entries.push(enumerator.getNext()); + } + return entries; + }, + /** + * Installs the this.zipPath contents into the directory used to store GMP + * addons for the current platform. + * + * @return a promise which will be resolved or rejected + * See GMPInstallManager.installAddon for resolve/rejected info + */ + install: function() { + try { + let log = getScopedLogger("GMPExtractor.install"); + this._deferred = Promise.defer(); + log.info("Installing " + this.zipPath + "..."); + // Get the input zip file + let zipFile = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsIFile); + zipFile.initWithPath(this.zipPath); + + // Initialize a zipReader and obtain the entries + var zipReader = Cc["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Ci.nsIZipReader); + zipReader.open(zipFile) + let entries = this._getZipEntries(zipReader); + let extractedPaths = []; + + // Extract each of the entries + entries.forEach(entry => { + // We don't need these types of files + if (entry.includes("__MACOSX")) { + return; + } + let outFile = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsILocalFile); + outFile.initWithPath(this.installToDirPath); + outFile.appendRelativePath(entry); + + // Make sure the directory hierarchy exists + if(!outFile.parent.exists()) { + outFile.parent.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8)); + } + zipReader.extract(entry, outFile); + extractedPaths.push(outFile.path); + log.info(entry + " was successfully extracted to: " + + outFile.path); + }); + zipReader.close(); + if (!GMPInstallManager.overrideLeaveDownloadedZip) { + zipFile.remove(false); + } + + log.info(this.zipPath + " was installed successfully"); + this._deferred.resolve(extractedPaths); + } catch (e) { + if (zipReader) { + zipReader.close(); + } + this._deferred.reject({ + target: this, + status: e, + type: "exception" + }); + } + return this._deferred.promise; + } +}; + + +/** + * Constructs an object which downloads and initiates an install of + * the specified GMPAddon object. + * @param gmpAddon The addon to install. + */ +function GMPDownloader(gmpAddon) +{ + this._gmpAddon = gmpAddon; +} +/** + * Computes the file hash of fileToHash with the specified hash function + * @param hashFunctionName A hash function name such as sha512 + * @param fileToHash An nsIFile to hash + * @return a promise which resolve to a digest in binary hex format + */ +GMPDownloader.computeHash = function(hashFunctionName, fileToHash) { + let log = getScopedLogger("GMPDownloader.computeHash"); + let digest; + let fileStream = Cc["@mozilla.org/network/file-input-stream;1"]. + createInstance(Ci.nsIFileInputStream); + fileStream.init(fileToHash, FileUtils.MODE_RDONLY, + FileUtils.PERMS_FILE, 0); + try { + let hash = Cc["@mozilla.org/security/hash;1"]. + createInstance(Ci.nsICryptoHash); + let hashFunction = + Ci.nsICryptoHash[hashFunctionName.toUpperCase()]; + if (!hashFunction) { + log.error("could not get hash function"); + return Promise.reject(); + } + hash.init(hashFunction); + hash.updateFromStream(fileStream, -1); + digest = binaryToHex(hash.finish(false)); + } catch (e) { + log.warn("failed to compute hash: " + e); + digest = ""; + } + fileStream.close(); + return Promise.resolve(digest); +}, +GMPDownloader.prototype = { + /** + * Starts the download process for an addon. + * @return a promise which will be resolved or rejected + * See GMPInstallManager.installAddon for resolve/rejected info + */ + start: function() { + let log = getScopedLogger("GMPDownloader.start"); + this._deferred = Promise.defer(); + if (!this._gmpAddon.isValid) { + log.info("gmpAddon is not valid, will not continue"); + return Promise.reject({ + target: this, + status: status, + type: "downloaderr" + }); + } + + let uri = Services.io.newURI(this._gmpAddon.URL, null, null); + this._request = Cc["@mozilla.org/network/incremental-download;1"]. + createInstance(Ci.nsIIncrementalDownload); + let gmpFile = FileUtils.getFile("TmpD", [this._gmpAddon.id + ".zip"]); + if (gmpFile.exists()) { + gmpFile.remove(false); + } + + log.info("downloading from " + uri.spec + " to " + gmpFile.path); + this._request.init(uri, gmpFile, DOWNLOAD_CHUNK_BYTES_SIZE, + DOWNLOAD_INTERVAL); + this._request.start(this, null); + return this._deferred.promise; + }, + // For nsIRequestObserver + onStartRequest: function(request, context) { + }, + // For nsIRequestObserver + // Called when the GMP addon zip file is downloaded + onStopRequest: function(request, context, status) { + let log = getScopedLogger("GMPDownloader.onStopRequest"); + log.info("onStopRequest called"); + if (!Components.isSuccessCode(status)) { + log.info("status failed: " + status); + this._deferred.reject({ + target: this, + status: status, + type: "downloaderr" + }); + return; + } + + let promise = this._verifyDownload(); + promise.then(() => { + log.info("GMP file is ready to unzip"); + let destination = this._request.destination; + + let zipPath = destination.path; + let gmpAddon = this._gmpAddon; + let installToDirPath = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsIFile); + let path = OS.Path.join(OS.Constants.Path.profileDir, + gmpAddon.id, + gmpAddon.version); + installToDirPath.initWithPath(path); + log.info("install to directory path: " + installToDirPath.path); + let gmpInstaller = new GMPExtractor(zipPath, installToDirPath.path); + let installPromise = gmpInstaller.install(); + installPromise.then(extractedPaths => { + // Success, set the prefs + let now = Math.round(Date.now() / 1000); + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, now, gmpAddon.id); + // Setting the version pref signals installation completion to consumers, + // if you need to set other prefs etc. do it before this. + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_VERSION, gmpAddon.version, + gmpAddon.id); + this._deferred.resolve(extractedPaths); + }, err => { + this._deferred.reject(err); + }); + }, err => { + log.warn("verifyDownload check failed"); + this._deferred.reject({ + target: this, + status: 200, + type: "verifyerr" + }); + }); + }, + /** + * Verifies that the downloaded zip file's hash matches the GMPAddon hash. + * @return a promise which resolves if the download verifies + */ + _verifyDownload: function() { + let verifyDownloadDeferred = Promise.defer(); + let log = getScopedLogger("GMPDownloader._verifyDownload"); + log.info("_verifyDownload called"); + if (!this._request) { + return Promise.reject(); + } + + let destination = this._request.destination; + log.info("for path: " + destination.path); + + // Ensure that the file size matches the expected file size. + if (this._gmpAddon.size !== undefined && + destination.fileSize != this._gmpAddon.size) { + log.warn("Downloader:_verifyDownload downloaded size " + + destination.fileSize + " != expected size " + + this._gmpAddon.size + "."); + return Promise.reject(); + } + + let promise = GMPDownloader.computeHash(this._gmpAddon.hashFunction, destination); + promise.then(digest => { + let expectedDigest = this._gmpAddon.hashValue.toLowerCase(); + if (digest !== expectedDigest) { + log.warn("hashes do not match! Got: `" + + digest + "`, expected: `" + expectedDigest + "`"); + this._deferred.reject(); + return; + } + + log.info("hashes match!"); + verifyDownloadDeferred.resolve(); + }, err => { + verifyDownloadDeferred.reject(); + }); + return verifyDownloadDeferred.promise; + }, + QueryInterface: XPCOMUtils.generateQI([Ci.nsIRequestObserver]) +}; + +/** + * Convert a string containing binary values to hex. + */ +function binaryToHex(input) { + let result = ""; + for (let i = 0; i < input.length; ++i) { + let hex = input.charCodeAt(i).toString(16); + if (hex.length == 1) + hex = "0" + hex; + result += hex; + } + return result; +} diff --git a/components/addons/src/GMPProvider.jsm b/components/addons/src/GMPProvider.jsm new file mode 100644 index 000000000..c89427101 --- /dev/null +++ b/components/addons/src/GMPProvider.jsm @@ -0,0 +1,605 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; + +this.EXPORTED_SYMBOLS = []; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/GMPUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter( + this, "GMPInstallManager", "resource://gre/modules/GMPInstallManager.jsm"); +XPCOMUtils.defineLazyModuleGetter( + this, "setTimeout", "resource://gre/modules/Timer.jsm"); + +const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties"; +const STRING_TYPE_NAME = "type.%ID%.name"; + +const SEC_IN_A_DAY = 24 * 60 * 60; +// How long to wait after a user enabled EME before attempting to download CDMs. +const GMP_CHECK_DELAY = 10 * 1000; // milliseconds + +const NS_GRE_DIR = "GreD"; +const CLEARKEY_PLUGIN_ID = "gmp-clearkey"; +const CLEARKEY_VERSION = "0.1"; + +const GMP_LICENSE_INFO = "gmp_license_info"; + +const GMP_PLUGINS = [ + { + id: OPEN_H264_ID, + name: "openH264_name", + description: "openH264_description2", + // The following licenseURL is part of an awful hack to include the OpenH264 + // license without having bug 624602 fixed yet, and intentionally ignores + // localisation. + licenseURL: "chrome://mozapps/content/extensions/OpenH264-license.txt", + homepageURL: "http://www.openh264.org/", + optionsURL: "chrome://mozapps/content/extensions/gmpPrefs.xul" + }, + { + id: WIDEVINE_ID, + name: "widevine_name", + // Describe the purpose of both CDMs in the same way. + description: "widevine_description2", + licenseURL: "https://www.google.com/policies/privacy/", + homepageURL: "https://www.widevine.com/", + optionsURL: "chrome://mozapps/content/extensions/gmpPrefs.xul", + isEME: true + }]; +XPCOMUtils.defineConstant(this, "GMP_PLUGINS", GMP_PLUGINS); + +XPCOMUtils.defineLazyGetter(this, "pluginsBundle", + () => Services.strings.createBundle("chrome://global/locale/plugins.properties")); +XPCOMUtils.defineLazyGetter(this, "gmpService", + () => Cc["@mozilla.org/gecko-media-plugin-service;1"].getService(Ci.mozIGeckoMediaPluginChromeService)); + +var messageManager = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + +var gLogger; +var gLogAppenderDump = null; + +function configureLogging() { + if (!gLogger) { + gLogger = Log.repository.getLogger("Toolkit.GMP"); + gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + } + gLogger.level = GMPPrefs.get(GMPPrefs.KEY_LOGGING_LEVEL, Log.Level.Warn); + + let logDumping = GMPPrefs.get(GMPPrefs.KEY_LOGGING_DUMP, false); + if (logDumping != !!gLogAppenderDump) { + if (logDumping) { + gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter()); + gLogger.addAppender(gLogAppenderDump); + } else { + gLogger.removeAppender(gLogAppenderDump); + gLogAppenderDump = null; + } + } +} + + + +/** + * The GMPWrapper provides the info for the various GMP plugins to public + * callers through the API. + */ +function GMPWrapper(aPluginInfo) { + this._plugin = aPluginInfo; + this._log = + Log.repository.getLoggerWithMessagePrefix("Toolkit.GMP", + "GMPWrapper(" + + this._plugin.id + ") "); + Preferences.observe(GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, + this._plugin.id), + this.onPrefEnabledChanged, this); + Preferences.observe(GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, + this._plugin.id), + this.onPrefVersionChanged, this); + if (this._plugin.isEME) { + Preferences.observe(GMPPrefs.KEY_EME_ENABLED, + this.onPrefEMEGlobalEnabledChanged, this); + messageManager.addMessageListener("EMEVideo:ContentMediaKeysRequest", this); + } +} + +GMPWrapper.prototype = { + // An active task that checks for plugin updates and installs them. + _updateTask: null, + _gmpPath: null, + _isUpdateCheckPending: false, + + optionsType: AddonManager.OPTIONS_TYPE_INLINE, + get optionsURL() { return this._plugin.optionsURL; }, + + set gmpPath(aPath) { this._gmpPath = aPath; }, + get gmpPath() { + if (!this._gmpPath && this.isInstalled) { + this._gmpPath = OS.Path.join(OS.Constants.Path.profileDir, + this._plugin.id, + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VERSION, + null, this._plugin.id)); + } + return this._gmpPath; + }, + + get id() { return this._plugin.id; }, + get type() { return "plugin"; }, + get isGMPlugin() { return true; }, + get name() { return this._plugin.name; }, + get creator() { return null; }, + get homepageURL() { return this._plugin.homepageURL; }, + + get description() { return this._plugin.description; }, + get fullDescription() { return this._plugin.fullDescription; }, + + get version() { return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VERSION, null, + this._plugin.id); }, + + get isActive() { return !this.appDisabled && !this.userDisabled; }, + get appDisabled() { + if (this._plugin.isEME && !GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true)) { + // If "media.eme.enabled" is false, all EME plugins are disabled. + return true; + } + return false; + }, + + get userDisabled() { + return !GMPPrefs.get(GMPPrefs.KEY_PLUGIN_ENABLED, true, this._plugin.id); + }, + set userDisabled(aVal) { GMPPrefs.set(GMPPrefs.KEY_PLUGIN_ENABLED, + aVal === false, + this._plugin.id); }, + + get blocklistState() { return Ci.nsIBlocklistService.STATE_NOT_BLOCKED; }, + get size() { return 0; }, + get scope() { return AddonManager.SCOPE_APPLICATION; }, + get pendingOperations() { return AddonManager.PENDING_NONE; }, + + get operationsRequiringRestart() { return AddonManager.OP_NEEDS_RESTART_NONE }, + + get permissions() { + let permissions = 0; + if (!this.appDisabled) { + permissions |= AddonManager.PERM_CAN_UPGRADE; + permissions |= this.userDisabled ? AddonManager.PERM_CAN_ENABLE : + AddonManager.PERM_CAN_DISABLE; + } + return permissions; + }, + + get updateDate() { + let time = Number(GMPPrefs.get(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, null, + this._plugin.id)); + if (time !== NaN && this.isInstalled) { + return new Date(time * 1000) + } + return null; + }, + + get isCompatible() { + return true; + }, + + get isPlatformCompatible() { + return true; + }, + + get providesUpdatesSecurely() { + return true; + }, + + get foreignInstall() { + return false; + }, + + isCompatibleWith: function(aAppVersion, aPlatformVersion) { + return true; + }, + + get applyBackgroundUpdates() { + if (!GMPPrefs.isSet(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id)) { + return AddonManager.AUTOUPDATE_DEFAULT; + } + + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, this._plugin.id) ? + AddonManager.AUTOUPDATE_ENABLE : AddonManager.AUTOUPDATE_DISABLE; + }, + + set applyBackgroundUpdates(aVal) { + if (aVal == AddonManager.AUTOUPDATE_DEFAULT) { + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id); + } else if (aVal == AddonManager.AUTOUPDATE_ENABLE) { + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, this._plugin.id); + } else if (aVal == AddonManager.AUTOUPDATE_DISABLE) { + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, this._plugin.id); + } + }, + + findUpdates: function(aListener, aReason, aAppVersion, aPlatformVersion) { + this._log.trace("findUpdates() - " + this._plugin.id + " - reason=" + + aReason); + + AddonManagerPrivate.callNoUpdateListeners(this, aListener); + + if (aReason === AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) { + if (!AddonManager.shouldAutoUpdate(this)) { + this._log.trace("findUpdates() - " + this._plugin.id + + " - no autoupdate"); + return Promise.resolve(false); + } + + let secSinceLastCheck = + Date.now() / 1000 - Preferences.get(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0); + if (secSinceLastCheck <= SEC_IN_A_DAY) { + this._log.trace("findUpdates() - " + this._plugin.id + + " - last check was less then a day ago"); + return Promise.resolve(false); + } + } else if (aReason !== AddonManager.UPDATE_WHEN_USER_REQUESTED) { + this._log.trace("findUpdates() - " + this._plugin.id + + " - the given reason to update is not supported"); + return Promise.resolve(false); + } + + if (this._updateTask !== null) { + this._log.trace("findUpdates() - " + this._plugin.id + + " - update task already running"); + return this._updateTask; + } + + this._updateTask = Task.spawn(function* GMPProvider_updateTask() { + this._log.trace("findUpdates() - updateTask"); + try { + let installManager = new GMPInstallManager(); + let gmpAddons = yield installManager.checkForAddons(); + let update = gmpAddons.find(function(aAddon) { + return aAddon.id === this._plugin.id; + }, this); + if (update && update.isValid && !update.isInstalled) { + this._log.trace("findUpdates() - found update for " + + this._plugin.id + ", installing"); + yield installManager.installAddon(update); + } else { + this._log.trace("findUpdates() - no updates for " + this._plugin.id); + } + this._log.info("findUpdates() - updateTask succeeded for " + + this._plugin.id); + } catch (e) { + this._log.error("findUpdates() - updateTask for " + this._plugin.id + + " threw", e); + throw e; + } finally { + this._updateTask = null; + return true; + } + }.bind(this)); + + return this._updateTask; + }, + + get pluginMimeTypes() { return []; }, + get pluginLibraries() { + if (this.isInstalled) { + let path = this.version; + return [path]; + } + return []; + }, + get pluginFullpath() { + if (this.isInstalled) { + let path = OS.Path.join(OS.Constants.Path.profileDir, + this._plugin.id, + this.version); + return [path]; + } + return []; + }, + + get isInstalled() { + return this.version && this.version.length > 0; + }, + + _handleEnabledChanged: function() { + AddonManagerPrivate.callAddonListeners(this.isActive ? + "onEnabling" : "onDisabling", + this, false); + if (this._gmpPath) { + if (this.isActive) { + this._log.info("onPrefEnabledChanged() - adding gmp directory " + + this._gmpPath); + gmpService.addPluginDirectory(this._gmpPath); + } else { + this._log.info("onPrefEnabledChanged() - removing gmp directory " + + this._gmpPath); + gmpService.removePluginDirectory(this._gmpPath); + } + } + AddonManagerPrivate.callAddonListeners(this.isActive ? + "onEnabled" : "onDisabled", + this); + }, + + onPrefEMEGlobalEnabledChanged: function() { + AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, + ["appDisabled"]); + if (this.appDisabled) { + this.uninstallPlugin(); + } else { + AddonManagerPrivate.callInstallListeners("onExternalInstall", null, this, + null, false); + AddonManagerPrivate.callAddonListeners("onInstalling", this, false); + AddonManagerPrivate.callAddonListeners("onInstalled", this); + this.checkForUpdates(GMP_CHECK_DELAY); + } + if (!this.userDisabled) { + this._handleEnabledChanged(); + } + }, + + checkForUpdates: function(delay) { + if (this._isUpdateCheckPending) { + return; + } + this._isUpdateCheckPending = true; + GMPPrefs.reset(GMPPrefs.KEY_UPDATE_LAST_CHECK, null); + // Delay this in case the user changes his mind and doesn't want to + // enable EME after all. + setTimeout(() => { + if (!this.appDisabled) { + let gmpInstallManager = new GMPInstallManager(); + // We don't really care about the results, if someone is interested + // they can check the log. + gmpInstallManager.simpleCheckAndInstall().then(null, () => {}); + } + this._isUpdateCheckPending = false; + }, delay); + }, + + receiveMessage: function({target: browser, data: data}) { + this._log.trace("receiveMessage() data=" + data); + let parsedData; + try { + parsedData = JSON.parse(data); + } catch(ex) { + this._log.error("Malformed EME video message with data: " + data); + return; + } + let {status: status, keySystem: keySystem} = parsedData; + if (status == "cdm-not-installed" || status == "cdm-insufficient-version") { + this.checkForUpdates(0); + } + }, + + onPrefEnabledChanged: function() { + if (!this._plugin.isEME || !this.appDisabled) { + this._handleEnabledChanged(); + } + }, + + onPrefVersionChanged: function() { + AddonManagerPrivate.callAddonListeners("onUninstalling", this, false); + if (this._gmpPath) { + this._log.info("onPrefVersionChanged() - unregistering gmp directory " + + this._gmpPath); + gmpService.removeAndDeletePluginDirectory(this._gmpPath, true /* can defer */); + } + AddonManagerPrivate.callAddonListeners("onUninstalled", this); + + AddonManagerPrivate.callInstallListeners("onExternalInstall", null, this, + null, false); + AddonManagerPrivate.callAddonListeners("onInstalling", this, false); + this._gmpPath = null; + if (this.isInstalled) { + this._gmpPath = OS.Path.join(OS.Constants.Path.profileDir, + this._plugin.id, + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VERSION, + null, this._plugin.id)); + } + if (this._gmpPath && this.isActive) { + this._log.info("onPrefVersionChanged() - registering gmp directory " + + this._gmpPath); + gmpService.addPluginDirectory(this._gmpPath); + } + AddonManagerPrivate.callAddonListeners("onInstalled", this); + }, + + uninstallPlugin: function() { + AddonManagerPrivate.callAddonListeners("onUninstalling", this, false); + if (this.gmpPath) { + this._log.info("uninstallPlugin() - unregistering gmp directory " + + this.gmpPath); + gmpService.removeAndDeletePluginDirectory(this.gmpPath); + } + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id); + AddonManagerPrivate.callAddonListeners("onUninstalled", this); + }, + + shutdown: function() { + Preferences.ignore(GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, + this._plugin.id), + this.onPrefEnabledChanged, this); + Preferences.ignore(GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, + this._plugin.id), + this.onPrefVersionChanged, this); + if (this._plugin.isEME) { + Preferences.ignore(GMPPrefs.KEY_EME_ENABLED, + this.onPrefEMEGlobalEnabledChanged, this); + messageManager.removeMessageListener("EMEVideo:ContentMediaKeysRequest", this); + } + return this._updateTask; + }, +}; + +var GMPProvider = { + get name() { return "GMPProvider"; }, + + _plugins: null, + + startup: function() { + configureLogging(); + this._log = Log.repository.getLoggerWithMessagePrefix("Toolkit.GMP", + "GMPProvider."); + this.buildPluginList(); + this.ensureProperCDMInstallState(); + + Preferences.observe(GMPPrefs.KEY_LOG_BASE, configureLogging); + + for (let [id, plugin] of this._plugins) { + let wrapper = plugin.wrapper; + let gmpPath = wrapper.gmpPath; + let isEnabled = wrapper.isActive; + this._log.trace("startup - enabled=" + isEnabled + ", gmpPath=" + + gmpPath); + + if (gmpPath && isEnabled) { + this._log.info("startup - adding gmp directory " + gmpPath); + try { + gmpService.addPluginDirectory(gmpPath); + } catch (e if e.name == 'NS_ERROR_NOT_AVAILABLE') { + this._log.warn("startup - adding gmp directory failed with " + + e.name + " - sandboxing not available?", e); + } + } + } + + if (Preferences.get(GMPPrefs.KEY_EME_ENABLED, false)) { + try { + let greDir = Services.dirsvc.get(NS_GRE_DIR, + Ci.nsILocalFile); + let clearkeyPath = OS.Path.join(greDir.path, + CLEARKEY_PLUGIN_ID, + CLEARKEY_VERSION); + this._log.info("startup - adding clearkey CDM directory " + + clearkeyPath); + gmpService.addPluginDirectory(clearkeyPath); + } catch (e) { + this._log.warn("startup - adding clearkey CDM failed", e); + } + } + }, + + shutdown: function() { + this._log.trace("shutdown"); + Preferences.ignore(GMPPrefs.KEY_LOG_BASE, configureLogging); + + let shutdownTask = Task.spawn(function* GMPProvider_shutdownTask() { + this._log.trace("shutdown - shutdownTask"); + let shutdownSucceeded = true; + + for (let plugin of this._plugins.values()) { + try { + yield plugin.wrapper.shutdown(); + } catch (e) { + shutdownSucceeded = false; + } + } + + this._plugins = null; + + if (!shutdownSucceeded) { + throw new Error("Shutdown failed"); + } + }.bind(this)); + + return shutdownTask; + }, + + getAddonByID: function(aId, aCallback) { + if (!this.isEnabled) { + aCallback(null); + return; + } + + let plugin = this._plugins.get(aId); + if (plugin && !GMPUtils.isPluginHidden(plugin)) { + aCallback(plugin.wrapper); + } else { + aCallback(null); + } + }, + + getAddonsByTypes: function(aTypes, aCallback) { + if (!this.isEnabled || + (aTypes && aTypes.indexOf("plugin") < 0)) { + aCallback([]); + return; + } + + // Tycho: + // let results = [p.wrapper for ([id, p] of this._plugins) + // if (!GMPUtils.isPluginHidden(p))]; + let results = []; + for (let [id, p] of this._plugins) { + if (!GMPUtils.isPluginHidden(p)) { + results.push(p.wrapper); + } + } + + aCallback(results); + }, + + get isEnabled() { + return GMPPrefs.get(GMPPrefs.KEY_PROVIDER_ENABLED, false); + }, + + generateFullDescription: function(aLicenseURL, aLicenseInfo) { + return "<xhtml:a href=\"" + aLicenseURL + "\" target=\"_blank\">" + + aLicenseInfo + "</xhtml:a>." + }, + + buildPluginList: function() { + let licenseInfo = pluginsBundle.GetStringFromName(GMP_LICENSE_INFO); + + this._plugins = new Map(); + for (let aPlugin of GMP_PLUGINS) { + let plugin = { + id: aPlugin.id, + name: pluginsBundle.GetStringFromName(aPlugin.name), + description: pluginsBundle.GetStringFromName(aPlugin.description), + homepageURL: aPlugin.homepageURL, + optionsURL: aPlugin.optionsURL, + wrapper: null, + isEME: aPlugin.isEME, + }; + if (aPlugin.licenseURL) { + plugin.fullDescription = + this.generateFullDescription(aPlugin.licenseURL, licenseInfo); + } + plugin.wrapper = new GMPWrapper(plugin); + this._plugins.set(plugin.id, plugin); + } + }, + + ensureProperCDMInstallState: function() { + if (!GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true)) { + for (let [id, plugin] of this._plugins) { + if (plugin.isEME && plugin.wrapper.isInstalled) { + gmpService.addPluginDirectory(plugin.wrapper.gmpPath); + plugin.wrapper.uninstallPlugin(); + } + } + } + }, +}; + +AddonManagerPrivate.registerProvider(GMPProvider, [ + new AddonManagerPrivate.AddonType("plugin", URI_EXTENSION_STRINGS, + STRING_TYPE_NAME, + AddonManager.VIEW_TYPE_LIST, 6000, + AddonManager.TYPE_SUPPORTS_ASK_TO_ACTIVATE) +]); diff --git a/components/addons/src/GMPUtils.jsm b/components/addons/src/GMPUtils.jsm new file mode 100644 index 000000000..593fc3c8d --- /dev/null +++ b/components/addons/src/GMPUtils.jsm @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = + Components; + +this.EXPORTED_SYMBOLS = [ "GMP_PLUGIN_IDS", + "GMPPrefs", + "GMPUtils", + "OPEN_H264_ID", + "WIDEVINE_ID" ]; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +// GMP IDs +const OPEN_H264_ID = "gmp-gmpopenh264"; +const WIDEVINE_ID = "gmp-widevinecdm"; +const GMP_PLUGIN_IDS = [ OPEN_H264_ID, WIDEVINE_ID ]; + +var GMPPluginUnsupportedReason = { + NOT_WINDOWS: 1, + WINDOWS_VERSION: 2, +}; + +var GMPPluginHiddenReason = { + UNSUPPORTED: 1, + EME_DISABLED: 2, +}; + +this.GMPUtils = { + /** + * Checks whether or not a given plugin is hidden. Hidden plugins are neither + * downloaded nor displayed in the addons manager. + * @param aPlugin + * The plugin to check. + */ + isPluginHidden: function(aPlugin) { + if (!aPlugin.isEME) { + return false; + } + + if (!this._isPluginSupported(aPlugin) || + !this._isPluginVisible(aPlugin)) { + return true; + } + + if (!GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true)) { + return true; + } + + return false; + }, + + /** + * Checks whether or not a given plugin is supported by the current OS. + * @param aPlugin + * The plugin to check. + */ + _isPluginSupported: function(aPlugin) { + if (this._isPluginForceSupported(aPlugin)) { + return true; + } + if (aPlugin.id == WIDEVINE_ID) { + +#if defined(XP_WIN) || defined(XP_LINUX) + // The Widevine plugin is available for Windows versions Vista and later, + // Mac OSX, and Linux. + return true; +#else + return false; +#endif + } + + return true; + }, + + /** + * Checks whether or not a given plugin is visible in the addons manager + * UI and the "enable DRM" notification box. This can be used to test + * plugins that aren't yet turned on in the mozconfig. + * @param aPlugin + * The plugin to check. + */ + _isPluginVisible: function(aPlugin) { + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VISIBLE, false, aPlugin.id); + }, + + /** + * Checks whether or not a given plugin is forced-supported. This is used + * in automated tests to override the checks that prevent GMPs running on an + * unsupported platform. + * @param aPlugin + * The plugin to check. + */ + _isPluginForceSupported: function(aPlugin) { + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, false, aPlugin.id); + }, +}; + +/** + * Manages preferences for GMP addons + */ +this.GMPPrefs = { + KEY_EME_ENABLED: "media.eme.enabled", + KEY_PLUGIN_ENABLED: "media.{0}.enabled", + KEY_PLUGIN_LAST_UPDATE: "media.{0}.lastUpdate", + KEY_PLUGIN_VERSION: "media.{0}.version", + KEY_PLUGIN_AUTOUPDATE: "media.{0}.autoupdate", + KEY_PLUGIN_VISIBLE: "media.{0}.visible", + KEY_PLUGIN_ABI: "media.{0}.abi", + KEY_PLUGIN_FORCE_SUPPORTED: "media.{0}.forceSupported", + KEY_URL: "media.gmp-manager.url", + KEY_URL_OVERRIDE: "media.gmp-manager.url.override", + KEY_CERT_CHECKATTRS: "media.gmp-manager.cert.checkAttributes", + KEY_CERT_REQUIREBUILTIN: "media.gmp-manager.cert.requireBuiltIn", + KEY_UPDATE_LAST_CHECK: "media.gmp-manager.lastCheck", + KEY_SECONDS_BETWEEN_CHECKS: "media.gmp-manager.secondsBetweenChecks", + KEY_UPDATE_ENABLED: "media.gmp-manager.updateEnabled", + KEY_APP_DISTRIBUTION: "distribution.id", + KEY_APP_DISTRIBUTION_VERSION: "distribution.version", + KEY_BUILDID: "media.gmp-manager.buildID", + KEY_CERTS_BRANCH: "media.gmp-manager.certs.", + KEY_PROVIDER_ENABLED: "media.gmp-provider.enabled", + KEY_LOG_BASE: "media.gmp.log.", + KEY_LOGGING_LEVEL: "media.gmp.log.level", + KEY_LOGGING_DUMP: "media.gmp.log.dump", + + /** + * Obtains the specified preference in relation to the specified plugin. + * @param aKey The preference key value to use. + * @param aDefaultValue The default value if no preference exists. + * @param aPlugin The plugin to scope the preference to. + * @return The obtained preference value, or the defaultValue if none exists. + */ + get: function(aKey, aDefaultValue, aPlugin) { + if (aKey === this.KEY_APP_DISTRIBUTION || + aKey === this.KEY_APP_DISTRIBUTION_VERSION) { + return Services.prefs.getDefaultBranch(null).getCharPref(aKey, "default"); + } + return Preferences.get(this.getPrefKey(aKey, aPlugin), aDefaultValue); + }, + + /** + * Sets the specified preference in relation to the specified plugin. + * @param aKey The preference key value to use. + * @param aVal The value to set. + * @param aPlugin The plugin to scope the preference to. + */ + set: function(aKey, aVal, aPlugin) { + Preferences.set(this.getPrefKey(aKey, aPlugin), aVal); + }, + + /** + * Checks whether or not the specified preference is set in relation to the + * specified plugin. + * @param aKey The preference key value to use. + * @param aPlugin The plugin to scope the preference to. + * @return true if the preference is set, false otherwise. + */ + isSet: function(aKey, aPlugin) { + return Preferences.isSet(this.getPrefKey(aKey, aPlugin)); + }, + + /** + * Resets the specified preference in relation to the specified plugin to its + * default. + * @param aKey The preference key value to use. + * @param aPlugin The plugin to scope the preference to. + */ + reset: function(aKey, aPlugin) { + Preferences.reset(this.getPrefKey(aKey, aPlugin)); + }, + + /** + * Scopes the specified preference key to the specified plugin. + * @param aKey The preference key value to use. + * @param aPlugin The plugin to scope the preference to. + * @return A preference key scoped to the specified plugin. + */ + getPrefKey: function(aKey, aPlugin) { + return aKey.replace("{0}", aPlugin || ""); + }, +}; diff --git a/components/addons/src/ProductAddonChecker.jsm b/components/addons/src/ProductAddonChecker.jsm new file mode 100644 index 000000000..c6324da0a --- /dev/null +++ b/components/addons/src/ProductAddonChecker.jsm @@ -0,0 +1,464 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +const LOCAL_EME_SOURCES = [{ + "id": "gmp-gmpopenh264", + "src": "chrome://global/content/gmp-sources/openh264.json" +}, { + "id": "gmp-widevinecdm", + "src": "chrome://global/content/gmp-sources/widevinecdm.json" +}]; + +this.EXPORTED_SYMBOLS = [ "ProductAddonChecker" ]; + +Cu.importGlobalProperties(["XMLHttpRequest"]); + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/CertUtils.jsm"); +/* globals checkCert, BadCertHandler*/ +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); + +/* globals GMPPrefs */ +XPCOMUtils.defineLazyModuleGetter(this, "GMPPrefs", + "resource://gre/modules/GMPUtils.jsm"); + +/* globals OS */ + +XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", + "resource://gre/modules/UpdateUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ServiceRequest", + "resource://gre/modules/ServiceRequest.jsm"); + +// This exists so that tests can override the XHR behaviour for downloading +// the addon update XML file. +var CreateXHR = function() { + return Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsISupports); +} + +var logger = Log.repository.getLogger("addons.productaddons"); + +/** + * Number of milliseconds after which we need to cancel `downloadXML`. + * + * Bug 1087674 suggests that the XHR we use in `downloadXML` may + * never terminate in presence of network nuisances (e.g. strange + * antivirus behavior). This timeout is a defensive measure to ensure + * that we fail cleanly in such case. + */ +const TIMEOUT_DELAY_MS = 20000; +// Chunk size for the incremental downloader +const DOWNLOAD_CHUNK_BYTES_SIZE = 300000; +// Incremental downloader interval +const DOWNLOAD_INTERVAL = 0; +// How much of a file to read into memory at a time for hashing +const HASH_CHUNK_SIZE = 8192; + +/** + * Gets the status of an XMLHttpRequest either directly or from its underlying + * channel. + * + * @param request + * The XMLHttpRequest. + * @return an integer status value. + */ +function getRequestStatus(request) { + let status = null; + try { + status = request.status; + } + catch (e) { + } + + if (status != null) { + return status; + } + + return request.channel.QueryInterface(Ci.nsIRequest).status; +} + +/** + * Downloads an XML document from a URL optionally testing the SSL certificate + * for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to the DOM document downloaded or rejects + * with a JS exception in case of error. + */ +function downloadXML(url, allowNonBuiltIn = false, allowedCerts = null) { + return new Promise((resolve, reject) => { + let request = CreateXHR(); + // This is here to let unit test code override XHR + if (request.wrappedJSObject) { + request = request.wrappedJSObject; + } + request.open("GET", url, true); + request.channel.notificationCallbacks = new BadCertHandler(allowNonBuiltIn); + // Prevent the request from reading from the cache. + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + // Use conservative TLS settings. See bug 1325501. + // TODO move to ServiceRequest. + if (request.channel instanceof Ci.nsIHttpChannelInternal) { + request.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = true; + } + request.timeout = TIMEOUT_DELAY_MS; + + request.overrideMimeType("text/xml"); + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + request.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + request.setRequestHeader("Pragma", "no-cache"); + + let fail = (event) => { + let request = event.target; + let status = getRequestStatus(request); + let message = "Failed downloading XML, status: " + status + ", reason: " + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status; + reject(ex); + }; + + let success = (event) => { + logger.info("Completed downloading document"); + let request = event.target; + + try { + checkCert(request.channel, allowNonBuiltIn, allowedCerts); + } catch (ex) { + logger.error("Request failed certificate checks: " + ex); + ex.status = getRequestStatus(request); + reject(ex); + return; + } + + resolve(request.responseXML); + }; + + request.addEventListener("error", fail, false); + request.addEventListener("abort", fail, false); + request.addEventListener("timeout", fail, false); + request.addEventListener("load", success, false); + + logger.info("sending request to: " + url); + request.send(null); + }); +} + +function downloadJSON(uri) { + logger.info("fetching config from: " + uri); + return new Promise((resolve, reject) => { + let xmlHttp = new ServiceRequest({mozAnon: true}); + + xmlHttp.onload = function(aResponse) { + resolve(JSON.parse(this.responseText)); + }; + + xmlHttp.onerror = function(e) { + reject("Fetching " + uri + " results in error code: " + e.target.status); + }; + + xmlHttp.open("GET", uri); + xmlHttp.overrideMimeType("application/json"); + xmlHttp.send(); + }); +} + + +/** + * Parses a list of add-ons from a DOM document. + * + * @param document + * The DOM document to parse. + * @return null if there is no <addons> element otherwise an object containing + * an array of the addons listed and a field notifying whether the + * fallback was used. + */ +function parseXML(document) { + // Check that the root element is correct + if (document.documentElement.localName != "updates") { + throw new Error("got node name: " + document.documentElement.localName + + ", expected: updates"); + } + + // Check if there are any addons elements in the updates element + let addons = document.querySelector("updates:root > addons"); + if (!addons) { + return null; + } + + let results = []; + let addonList = document.querySelectorAll("updates:root > addons > addon"); + for (let addonElement of addonList) { + let addon = {}; + + for (let name of ["id", "URL", "hashFunction", "hashValue", "version", "size"]) { + if (addonElement.hasAttribute(name)) { + addon[name] = addonElement.getAttribute(name); + } + } + addon.size = Number(addon.size) || undefined; + + results.push(addon); + } + + return { + usedFallback: false, + gmpAddons: results + }; +} + +/** + * If downloading from the network fails (AUS server is down), + * load the sources from local build configuration. + */ +function downloadLocalConfig() { + + if (!GMPPrefs.get(GMPPrefs.KEY_UPDATE_ENABLED, true)) { + logger.info("Updates are disabled via media.gmp-manager.updateEnabled"); + return Promise.resolve({usedFallback: true, gmpAddons: []}); + } + + return Promise.all(LOCAL_EME_SOURCES.map(conf => { + return downloadJSON(conf.src).then(addons => { + + let platforms = addons.vendors[conf.id].platforms; + let target = Services.appinfo.OS + "_" + UpdateUtils.ABI; + let details = null; + + while (!details) { + if (!(target in platforms)) { + // There was no matching platform so return false, this addon + // will be filtered from the results below + logger.info("no details found for: " + target); + return false; + } + // Field either has the details of the binary or is an alias + // to another build target key that does + if (platforms[target].alias) { + target = platforms[target].alias; + } else { + details = platforms[target]; + } + } + + logger.info("found plugin: " + conf.id); + return { + "id": conf.id, + "URL": details.fileUrl, + "hashFunction": addons.hashFunction, + "hashValue": details.hashValue, + "version": addons.vendors[conf.id].version, + "size": details.filesize + }; + }); + })).then(addons => { + + // Some filters may not match this platform so + // filter those out + addons = addons.filter(x => x !== false); + + return { + usedFallback: true, + gmpAddons: addons + }; + }); +} + +/** + * Downloads file from a URL using XHR. + * + * @param url + * The url to download from. + * @return a promise that resolves to the path of a temporary file or rejects + * with a JS exception in case of error. + */ +function downloadFile(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.onload = function(response) { + logger.info("downloadXHR File download. status=" + xhr.status); + if (xhr.status != 200 && xhr.status != 206) { + reject(Components.Exception("File download failed", xhr.status)); + return; + } + Task.spawn(function* () { + let f = yield OS.File.openUnique(OS.Path.join(OS.Constants.Path.tmpDir, "tmpaddon")); + let path = f.path; + logger.info(`Downloaded file will be saved to ${path}`); + yield f.file.close(); + yield OS.File.writeAtomic(path, new Uint8Array(xhr.response)); + return path; + }).then(resolve, reject); + }; + + let fail = (event) => { + let request = event.target; + let status = getRequestStatus(request); + let message = "Failed downloading via XHR, status: " + status + ", reason: " + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status; + reject(ex); + }; + xhr.addEventListener("error", fail); + xhr.addEventListener("abort", fail); + + xhr.responseType = "arraybuffer"; + try { + xhr.open("GET", url); + // Use conservative TLS settings. See bug 1325501. + // TODO move to ServiceRequest. + if (xhr.channel instanceof Ci.nsIHttpChannelInternal) { + xhr.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = true; + } + xhr.send(null); + } catch (ex) { + reject(ex); + } + }); +} + +/** + * Convert a string containing binary values to hex. + */ +function binaryToHex(input) { + let result = ""; + for (let i = 0; i < input.length; ++i) { + let hex = input.charCodeAt(i).toString(16); + if (hex.length == 1) { + hex = "0" + hex; + } + result += hex; + } + return result; +} + +/** + * Calculates the hash of a file. + * + * @param hashFunction + * The type of hash function to use, must be supported by nsICryptoHash. + * @param path + * The path of the file to hash. + * @return a promise that resolves to hash of the file or rejects with a JS + * exception in case of error. + */ +var computeHash = Task.async(function*(hashFunction, path) { + let file = yield OS.File.open(path, { existing: true, read: true }); + try { + let hasher = Cc["@mozilla.org/security/hash;1"]. + createInstance(Ci.nsICryptoHash); + hasher.initWithString(hashFunction); + + let bytes; + do { + bytes = yield file.read(HASH_CHUNK_SIZE); + hasher.update(bytes, bytes.length); + } while (bytes.length == HASH_CHUNK_SIZE); + + return binaryToHex(hasher.finish(false)); + } + finally { + yield file.close(); + } +}); + +/** + * Verifies that a downloaded file matches what was expected. + * + * @param properties + * The properties to check, `size` and `hashFunction` with `hashValue` + * are supported. Any properties missing won't be checked. + * @param path + * The path of the file to check. + * @return a promise that resolves if the file matched or rejects with a JS + * exception in case of error. + */ +var verifyFile = Task.async(function*(properties, path) { + if (properties.size !== undefined) { + let stat = yield OS.File.stat(path); + if (stat.size != properties.size) { + throw new Error("Downloaded file was " + stat.size + " bytes but expected " + properties.size + " bytes."); + } + } + + if (properties.hashFunction !== undefined) { + let expectedDigest = properties.hashValue.toLowerCase(); + let digest = yield computeHash(properties.hashFunction, path); + if (digest != expectedDigest) { + throw new Error("Hash was `" + digest + "` but expected `" + expectedDigest + "`."); + } + } +}); + +const ProductAddonChecker = { + /** + * Downloads a list of add-ons from a URL optionally testing the SSL + * certificate for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to an object containing the list of add-ons + * and whether the local fallback was used, or rejects with a JS + * exception in case of error. + */ + getProductAddonList: function(url, allowNonBuiltIn = false, allowedCerts = null) { + if (!GMPPrefs.get(GMPPrefs.KEY_UPDATE_ENABLED, true)) { + logger.info("Updates are disabled via media.gmp-manager.updateEnabled"); + return Promise.resolve({usedFallback: true, gmpAddons: []}); + } + + return downloadXML(url, allowNonBuiltIn, allowedCerts) + .then(parseXML) + .catch(downloadLocalConfig); + }, + + /** + * Downloads an add-on to a local file and checks that it matches the expected + * file. The caller is responsible for deleting the temporary file returned. + * + * @param addon + * The addon to download. + * @return a promise that resolves to the temporary file downloaded or rejects + * with a JS exception in case of error. + */ + downloadAddon: Task.async(function*(addon) { + let path = yield downloadFile(addon.URL); + try { + yield verifyFile(addon, path); + return path; + } + catch (e) { + yield OS.File.remove(path); + throw e; + } + }) +} |