diff --git a/build/conformance.textproto b/build/conformance.textproto index 0ac6dd4136..4d2f0a43a2 100644 --- a/build/conformance.textproto +++ b/build/conformance.textproto @@ -232,6 +232,7 @@ requirement: { error_message: 'Event is not constructable on IE. Instead, use ' 'document.createEvent(\'CustomEvent\') to create an event and ' 'event.initCustomEvent(\'my-event-type\') to initialize it.' + whitelist_regexp: 'lib/polyfill/patchedmediakeys_apple.js' } @@ -247,6 +248,7 @@ requirement: { value: 'TypedArray.prototype.slice' error_message: 'TypedArray.slice is not allowed because it ' 'is not supported on IE11' + whitelist_regexp: 'lib/polyfill/patchedmediakeys_apple.js' } requirement: { type: BANNED_NAME diff --git a/build/types/polyfill b/build/types/polyfill index 8afff665d0..89165ad711 100644 --- a/build/types/polyfill +++ b/build/types/polyfill @@ -5,6 +5,7 @@ +../../lib/polyfill/input_event.js +../../lib/polyfill/mathround.js +../../lib/polyfill/mediasource.js ++../../lib/polyfill/patchedmediakeys_apple.js +../../lib/polyfill/patchedmediakeys_ms.js +../../lib/polyfill/patchedmediakeys_nop.js +../../lib/polyfill/patchedmediakeys_webkit.js diff --git a/demo/asset_section.js b/demo/asset_section.js index 441992ce11..3eed041b40 100644 --- a/demo/asset_section.js +++ b/demo/asset_section.js @@ -216,6 +216,7 @@ shakaDemo.preparePlayer_ = function(asset) { let commonDrmSystems = [ 'com.widevine.alpha', 'com.microsoft.playready', + 'com.apple.fps.1_0', 'com.adobe.primetime', 'org.w3.clearkey', ]; diff --git a/demo/common/assets.js b/demo/common/assets.js index 3a515cf59c..44f3abd73c 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -64,6 +64,7 @@ shakaAssets.Source = { /** @enum {string} */ shakaAssets.KeySystem = { CLEAR_KEY: 'org.w3.clearkey', + FAIRPLAY: 'com.apple.fps.1_0', PLAYREADY: 'com.microsoft.playready', WIDEVINE: 'com.widevine.alpha', }; diff --git a/externs/webkitmediakeys.js b/externs/webkitmediakeys.js new file mode 100644 index 0000000000..bba3431716 --- /dev/null +++ b/externs/webkitmediakeys.js @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Externs for prefixed EME v20140218 as supported by IE11/Edge + * (http://www.w3.org/TR/2014/WD-encrypted-media-20140218). + * @externs + */ + + +/** + * @constructor + * @param {string} keySystem + */ +function WebKitMediaKeys(keySystem) {} + + +/** + * @param {string} keySystem + * @param {string} contentType + * @return {boolean} + */ +WebKitMediaKeys.isTypeSupported = function(keySystem, contentType) {}; + + +/** + * @param {string} contentType + * @param {Uint8Array} initData + * @return {!WebKitMediaKeySession} + */ +WebKitMediaKeys.prototype.createSession = function(contentType, initData) {}; + + +/** + * @interface + * @extends {EventTarget} + */ +function WebKitMediaKeySession() {} + + +/** + * @param {Uint8Array} message + */ +WebKitMediaKeySession.prototype.update = function(message) {}; + + +WebKitMediaKeySession.prototype.close = function() {}; + + +/** @type {WebKitMediaKeyError} */ +WebKitMediaKeySession.prototype.error; + + +/** @override */ +WebKitMediaKeySession.prototype.addEventListener = + function(type, listener, useCapture) {}; + + +/** @override */ +WebKitMediaKeySession.prototype.removeEventListener = + function(type, listener, useCapture) {}; + + +/** @override */ +WebKitMediaKeySession.prototype.dispatchEvent = function(evt) {}; + + +/** + * @param {WebKitMediaKeys} mediaKeys + */ +HTMLMediaElement.prototype.webkitSetMediaKeys = function(mediaKeys) {}; + + +/** @type {WebKitMediaKeys} */ +HTMLMediaElement.prototype.webkitKeys; + + +/** @constructor */ +function WebKitMediaKeyError() {} + + +/** @type {number} */ +WebKitMediaKeyError.prototype.code; + + +/** @type {number} */ +WebKitMediaKeyError.prototype.systemCode; + + +/** @type {number} */ +WebKitMediaKeyError.MEDIA_KEYERR_UNKNOWN; + + +/** @type {number} */ +WebKitMediaKeyError.MEDIA_KEYERR_CLIENT; + + +/** @type {number} */ +WebKitMediaKeyError.MEDIA_KEYERR_SERVICE; + + +/** @type {number} */ +WebKitMediaKeyError.MEDIA_KEYERR_OUTPUT; + + +/** @type {number} */ +WebKitMediaKeyError.MEDIA_KEYERR_HARDWARECHANGE; + + +/** @type {number} */ +WebKitMediaKeyError.MEDIA_KEYERR_DOMAIN; diff --git a/lib/media/drm_engine.js b/lib/media/drm_engine.js index a5702b09d0..64ddafd645 100644 --- a/lib/media/drm_engine.js +++ b/lib/media/drm_engine.js @@ -461,6 +461,11 @@ shaka.media.DrmEngine.prototype.attach = function(video) { // warn when the stream is encrypted, even though the manifest does not know // it. // Don't complain about this twice, so just listenOnce(). + // FIXME: This is ineffective when a prefixed event is translated by our + // polyfills, since those events are only caught and translated by a + // MediaKeys instance. With clear content and no polyfilled MediaKeys + // instance attached, you'll never see the 'encrypted' event on those + // platforms (IE 11 & Safari). this.eventManager_.listenOnce(video, 'encrypted', (event) => { this.onError_(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, @@ -1186,11 +1191,18 @@ shaka.media.DrmEngine.prototype.sendLicenseRequest_ = function(event) { this.currentDrmInfo_.keySystem == 'com.chromecast.playready') { this.unpackPlayReadyRequest_(request); } + if (this.currentDrmInfo_.keySystem.startsWith('com.apple.fps')) { + this.formatFairPlayRequest_(request); + } this.playerInterface_.netEngine.request(requestType, request).promise .then(function(response) { if (this.isDestroying_) { return Promise.reject(); } + if (this.currentDrmInfo_.keySystem.startsWith('com.apple.fps')) { + this.parseFairPlayResponse_(response); + } + // Request succeeded, now pass the response to the CDM. return session.update(response.data).then(function() { let event = new shaka.util.FakeEvent('drmsessionupdate'); @@ -1309,6 +1321,72 @@ shaka.media.DrmEngine.prototype.unpackPlayReadyRequest_ = function(request) { }; +/** + * Formats FairPlay license requests. Modifies the request object. + * + * @param {shaka.extern.Request} request + * @private + */ +shaka.media.DrmEngine.prototype.formatFairPlayRequest_ = function(request) { + // The standard format for FairPlay seems to be to place the request into a + // POST parameter (spc=). + const originalPayload = new Uint8Array(request.body); + const base64Payload = shaka.util.Uint8ArrayUtils.toBase64(originalPayload); + const params = 'spc=' + base64Payload; + request.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + request.body = shaka.util.StringUtils.toUTF8(params); +}; + + +/** + * Parse FairPlay license response format. Modifies the response object. + * This will run after any response filters, so application-specific formats + * can still be handled by the app. + * + * @param {shaka.extern.Response} response + * @private + */ +shaka.media.DrmEngine.prototype.parseFairPlayResponse_ = function(response) { + // In Apple's docs, responses can be of the form: + // '\nbase64encoded\n' or 'base64encoded' + // We have also seen responses in JSON format from some of our partners. + // In all of these text-based formats, the CKC data is base64-encoded. + + // This handles all of the above. Other formats should be handled via + // application-level response filters. + + let responseText; + try { + // Convert it to text for further processing. + responseText = shaka.util.StringUtils.fromUTF8(response.data); + } catch (error) { + // Assume it's not a text format of any kind and leave it alone. + return; + } + + // Trim whitespace. + responseText = responseText.trim(); + + // Look for wrapper and remove it. + if (responseText.substr(0, 5) === '' && + responseText.substr(-6) === '') { + responseText = responseText.slice(5, -6); + } + + // Look for a JSON wrapper and remove it. + try { + const responseObject = JSON.parse(responseText); + responseText = responseObject['ckc']; + } catch (error) { + // It wasn't JSON. Fall through with other transformations. + } + + // Decode the base64-encoded data into the format the browser expects. + // It's not clear why FairPlay license servers don't just serve this directly. + response.data = shaka.util.Uint8ArrayUtils.fromBase64(responseText).buffer; +}; + + /** * @param {!Event} event * @private @@ -1477,6 +1555,7 @@ shaka.media.DrmEngine.probeSupport = function() { 'org.w3.clearkey', 'com.widevine.alpha', 'com.microsoft.playready', + 'com.apple.fps.3_0', 'com.apple.fps.2_0', 'com.apple.fps.1_0', 'com.apple.fps', diff --git a/lib/player.js b/lib/player.js index 586fe554d7..efcdf0a4ed 100644 --- a/lib/player.js +++ b/lib/player.js @@ -4545,7 +4545,11 @@ shaka.Player.prototype.onVideoError_ = function(event) { * @private */ shaka.Player.prototype.onKeyStatus_ = function(keyStatusMap) { - goog.asserts.assert(this.streamingEngine_, 'Should have been initialized.'); + if (!this.streamingEngine_) { + // We can't use this info to manage restrictions in src= mode, so ignore it. + return; + } + const restrictedStatuses = shaka.Player.restrictedStatuses_; /** @type {shaka.extern.Period} */ diff --git a/lib/polyfill/patchedmediakeys_apple.js b/lib/polyfill/patchedmediakeys_apple.js new file mode 100644 index 0000000000..9531a93077 --- /dev/null +++ b/lib/polyfill/patchedmediakeys_apple.js @@ -0,0 +1,850 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +goog.provide('shaka.polyfill.PatchedMediaKeysApple'); + +goog.require('goog.Uri'); +goog.require('goog.asserts'); +goog.require('shaka.log'); +goog.require('shaka.polyfill.register'); +goog.require('shaka.util.EventManager'); +goog.require('shaka.util.FakeEvent'); +goog.require('shaka.util.FakeEventTarget'); +goog.require('shaka.util.PublicPromise'); +goog.require('shaka.util.StringUtils'); +goog.require('shaka.util.Uint8ArrayUtils'); + + +/** + * @namespace shaka.polyfill.PatchedMediaKeysApple + * + * @summary A polyfill to implement modern, standardized EME on top of Apple's + * prefixed EME in Safari. + */ + + +/** + * Installs the polyfill if needed. + */ +shaka.polyfill.PatchedMediaKeysApple.install = function() { + if (!window.HTMLVideoElement || !window.WebKitMediaKeys || + (navigator.requestMediaKeySystemAccess && + MediaKeySystemAccess.prototype.getConfiguration)) { + return; + } + shaka.log.info('Using Apple-prefixed EME'); + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + // Construct a fake key ID. This is not done at load-time to avoid exceptions + // on unsupported browsers. This particular fake key ID was suggested in + // w3c/encrypted-media#32. + PatchedMediaKeysApple.MediaKeyStatusMap.KEY_ID_ = + (new Uint8Array([0])).buffer; + + // Delete mediaKeys to work around strict mode compatibility issues. + delete HTMLMediaElement.prototype['mediaKeys']; + // Work around read-only declaration for mediaKeys by using a string. + HTMLMediaElement.prototype['mediaKeys'] = null; + HTMLMediaElement.prototype.setMediaKeys = PatchedMediaKeysApple.setMediaKeys; + + // Install patches + window.MediaKeys = PatchedMediaKeysApple.MediaKeys; + window.MediaKeySystemAccess = PatchedMediaKeysApple.MediaKeySystemAccess; + navigator.requestMediaKeySystemAccess = + PatchedMediaKeysApple.requestMediaKeySystemAccess; +}; + + +/** + * An implementation of navigator.requestMediaKeySystemAccess. + * Retrieves a MediaKeySystemAccess object. + * + * @this {!Navigator} + * @param {string} keySystem + * @param {!Array.} supportedConfigurations + * @return {!Promise.} + */ +shaka.polyfill.PatchedMediaKeysApple.requestMediaKeySystemAccess = + function(keySystem, supportedConfigurations) { + shaka.log.debug('PatchedMediaKeysApple.requestMediaKeySystemAccess'); + goog.asserts.assert(this == navigator, + 'bad "this" for requestMediaKeySystemAccess'); + + // Alias. + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + try { + const access = new PatchedMediaKeysApple.MediaKeySystemAccess( + keySystem, supportedConfigurations); + return Promise.resolve(/** @type {!MediaKeySystemAccess} */ (access)); + } catch (exception) { + return Promise.reject(exception); + } +}; + + +/** + * An implementation of MediaKeySystemAccess. + * + * @constructor + * @struct + * @param {string} keySystem + * @param {!Array.} supportedConfigurations + * @implements {MediaKeySystemAccess} + * @throws {Error} if the key system is not supported. + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySystemAccess = + function(keySystem, supportedConfigurations) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySystemAccess'); + + /** @type {string} */ + this.keySystem = keySystem; + + /** @private {!MediaKeySystemConfiguration} */ + this.configuration_; + + // Optimization: WebKitMediaKeys.isTypeSupported delays responses by a + // significant amount of time, possibly to discourage fingerprinting. + // Since we know only FairPlay is supported here, let's skip queries for + // anything else to speed up the process. + if (keySystem.startsWith('com.apple.fps')) { + for (const cfg of supportedConfigurations) { + const newCfg = this.checkConfig_(cfg); + if (newCfg) { + this.configuration_ = newCfg; + return; + } + } + } + + // As per the spec, this should be a DOMException, but there is not a public + // constructor for DOMException. + const unsupportedKeySystemError = new Error('Unsupported keySystem'); + unsupportedKeySystemError.name = 'NotSupportedError'; + unsupportedKeySystemError.code = DOMException.NOT_SUPPORTED_ERR; + throw unsupportedKeySystemError; +}; + + +/** + * Check a single config for MediaKeySystemAccess. + * + * @param {MediaKeySystemConfiguration} cfg The requested config. + * @return {?MediaKeySystemConfiguration} A matching config we can support, or + * null if the input is not supportable. + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySystemAccess.prototype. + checkConfig_ = function(cfg) { + if (cfg.persistentState == 'required') { + // Not supported by the prefixed API. + return null; + } + + // Create a new config object and start adding in the pieces which we find + // support for. We will return this from getConfiguration() later if asked. + + /** @type {!MediaKeySystemConfiguration} */ + const newCfg = { + 'audioCapabilities': [], + 'videoCapabilities': [], + // It is technically against spec to return these as optional, but we + // don't truly know their values from the prefixed API: + 'persistentState': 'optional', + 'distinctiveIdentifier': 'optional', + // Pretend the requested init data types are supported, since we don't + // really know that either: + 'initDataTypes': cfg.initDataTypes, + 'sessionTypes': ['temporary'], + 'label': cfg.label, + }; + + // PatchedMediaKeysApple tests for key system availability through + // WebKitMediaKeys.isTypeSupported. + let ranAnyTests = false; + let success = false; + + if (cfg.audioCapabilities) { + for (const cap of cfg.audioCapabilities) { + if (cap.contentType) { + ranAnyTests = true; + + const contentType = cap.contentType.split(';')[0]; + if (WebKitMediaKeys.isTypeSupported(this.keySystem, contentType)) { + newCfg.audioCapabilities.push(cap); + success = true; + } + } + } + } + + if (cfg.videoCapabilities) { + for (const cap of cfg.videoCapabilities) { + if (cap.contentType) { + ranAnyTests = true; + + const contentType = cap.contentType.split(';')[0]; + if (WebKitMediaKeys.isTypeSupported(this.keySystem, contentType)) { + newCfg.videoCapabilities.push(cap); + success = true; + } + } + } + } + + if (!ranAnyTests) { + // If no specific types were requested, we check all common types to find + // out if the key system is present at all. + success = WebKitMediaKeys.isTypeSupported(this.keySystem, 'video/mp4'); + } + + if (success) { + return newCfg; + } + return null; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySystemAccess.prototype. + createMediaKeys = function() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySystemAccess.createMediaKeys'); + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + const mediaKeys = new PatchedMediaKeysApple.MediaKeys(this.keySystem); + return Promise.resolve(/** @type {!MediaKeys} */ (mediaKeys)); +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySystemAccess.prototype. + getConfiguration = function() { + shaka.log.debug( + 'PatchedMediaKeysApple.MediaKeySystemAccess.getConfiguration'); + return this.configuration_; +}; + + +/** + * An implementation of HTMLMediaElement.prototype.setMediaKeys. + * Attaches a MediaKeys object to the media element. + * + * @this {!HTMLMediaElement} + * @param {MediaKeys} mediaKeys + * @return {!Promise} + */ +shaka.polyfill.PatchedMediaKeysApple.setMediaKeys = function(mediaKeys) { + shaka.log.debug('PatchedMediaKeysApple.setMediaKeys'); + goog.asserts.assert(this instanceof HTMLMediaElement, + 'bad "this" for setMediaKeys'); + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + const newMediaKeys = + /** @type {shaka.polyfill.PatchedMediaKeysApple.MediaKeys} */ ( + mediaKeys); + const oldMediaKeys = + /** @type {shaka.polyfill.PatchedMediaKeysApple.MediaKeys} */ ( + this.mediaKeys); + + if (oldMediaKeys && oldMediaKeys != newMediaKeys) { + goog.asserts.assert(oldMediaKeys instanceof PatchedMediaKeysApple.MediaKeys, + 'non-polyfill instance of oldMediaKeys'); + // Have the old MediaKeys stop listening to events on the video tag. + oldMediaKeys.setMedia(null); + } + + delete this['mediaKeys']; // in case there is an existing getter + this['mediaKeys'] = mediaKeys; // work around read-only declaration + + if (newMediaKeys) { + goog.asserts.assert(newMediaKeys instanceof PatchedMediaKeysApple.MediaKeys, + 'non-polyfill instance of newMediaKeys'); + return newMediaKeys.setMedia(this); + } + + return Promise.resolve(); +}; + + +/** + * An implementation of MediaKeys. + * + * @constructor + * @struct + * @param {string} keySystem + * @implements {MediaKeys} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeys = function(keySystem) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeys'); + + /** @private {!WebKitMediaKeys} */ + this.nativeMediaKeys_ = new WebKitMediaKeys(keySystem); + + /** @private {!shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + + /** @type {Uint8Array} */ + this.certificate = null; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeys.prototype. + createSession = function(sessionType) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeys.createSession'); + + sessionType = sessionType || 'temporary'; + // For now, only the 'temporary' type is supported. + if (sessionType != 'temporary') { + throw new TypeError('Session type ' + sessionType + + ' is unsupported on this platform.'); + } + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + return new PatchedMediaKeysApple.MediaKeySession( + this.nativeMediaKeys_, sessionType); +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeys.prototype. + setServerCertificate = function(serverCertificate) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeys.setServerCertificate'); + + this.certificate = + serverCertificate ? new Uint8Array(serverCertificate) : null; + + return Promise.resolve(true); +}; + + +/** + * @param {HTMLMediaElement} media + * @protected + * @return {!Promise} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeys.prototype. + setMedia = function(media) { + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + // Remove any old listeners. + this.eventManager_.removeAll(); + + // It is valid for media to be null; null is used to flag that event handlers + // need to be cleaned up. + if (!media) { + return Promise.resolve(); + } + + // Intercept and translate these prefixed EME events. + this.eventManager_.listen(media, 'webkitneedkey', + /** @type {shaka.util.EventManager.ListenerType} */ + (PatchedMediaKeysApple.onWebkitNeedKey_)); + + // Wrap native HTMLMediaElement.webkitSetMediaKeys with a Promise. + try { + // Some browsers require that readyState >=1 before mediaKeys can be set, so + // check this and wait for loadedmetadata if we are not in the correct state + if (media.readyState >= 1) { + media.webkitSetMediaKeys(this.nativeMediaKeys_); + } else { + this.eventManager_.listenOnce(media, 'loadedmetadata', () => { + media.webkitSetMediaKeys(this.nativeMediaKeys_); + }); + } + + return Promise.resolve(); + } catch (exception) { + return Promise.reject(exception); + } +}; + + +/** + * An implementation of MediaKeySession. + * + * @constructor + * @struct + * @param {WebKitMediaKeys} nativeMediaKeys + * @param {string} sessionType + * @implements {MediaKeySession} + * @extends {shaka.util.FakeEventTarget} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession = + function(nativeMediaKeys, sessionType) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession'); + shaka.util.FakeEventTarget.call(this); + + /** The native MediaKeySession, which will be created in generateRequest. + * @private {WebKitMediaKeySession} */ + this.nativeMediaKeySession_ = null; + + /** @private {WebKitMediaKeys} */ + this.nativeMediaKeys_ = nativeMediaKeys; + + // Promises that are resolved later + /** @private {shaka.util.PublicPromise} */ + this.generateRequestPromise_ = null; + + /** @private {shaka.util.PublicPromise} */ + this.updatePromise_ = null; + + /** @private {!shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + + /** @type {string} */ + this.sessionId = ''; + + /** @type {number} */ + this.expiration = NaN; + + /** @type {!shaka.util.PublicPromise} */ + this.closed = new shaka.util.PublicPromise(); + + /** @type {!shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap} */ + this.keyStatuses = + new shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap(); +}; +goog.inherits(shaka.polyfill.PatchedMediaKeysApple.MediaKeySession, + shaka.util.FakeEventTarget); + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + generateRequest = function(initDataType, initData) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.generateRequest'); + + this.generateRequestPromise_ = new shaka.util.PublicPromise(); + + try { + // This EME spec version requires a MIME content type as the 1st param + // to createSession, but doesn't seem to matter what the value is. + // It also only accepts Uint8Array, not ArrayBuffer, so explicitly make + // initData into a Uint8Array. + this.nativeMediaKeySession_ = this.nativeMediaKeys_.createSession( + 'video/mp4', new Uint8Array(initData)); + + // Attach session event handlers here. + this.eventManager_.listen(this.nativeMediaKeySession_, 'webkitkeymessage', + /** @type {shaka.util.EventManager.ListenerType} */ + (this.onWebkitKeyMessage_.bind(this))); + this.eventManager_.listen(this.nativeMediaKeySession_, 'webkitkeyadded', + /** @type {shaka.util.EventManager.ListenerType} */ + (this.onWebkitKeyAdded_.bind(this))); + this.eventManager_.listen(this.nativeMediaKeySession_, 'webkitkeyerror', + /** @type {shaka.util.EventManager.ListenerType} */ + (this.onWebkitKeyError_.bind(this))); + + this.updateKeyStatus_('status-pending'); + } catch (exception) { + this.generateRequestPromise_.reject(exception); + } + + return this.generateRequestPromise_; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + load = function() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.load'); + + return Promise.reject(new Error('MediaKeySession.load not yet supported')); +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + update = function(response) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.update'); + + this.updatePromise_ = new shaka.util.PublicPromise(); + + try { + // Pass through to the native session. + this.nativeMediaKeySession_.update(new Uint8Array(response)); + } catch (exception) { + this.updatePromise_.reject(exception); + } + + return this.updatePromise_; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + close = function() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.close'); + + try { + // Pass through to the native session. + this.nativeMediaKeySession_.close(); + + this.closed.resolve(); + this.eventManager_.removeAll(); + } catch (exception) { + this.closed.reject(exception); + } + + return this.closed; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + remove = function() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.remove'); + + return Promise.reject(new Error('MediaKeySession.remove is only ' + + 'applicable for persistent licenses, which are not supported on ' + + 'this platform')); +}; + + +/** + * Rebuild FairPlay init data according to Apple's docs. It's unclear why this + * is not done by the browser, or if the unprefixed version will do it for us. + * + * @param {BufferSource} initData + * @param {BufferSource} certificate + * @return {BufferSource} + */ +shaka.polyfill.PatchedMediaKeysApple.rebuildInitData_ = + function(initData, certificate) { + // TODO: Move this into DrmEngine if it is still needed with unprefixed EME. + // FairPlay init data is in two parts and must be processed a bit. + // The first part is a 4 byte little-endian int, which is the length of the + // second part. + const initDataArray = new Uint8Array(initData); + const dataview = new DataView(initDataArray.buffer); + const length = dataview.getUint32( + /* position= */ 0, /* littleEndian= */ true); + if (length + 4 != initDataArray.byteLength) { + throw new Error('Malformed init data!'); + } + + // The second part is a UTF-16 LE URI from the manifest. + const uriString = shaka.util.StringUtils.fromUTF16( + initDataArray.slice(4), /* littleEndian= */ true); + + // The domain of that URI is the content ID according to Apple's FPS sample. + const uri = new goog.Uri(uriString); + const contentId = uri.getDomain(); + + // From that, we build a new init data to use in the session. This is + // composed of several parts. First, the raw init data we already got. + // Second, a 4-byte LE length followed by the content ID in UTF-16-LE. + // Third, a 4-byte LE length followed by the certificate. + const contentIdArray = new Uint8Array( + shaka.util.StringUtils.toUTF16(contentId, /* littleEndian= */ true)); + + const rebuiltInitData = new Uint8Array( + initDataArray.byteLength + + 4 + contentIdArray.byteLength + + 4 + certificate.byteLength); + + let offset = 0; + /** @param {!Uint8Array} array */ + const append = (array) => { + rebuiltInitData.set(array, offset); + offset += array.byteLength; + }; + /** @param {!Uint8Array} array */ + const appendWithLength = (array) => { + const view = new DataView(rebuiltInitData.buffer); + const value = array.byteLength; + view.setUint32(offset, value, /* littleEndian= */ true); + offset += 4; + append(array); + }; + + append(initDataArray); + appendWithLength(contentIdArray); + appendWithLength(new Uint8Array(certificate)); + + return rebuiltInitData; +}; + + +/** + * Handler for the native media elements webkitneedkey event. + * + * @this {!HTMLMediaElement} + * @param {!MediaKeyEvent} event + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.onWebkitNeedKey_ = function(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitNeedKey_', event); + + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + const mediaKeys = + /** @type {shaka.polyfill.PatchedMediaKeysApple.MediaKeys} */( + this.mediaKeys); + goog.asserts.assert(mediaKeys instanceof PatchedMediaKeysApple.MediaKeys, + 'non-polyfill instance of newMediaKeys'); + + goog.asserts.assert(event.initData != null, 'missing init data!'); + + const certificate = mediaKeys.certificate; + goog.asserts.assert(certificate != null, 'missing certificate!'); + + // NOTE: Because "this" is a real EventTarget, the event we dispatch here must + // also be a real Event. + const event2 = new Event('encrypted'); + // TODO: validate this initDataType against the unprefixed version + event2.initDataType = 'cenc'; + event2.initData = PatchedMediaKeysApple.rebuildInitData_( + event.initData, certificate); + + this.dispatchEvent(event2); +}; + + +/** + * Handler for the native keymessage event on WebKitMediaKeySession. + * + * @param {!MediaKeyEvent} event + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + onWebkitKeyMessage_ = function(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitKeyMessage_', event); + + // We can now resolve this.generateRequestPromise, which should be non-null. + goog.asserts.assert(this.generateRequestPromise_, + 'generateRequestPromise_ should be set before now!'); + if (this.generateRequestPromise_) { + this.generateRequestPromise_.resolve(); + this.generateRequestPromise_ = null; + } + + const isNew = this.keyStatuses.getStatus() == undefined; + + const event2 = new shaka.util.FakeEvent('message', { + messageType: isNew ? 'license-request' : 'license-renewal', + message: event.message.buffer, + }); + + this.dispatchEvent(event2); +}; + + +/** + * Handler for the native keyadded event on WebKitMediaKeySession. + * + * @param {!MediaKeyEvent} event + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + onWebkitKeyAdded_ = function(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitKeyAdded_', event); + + // This shouldn't fire while we're in the middle of generateRequest, but if it + // does, we will need to change the logic to account for it. + goog.asserts.assert(!this.generateRequestPromise_, + 'Key added during generate!'); + + // We can now resolve this.updatePromise, which should be non-null. + goog.asserts.assert(this.updatePromise_, + 'updatePromise_ should be set before now!'); + if (this.updatePromise_) { + this.updateKeyStatus_('usable'); + this.updatePromise_.resolve(); + this.updatePromise_ = null; + } +}; + + +/** + * Handler for the native keyerror event on WebKitMediaKeySession. + * + * @param {!MediaKeyEvent} event + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + onWebkitKeyError_ = function(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitKeyError_', event); + + const error = new Error('EME PatchedMediaKeysApple key error'); + error.errorCode = this.nativeMediaKeySession_.error; + + if (this.generateRequestPromise_ != null) { + this.generateRequestPromise_.reject(error); + this.generateRequestPromise_ = null; + } else if (this.updatePromise_ != null) { + this.updatePromise_.reject(error); + this.updatePromise_ = null; + } else { + // Unexpected error - map native codes to standardised key statuses. + // Possible values of this.nativeMediaKeySession_.error.code: + // MEDIA_KEYERR_UNKNOWN = 1 + // MEDIA_KEYERR_CLIENT = 2 + // MEDIA_KEYERR_SERVICE = 3 + // MEDIA_KEYERR_OUTPUT = 4 + // MEDIA_KEYERR_HARDWARECHANGE = 5 + // MEDIA_KEYERR_DOMAIN = 6 + + switch (this.nativeMediaKeySession_.error.code) { + case WebKitMediaKeyError.MEDIA_KEYERR_OUTPUT: + case WebKitMediaKeyError.MEDIA_KEYERR_HARDWARECHANGE: + this.updateKeyStatus_('output-not-allowed'); + break; + default: + this.updateKeyStatus_('internal-error'); + break; + } + } +}; + + +/** + * Updates key status and dispatch a 'keystatuseschange' event. + * + * @param {string} status + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession.prototype. + updateKeyStatus_ = function(status) { + this.keyStatuses.setStatus(status); + const event = new shaka.util.FakeEvent('keystatuseschange'); + this.dispatchEvent(event); +}; + + +/** + * An implementation of MediaKeyStatusMap. + * This fakes a map with a single key ID. + * + * @constructor + * @struct + * @implements {MediaKeyStatusMap} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap = function() { + /** + * @type {number} + */ + this.size = 0; + + /** + * @private {string|undefined} + */ + this.status_ = undefined; +}; + + +/** + * @const {!ArrayBuffer} + * @private + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.KEY_ID_; + + +/** + * An internal method used by the session to set key status. + * @param {string|undefined} status + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + setStatus = function(status) { + this.size = status == undefined ? 0 : 1; + this.status_ = status; +}; + + +/** + * An internal method used by the session to get key status. + * @return {string|undefined} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + getStatus = function() { + return this.status_; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + forEach = function(fn) { + if (this.status_) { + const fakeKeyId = + shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.KEY_ID_; + fn(this.status_, fakeKeyId); + } +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + get = function(keyId) { + if (this.has(keyId)) { + return this.status_; + } + return undefined; +}; + + +/** @override */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + has = function(keyId) { + const fakeKeyId = + shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.KEY_ID_; + if (this.status_ && + shaka.util.Uint8ArrayUtils.equal( + new Uint8Array(keyId), new Uint8Array(fakeKeyId))) { + return true; + } + return false; +}; + + +/** + * @suppress {missingReturn} + * @override + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + entries = function() { + goog.asserts.assert(false, 'Not used! Provided only for the compiler.'); +}; + + +/** + * @suppress {missingReturn} + * @override + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + keys = function() { + goog.asserts.assert(false, 'Not used! Provided only for the compiler.'); +}; + + +/** + * @suppress {missingReturn} + * @override + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap.prototype. + values = function() { + goog.asserts.assert(false, 'Not used! Provided only for the compiler.'); +}; + + +shaka.polyfill.register(shaka.polyfill.PatchedMediaKeysApple.install); diff --git a/lib/util/string_utils.js b/lib/util/string_utils.js index 537f03ced1..01a9618c50 100644 --- a/lib/util/string_utils.js +++ b/lib/util/string_utils.js @@ -182,6 +182,25 @@ shaka.util.StringUtils.toUTF8 = function(str) { }; +/** + * Creates a ArrayBuffer from the given string, converting to UTF-16 encoding. + * + * @param {string} str + * @param {boolean} littleEndian + * @return {!ArrayBuffer} + * @export + */ +shaka.util.StringUtils.toUTF16 = function(str, littleEndian) { + const result = new Uint8Array(str.length * 2); + const view = new DataView(result.buffer); + for (let i = 0; i < str.length; ++i) { + const value = str.charCodeAt(i); + view.setUint16(/* position= */ i * 2, value, littleEndian); + } + return result.buffer; +}; + + /** * Creates a new string from the given array of char codes. * diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index f1444e01e8..3f9fc0d425 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -47,6 +47,7 @@ goog.require('shaka.polyfill.IndexedDB'); goog.require('shaka.polyfill.InputEvent'); goog.require('shaka.polyfill.MathRound'); goog.require('shaka.polyfill.MediaSource'); +goog.require('shaka.polyfill.PatchedMediaKeysApple'); goog.require('shaka.polyfill.PatchedMediaKeysMs'); goog.require('shaka.polyfill.PatchedMediaKeysNop'); goog.require('shaka.polyfill.PatchedMediaKeysWebkit'); diff --git a/test/util/string_utils_unit.js b/test/util/string_utils_unit.js index bcb7e93d8b..32ed0f081a 100644 --- a/test/util/string_utils_unit.js +++ b/test/util/string_utils_unit.js @@ -108,6 +108,20 @@ describe('StringUtils', function() { expect(new Uint8Array(buffer)).toEqual(new Uint8Array(arr)); }); + it('converts toUTF16-LE', function() { + const str = 'Xe\u4524\u1952'; + const arr = [0x58, 0, 0x65, 0, 0x24, 0x45, 0x52, 0x19]; + const buffer = StringUtils.toUTF16(str, /* littleEndian */ true); + expect(new Uint8Array(buffer)).toEqual(new Uint8Array(arr)); + }); + + it('converts toUTF16-BE', function() { + const str = 'Xe\u4524\u1952'; + const arr = [0, 0x58, 0, 0x65, 0x45, 0x24, 0x19, 0x52]; + const buffer = StringUtils.toUTF16(str, /* littleEndian */ false); + expect(new Uint8Array(buffer)).toEqual(new Uint8Array(arr)); + }); + it('does not cause stack overflow, #335', function() { let buffer = new Uint8Array(8e5).buffer; // Well above arg count limit. expect(StringUtils.fromUTF8(buffer).length).toBe(buffer.byteLength);