From ba8c28bf39c4c2816376a5f09cf1411fed4ef037 Mon Sep 17 00:00:00 2001 From: Matt Hensman Date: Fri, 26 Jul 2019 00:09:51 +0100 Subject: [PATCH] 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. --- app/src/bridge/MediaServer.ts | 73 -- app/src/bridge/index.ts | 131 ++- ext/bin/build.js | 2 +- ext/package-lock.json | 2 +- ext/src/SelectorManager.ts | 85 ++ ext/src/StatusManager.ts | 154 +++ ext/src/createMenus.ts | 278 +++++ ext/src/createShim.ts | 155 +++ ext/src/defaultOptions.ts | 27 +- ext/src/global.d.ts | 4 +- ext/src/lib/{getBridgeInfo.ts => bridge.ts} | 25 +- ext/src/{ => lib}/endpoints.ts | 0 ext/src/lib/loadSender.ts | 52 + ext/src/lib/mediaCasting.ts | 81 ++ ext/src/lib/messageRouter.ts | 35 - ext/src/lib/options.ts | 184 +++- ext/src/lib/typedEvents.ts | 21 + ext/src/lib/utils.ts | 22 + ext/src/main.ts | 984 +++++------------- ext/src/manifest.json | 9 + ext/src/messageTypes.ts | 40 - .../NativeMacReceiverSelector.ts | 53 +- .../PopupReceiverSelector.ts | 30 +- .../receiver_selectors/ReceiverSelector.ts | 15 +- ext/src/receiver_selectors/index.ts | 14 +- ext/src/senders/mediaCast.ts | 183 ++-- ext/src/senders/mirroringCast.ts | 37 +- ext/src/shim/cast/classes/ApiConfig.ts | 15 +- ext/src/shim/cast/index.ts | 152 ++- ext/src/shim/content.ts | 2 +- ext/src/shim/contentBridge.ts | 18 +- ext/src/shim/export.ts | 42 +- ext/src/shim/index.ts | 7 +- ext/src/types.ts | 17 - ext/src/ui/options/Bridge.tsx | 2 +- ext/src/ui/options/index.tsx | 25 +- ext/src/ui/popup/index.tsx | 5 - package-lock.json | 6 + package.json | 1 + test/spec/shim/chrome.spec.js | 4 + 40 files changed, 1751 insertions(+), 1241 deletions(-) delete mode 100644 app/src/bridge/MediaServer.ts create mode 100644 ext/src/SelectorManager.ts create mode 100644 ext/src/StatusManager.ts create mode 100644 ext/src/createMenus.ts create mode 100644 ext/src/createShim.ts rename ext/src/lib/{getBridgeInfo.ts => bridge.ts} (76%) rename ext/src/{ => lib}/endpoints.ts (100%) create mode 100644 ext/src/lib/loadSender.ts create mode 100644 ext/src/lib/mediaCasting.ts delete mode 100644 ext/src/lib/messageRouter.ts create mode 100644 ext/src/lib/typedEvents.ts delete mode 100644 ext/src/messageTypes.ts diff --git a/app/src/bridge/MediaServer.ts b/app/src/bridge/MediaServer.ts deleted file mode 100644 index db2bb90..0000000 --- a/app/src/bridge/MediaServer.ts +++ /dev/null @@ -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); - } - } -} diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts index adacc8a..30ad251 100755 --- a/app/src/bridge/index.ts +++ b/app/src/bridge/index.ts @@ -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 = new Map(); const existingMedia: Map = 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; diff --git a/ext/bin/build.js b/ext/bin/build.js index 2505a37..5aba88b 100644 --- a/ext/bin/build.js +++ b/ext/bin/build.js @@ -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 diff --git a/ext/package-lock.json b/ext/package-lock.json index 82e3908..fbc63a8 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -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 }, diff --git a/ext/src/SelectorManager.ts b/ext/src/SelectorManager.ts new file mode 100644 index 0000000..80802c7 --- /dev/null +++ b/ext/src/SelectorManager.ts @@ -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 { + + 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 +}; diff --git a/ext/src/StatusManager.ts b/ext/src/StatusManager.ts new file mode 100644 index 0000000..d95d8b8 --- /dev/null +++ b/ext/src/StatusManager.ts @@ -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 { + private bridgePort: browser.runtime.Port; + private receivers = new Map(); + + 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); + } +}; diff --git a/ext/src/createMenus.ts b/ext/src/createMenus.ts new file mode 100644 index 0000000..6bae74d --- /dev/null +++ b/ext/src/createMenus.ts @@ -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(); + + +let hasCreatedMenus = false; + +export default async function createMenus () { + if (!hasCreatedMenus) { + hasCreatedMenus = true; + + const opts = await options.getAll(); + + //