diff --git a/app/bin/build.js b/app/bin/build.js index 9fcdba0..7298b66 100644 --- a/app/bin/build.js +++ b/app/bin/build.js @@ -180,11 +180,17 @@ async function build () { fs.writeFileSync(path.join(BUILD_PATH, "package.json") , JSON.stringify(pkgManifest)) + /** + * With the BUILD_PATH/bridge dir, cannot build to + * BUILD_PATH/bridge file. + */ + const tempExecutableName = `${executableName[argv.platform]}.temp`; + // Run pkg to create a single executable await pkg.exec([ BUILD_PATH , "--target", `${pkgPlatform[argv.platform]}-${argv.arch}` - , "--output", path.join(BUILD_PATH, executableName[argv.platform]) + , "--output", path.join(BUILD_PATH, tempExecutableName) ]); // Build NativeMacReceiverSelector @@ -226,16 +232,14 @@ async function build () { , { overwrite: true }); } } else { - const builtExecutableName = executableName[argv.platform]; - // Move executable and app manifest to dist fs.moveSync( path.join(BUILD_PATH, manifestName) , path.join(DIST_PATH, manifestName) , { overwrite: true }); fs.moveSync( - path.join(BUILD_PATH, builtExecutableName) - , path.join(DIST_PATH, builtExecutableName) + path.join(BUILD_PATH, tempExecutableName) + , path.join(DIST_PATH, executableName[argv.platform]) , { overwrite: true }); if (isBuildingForMacOnMac) { diff --git a/app/src/Media.ts b/app/src/bridge/Media.ts similarity index 100% rename from app/src/Media.ts rename to app/src/bridge/Media.ts diff --git a/app/src/MediaServer.ts b/app/src/bridge/MediaServer.ts similarity index 100% rename from app/src/MediaServer.ts rename to app/src/bridge/MediaServer.ts diff --git a/app/src/Session.ts b/app/src/bridge/Session.ts similarity index 100% rename from app/src/Session.ts rename to app/src/bridge/Session.ts diff --git a/app/src/StatusListener.ts b/app/src/bridge/StatusListener.ts similarity index 100% rename from app/src/StatusListener.ts rename to app/src/bridge/StatusListener.ts diff --git a/app/src/airplay/auth.ts b/app/src/bridge/airplay/auth.ts similarity index 100% rename from app/src/airplay/auth.ts rename to app/src/bridge/airplay/auth.ts diff --git a/app/src/airplay/bplist.ts b/app/src/bridge/airplay/bplist.ts similarity index 100% rename from app/src/airplay/bplist.ts rename to app/src/bridge/airplay/bplist.ts diff --git a/app/src/castTypes.ts b/app/src/bridge/castTypes.ts similarity index 100% rename from app/src/castTypes.ts rename to app/src/bridge/castTypes.ts diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts new file mode 100755 index 0000000..3b86c36 --- /dev/null +++ b/app/src/bridge/index.ts @@ -0,0 +1,307 @@ +import dnssd from "dnssd"; + +import child_process from "child_process"; +import events from "events"; +import fs from "fs"; +import http from "http"; +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"; + +import { DecodeTransform + , EncodeTransform + , ResponseTransform } from "../transforms"; + +import { MediaStatus + , ReceiverStatus } from "./castTypes"; + +import { Message } from "./types"; + +import { __applicationName + , __applicationVersion } from "../../package.json"; + + +// Increase listener limit +events.EventEmitter.defaultMaxListeners = 50; + + +const browser = new dnssd.Browser(dnssd.tcp("googlecast")); + +// Local media server +let mediaServer: MediaServer; + +process.on("SIGTERM", () => { + if (mediaServer) { + mediaServer.stop(); + } +}); + + +const decodeTransform = new DecodeTransform(); +const encodeTransform = new EncodeTransform(); + +// stdin -> stdout +process.stdin + .pipe(decodeTransform) + .pipe(new ResponseTransform(handleMessage)) + .pipe(encodeTransform) + .pipe(process.stdout); + +/** + * Encode and send a message to the extension. + */ +function sendMessage (message: object) { + try { + encodeTransform.write(message); + } catch (err) { + console.error("Failed to encode message"); + } +} + + +interface InitializeOptions { + shouldWatchStatus?: boolean; +} + +// Existing counterpart Media/Session objects +const existingSessions: Map = new Map(); +const existingMedia: Map = new Map(); + +let receiverSelectorApp: child_process.ChildProcess; + +/** + * Handle incoming messages from the extension and forward + * them to the appropriate handlers. + * + * Initializes the counterpart objects and is responsible + * for managing existing ones. + */ +async function handleMessage (message: Message) { + if (message.subject.startsWith("bridge:/media/")) { + if (existingMedia.has(message._id)) { + // Forward message to instance message handler + existingMedia.get(message._id).messageHandler(message); + } else { + if (message.subject.endsWith("/initialize")) { + // Get Session object media belongs to + const parentSession = existingSessions.get( + message.data._internalSessionId); + + // Create Media + existingMedia.set(message._id, new Media( + message.data.sessionId + , message.data.mediaSessionId + , message._id + , parentSession + , sendMessage)); + } + } + + return; + } + + if (message.subject.startsWith("bridge:/session/")) { + if (existingSessions.has(message._id)) { + // Forward message to instance message handler + existingSessions.get(message._id).messageHandler(message); + } else { + if (message.subject.endsWith("/initialize")) { + // Create Session + existingSessions.set(message._id, new Session( + message.data.address + , message.data.port + , message.data.appId + , message.data.sessionId + , message._id + , sendMessage)); + } + } + + return; + } + + switch (message.subject) { + case "bridge:/getInfo": { + const extensionVersion = message.data; + return __applicationVersion; + } + + case "bridge:/initialize": { + const options: InitializeOptions = message.data; + initialize(options); + + break; + } + + + case "bridge:/receiverSelector/open": { + const receiverSelectorData = message.data; + + if (process.platform !== "darwin") { + console.error("Invalid platform for native selector."); + process.exit(1); + } + + if (!receiverSelectorData) { + console.error("Missing native selector data."); + process.exit(1); + } else { + try { + JSON.parse(receiverSelectorData); + } catch (err) { + console.error("Invalid native selector data."); + } + } + + receiverSelectorApp = child_process.spawn( + path.join(process.cwd(), "selector") + , [ receiverSelectorData ]); + + receiverSelectorApp.stdout.setEncoding("utf8"); + receiverSelectorApp.stdout.on("data", data => { + sendMessage({ + subject: "main:/receiverSelector/selected" + , data: JSON.parse(data) + }); + }); + + receiverSelectorApp.addListener("error", err => { + sendMessage({ + subject: "main:/receiverSelector/error" + , data: err.message + }); + }); + + receiverSelectorApp.on("close", () => { + sendMessage({ + subject: "main:/receiverSelector/close" + }); + }); + + break; + } + + case "bridge:/receiverSelector/close": { + receiverSelectorApp.kill(); + break; + } + + + case "bridge:/mediaServer/start": { + const { filePath, port } = message.data; + + mediaServer = new MediaServer(filePath, port); + mediaServer.start(); + + mediaServer.on("started", () => { + sendMessage({ + subject: "mediaCast:/mediaServer/started" + }); + }); + + mediaServer.on("stopped", () => { + sendMessage({ + subject: "mediaCast:/mediaServer/stopped" + }); + }); + + break; + } + + case "bridge:/mediaServer/stop": { + if (mediaServer) { + mediaServer.stop(); + } + + break; + } + } +} + + +function initialize (options: InitializeOptions) { + if (options.shouldWatchStatus) { + browser.on("serviceUp", onStatusBrowserServiceUp); + browser.on("serviceDown", onStatusBrowserServiceDown); + } + + browser.on("serviceUp", onBrowserServiceUp); + browser.on("servicedown", onBrowserServiceDown); + browser.start(); + + + function onBrowserServiceUp (service: dnssd.Service) { + sendMessage({ + subject: "shim:/serviceUp" + , data: { + host: service.addresses[0] + , port: service.port + , id: service.txt.id + , friendlyName: service.txt.fn + } + }); + } + + function onBrowserServiceDown (service: dnssd.Service) { + sendMessage({ + subject: "shim:/serviceDown" + , data: { + id: service.txt.id + } + }); + } + + + // Receiver status listeners for status mode + const statusListeners = new Map(); + + function onStatusBrowserServiceUp (service: dnssd.Service) { + const { id } = service.txt; + + const listener = new StatusListener( + service.addresses[0] + , service.port); + + listener.on("receiverStatus", (status: ReceiverStatus) => { + const receiverStatusMessage: any = { + subject: "receiverStatus" + , data: { + id + , status: { + volume: { + level: status.volume.level + , muted: status.volume.muted + } + } + } + }; + + if ("applications" in status) { + const application = status.applications[0]; + + receiverStatusMessage.data.status.application = { + displayName: application.displayName + , isIdleScreen: application.isIdleScreen + , statusText: application.statusText + }; + } + + sendMessage(receiverStatusMessage); + }); + + statusListeners.set(id, listener); + } + + function onStatusBrowserServiceDown (service: dnssd.Service) { + const { id } = service.txt; + + if (statusListeners.has(id)) { + statusListeners.get(id).deregister(); + statusListeners.delete(id); + } + } +} diff --git a/app/src/types.ts b/app/src/bridge/types.ts similarity index 100% rename from app/src/types.ts rename to app/src/bridge/types.ts diff --git a/app/src/daemon/index.ts b/app/src/daemon/index.ts new file mode 100644 index 0000000..d53e4ab --- /dev/null +++ b/app/src/daemon/index.ts @@ -0,0 +1,61 @@ +"use strict"; + +import { spawn } from "child_process"; +import { Readable } from "stream"; + +import path from "path"; +import WebSocket from "ws"; + +import { DecodeTransform + , EncodeTransform } from "../transforms"; + + +const wss = new WebSocket.Server({ port: 9556 }); + +wss.on("connection", socket => { + + /** + * Daemon and bridge are the same binary, so spawn a new + * version of self in bridge mode. + */ + const bridge = spawn(process.execPath, [ process.argv[1] ]); + + // Stream for incoming WebSocket messages + const messageStream = new Readable({ + objectMode: true + }); + + // tslint:disable-next-line:no-empty + messageStream._read = () => {}; + + /** + * Incoming JSON messages from the extension over the + * WebSocket connection are parsed and re-encoded to be sent + * to bridge stdin. + */ + socket.on("message", (message: string) => { + messageStream.push(JSON.parse(message)); + }); + + messageStream + .pipe(new EncodeTransform()) + .pipe(bridge.stdin); + + /** + * Incoming messages from the bridge are decoded and + * stringified and sent to the extension over the WebSocket + * connection. + */ + bridge.stdout + .pipe(new DecodeTransform()) + .on("data", data => { + // Socket can be CLOSING here + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(data)); + } + }); + + // Handle termination + socket.on("close", () => bridge.kill()); + bridge.on("exit", () => socket.close()); +}); diff --git a/app/src/main.ts b/app/src/main.ts old mode 100755 new mode 100644 index 341e53a..a3e4484 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -1,299 +1,18 @@ -import dnssd from "dnssd"; +"use strict"; -import child_process from "child_process"; -import events from "events"; -import fs from "fs"; -import http from "http"; -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"; -import * as transforms from "./transforms"; - -import { MediaStatus, ReceiverStatus } from "./castTypes"; - -import { Message } from "./types"; - -import { __applicationName - , __applicationVersion } from "../package.json"; +import minimist from "minimist"; -// Increase listener limit -events.EventEmitter.defaultMaxListeners = 50; - - -const browser = new dnssd.Browser(dnssd.tcp("googlecast")); - -// Local media server -let mediaServer: MediaServer; - -process.on("SIGTERM", () => { - if (mediaServer) { - mediaServer.stop(); +const argv = minimist(process.argv.slice(2), { + boolean: [ "daemon" ] + , default: { + daemon: false } }); -// stdin -> stdout -process.stdin - .pipe(transforms.decode) - .pipe(transforms.response(handleMessage)) - .pipe(transforms.encode) - .pipe(process.stdout); -/** - * Encode and send a message to the extension. - */ -function sendMessage (message: object) { - try { - transforms.encode.write(message); - } catch (err) { - console.error("Failed to encode message"); - } -} - - -interface InitializeOptions { - shouldWatchStatus?: boolean; -} - -// Existing counterpart Media/Session objects -const existingSessions: Map = new Map(); -const existingMedia: Map = new Map(); - -let receiverSelectorApp: child_process.ChildProcess; - -/** - * Handle incoming messages from the extension and forward - * them to the appropriate handlers. - * - * Initializes the counterpart objects and is responsible - * for managing existing ones. - */ -async function handleMessage (message: Message) { - if (message.subject.startsWith("bridge:/media/")) { - if (existingMedia.has(message._id)) { - // Forward message to instance message handler - existingMedia.get(message._id).messageHandler(message); - } else { - if (message.subject.endsWith("/initialize")) { - // Get Session object media belongs to - const parentSession = existingSessions.get( - message.data._internalSessionId); - - // Create Media - existingMedia.set(message._id, new Media( - message.data.sessionId - , message.data.mediaSessionId - , message._id - , parentSession - , sendMessage)); - } - } - - return; - } - - if (message.subject.startsWith("bridge:/session/")) { - if (existingSessions.has(message._id)) { - // Forward message to instance message handler - existingSessions.get(message._id).messageHandler(message); - } else { - if (message.subject.endsWith("/initialize")) { - // Create Session - existingSessions.set(message._id, new Session( - message.data.address - , message.data.port - , message.data.appId - , message.data.sessionId - , message._id - , sendMessage)); - } - } - - return; - } - - switch (message.subject) { - case "bridge:/getInfo": { - const extensionVersion = message.data; - return __applicationVersion; - } - - case "bridge:/initialize": { - const options: InitializeOptions = message.data; - initialize(options); - - break; - } - - - case "bridge:/receiverSelector/open": { - const receiverSelectorData = message.data; - - if (process.platform !== "darwin") { - console.error("Invalid platform for native selector."); - process.exit(1); - } - - if (!receiverSelectorData) { - console.error("Missing native selector data."); - process.exit(1); - } else { - try { - JSON.parse(receiverSelectorData); - } catch (err) { - console.error("Invalid native selector data."); - } - } - - receiverSelectorApp = child_process.spawn( - path.join(process.cwd(), "selector") - , [ receiverSelectorData ]); - - receiverSelectorApp.stdout.setEncoding("utf8"); - receiverSelectorApp.stdout.on("data", data => { - sendMessage({ - subject: "main:/receiverSelector/selected" - , data: JSON.parse(data) - }); - }); - - receiverSelectorApp.addListener("error", err => { - sendMessage({ - subject: "main:/receiverSelector/error" - , data: err.message - }); - }); - - receiverSelectorApp.on("close", () => { - sendMessage({ - subject: "main:/receiverSelector/close" - }); - }); - - break; - } - - case "bridge:/receiverSelector/close": { - receiverSelectorApp.kill(); - break; - } - - - case "bridge:/mediaServer/start": { - const { filePath, port } = message.data; - - mediaServer = new MediaServer(filePath, port); - mediaServer.start(); - - mediaServer.on("started", () => { - sendMessage({ - subject: "mediaCast:/mediaServer/started" - }); - }); - - mediaServer.on("stopped", () => { - sendMessage({ - subject: "mediaCast:/mediaServer/stopped" - }); - }); - - break; - } - - case "bridge:/mediaServer/stop": { - if (mediaServer) { - mediaServer.stop(); - } - - break; - } - } -} - - -function initialize (options: InitializeOptions) { - if (options.shouldWatchStatus) { - browser.on("serviceUp", onStatusBrowserServiceUp); - browser.on("serviceDown", onStatusBrowserServiceDown); - } - - browser.on("serviceUp", onBrowserServiceUp); - browser.on("servicedown", onBrowserServiceDown); - browser.start(); - - - function onBrowserServiceUp (service: dnssd.Service) { - sendMessage({ - subject: "shim:/serviceUp" - , data: { - host: service.addresses[0] - , port: service.port - , id: service.txt.id - , friendlyName: service.txt.fn - } - }); - } - - function onBrowserServiceDown (service: dnssd.Service) { - sendMessage({ - subject: "shim:/serviceDown" - , data: { - id: service.txt.id - } - }); - } - - - // Receiver status listeners for status mode - const statusListeners = new Map(); - - function onStatusBrowserServiceUp (service: dnssd.Service) { - const { id } = service.txt; - - const listener = new StatusListener( - service.addresses[0] - , service.port); - - listener.on("receiverStatus", (status: ReceiverStatus) => { - const receiverStatusMessage: any = { - subject: "receiverStatus" - , data: { - id - , status: { - volume: { - level: status.volume.level - , muted: status.volume.muted - } - } - } - }; - - if ("applications" in status) { - const application = status.applications[0]; - - receiverStatusMessage.data.status.application = { - displayName: application.displayName - , isIdleScreen: application.isIdleScreen - , statusText: application.statusText - }; - } - - sendMessage(receiverStatusMessage); - }); - - statusListeners.set(id, listener); - } - - function onStatusBrowserServiceDown (service: dnssd.Service) { - const { id } = service.txt; - - if (statusListeners.has(id)) { - statusListeners.get(id).deregister(); - statusListeners.delete(id); - } - } +if (argv.daemon) { + import("./daemon"); +} else { + import("./bridge"); } diff --git a/app/src/transforms.ts b/app/src/transforms.ts index d6f4720..237a0f5 100755 --- a/app/src/transforms.ts +++ b/app/src/transforms.ts @@ -1,7 +1,7 @@ "use strict"; import { Transform } from "stream"; -import { Message } from "./types"; +import { Message } from "./bridge/types"; type ResponseHandlerFunction = (message: Message) => Promise; @@ -10,12 +10,21 @@ type ResponseHandlerFunction = (message: Message) => Promise; * Takes a handler function that implements the transform * and calls the transform callback. */ -export const response = (handler: ResponseHandlerFunction) => new Transform({ - readableObjectMode: true - , writableObjectMode: true +export class ResponseTransform extends Transform { + constructor (private _handler: ResponseHandlerFunction) { + super({ + readableObjectMode: true + , writableObjectMode: true + }); + } - , transform (chunk: Message, encoding, callback) { - Promise.resolve(handler(chunk)) + public _transform ( + chunk: Message + , encoding: string + // tslint:disable-next-line:ban-types + , callback: Function) { + + Promise.resolve(this._handler(chunk)) .then(res => { if (res) { callback(null, res); @@ -24,81 +33,100 @@ export const response = (handler: ResponseHandlerFunction) => new Transform({ } }); } -}); +} /** * Takes input, decodes the message string, parses as JSON * and outputs the parsed result. */ -export const decode = new Transform({ - readableObjectMode: true +export class DecodeTransform extends Transform { + // Message data + private _messageBuffer = Buffer.alloc(0); + private _messageLength: number = null; - , transform (chunk, encoding, callback) { - const self = this as any; - - // Setup persistent data - if (!this.hasOwnProperty("_buf") - && !this.hasOwnProperty("_messageLength")) { - - self._buf = Buffer.alloc(0); - self._messageLength = null; - } - - // Append next chunk to buffer - self._buf = Buffer.concat([ self._buf, chunk ]); - - while (true) { - if (self._messageLength === null) { - if (self._buf.length >= 4) { - - // Read message length - self._messageLength = self._buf.readUInt32LE(0); - - // Offset buffer - self._buf = self._buf.slice(4); - - continue; - } - } else { - if (self._buf.length >= self._messageLength) { - const message = JSON.parse(self._buf.slice( - 0, self._messageLength)); - - this.push(message); - - // Cleanup persistent data - self._buf = self._buf.slice(self._messageLength); - self._messageLength = null; - - // Parse next message - continue; - } - } - - // No complete messages left - callback(); - break; - } + constructor () { + super({ + readableObjectMode: true + }); } -}); + + public _transform ( + chunk: any + , encoding: string + // tslint:disable-next-line:ban-types + , callback: Function) { + + // Append next chunk to buffer + this._messageBuffer = Buffer.concat([ + this._messageBuffer + , chunk + ]); + + for (;;) { + if (this._messageLength === null) { + if (this._messageBuffer.length >= 4) { + // Read message length and offset buffer + this._messageLength = this._messageBuffer.readUInt32LE(0); + this._messageBuffer = this._messageBuffer.slice(4); + + // Next message chunk + continue; + } + } else { + if (this._messageBuffer.length >= this._messageLength) { + const message = JSON.parse(this._messageBuffer + .slice(0, this._messageLength) + .toString()); + + // Push message content + this.push(message); + + // Offset buffer to start of next message + this._messageBuffer = this._messageBuffer.slice( + this._messageLength); + this._messageLength = null; + + // Next message + continue; + } + } + + // No complete messages left + callback(); + break; + } + } + } /** * Takes input, encodes the message length and content and * outputs the encoded result. */ -export const encode = new Transform({ - writableObjectMode: true +export class EncodeTransform extends Transform { + constructor () { + super({ + writableObjectMode: true + }); + } + + public _transform ( + chunk: any + , encoding: string + // tslint:disable-next-line:ban-types + , callback: Function) { - , transform (chunk, encoding, callback) { const messageLength = Buffer.alloc(4); const message = Buffer.from(JSON.stringify(chunk)); // Write message length messageLength.writeUInt32LE(message.length, 0); - // Output joined message length and content - callback(null, Buffer.concat([messageLength, message])); + // Output joined length and content + callback(null, Buffer.concat([ + messageLength + , message + ])); } -}); + } diff --git a/ext/src/lib/getBridgeInfo.ts b/ext/src/lib/getBridgeInfo.ts index 449d98c..08edc6e 100644 --- a/ext/src/lib/getBridgeInfo.ts +++ b/ext/src/lib/getBridgeInfo.ts @@ -1,6 +1,7 @@ "use strict"; import semver from "semver"; +import nativeMessaging from "./nativeMessaging"; export interface BridgeInfo { name: string; @@ -15,7 +16,7 @@ export interface BridgeInfo { export default async function getBridgeInfo (): Promise { let applicationVersion: string; try { - applicationVersion = await browser.runtime.sendNativeMessage( + applicationVersion = await nativeMessaging.sendNativeMessage( APPLICATION_NAME , { subject: "bridge:/getInfo" , data: EXTENSION_VERSION }); diff --git a/ext/src/lib/nativeMessaging.ts b/ext/src/lib/nativeMessaging.ts new file mode 100644 index 0000000..79c78d6 --- /dev/null +++ b/ext/src/lib/nativeMessaging.ts @@ -0,0 +1,181 @@ +"use strict"; + +import { Message } from "../types"; + + +const WEBSOCKET_DAEMON_URL = "ws://localhost:9556"; + + +type DisconnectListener = () => void; +type MessageListener = (message: any) => void; + +function connectNative (application: string) { + /** + * In order to preserve the synchronous API, messages are + * queued before either the native messaging host or the + * WebSocket connection is ready to send data. + */ + let messageQueue: object[] = []; + + /** + * Set once the native messaging host is known to be either + * present/missing. Determines whether messages go to the + * message queue. + */ + let isNativeHostStatusKnown = false; + + const port = browser.runtime.connectNative(application); + + + let socket: WebSocket; + + const onDisconnectListeners = new Set(); + const onMessageListeners = new Set(); + + // Port proxy API + const portObject: browser.runtime.Port = { + error: null as any + , name: "" + + , onDisconnect: { + addListener (cb: DisconnectListener) { + onDisconnectListeners.add(cb); + } + , removeListener (cb: DisconnectListener) { + onDisconnectListeners.delete(cb); + } + , hasListener (cb: DisconnectListener) { + return onDisconnectListeners.has(cb); + } + } + , onMessage: { + addListener (cb: MessageListener) { + onMessageListeners.add(cb); + } + , removeListener (cb: MessageListener) { + onMessageListeners.delete(cb); + } + , hasListener (cb: MessageListener) { + return onMessageListeners.has(cb); + } + + // Workaround for modified types + , hasListeners () { return false; } + } + + , disconnect () { + if (socket) { + socket.close(); + } else { + port.disconnect(); + } + } + + , postMessage (message) { + if (socket) { + switch (socket.readyState) { + case WebSocket.CONNECTING: { + // Queue message until WebSocket is ready + messageQueue.push(message); + break; + } + + case WebSocket.OPEN: { + socket.send(JSON.stringify(message)); + break; + } + } + } else { + if (!isNativeHostStatusKnown) { + // Queue message until native messaging host is ready + messageQueue.push(message); + } + + port.postMessage(message); + } + } + }; + + + port.onDisconnect.addListener(() => { + if (port.error && !isNativeHostStatusKnown) { + isNativeHostStatusKnown = true; + + socket = new WebSocket(WEBSOCKET_DAEMON_URL); + + socket.addEventListener("open", ev => { + // Send all messages in queue + while (messageQueue.length) { + const message = messageQueue.pop(); + socket.send(JSON.stringify(message)); + } + }); + + socket.addEventListener("message", ev => { + for (const listener of onMessageListeners) { + listener(JSON.parse(ev.data)); + } + }); + + socket.addEventListener("close", ev => { + if (ev.code !== 1000) { + this.error = { + // TODO: Set a proper error message + message: "" + }; + } + + for (const listener of onDisconnectListeners) { + listener(); + } + }); + } + }); + + port.onMessage.addListener((message: any) => { + if (!isNativeHostStatusKnown) { + isNativeHostStatusKnown = true; + messageQueue = []; + } + + for (const listener of onMessageListeners) { + listener(message); + } + }); + + + return portObject; +} + +async function sendNativeMessage ( + application: string + , message: any) { + + try { + return await browser.runtime.sendNativeMessage(application, message); + } catch (err) { + return await new Promise((resolve, reject) => { + const ws = new WebSocket(WEBSOCKET_DAEMON_URL); + + ws.addEventListener("open", () => { + ws.send(JSON.stringify(message)); + }); + + ws.addEventListener("message", ev => { + ws.close(); + resolve(JSON.parse(ev.data)); + }); + + ws.addEventListener("error", () => { + console.error("fx_cast (Debug): No bridge application found."); + reject(); + }); + }); + } +} + + +export default { + connectNative + , sendNativeMessage +}; diff --git a/ext/src/main.ts b/ext/src/main.ts index d5f555f..b256c2d 100755 --- a/ext/src/main.ts +++ b/ext/src/main.ts @@ -1,8 +1,11 @@ "use strict"; +import semver from "semver"; + import defaultOptions, { Options } from "./defaultOptions"; import getBridgeInfo from "./lib/getBridgeInfo"; import messageRouter from "./lib/messageRouter"; +import nativeMessaging from "./lib/nativeMessaging"; import { getChromeUserAgent } from "./lib/userAgents"; import { getWindowCenteredProps } from "./lib/utils"; @@ -18,8 +21,6 @@ import { ReceiverStatusMessage , ServiceDownMessage , ServiceUpMessage } from "./messageTypes"; -import semver from "semver"; - const _ = browser.i18n.getMessage; @@ -226,7 +227,7 @@ browser.menus.onShown.addListener(info => { * 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; @@ -536,11 +537,68 @@ interface Shim { const shimMap = new Map(); -const statusBridge = browser.runtime.connectNative(APPLICATION_NAME); +let statusBridge: browser.runtime.Port; const statusBridgeReceivers = new Map(); -statusBridge.onMessage.addListener(async (message: Message) => { + +/** + * Create status bridge, set event handlers and initialize. + */ +function initStatusBridge () { + statusBridge = nativeMessaging.connectNative(APPLICATION_NAME); + statusBridge.onDisconnect.addListener(onStatusBridgeDisconnect); + statusBridge.onMessage.addListener(onStatusBridgeMessage); + + statusBridge.postMessage({ + subject: "bridge:/initialize" + , data: { + mode: "status" + } + }); +} + +initStatusBridge(); + +/** + * 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. + */ +function onStatusBridgeDisconnect () { + // Notify shims for receiver availability + for (const [ , receiver ] of statusBridgeReceivers) { + for (const [, shim ] of shimMap) { + shim.port.postMessage({ + subject: "shim:/serviceDown" + , data: { id: receiver.id } + }); + } + } + + // Cleanup + statusBridgeReceivers.clear(); + statusBridge.onDisconnect.removeListener(onStatusBridgeDisconnect); + statusBridge.onMessage.removeListener(onStatusBridgeMessage); + statusBridge = null; + + // After 10 seconds, attempt to reinitialize + window.setTimeout(() => { + initStatusBridge(); + }, 10000); +} + +/** + * Handles incoming status bridge messages. + */ +async function onStatusBridgeMessage (message: Message) { switch (message.subject) { + case "shim:/serviceUp": { const receiver = (message as ServiceUpMessage).data; statusBridgeReceivers.set(receiver.id, receiver); @@ -591,14 +649,7 @@ statusBridge.onMessage.addListener(async (message: Message) => { break; } } -}); - -statusBridge.postMessage({ - subject: "bridge:/initialize" - , data: { - mode: "status" - } -}); +} async function onConnectShim (port: browser.runtime.Port) { @@ -664,7 +715,7 @@ async function onConnectShim (port: browser.runtime.Port) { } // Spawn bridge app instance - const bridgePort = browser.runtime.connectNative(APPLICATION_NAME); + const bridgePort = nativeMessaging.connectNative(APPLICATION_NAME); if (bridgePort.error) { console.error(`Failed connect to ${APPLICATION_NAME}:` diff --git a/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts b/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts index bb8b6c7..80a6d5d 100644 --- a/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts +++ b/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts @@ -1,5 +1,7 @@ "use strict"; +import nativeMessaging from "../../lib/nativeMessaging"; + import ReceiverSelectorManager, { ReceiverSelectorMediaType } from "../ReceiverSelectorManager"; @@ -27,7 +29,7 @@ export default class NativeMacReceiverSelectorManager receivers: Receiver[] , defaultMediaType: ReceiverSelectorMediaType): Promise { - this.bridgePort = browser.runtime.connectNative(APPLICATION_NAME); + this.bridgePort = nativeMessaging.connectNative(APPLICATION_NAME); this.bridgePort.onMessage.addListener((message: Message) => { switch (message.subject) { diff --git a/ext/src/ui/popup/index.tsx b/ext/src/ui/popup/index.tsx index 190c707..8f9ca27 100755 --- a/ext/src/ui/popup/index.tsx +++ b/ext/src/ui/popup/index.tsx @@ -122,7 +122,7 @@ class PopupApp extends Component<{}, PopupAppState> { )} + key={ i }/> ))} ); diff --git a/package-lock.json b/package-lock.json index 41ed4db..8bb4e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,6 +2,18 @@ "requires": true, "lockfileVersion": 1, "dependencies": { + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, + "@types/minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=", + "dev": true + }, "@types/node": { "version": "11.9.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-11.9.5.tgz", @@ -23,6 +35,16 @@ "@types/node": "*" } }, + "@types/ws": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.1.tgz", + "integrity": "sha512-EzH8k1gyZ4xih/MaZTXwT2xOkPiIMSrhQ9b8wrlX88L0T02eYsddatQlwVFlEPyEqV0ChpdpNnE51QPH6NVT4Q==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/node": "*" + } + }, "ansi-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", diff --git a/package.json b/package.json index aa82b4c..12ef1fe 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "lint:ext": "npm run lint --prefix ./ext" }, "devDependencies": { + "@types/minimist": "^1.2.0", "@types/semver": "^5.5.0", "@types/uuid": "^3.4.4", + "@types/ws": "^6.0.1", "fs-extra": "^7.0.1", "glob": "^7.1.3", "jasmine-console-reporter": "^3.1.0",