Restructure background script (#70)

Splits some background script functionality into separate modules:
 - Receiver selector handling is moved to ./SelectorManager.
 - Status bridge handling is moved to ./StatusManager.
 - Menu creation and updates are handled in ./createMenus.
 - Shim creation is handled in ./createShim.

TypedEventTarget allows EventTarget-derived classes to export typed events.

Options type definition is moved to ./lib/options, module assumes more responsibility for update handling and provides a "changed" event.

Private cast._requestSession method allows bypassing receiver selector.
This commit is contained in:
Matt Hensman
2019-07-26 00:09:51 +01:00
committed by GitHub
parent 2fe72ed24c
commit ba8c28bf39
40 changed files with 1751 additions and 1241 deletions

View File

@@ -1,73 +0,0 @@
"use strict";
import EventEmitter from "events";
import fs from "fs";
import http from "http";
import mime from "mime-types";
import { Message
, SendMessageCallback } from "./types";
export default class MediaServer extends EventEmitter {
private httpServer: http.Server;
constructor (
private filePath: string
, private port: number) {
super();
this.httpServer = http.createServer(this.requestListener.bind(this));
}
public start () {
this.httpServer.listen(this.port, () => {
this.emit("started");
});
}
public stop () {
if (this.httpServer && this.httpServer.listening) {
this.httpServer.close(() => {
this.emit("stopped");
});
}
}
private requestListener (
req: http.IncomingMessage
, res: http.ServerResponse) {
const { size: fileSize } = fs.statSync(this.filePath);
const { range } = req.headers;
const contentType = mime.lookup(this.filePath) || "video/mp4";
// Partial content HTTP 206
if (range) {
const bounds = range.substring(6).split("-");
const start = parseInt(bounds[0]);
const end = bounds[1] ? parseInt(bounds[1]) : fileSize - 1;
const chunkSize = (end - start) + 1;
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": chunkSize
, "Content-Type": contentType
});
fs.createReadStream(this.filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
fs.createReadStream(this.filePath).pipe(res);
}
}
}

View File

@@ -8,7 +8,6 @@ import mime from "mime-types";
import path from "path";
import Media from "./Media";
import MediaServer from "./MediaServer";
import Session from "./Session";
import StatusListener from "./StatusListener";
@@ -32,11 +31,11 @@ events.EventEmitter.defaultMaxListeners = 50;
const browser = new dnssd.Browser(dnssd.tcp("googlecast"));
// Local media server
let mediaServer: MediaServer;
let mediaServer: http.Server;
process.on("SIGTERM", () => {
if (mediaServer) {
mediaServer.stop();
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
});
@@ -47,18 +46,28 @@ const encodeTransform = new EncodeTransform();
// stdin -> stdout
process.stdin
.pipe(decodeTransform)
.pipe(new ResponseTransform(handleMessage))
.pipe(encodeTransform)
decodeTransform.on("data", handleMessage);
encodeTransform
.pipe(process.stdout);
/**
* Encode and send a message to the extension.
* Encode and send a message to the extension. If message is
* a string, send that as the message subject, else send a
* passed message object.
*/
function sendMessage (message: object) {
function sendMessage (message: string | object) {
try {
encodeTransform.write(message);
if (typeof message === "string") {
encodeTransform.write({
subject: message
});
} else {
encodeTransform.write(message);
}
} catch (err) {
console.error("Failed to encode message");
console.error("Failed to encode message", err);
}
}
@@ -72,6 +81,7 @@ const existingSessions: Map<string, Session> = new Map();
const existingMedia: Map<string, Media> = new Map();
let receiverSelectorApp: child_process.ChildProcess;
let receiverSelectorAppClosed = true;
/**
* Handle incoming messages from the extension and forward
@@ -130,10 +140,18 @@ async function handleMessage (message: Message) {
return;
}
if (message.subject.startsWith("bridge:/receiverSelector/")) {
handleReceiverSelectorMessage(message);
}
if (message.subject.startsWith("bridge:/mediaServer/")) {
handleMediaServerMessage(message);
}
switch (message.subject) {
case "bridge:/getInfo": {
const extensionVersion = message.data;
return __applicationVersion;
encodeTransform.write(__applicationVersion);
}
case "bridge:/initialize": {
@@ -142,8 +160,11 @@ async function handleMessage (message: Message) {
break;
}
}
}
function handleReceiverSelectorMessage (message: Message) {
switch (message.subject) {
case "bridge:/receiverSelector/open": {
const receiverSelectorData = message.data;
@@ -167,6 +188,8 @@ async function handleMessage (message: Message) {
path.join(process.cwd(), "selector")
, [ receiverSelectorData ]);
receiverSelectorAppClosed = false;
receiverSelectorApp.stdout!.setEncoding("utf8");
receiverSelectorApp.stdout!.on("data", data => {
sendMessage({
@@ -175,7 +198,7 @@ async function handleMessage (message: Message) {
});
});
receiverSelectorApp.addListener("error", err => {
receiverSelectorApp.on("error", err => {
sendMessage({
subject: "main:/receiverSelector/error"
, data: err.message
@@ -183,9 +206,13 @@ async function handleMessage (message: Message) {
});
receiverSelectorApp.on("close", () => {
sendMessage({
subject: "main:/receiverSelector/close"
});
if (!receiverSelectorAppClosed) {
receiverSelectorAppClosed = true;
sendMessage({
subject: "main:/receiverSelector/close"
});
}
});
break;
@@ -193,34 +220,78 @@ async function handleMessage (message: Message) {
case "bridge:/receiverSelector/close": {
receiverSelectorApp.kill();
receiverSelectorAppClosed = true;
break;
}
}
}
function handleMediaServerMessage (message: Message) {
switch (message.subject) {
case "bridge:/mediaServer/start": {
const { filePath, port } = message.data;
const { filePath, port }
: { filePath: string, port: number } = message.data;
mediaServer = new MediaServer(filePath, port);
mediaServer.start();
const contentType = mime.lookup(filePath);
mediaServer.on("started", () => {
sendMessage({
subject: "mediaCast:/mediaServer/started"
});
if (!contentType) {
sendMessage("mediaCast:/mediaServer/error");
break;
}
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
mediaServer = http.createServer((req, res) => {
const { size: fileSize } = fs.statSync(filePath);
const { range } = req.headers;
// Partial content HTTP 206
if (range) {
const bounds = range.substring(6).split("-");
const start = parseInt(bounds[0]);
const end = bounds[1] ? parseInt(bounds[1]) : fileSize - 1;
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": (end - start) + 1
, "Content-Type": contentType
});
fs.createReadStream(filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
fs.createReadStream(filePath).pipe(res);
}
});
mediaServer.on("stopped", () => {
sendMessage({
subject: "mediaCast:/mediaServer/stopped"
});
mediaServer.on("listening", () => {
sendMessage("mediaCast:/mediaServer/started");
});
mediaServer.on("close", () => {
console.error("mediaServer close");
sendMessage("mediaCast:/mediaServer/stopped");
});
mediaServer.on("error", (a) => {
console.error("mediaServer error", a);
sendMessage("mediaCast:/mediaServer/error");
});
mediaServer.listen(port);
break;
}
case "bridge:/mediaServer/stop": {
if (mediaServer) {
mediaServer.stop();
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
break;

View File

@@ -70,7 +70,7 @@ const webpackConfig = require(`${ROOT}/webpack.config.js`)({
webpackConfig.mode = argv.mode;
webpackConfig.devtool = argv.mode === "production"
? "none"
: "eval";
: "source-map";
// Clean

2
ext/package-lock.json generated
View File

@@ -6377,7 +6377,7 @@
},
"pretty-format": {
"version": "3.8.0",
"resolved": "http://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U=",
"dev": true
},

View File

@@ -0,0 +1,85 @@
"use strict";
import options from "./lib/options";
import { getReceiverSelector
, ReceiverSelection
, ReceiverSelector
, ReceiverSelectorMediaType
, ReceiverSelectorType } from "./receiver_selectors";
import StatusManager from "./StatusManager";
let sharedSelector: ReceiverSelector;
async function getSelector () {
return getReceiverSelector(
await options.get("receiverSelectorType"));
}
async function getSharedSelector () {
if (!sharedSelector) {
sharedSelector = await getSelector();
}
return sharedSelector;
}
/**
* 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 getSelection (
defaultMediaType =
ReceiverSelectorMediaType.Tab
, availableMediaTypes =
ReceiverSelectorMediaType.Tab
| ReceiverSelectorMediaType.Screen)
// | ReceiverSelectorMediaType.File)
: Promise<ReceiverSelection> {
return new Promise(async (resolve, reject) => {
/**
* Close any existing selector, and renew to minimize issues
* with bridge failing.
*/
await getSharedSelector();
if (sharedSelector.isOpen) {
sharedSelector.close();
}
sharedSelector = await getSelector();
sharedSelector.addEventListener("selected", ev => {
console.info("fx_cast (Debug): Selected receiver", ev.detail);
resolve(ev.detail);
});
sharedSelector.addEventListener("cancelled", ev => {
console.info("fx_cast (Debug): Cancelled receiver selection");
resolve(null);
});
sharedSelector.addEventListener("error", ev => {
console.error("fx_cast (Debug): Failed to select receiver");
reject();
});
sharedSelector.open(
StatusManager.getReceivers()
, defaultMediaType
, availableMediaTypes);
});
}
export default {
getSelection
, getSharedSelector
};

