import bridge from "../lib/bridge"; import { BaseConfig, baseConfigStorage, getAppTag } from "../lib/chromecastConfigApi"; import logger from "../lib/logger"; import messaging, { Message, Port } from "../messaging"; import options from "../lib/options"; import type { TypedMessagePort } from "../lib/TypedMessagePort"; import { ReceiverDevice, ReceiverSelectorAppInfo, ReceiverSelectorMediaType, ReceiverSelectorPageInfo } from "../types"; import type { ApiConfig } from "../cast/sdk/classes"; import { AutoJoinPolicy, ReceiverAction } from "../cast/sdk/enums"; import { createReceiver } from "../cast/utils"; import ReceiverSelector, { ReceiverSelection, ReceiverSelectorMediaMessage, ReceiverSelectorReceiverMessage } from "./ReceiverSelector"; import deviceManager from "./deviceManager"; import { ActionState, updateActionState } from "./action"; type AnyPort = Port | TypedMessagePort; export interface ContentContext { tabId: number; frameId: number; origin?: string; } /** Checks if two content contexts match. */ function isSameContext(ctx1?: ContentContext, ctx2?: ContentContext) { if (!ctx1 || !ctx2) return false; return ctx1?.tabId === ctx2?.tabId && ctx1?.frameId === ctx2?.frameId; } interface CastSession { bridgePort: Port; deviceId: string; appId: string; sessionId?: string; autoJoinContexts: Set; } /** Creates a cast session object and sets up messaging. */ async function createCastSession(opts: { deviceId: string; instance: CastInstance; appId?: string; }) { // If not explicitly provided, use session request app ID if (!opts.appId) { if (!opts.instance.apiConfig?.sessionRequest) { throw logger.error( "App ID not provided and instance missing valid session request!" ); } opts.appId = opts.instance.apiConfig.sessionRequest.appId; } const session: CastSession = { bridgePort: await bridge.connect(), deviceId: opts.deviceId, appId: opts.appId, autoJoinContexts: new Set() }; if (opts.instance.contentContext) { session.autoJoinContexts.add(opts.instance.contentContext); } opts.instance.session = session; opts.instance.bridgeMessageListener = message => { handleBridgeMessage(opts.instance, message); }; session.bridgePort.onMessage.addListener( opts.instance.bridgeMessageListener ); session.bridgePort.onDisconnect.addListener(() => destroyCastInstance(opts.instance) ); if (opts.instance.contentContext?.tabId) { updateActionState( ActionState.Connecting, opts.instance.contentContext?.tabId ); } return session; } function joinSession(instance: CastInstance, session: CastSession) { if (!session.sessionId) return; instance.session = session; instance.bridgeMessageListener = message => handleBridgeMessage(instance, message); session.bridgePort.onMessage.addListener(instance.bridgeMessageListener); session.bridgePort.onDisconnect.addListener(() => destroyCastInstance(instance) ); const device = deviceManager.getDeviceById(session.deviceId); if (!device?.status?.applications?.length) { throw logger.error("Invalid device state!"); } /** * Re-create sessionCreated message. Since the * sender app hasn't requested a session, this * will be handled by calling the session * listener. */ const application = device?.status?.applications[0]; instance.contentPort.postMessage({ subject: "cast:sessionCreated", data: { appId: application.appId, appImages: [], displayName: application.displayName, namespaces: application.namespaces, receiverFriendlyName: device.friendlyName, receiverId: device.id, senderApps: [], sessionId: session.sessionId, statusText: application.statusText, transportId: session.sessionId, volume: device.status.volume, receiver: createReceiver(device), media: device.mediaStatus } }); if (instance.contentContext?.tabId) { updateActionState( ActionState.Connected, instance.contentContext?.tabId ); } } function leaveSession(instance: CastInstance) { if (!instance.session?.sessionId) return; instance.contentPort.postMessage({ subject: "cast:sessionDisconnected", data: { sessionId: instance.session.sessionId } }); delete instance.session; if (instance.contentContext?.tabId) { updateActionState(ActionState.Default, instance.contentContext.tabId); } } export interface CastInstance { contentPort: AnyPort; contentContext?: ContentContext; /** From an extension-source, grants additional permissions. */ isTrusted: boolean; /** ApiConfig provided on initialization. */ apiConfig?: ApiConfig; /** Established session details. */ session?: CastSession; /** Listener for bridge messages. */ bridgeMessageListener?: (message: Message) => void; } /** Creates a cast instance object and associated bridge instance. */ function createCastInstance(opts: { contentPort: AnyPort; contentContext?: { tabId: number; frameId?: number }; isTrusted?: boolean; }) { const instance: CastInstance = { contentPort: opts.contentPort, isTrusted: opts.isTrusted ?? false }; /** * Set content context with fallback to extension message sender * context for content scripts. */ if (opts.contentContext) { instance.contentContext = { tabId: opts.contentContext.tabId, frameId: opts.contentContext.frameId ?? 0 }; } else if ( !(opts.contentPort instanceof MessagePort) && opts.contentPort.sender?.tab?.id ) { // Get origin from content port let origin: Optional; if (opts.contentPort.sender?.tab?.url) { try { ({ origin } = new URL(opts.contentPort.sender.tab.url)); // eslint-disable-next-line no-empty } catch {} } instance.contentContext = { tabId: opts.contentPort.sender.tab.id, frameId: opts.contentPort.sender.frameId ?? 0, origin }; } return instance; } /** Removes cast instance and disconnects messaging ports. */ function destroyCastInstance(instance: CastInstance) { if (instance.contentPort instanceof MessagePort) { instance.contentPort.close(); } else { instance.contentPort.disconnect(); } if (instance.session && instance.bridgeMessageListener) { instance.session.bridgePort.onMessage.removeListener( instance.bridgeMessageListener ); } if (instance.contentContext?.tabId) { updateActionState(ActionState.Default, instance.contentContext?.tabId); } activeInstances.delete(instance); } /** * Check instance's auto join policy against a content context to * determine if it's a valid auto join target. */ function isValidAutoJoinContext( instance: CastInstance, context: ContentContext ) { if (!instance.apiConfig?.autoJoinPolicy) return false; const { autoJoinPolicy } = instance.apiConfig; if ( autoJoinPolicy === AutoJoinPolicy.ORIGIN_SCOPED || autoJoinPolicy === AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED ) { // Check origin if (context.origin !== instance.contentContext?.origin) return false; // If tab-scoped, check context if ( autoJoinPolicy === AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED && !isSameContext(context, instance.contentContext) ) return false; return true; } return false; } interface AutoJoinTarget { session: CastSession; autoJoinContext: ContentContext; } function findAutoJoinTarget(instance: CastInstance) { for (const [, session] of activeSessions) { if ( !session.sessionId || session.appId !== instance.apiConfig?.sessionRequest.appId ) continue; for (const context of session.autoJoinContexts) { if (isValidAutoJoinContext(instance, context)) { return { session, autoJoinContext: context } as AutoJoinTarget; } } } } /** Whitelist of safe message types from content. */ const allowedContentMessages: Array = [ "main:initializeCastSdk", "main:requestSession", "main:requestSessionById", "main:leaveSession", "bridge:sendCastReceiverMessage", "bridge:sendCastSessionMessage" ]; /** Chromecast base config to check compatibility with audio devices. */ let baseConfig: BaseConfig; /** Shared receiver selector. */ let receiverSelector: Optional; /** Set of active cast instances. */ const activeInstances = new Set(); /** Map of active session IDs to session info objects. */ const activeSessions = new Map(); /** Keeps track of cast API instances and provides bridge messaging. */ const castManager = new (class { async init() { // Handle incoming instance connections messaging.onConnect.addListener(async port => { if (port.name === "cast") { this.createInstance(port); } else if (port.name === "trusted-cast") { // Create trusted instance this.createInstance(port, undefined, true); } }); // Pass receiver availability updates to cast API const updateReceiverAvailability = () => { const isAvailable = deviceManager.getDevices().length > 0; for (const instance of activeInstances) { instance.contentPort.postMessage({ subject: "cast:receiverAvailabilityUpdated", data: { isAvailable } }); } }; deviceManager.addEventListener("deviceUp", updateReceiverAvailability); deviceManager.addEventListener( "deviceDown", updateReceiverAvailability ); deviceManager.addEventListener("applicationClosed", ev => { const session = activeSessions.get(ev.detail.sessionId); if (!session?.sessionId) return; // Remove session from instances and notify SDK for (const instance of activeInstances) { if (instance.session === session) { instance.contentPort.postMessage({ subject: "cast:sessionStopped", data: { sessionId: session.sessionId } }); delete instance.session; if (instance.contentContext?.tabId) { updateActionState( ActionState.Default, instance.contentContext.tabId ); } } } activeSessions.delete(session.sessionId); }); } /** * Finds a cast instance at the given tab (and optionally frame) ID. */ getInstanceAt(tabId: number, frameId?: number) { for (const instance of activeInstances) { if (instance.contentContext?.tabId === tabId) { // If frame ID doesn't match go to next instance if (frameId && instance.contentContext.frameId !== frameId) { continue; } return instance; } } } getInstanceByDeviceId(deviceId: string) { for (const instance of activeInstances) { if (instance.session?.deviceId === deviceId) return instance; } } /** * Creates a cast instance with a given port and connects messaging * correctly depending on the type of port. */ async createInstance( port: AnyPort, contentContext?: ContentContext, isTrusted?: boolean ) { const instance = await (port instanceof MessagePort ? this.createInstanceFromBackground(port, contentContext) : this.createInstanceFromContent(port, isTrusted)); activeInstances.add(instance); instance.contentPort.postMessage({ subject: "cast:instanceCreated", data: { isAvailable: (await bridge.getInfo()).isVersionCompatible } }); return instance; } /** Creates a cast instance with a `MessagePort` content port. */ private async createInstanceFromBackground( contentPort: MessagePort, contentContext?: ContentContext ): Promise { const instance = await createCastInstance({ contentPort, contentContext, isTrusted: true }); // Ensure only one instance per context if (contentContext) { for (const instance of activeInstances) { if (isSameContext(instance.contentContext, contentContext)) { destroyCastInstance(instance); break; } } } // cast instance -> (any) contentPort.addEventListener("message", ev => { handleContentMessage(instance, ev.data); }); contentPort.start(); return instance; } /** * Creates a cast instance with a WebExtension `Port` content port. */ private async createInstanceFromContent( contentPort: Port, isTrusted?: boolean ): Promise { if ( contentPort.sender?.tab?.id === undefined || contentPort.sender?.frameId === undefined ) { throw logger.error( "Cast instance created from content with an invalid port context." ); } const instance = await createCastInstance({ contentPort, isTrusted }); // cast instance -> (any) const onContentPortMessage = (message: Message) => { handleContentMessage(instance, message); }; contentPort.onMessage.addListener(onContentPortMessage); contentPort.onDisconnect.addListener(() => { destroyCastInstance(instance); }); return instance; } /** * Gets a receiver selection and loads the appropriate sender for a * given context. */ async triggerCast(tabId: number, frameId = 0) { let selection: Nullable; try { selection = await getReceiverSelection({ tabId, frameId }); } catch (err) { logger.error("Failed to get receiver selection (triggerCast)", err); return; } if (!selection) return; loadSender(selection, { tabId, frameId }); } })(); export default castManager; /** Handles messages to cast instances from bridge. */ async function handleBridgeMessage(instance: CastInstance, message: Message) { // Intercept messages to store relevant info switch (message.subject) { case "main:castSessionCreated": { // Close after session is created if ( receiverSelector?.isOpen && // If selector context is the same as the instance context isSameContext( receiverSelector.pageInfo, instance.contentContext ) && // If selector is supposed to close (await options.get("receiverSelectorWaitForConnection")) ) { receiverSelector.close(); } const { receiverId: deviceId } = message.data; if (!instance.session) { logger.error("Instance is missing session!"); break; } instance.session.sessionId = message.data.sessionId; activeSessions.set(message.data.sessionId, instance.session); const device = deviceManager.getDeviceById(deviceId); if (!device) { logger.error( "[on main:castSessionCreated]: Could not find device with ID:", deviceId ); break; } instance.contentPort.postMessage({ subject: "cast:sessionCreated", data: { ...message.data, receiver: createReceiver(device) } }); if (instance.contentContext?.tabId) { updateActionState( ActionState.Connected, instance.contentContext?.tabId ); } break; } case "main:castSessionUpdated": instance.contentPort.postMessage({ subject: "cast:sessionUpdated", data: message.data }); } instance.contentPort.postMessage(message); } /** * Handle content messages from the cast instance. These will either * be handled here in the background script or forwarded to the * bridge associated with the cast instance. */ async function handleContentMessage(instance: CastInstance, message: Message) { // Limit untrusted instances to allowed messages subset if ( !allowedContentMessages.includes(message.subject) && !instance.isTrusted ) { logger.error(`Forbidden message type! (${message.subject})`); destroyCastInstance(instance); return; } const [destination] = message.subject.split(":"); if (destination === "bridge") { instance.session?.bridgePort.postMessage(message); } switch (message.subject) { case "main:initializeCastSdk": { instance.apiConfig = message.data.apiConfig; instance.contentPort.postMessage({ subject: "cast:receiverAvailabilityUpdated", data: { isAvailable: deviceManager.getDevices().length > 0 } }); // No need to check for existing sessions if page-scoped if ( instance.apiConfig.autoJoinPolicy === AutoJoinPolicy.PAGE_SCOPED ) { break; } // Check existing sessions for a valid auto join target const target = findAutoJoinTarget(instance); if (target) joinSession(instance, target.session); break; } // User has triggered receiver selection via the cast API case "main:requestSession": { const { sessionRequest, receiverDevice } = message.data; // Handle trusted instance receiver selection bypass if (receiverDevice) { if (receiverSelector?.isOpen && instance.contentContext) { receiverSelector.pageInfo = { ...instance.contentContext, url: ( await browser.webNavigation.getFrame({ tabId: instance.contentContext?.tabId, frameId: instance.contentContext?.frameId }) ).url }; } if (!instance.isTrusted) { logger.error( "Cast instance not trusted to bypass receiver selection!" ); destroyCastInstance(instance); break; } const session = await createCastSession({ instance, deviceId: receiverDevice.id, appId: sessionRequest.appId }); session.bridgePort.postMessage({ subject: "bridge:createCastSession", data: { appId: sessionRequest.appId, receiverDevice } }); break; } try { const selection = await getReceiverSelection({ castInstance: instance }); // Handle cancellation if (!selection) { instance.contentPort.postMessage({ subject: "cast:sessionRequestCancelled" }); break; } /** * If the media type returned from the selector has * been changed, we need to cancel the current * sender and switch it out for the right one. */ if (selection.mediaType !== ReceiverSelectorMediaType.App) { instance.contentPort.postMessage({ subject: "cast:sessionRequestCancelled" }); if (!instance.contentContext) { throw logger.error("Missing content context"); } loadSender(selection, instance.contentContext); break; } instance.contentPort.postMessage({ subject: "cast:receiverAction", data: { receiver: createReceiver(selection.device), action: ReceiverAction.CAST } }); const session = await createCastSession({ instance, deviceId: selection.device.id, appId: sessionRequest.appId }); session.bridgePort.postMessage({ subject: "bridge:createCastSession", data: { appId: sessionRequest.appId, receiverDevice: selection.device } }); } catch (err) { // TODO: Report errors properly instance.contentPort.postMessage({ subject: "cast:sessionRequestCancelled" }); } break; } case "main:requestSessionById": { const session = activeSessions.get(message.data.sessionId); if (!session) { logger.log( `Session not found! (id: ${message.data.sessionId})` ); break; } if (instance.apiConfig?.sessionRequest.appId === session.appId) { joinSession(instance, session); // If requesting by ID, add to the list of auto join contexts if (instance.contentContext) { session.autoJoinContexts.add(instance.contentContext); } } break; } case "main:leaveSession": { if (!instance.contentContext || !instance.session?.sessionId) { logger.error("Cannot leave session, instance invalid!"); break; } // Find auto join target for this instance const target = findAutoJoinTarget(instance); if (target) { // Remove auto join context for future instances instance.session.autoJoinContexts.delete( target.autoJoinContext ); const sessionAppId = instance.session.appId; leaveSession(instance); /** * Disconnect other instances within the scope of this * instances's auto join policy. */ for (const activeInstance of activeInstances) { if ( (activeInstance === instance || activeInstance.session?.appId) !== sessionAppId ) continue; if ( isValidAutoJoinContext( activeInstance, target.autoJoinContext ) ) { leaveSession(activeInstance); } } } else { leaveSession(instance); } } } } /** * Loads the appropriate sender for a given receiver selector response. */ async function loadSender( selection: ReceiverSelection, contentContext: ContentContext ) { // Cancelled if (!selection) { return; } switch (selection.mediaType) { case ReceiverSelectorMediaType.App: { const instance = castManager.getInstanceAt( contentContext.tabId, contentContext.frameId ); if (!instance) { throw logger.error( `Cast instance not found at tabId ${contentContext.tabId} / frameId ${contentContext.frameId}` ); } if (!instance.apiConfig?.sessionRequest.appId) { throw logger.error("Invalid session request"); } instance.contentPort.postMessage({ subject: "cast:receiverAction", data: { receiver: createReceiver(selection.device), action: ReceiverAction.CAST } }); const session = await createCastSession({ instance, deviceId: selection.device.id }); session.bridgePort.postMessage({ subject: "bridge:createCastSession", data: { appId: session.appId, receiverDevice: selection.device } }); break; } case ReceiverSelectorMediaType.Screen: await createMirroringPopup(selection.device); break; } } /** * Opens a receiver selector with the specified default/available media * types. * * Returns a promise that: * - Resolves to a ReceiverSelection object if selection is * successful. * - Resolves to null if the selection is cancelled. * - Rejects if the selection fails. */ async function getReceiverSelection(selectionOpts: { tabId?: number; frameId?: number; castInstance?: CastInstance; }): Promise { /** * If the current context is running the mirroring app, pretend * it doesn't exist because it shouldn't be launched like this. */ if ( selectionOpts.castInstance?.apiConfig?.sessionRequest.appId === (await options.get("mirroringAppId")) ) { selectionOpts.castInstance = undefined; } let defaultMediaType = ReceiverSelectorMediaType.Screen; let availableMediaTypes = ReceiverSelectorMediaType.Screen; // Default frame ID if (selectionOpts.frameId === undefined) selectionOpts.frameId = 0; // Fallback to instance context if ( selectionOpts.tabId === undefined && selectionOpts.castInstance?.contentContext ) { selectionOpts.tabId = selectionOpts.castInstance.contentContext.tabId; selectionOpts.frameId = selectionOpts.castInstance.contentContext.frameId; } const opts = await options.getAll(); /** * If context supplied, but no instance, check for an instance at * that context. */ if ( !selectionOpts.castInstance && selectionOpts.tabId !== undefined && selectionOpts.frameId !== undefined ) { const contextInstance = castManager.getInstanceAt( selectionOpts.tabId, selectionOpts.frameId ); // Ignore extension senders if (!contextInstance?.isTrusted) { selectionOpts.castInstance = contextInstance; } } let pageInfo: Optional; if (selectionOpts.tabId !== undefined) { try { pageInfo = { tabId: selectionOpts.tabId, frameId: selectionOpts.frameId, url: ( await browser.webNavigation.getFrame({ tabId: selectionOpts.tabId, frameId: selectionOpts.frameId }) ).url }; } catch (err) { logger.error("Failed to locate frame!", err); } } let appInfo: Optional; if (selectionOpts.castInstance?.apiConfig) { if (!baseConfig) { try { ({ baseConfig } = await baseConfigStorage.get("baseConfig")); } catch (err) { throw logger.error("Failed to get Chromecast base config!"); } } appInfo = { sessionRequest: selectionOpts.castInstance.apiConfig.sessionRequest, isRequestAppAudioCompatible: getAppTag( baseConfig, selectionOpts.castInstance.apiConfig?.sessionRequest.appId )?.supports_audio_only }; // Enable app media type if sender application is present defaultMediaType = ReceiverSelectorMediaType.App; availableMediaTypes |= ReceiverSelectorMediaType.App; } // Disable mirroring media types if mirroring is not enabled if (!opts.mirroringEnabled) { availableMediaTypes &= ~ReceiverSelectorMediaType.Screen; } // Ensure status manager is initialized await deviceManager.init(); return new Promise(async (resolve, reject) => { // Close an existing open selector if (receiverSelector?.isOpen) { await receiverSelector.close(); } receiverSelector = createSelector(); // Handle selected return value const onSelected = (ev: CustomEvent) => resolve(ev.detail); receiverSelector.addEventListener("selected", onSelected); // Handle cancelled return value const onCancelled = () => resolve(null); receiverSelector.addEventListener("cancelled", onCancelled); const onError = (ev: CustomEvent) => reject(ev.detail); receiverSelector.addEventListener("error", onError); // Cleanup listeners receiverSelector.addEventListener( "close", () => { receiverSelector?.removeEventListener("selected", onSelected); receiverSelector?.removeEventListener("cancelled", onCancelled); receiverSelector?.removeEventListener("error", onError); }, { once: true } ); receiverSelector.open({ devices: deviceManager.getDevices(), defaultMediaType, availableMediaTypes, appInfo, pageInfo }); }); } /** * Creates new ReceiverSelector object and adds listeners for * updates/messages. */ function createSelector() { // Get a new selector for each selection const selector = new ReceiverSelector( deviceManager.getBridgeInfo()?.isVersionCompatible ?? false ); /** * Sends message to cast instance to trigger stopped receiver action * (if applicable). */ const onStop = (ev: CustomEvent<{ deviceId: string }>) => { const castInstance = castManager.getInstanceByDeviceId( ev.detail.deviceId ); if (!castInstance) return; const device = deviceManager.getDeviceById(ev.detail.deviceId); if (!device) return; castInstance.contentPort.postMessage({ subject: "cast:receiverAction", data: { receiver: createReceiver(device), action: ReceiverAction.STOP } }); }; selector.addEventListener("stop", onStop); // Forward receiver messages const onReceiverMessage = ( ev: CustomEvent ) => deviceManager.sendReceiverMessage( ev.detail.deviceId, ev.detail.message ); selector.addEventListener("receiverMessage", onReceiverMessage); // Forward media messages const onMediaMessage = (ev: CustomEvent) => deviceManager.sendMediaMessage(ev.detail.deviceId, ev.detail.message); selector.addEventListener("mediaMessage", onMediaMessage); // Update selector data whenever devices change/update const onDeviceChange = () => { const connectedSessionIds: string[] = []; for (const instance of activeInstances) { if (instance.session?.sessionId) { connectedSessionIds.push(instance.session.sessionId); } } selector.update( deviceManager.getDevices(), deviceManager.getBridgeInfo()?.isVersionCompatible ?? false, connectedSessionIds ); }; deviceManager.addEventListener("deviceUp", onDeviceChange); deviceManager.addEventListener("deviceDown", onDeviceChange); deviceManager.addEventListener("deviceUpdated", onDeviceChange); deviceManager.addEventListener("deviceMediaUpdated", onDeviceChange); // Cleanup listeners selector.addEventListener( "close", () => { deviceManager.removeEventListener("deviceUp", onDeviceChange); deviceManager.removeEventListener("deviceDown", onDeviceChange); deviceManager.removeEventListener("deviceUpdated", onDeviceChange); deviceManager.removeEventListener( "deviceMediaUpdated", onDeviceChange ); selector.removeEventListener("stop", onStop); selector.removeEventListener("receiverMessage", onReceiverMessage); selector.removeEventListener("mediaMessage", onMediaMessage); }, { once: true } ); return selector; } /** Creates and manages mirroring popup window. */ async function createMirroringPopup(device: ReceiverDevice) { let popup: browser.windows.Window; try { popup = await browser.windows.create({ url: browser.runtime.getURL("ui/mirroring/index.html"), type: "popup", width: 400, height: 150 }); } catch (err) { logger.error("Failed to create mirroring popup!", err); return; } const onMirroringPopupMessage = (port: Port) => { if ( port.sender?.tab?.windowId !== popup.id || port.name !== "mirroring" ) { return; } port.postMessage({ subject: "mirroringPopup:init", data: { device } }); }; messaging.onConnect.addListener(onMirroringPopupMessage); browser.windows.onRemoved.addListener(function onWindowRemoved(windowId) { if (windowId !== popup.id) return; messaging.onConnect.removeListener(onMirroringPopupMessage); browser.windows.onRemoved.removeListener(onWindowRemoved); }); }