/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.polyfill.PatchedMediaKeysApple');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.DrmEngine');
goog.require('shaka.polyfill');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.MediaReadyState');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StringUtils');
/**
* @summary A polyfill to implement modern, standardized EME on top of Apple's
* prefixed EME in Safari.
*/
shaka.polyfill.PatchedMediaKeysApple = class {
/**
* Installs the polyfill if needed.
*/
static install() {
if (!window.HTMLVideoElement || !window.WebKitMediaKeys) {
// No HTML5 video or no prefixed EME.
return;
}
/* Unprefixed EME disabled. See:
https://github.com/google/shaka-player/pull/3021#issuecomment-766999811
// Only tested in Safari 14.
const safariVersion = shaka.util.Platform.safariVersion();
if (navigator.requestMediaKeySystemAccess &&
// eslint-disable-next-line no-restricted-syntax
MediaKeySystemAccess.prototype.getConfiguration &&
safariVersion && safariVersion >= 14) {
// Unprefixed EME is preferable.
return;
}
*/
shaka.log.info('Using Apple-prefixed EME');
// Alias
const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple;
// Delete mediaKeys to work around strict mode compatibility issues.
// eslint-disable-next-line no-restricted-syntax
delete HTMLMediaElement.prototype['mediaKeys'];
// Work around read-only declaration for mediaKeys by using a string.
// eslint-disable-next-line no-restricted-syntax
HTMLMediaElement.prototype['mediaKeys'] = null;
// eslint-disable-next-line no-restricted-syntax
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.<!MediaKeySystemConfiguration>} supportedConfigurations
* @return {!Promise.<!MediaKeySystemAccess>}
*/
static requestMediaKeySystemAccess(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 HTMLMediaElement.prototype.setMediaKeys.
* Attaches a MediaKeys object to the media element.
*
* @this {!HTMLMediaElement}
* @param {MediaKeys} mediaKeys
* @return {!Promise}
*/
static setMediaKeys(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();
}
/**
* Handler for the native media elements webkitneedkey event.
*
* @this {!HTMLMediaElement}
* @param {!MediaKeyEvent} event
* @suppress {constantProperty} We reassign what would be const on a real
* MediaEncryptedEvent, but in our look-alike event.
* @private
*/
static onWebkitNeedKey_(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!');
// Convert the prefixed init data to match the native 'encrypted' event.
const uint8 = shaka.util.BufferUtils.toUint8(event.initData);
const dataview = shaka.util.BufferUtils.toDataView(uint8);
// The first part is a 4 byte little-endian int, which is the length of
// the second part.
const length = dataview.getUint32(
/* position= */ 0, /* littleEndian= */ true);
if (length + 4 != uint8.byteLength) {
throw new RangeError('Malformed FairPlay init data');
}
// The remainder is a UTF-16 skd URL. Convert this to UTF-8 and pass on.
const str = shaka.util.StringUtils.fromUTF16(
uint8.subarray(4), /* littleEndian= */ true);
const initData = shaka.util.StringUtils.toUTF8(str);
// NOTE: Because "this" is a real EventTarget, the event we dispatch here
// must also be a real Event.
const event2 = new Event('encrypted');
const encryptedEvent =
/** @type {!MediaEncryptedEvent} */(/** @type {?} */(event2));
encryptedEvent.initDataType = 'skd';
encryptedEvent.initData = shaka.util.BufferUtils.toArrayBuffer(initData);
this.dispatchEvent(event2);
}
};
/**
* An implementation of MediaKeySystemAccess.
*
* @implements {MediaKeySystemAccess}
*/
shaka.polyfill.PatchedMediaKeysApple.MediaKeySystemAccess = class {
/**
* @param {string} keySystem
* @param {!Array.<!MediaKeySystemConfiguration>} supportedConfigurations
*/
constructor(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;
}
}
}
// According to the spec, this should be a DOMException, but there is not a
// public constructor for that. So we make this look-alike instead.
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
*/
checkConfig_(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 */
createMediaKeys() {
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 */
getConfiguration() {
shaka.log.debug(
'PatchedMediaKeysApple.MediaKeySystemAccess.getConfiguration');
return this.configuration_;
}
};
/**
* An implementation of MediaKeys.
*
* @implements {MediaKeys}
*/
shaka.polyfill.PatchedMediaKeysApple.MediaKeys = class {
/** @param {string} keySystem */
constructor(keySystem) {
shaka.log.debug('PatchedMediaKeysApple.MediaKeys');
/** @private {!WebKitMediaKeys} */
this.nativeMediaKeys_ = new WebKitMediaKeys(keySystem);
/** @private {!shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
}
/** @override */
createSession(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 */
setServerCertificate(serverCertificate) {
shaka.log.debug('PatchedMediaKeysApple.MediaKeys.setServerCertificate');
return Promise.resolve(false);
}
/**
* @param {HTMLMediaElement} media
* @protected
* @return {!Promise}
*/
setMedia(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
shaka.util.MediaReadyState.waitForReadyState(media,
HTMLMediaElement.HAVE_METADATA,
this.eventManager_, () => {
media.webkitSetMediaKeys(this.nativeMediaKeys_);
});
return Promise.resolve();
} catch (exception) {
return Promise.reject(exception);
}
}
};
/**
* An implementation of MediaKeySession.
*
* @implements {MediaKeySession}
*/
shaka.polyfill.PatchedMediaKeysApple.MediaKeySession =
class extends shaka.util.FakeEventTarget {
/**
* @param {WebKitMediaKeys} nativeMediaKeys
* @param {string} sessionType
*/
constructor(nativeMediaKeys, sessionType) {
shaka.log.debug('PatchedMediaKeysApple.MediaKeySession');
super();
/**
* 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();
}
/** @override */
generateRequest(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.
const session = this.nativeMediaKeys_.createSession(
'video/mp4', shaka.util.BufferUtils.toUint8(initData));
this.nativeMediaKeySession_ = session;
this.sessionId = session.sessionId || '';
// Attach session event handlers here.
this.eventManager_.listen(
this.nativeMediaKeySession_, 'webkitkeymessage',
/** @type {shaka.util.EventManager.ListenerType} */
((event) => this.onWebkitKeyMessage_(event)));
this.eventManager_.listen(session, 'webkitkeyadded',
/** @type {shaka.util.EventManager.ListenerType} */
((event) => this.onWebkitKeyAdded_(event)));
this.eventManager_.listen(session, 'webkitkeyerror',
/** @type {shaka.util.EventManager.ListenerType} */
((event) => this.onWebkitKeyError_(event)));
this.updateKeyStatus_('status-pending');
} catch (exception) {
this.generateRequestPromise_.reject(exception);
}
return this.generateRequestPromise_;
}
/** @override */
load() {
shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.load');
return Promise.reject(new Error('MediaKeySession.load not yet supported'));
}
/** @override */
update(response) {
shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.update');
this.updatePromise_ = new shaka.util.PublicPromise();
try {
// Pass through to the native session.
this.nativeMediaKeySession_.update(
shaka.util.BufferUtils.toUint8(response));
} catch (exception) {
this.updatePromise_.reject(exception);
}
return this.updatePromise_;
}
/** @override */
close() {
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 */
remove() {
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'));
}
/**
* Handler for the native keymessage event on WebKitMediaKeySession.
*
* @param {!MediaKeyEvent} event
* @private
*/
onWebkitKeyMessage_(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: shaka.util.BufferUtils.toArrayBuffer(event.message),
});
this.dispatchEvent(event2);
}
/**
* Handler for the native keyadded event on WebKitMediaKeySession.
*
* @param {!MediaKeyEvent} event
* @private
*/
onWebkitKeyAdded_(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
*/
onWebkitKeyError_(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
*/
updateKeyStatus_(status) {
this.keyStatuses.setStatus(status);
const event = new shaka.util.FakeEvent('keystatuseschange');
this.dispatchEvent(event);
}
};
/**
* @summary An implementation of MediaKeyStatusMap.
* This fakes a map with a single key ID.
*
* @todo Consolidate the MediaKeyStatusMap types in these polyfills.
* @implements {MediaKeyStatusMap}
*/
shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap = class {
/** */
constructor() {
/**
* @type {number}
*/
this.size = 0;
/**
* @private {string|undefined}
*/
this.status_ = undefined;
}
/**
* An internal method used by the session to set key status.
* @param {string|undefined} status
*/
setStatus(status) {
this.size = status == undefined ? 0 : 1;
this.status_ = status;
}
/**
* An internal method used by the session to get key status.
* @return {string|undefined}
*/
getStatus() {
return this.status_;
}
/** @override */
forEach(fn) {
if (this.status_) {
fn(this.status_, shaka.media.DrmEngine.DUMMY_KEY_ID.value());
}
}
/** @override */
get(keyId) {
if (this.has(keyId)) {
return this.status_;
}
return undefined;
}
/** @override */
has(keyId) {
const fakeKeyId = shaka.media.DrmEngine.DUMMY_KEY_ID.value();
if (this.status_ && shaka.util.BufferUtils.equal(keyId, fakeKeyId)) {
return true;
}
return false;
}
/**
* @suppress {missingReturn}
* @override
*/
entries() {
goog.asserts.assert(false, 'Not used! Provided only for the compiler.');
}
/**
* @suppress {missingReturn}
* @override
*/
keys() {
goog.asserts.assert(false, 'Not used! Provided only for the compiler.');
}
/**
* @suppress {missingReturn}
* @override
*/
values() {
goog.asserts.assert(false, 'Not used! Provided only for the compiler.');
}
};
shaka.polyfill.register(shaka.polyfill.PatchedMediaKeysApple.install);