154
ext/src/StatusManager.ts Normal file
View File

@@ -0,0 +1,154 @@
"use strict";
import bridge from "./lib/bridge";
import options from "./lib/options";
import { TypedEventTarget } from "./lib/typedEvents";
import { Message, Receiver, ReceiverStatus } from "./types";
interface ReceiverStatusMessage extends Message {
subject: "receiverStatus";
data: {
id: string;
status: ReceiverStatus;
};
}
interface ServiceDownMessage extends Message {
subject: "shim:/serviceDown";
data: {
id: string;
};
}
interface ServiceUpMessage extends Message {
subject: "shim:/serviceUp";
data: Receiver;
}
interface EventMap {
"serviceUp": ServiceUpMessage["data"];
"serviceDown": ServiceDownMessage["data"];
"statusUpdate": ReceiverStatusMessage["data"];
}
// tslint:disable-next-line:new-parens
export default new class extends TypedEventTarget<EventMap> {
private bridgePort: browser.runtime.Port;
private receivers = new Map<string, Receiver>();
constructor () {
super();
// Bind listeners
this.onBridgePortMessage = this.onBridgePortMessage.bind(this);
this.onBridgePortDisconnect = this.onBridgePortDisconnect.bind(this);
this.initBridgePort();
}
public getReceivers () {
return Array.from(this.receivers.values());
}
private async initBridgePort () {
this.bridgePort = await bridge.connect();
this.bridgePort.onMessage.addListener(this.onBridgePortMessage);
this.bridgePort.onDisconnect.addListener(this.onBridgePortDisconnect);
this.bridgePort.postMessage({
subject: "bridge:/initialize"
, data: {
shouldWatchStatus: true
}
});
}
/**
* Handles incoming bridge status messages, manages the
* receiver list, and dispatches events.
*/
private onBridgePortMessage (message: Message) {
switch (message.subject) {
case "shim:/serviceUp": {
const { data: receiver } = (message as ServiceUpMessage);
this.receivers.set(receiver.id, receiver);
const serviceUpEvent = new CustomEvent("serviceUp", {
detail: receiver
});
this.dispatchEvent(serviceUpEvent);
break;
}
case "shim:/serviceDown": {
const { data: { id }} = (message as ServiceDownMessage);
if (this.receivers.has(id)) {
this.receivers.delete(id);
}
const serviceDownEvent = new CustomEvent("serviceDown", {
detail: { id }
});
this.dispatchEvent(serviceDownEvent);
break;
}
case "receiverStatus": {
const { data: { id, status }}
= (message as ReceiverStatusMessage);
const receiver = this.receivers.get(id);
// Merge with existing
this.receivers.set(id, {
...receiver
, status: {
...receiver.status
, ...status
}
});
}
}
}
/**
* Runs once the status bridge has disconnected. Sends
* serviceDown messages for all receivers to all shims to
* update receiver availability, then clears the receiver
* list.
*
* Attempts to reinitialize the status bridge after 10
* seconds. If it fails immediately, this handler will be
* triggered again and the timer is reset for another 10
* seconds.
*/
private onBridgePortDisconnect () {
for (const [, receiver] of this.receivers) {
const serviceDownEvent = new CustomEvent("serviceDown", {
detail: { id: receiver.id }
});
this.dispatchEvent(serviceDownEvent);
}
// Cleanup
this.receivers.clear();
this.bridgePort.onDisconnect.removeListener(
this.onBridgePortDisconnect);
this.bridgePort.onMessage.removeListener(this.onBridgePortMessage);
this.bridgePort = null;
window.setTimeout(async () => {
this.initBridgePort();
}, 10000);
}
};

278
ext/src/createMenus.ts Normal file
View File

@@ -0,0 +1,278 @@
"use strict";
import options from "./lib/options";
import { TypedEventTarget } from "./lib/typedEvents";
const _ = browser.i18n.getMessage;
const URL_PATTERN_HTTP = "http://*/*";
const URL_PATTERN_HTTPS = "https://*/*";
const URL_PATTERN_FILE = "file://*/*";
const URL_PATTERNS_REMOTE = [ URL_PATTERN_HTTP, URL_PATTERN_HTTPS ];
const URL_PATTERNS_ALL = [ ...URL_PATTERNS_REMOTE, URL_PATTERN_FILE ];
type MenuId = string | number;
let menuIdMediaCast: MenuId;
let menuIdMirroringCast: MenuId;
let menuIdWhitelist: MenuId;
let menuIdWhitelistRecommended: MenuId;
const whitelistChildMenuPatterns = new Map<MenuId, string>();
let hasCreatedMenus = false;
export default async function createMenus () {
if (!hasCreatedMenus) {
hasCreatedMenus = true;
const opts = await options.getAll();
// <video>/<audio> "Cast..." context menu item
menuIdMediaCast = await browser.menus.create({
contexts: [ "audio", "video" ]
, title: _("contextCast")
, visible: opts.mediaEnabled
, targetUrlPatterns: opts.localMediaEnabled
? URL_PATTERNS_ALL
: URL_PATTERNS_REMOTE
});
// Screen/Tab mirroring "Cast..." context menu item
menuIdMirroringCast = await browser.menus.create({
contexts: [ "browser_action", "page", "tools_menu" ]
, title: _("contextCast")
, visible: opts.mirroringEnabled
// Mirroring doesn't work from file:// urls
, documentUrlPatterns: URL_PATTERNS_REMOTE
});
menuIdWhitelist = await browser.menus.create({
contexts: [ "browser_action" ]
, title: _("contextAddToWhitelist")
, enabled: false
});
menuIdWhitelistRecommended = await browser.menus.create({
title: _("contextAddToWhitelistRecommended")
, parentId: menuIdWhitelist
});
await browser.menus.create({
type: "separator"
, parentId: menuIdWhitelist
});
}
return {
menuIdMediaCast
, menuIdMirroringCast
, menuIdWhitelist
, menuIdWhitelistRecommended
, whitelistChildMenuPatterns
};
}
options.addEventListener("changed", async ev => {
const alteredOpts = ev.detail;
const opts = await options.getAll();
if (alteredOpts.includes("mirroringEnabled")) {
browser.menus.update(menuIdMirroringCast, {
visible: opts.mirroringEnabled
});
}
if (alteredOpts.includes("mediaEnabled")) {
browser.menus.update(menuIdMediaCast, {
visible: opts.mediaEnabled
});
}
if (alteredOpts.includes("localMediaEnabled")) {
browser.menus.update(menuIdMediaCast, {
targetUrlPatterns: opts.localMediaEnabled
? URL_PATTERNS_ALL
: URL_PATTERNS_REMOTE
});
}
});
browser.menus.onClicked.addListener(async info => {
if (info.parentMenuItemId === menuIdWhitelist) {
const pattern = whitelistChildMenuPatterns.get(info.menuItemId);
const whitelist = await options.get("userAgentWhitelist");
// Add to whitelist and update options
whitelist.push(pattern);
await options.set("userAgentWhitelist", whitelist);
}
});
browser.menus.onShown.addListener(async info => {
// Only rebuild menus if whitelist menu present
// WebExt typings are broken again here, so ugly casting
const menuIds = info.menuIds as unknown as number[];
if (menuIds.includes(menuIdWhitelist as number)) {
return;
}
/**
* If page URL doesn't exist, we're not on a page and have
* nothing to whitelist, so disable the menu and return.
*/
if (!info.pageUrl) {
browser.menus.update(menuIdWhitelist, {
enabled: false
});
browser.menus.refresh();
return;
}
const url = new URL(info.pageUrl);
const urlHasOrigin = url.origin !== "null";
/**
* If the page URL doesn't have an origin, we're not on a
* remote page and have nothing to whitelist, so disable the
* menu and return.
*/
if (!urlHasOrigin) {
browser.menus.update(menuIdWhitelist, {
enabled: false
});
browser.menus.refresh();
return;
}
// Enable the whitelist menu
browser.menus.update(menuIdWhitelist, {
enabled: true
});
for (const [ menuId ] of whitelistChildMenuPatterns) {
// Clear all page-specific temporary menus
if (menuId !== menuIdWhitelistRecommended) {
browser.menus.remove(menuId);
}
whitelistChildMenuPatterns.delete(menuId);
}
// If there is more than one subdomain, get the base domain
const baseDomain = (url.host.match(/\./g) || []).length > 1
? url.host.substring(url.host.indexOf(".") + 1)
: url.host;
const patternRecommended = `${url.origin}/*`;
const patternSearch = `${url.origin}${url.pathname}${url.search}`;
const patternWildcardProtocol = `*://${url.host}/*`;
const patternWildcardSubdomain = `${url.protocol}//*.${baseDomain}/*`;
const patternWildcardProtocolAndSubdomain = `*://*.${baseDomain}/*`;
// Update recommended menu item
browser.menus.update(menuIdWhitelistRecommended, {
title: _("contextAddToWhitelistRecommended", patternRecommended)
});
whitelistChildMenuPatterns.set(
menuIdWhitelistRecommended, patternRecommended);
if (url.search) {
const whitelistSearchMenuId = await browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternSearch)
, parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
whitelistSearchMenuId, patternSearch);
}
/**
* Split URL path into segments and add menu items for each
* partial path as the segments are removed.
*/
{
const pathTrimmed = url.pathname.endsWith("/")
? url.pathname.substring(0, url.pathname.length - 1)
: url.pathname;
const pathSegments = pathTrimmed.split("/")
.filter(segment => segment)
.reverse();
if (pathSegments.length) {
let index = 0;
for (const pathSegment of pathSegments) {
const partialPath = pathSegments
.slice(index)
.reverse()
.join("/");
const pattern = `${url.origin}/${partialPath}/*`;
const partialPathMenuId = await browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", pattern)
, parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
partialPathMenuId, pattern);
index++;
}
}
}
const wildcardProtocolMenuId = await browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd"
, patternWildcardProtocol)
, parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardProtocolMenuId, patternWildcardProtocol);
const wildcardSubdomainMenuId = await browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd"
, patternWildcardSubdomain)
, parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardSubdomainMenuId, patternWildcardSubdomain);
const wildcardProtocolAndSubdomainMenuId = await browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd"
, patternWildcardProtocolAndSubdomain)
, parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardProtocolAndSubdomainMenuId
, patternWildcardProtocolAndSubdomain);
await browser.menus.refresh();
});

