diff --git a/ext/src/background/castManager.ts b/ext/src/background/castManager.ts index 67f7225..724cc58 100644 --- a/ext/src/background/castManager.ts +++ b/ext/src/background/castManager.ts @@ -48,7 +48,7 @@ interface CastSession { deviceId: string; appId: string; sessionId?: string; - autoJoinContexts: ContentContext[]; + autoJoinContexts: Set; } /** Creates a cast session object and sets up messaging. */ @@ -71,11 +71,11 @@ async function createCastSession(opts: { bridgePort: await bridge.connect(), deviceId: opts.deviceId, appId: opts.appId, - autoJoinContexts: [] + autoJoinContexts: new Set() }; if (opts.instance.contentContext) { - session.autoJoinContexts.push(opts.instance.contentContext); + session.autoJoinContexts.add(opts.instance.contentContext); } opts.instance.session = session; @@ -150,6 +150,20 @@ function joinSession(instance: CastInstance, session: CastSession) { } } +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; @@ -230,11 +244,60 @@ function destroyCastInstance(instance: CastInstance) { 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; + + const { autoJoinPolicy } = instance.apiConfig; + if ( + autoJoinPolicy === AutoJoinPolicy.ORIGIN_SCOPED || + autoJoinPolicy === AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED + ) { + if (context.origin !== instance.contentContext?.origin) return false; + 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" ]; @@ -298,7 +361,7 @@ const castManager = new (class { if (instance.contentContext?.tabId) { updateActionState( ActionState.Default, - instance.contentContext?.tabId + instance.contentContext.tabId ); } } @@ -523,7 +586,7 @@ async function handleContentMessage(instance: CastInstance, message: Message) { } switch (message.subject) { - case "main:initializeCastSdk": + case "main:initializeCastSdk": { instance.apiConfig = message.data.apiConfig; instance.contentPort.postMessage({ subject: "cast:receiverAvailabilityUpdated", @@ -540,43 +603,11 @@ async function handleContentMessage(instance: CastInstance, message: Message) { } // Check existing sessions for a valid auto join target - sessionLoop: for (const [, session] of activeSessions) { - if ( - !session.sessionId || - session.appId !== instance.apiConfig.sessionRequest.appId - ) { - continue; - } - - // Check for valid auto join sessions - const { autoJoinPolicy } = instance.apiConfig; - if ( - autoJoinPolicy === AutoJoinPolicy.ORIGIN_SCOPED || - autoJoinPolicy === AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED - ) { - for (const context of session.autoJoinContexts) { - // Check same origin - if ( - context.origin !== instance.contentContext?.origin - ) { - continue; - } - // Check same context for tab scoped - if ( - autoJoinPolicy === - AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED && - !isSameContext(context, instance.contentContext) - ) { - continue; - } - - joinSession(instance, session); - break sessionLoop; - } - } - } + const target = findAutoJoinTarget(instance); + if (target) joinSession(instance, target.session); break; + } // User has triggered receiver selection via the cast API case "main:requestSession": { @@ -690,12 +721,54 @@ async function handleContentMessage(instance: CastInstance, message: Message) { // If requesting by ID, add to the list of auto join contexts if (instance.contentContext) { - session.autoJoinContexts.push(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); + } + } } } diff --git a/ext/src/cast/sdk/Session.ts b/ext/src/cast/sdk/Session.ts index c4ebc99..3409796 100644 --- a/ext/src/cast/sdk/Session.ts +++ b/ext/src/cast/sdk/Session.ts @@ -100,6 +100,11 @@ export const SessionSendMessageCallbacks = new WeakMap< Map >(); +export const SessionLeaveSuccessCallback = new WeakMap< + Session, + Optional<() => void> +>(); + /** Creates a Session object and initializes private data. */ export function createSession( sessionArgs: ConstructorParameters @@ -139,6 +144,13 @@ export default class Session { return sendMessageCallback; } + get #leaveSuccessCallback() { + return SessionLeaveSuccessCallback.get(this); + } + set #leaveSuccessCallback(successCallback: Optional<() => void>) { + SessionLeaveSuccessCallback.set(this, successCallback); + } + media: Media[] = []; namespaces: Array<{ name: string }> = []; senderApps: SenderApplication[] = []; @@ -257,10 +269,21 @@ export default class Session { } leave( - _successCallback?: () => void, - _errorCallback?: (err: CastError) => void + successCallback?: () => void, + errorCallback?: (err: CastError) => void ) { - logger.info("STUB :: Session#leave"); + if (!this.sessionId) { + errorCallback?.( + new CastError(ErrorCode.INVALID_PARAMETER, "Session not active") + ); + return; + } + + this.#leaveSuccessCallback = successCallback; + + pageMessaging.page.sendMessage({ + subject: "main:leaveSession" + }); } loadMedia( diff --git a/ext/src/cast/sdk/index.ts b/ext/src/cast/sdk/index.ts index eca4db2..2437e2a 100644 --- a/ext/src/cast/sdk/index.ts +++ b/ext/src/cast/sdk/index.ts @@ -33,6 +33,7 @@ import { import Session, { createSession, + SessionLeaveSuccessCallback, SessionMessageListeners, SessionSendMessageCallbacks, SessionUpdateListeners @@ -220,8 +221,7 @@ export default class { } case "cast:sessionStopped": { - const { sessionId } = message.data; - const session = this.#sessions.get(sessionId); + const session = this.#sessions.get(message.data.sessionId); if (session?.status === SessionStatus.CONNECTED) { session.status = SessionStatus.STOPPED; @@ -236,6 +236,24 @@ export default class { break; } + case "cast:sessionDisconnected": { + const session = this.#sessions.get(message.data.sessionId); + if (session?.status === SessionStatus.CONNECTED) { + session.status = SessionStatus.DISCONNECTED; + + SessionLeaveSuccessCallback.get(session)?.(); + + const updateListeners = SessionUpdateListeners.get(session); + if (updateListeners) { + for (const listener of updateListeners) { + listener(true); + } + } + } + + break; + } + case "cast:sessionMessageReceived": { const { sessionId, namespace, messageData } = message.data; const session = this.#sessions.get(sessionId); diff --git a/ext/src/messaging.ts b/ext/src/messaging.ts index eb71d80..56d395b 100644 --- a/ext/src/messaging.ts +++ b/ext/src/messaging.ts @@ -93,12 +93,14 @@ type ExtMessageDefinitions = { "cast:sessionRequestCancelled": undefined; "main:requestSessionById": { sessionId: string }; + "main:leaveSession": void; "cast:instanceCreated": { isAvailable: boolean }; "cast:receiverAvailabilityUpdated": { isAvailable: boolean }; "cast:sessionCreated": CastSessionCreatedDetails & { receiver: Receiver }; "cast:sessionUpdated": CastSessionUpdatedDetails; + "cast:sessionDisconnected": { sessionId: string }; /** Allows the selector popup to send cast NS_RECEIVER messages. */ "main:sendReceiverMessage": ReceiverSelectorReceiverMessage;