diff --git a/.gitignore b/.gitignore index deb20797..1501c7cd 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ packages/*/src/polyfills .turbo examples/astro/.vercel/ + +.yalc +yalc.lock \ No newline at end of file diff --git a/packages/mux-player/src/base.ts b/packages/mux-player/src/base.ts index d7b62dac..290796bd 100644 --- a/packages/mux-player/src/base.ts +++ b/packages/mux-player/src/base.ts @@ -762,6 +762,12 @@ class MuxPlayerElement extends VideoApiElement implements IMuxPlayerElement { } break; } + case MuxVideoAttributes.USE_WEBKIT_FAIRPLAY: { + if (newValue == null || newValue !== oldValue) { + this.useWebkitFairplay = newValue != null; + } + break; + } } const shouldClearState = [ @@ -1903,6 +1909,18 @@ class MuxPlayerElement extends VideoApiElement implements IMuxPlayerElement { } this.media.capRenditionToPlayerSize = val; } + + get useWebkitFairplay(): boolean { + return this.media?.useWebkitFairplay ?? false; + } + + set useWebkitFairplay(val: boolean) { + if (val === this.useWebkitFairplay) return; + + if (this.media) { + this.media.useWebkitFairplay = val; + } + } } export function getVideoAttribute(el: MuxPlayerElement, name: string) { diff --git a/packages/mux-video/src/base.ts b/packages/mux-video/src/base.ts index 1f149a43..36f0b6c0 100644 --- a/packages/mux-video/src/base.ts +++ b/packages/mux-video/src/base.ts @@ -83,6 +83,7 @@ export const Attributes = { TYPE: 'type', LOGO: 'logo', CAP_RENDITION_TO_PLAYER_SIZE: 'cap-rendition-to-player-size', + USE_WEBKIT_FAIRPLAY: 'use-webkit-fairplay', } as const; const AttributeNameValues = Object.values(Attributes); @@ -788,6 +789,37 @@ export class MuxVideoBaseElement extends CustomVideoElement implements IMuxVideo } } + get useWebkitFairplay(): boolean { + return this.hasAttribute(Attributes.USE_WEBKIT_FAIRPLAY); + } + + set useWebkitFairplay(val: boolean) { + // dont' cause an infinite loop + if (val === this.useWebkitFairplay) return; + + if (val) { + this.setAttribute(Attributes.USE_WEBKIT_FAIRPLAY, ''); + } else { + this.removeAttribute(Attributes.USE_WEBKIT_FAIRPLAY); + } + } + + drmSetupFallback = async () => { + this.useWebkitFairplay = true; + + const wasPlaying = !this.paused; + this.pause(); + const currentTime = this.currentTime; + + this.unload(); + await this.#requestLoad(); + + this.currentTime = currentTime; + if (wasPlaying) { + await this.play(); + } + }; + async #requestLoad() { if (this.#loadRequested) return; await (this.#loadRequested = Promise.resolve()); diff --git a/packages/playback-core/package.json b/packages/playback-core/package.json index 83299dc6..af9ad017 100644 --- a/packages/playback-core/package.json +++ b/packages/playback-core/package.json @@ -39,7 +39,7 @@ "scripts": { "clean": "shx rm -rf dist/", "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src/ --ext .js,.jsx,.ts,.tsx", - "test": "web-test-runner **/*.test.js --port 8004 --coverage --config test/web-test-runner.config.mjs --root-dir ../..", + "test": "web-test-runner **/{*,drm/*}.test.js --port 8004 --coverage --config test/web-test-runner.config.mjs --root-dir ../..", "posttest": "replace 'SF:src/' 'SF:packages/playback-core/src/' coverage/lcov.info --silent", "dev:cjs": "npm run build:cjs -- --watch=forever", "dev:esm": "npm run build:esm -- --watch=forever", diff --git a/packages/playback-core/src/eme-fariplay.ts b/packages/playback-core/src/eme-fariplay.ts new file mode 100644 index 00000000..1955db2a --- /dev/null +++ b/packages/playback-core/src/eme-fariplay.ts @@ -0,0 +1,257 @@ +import { MediaError, MuxErrorCategory, MuxErrorCode } from './errors'; +import { i18n } from './util'; +interface EMEFairplayConfiguration { + mediaEl: HTMLMediaElement; + getAppCertificate: () => Promise; + getLicenseKey: (spc: ArrayBuffer) => Promise; + saveAndDispatchError: (mediaEl: HTMLMediaElement, error: MediaError) => void; + fallback?: () => void; + drmTypeCb: () => void; +} + +export const setupEmeFairplayDRM = ({ + mediaEl, + getAppCertificate, + getLicenseKey, + fallback, + saveAndDispatchError, + drmTypeCb, +}: EMEFairplayConfiguration): (() => Promise) => { + const context = new FairPlayContext(mediaEl, getAppCertificate, getLicenseKey, saveAndDispatchError, drmTypeCb); + + const encryptedHandler = async (event: MediaEncryptedEvent): Promise => { + try { + const initDataType = event.initDataType; + if (initDataType !== 'skd') { + console.error(`Received unexpected initialization data type "${initDataType}"`); + return; + } + + const initData = event.initData; + if (initData == null) { + console.error(`Could not start encrypted playback due to missing initData in ${event.type} event`); + return; + } + + await context.setup(initDataType); + const session = context.createSession(); + await session?.generateRequest(initDataType, initData).catch((e: Error) => { + if (e.name === 'NotSupportedError') { + console.warn('Failed to generate license request', e); + context.teardown(); + fallback?.(); + } else { + console.error('Failed to generate license request', e); + const message = i18n( + 'Failed to generate a DRM license request. This may be an issue with the player or your protected content.' + ); + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_GENERATE_REQUEST_FAILED; + return Promise.reject(mediaError); + } + }); + } catch (error) { + saveAndDispatchError(mediaEl, error as MediaError); + } + }; + + mediaEl.addEventListener('encrypted', encryptedHandler); + return async () => { + await context.teardown(); + mediaEl.removeEventListener('encrypted', encryptedHandler); + }; +}; +class FairPlayContext { + mediaEl: HTMLMediaElement; + getAppCertificate: EMEFairplayConfiguration['getAppCertificate']; + getLicenseKey: EMEFairplayConfiguration['getLicenseKey']; + saveAndDispatchError: EMEFairplayConfiguration['saveAndDispatchError']; + drmTypeCb: EMEFairplayConfiguration['drmTypeCb']; + + session: MediaKeySession | null = null; + certificate: BufferSource | null = null; + teardownSession: (() => void) | null = null; + + constructor( + mediaEl: HTMLMediaElement, + getAppCertificate: EMEFairplayConfiguration['getAppCertificate'], + getLicenseKey: EMEFairplayConfiguration['getLicenseKey'], + saveAndDispatchError: EMEFairplayConfiguration['saveAndDispatchError'], + drmTypeCb: EMEFairplayConfiguration['drmTypeCb'] + ) { + this.mediaEl = mediaEl; + this.getAppCertificate = getAppCertificate; + this.getLicenseKey = getLicenseKey; + this.saveAndDispatchError = saveAndDispatchError; + this.drmTypeCb = drmTypeCb; + } + + async setup(initDataType: MediaEncryptedEvent['initDataType']) { + if (this.certificate === null) { + this.certificate = await this.getAppCertificate(); + } + + if (!this.mediaEl.mediaKeys) { + const access = await this.initMediaAccess(initDataType); + const mediaKeys = await access.createMediaKeys(); + try { + await mediaKeys.setServerCertificate(this.certificate); + } catch { + const message = i18n( + 'Your server certificate failed when attempting to set it. This may be an issue with a no longer valid certificate.' + ); + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_SERVER_CERT_FAILED; + throw mediaError; + } + + await this.mediaEl.setMediaKeys(mediaKeys); + } + } + + async teardown() { + if (this.mediaEl.mediaKeys) { + await this.mediaEl.setMediaKeys(null).catch(() => {}); + } + if (this.teardownSession !== null) { + this.teardownSession(); + } + this.teardownSession = null; + this.certificate = null; + this.session = null; + } + + // We keep a reference to the session so we don't create many to different events + setSession = (newValue: MediaKeySession, newTeardown: () => void) => { + if (this.session && this.session !== newValue) { + this.teardownSession?.(); + } + this.session = newValue; + this.teardownSession = newTeardown; + }; + + /** Creates a session and sets up it's teardown function */ + createSession() { + if (!this.mediaEl.mediaKeys) { + // Should never happen + throw new Error('Unexpected error creating session. No Media Keys'); + } + const newSession = this.mediaEl.mediaKeys.createSession(); + const teardownSession = this.setupMediaKeySession(this.mediaEl, newSession); + this.setSession(newSession, teardownSession); + return newSession; + } + + initMediaAccess = async (initDataType: string): Promise => { + try { + const access = await navigator.requestMediaKeySystemAccess('com.apple.fps', [ + { + initDataTypes: [initDataType], + videoCapabilities: [{ contentType: 'application/vnd.apple.mpegurl', robustness: '' }], + distinctiveIdentifier: 'not-allowed', + persistentState: 'not-allowed', + sessionTypes: ['temporary'], + }, + ]); + this.drmTypeCb(); + return access; + } catch { + const message = i18n( + 'Cannot play DRM-protected content with current security configuration on this browser. Try playing in another browser.' + ); + // Should we flag this as a business exception? + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM; + + throw mediaError; + } + }; + + /** Adds event listeners to the given session, returning a teardown function for that specified session */ + setupMediaKeySession = (mediaEl: HTMLMediaElement, session: MediaKeySession) => { + const onMessageHandler = (ev: MediaKeyMessageEvent) => this.#onMessage(ev); + const onKeyStatusChangeHandler = (ev: MediaKeySessionEventMap['keystatuseschange']) => this.#onKeyStatusChange(ev); + + const teardownSession = () => { + if (session) { + session.removeEventListener('keystatuseschange', onKeyStatusChangeHandler); + session.removeEventListener('message', onMessageHandler); + // This call may throw an invalid state error, but it's safe to ignore + session.close().catch(() => {}); + } + mediaEl.removeEventListener('webkitcurrentplaybacktargetiswirelesschanged', teardownSession); + mediaEl.removeEventListener('teardown', teardownSession); + + this.session = null; + this.teardownSession = null; + }; + session.addEventListener('keystatuseschange', onKeyStatusChangeHandler); + session.addEventListener('message', onMessageHandler); + if ('webkitCurrentPlaybackTargetIsWireless' in mediaEl) { + // @ts-ignore + mediaEl.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', teardownSession, { once: true }); + } + mediaEl.addEventListener('teardown', teardownSession, { once: true }); + return teardownSession; + }; + + #onKeyStatusChange = (event: MediaKeySessionEventMap['keystatuseschange']): void => { + const updateMediaKeyStatus = (mediaKeyStatus: string): void => { + let mediaError; + if (mediaKeyStatus === 'internal-error') { + const message = i18n( + 'The DRM Content Decryption Module system had an internal failure. Try reloading the page, upading your browser, or playing in another browser.' + ); + mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR; + } else if (mediaKeyStatus === 'output-restricted' || mediaKeyStatus === 'output-downscaled') { + const message = i18n( + 'DRM playback is being attempted in an environment that is not sufficiently secure. User may see black screen.' + ); + // NOTE: When encountered, this is a non-fatal error (though it's certainly interruptive of standard playback experience). (CJP) + mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, false); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_OUTPUT_RESTRICTED; + } + + if (mediaError) { + this.saveAndDispatchError(this.mediaEl, mediaError); + } + }; + + const session = event.target as MediaKeySession; + if (session) { + session.keyStatuses.forEach((keyStatus: string) => updateMediaKeyStatus(keyStatus)); + } + }; + + #onMessage = async (event: MediaKeyMessageEvent): Promise => { + const session = event.target as MediaKeySession; + try { + const spc = event.message; + const ckc = await this.getLicenseKey(spc); + + try { + // This is the same call whether we are local or AirPlay. + // Safari will forward CKC to Apple TV automatically. + await session.update(ckc); + } catch { + const message = i18n( + 'Failed to update DRM license. This may be an issue with the player or your protected content.' + ); + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_LICENSE_FAILED; + + throw mediaError; + } + } catch (errOrResp) { + console.error('Error on FairPlay session message', errOrResp); + this.saveAndDispatchError(this.mediaEl, errOrResp as MediaError); + } + }; +} diff --git a/packages/playback-core/src/index.ts b/packages/playback-core/src/index.ts index 3499e912..884a55b1 100644 --- a/packages/playback-core/src/index.ts +++ b/packages/playback-core/src/index.ts @@ -35,7 +35,9 @@ import { import { StreamTypes, PlaybackTypes, ExtensionMimeTypeMap, CmcdTypes, HlsPlaylistTypes, MediaTypes } from './types'; import { getErrorFromResponse, MuxJWTAud } from './request-errors'; import MinCapLevelController from './min-cap-level-controller'; -// import { MediaKeySessionContext } from 'hls.js'; +import { setupWebkitNativeFairplayDRM } from './webkit-fairplay'; +import { setupEmeFairplayDRM } from './eme-fariplay'; + export { mux, Hls, @@ -877,190 +879,74 @@ export const getLicenseKey = async (message: ArrayBuffer, licenseServerUrl: stri }; export const setupNativeFairplayDRM = ( - props: Partial>, + props: Partial< + Pick< + MuxMediaPropsInternal, + 'useWebkitFairplay' | 'drmSetupFallback' | 'playbackToken' | 'customDomain' | 'drmTypeCb' + > + >, mediaEl: HTMLMediaElement ) => { - const setupMediaKeys = async (initDataType: string) => { - const access = await navigator - .requestMediaKeySystemAccess('com.apple.fps', [ - { - initDataTypes: [initDataType], - videoCapabilities: [{ contentType: 'application/vnd.apple.mpegurl', robustness: '' }], - distinctiveIdentifier: 'not-allowed', - persistentState: 'not-allowed', - sessionTypes: ['temporary'], - }, - ]) - .then((value) => { - props.drmTypeCb?.(DRMType.FAIRPLAY); - return value; - }) - .catch(() => { - const message = i18n( - 'Cannot play DRM-protected content with current security configuration on this browser. Try playing in another browser.' - ); - // Should we flag this as a business exception? - const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); - mediaError.errorCategory = MuxErrorCategory.DRM; - mediaError.muxCode = MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM; - saveAndDispatchError(mediaEl, mediaError); - }); - - if (!access) return; - - const keys = await access.createMediaKeys(); - - try { - const fairPlayAppCert = await getAppCertificate(toAppCertURL(props, 'fairplay')).catch((errOrResp) => { - if (errOrResp instanceof Response) { - const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props); - console.error('mediaError', mediaError?.message, mediaError?.context); - if (mediaError) { - return Promise.reject(mediaError); - } - // NOTE: This should never happen. Adding for exhaustiveness (CJP). - return Promise.reject(new Error('Unexpected error in app cert request')); - } - return Promise.reject(errOrResp); - }); - await keys.setServerCertificate(fairPlayAppCert).catch(() => { - const message = i18n( - 'Your server certificate failed when attempting to set it. This may be an issue with a no longer valid certificate.' - ); - const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); - mediaError.errorCategory = MuxErrorCategory.DRM; - mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_SERVER_CERT_FAILED; - return Promise.reject(mediaError); - }); - // @ts-ignore - } catch (error: Error | MediaError) { - saveAndDispatchError(mediaEl, error); - return; - } - await mediaEl.setMediaKeys(keys); - }; - - const updateMediaKeyStatus = (mediaKeyStatus: MediaKeyStatus) => { - let mediaError; - if (mediaKeyStatus === 'internal-error') { - const message = i18n( - 'The DRM Content Decryption Module system had an internal failure. Try reloading the page, upading your browser, or playing in another browser.' - ); - mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); - mediaError.errorCategory = MuxErrorCategory.DRM; - mediaError.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR; - } else if (mediaKeyStatus === 'output-restricted' || mediaKeyStatus === 'output-downscaled') { - const message = i18n( - 'DRM playback is being attempted in an environment that is not sufficiently secure. User may see black screen.' - ); - // NOTE: When encountered, this is a non-fatal error (though it's certainly interruptive of standard playback experience). (CJP) - mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, false); - mediaError.errorCategory = MuxErrorCategory.DRM; - mediaError.muxCode = MuxErrorCode.ENCRYPTED_OUTPUT_RESTRICTED; - } - - if (mediaError) { - saveAndDispatchError(mediaEl, mediaError); - } - }; - - const setupMediaKeySession = async (initDataType: string, initData: ArrayBuffer) => { - const session = (mediaEl.mediaKeys as MediaKeys).createSession(); - const onKeyStatusChange = () => { - // recheck key statuses - // NOTE: As an improvement, we could also add checks for a status of 'expired' and - // attempt to renew the license here (CJP) - session.keyStatuses.forEach((keyStatus) => updateMediaKeyStatus(keyStatus)); - }; - - const onMessage = async (event: MediaKeyMessageEvent) => { - const spc = event.message; - try { - const ckc = await getLicenseKey(spc, toLicenseKeyURL(props, 'fairplay')); - - try { - // This is the same call whether we are local or AirPlay. - // Safari will forward CKC to Apple TV automatically. - await session.update(ckc); - } catch { - const message = i18n( - 'Failed to update DRM license. This may be an issue with the player or your protected content.' - ); - const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); - mediaError.errorCategory = MuxErrorCategory.DRM; - mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_LICENSE_FAILED; - - saveAndDispatchError(mediaEl, mediaError); + const getAppCertificateHandler = () => + getAppCertificate(toAppCertURL(props, 'fairplay')).catch((errOrResp) => { + if (errOrResp instanceof Response) { + const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props); + console.error('mediaError', mediaError?.message, mediaError?.context); + if (mediaError) { + return Promise.reject(mediaError); } - } catch (errOrResp) { - if (errOrResp instanceof Response) { - const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props); - console.error('mediaError', mediaError?.message, mediaError?.context); + // NOTE: This should never happen. Adding for exhaustiveness (CJP). + return Promise.reject(new Error('Unexpected error in app cert request')); + } + return Promise.reject(errOrResp); + }); - if (mediaError) { - saveAndDispatchError(mediaEl, mediaError); - return; - } + const getLicenseKeyHandler = (message: ArrayBuffer) => + getLicenseKey(message, toLicenseKeyURL(props, 'fairplay')).catch((errOrResp) => { + if (errOrResp instanceof Response) { + const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props); + console.error('mediaError', mediaError?.message, mediaError?.context); - console.error('Unexpected error in license key request', errOrResp); - return; + if (mediaError) { + return Promise.reject(mediaError); } - - console.error(errOrResp); + // NOTE: This should never happen. Adding for exhaustiveness (CJP). + return Promise.reject(new Error('Unexpected error in license key request')); } - }; - - session.addEventListener('keystatuseschange', onKeyStatusChange); - session.addEventListener('message', onMessage); - mediaEl.addEventListener( - 'teardown', - () => { - session.removeEventListener('keystatuseschange', onKeyStatusChange); - session.removeEventListener('message', onMessage); - session.close(); - }, - { once: true } - ); - - await session.generateRequest(initDataType, initData).catch((e) => { - console.error('Failed to generate license request', e); - const message = i18n( - 'Failed to generate a DRM license request. This may be an issue with the player or your protected content.' - ); - const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); - mediaError.errorCategory = MuxErrorCategory.DRM; - mediaError.muxCode = MuxErrorCode.ENCRYPTED_GENERATE_REQUEST_FAILED; - return Promise.reject(mediaError); + return Promise.reject(errOrResp); }); - }; - - const onFpEncrypted = async (event: MediaEncryptedEvent) => { - try { - const initDataType = event.initDataType; - if (initDataType !== 'skd') { - console.error(`Received unexpected initialization data type "${initDataType}"`); - return; - } - if (!mediaEl.mediaKeys) { - await setupMediaKeys(initDataType); - } + const commonConfig = { + mediaEl: mediaEl, + getAppCertificate: getAppCertificateHandler, + getLicenseKey: getLicenseKeyHandler, + saveAndDispatchError, + drmTypeCb: () => { + props.drmTypeCb?.(DRMType.FAIRPLAY); + }, + }; - const initData = event.initData; - if (initData == null) { - console.error(`Could not start encrypted playback due to missing initData in ${event.type} event`); - return; - } + if (props.useWebkitFairplay) { + const teardownWebkitFPS = setupWebkitNativeFairplayDRM(commonConfig); + // @ts-ignore + mediaEl.addEventListener('teardown', teardownWebkitFPS, { once: true }); + } else { + let fallback = undefined; - await setupMediaKeySession(initDataType, initData); - // @ts-ignore - } catch (error: Error | MediaError) { - saveAndDispatchError(mediaEl, error); - return; + if (props.drmSetupFallback) { + const propsFallback = props.drmSetupFallback; + fallback = async () => { + await teardownEmeFPS(); + await propsFallback(); + }; } - }; - - addEventListenerWithTeardown(mediaEl, 'encrypted', onFpEncrypted); + const teardownEmeFPS = setupEmeFairplayDRM({ + ...commonConfig, + fallback, + }); + // @ts-ignore + mediaEl.addEventListener('teardown', teardownEmeFPS, { once: true }); + } }; export const toLicenseKeyURL = ( @@ -1218,6 +1104,8 @@ export const loadMedia = ( | 'customDomain' | 'disablePseudoEnded' | 'debug' + | 'useWebkitFairplay' + | 'drmSetupFallback' > >, mediaEl: HTMLMediaElement, diff --git a/packages/playback-core/src/types.ts b/packages/playback-core/src/types.ts index fe8cdb2d..23731ee7 100644 --- a/packages/playback-core/src/types.ts +++ b/packages/playback-core/src/types.ts @@ -207,6 +207,19 @@ export type MuxMediaPropTypes = { tokens: Tokens; type: MediaTypes; extraSourceParams: Record; + /** + * Set to true to set up Native FairPlay DRM using webkit prefixed API functions + * and will use com.apple.fps.1_0 key system. + * + * Set to false to set up Native FairPlay DRM using EME API and com.apple.fps key system + */ + useWebkitFairplay: boolean; + /** + * Used to address a FPS specific bug present when setup is done using EME. + * This fallback will be called if session.generateRequest call fails. + * Can be set to undefined to prevent the fallback from being called. + */ + drmSetupFallback?: () => Promise; }; export type HTMLMediaElementProps = Partial>; diff --git a/packages/playback-core/src/webkit-fairplay.d.ts b/packages/playback-core/src/webkit-fairplay.d.ts new file mode 100644 index 00000000..67140540 --- /dev/null +++ b/packages/playback-core/src/webkit-fairplay.d.ts @@ -0,0 +1,55 @@ +declare global { + interface Window { + WebKitMediaKeys?: { + new (keySystem: string): WebKitMediaKeys; + isTypeSupported(keySystem: string, mimeType?: string): boolean; + }; + } + + class WebKitMediaKeys { + constructor(keySystem: string); + + createSession(mimeType: string, initData: BufferSource): WebKitMediaKeySession; + setServerCertificate(cert: ArrayBuffer): boolean; + } + + /** + * From https://developer.apple.com/documentation/webkitjs/webkitmediakeyerror + * - MEDIA_KEYERR_CLIENT + * - MEDIA_KEYERR_DOMAIN + * - MEDIA_KEYERR_HARDWARECHANGE + * - MEDIA_KEYERR_OUTPUT + * - MEDIA_KEYERR_SERVICE + * - MEDIA_KEYERR_UNKNOWN + */ + interface WebKitMediaKeysError { + code: number; + systemCode: number; + } + + interface WebKitMediaKeySession extends EventTarget { + error: WebKitMediaKeysError | null; + update(response: BufferSource): Promise; + close(): Promise; + } + + interface WebkitHTMLMediaElement extends HTMLMediaElement { + webkitKeys: WebKitMediaKeys; + webkitSetMediaKeys(webkitKeys: WebKitMediaKeys | null): void; + } + + interface WebkitNeedKeyEvent extends MediaEncryptedEvent { + target: WebkitHTMLMediaElement; + } + + interface WebkiKeyMessageEvent extends MediaKeyMessageEvent { + target: WebKitMediaKeySession; + } + + interface WebkitKeyErrorEvent extends MediaKeySessionEventMap { + target: WebKitMediaKeySession; + } +} + +// Needs this to be a module +export {}; diff --git a/packages/playback-core/src/webkit-fairplay.ts b/packages/playback-core/src/webkit-fairplay.ts new file mode 100644 index 00000000..1052035c --- /dev/null +++ b/packages/playback-core/src/webkit-fairplay.ts @@ -0,0 +1,271 @@ +/// + +import { MediaError, MuxErrorCategory, MuxErrorCode } from './errors'; +import { i18n } from './util'; + +interface WebkitNativeFairplayConfig { + mediaEl: HTMLMediaElement; + getAppCertificate: () => Promise; + getLicenseKey: (spc: ArrayBuffer) => Promise; + saveAndDispatchError: (mediaEl: HTMLMediaElement, error: MediaError) => void; + drmTypeCb: () => void; +} + +const LEGACY_KEY_SYSTEM = 'com.apple.fps.1_0'; + +export const setupWebkitNativeFairplayDRM = async ({ + mediaEl, + getAppCertificate, + getLicenseKey, + saveAndDispatchError, + drmTypeCb, +}: WebkitNativeFairplayConfig) => { + if (!window.WebKitMediaKeys || !('onwebkitneedkey' in mediaEl)) { + console.error('No WebKitMediaKeys. FairPlay may not be supported'); + + const message = i18n( + 'Cannot play DRM-protected content with current security configuration on this browser. Try playing in another browser.' + ); + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR; + + return saveAndDispatchError(mediaEl, mediaError); + } + + const wkMediaEl: WebkitHTMLMediaElement = mediaEl as unknown as WebkitHTMLMediaElement; + + if (!wkMediaEl.webkitKeys) { + setupWebkitKey(wkMediaEl); + drmTypeCb(); + } + // @ts-ignore + const context = new WebkitFairPlayContext(mediaEl, getAppCertificate, getLicenseKey, saveAndDispatchError, drmTypeCb); + + const webkitneedkeyHandler = async (ev: WebkitNeedKeyEvent) => { + try { + await context.setup(); + const certificate = context.certificate; + + if (ev.initData === null || certificate == null) return; + const initData = getInitData(ev.initData, certificate); + + context.createSession(wkMediaEl, initData); + } catch (e) { + console.error('Could not start encrypted playback due to exception', e); + saveAndDispatchError(wkMediaEl, e as MediaError); + } + }; + // @ts-ignore + mediaEl.addEventListener('webkitneedkey', webkitneedkeyHandler); + + // Teardown function + return () => { + context.teardown(); + // @ts-ignore + mediaEl.removeEventListener('webkitneedkey', webkitneedkeyHandler); + }; +}; + +/** + * Adds a webkit Media key using {@link LEGACY_KEY_SYSTEM}. + * Throws a MediaError if the operation is not supported. + */ +const setupWebkitKey = (mediaEl: WebkitHTMLMediaElement) => { + try { + const mediaKeys = new WebKitMediaKeys(LEGACY_KEY_SYSTEM); + mediaEl.webkitSetMediaKeys(mediaKeys); + } catch { + const message = + 'Cannot play DRM-protected content with current security configuration on this browser. Try playing in another browser.'; + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM; + + throw mediaError; + } +}; + +/** + * Expects initData buffer to come in the following format: + * [4 bytes length][init data words] where the words consist of 16 bit LE words, and length is number of bytes (i.e. 2 * number of words) + * + * @param initDataBuffer as obtained from the native event. + * @param getAppCertificate a function that returns the needed certificate in .dar format as an ArrayBuffer of bytes. + * @returns Uint8Array of initData in the format: + * [4 bytes init data length][init data bytes] + * [4 bytes content Id length][content id bytes] + * [4 bytes certificate length][certificate bytes] + */ +const getInitData = (initDataBuffer: ArrayBuffer, certificateBuffer: ArrayBuffer) => { + const contentIdBuffer = stringToUtf16LE(getContentId(initDataBuffer)); + + const initData: Uint8Array = new Uint8Array(initDataBuffer); + const contentId: Uint8Array = new Uint8Array(contentIdBuffer); + const certificate: Uint8Array = new Uint8Array(certificateBuffer); + + const newLength = initData.byteLength + 4 + certificate.byteLength + 4 + contentId.byteLength; + const rebuiltInitData = new Uint8Array(newLength); + let offset = 0; + const append = (array: Uint8Array) => { + rebuiltInitData.set(array, offset); + offset += array.byteLength; + }; + const appendWithLength = (array: Uint8Array) => { + const view = new DataView(rebuiltInitData.buffer); + const value = array.byteLength; + view.setUint32(offset, value, /* littleEndian= */ true); + offset += 4; + append(array); + }; + + append(initData); + appendWithLength(contentId); + appendWithLength(certificate); + + return rebuiltInitData; +}; + +const getContentId = (initData: ArrayBuffer) => { + const sdkUrl = new TextDecoder('utf-16le').decode(initData); + return sdkUrl.replace('skd://', '').slice(1); // First char is length +}; + +function stringToUtf16LE(str: string) { + const buffer = new ArrayBuffer(str.length * 2); + const view = new DataView(buffer); + + for (let i = 0; i < str.length; i++) { + view.setUint16(i * 2, str.charCodeAt(i), true); // little-endian + } + + return buffer; +} + +class WebkitFairPlayContext { + mediaEl: WebkitHTMLMediaElement; + getAppCertificate: WebkitNativeFairplayConfig['getAppCertificate']; + getLicenseKey: WebkitNativeFairplayConfig['getLicenseKey']; + saveAndDispatchError: WebkitNativeFairplayConfig['saveAndDispatchError']; + drmTypeCb: WebkitNativeFairplayConfig['drmTypeCb']; + + session: WebKitMediaKeySession | null = null; + certificate: ArrayBuffer | null = null; + teardownSession: (() => void) | null = null; + + constructor( + mediaEl: WebkitHTMLMediaElement, + getAppCertificate: WebkitNativeFairplayConfig['getAppCertificate'], + getLicenseKey: WebkitNativeFairplayConfig['getLicenseKey'], + saveAndDispatchError: WebkitNativeFairplayConfig['saveAndDispatchError'], + drmTypeCb: WebkitNativeFairplayConfig['drmTypeCb'] + ) { + this.mediaEl = mediaEl; + this.getAppCertificate = getAppCertificate; + this.getLicenseKey = getLicenseKey; + this.saveAndDispatchError = saveAndDispatchError; + this.drmTypeCb = drmTypeCb; + } + + async setup() { + if (this.certificate === null) { + this.certificate = await this.getAppCertificate(); + } + } + + teardown() { + if (this.teardownSession !== null) { + this.teardownSession(); + } + if (this.mediaEl.webkitKeys) { + try { + this.mediaEl.webkitSetMediaKeys(null); + } catch (e) { + console.warn('There was an error tearing down WebkitKeys', e); + } + } + this.teardownSession = null; + this.certificate = null; + this.session = null; + } + + // We keep a reference to the session so we don't create many to different events + setSession = (newValue: WebKitMediaKeySession, newTeardown: () => void) => { + if (this.session && this.session !== newValue) { + this.teardownSession?.(); + } + this.session = newValue; + this.teardownSession = newTeardown; + }; + + createSession(mediaEl: WebkitHTMLMediaElement, initData: BufferSource) { + if (!this.mediaEl.webkitKeys) { + // Should never happen + throw new Error('Unexpected error creating session. No Media Keys'); + } + const newSession = mediaEl.webkitKeys.createSession('application/vnd.apple.mpegurl', initData); + const teardownSession = this.setupWebkitKeySession(mediaEl, newSession); + this.setSession(newSession, teardownSession); + } + + /** + * Adds necessary event handlers to a new session and returns the function to tear them down + */ + setupWebkitKeySession = (mediaEl: WebkitHTMLMediaElement, session: WebKitMediaKeySession) => { + const onwebkitkeymessageHandler = async (event: WebkiKeyMessageEvent) => { + try { + const spc = event.message; + const ckc = await this.getLicenseKey(spc); + event.target.update(ckc); + } catch (errOrResp) { + console.error('Error on FairPlay session message', errOrResp); + this.saveAndDispatchError(this.mediaEl, errOrResp as MediaError); + } + }; + + const onwebkitkeyerrorHandler = (event: WebkitKeyErrorEvent) => { + const error = event.target.error; + if (!error) return; + console.error(`Internal Webkit Key Session Error - sysCode: ${error.systemCode} code: ${error.code}`); + + const message = i18n( + 'The DRM Content Decryption Module system had an internal failure. Try reloading the page, upading your browser, or playing in another browser.' + ); + const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true); + mediaError.errorCategory = MuxErrorCategory.DRM; + mediaError.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR; + + this.saveAndDispatchError(mediaEl, mediaError); + }; + + const teardownSession = () => { + if (!session) return; + if ('onwebkitkeymessage' in session) { + session.onwebkitkeymessage = null; + } + if ('onwebkitkeyerror' in session) { + session.onwebkitkeyerror = null; + } + try { + session.close(); + } catch {} + if ('webkitCurrentPlaybackTargetIsWireless' in mediaEl) { + mediaEl.removeEventListener('webkitcurrentplaybacktargetiswirelesschanged', teardownSession); + } + + this.teardownSession = null; + this.session = null; + }; + + if ('webkitCurrentPlaybackTargetIsWireless' in mediaEl) { + mediaEl.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', teardownSession, { once: true }); + } + if ('onwebkitkeymessage' in session) { + session.onwebkitkeymessage = onwebkitkeymessageHandler; + } + if ('onwebkitkeyerror' in session) { + session.onwebkitkeyerror = onwebkitkeyerrorHandler; + } + return teardownSession; + }; +} diff --git a/packages/playback-core/test/drm/webkit-fariplay.test.js b/packages/playback-core/test/drm/webkit-fariplay.test.js new file mode 100644 index 00000000..198eb8a8 --- /dev/null +++ b/packages/playback-core/test/drm/webkit-fariplay.test.js @@ -0,0 +1,244 @@ +import { assert, fixture } from '@open-wc/testing'; +import { MediaError, MuxErrorCategory, MuxErrorCode } from '../../src/errors.ts'; +import { setupWebkitNativeFairplayDRM } from '../../src/webkit-fairplay.ts'; +import { mockEventListeners } from '../helpers/event-listener-mock.js'; + +/** + * Check if the browser is Safari (where WebKit FairPlay is supported) + */ +const isSafari = () => { + return /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); +}; + +// Only run these tests in Safari +const describeIfSafari = isSafari() ? describe : describe.skip; + +describeIfSafari('WebKit FairPlay DRM', () => { + let mediaEl; + let errorCalls; + let drmTypeCallCount; + let eventListenerMock; + + const mockCertificate = new Uint8Array([0, 2, 0, 1]).buffer; + const mockLicenseKey = new Uint8Array([0, 2, 0, 1]).buffer; + + const getAppCertificate = () => Promise.resolve(mockCertificate); + const getLicenseKey = (_spc) => Promise.resolve(mockLicenseKey); + const saveAndDispatchError = (_mediaEl, err) => { + errorCalls.push(err); + }; + const drmTypeCb = () => { + drmTypeCallCount++; + }; + + beforeEach(async () => { + errorCalls = []; + drmTypeCallCount = 0; + + mediaEl = await fixture(``); + + // Mock addEventListener and removeEventListener to track listeners + eventListenerMock = mockEventListeners(mediaEl); + + // Mock WebKit FairPlay APIs if they don't exist + if (!window.WebKitMediaKeys) { + console.warn('Running tests in environment with no Webkit Media Keys'); + window.WebKitMediaKeys = class { + constructor(keySystem) { + this.keySystem = keySystem; + } + createSession(mimeType, initData) { + return new MockWebKitMediaKeySession(); + } + }; + } + + if (!('onwebkitneedkey' in mediaEl)) { + mediaEl.onwebkitneedkey = null; + } + }); + + afterEach(() => { + // Restore original addEventListener and removeEventListener + if (eventListenerMock) { + eventListenerMock.restore(); + eventListenerMock = null; + } + + // Clean up event listeners + if (mediaEl) { + mediaEl.onwebkitneedkey = null; + } + }); + + describe('Setup and Teardown', () => { + it('should add webkitneedkey event listener on setup', async function () { + const teardown = await setupWebkitNativeFairplayDRM({ + mediaEl, + getAppCertificate, + getLicenseKey, + saveAndDispatchError, + drmTypeCb, + }); + + assert.isTrue(mediaEl.hasEventListener('webkitneedkey')); + teardown(); + assert.isFalse(mediaEl.hasEventListener('webkitneedkey')); + }); + + it('should call drmTypeCb when setting up MediaKeys', async function () { + assert.equal(drmTypeCallCount, 0); + + const teardown = await setupWebkitNativeFairplayDRM({ + mediaEl, + getAppCertificate, + getLicenseKey, + saveAndDispatchError, + drmTypeCb, + }); + + assert.equal(drmTypeCallCount, 1); + teardown(); + assert.equal(drmTypeCallCount, 1); + }); + }); + + describe('Error Handling', () => { + it('should handle missing WebKitMediaKeys gracefully', async function () { + const originalWebKitMediaKeys = window.WebKitMediaKeys; + delete window.WebKitMediaKeys; + delete mediaEl.onwebkitneedkey; + + try { + await setupWebkitNativeFairplayDRM({ + mediaEl, + getAppCertificate, + getLicenseKey, + saveAndDispatchError, + drmTypeCb, + }); + + assert.isTrue(errorCalls.length > 0, 'Should dispatch error'); + const error = errorCalls[0]; + assert.equal(error.errorCategory, MuxErrorCategory.DRM); + assert.equal(error.muxCode, MuxErrorCode.ENCRYPTED_CDM_ERROR); + } finally { + window.WebKitMediaKeys = originalWebKitMediaKeys; + } + }); + + it('should dispatch error if certificate fetch fails', async function () { + const failingGetAppCertificate = () => + Promise.reject(new MediaError('Certificate failed', MediaError.MEDIA_ERR_ENCRYPTED)); + + const teardown = await setupWebkitNativeFairplayDRM({ + mediaEl, + getAppCertificate: failingGetAppCertificate, + getLicenseKey, + saveAndDispatchError, + drmTypeCb, + }); + + // Simulate webkitneedkey event + const event = new CustomEvent('webkitneedkey', { + detail: { + initData: new Uint8Array([1, 2, 3, 4]).buffer, + }, + }); + mediaEl.dispatchEvent(event); + + // Give async operations time to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + teardown(); + }); + }); + + describe('Event Handling', () => { + it('should handle webkitneedkey events', async function () { + const teardown = await setupWebkitNativeFairplayDRM({ + mediaEl, + getAppCertificate, + getLicenseKey, + saveAndDispatchError, + drmTypeCb, + }); + + // Create and dispatch a mock webkitneedkey event + const initData = new Uint8Array([ + 0, + 0, + 0, + 4, // length + 115, + 107, + 100, + 58, // 'skd:' in ascii + ]).buffer; + + const event = new CustomEvent('webkitneedkey', { + detail: { initData }, + }); + + mediaEl.dispatchEvent(event); + + // Allow mono thread to setup + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.isTrue(mediaEl.hasEventListener('webkitcurrentplaybacktargetiswirelesschanged'), 'Session has been setup'); + + // Give async operations time to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + teardown(); + + assert.isFalse( + mediaEl.hasEventListener('webkitcurrentplaybacktargetiswirelesschanged'), + 'Session has been torn down' + ); + }); + }); +}); + +/** + * Mock WebKitMediaKeySession for testing + */ +class MockWebKitMediaKeySession { + constructor() { + this.onwebkitkeymessage = null; + this.onwebkitkeyerror = null; + console.log('Created mock session'); + } + + update(ckc) { + // Mock implementation + } + + close() { + // Mock implementation + } + + simulateKeyMessage(message) { + if (this.onwebkitkeymessage) { + this.onwebkitkeymessage({ + message, + target: this, + }); + } + } + + simulateKeyError(systemCode = 0, code = 0) { + if (this.onwebkitkeyerror) { + this.onwebkitkeyerror({ + target: { + error: { systemCode, code }, + }, + }); + } + } +} diff --git a/packages/playback-core/test/helpers/event-listener-mock.js b/packages/playback-core/test/helpers/event-listener-mock.js new file mode 100644 index 00000000..610ed2b0 --- /dev/null +++ b/packages/playback-core/test/helpers/event-listener-mock.js @@ -0,0 +1,53 @@ +/** + * Mock addEventListener and removeEventListener on an element to track listeners + * @param {HTMLElement} element - The element to mock + * @returns {Object} An object containing: + * - eventListeners: Map of event types to arrays of listeners + * - restore: Function to restore original methods + * - hasEventListener: Function to check if a listener exists + */ +export function mockEventListeners(element) { + const eventListeners = new Map(); + const originalAddEventListener = element.addEventListener.bind(element); + const originalRemoveEventListener = element.removeEventListener.bind(element); + + element.addEventListener = function (type, listener, options) { + if (!eventListeners.has(type)) { + eventListeners.set(type, []); + } + eventListeners.get(type).push({ listener, options }); + return originalAddEventListener(type, listener, options); + }; + + element.removeEventListener = function (type, listener, options) { + if (eventListeners.has(type)) { + const listeners = eventListeners.get(type); + const index = listeners.findIndex((l) => l.listener === listener); + if (index !== -1) { + listeners.splice(index, 1); + } + if (listeners.length === 0) { + eventListeners.delete(type); + } + } + return originalRemoveEventListener(type, listener, options); + }; + + // Helper method to check if an event listener exists + element.hasEventListener = function (type) { + return eventListeners.has(type) && eventListeners.get(type).length > 0; + }; + + // Return an object with the eventListeners map and a restore function + return { + eventListeners, + restore: () => { + element.addEventListener = originalAddEventListener; + element.removeEventListener = originalRemoveEventListener; + delete element.hasEventListener; + }, + hasEventListener: (type) => { + return eventListeners.has(type) && eventListeners.get(type).length > 0; + }, + }; +}