155
ext/src/createShim.ts Normal file
View File

@@ -0,0 +1,155 @@
"use strict";
import bridge from "./lib/bridge";
import loadSender from "./lib/loadSender";
import options from "./lib/options";
import { TypedEventTarget } from "./lib/typedEvents";
import { Message } from "./types";
import { ReceiverSelectorMediaType } from "./receiver_selectors";
import SelectorManager from "./SelectorManager";
import StatusManager from "./StatusManager";
export interface Shim {
bridgePort: browser.runtime.Port;
contentPort?: browser.runtime.Port;
contentTabId?: number;
contentFrameId?: number;
}
export default async function createShim (
port: browser.runtime.Port): Promise<Shim> {
const contentPort = port;
const contentTabId = port.sender.tab.id;
const contentFrameId = port.sender.frameId;
const bridgePort = await bridge.connect();
/**
* If either the bridge port or the content port disconnects,
* just teardown all communication.
*/
function onDisconnect () {
bridgePort.onMessage.removeListener(onBridgePortMessage);
contentPort.onMessage.removeListener(onContentPortMessage);
// Ensure all ports are disconnected
contentPort.disconnect();
bridgePort.disconnect();
}
bridgePort.onDisconnect.addListener(onDisconnect);
contentPort.onDisconnect.addListener(onDisconnect);
// Add listeners
bridgePort.onMessage.addListener(onBridgePortMessage);
contentPort.onMessage.addListener(onContentPortMessage);
function onBridgePortMessage (message: Message) {
contentPort.postMessage(message);
}
async function onContentPortMessage (message: Message) {
const [ destination ] = message.subject.split(":/");
if (destination === "bridge") {
bridgePort.postMessage(message);
}
switch (message.subject) {
case "main:/shimInitialized": {
for (const receiver of StatusManager.getReceivers()) {
contentPort.postMessage({
subject: "shim:/serviceUp"
, data: { id: receiver.id }
});
}
break;
}
case "main:/selectReceiverBegin": {
const allMediaTypes =
ReceiverSelectorMediaType.App
| ReceiverSelectorMediaType.Tab
| ReceiverSelectorMediaType.Screen
| ReceiverSelectorMediaType.File;
try {
const selection = await SelectorManager.getSelection(
ReceiverSelectorMediaType.App
, allMediaTypes);
// Handle cancellation
if (!selection) {
contentPort.postMessage({
subject: "shim:/selectReceiverCancelled"
});
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) {
contentPort.postMessage({
subject: "shim:/selectReceiverCancelled"
});
loadSender({
tabId: contentTabId
, frameId: contentFrameId
, selection
});
break;
}
// Pass selection back to shim
contentPort.postMessage({
subject: "shim:/selectReceiverEnd"
, data: selection
});
} catch (err) {
// TODO: Report errors properly
contentPort.postMessage({
subject: "shim:/selectReceiverCancelled"
});
}
break;
}
case "main:/sessionCreated": {
const selector = await SelectorManager.getSharedSelector();
if (selector.isOpen) {
selector.close();
}
break;
}
}
}
contentPort.postMessage({
subject: "shim:/initialized"
, data: await bridge.getInfo()
});
return {
bridgePort
, contentPort
, contentTabId
, contentFrameId
};
}

View File

