Restore mirroring sender functionality to level before SDK refactoring

This commit is contained in:
hensm
2022-09-01 12:23:03 +01:00
parent f50c8937e4
commit 314a1d2031
7 changed files with 303 additions and 197 deletions

View File

@@ -57,6 +57,8 @@ For an instance created for an extension script:
- If **content/extension page**: The `contentBridge.ts` script is imported as a module, with the usual side-effects of creating a messaging connection to the Cast Manager and hooking up page messaging (as described for page script instances). - If **content/extension page**: The `contentBridge.ts` script is imported as a module, with the usual side-effects of creating a messaging connection to the Cast Manager and hooking up page messaging (as described for page script instances).
3. Listeners are added for the `cast:instanceCreated` message, so that the `ensureInit` function can resolve its promise and provide a Cast Manager port after initialization. 3. Listeners are added for the `cast:instanceCreated` message, so that the `ensureInit` function can resolve its promise and provide a Cast Manager port after initialization.
Extension sender apps are considered to be trusted by the cast manager and are granted additional privileges. They can bypass the receiver selection step when requesting a session by providing a receiver device when initialising the SDK via `ensureInit`.
#### All contexts #### All contexts
The process now continues identically for all contexts: The process now continues identically for all contexts:

View File

@@ -47,6 +47,9 @@ export interface CastInstance {
contentPort: AnyPort; contentPort: AnyPort;
contentContext?: ContentContext; contentContext?: ContentContext;
/** From an extension-source, grants additional permissions. */
isTrusted: boolean;
/** ApiConfig provided on initialization. */ /** ApiConfig provided on initialization. */
apiConfig?: ApiConfig; apiConfig?: ApiConfig;
/** Established session details. */ /** Established session details. */
@@ -58,10 +61,12 @@ async function createCastInstance(opts: {
bridgePort?: Port; bridgePort?: Port;
contentPort: AnyPort; contentPort: AnyPort;
contentContext?: { tabId: number; frameId?: number }; contentContext?: { tabId: number; frameId?: number };
isTrusted?: boolean;
}) { }) {
const instance: CastInstance = { const instance: CastInstance = {
bridgePort: opts.bridgePort ?? (await bridge.connect()), bridgePort: opts.bridgePort ?? (await bridge.connect()),
contentPort: opts.contentPort contentPort: opts.contentPort,
isTrusted: opts.isTrusted ?? false
}; };
/** /**
@@ -113,6 +118,9 @@ const castManager = new (class {
messaging.onConnect.addListener(async port => { messaging.onConnect.addListener(async port => {
if (port.name === "cast") { if (port.name === "cast") {
this.createInstance(port); this.createInstance(port);
} else if (port.name === "trusted-cast") {
// Create trusted instance
this.createInstance(port, undefined, true);
} }
}); });
@@ -161,10 +169,14 @@ const castManager = new (class {
* Creates a cast instance with a given port and connects messaging * Creates a cast instance with a given port and connects messaging
* correctly depending on the type of port. * correctly depending on the type of port.
*/ */
async createInstance(port: AnyPort, contentContext?: ContentContext) { async createInstance(
port: AnyPort,
contentContext?: ContentContext,
isTrusted?: boolean
) {
const instance = await (port instanceof MessagePort const instance = await (port instanceof MessagePort
? this.createInstanceFromBackground(port, contentContext) ? this.createInstanceFromBackground(port, contentContext)
: this.createInstanceFromContent(port)); : this.createInstanceFromContent(port, isTrusted));
this.activeInstances.add(instance); this.activeInstances.add(instance);
@@ -184,7 +196,8 @@ const castManager = new (class {
const instance = await createCastInstance({ const instance = await createCastInstance({
bridgePort: await bridge.connect(), bridgePort: await bridge.connect(),
contentPort, contentPort,
contentContext contentContext,
isTrusted: true
}); });
// Ensure only one instance per context // Ensure only one instance per context
@@ -221,7 +234,8 @@ const castManager = new (class {
* Creates a cast instance with a WebExtension `Port` content port. * Creates a cast instance with a WebExtension `Port` content port.
*/ */
private async createInstanceFromContent( private async createInstanceFromContent(
contentPort: Port contentPort: Port,
isTrusted?: boolean
): Promise<CastInstance> { ): Promise<CastInstance> {
if ( if (
contentPort.sender?.tab?.id === undefined || contentPort.sender?.tab?.id === undefined ||
@@ -246,7 +260,7 @@ const castManager = new (class {
} }
} }
const instance = await createCastInstance({ contentPort }); const instance = await createCastInstance({ contentPort, isTrusted });
// cast instance -> (any) // cast instance -> (any)
const onContentPortMessage = (message: Message) => { const onContentPortMessage = (message: Message) => {
@@ -362,7 +376,27 @@ const castManager = new (class {
// User has triggered receiver selection via the cast API // User has triggered receiver selection via the cast API
case "main:requestSession": { case "main:requestSession": {
const { sessionRequest } = message.data; const { sessionRequest, receiverDevice } = message.data;
// Handle trusted instance receiver selection bypass
if (receiverDevice) {
if (!instance.isTrusted) {
logger.error(
"Cast instance not trusted to bypass receiver selection!"
);
break;
}
instance.bridgePort.postMessage({
subject: "bridge:createCastSession",
data: {
appId: sessionRequest.appId,
receiverDevice
}
});
break;
}
try { try {
const selection = await getReceiverSelection({ const selection = await getReceiverSelection({
@@ -485,8 +519,9 @@ const castManager = new (class {
case ReceiverSelectorMediaType.Screen: case ReceiverSelectorMediaType.Screen:
await browser.tabs.executeScript(contentContext.tabId, { await browser.tabs.executeScript(contentContext.tabId, {
code: stringify` code: stringify`
window.selectedMedia = ${selection.mediaType}; window.mirroringMediaType = ${selection.mediaType};
window.selectedReceiver = ${selection.receiverDevice}; window.receiverDevice = ${selection.receiverDevice};
window.contextTabId = ${contentContext.tabId};
`, `,
frameId: contentContext.frameId frameId: contentContext.frameId
}); });

View File

@@ -1,8 +1,8 @@
import messaging, { Message } from "../messaging"; import messaging, { Message } from "../messaging";
import pageMessenging from "./pageMessenging"; import pageMessenging from "./pageMessenging";
// Message port to background script // Message port to cast manager in background script
export const managerPort = messaging.connect({ name: "cast" }); const managerPort = messaging.connect({ name: "cast" });
const forwardToPage = (message: Message) => { const forwardToPage = (message: Message) => {
pageMessenging.extension.sendMessage(message); pageMessenging.extension.sendMessage(message);

View File

@@ -2,7 +2,8 @@
"use strict"; "use strict";
import type { TypedMessagePort } from "../lib/TypedMessagePort"; import type { TypedMessagePort } from "../lib/TypedMessagePort";
import type { Message } from "../messaging"; import messaging, { Message } from "../messaging";
import type { ReceiverDevice } from "../types";
import pageMessenging from "./pageMessenging"; import pageMessenging from "./pageMessenging";
import CastSDK from "./sdk"; import CastSDK from "./sdk";
@@ -14,17 +15,23 @@ let existingInstance = new CastSDK();
export default existingInstance; export default existingInstance;
interface EnsureInitOpts {
contextTabId?: number;
/** Skip receiver selection. */
receiverDevice?: ReceiverDevice;
}
/** /**
* To support exporting the API from a module, we need to retain the * To support exporting the API from a module, we need to retain the
* MessageChannel-based pageMessaging layer despite not crossing any * MessageChannel-based pageMessaging layer despite not crossing any
* context boundaries. * context boundaries.
* *
* The ensureInit function creates a messaging connection to the * The ensureInit function creates a messaging connection to the
* castManager, hooks it up to the pageMessaging layer and also provides * cast manager, hooks it up to the pageMessaging layer and also
* a messaging port so consumers of this module can communicate with the * provides a messaging port so consumers of this module can communicate
* castManager. * with the cast manager.
*/ */
export function ensureInit(contextTabId?: number): Promise<CastPort> { export function ensureInit(opts: EnsureInitOpts): Promise<CastPort> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
// If already initialized // If already initialized
if (existingPort) { if (existingPort) {
@@ -43,26 +50,26 @@ export function ensureInit(contextTabId?: number): Promise<CastPort> {
); );
/** /**
* port1 will handle castManager messages. * port1 will handle cast manager messages.
* port2 will handle cast instance messages. * port2 will handle cast instance messages.
*/ */
const { port1: managerPort, port2: instancePort } = const { port1: managerPort, port2: instancePort } =
new MessageChannel(); new MessageChannel();
/** /**
* Provide castManager with a port to send messages to * Provide cast manager with a port to send messages to
* cast instance. * cast instance.
*/ */
if (contextTabId) { if (opts.contextTabId) {
await castManager.createInstance(instancePort, { await castManager.createInstance(instancePort, {
tabId: contextTabId, tabId: opts.contextTabId,
frameId: 0 frameId: 0
}); });
} else { } else {
await castManager.createInstance(instancePort); await castManager.createInstance(instancePort);
} }
// castManager -> cast instance // cast manager -> cast instance
managerPort.addEventListener("message", ev => { managerPort.addEventListener("message", ev => {
const message = ev.data as Message; const message = ev.data as Message;
if (message.subject === "cast:instanceCreated") { if (message.subject === "cast:instanceCreated") {
@@ -77,30 +84,67 @@ export function ensureInit(contextTabId?: number): Promise<CastPort> {
}); });
managerPort.start(); managerPort.start();
// Cast instance -> castManager // Cast instance -> cast manager
pageMessenging.extension.addListener(message => { pageMessenging.extension.addListener(message => {
// Skip receiver selection
if (opts.receiverDevice) {
message = rewriteTrustedRequestSession(
message,
opts.receiverDevice
);
}
managerPort.postMessage(message); managerPort.postMessage(message);
}); });
} else { } else {
// Let contentBridge hook up pageMessaging const managerPort = messaging.connect({ name: "trusted-cast" });
const { managerPort: backgroundPort } = await import(
"./contentBridge"
);
existingPort = pageMessenging.page.messagePort;
backgroundPort.onMessage.addListener(function onManagerMessage( // Cast manager -> cast instance
message: Message managerPort.onMessage.addListener(message => {
) {
if (message.subject === "cast:instanceCreated") { if (message.subject === "cast:instanceCreated") {
if (message.data.isAvailable) { if (message.data.isAvailable) {
resolve(pageMessenging.page.messagePort); resolve(pageMessenging.page.messagePort);
} else { } else {
reject(); reject();
} }
backgroundPort.onMessage.removeListener(onManagerMessage);
} }
pageMessenging.extension.sendMessage(message);
}); });
// Cast instance -> cast manager
pageMessenging.extension.addListener(message => {
// Skip receiver selection
if (opts.receiverDevice) {
message = rewriteTrustedRequestSession(
message,
opts.receiverDevice
);
}
managerPort.postMessage(message);
});
managerPort.onDisconnect.addListener(() => {
pageMessenging.extension.close();
});
existingPort = pageMessenging.page.messagePort;
} }
}); });
} }
/**
* If a receiver device was passed to `ensureInit`, messages to the cast
* manager will be passed through this function and the receiver device
* will be added to the message payload. This tells the cast manager to
* skip receiver selection when requesting a session.
*/
function rewriteTrustedRequestSession(
message: Message,
receiverDevice: ReceiverDevice
) {
if (message.subject !== "main:requestSession") return message;
message.data.receiverDevice = receiverDevice;
return message;
}

View File

@@ -30,6 +30,8 @@ export default class MediaSender {
private isLocalMedia = false; private isLocalMedia = false;
private isLocalMediaEnabled = false; private isLocalMediaEnabled = false;
private wasSessionRequested = false;
// Cast API objects // Cast API objects
private session?: Session; private session?: Session;
private media?: Media; private media?: Media;
@@ -49,7 +51,7 @@ export default class MediaSender {
private async init() { private async init() {
try { try {
this.port = await ensureInit(this.contextTabId); this.port = await ensureInit({ contextTabId: this.contextTabId });
} catch (err) { } catch (err) {
logger.error("Failed to initialize cast API", err); logger.error("Failed to initialize cast API", err);
} }
@@ -96,12 +98,12 @@ export default class MediaSender {
// Unused // Unused
} }
private receiverListener(availability: ReceiverAvailability) { private receiverListener(availability: ReceiverAvailability) {
// Already have session if (this.wasSessionRequested) return;
if (this.session) return; this.wasSessionRequested = false;
if (availability === cast.ReceiverAvailability.AVAILABLE) { if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession( cast.requestSession(
(session: Session) => { session => {
this.session = session; this.session = session;
this.loadMedia(); this.loadMedia();
}, },

View File

@@ -1,199 +1,220 @@
"use strict"; "use strict";
import options from "../../lib/options"; import options from "../../lib/options";
import cast, { ensureInit } from "../export"; import { Logger } from "../../lib/logger";
import { ReceiverDevice, ReceiverSelectorMediaType } from "../../types"; import { ReceiverDevice, ReceiverSelectorMediaType } from "../../types";
import type Session from "../sdk/Session"; import type Session from "../sdk/Session";
import cast, { ensureInit } from "../export";
import type { ReceiverAvailability } from "../sdk/enums";
// Variables passed from background const logger = new Logger("fx_cast [mirroring sender]");
const {
selectedMedia,
selectedReceiver
}: {
selectedMedia: ReceiverSelectorMediaType;
selectedReceiver: ReceiverDevice;
} = window as any;
const FX_CAST_RECEIVER_APP_NAMESPACE = "urn:x-cast:fx_cast"; const NS_FX_CAST = "urn:x-cast:fx_cast";
let session: Session; type MirroringAppMessage =
let wasSessionRequested = false; | { subject: "peerConnectionOffer"; data: RTCSessionDescriptionInit }
| { subject: "peerConnectionAnswer"; data: RTCSessionDescriptionInit }
| { subject: "iceCandidate"; data: RTCIceCandidateInit }
| { subject: "close" };
let peerConnection: RTCPeerConnection; type MirroringMediaType =
| ReceiverSelectorMediaType.Tab
| ReceiverSelectorMediaType.Screen;
/** interface MirroringSenderOpts {
* Sends a message to the fx_cast app running on the mirroringMediaType: MirroringMediaType;
* receiver device. contextTabId?: number;
*/ receiverDevice?: ReceiverDevice;
function sendAppMessage(subject: string, data: unknown) {
if (!session) {
return;
}
session.sendMessage(FX_CAST_RECEIVER_APP_NAMESPACE, {
subject,
data
});
} }
window.addEventListener("beforeunload", () => { class MirroringSender {
sendAppMessage("close", null); private mirroringMediaType: MirroringMediaType;
}); private contextTabId?: number;
private receiverDevice?: ReceiverDevice;
async function onRequestSessionSuccess(newSession: Session) { private session?: Session;
cast.logMessage("onRequestSessionSuccess"); private wasSessionRequested = false;
session = newSession; private peerConnection?: RTCPeerConnection;
session.addMessageListener(
FX_CAST_RECEIVER_APP_NAMESPACE,
async (_namespace, message) => {
const { subject, data } = JSON.parse(message);
switch (subject) { constructor(opts: MirroringSenderOpts) {
case "peerConnectionAnswer": { this.mirroringMediaType = opts.mirroringMediaType;
peerConnection.setRemoteDescription(data); this.contextTabId = opts.contextTabId;
break; this.receiverDevice = opts.receiverDevice;
}
case "iceCandidate": { this.init();
peerConnection.addIceCandidate(data); }
break;
} private async init() {
} try {
ensureInit({
contextTabId: this.contextTabId,
receiverDevice: this.receiverDevice
});
} catch (err) {
logger.error("Failed to initialize cast API", err);
} }
);
peerConnection = new RTCPeerConnection(); const mirroringAppId = await options.get("mirroringAppId");
peerConnection.addEventListener("icecandidate", ev => { const sessionRequest = new cast.SessionRequest(mirroringAppId);
sendAppMessage("iceCandidate", ev.candidate);
});
switch (selectedMedia) { const apiConfig = new cast.ApiConfig(
case ReceiverSelectorMediaType.Tab: { sessionRequest,
const canvas = document.createElement("canvas"); this.sessionListener,
const ctx = canvas.getContext("2d"); this.receiverListener
);
// Shouldn't be possible cast.initialize(apiConfig);
if (!ctx) { }
break;
private sessionListener() {
// Unused
}
private receiverListener = (availability: ReceiverAvailability) => {
if (this.wasSessionRequested) return;
this.wasSessionRequested = true;
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
session => {
this.session = session;
this.createMirroringConnection();
},
err => {
logger.error("Session request failed", err);
}
);
}
};
private sendMirroringAppMessage(message: MirroringAppMessage) {
if (!this.session) return;
this.session.sendMessage(NS_FX_CAST, message);
}
private async createMirroringConnection() {
this.session?.addMessageListener(NS_FX_CAST, async (_ns, message) => {
const parsedMessage = JSON.parse(message) as MirroringAppMessage;
switch (parsedMessage.subject) {
case "peerConnectionAnswer":
this.peerConnection?.setRemoteDescription(
parsedMessage.data
);
break;
case "iceCandidate":
this.peerConnection?.addIceCandidate(parsedMessage.data);
break;
} }
});
// Set initial size this.peerConnection = new RTCPeerConnection();
this.peerConnection.addEventListener("icecandidate", ev => {
if (!ev.candidate) return;
this.sendMirroringAppMessage({
subject: "iceCandidate",
data: ev.candidate
});
});
switch (this.mirroringMediaType) {
case ReceiverSelectorMediaType.Tab:
this.peerConnection.addStream(this.getTabStream());
break;
case ReceiverSelectorMediaType.Screen:
this.peerConnection.addStream(await this.getScreenStream());
break;
}
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
this.sendMirroringAppMessage({
subject: "peerConnectionOffer",
data: offer
});
}
private getTabStream() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw logger.error("Failed to get tab canvas context!");
}
// Set initial size
canvas.width = window.innerWidth * window.devicePixelRatio;
canvas.height = window.innerHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
// Resize canvas whenever the window resizes
window.addEventListener("resize", () => {
canvas.width = window.innerWidth * window.devicePixelRatio; canvas.width = window.innerWidth * window.devicePixelRatio;
canvas.height = window.innerHeight * window.devicePixelRatio; canvas.height = window.innerHeight * window.devicePixelRatio;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(window.devicePixelRatio, window.devicePixelRatio); ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
});
// Resize canvas whenever the window resizes const drawFlags =
window.addEventListener("resize", () => { ctx.DRAWWINDOW_DRAW_CARET |
canvas.width = window.innerWidth * window.devicePixelRatio; ctx.DRAWWINDOW_DRAW_VIEW |
canvas.height = window.innerHeight * window.devicePixelRatio; ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES |
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.DRAWWINDOW_USE_WIDGET_LAYERS;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
});
const drawFlags = let lastFrame: DOMHighResTimeStamp;
ctx.DRAWWINDOW_DRAW_CARET | window.requestAnimationFrame(function draw(now: DOMHighResTimeStamp) {
ctx.DRAWWINDOW_DRAW_VIEW | if (!lastFrame) {
ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES | lastFrame = now;
ctx.DRAWWINDOW_USE_WIDGET_LAYERS; }
let lastFrame: DOMHighResTimeStamp; if (now - lastFrame > 1000 / 30) {
window.requestAnimationFrame(function draw( ctx.drawWindow(
now: DOMHighResTimeStamp window, // window
) { 0,
if (!lastFrame) { 0, // x, y
lastFrame = now; canvas.width, // w
} canvas.height, // h
"white", // bgColor
drawFlags
); // flags
if (now - lastFrame > 1000 / 30) { lastFrame = now;
ctx.drawWindow( }
window, // window
0,
0, // x, y
canvas.width, // w
canvas.height, // h
"white", // bgColor
drawFlags
); // flags
lastFrame = now; window.requestAnimationFrame(draw);
} });
window.requestAnimationFrame(draw); return canvas.captureStream();
});
/**
* Capture video stream from canvas and feed into the RTC
* connection.
*/
peerConnection.addStream(canvas.captureStream());
break;
}
case ReceiverSelectorMediaType.Screen: {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: "motion" },
audio: false
});
peerConnection.addStream(stream);
break;
}
} }
// Create SDP offer and set locally private getScreenStream() {
const offer = await peerConnection.createOffer(); return new Promise<MediaStream>(resolve => {
await peerConnection.setLocalDescription(offer); window.addEventListener(
"click",
// Send local offer to receiver app () => {
sendAppMessage("peerConnectionOffer", offer); resolve(
} navigator.mediaDevices.getDisplayMedia({
video: { cursor: "motion" },
function receiverListener(availability: string) { audio: false
cast.logMessage("receiverListener"); })
);
if (wasSessionRequested) { },
return; { once: true }
} );
});
if (availability === cast.ReceiverAvailability.AVAILABLE) {
wasSessionRequested = true;
cast.requestSession(
onRequestSessionSuccess,
onRequestSessionError,
undefined,
selectedReceiver
);
} }
} }
function onRequestSessionError() { /**
cast.logMessage("onRequestSessionError"); * If loaded as a content script, opts are stored on the window object.
} */
function sessionListener() { if (window.location.protocol !== "moz-extension:") {
cast.logMessage("sessionListener"); const window_ = window as any;
}
function onInitializeSuccess() {
cast.logMessage("onInitializeSuccess");
}
function onInitializeError() {
cast.logMessage("onInitializeError");
}
ensureInit().then(async () => { new MirroringSender({
const mirroringAppId = await options.get("mirroringAppId"); mirroringMediaType: window_.mirroringMediaType,
const sessionRequest = new cast.SessionRequest(mirroringAppId); contextTabId: window_.contextTabId,
receiverDevice: window_.receiverDevice
const apiConfig = new cast.ApiConfig( });
sessionRequest, }
sessionListener,
receiverListener,
undefined,
undefined
);
cast.initialize(apiConfig, onInitializeSuccess, onInitializeError);
});

View File

@@ -86,6 +86,8 @@ type ExtMessageDefinitions = {
*/ */
"main:requestSession": { "main:requestSession": {
sessionRequest: SessionRequest; sessionRequest: SessionRequest;
/** Skip receiver selection (allowed for trusted instances only). */
receiverDevice?: ReceiverDevice;
}; };
/** Return message to the cast API when a selection is cancelled. */ /** Return message to the cast API when a selection is cancelled. */
"cast:sessionRequestCancelled": undefined; "cast:sessionRequestCancelled": undefined;