Fix longstanding architectural issues

This commit is contained in:
hensm
2022-09-01 02:14:00 +01:00
committed by Matt Hensman
parent 83c81219d7
commit 7a35da2ba1
30 changed files with 1274 additions and 1282 deletions

View File

@@ -4,23 +4,15 @@ import logger from "../lib/logger";
import messaging, { Port, Message } from "../messaging";
import options from "../lib/options";
import { TypedEventTarget } from "../lib/TypedEventTarget";
import { getMediaTypesForPageUrl } from "../lib/utils";
import {
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
import type {
ReceiverDevice,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../types";
import deviceManager from "./deviceManager";
import castManager from "./castManager";
import { BaseConfig, baseConfigStorage, getAppTag } from "../cast/googleApi";
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
import type { SessionRequest } from "../cast/sdk/classes";
import { ReceiverAction } from "../cast/sdk/enums";
import { createReceiver } from "../cast/utils";
const POPUP_URL = browser.runtime.getURL("ui/popup/index.html");
export interface ReceiverSelection {
@@ -47,8 +39,6 @@ interface ReceiverSelectorEvents {
mediaMessage: ReceiverSelectorMediaMessage;
}
let baseConfig: BaseConfig;
/**
* Manages the receiver selector popup window and communication with the
* extension page hosted within.
@@ -68,8 +58,7 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
private wasReceiverSelected = false;
private appId?: string;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
constructor() {
@@ -102,10 +91,10 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
receiverDevices: ReceiverDevice[];
defaultMediaType: ReceiverSelectorMediaType;
availableMediaTypes: ReceiverSelectorMediaType;
appId?: string;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
}) {
this.appId = opts.appId;
this.appInfo = opts.appInfo;
this.pageInfo = opts.pageInfo;
// If popup already exists, close it
@@ -178,8 +167,6 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
await browser.windows.remove(this.windowId);
}
this.appId = undefined;
if (this.messagePort && !this.messagePortDisconnected) {
this.messagePort.disconnect();
}
@@ -220,7 +207,10 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
this.messagePort.postMessage({
subject: "popup:init",
data: { appId: this.appId, pageInfo: this.pageInfo }
data: {
appInfo: this.appInfo,
pageInfo: this.pageInfo
}
});
this.messagePort.postMessage({
@@ -303,226 +293,4 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
}
}
}
static sharedInstance = new ReceiverSelector();
/**
* 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.
*/
static async getSelection(
contextTabId: number,
contextFrameId = 0,
selectionOpts?: {
sessionRequest?: SessionRequest;
withMediaSender?: boolean;
}
): Promise<ReceiverSelection | null> {
let castInstance = castManager.getInstance(
contextTabId,
contextFrameId
);
/**
* If the current context is running the mirroring app, pretend
* it doesn't exist because it shouldn't be launched like this.
*/
if (
castInstance?.apiConfig?.sessionRequest.appId ===
(await options.get("mirroringAppId"))
) {
castInstance = undefined;
}
let defaultMediaType = ReceiverSelectorMediaType.Tab;
let availableMediaTypes = ReceiverSelectorMediaType.None;
let pageUrl: string | undefined;
try {
pageUrl = (
await browser.webNavigation.getFrame({
tabId: contextTabId,
frameId: contextFrameId
})
).url;
availableMediaTypes = getMediaTypesForPageUrl(pageUrl);
} catch {
logger.error(
"Failed to locate frame, falling back to default available media types."
);
}
// Enable app media type if sender application is present
if (castInstance || selectionOpts?.withMediaSender) {
defaultMediaType = ReceiverSelectorMediaType.App;
availableMediaTypes |= ReceiverSelectorMediaType.App;
}
const opts = await options.getAll();
// Disable mirroring media types if mirroring is not enabled
if (!opts.mirroringEnabled) {
availableMediaTypes &= ~(
ReceiverSelectorMediaType.Tab | ReceiverSelectorMediaType.Screen
);
}
// Remove file media type if local media is not enabled
if (!opts.mediaEnabled || !opts.localMediaEnabled) {
availableMediaTypes &= ~ReceiverSelectorMediaType.File;
}
// Ensure status manager is initialized
await deviceManager.init();
let isRequestAppAudioCompatible: Optional<boolean>;
if (castInstance?.apiConfig?.sessionRequest.appId) {
if (!baseConfig) {
try {
baseConfig = (await baseConfigStorage.get("baseConfig"))
.baseConfig;
} catch (err) {
throw logger.error("Failed to get Chromecast base config!");
}
}
isRequestAppAudioCompatible = getAppTag(
baseConfig,
castInstance.apiConfig?.sessionRequest.appId
)?.supports_audio_only;
}
return new Promise(async (resolve, reject) => {
// Close an existing open selector
if (ReceiverSelector.sharedInstance.isOpen) {
await ReceiverSelector.sharedInstance.close();
}
const selector = createSelector();
ReceiverSelector.sharedInstance = selector;
// Handle selected return value
const onSelected = (ev: CustomEvent<ReceiverSelection>) =>
resolve(ev.detail);
selector.addEventListener("selected", onSelected);
// Handle cancelled return value
const onCancelled = () => resolve(null);
selector.addEventListener("cancelled", onCancelled);
const onError = (ev: CustomEvent<string>) => reject(ev.detail);
selector.addEventListener("error", onError);
// Cleanup listeners
selector.addEventListener(
"close",
() => {
selector.removeEventListener("selected", onSelected);
selector.removeEventListener("cancelled", onCancelled);
selector.removeEventListener("error", onError);
},
{ once: true }
);
selector.open({
receiverDevices: deviceManager.getDevices(),
defaultMediaType,
availableMediaTypes,
appId: castInstance?.apiConfig?.sessionRequest.appId,
// Create page info
pageInfo: pageUrl
? {
url: pageUrl,
tabId: contextTabId,
frameId: contextFrameId,
sessionRequest: selectionOpts?.sessionRequest,
isRequestAppAudioCompatible
}
: undefined
});
});
}
}
/**
* Creates new ReceiverSelector object and adds listeners for
* updates/messages.
*/
function createSelector() {
// Get a new selector for each selection
const selector = new ReceiverSelector();
ReceiverSelector.sharedInstance = selector;
/**
* 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:sendReceiverAction",
data: {
receiver: createReceiver(device),
action: ReceiverAction.STOP
}
});
};
selector.addEventListener("stop", onStop);
// Forward receiver messages
const onReceiverMessage = (
ev: CustomEvent<ReceiverSelectorReceiverMessage>
) =>
deviceManager.sendReceiverMessage(
ev.detail.deviceId,
ev.detail.message
);
selector.addEventListener("receiverMessage", onReceiverMessage);
// Forward media messages
const onMediaMessage = (ev: CustomEvent<ReceiverSelectorMediaMessage>) =>
deviceManager.sendMediaMessage(ev.detail.deviceId, ev.detail.message);
selector.addEventListener("mediaMessage", onMediaMessage);
// Update selector data whenever devices change/update
const onDeviceChange = () => selector.update(deviceManager.getDevices());
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;
}

View File

@@ -7,11 +7,10 @@ import bridge, { BridgeInfo } from "../lib/bridge";
import castManager from "./castManager";
import deviceManager from "./deviceManager";
import ReceiverSelector from "./ReceiverSelector";
import { initMenus } from "./menus";
import { initWhitelist } from "./whitelist";
import { baseConfigStorage, fetchBaseConfig } from "../cast/googleApi";
import { baseConfigStorage, fetchBaseConfig } from "../lib/chromecastConfigApi";
const _ = browser.i18n.getMessage;
@@ -133,26 +132,13 @@ async function init() {
await initMenus();
await initWhitelist();
/**
* When the browser action is clicked, open a receiver selector and
* load a sender for the response. The mirroring sender is loaded
* into the current tab at the
* top-level frame.
*/
browser.browserAction.onClicked.addListener(async tab => {
if (tab.id === undefined) {
logger.error("Tab ID not found in browser action handler.");
return;
}
const selection = await ReceiverSelector.getSelection(tab.id);
if (selection) {
castManager.loadSender({
tabId: tab.id,
frameId: 0,
selection
});
}
castManager.triggerCast(tab.id);
});
}

View File

@@ -1,32 +1,40 @@
"use strict";
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 { stringify } from "../lib/utils";
import { getMediaTypesForPageUrl, stringify } from "../lib/utils";
import type { TypedMessagePort } from "../lib/TypedMessagePort";
import { ReceiverSelectorMediaType } from "../types";
import {
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../types";
import type { ApiConfig } from "../cast/sdk/classes";
import { ReceiverAction } from "../cast/sdk/enums";
import { createReceiver } from "../cast/utils";
import deviceManager from "./deviceManager";
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
type AnyPort = Port | MessagePort;
import ReceiverSelector, {
ReceiverSelection,
ReceiverSelectorMediaMessage,
ReceiverSelectorReceiverMessage
} from "./ReceiverSelector";
export interface CastInstance {
bridgePort: Port;
contentPort: AnyPort;
contentTabId?: number;
contentFrameId?: number;
type AnyPort = Port | TypedMessagePort<Message>;
/** ApiConfig provided on initialization. */
apiConfig?: ApiConfig;
/** Established session details. */
session?: CastSession;
export interface ContentContext {
tabId: number;
frameId: number;
}
interface CastSession {
@@ -34,11 +42,73 @@ interface CastSession {
deviceId: string;
}
export interface CastInstance {
bridgePort: Port;
contentPort: AnyPort;
contentContext?: ContentContext;
/** ApiConfig provided on initialization. */
apiConfig?: ApiConfig;
/** Established session details. */
session?: CastSession;
}
/** Creates a cast instance object and associated bridge instance. */
async function createCastInstance(opts: {
bridgePort?: Port;
contentPort: AnyPort;
contentContext?: { tabId: number; frameId?: number };
}) {
const instance: CastInstance = {
bridgePort: opts.bridgePort ?? (await bridge.connect()),
contentPort: opts.contentPort
};
/**
* 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
) {
instance.contentContext = {
tabId: opts.contentPort.sender.tab.id,
frameId: opts.contentPort.sender.frameId ?? 0
};
}
return instance;
}
/** Disconnects either instance content port type. */
function disconnectContentPort(port: AnyPort) {
if (port instanceof MessagePort) {
port.close();
} else {
port.disconnect();
}
}
/** 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;
}
let baseConfig: BaseConfig;
let receiverSelector: Optional<ReceiverSelector>;
/** Keeps track of cast API instances and provides bridge messaging. */
export default new (class {
const castManager = new (class {
private activeInstances = new Set<CastInstance>();
public async init() {
async init() {
// Handle incoming instance connections
messaging.onConnect.addListener(async port => {
if (port.name === "cast") {
@@ -52,7 +122,7 @@ export default new (class {
for (const instance of this.activeInstances) {
instance.contentPort.postMessage({
subject: "cast:updateReceiverAvailability",
subject: "cast:receiverAvailabilityUpdated",
data: { isAvailable }
});
}
@@ -68,11 +138,11 @@ export default new (class {
/**
* Finds a cast instance at the given tab (and optionally frame) ID.
*/
public getInstance(tabId: number, frameId?: number) {
getInstanceAt(tabId: number, frameId?: number) {
for (const instance of this.activeInstances) {
if (instance.contentTabId === tabId) {
if (instance.contentContext?.tabId === tabId) {
// If frame ID doesn't match go to next instance
if (frameId && instance.contentFrameId !== frameId) {
if (frameId && instance.contentContext.frameId !== frameId) {
continue;
}
@@ -81,7 +151,7 @@ export default new (class {
}
}
public getInstanceByDeviceId(deviceId: string) {
getInstanceByDeviceId(deviceId: string) {
for (const instance of this.activeInstances) {
if (instance.session?.deviceId === deviceId) return instance;
}
@@ -91,9 +161,9 @@ export default new (class {
* Creates a cast instance with a given port and connects messaging
* correctly depending on the type of port.
*/
public async createInstance(port: AnyPort) {
async createInstance(port: AnyPort, contentContext?: ContentContext) {
const instance = await (port instanceof MessagePort
? this.createInstanceFromBackground(port)
? this.createInstanceFromBackground(port, contentContext)
: this.createInstanceFromContent(port));
this.activeInstances.add(instance);
@@ -108,27 +178,41 @@ export default new (class {
/** Creates a cast instance with a `MessagePort` content port. */
private async createInstanceFromBackground(
contentPort: MessagePort
contentPort: MessagePort,
contentContext?: ContentContext
): Promise<CastInstance> {
const instance: CastInstance = {
const instance = await createCastInstance({
bridgePort: await bridge.connect(),
contentPort
};
contentPort,
contentContext
});
// Ensure only one instance per context
if (contentContext) {
for (const instance of this.activeInstances) {
if (isSameContext(instance.contentContext, contentContext)) {
instance.bridgePort.disconnect();
this.activeInstances.delete(instance);
break;
}
}
}
instance.bridgePort.onDisconnect.addListener(() => {
contentPort.close();
this.activeInstances.delete(instance);
});
// bridge -> content
// bridge -> cast instance
instance.bridgePort.onMessage.addListener(message => {
this.handleBridgeMessage(instance, message);
});
// content -> (any)
// cast instance -> (any)
contentPort.addEventListener("message", ev => {
this.handleContentMessage(instance, ev.data);
});
contentPort.start();
return instance;
}
@@ -148,33 +232,27 @@ export default new (class {
);
}
/**
* If there's already an active instance for the sender
* tab/frame ID, disconnect it.
*
* TODO: Fix this behaviour!
*/
// Ensure only one instance per context
for (const instance of this.activeInstances) {
if (
instance.contentTabId === contentPort.sender.tab.id &&
instance.contentFrameId === contentPort.sender.frameId
isSameContext(
instance.contentContext,
contentPort.sender as ContentContext
)
) {
instance.bridgePort.disconnect();
disconnectContentPort(instance.contentPort);
break;
}
}
const instance: CastInstance = {
bridgePort: await bridge.connect(),
contentPort,
contentTabId: contentPort.sender.tab.id,
contentFrameId: contentPort.sender.frameId
};
const instance = await createCastInstance({ contentPort });
// content -> (any)
// cast instance -> (any)
const onContentPortMessage = (message: Message) => {
this.handleContentMessage(instance, message);
};
// bridge -> content
// bridge -> cast instance
const onBridgePortMessage = (message: Message) => {
this.handleBridgeMessage(instance, message);
};
@@ -206,16 +284,17 @@ export default new (class {
switch (message.subject) {
case "main:castSessionCreated": {
// Close after session is created
const selector = ReceiverSelector.sharedInstance;
if (
selector.isOpen &&
receiverSelector?.isOpen &&
// If selector context is the same as the instance context
selector.pageInfo?.tabId === instance.contentTabId &&
selector.pageInfo?.frameId === instance.contentFrameId &&
receiverSelector.pageInfo?.tabId ===
instance.contentContext?.tabId &&
receiverSelector.pageInfo?.frameId ===
instance.contentContext?.frameId &&
// If selector is supposed to close
(await options.get("receiverSelectorWaitForConnection"))
) {
selector.close();
receiverSelector.close();
}
const { receiverId: deviceId } = message.data;
@@ -270,44 +349,30 @@ export default new (class {
}
switch (message.subject) {
// Cast API has been initialized
case "main:initializeCast": {
case "main:initializeCast":
instance.apiConfig = message.data.apiConfig;
instance.contentPort.postMessage({
subject: "cast:updateReceiverAvailability",
subject: "cast:receiverAvailabilityUpdated",
data: {
isAvailable: deviceManager.getDevices().length > 0
}
});
break;
}
// User has triggered receiver selection via the cast API
case "main:selectReceiver": {
if (
instance.contentTabId === undefined ||
instance.contentFrameId === undefined
) {
throw logger.error(
"Cast instance associated with content sender missing tab/frame ID"
);
}
case "main:requestSession": {
const { sessionRequest } = message.data;
try {
const selection = await ReceiverSelector.getSelection(
instance.contentTabId,
instance.contentFrameId,
{ sessionRequest }
);
const selection = await getReceiverSelection({
castInstance: instance
});
// Handle cancellation
if (!selection) {
instance.contentPort.postMessage({
subject: "cast:selectReceiver/cancelled"
subject: "cast:sessionRequestCancelled"
});
break;
@@ -320,14 +385,13 @@ export default new (class {
*/
if (selection.mediaType !== ReceiverSelectorMediaType.App) {
instance.contentPort.postMessage({
subject: "cast:selectReceiver/cancelled"
subject: "cast:sessionRequestCancelled"
});
this.loadSender({
tabId: instance.contentTabId,
frameId: instance.contentFrameId,
selection
});
if (!instance.contentContext) {
throw logger.error("Missing content context");
}
this.loadSender(selection, instance.contentContext);
break;
}
@@ -342,7 +406,7 @@ export default new (class {
} catch (err) {
// TODO: Report errors properly
instance.contentPort.postMessage({
subject: "cast:selectReceiver/cancelled"
subject: "cast:sessionRequestCancelled"
});
}
@@ -352,25 +416,45 @@ export default new (class {
}
/**
* Loads the appropriate sender for a given receiver selector
* response.
* Gets a receiver selection and loads the appropriate sender for a
* given context.
*/
public async loadSender(opts: {
tabId: number;
frameId?: number;
selection: ReceiverSelection;
}) {
// Cancelled
if (!opts.selection) {
async triggerCast(tabId: number, frameId = 0) {
let selection: Nullable<ReceiverSelection>;
try {
selection = await getReceiverSelection({ tabId, frameId });
} catch (err) {
logger.error("Failed to get receiver selection (triggerCast)", err);
return;
}
switch (opts.selection.mediaType) {
if (!selection) return;
this.loadSender(selection, { tabId, frameId });
}
/**
* Loads the appropriate sender for a given receiver selector
* response.
*/
private async loadSender(
selection: ReceiverSelection,
contentContext: ContentContext
) {
// Cancelled
if (!selection) {
return;
}
switch (selection.mediaType) {
case ReceiverSelectorMediaType.App: {
const instance = this.getInstance(opts.tabId, opts.frameId);
const instance = this.getInstanceAt(
contentContext.tabId,
contentContext.frameId
);
if (!instance) {
throw logger.error(
`Cast instance not found at tabId ${opts.tabId} / frameId ${opts.frameId}`
`Cast instance not found at tabId ${contentContext.tabId} / frameId ${contentContext.frameId}`
);
}
@@ -379,9 +463,9 @@ export default new (class {
}
instance.contentPort.postMessage({
subject: "cast:sendReceiverAction",
subject: "cast:receiverAction",
data: {
receiver: createReceiver(opts.selection.receiverDevice),
receiver: createReceiver(selection.receiverDevice),
action: ReceiverAction.CAST
}
});
@@ -390,7 +474,7 @@ export default new (class {
subject: "bridge:createCastSession",
data: {
appId: instance.apiConfig?.sessionRequest.appId,
receiverDevice: opts.selection.receiverDevice
receiverDevice: selection.receiverDevice
}
});
@@ -398,34 +482,239 @@ export default new (class {
}
case ReceiverSelectorMediaType.Tab:
case ReceiverSelectorMediaType.Screen: {
await browser.tabs.executeScript(opts.tabId, {
case ReceiverSelectorMediaType.Screen:
await browser.tabs.executeScript(contentContext.tabId, {
code: stringify`
window.selectedMedia = ${opts.selection.mediaType};
window.selectedReceiver = ${opts.selection.receiverDevice};
window.selectedMedia = ${selection.mediaType};
window.selectedReceiver = ${selection.receiverDevice};
`,
frameId: opts.frameId
frameId: contentContext.frameId
});
await browser.tabs.executeScript(opts.tabId, {
await browser.tabs.executeScript(contentContext.tabId, {
file: "cast/senders/mirroring.js",
frameId: opts.frameId
frameId: contentContext.frameId
});
break;
}
case ReceiverSelectorMediaType.File: {
const fileUrl = new URL(`file://${opts.selection.filePath}`);
const { init } = await import("../cast/senders/media");
init({
mediaUrl: fileUrl.href,
receiver: opts.selection.receiverDevice
});
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<ReceiverSelection | null> {
/**
* 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.Tab;
let availableMediaTypes = ReceiverSelectorMediaType.None;
// Default frame ID
if (!selectionOpts.frameId) selectionOpts.frameId = 0;
// Fallback to instance context
if (!selectionOpts.tabId && selectionOpts.castInstance?.contentContext) {
selectionOpts.tabId = selectionOpts.castInstance.contentContext.tabId;
selectionOpts.frameId =
selectionOpts.castInstance.contentContext.frameId;
}
let pageInfo: Optional<ReceiverSelectorPageInfo>;
if (selectionOpts.tabId) {
try {
pageInfo = {
tabId: selectionOpts.tabId,
frameId: selectionOpts.frameId,
url: (
await browser.webNavigation.getFrame({
tabId: selectionOpts.tabId,
frameId: selectionOpts.frameId
})
).url
};
availableMediaTypes = getMediaTypesForPageUrl(pageInfo.url);
} catch {
logger.error(
"Failed to locate frame, falling back to default available media types."
);
}
}
// Enable app media type if sender application is present
if (selectionOpts.castInstance) {
defaultMediaType = ReceiverSelectorMediaType.App;
availableMediaTypes |= ReceiverSelectorMediaType.App;
}
const opts = await options.getAll();
// Disable mirroring media types if mirroring is not enabled
if (!opts.mirroringEnabled) {
availableMediaTypes &= ~(
ReceiverSelectorMediaType.Tab | ReceiverSelectorMediaType.Screen
);
}
// Ensure status manager is initialized
await deviceManager.init();
let appInfo: Optional<ReceiverSelectorAppInfo>;
if (selectionOpts.castInstance?.apiConfig) {
if (!baseConfig) {
try {
baseConfig = (await baseConfigStorage.get("baseConfig"))
.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
};
}
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<ReceiverSelection>) =>
resolve(ev.detail);
receiverSelector.addEventListener("selected", onSelected);
// Handle cancelled return value
const onCancelled = () => resolve(null);
receiverSelector.addEventListener("cancelled", onCancelled);
const onError = (ev: CustomEvent<string>) => 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({
receiverDevices: 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();
/**
* 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<ReceiverSelectorReceiverMessage>
) =>
deviceManager.sendReceiverMessage(
ev.detail.deviceId,
ev.detail.message
);
selector.addEventListener("receiverMessage", onReceiverMessage);
// Forward media messages
const onMediaMessage = (ev: CustomEvent<ReceiverSelectorMediaMessage>) =>
deviceManager.sendMediaMessage(ev.detail.deviceId, ev.detail.message);
selector.addEventListener("mediaMessage", onMediaMessage);
// Update selector data whenever devices change/update
const onDeviceChange = () => selector.update(deviceManager.getDevices());
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;
}
export default castManager;

View File

@@ -4,13 +4,10 @@ import logger from "../lib/logger";
import options from "../lib/options";
import { stringify } from "../lib/utils";
import { ReceiverSelectorMediaType } from "../types";
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
import castManager from "./castManager";
import * as menuIds from "../menuIds";
import castManager from "./castManager";
const _ = browser.i18n.getMessage;
const URL_PATTERN_HTTP = "http://*/*";
@@ -176,49 +173,21 @@ async function onMenuClicked(
return;
}
// Handle cast menus
const castMenuClicked = info.menuItemId === menuIdCast;
const castMediaMenuClicked = info.menuItemId === menuIdCastMedia;
if (castMenuClicked || castMediaMenuClicked) {
if (tab?.id === undefined) {
throw logger.error("Menu handler tab ID not found.");
}
if (!info.pageUrl) {
throw logger.error("Menu handler page URL not found.");
if (tab?.id === undefined) {
logger.error("Menu handler tab ID not found.");
return;
}
switch (info.menuItemId) {
case menuIdCast: {
castManager.triggerCast(tab.id, info.frameId);
break;
}
let selection: Nullable<ReceiverSelection> = null;
try {
selection = await ReceiverSelector.getSelection(
tab.id,
info.frameId,
{
withMediaSender: castMediaMenuClicked
}
);
} catch (err) {
logger.error("Failed to get receiver selection (cast menu)", err);
return;
}
// Invalid selection result
if (!selection) return;
if (castMenuClicked) {
castManager.loadSender({
tabId: tab.id,
frameId: info.frameId,
selection
});
} else if (castMediaMenuClicked) {
/**
* If the selected media type is App, that refers to
* the media sender in this context, so load media
* sender.
*/
if (selection.mediaType === ReceiverSelectorMediaType.App) {
case menuIdCastMedia:
if (info.srcUrl) {
await browser.tabs.executeScript(tab.id, {
code: stringify`
window.receiver = ${selection.receiverDevice};
window.mediaUrl = ${info.srcUrl};
window.targetElementId = ${info.targetElementId};
`,
@@ -226,18 +195,11 @@ async function onMenuClicked(
});
await browser.tabs.executeScript(tab.id, {
file: "cast/senders/media/index.js",
file: "cast/senders/media.js",
frameId: info.frameId
});
} else {
// Handle other responses
castManager.loadSender({
tabId: tab.id,
frameId: info.frameId,
selection
});
}
}
break;
}
}

View File

@@ -9,7 +9,7 @@ import { RemoteMatchPattern } from "../lib/matchPattern";
import {
CAST_FRAMEWORK_LOADER_SCRIPT_URL,
CAST_LOADER_SCRIPT_URL
} from "../cast/endpoints";
} from "../cast/urls";
// Missing on @types/firefox-webext-browser
type OnBeforeSendHeadersDetails = Parameters<
@@ -207,7 +207,7 @@ async function onBeforeCastSDKRequest(details: OnBeforeRequestDetails) {
});
return {
redirectUrl: browser.runtime.getURL("cast/index.js")
redirectUrl: browser.runtime.getURL("cast/content.js")
};
}
@@ -250,6 +250,13 @@ async function registerSiteWhitelist() {
{ urls: ["<all_urls>"] },
["blocking", "requestHeaders"]
);
browser.contentScripts.register({
matches: siteWhitelist.map(item => item.pattern),
js: [{ file: "cast/contentInitial.js" }],
runAt: "document_start",
allFrames: true
});
}
function unregisterSiteWhitelist() {