@@ -1,29 +1,10 @@
"use strict";
import { Options } from "./lib/options";
import { ReceiverSelectorType } from "./receiver_selectors";
export interface Options {
bridgeApplicationName: string;
mediaEnabled: boolean;
mediaSyncElement: boolean;
mediaStopOnUnload: boolean;
localMediaEnabled: boolean;
localMediaServerPort: number;
mirroringEnabled: boolean;
mirroringAppId: string;
receiverSelectorType: ReceiverSelectorType;
// TODO: Implement
receiverSelectorCloseIfFocusLost: boolean;
receiverSelectorWaitForConnection: boolean;
userAgentWhitelistEnabled: boolean;
userAgentWhitelist: string[];
[key: string]: Options[keyof Options];
}
const options: Options = {
export default {
bridgeApplicationName: APPLICATION_NAME
, mediaEnabled: true
, mediaSyncElement: false
@@ -39,6 +20,4 @@ const options: Options = {
, userAgentWhitelist: [
"https://www.netflix.com/*"
]
};
export default options;
} as Options;

4
ext/src/global.d.ts vendored
View File

@@ -37,8 +37,8 @@ declare interface RTCPeerConnection {
}
declare interface MediaDevices {
getDisplayMedia (constraints: MediaStreamConstraints)
: Promise<MediaStream>;
getDisplayMedia (constraints: MediaStreamConstraints)
: Promise<MediaStream>;
}

View File

@@ -6,6 +6,23 @@ import nativeMessaging from "./nativeMessaging";
import options from "./options";
async function connect (): Promise<browser.runtime.Port> {
const applicationName = await options.get("bridgeApplicationName");
const bridgePort = nativeMessaging.connectNative(applicationName);
bridgePort.onDisconnect.addListener(() => {
if (bridgePort.error) {
console.error(`${applicationName} disconnected:`
, this.bridgePort.error.message);
} else {
console.info(`${applicationName} disconnected`);
}
});
return bridgePort;
}
export interface BridgeInfo {
name: string;
version: string;
@@ -16,7 +33,7 @@ export interface BridgeInfo {
isVersionNewer: boolean;
}
export default async function getBridgeInfo (): Promise<BridgeInfo> {
async function getInfo (): Promise<BridgeInfo> {
const applicationName = await options.get("bridgeApplicationName");
let applicationVersion: string;
@@ -65,3 +82,9 @@ export default async function getBridgeInfo (): Promise<BridgeInfo> {
, isVersionNewer
};
}
export default {
connect
, getInfo
};

52
ext/src/lib/loadSender.ts Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
import { stringify } from "./utils";
import { ReceiverSelection
, ReceiverSelectorMediaType } from "../receiver_selectors";
interface LoadSenderOptions {
tabId: number;
frameId: number;
selection: ReceiverSelection;
}
/**
* Loads the appropriate sender for a given receiver
* selector response.
*/
export default async function loadSender (opts: LoadSenderOptions) {
// Cancelled
if (!opts.selection) {
return;
}
switch (opts.selection.mediaType) {
case ReceiverSelectorMediaType.Tab:
case ReceiverSelectorMediaType.Screen: {
await browser.tabs.executeScript(opts.tabId, {
code: stringify`
window.selectedMedia = ${opts.selection.mediaType};
window.selectedReceiver = ${opts.selection.receiver};
`
, frameId: opts.frameId
});
await browser.tabs.executeScript(opts.tabId, {
file: "senders/mirroringCast.js"
, frameId: opts.frameId
});
break;
}
case ReceiverSelectorMediaType.File: {
const fileUrl = new URL(`file://${opts.selection.filePath}`);
const mediaSession = await mediaCasting.loadMediaUrl(
fileUrl.href, opts.selection.receiver);
break;
}
}
}

View File

@@ -0,0 +1,81 @@
"use strict";
import cast, { ensureInit } from "../shim/export";
import options from "./options";
import { Receiver } from "../types";
function getMediaSession (
receiver?: Receiver): Promise<cast.Session> {
return new Promise(async (resolve, reject) => {
await ensureInit();
/**
* If a receiver is available, call requestSession. If a
* specific receiver was specified, bypass receiver selector
* and create session directly.
*/
function receiverListener (availability: string) {
if (availability === cast.ReceiverAvailability.AVAILABLE) {
if (receiver) {
cast._requestSession(receiver, resolve, reject);
} else {
cast.requestSession(resolve, reject);
}
}
}
const sessionRequest = new cast.SessionRequest(
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID);
const apiConfig = new cast.ApiConfig(
sessionRequest
, null // sessionListener
, receiverListener); // receiverListener
cast.initialize(apiConfig);
});
}
function loadMediaUrl (
mediaUrl: string
, receiver: Receiver): Promise<cast.Session> {
return new Promise(async (resolve, reject) => {
const isLocalMedia = mediaUrl.startsWith("file://");
const isLocalMediaEnabled = await options.get("localMediaEnabled");
if (isLocalMedia && !isLocalMediaEnabled) {
console.error("fx_cast (Debug): Local media casting not enabled");
return;
}
const mediaUrlObject = new URL(mediaUrl);
const mediaInfo = new cast.media.MediaInfo(mediaUrlObject.href, null);
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
mediaInfo.metadata.title = mediaUrlObject.pathname;
const mediaSession = await getMediaSession(receiver);
const loadRequest = new cast.media.LoadRequest(mediaInfo);
loadRequest.autoplay = false;
mediaSession.loadMedia(loadRequest
, null // successCallback
, () => { reject(); }); // errorCallback
});
}
export default {
getMediaSession
, loadMediaUrl
};

View File

@@ -1,35 +0,0 @@
"use strict";
import { Message } from "../types";
interface Details {
tabId: number;
frameId: number;
}
type SenderCallback = (message: Message, details: Details) => void;
const routeMap = new Map<string, SenderCallback>();
function register (routeName: string, senderCallback: SenderCallback) {
routeMap.set(routeName, senderCallback);
}
function deregister (routeName: string) {
routeMap.delete(routeName);
}
function handleMessage (message: Message, details?: Details) {
const destination = message.subject.split(":")[0];
if (routeMap.has(destination)) {
routeMap.get(destination)(message, details);
}
}
export default {
register
, deregister
, handleMessage
};

View File

@@ -1,79 +1,149 @@
"use strict";
import defaultOptions, { Options } from "../defaultOptions";
import defaultOptions from "../defaultOptions";
import { Message } from "../types";
import { TypedEventTarget } from "./typedEvents";
/**
* Fetches `options` key from storage and returns it as
* Options interface type.
*/
async function getAll (): Promise<Options> {
const { options }: { options: Options } =
await browser.storage.sync.get("options");
export interface Options {
bridgeApplicationName: string;
mediaEnabled: boolean;
mediaSyncElement: boolean;
mediaStopOnUnload: boolean;
localMediaEnabled: boolean;
localMediaServerPort: number;
mirroringEnabled: boolean;
mirroringAppId: string;
userAgentWhitelistEnabled: boolean;
userAgentWhitelist: string[];
return options;
[key: string]: Options[keyof Options];
}
/**
* Takes Options object and sets to `options` storage key.
* Returns storage promise.
*/
async function setAll (options: Options): Promise<void> {
return browser.storage.sync.set({ options });
interface EventMap {
"changed": Array<keyof Options>;
}
/**
* Gets specific option from storage and returns it as its
* type from Options interface type.
*/
async function get<T extends keyof Options> (name: T): Promise<Options[T]> {
const options = await getAll();
// tslint:disable-next-line:new-parens
export default new class extends TypedEventTarget<EventMap> {
constructor () {
super();
if (options.hasOwnProperty(name)) {
return options[name];
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync") {
return;
}
// Types issue
const _changes = changes as {
[key: string]: browser.storage.StorageChange
};
if ("options" in _changes) {
const { oldValue, newValue } = _changes.options;
const changedKeys = [];
for (const key in newValue) {
// Don't track added keys
if (!(key in oldValue)) {
continue;
}
const oldKeyValue = oldValue[key];
const newKeyValue = newValue[key];
// Equality comparison
if (oldKeyValue === newKeyValue) {
continue;
}
// Array comparison
if (oldKeyValue instanceof Array
&& newKeyValue instanceof Array) {
if (oldKeyValue.length === newKeyValue.length
&& oldKeyValue.every((value, index) =>
value === newKeyValue[index])) {
continue;
}
}
changedKeys.push(key);
}
this.dispatchEvent(new CustomEvent("changed", {
detail: changedKeys
}));
}
});
}
}
/**
* Sets specific option to storage. Returns storage
* promise.
*/
async function set<T extends keyof Options> (
name: T
, value: Options[T]): Promise<void> {
/**
* Fetches `options` key from storage and returns it as
* Options interface type.
*/
public async getAll (): Promise<Options> {
const { options }: { options: Options } =
await browser.storage.sync.get("options");
const options = await getAll();
options[name] = value;
return setAll(options);
}
return options;
}
/**
* Takes Options object and sets to `options` storage key.
* Returns storage promise.
*/
public async setAll (options: Options): Promise<void> {
return browser.storage.sync.set({ options });
}
/**
* Gets existing options from storage and compares it
* against defaults. Any options in defaults and not in
* storage are set. Does not override any existing options.
*/
async function update (defaults = defaultOptions): Promise<void> {
const oldOpts = await getAll();
const newOpts: Partial<Options> = {};
/**
* Gets specific option from storage and returns it as its
* type from Options interface type.
*/
public async get<T extends keyof Options> (name: T): Promise<Options[T]> {
const options = await this.getAll();
// Find options not already in storage
for (const [ optName, optVal ] of Object.entries(defaults)) {
if (!oldOpts.hasOwnProperty(optName)) {
newOpts[optName] = optVal;
if (options.hasOwnProperty(name)) {
return options[name];
}
}
// Update storage with default values of new options
return setAll({
...oldOpts
, ...newOpts
});
}
/**
* Sets specific option to storage. Returns storage
* promise.
*/
public async set<T extends keyof Options> (
name: T
, value: Options[T]): Promise<void> {
const options = await this.getAll();
options[name] = value;
return this.setAll(options);
}
export default {
get, getAll
, set, setAll
, update
/**
* Gets existing options from storage and compares it
* against defaults. Any options in defaults and not in
* storage are set. Does not override any existing options.
*/
public async update (defaults = defaultOptions): Promise<void> {
const oldOpts = await this.getAll();
const newOpts: Partial<Options> = {};
// Find options not already in storage
for (const [ optName, optVal ] of Object.entries(defaults)) {
if (!oldOpts.hasOwnProperty(optName)) {
newOpts[optName] = optVal;
}
}
// Update storage with default values of new options
return this.setAll({
...oldOpts
, ...newOpts
});
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
export interface TypedEvents {
[key: string]: any;
}
export class TypedEventTarget<T extends TypedEvents> extends EventTarget {
public addEventListener<K extends keyof T> (
type: K, listener: (ev: CustomEvent<T[K]>) => void): void {
super.addEventListener(type as string, listener);
}
public removeEventListener<K extends keyof T> (
type: K, listener: (ev: CustomEvent<T[K]>) => void): void {
super.removeEventListener(type as string, listener);
}
public dispatchEvent<K extends keyof T> (ev: CustomEvent<T[K]>): boolean {
return super.dispatchEvent(ev);
}
}

View File

@@ -9,6 +9,28 @@ export function getNextEllipsis (ellipsis: string): string {
/* tslint:enable:curly */
}
/**
* Template literal tag function, JSON-encodes substitutions.
*/
export function stringify (
templateStrings: TemplateStringsArray
, ...substitutions: any[]) {
let formattedString = "";
for (const templateString of templateStrings) {
if (!formattedString) {
formattedString += templateString;
continue;
}
formattedString += JSON.stringify(substitutions.shift());
formattedString += templateString;
}
return formattedString;
}
interface WindowCenteredProps {
width: number;

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,15 @@
"scripts": [ "main.js" ]
}
, "content_scripts": [
{
"all_frames": true
, "js": [ "shim/content.js" ]
, "matches": [ "<all_urls>" ]
, "run_at": "document_start"
}
]
, "content_security_policy": "CONTENT_SECURITY_POLICY"
, "default_locale": "en"
, "manifest_version": 2

View File

@@ -1,40 +0,0 @@
"use strict";
import { Message, Receiver, ReceiverStatus } from "./types";
export interface ReceiverStatusMessage extends Message {
subject: "receiverStatus";
data: {
id: string;
status: ReceiverStatus;
};
}
export interface ServiceDownMessage extends Message {
subject: "shim:/serviceDown";
data: {
id: string;
};
}
export interface ServiceUpMessage extends Message {
subject: "shim:/serviceUp";
data: Receiver;
}
export interface NativeReceiverSelectorSelectedMessage extends Message {
subject: "main:/receiverSelector/selected";
data: Receiver;
}
export interface NativeReceiverSelectorCloseMessage extends Message {
subject: "main:/receiverSelector/error";
data: string;
}
export interface NativeReceiverSelectorErrorMessage extends Message {
subject: "main:/receiverSelector/error";
data: string;
}

View File

@@ -1,39 +1,56 @@
"use strict";
import nativeMessaging from "../lib/nativeMessaging";
import bridge from "../lib/bridge";
import options from "../lib/options";
import ReceiverSelector, {
ReceiverSelectorMediaType } from "./ReceiverSelector";
ReceiverSelection
, ReceiverSelectorEvents
, ReceiverSelectorMediaType } from "./ReceiverSelector";
import { TypedEventTarget } from "../lib/typedEvents";
import { getWindowCenteredProps } from "../lib/utils";
import { Message, Receiver } from "../types";
import { NativeReceiverSelectorCloseMessage
, NativeReceiverSelectorErrorMessage
, NativeReceiverSelectorSelectedMessage } from "../messageTypes";
const _ = browser.i18n.getMessage;
interface NativeReceiverSelectorSelectedMessage extends Message {
subject: "main:/receiverSelector/selected";
data: ReceiverSelection;
}
interface NativeReceiverSelectorCloseMessage extends Message {
subject: "main:/receiverSelector/error";
data: string;
}
interface NativeReceiverSelectorErrorMessage extends Message {
subject: "main:/receiverSelector/error";
data: string;
}
// TODO: Figure out lifetime properly
export default class NativeMacReceiverSelector
extends EventTarget
extends TypedEventTarget<ReceiverSelectorEvents>
implements ReceiverSelector {
private bridgePort: browser.runtime.Port;
private bridgePortDisconnected: boolean = false;
private wasReceiverSelected: boolean = false;
private _isOpen: boolean = false;
get isOpen () {
return this._isOpen;
}
public async open (
receivers: Receiver[]
, defaultMediaType: ReceiverSelectorMediaType
, availableMediaTypes: ReceiverSelectorMediaType): Promise<void> {
const applicationName = await options.get("bridgeApplicationName");
this.bridgePort = nativeMessaging.connectNative(applicationName);
this.bridgePort = await bridge.connect();
this.bridgePort.onMessage.addListener((message: Message) => {
switch (message.subject) {
@@ -56,7 +73,9 @@ export default class NativeMacReceiverSelector
});
this.bridgePort.onDisconnect.addListener(() => {
this.bridgePortDisconnected = true;
this.bridgePort = null;
this.wasReceiverSelected = false;
this._isOpen = false;
});
@@ -84,14 +103,18 @@ export default class NativeMacReceiverSelector
, i18n_mediaSelectToLabel: _("popupMediaSelectToLabel")
})
});
this._isOpen = true;
}
public close (): void {
if (this.bridgePort && !this.bridgePortDisconnected) {
if (this.bridgePort) {
this.bridgePort.postMessage({
subject: "bridge:/receiverSelector/close"
});
}
this._isOpen = false;
}
@@ -115,12 +138,12 @@ export default class NativeMacReceiverSelector
this.dispatchEvent(new CustomEvent("cancelled"));
}
if (!this.bridgePortDisconnected) {
if (this.bridgePort) {
this.bridgePort.disconnect();
}
this.bridgePort = null;
this.bridgePortDisconnected = false;
this.wasReceiverSelected = false;
this._isOpen = false;
}
}

View File

@@ -1,19 +1,23 @@
"use strict";
import ReceiverSelector, {
ReceiverSelectorMediaType } from "./ReceiverSelector";
ReceiverSelectorEvents
, ReceiverSelectorMediaType } from "./ReceiverSelector";
import { TypedEventTarget } from "../lib/typedEvents";
import { getWindowCenteredProps } from "../lib/utils";
import { Message, Receiver } from "../types";
export default class PopupReceiverSelector
extends EventTarget
extends TypedEventTarget<ReceiverSelectorEvents>
implements ReceiverSelector {
private windowId: number;
private openerWindowId: number;
private messagePort: browser.runtime.Port;
private messagePortDisconnected: boolean;
private receivers: Receiver[];
private defaultMediaType: ReceiverSelectorMediaType;
@@ -21,6 +25,8 @@ export default class PopupReceiverSelector
private wasReceiverSelected: boolean = false;
private _isOpen: boolean = false;
constructor () {
super();
@@ -48,6 +54,9 @@ export default class PopupReceiverSelector
this.messagePort = port;
this.messagePort.onMessage.addListener(this.onPopupMessage);
this.messagePort.onDisconnect.addListener(() => {
this.messagePortDisconnected = true;
});
this.messagePort.postMessage({
subject: "popup:/populateReceiverList"
@@ -60,6 +69,9 @@ export default class PopupReceiverSelector
});
}
get isOpen () {
return this._isOpen;
}
public async open (
receivers: Receiver[]
@@ -85,6 +97,8 @@ export default class PopupReceiverSelector
, ...centeredProps
});
this._isOpen = true;
this.windowId = popup.id;
this.openerWindowId = openerWindow.id;
@@ -98,8 +112,16 @@ export default class PopupReceiverSelector
this.onWindowsFocusChanged);
}
public close (): void {
browser.windows.remove(this.windowId);
public async close (): Promise<void> {
if (this.windowId) {
await browser.windows.remove(this.windowId);
}
this._isOpen = false;
if (this.messagePort && !this.messagePortDisconnected) {
this.messagePort.disconnect();
}
}

View File

@@ -1,5 +1,6 @@
"use strict";
import { TypedEventTarget } from "../lib/typedEvents";
import { Receiver } from "../types";
@@ -16,12 +17,18 @@ export interface ReceiverSelection {
filePath?: string;
}
export type ReceiverSelectorSelectedEvent = CustomEvent<ReceiverSelection>;
export type ReceiverSelectorErrorEvent = CustomEvent;
export type ReceiverSelectorCancelledEvent = CustomEvent;
export interface ReceiverSelectorEvents {
"selected": ReceiverSelection;
"error": void;
"cancelled": void;
}
export default interface ReceiverSelector
extends TypedEventTarget<ReceiverSelectorEvents> {
readonly isOpen: boolean;
export default interface ReceiverSelector extends EventTarget {
open (receivers: Receiver[]
, defaultMediaType: ReceiverSelectorMediaType
, availableMediaTypes: ReceiverSelectorMediaType): void;

View File

@@ -6,12 +6,16 @@ import PopupReceiverSelector
from "./PopupReceiverSelector";
export { ReceiverSelection
, ReceiverSelectorCancelledEvent
, ReceiverSelectorErrorEvent
, ReceiverSelectorMediaType
, ReceiverSelectorSelectedEvent } from "./ReceiverSelector";
import { ReceiverSelection
, ReceiverSelectorMediaType } from "./ReceiverSelector";
type ReceiverSelector = ReturnType<typeof getReceiverSelector>;
export {
ReceiverSelector
, ReceiverSelection
, ReceiverSelectorMediaType
};
export enum ReceiverSelectorType {
Popup

View File

@@ -1,17 +1,22 @@
"use strict";
import { Options } from "../defaultOptions";
import cast, { init } from "../shim/export";
import mediaCasting from "../lib/mediaCasting";
import options from "../lib/options";
import cast, { ensureInit } from "../shim/export";
import { Message, Receiver } from "../types";
// Variables passed from background
const { srcUrl
const { selectedReceiver
, srcUrl
, targetElementId }
: { srcUrl: string
: { selectedReceiver: Receiver
, srcUrl: string
, targetElementId: number } = (window as any);
let options: Options;
let backgroundPort: browser.runtime.Port;
let session: cast.Session;
let currentMedia: cast.media.Media;
@@ -24,16 +29,13 @@ const isLocalFile = srcUrl.startsWith("file:");
const mediaElement = browser.menus.getTargetElement(
targetElementId) as HTMLMediaElement;
window.addEventListener("beforeunload", () => {
browser.runtime.sendMessage({
window.addEventListener("beforeunload", async () => {
backgroundPort.postMessage({
subject: "bridge:/mediaServer/stop"
});
if (options.mediaStopOnUnload) {
if (await options.get("mediaStopOnUnload")) {
session.stop(null, null);
/*currentMedia.stop(null
, onMediaStopSuccess
, onMediaStopError);*/
}
});
@@ -55,42 +57,63 @@ function getLocalAddress () {
}
async function onRequestSessionSuccess (newSession: cast.Session) {
cast.logMessage("onRequestSessionSuccess");
session = newSession;
let mediaUrl = new URL(srcUrl);
const port = options.localMediaServerPort;
if (isLocalFile) {
await new Promise((resolve, reject) => {
browser.runtime.sendMessage({
subject: "bridge:/mediaServer/start"
, data: {
filePath: decodeURI(mediaUrl.pathname)
, port
}
});
browser.runtime.onMessage.addListener(function onMessage (message) {
if (message.subject === "mediaCast:/mediaServer/started") {
browser.runtime.onMessage.removeListener(onMessage);
resolve();
}
});
function startMediaServer (filePath: string, port: number) {
return new Promise((resolve, reject) => {
backgroundPort.postMessage({
subject: "bridge:/mediaServer/start"
, data: {
filePath: decodeURI(filePath)
, port
}
});
// Address of local HTTP server
mediaUrl = new URL(`http://${await getLocalAddress()}:${port}/`);
backgroundPort.onMessage.addListener(
function onMessage (message: Message) {
switch (message.subject) {
case "mediaCast:/mediaServer/started": {
backgroundPort.onMessage.removeListener(onMessage);
resolve();
}
case "mediaCast:/mediaServer/error": {
backgroundPort.onMessage.removeListener(onMessage);
reject();
}
}
});
});
}
async function loadMedia () {
let mediaUrl = new URL(srcUrl);
const mediaTitle = mediaUrl.pathname;
/**
* If the media is a local file, start an HTTP media server
* and change the media URL to point to it.
*/
if (isLocalFile) {
const host = await getLocalAddress();
const port = await options.get("localMediaServerPort");
try {
// Wait until media server is listening
await startMediaServer(mediaUrl.pathname, port);
} catch (err) {
console.error("Failed to start media server");
return;
}
mediaUrl = new URL(`http://${host}:${port}/`);
}
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, null);
// Media metadata (title/poster)
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
mediaInfo.metadata.title = mediaUrl.pathname;
mediaInfo.metadata.title = mediaTitle;
if (mediaElement instanceof HTMLVideoElement && mediaElement.poster) {
mediaInfo.metadata.images = [
@@ -164,41 +187,13 @@ async function onRequestSessionSuccess (newSession: cast.Session) {
, onLoadMediaError);
}
function onRequestSessionError () {
cast.logMessage("onRequestSessionError");
}
function sessionListener (newSession: cast.Session) {
cast.logMessage("sessionListener");
}
function receiverListener (availability: string) {
cast.logMessage("receiverListener");
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
onRequestSessionSuccess
, onRequestSessionError);
}
}
function onInitializeSuccess () {
cast.logMessage("onInitializeSuccess");
}
function onInitializeError () {
cast.logMessage("onInitializeError");
}
function onLoadMediaSuccess (media: cast.media.Media) {
async function onLoadMediaSuccess (media: cast.media.Media) {
cast.logMessage("onLoadMediaSuccess");
currentMedia = media;
if (options.mediaSyncElement) {
if (await options.get("mediaSyncElement")) {
mediaElement.addEventListener("play", () => {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
@@ -312,73 +307,57 @@ function onLoadMediaSuccess (media: cast.media.Media) {
}
}
function onRequestSessionError () {
cast.logMessage("onRequestSessionError");
}
function sessionListener (newSession: cast.Session) {
cast.logMessage("sessionListener");
}
function onInitializeSuccess () {
cast.logMessage("onInitializeSuccess");
}
function onInitializeError () {
cast.logMessage("onInitializeError");
}
function onLoadMediaError () {
cast.logMessage("onLoadMediaError");
}
/* play */
function onMediaPlaySuccess () {
cast.logMessage("onMediaPlaySuccess");
}
function onMediaPlayError (err: cast.Error) {
cast.logMessage("onMediaPlayError");
}
/* pause */
function onMediaPauseSuccess () {
cast.logMessage("onMediaPauseSuccess");
}
function onMediaPauseError (err: cast.Error) {
cast.logMessage("onMediaPauseError");
}
/* stop */
function onMediaStopSuccess () {
cast.logMessage("onMediaStopSuccess");
}
function onMediaStopError (err: cast.Error) {
cast.logMessage("onMediaStopError");
}
/* seek */
function onMediaSeekSuccess () {
cast.logMessage("onMediaSeekSuccess");
}
function onMediaSeekError (err: cast.Error) {
cast.logMessage("onMediaSeekError");
}
init().then(async bridgeInfo => {
if (!bridgeInfo.isVersionCompatible) {
console.error("__onGCastApiAvailable error");
return;
}
ensureInit().then(async (port) => {
backgroundPort = port;
options = (await browser.storage.sync.get("options")).options;
if (isLocalFile && !options.localMediaEnabled) {
const isLocalMediaEnabled = await options.get("localMediaEnabled");
if (isLocalFile && !isLocalMediaEnabled) {
cast.logMessage("Local media casting not enabled");
return;
}
session = await mediaCasting.getMediaSession(selectedReceiver);
const sessionRequest = new cast.SessionRequest(
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID);
const apiConfig = new cast.ApiConfig(sessionRequest
, sessionListener
, receiverListener);
cast.initialize(apiConfig
, onInitializeSuccess
, onInitializeError);
loadMedia();
});

View File

@@ -1,15 +1,19 @@
"use strict";
import options from "../lib/options";
import cast, { init } from "../shim/export";
import cast, { ensureInit } from "../shim/export";
import { ReceiverSelectorMediaType }
from "../receiver_selectors/ReceiverSelector";
import { Receiver } from "../types";
// Variables passed from background
const { selectedMedia }
: { selectedMedia: ReceiverSelectorMediaType } = (window as any);
const { selectedMedia
, selectedReceiver }
: { selectedMedia: ReceiverSelectorMediaType
, selectedReceiver: Receiver } = (window as any);
const FX_CAST_RECEIVER_APP_NAMESPACE = "urn:x-cast:fx_cast";
@@ -42,6 +46,10 @@ if (typeof navigator.mediaDevices.getDisplayMedia === "undefined") {
* receiver device.
*/
function sendAppMessage (subject: string, data: any) {
if (!session) {
return;
}
session.sendMessage(FX_CAST_RECEIVER_APP_NAMESPACE, {
subject
, data
@@ -54,9 +62,7 @@ window.addEventListener("beforeunload", () => {
});
async function onRequestSessionSuccess (
newSession: cast.Session
, newSelectedMedia: ReceiverSelectorMediaType) {
async function onRequestSessionSuccess (newSession: cast.Session) {
cast.logMessage("onRequestSessionSuccess");
@@ -83,7 +89,7 @@ async function onRequestSessionSuccess (
sendAppMessage("iceCandidate", ev.candidate);
});
switch (newSelectedMedia) {
switch (selectedMedia) {
case ReceiverSelectorMediaType.Tab: {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
@@ -161,8 +167,9 @@ function receiverListener (availability: string) {
if (availability === cast.ReceiverAvailability.AVAILABLE) {
wasSessionRequested = true;
cast.requestSession(
onRequestSessionSuccess
cast._requestSession(
selectedReceiver
, onRequestSessionSuccess
, onRequestSessionError);
}
}
@@ -182,13 +189,7 @@ function onInitializeError () {
}
init().then(async bridgeInfo => {
if (!bridgeInfo.isVersionCompatible) {
console.error("__onGCastApiAvailable error");
return;
}
ensureInit().then(async () => {
const mirroringAppId = await options.get("mirroringAppId");
const sessionRequest = new cast.SessionRequest(mirroringAppId);
@@ -196,9 +197,7 @@ init().then(async bridgeInfo => {
sessionRequest
, sessionListener
, receiverListener
, undefined, undefined
, selectedMedia
, availableMediaTypes);
, undefined, undefined);
cast.initialize(apiConfig
, onInitializeSuccess

View File

@@ -6,9 +6,6 @@ import SessionRequest from "./SessionRequest";
import { AutoJoinPolicy
, DefaultActionPolicy } from "../enums";
import { ReceiverSelectorMediaType }
from "../../../receiver_selectors/ReceiverSelector";
export default class ApiConfig {
public additionalSessionRequests: any[] = [];
@@ -23,15 +20,5 @@ export default class ApiConfig {
, public autoJoinPolicy: string
= AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED
, public defaultActionPolicy: string
= DefaultActionPolicy.CREATE_SESSION
// TODO: Remove awful hack for mirror casting
, public _defaultMediaType: ReceiverSelectorMediaType
= ReceiverSelectorMediaType.App
, public _availableMediaTypes: ReceiverSelectorMediaType
= ReceiverSelectorMediaType.App
| ReceiverSelectorMediaType.Tab
| ReceiverSelectorMediaType.Screen
| ReceiverSelectorMediaType.File) {
}
= DefaultActionPolicy.CREATE_SESSION) {}
}

View File

@@ -4,7 +4,7 @@ import ApiConfig from "./classes/ApiConfig";
import DialRequest from "./classes/DialRequest";
import Error_ from "./classes/Error";
import Image_ from "./classes/Image";
import Receiver from "./classes/Receiver";
import Receiver_ from "./classes/Receiver";
import ReceiverDisplayStatus from "./classes/ReceiverDisplayStatus";
import SenderApplication from "./classes/SenderApplication";
import Session from "./classes/Session";
@@ -26,18 +26,18 @@ import { AutoJoinPolicy
import * as media from "./media";
import { ReceiverSelectorMediaType }
from "../../receiver_selectors/ReceiverSelector";
from "../../receiver_selectors/ReceiverSelector";
import { Receiver } from "../../types";
import { onMessage, sendMessageResponse } from "../eventMessageChannel";
type ReceiverActionListener = (
receiver: Receiver
receiver: Receiver_
, receiverAction: string) => void;
type RequestSessionSuccessCallback = (
session: Session
, selectedMedia: ReceiverSelectorMediaType) => void;
type RequestSessionSuccessCallback = (session: Session) => void;
type SuccessCallback = () => void;
type ErrorCallback = (err: Error_) => void;
@@ -61,12 +61,13 @@ export {
, SenderPlatform, SessionStatus, VolumeControlType
// Classes
, ApiConfig, DialRequest, Receiver, ReceiverDisplayStatus
, ApiConfig, DialRequest, ReceiverDisplayStatus
, SenderApplication, Session, SessionRequest, Timeout
, Volume
, Error_ as Error
, Image_ as Image
, Receiver_ as Receiver
, media
};
@@ -84,14 +85,17 @@ export function addReceiverActionListener (
export function initialize (
newApiConfig: ApiConfig
, successCallback: SuccessCallback
, errorCallback: ErrorCallback): void {
, successCallback?: SuccessCallback
, errorCallback?: ErrorCallback): void {
console.info("fx_cast (Debug): cast.initialize");
// Already initialized
if (apiConfig) {
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER));
if (errorCallback) {
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER));
}
return;
}
@@ -106,7 +110,9 @@ export function initialize (
? ReceiverAvailability.AVAILABLE
: ReceiverAvailability.UNAVAILABLE);
successCallback();
if (successCallback) {
successCallback();
}
}
export function logMessage (message: string): void {
@@ -125,29 +131,37 @@ export function removeReceiverActionListener (
}
export function requestSession (
successCallback: RequestSessionSuccessCallback
, errorCallback: ErrorCallback
, sessionRequest: SessionRequest
= apiConfig.sessionRequest): void {
successCallback?: RequestSessionSuccessCallback
, errorCallback?: ErrorCallback
, sessionRequest: SessionRequest = apiConfig.sessionRequest): void {
console.info("fx_cast (Debug): cast.requestSession");
// Called before initialization
if (!apiConfig) {
errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED));
if (errorCallback) {
errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED));
}
return;
}
// Already requesting session
if (sessionRequestInProgress) {
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER
, "Session request already in progress."));
if (errorCallback) {
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER
, "Session request already in progress."));
}
return;
}
// No available receivers
if (!receiverList.length) {
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
if (errorCallback) {
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
}
return;
}
@@ -159,21 +173,94 @@ export function requestSession (
// Open destination chooser
sendMessageResponse({
subject: "main:/selectReceiverBegin"
, data: {
defaultMediaType: apiConfig._defaultMediaType
, availableMediaTypes: apiConfig._availableMediaTypes
}
});
}
export function _requestSession (
_receiver: Receiver
, successCallback?: RequestSessionSuccessCallback
, errorCallback?: ErrorCallback): void {
console.info("fx_cast (Debug): cast._requestSession");
if (!apiConfig) {
if (errorCallback) {
errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED));
}
return;
}
if (sessionRequestInProgress) {
if (errorCallback) {
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER
, "Session request already in progress."));
}
return;
}
if (!receiverList.length) {
if (errorCallback) {
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
}
return;
}
sessionRequestInProgress = true;
sessionSuccessCallback = successCallback;
sessionErrorCallback = errorCallback;
const selectedReceiver = new Receiver_(
_receiver.id
, _receiver.friendlyName);
(selectedReceiver as any)._address = _receiver.host;
(selectedReceiver as any)._port = _receiver.port;
function createSession () {
sessionList.push(new Session(
sessionList.length.toString() // sessionId
, apiConfig.sessionRequest.appId // appId
, _receiver.friendlyName // displayName
, [] // appImages
, selectedReceiver // receiver
, (session: Session) => {
sendMessageResponse({
subject: "main:/sessionCreated"
});
sessionRequestInProgress = false;
if (sessionSuccessCallback) {
sessionSuccessCallback(session);
}
}));
}
// If an existing session is active, stop it and start new one
if (sessionList.length) {
const lastSession = sessionList[sessionList.length - 1];
if (lastSession.status !== SessionStatus.STOPPED) {
lastSession.stop(createSession, null);
}
} else {
createSession();
}
}
export function requestSessionById (sessionId: string): void {
console.info("STUB :: cast.requestSessionById");
}
export function setCustomReceivers (
receivers: Receiver[]
, successCallback: SuccessCallback
, errorCallback: ErrorCallback): void {
receivers: Receiver_[]
, successCallback?: SuccessCallback
, errorCallback?: ErrorCallback): void {
console.info("STUB :: cast.setCustomReceivers");
}
@@ -240,7 +327,7 @@ onMessage(async message => {
case "shim:/selectReceiverEnd": {
console.info("fx_cast (Debug): Selected receiver");
const selectedReceiver = new Receiver(
const selectedReceiver = new Receiver_(
message.data.receiver.id
, message.data.receiver.friendlyName);
@@ -261,9 +348,9 @@ onMessage(async message => {
sessionRequestInProgress = false;
sessionSuccessCallback(
session
, message.data.mediaType);
if (sessionSuccessCallback) {
sessionSuccessCallback(session);
}
}));
}
@@ -287,7 +374,10 @@ onMessage(async message => {
case "shim:/selectReceiverCancelled": {
if (sessionRequestInProgress) {
sessionRequestInProgress = false;
sessionErrorCallback(new Error_(ErrorCode.CANCEL));
if (sessionErrorCallback) {
sessionErrorCallback(new Error_(ErrorCode.CANCEL));
}
}
break;

View File

@@ -1,7 +1,7 @@
"use strict";
import { CAST_LOADER_SCRIPT_URL
, CAST_SCRIPT_URLS } from "../endpoints";
, CAST_SCRIPT_URLS } from "../lib/endpoints";
(window.wrappedJSObject as any).chrome = cloneInto({}, window);

View File

@@ -1,6 +1,7 @@
"use strict";
import { loadScript } from "../lib/utils";
import { Message } from "../types";
import { onMessageResponse, sendMessage } from "./eventMessageChannel";
@@ -17,12 +18,17 @@ if (isFramework) {
// Message port to background script
const backgroundPort = browser.runtime.connect({ name: "shim" });
export const backgroundPort = browser.runtime.connect({ name: "shim" });
// Forward background messages to shim
backgroundPort.onMessage.addListener(sendMessage);
const forwardToShim = (message: Message) => sendMessage(message);
const forwardToMain = (message: Message) => backgroundPort.postMessage(message);
// Forward shim messages to background
onMessageResponse(message => {
backgroundPort.postMessage(message);
// Add message listeners
backgroundPort.onMessage.addListener(forwardToShim);
const listener = onMessageResponse(forwardToMain);
// Remove listeners
backgroundPort.onDisconnect.addListener(() => {
backgroundPort.onMessage.removeListener(forwardToShim);
listener.disconnect();
});

View File

@@ -2,11 +2,14 @@
import * as cast from "./cast";
import { BridgeInfo } from "../lib/getBridgeInfo";
import { BridgeInfo } from "../lib/bridge";
import { Message } from "../types";
import { onMessage } from "./eventMessageChannel";
let initializedBridgeInfo: BridgeInfo;
let initializedBackgroundPort: browser.runtime.Port;
/**
* To support exporting an API from a module, we need to
* retain the event-based message passing despite not
@@ -14,17 +17,44 @@ import { onMessage } from "./eventMessageChannel";
* for and emits these messages, and changing that behavior
* is too messy.
*/
export function init (): Promise<BridgeInfo> {
export function ensureInit (): Promise<browser.runtime.Port> {
return new Promise(async (resolve, reject) => {
// Trigger message port setup side-effects
import("./contentBridge");
// If already initialized, just return existing bridge info
if (initializedBridgeInfo) {
if (initializedBridgeInfo.isVersionCompatible) {
resolve(initializedBackgroundPort);
} else {
reject();
}
return;
}
/**
* If the module is imported into a background script
* context, the location will be the internal extension URL,
* whereas in a content script, it will be the content page
* URL.
*/
if (window.location.protocol === "moz-extension:") {
//
} else {
// Trigger message port setup side-effects
const { backgroundPort } = await import("./contentBridge");
initializedBackgroundPort = backgroundPort;
}
onMessage(message => {
switch (message.subject) {
case "shim:/initialized": {
const bridgeInfo: BridgeInfo = message.data;
resolve(bridgeInfo);
initializedBridgeInfo = message.data;
if (initializedBridgeInfo.isVersionCompatible) {
resolve(initializedBackgroundPort);
} else {
reject();
}
}
}
});

View File

@@ -2,7 +2,7 @@
import * as cast from "./cast";
import { CAST_FRAMEWORK_SCRIPT_URL } from "../endpoints";
import { CAST_FRAMEWORK_SCRIPT_URL } from "../lib/endpoints";
import { loadScript } from "../lib/utils";
import { onMessage } from "./eventMessageChannel";
@@ -13,6 +13,10 @@ if (!_window.chrome) {
_window.chrome = {};
}
// Remove private APIs
delete cast._requestSession;
// Create page-accessible API object
_window.chrome.cast = cast;
@@ -61,7 +65,6 @@ if (document.currentScript) {
}
}
onMessage(message => {
switch (message.subject) {
case "shim:/initialized": {

View File

@@ -26,20 +26,3 @@ export interface ReceiverStatus {
muted: boolean
};
}
export interface DownloadDelta {
id: number;
url?: browser.downloads.StringDelta;
filename?: browser.downloads.StringDelta;
danger?: browser.downloads.StringDelta;
mime?: browser.downloads.StringDelta;
startTime?: browser.downloads.StringDelta;
endTime?: browser.downloads.StringDelta;
state?: browser.downloads.StringDelta;
canResume?: browser.downloads.BooleanDelta;
paused?: browser.downloads.BooleanDelta;
error?: browser.downloads.StringDelta;
totalBytes?: browser.downloads.DoubleDelta;
fileSize?: browser.downloads.DoubleDelta;
exists?: browser.downloads.BooleanDelta;
}

View File

@@ -7,7 +7,7 @@ import semver from "semver";
import { getNextEllipsis
, getWindowCenteredProps } from "../../lib/utils";
import { BridgeInfo } from "../../lib/getBridgeInfo";
import { BridgeInfo } from "../../lib/bridge";
const _ = browser.i18n.getMessage;

View File

@@ -4,13 +4,13 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import defaultOptions, { Options } from "../../defaultOptions";
import defaultOptions from "../../defaultOptions";
import Bridge from "./Bridge";
import EditableList from "./EditableList";
import getBridgeInfo, { BridgeInfo } from "../../lib/getBridgeInfo";
import options from "../../lib/options";
import bridge, { BridgeInfo } from "../../lib/bridge";
import options, { Options } from "../../lib/options";
import { REMOTE_MATCH_PATTERN_REGEX } from "../../lib/utils";
import { ReceiverSelectorType } from "../../receiver_selectors";
@@ -88,7 +88,7 @@ class OptionsApp extends Component<{}, OptionsAppState> {
, options: await options.getAll()
});
const bridgeInfo = await getBridgeInfo();
const bridgeInfo = await bridge.getInfo();
const { os } = await browser.runtime.getPlatformInfo();
this.setState({
@@ -350,17 +350,8 @@ class OptionsApp extends Component<{}, OptionsAppState> {
this.form.reportValidity();
try {
const oldOpts = await options.getAll();
await options.setAll(this.state.options);
const alteredOptions = [];
for (const [ key, val ] of Object.entries(this.state.options)) {
const oldVal = oldOpts[key];
if (oldVal !== val) {
alteredOptions.push(key);
}
}
this.setState({
hasSaved: true
}, () => {
@@ -370,12 +361,6 @@ class OptionsApp extends Component<{}, OptionsAppState> {
});
}, 1000);
});
// Send update message / event
browser.runtime.sendMessage({
subject: "optionsUpdated"
, data: { alteredOptions }
});
} catch (err) {
console.error("Failed to save options");
}
@@ -421,7 +406,7 @@ class OptionsApp extends Component<{}, OptionsAppState> {
bridgeLoading: true
});
const bridgeInfo = await getBridgeInfo();
const bridgeInfo = await bridge.getInfo();
this.setState({
bridgeInfo

View File

@@ -179,11 +179,6 @@ class PopupApp extends Component<{}, PopupAppState> {
try {
const filePath = window.prompt();
// Validate URL
const fileUrl = new URL(filePath.startsWith("file://")
? filePath
: `file://${filePath}`);
this.setState({
mediaType
, filePath

6
package-lock.json generated
View File

@@ -8,6 +8,12 @@
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==",
"dev": true
},
"@types/firefox-webext-browser": {
"version": "67.0.2",
"resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-67.0.2.tgz",
"integrity": "sha512-tXR5THtH5Fqwfmi4y/2dTxCTebqHsKCH2bif/VdE5CPcB/S5x5fu+N+wBuMENzN41agovHMU/LQbtWNV+Aux+w==",
"dev": true
},
"@types/minimist": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.0.tgz",

View File

@@ -20,6 +20,7 @@
"lint:ext": "npm run lint --prefix ./ext"
},
"devDependencies": {
"@types/firefox-webext-browser": "^67.0.2",
"@types/minimist": "^1.2.0",
"@types/semver": "^5.5.0",
"@types/uuid": "^3.4.4",

View File

@@ -22,6 +22,10 @@ describe("chrome", () => {
expect(chrome.cast.unescape).toBeDefined();
});
it("should not have private api methods", () => {
expect(chrome.cast._requestSession).toBeUndefined();
});
it("should have all api classes", () => {
expect(chrome.cast.ApiConfig).toBeDefined();
expect(chrome.cast.DialRequest).toBeDefined();