From a6ab0181716a1289375111bcf013829806285250 Mon Sep 17 00:00:00 2001 From: Matt Hensman Date: Fri, 21 Aug 2020 13:19:01 +0100 Subject: [PATCH] App refactor (#140) * Add additional types * Split components from single index module into smaller modules * Misc smaller changes --- app/src/bridge/castTypes.ts | 54 -- .../bridge/{ => components}/airplay/auth.ts | 0 .../bridge/{ => components}/airplay/bplist.ts | 0 .../{ => components/chromecast}/Media.ts | 21 +- .../{ => components/chromecast}/Session.ts | 122 ++-- .../chromecast}/StatusListener.ts | 0 app/src/bridge/components/chromecast/index.ts | 79 +++ app/src/bridge/components/discovery.ts | 111 ++++ app/src/bridge/components/mediaServer.ts | 157 +++++ app/src/bridge/components/receiverSelector.ts | 88 +++ app/src/bridge/index.ts | 564 +----------------- app/src/bridge/lib/messaging.ts | 15 + app/src/bridge/lib/subtitles.ts | 56 ++ app/src/bridge/types.ts | 283 ++++++++- app/src/{daemon/index.ts => daemon.ts} | 2 +- 15 files changed, 875 insertions(+), 677 deletions(-) delete mode 100644 app/src/bridge/castTypes.ts rename app/src/bridge/{ => components}/airplay/auth.ts (100%) rename app/src/bridge/{ => components}/airplay/bplist.ts (100%) rename app/src/bridge/{ => components/chromecast}/Media.ts (81%) rename app/src/bridge/{ => components/chromecast}/Session.ts (72%) rename app/src/bridge/{ => components/chromecast}/StatusListener.ts (100%) create mode 100644 app/src/bridge/components/chromecast/index.ts create mode 100644 app/src/bridge/components/discovery.ts create mode 100644 app/src/bridge/components/mediaServer.ts create mode 100644 app/src/bridge/components/receiverSelector.ts create mode 100644 app/src/bridge/lib/messaging.ts create mode 100644 app/src/bridge/lib/subtitles.ts rename app/src/{daemon/index.ts => daemon.ts} (96%) diff --git a/app/src/bridge/castTypes.ts b/app/src/bridge/castTypes.ts deleted file mode 100644 index 160685f..0000000 --- a/app/src/bridge/castTypes.ts +++ /dev/null @@ -1,54 +0,0 @@ -"use strict"; - -export interface ReceiverStatus { - volume: { - muted: boolean; - stepInterval: number; - controlType: string; - level: number; - }; - applications?: Array<{ - displayName: string; - statusText: string; - transportId: string; - isIdleScreen: boolean; - sessionId: string; - namespaces: Array<{ name: string }>; - appId: string; - }>; - userEq?: {}; -} - -export interface MediaStatus { - mediaSessionId: number; - supportedMediaCommands: number; - currentTime: number; - media: { - duration: number; - contentId: string; - streamType: string; - contentType: string; - }; - playbackRate: number; - volume: { - muted: boolean; - level: number; - }; - currentItemId: number; - idleReason: string; - playerState: string; - extendedStatus: { - playerState: string; - media: { - contentId: string; - streamType: string; - contentType: string; - metadata: { - images: Array<{ url: string }>; - metadataType: number; - artist: string; - title: string; - }; - } - }; -} diff --git a/app/src/bridge/airplay/auth.ts b/app/src/bridge/components/airplay/auth.ts similarity index 100% rename from app/src/bridge/airplay/auth.ts rename to app/src/bridge/components/airplay/auth.ts diff --git a/app/src/bridge/airplay/bplist.ts b/app/src/bridge/components/airplay/bplist.ts similarity index 100% rename from app/src/bridge/airplay/bplist.ts rename to app/src/bridge/components/airplay/bplist.ts diff --git a/app/src/bridge/Media.ts b/app/src/bridge/components/chromecast/Media.ts similarity index 81% rename from app/src/bridge/Media.ts rename to app/src/bridge/components/chromecast/Media.ts index 2bf7d6a..f6ad152 100644 --- a/app/src/bridge/Media.ts +++ b/app/src/bridge/components/chromecast/Media.ts @@ -1,14 +1,14 @@ "use strict"; -import { Channel } from "castv2"; +import castv2 from "castv2"; import Session from "./Session"; -import { Message - , SendMessageCallback } from "./types"; +import { Message } from "../../types"; +import { sendMessage } from "../../lib/messaging" -const MEDIA_NAMESPACE = "urn:x-cast:com.google.cast.media"; +const NS_MEDIA = "urn:x-cast:com.google.cast.media"; export interface UpdateMessageData { _volumeLevel?: number; @@ -25,15 +25,14 @@ export interface UpdateMessageData { export default class Media { - private channel: Channel; + private channel: castv2.Channel; constructor ( private referenceId: string - , private session: Session - , private sendMessageCallback: SendMessageCallback) { + , private session: Session) { - this.session.createChannel(MEDIA_NAMESPACE); - this.channel = this.session.channelMap.get(MEDIA_NAMESPACE)!; + this.session.createChannel(NS_MEDIA); + this.channel = this.session.channelMap.get(NS_MEDIA)!; this.channel.on("message", (data: any) => { if (data && data.type === "MEDIA_STATUS" @@ -88,8 +87,8 @@ export default class Media { } } - private sendMessage (subject: string, data: any = {}) { - this.sendMessageCallback({ + private sendMessage (subject: string, data: any) { + (sendMessage as any)({ subject , data , _id: this.referenceId diff --git a/app/src/bridge/Session.ts b/app/src/bridge/components/chromecast/Session.ts similarity index 72% rename from app/src/bridge/Session.ts rename to app/src/bridge/components/chromecast/Session.ts index 894a093..144ebbd 100644 --- a/app/src/bridge/Session.ts +++ b/app/src/bridge/components/chromecast/Session.ts @@ -2,8 +2,8 @@ import { Channel, Client } from "castv2"; -import { Message - , SendMessageCallback } from "./types"; +import { Message } from "../../types"; +import { sendMessage } from "../../lib/messaging"; export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection"; @@ -13,13 +13,6 @@ export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver"; export default class Session { public channelMap = new Map(); - public host: string; - public port: number; - - private sendMessageCallback: SendMessageCallback; - private sessionId: number; - private referenceId: string; - private client: Client; private clientConnection?: Channel; private clientHeartbeat?: Channel; @@ -34,92 +27,82 @@ export default class Session { private app: any; constructor ( - host: string - , port: number - , appId: string - , sessionId: number - , referenceId: string - , sendMessageCallback: SendMessageCallback) { - - this.host = host; - this.port = port; - - this.sessionId = sessionId; - this.referenceId = referenceId; - this.sendMessageCallback = sendMessageCallback; + public host: string + , public port: number + , private appId: string + , private sessionId: string + , private referenceId: string) { this.client = new Client(); + this.client.connect({ host, port }, this.onConnect.bind(this)); + } - this.client.connect({ host, port }, () => { - let transportHeartbeat: Channel; + private onConnect () { + let transportHeartbeat: Channel; - const sourceId = "sender-0"; - const destinationId = "receiver-0"; + const sourceId = "sender-0"; + const destinationId = "receiver-0"; - this.clientConnection = this.client.createChannel( + this.clientConnection = this.client.createChannel( sourceId, destinationId, NS_CONNECTION, "JSON"); - this.clientHeartbeat = this.client.createChannel( + this.clientHeartbeat = this.client.createChannel( sourceId, destinationId, NS_HEARTBEAT, "JSON"); - this.clientReceiver = this.client.createChannel( + this.clientReceiver = this.client.createChannel( sourceId, destinationId, NS_RECEIVER, "JSON"); - this.clientConnection.send({ type: "CONNECT" }); - this.clientHeartbeat.send({ type: "PING" }); + this.clientConnection.send({ type: "CONNECT" }); + this.clientHeartbeat.send({ type: "PING" }); - this.clientHeartbeatIntervalId = setInterval(() => { - if (transportHeartbeat) { - transportHeartbeat.send({ type: "PING" }); - } + this.clientHeartbeatIntervalId = setInterval(() => { + if (transportHeartbeat) { + transportHeartbeat.send({ type: "PING" }); + } - this.clientHeartbeat!.send({ type: "PING" }); - }, 5000); + this.clientHeartbeat!.send({ type: "PING" }); + }, 5000); - this.clientReceiver.send({ - type: "LAUNCH" - , appId - , requestId: 1 - }); + this.clientReceiver.send({ + type: "LAUNCH" + , appId: this.appId + , requestId: 1 + }); - this.clientReceiver.on("message", (message: any) => { - if (message.type === "RECEIVER_STATUS") { - this.sendMessage("shim:/session/updateStatus" - , message.status); - - if (!message.status.applications) { - return; - } + this.clientReceiver.on("message", (message: any) => { + if (message.type === "RECEIVER_STATUS") { + this.sendMessage("shim:/session/updateStatus", message.status); + if (message.status.applications) { const receiverApp = message.status.applications[0]; const receiverAppId = receiverApp.appId; - + this.app = receiverApp; - - if (receiverAppId !== appId) { + + if (receiverAppId !== this.appId) { // Close session this.sendMessage("shim:/session/stopped"); this.client.close(); clearInterval(this.clientHeartbeatIntervalId!); return; } - + if (!this.isSessionCreated) { this.isSessionCreated = true; - + this.transportId = this.app.transportId; this.clientId = `client-${Math.floor(Math.random() * 10e5)}`; - + this.transportConnection = this.client.createChannel( this.clientId, this.transportId! , NS_CONNECTION, "JSON"); transportHeartbeat = this.client.createChannel( this.clientId, this.transportId! , NS_HEARTBEAT, "JSON"); - + this.transportConnection.send({ type: "CONNECT" }); - + this.sessionId = this.app.sessionId; - + this.sendMessage("shim:/session/connected", { sessionId: this.app.sessionId , namespaces: this.app.namespaces @@ -128,7 +111,7 @@ export default class Session { }); } } - }); + } }); } @@ -169,25 +152,24 @@ export default class Session { public createChannel (namespace: string) { if (!this.channelMap.has(namespace)) { - this.channelMap.set(namespace - , this.client.createChannel( - this.clientId!, this.transportId!, namespace, "JSON")); + this.channelMap.set(namespace, this.client.createChannel( + this.clientId!, this.transportId! + , namespace, "JSON")); } } public close () { - this.clientConnection!.send({ type: "CLOSE" }); - if (this.transportConnection) { - this.transportConnection.send({ type: "CLOSE" }); - } + this.clientConnection?.send({ type: "CLOSE" }); + this.transportConnection?.send({ type: "CLOSE" }); } public stop () { - this.clientConnection!.send({ type: "STOP" }); + this.clientConnection?.send({ type: "STOP" }); } private sendMessage (subject: string, data: any = {}) { - this.sendMessageCallback({ + sendMessage({ + // @ts-ignore subject , data , _id: this.referenceId @@ -196,7 +178,7 @@ export default class Session { private _impl_addMessageListener (namespace: string) { this.createChannel(namespace); - this.channelMap.get(namespace)!.on("message", (data: any) => { + this.channelMap.get(namespace)?.on("message", (data: any) => { this.sendMessage("shim:/session/impl_addMessageListener", { namespace , data: JSON.stringify(data) diff --git a/app/src/bridge/StatusListener.ts b/app/src/bridge/components/chromecast/StatusListener.ts similarity index 100% rename from app/src/bridge/StatusListener.ts rename to app/src/bridge/components/chromecast/StatusListener.ts diff --git a/app/src/bridge/components/chromecast/index.ts b/app/src/bridge/components/chromecast/index.ts new file mode 100644 index 0000000..f998809 --- /dev/null +++ b/app/src/bridge/components/chromecast/index.ts @@ -0,0 +1,79 @@ +"use strict"; + +import castv2 from "castv2"; + +import Session, { NS_CONNECTION, NS_RECEIVER } from "./Session"; +import Media from "./Media"; +import { Receiver } from "../../types"; + + +// Existing counterpart Media/Session objects +const existingSessions: Map = new Map(); +const existingMedia: Map = new Map(); + +export function handleSessionMessage (message: any) { + if (!message._id) { + console.error("Session message missing _id"); + return; + } + + const sessionId = message._id; + + if (existingSessions.has(sessionId)) { + // Forward message to instance message handler + existingSessions.get(sessionId)?.messageHandler(message); + } else { + if (message.subject === "bridge:/session/initialize") { + existingSessions.set(sessionId, new Session( + message.data.address + , message.data.port + , message.data.appId + , message.data.sessionId + , sessionId)); + } + } +} + +export function handleMediaMessage (message: any) { + if (!message._id) { + console.error("Media message missing _id"); + return; + } + + const mediaId = message._id; + + if (existingMedia.has(mediaId)) { + // Forward message to instance message handler + existingMedia.get(mediaId)!.messageHandler(message); + } else { + if (message.subject === "bridge:/media/initialize") { + // Get Session object media belongs to + const parentSession = existingSessions.get( + message.data._internalSessionId); + + if (parentSession) { + // Create Media + existingMedia.set(mediaId, new Media( + mediaId + , parentSession)); + } + } + } +} + +export function stopReceiverApp (host: string, port: number) { + const client = new castv2.Client(); + + client.connect({ host, port }, () => { + const sourceId = "sender-0"; + const destinationId = "receiver-0"; + + const clientConnection = client.createChannel( + sourceId, destinationId, NS_CONNECTION, "JSON"); + const clientReceiver = client.createChannel( + sourceId, destinationId, NS_RECEIVER, "JSON"); + + clientConnection.send({ type: "CONNECT" }); + clientReceiver.send({ type: "STOP", requestId: 1 }); + }); +} diff --git a/app/src/bridge/components/discovery.ts b/app/src/bridge/components/discovery.ts new file mode 100644 index 0000000..ba1ae7d --- /dev/null +++ b/app/src/bridge/components/discovery.ts @@ -0,0 +1,111 @@ +"use strict"; + +import mdns from "mdns"; + +import StatusListener from "./chromecast/StatusListener"; +import { ReceiverStatus } from "../types"; +import { sendMessage } from "../lib/messaging"; + + +const browser = mdns.createBrowser(mdns.tcp("googlecast"), { + resolverSequence: [ + mdns.rst.DNSServiceResolve() + , "DNSServiceGetAddrInfo" in mdns.dns_sd + ? mdns.rst.DNSServiceGetAddrInfo() + // Some issues on Linux with IPv6, so restrict to IPv4 + : mdns.rst.getaddrinfo({ families: [ 4 ] }) + , mdns.rst.makeAddressesUnique() + ] +}); + +function onBrowserServiceUp (service: mdns.Service) { + sendMessage({ + subject: "main:/serviceUp" + , data: { + host: service.addresses[0] + , port: service.port + , id: service.txtRecord.id + , friendlyName: service.txtRecord.fn + } + }); +} + +function onBrowserServiceDown (service: mdns.Service) { + sendMessage({ + subject: "main:/serviceDown" + , data: { + id: service.txtRecord.id + } + }); +} + +browser.on("serviceUp", onBrowserServiceUp); +browser.on("servicedown", onBrowserServiceDown); + + +interface InitializeOptions { + shouldWatchStatus?: boolean; +} + +export function startDiscovery (options: InitializeOptions) { + if (options.shouldWatchStatus) { + browser.on("serviceUp", onStatusBrowserServiceUp); + browser.on("serviceDown", onStatusBrowserServiceDown); + } + + browser.start(); + + // Receiver status listeners for status mode + const statusListeners = new Map(); + + function onStatusBrowserServiceUp (service: mdns.Service) { + const { id } = service.txtRecord; + + const listener = new StatusListener( + service.addresses[0] + , service.port); + + listener.on("receiverStatus", (status: ReceiverStatus) => { + const receiverStatusMessage: any = { + subject: "main:/receiverStatus" + , data: { + id + , status: { + volume: { + level: status.volume.level + , muted: status.volume.muted + } + } + } + }; + + if (status.applications && status.applications.length) { + const application = status.applications[0]; + + receiverStatusMessage.data.status.application = { + appId: application.appId + , displayName: application.displayName + , isIdleScreen: application.isIdleScreen + , statusText: application.statusText + }; + } + + sendMessage(receiverStatusMessage); + }); + + statusListeners.set(id, listener); + } + + function onStatusBrowserServiceDown (service: mdns.Service) { + const { id } = service.txtRecord; + + if (statusListeners.has(id)) { + statusListeners.get(id)!.deregister(); + statusListeners.delete(id); + } + } +} + +export function stopDiscovery () { + browser.stop(); +} diff --git a/app/src/bridge/components/mediaServer.ts b/app/src/bridge/components/mediaServer.ts new file mode 100644 index 0000000..9be53c0 --- /dev/null +++ b/app/src/bridge/components/mediaServer.ts @@ -0,0 +1,157 @@ +"use strict"; + +import fs from "fs"; +import http from "http"; +import path from "path"; +import stream from "stream"; + +import mime from "mime-types"; + +import { sendMessage } from "../lib/messaging"; +import { convertSrtToVtt } from "../lib/subtitles"; + + +export let mediaServer: http.Server | undefined; + +export async function startMediaServer (filePath: string, port: number) { + if (mediaServer?.listening) { + await stopMediaServer(); + } + + let fileDir: string; + let fileName: string; + let fileSize: number; + + try { + const stat = await fs.promises.lstat(filePath); + + if (stat.isFile()) { + fileDir = path.dirname(filePath); + fileName = path.basename(filePath); + fileSize = stat.size; + } else { + console.error("Error: Media path is not a file."); + sendMessage({ + subject: "mediaCast:/mediaServer/error" + }); + + return; + } + } catch (err) { + console.error("Error: Failed to find media path."); + sendMessage({ + subject: "mediaCast:/mediaServer/error" + }); + + return; + } + + const contentType = mime.lookup(filePath); + if (!contentType) { + console.error("Error: Failed to find media type."); + sendMessage({ + subject: "mediaCast:/mediaServer/error" + }); + + return; + } + + /** + * Find any SubRip files within the same directory and + * convert to WebVTT source. + */ + const subtitles = new Map(); + try { + const dirEntries = await fs.promises.readdir( + fileDir, { withFileTypes: true }); + + for (const dirEntry of dirEntries) { + if (dirEntry.isFile() + && mime.lookup(dirEntry.name) === "application/x-subrip") { + + subtitles.set(dirEntry.name, await convertSrtToVtt( + path.join(fileDir, dirEntry.name))); + } + } + } catch (err) {} + + mediaServer = http.createServer(async (req, res) => { + if (!req.url) { + return; + } + + // Drop leading slash + if (req.url.startsWith("/")) { + req.url = req.url.slice(1); + } + + switch (req.url) { + case fileName: { + 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); + } + break; + } + + default: { + if (subtitles.has(req.url)) { + const vttSource = subtitles.get(req.url)!; + const vttStream = stream.Readable.from(vttSource); + + res.setHeader("Access-Control-Allow-Origin", "*"); + + vttStream.pipe(res); + } + + break; + } + } + }); + + mediaServer.on("listening", () => sendMessage({ + subject: "mediaCast:/mediaServer/started" + , data: { + mediaPath: fileName + , subtitlePaths: Array.from(subtitles.keys()) + } + })); + + mediaServer.on("close", () => sendMessage({ + subject: "mediaCast:/mediaServer/stopped" + })); + mediaServer.on("error", () => sendMessage({ + subject: "mediaCast:/mediaServer/error" + })); + + mediaServer.listen(port); +} + +export function stopMediaServer () { + if (mediaServer?.listening) { + mediaServer.close(); + mediaServer = undefined; + } +} diff --git a/app/src/bridge/components/receiverSelector.ts b/app/src/bridge/components/receiverSelector.ts new file mode 100644 index 0000000..59d42b7 --- /dev/null +++ b/app/src/bridge/components/receiverSelector.ts @@ -0,0 +1,88 @@ +"use strict"; + +import child_process from "child_process"; +import path from "path"; + +import { sendMessage } from "../lib/messaging"; + + +function fatal (message: string) { + console.error(message); + process.exit(1); +} + + +let selectorApp: child_process.ChildProcess | undefined; +let selectorAppOpen = false; + +export function startReceiverSelector (data: string) { + if (process.platform !== "darwin") { + fatal("Invalid platform for native receiver selector."); + } + + if (!data) { + fatal("Missing native selector data"); + } else { + try { + JSON.parse(data); + } catch (err) { + fatal("Invalid native selector data."); + } + } + + if (selectorApp && selectorAppOpen) { + selectorApp.kill(); + selectorAppOpen = false; + } + + const selectorPath = path.join(process.cwd() + , "fx_cast_selector.app/Contents/MacOS/fx_cast_selector"); + + selectorApp = child_process.spawn(selectorPath, [ data ]); + selectorAppOpen = true; + + if (selectorApp.stdout) { + selectorApp.stdout.setEncoding("utf-8"); + selectorApp.stdout.on("data", data => { + const jsonData = JSON.parse(data); + + if (!jsonData.mediaType) { + sendMessage({ + subject: "main:/receiverSelector/stop" + , data: jsonData + }); + + return; + } + + sendMessage({ + subject: "main:/receiverSelector/selected" + , data: jsonData + }); + }); + } + + selectorApp.on("error", err => { + sendMessage({ + subject: "main:/receiverSelector/error" + , data: err.message + }); + }); + + selectorApp.on("close", () => { + if (selectorAppOpen) { + selectorAppOpen = false; + + sendMessage({ + subject: "main:/receiverSelector/close" + }); + } + }); +} + +export function stopReceiverSelector () { + if (!selectorApp?.killed) { + selectorApp?.kill(); + selectorAppOpen = false; + } +} diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts index 1dfcad8..da82d57 100755 --- a/app/src/bridge/index.ts +++ b/app/src/bridge/index.ts @@ -1,99 +1,22 @@ -import mdns from "mdns"; +"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 stream from "stream"; +import { decodeTransform, encodeTransform } from "./lib/messaging"; +import { Message } from "./types"; -import Media from "./Media"; -import Session from "./Session"; -import StatusListener from "./StatusListener"; +import { handleSessionMessage, handleMediaMessage, stopReceiverApp } + from "./components/chromecast"; +import { startDiscovery, stopDiscovery } from "./components/discovery"; +import { startMediaServer, stopMediaServer } from "./components/mediaServer"; +import { startReceiverSelector, stopReceiverSelector } + from "./components/receiverSelector"; -import { DecodeTransform - , EncodeTransform } from "../transforms"; - -import { ReceiverStatus } from "./castTypes"; -import { Message, Receiver } from "./types"; - -import { __applicationName - , __applicationVersion } from "../../package.json"; - -import { Channel, Client } from "castv2"; -import { NS_CONNECTION - , NS_HEARTBEAT - , NS_RECEIVER } from "./Session"; - - -// Increase listener limit -events.EventEmitter.defaultMaxListeners = 50; - - -const decodeTransform = new DecodeTransform(); -const encodeTransform = new EncodeTransform(); - -// stdin -> stdout -process.stdin.pipe(decodeTransform); -decodeTransform.on("data", handleMessage); -encodeTransform.pipe(process.stdout); - -decodeTransform.on("error", err => { - console.error("Failed to decode message", err); -}); - -/** - * 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: string | object) { - try { - if (typeof message === "string") { - encodeTransform.write({ - subject: message - }); - } else { - encodeTransform.write(message); - } - } catch (err) { - console.error("Failed to encode message", err); - } -} - - -interface InitializeOptions { - shouldWatchStatus?: boolean; -} - - -let receiverSelectorApp: child_process.ChildProcess; -let receiverSelectorAppClosed = true; - -// Local media server -let mediaServer: http.Server; - -let browser: mdns.Browser; - - -// Existing counterpart Media/Session objects -const existingSessions: Map = new Map(); -const existingMedia: Map = new Map(); +import { __applicationName, __applicationVersion} from "../../package.json"; process.on("SIGTERM", () => { - if (mediaServer && mediaServer.listening) { - mediaServer.close(); - } - - if (receiverSelectorApp && !receiverSelectorAppClosed) { - receiverSelectorApp.kill(); - } - - if (browser) { - browser.stop(); - } + stopDiscovery(); + stopMediaServer(); + stopReceiverSelector(); }); @@ -104,71 +27,16 @@ process.on("SIGTERM", () => { * Initializes the counterpart objects and is responsible * for managing existing ones. */ -async function handleMessage (message: Message) { - if (message.subject.startsWith("bridge:/media/")) { - if (!message._id) { - console.error("Media message missing _id"); - return; - } - - const mediaId = message._id; - - if (existingMedia.has(mediaId)) { - // Forward message to instance message handler - existingMedia.get(mediaId)!.messageHandler(message); - } else { - if (message.subject.endsWith("/initialize")) { - // Get Session object media belongs to - const parentSession = existingSessions.get( - message.data._internalSessionId); - - if (parentSession) { - // Create Media - existingMedia.set(mediaId, new Media( - mediaId - , parentSession - , sendMessage)); - } - } - } - - return; - } - +decodeTransform.on("data", (message: Message) => { if (message.subject.startsWith("bridge:/session/")) { - if (!message._id) { - console.error("Session message missing _id"); - return; - } - - const sessionId = message._id; - - if (existingSessions.has(sessionId)) { - // Forward message to instance message handler - existingSessions.get(sessionId)!.messageHandler(message); - } else { - if (message.subject.endsWith("/initialize")) { - // Create Session - existingSessions.set(sessionId, new Session( - message.data.address - , message.data.port - , message.data.appId - , message.data.sessionId - , sessionId - , sendMessage)); - } - } - + handleSessionMessage(message); + return; + } + if (message.subject.startsWith("bridge:/media/")) { + handleMediaMessage(message); return; } - if (message.subject.startsWith("bridge:/receiverSelector/")) { - handleReceiverSelectorMessage(message); - } - - if (message.subject.startsWith("bridge:/mediaServer/")) { - handleMediaServerMessage(message); - } switch (message.subject) { case "bridge:/getInfo": { @@ -177,406 +45,32 @@ async function handleMessage (message: Message) { } case "bridge:/initialize": { - const options: InitializeOptions = message.data; - initialize(options); - + startDiscovery(message.data); break; } case "bridge:/stopReceiverApp": { - const receiver: Receiver = message.data.receiver; - const client = new Client(); - - client.connect({ host: receiver.host, port: receiver.port }, () => { - const sourceId = "sender-0"; - const destinationId = "receiver-0"; - - const clientConnection = client.createChannel( - sourceId, destinationId, NS_CONNECTION, "JSON"); - const clientReceiver = client.createChannel( - sourceId, destinationId, NS_RECEIVER, "JSON"); - - clientConnection.send({ type: "CONNECT" }); - clientReceiver.send({ type: "STOP", requestId: 1 }); - }); - + stopReceiverApp(message.data.receiver.host + , message.data.receiver.port); break; } - } -} -function handleReceiverSelectorMessage (message: Message) { - switch (message.subject) { + // Receiver selector 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."); - } - } - - // Kill existing process if it exists - if (receiverSelectorApp && !receiverSelectorAppClosed) { - receiverSelectorApp.kill(); - } - - const receiverSelectorPath = path.join(process.cwd() - , "fx_cast_selector.app/Contents/MacOS/fx_cast_selector"); - - receiverSelectorApp = child_process.spawn( - receiverSelectorPath - , [ receiverSelectorData ]); - - receiverSelectorAppClosed = false; - - receiverSelectorApp.stdout!.setEncoding("utf8"); - receiverSelectorApp.stdout!.on("data", data => { - const parsedData = JSON.parse(data); - - sendMessage({ - subject: !parsedData.mediaType - ? "main:/receiverSelector/stop" - : "main:/receiverSelector/selected" - , data: parsedData - }); - }); - - receiverSelectorApp.on("error", err => { - sendMessage({ - subject: "main:/receiverSelector/error" - , data: err.message - }); - }); - - receiverSelectorApp.on("close", () => { - if (!receiverSelectorAppClosed) { - receiverSelectorAppClosed = true; - - sendMessage({ - subject: "main:/receiverSelector/close" - }); - } - }); - - break; + startReceiverSelector(message.data); break; } - case "bridge:/receiverSelector/close": { - receiverSelectorApp.kill(); - receiverSelectorAppClosed = true; - - break; - } - } -} - -async function handleMediaServerMessage (message: Message) { - async function convertSrtToVtt (srtFilePath: string) { - const fileStream = fs.createReadStream( - srtFilePath, { encoding: "utf-8" }); - - let fileContents = ""; - for await (let chunk of fileStream) { - // Omit BOM if present - if (!fileContents && chunk[0] === "\uFEFF") { - chunk = chunk.slice(1); - } - - // Normalize line endings - fileContents += chunk.replace(/$\r\n/gm, "\n"); + stopReceiverSelector(); break; } - - let vttText = "WEBVTT\n"; - - /** - * Matches a caption group within an SubRip file. Match groups - * are the index (followed by a new line), the time range - * (followed by a new line), then any text content until a blank - * line. - */ - const REGEX_CAPTION = /(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g; - - /** - * WebVTT is very similar to SubRip, the main differences being - * the "WEBVTT" specifier and optional metadata at the head of - * the file, the optional caption indicies and the timecode - * millisecond separator. - */ - for (const groups of fileContents.matchAll(REGEX_CAPTION)) { - const captionSource = groups[0]; - const captionIndex = groups[1]; - const captionTime = groups[2]; - const captionText = groups[3]; - - vttText += `\n${captionIndex}\n`; - vttText += `${captionTime.replace(/,/g, ".")}\n`; - - if (captionText) { - vttText += `${captionText}`; - } - } - - return vttText; - } - - switch (message.subject) { + // Media server case "bridge:/mediaServer/start": { - const { filePath, port } - : { filePath: string, port: number } = message.data; - - if (mediaServer && mediaServer.listening) { - mediaServer.close(); - } - - - let fileDir: string; - let fileName: string; - let fileSize: number; - - try { - const stat = await fs.promises.lstat(filePath); - - if (stat.isFile()) { - fileDir = path.dirname(filePath); - fileName = path.basename(filePath); - fileSize = stat.size; - } else { - console.error("Error: Media path is not a file."); - sendMessage("mediaCast:/mediaServer/error"); - break; - } - } catch (err) { - console.error("Error: Failed to find media path."); - sendMessage("mediaCast:/mediaServer/error"); - break; - } - - const contentType = mime.lookup(filePath); - if (!contentType) { - sendMessage("mediaCast:/mediaServer/error"); - break; - } - - - // file name -> file contents - const subtitles = new Map(); - - try { - const dirEntries = await fs.promises.readdir( - fileDir, { withFileTypes: true }); - - /** - * Find any SubRip files within the same directory and - * convert to WebVTT source. - */ - for (const dirEntry of dirEntries) { - if (dirEntry.isFile() - && mime.lookup(dirEntry.name) === "application/x-subrip") { - - subtitles.set(dirEntry.name, await convertSrtToVtt( - path.join(fileDir, dirEntry.name))); - } - } - } catch (err) { - // Subtitles optional - } - - - mediaServer = http.createServer(async (req, res) => { - if (!req.url) { - return; - } - - // Drop leading slash - if (req.url.startsWith("/")) { - req.url = req.url.slice(1); - } - - switch (req.url) { - case fileName: { - 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); - } - break; - } - - default: { - if (subtitles.has(req.url)) { - const vttSource = subtitles.get(req.url)!; - const vttStream = stream.Readable.from(vttSource); - - res.setHeader("Access-Control-Allow-Origin", "*"); - - vttStream.pipe(res); - } - - break; - } - } - }); - - mediaServer.on("listening", () => { - sendMessage({ - subject: "mediaCast:/mediaServer/started" - , data: { - mediaPath: fileName - , subtitlePaths: Array.from(subtitles.keys()) - } - }); - }); - mediaServer.on("close", () => { - sendMessage("mediaCast:/mediaServer/stopped"); - }); - mediaServer.on("error", () => { - sendMessage("mediaCast:/mediaServer/error"); - }); - - mediaServer.listen(port); - + startMediaServer(message.data.filePath, message.data.port); break; } - case "bridge:/mediaServer/stop": { - if (mediaServer && mediaServer.listening) { - mediaServer.close(); - } - + stopMediaServer(); break; } } -} - - -function initialize (options: InitializeOptions) { - browser = mdns.createBrowser(mdns.tcp("googlecast"), { - resolverSequence: [ - mdns.rst.DNSServiceResolve() - , "DNSServiceGetAddrInfo" in mdns.dns_sd - ? mdns.rst.DNSServiceGetAddrInfo() - // Some issues on Linux with IPv6, so restrict to IPv4 - : mdns.rst.getaddrinfo({ families: [ 4 ] }) - , mdns.rst.makeAddressesUnique() - ] - }); - - browser.on("error", (err: any) => { - console.error("Discovery failed", err); - }); - - if (options.shouldWatchStatus) { - browser.on("serviceUp", onStatusBrowserServiceUp); - browser.on("serviceDown", onStatusBrowserServiceDown); - } - - browser.on("serviceUp", onBrowserServiceUp); - browser.on("servicedown", onBrowserServiceDown); - browser.start(); - - - function onBrowserServiceUp (service: mdns.Service) { - sendMessage({ - subject: "main:/serviceUp" - , data: { - host: service.addresses[0] - , port: service.port - , id: service.txtRecord.id - , friendlyName: service.txtRecord.fn - } - }); - } - - function onBrowserServiceDown (service: mdns.Service) { - sendMessage({ - subject: "main:/serviceDown" - , data: { - id: service.txtRecord.id - } - }); - } - - - // Receiver status listeners for status mode - const statusListeners = new Map(); - - function onStatusBrowserServiceUp (service: mdns.Service) { - const { id } = service.txtRecord; - - const listener = new StatusListener( - service.addresses[0] - , service.port); - - listener.on("receiverStatus", (status: ReceiverStatus) => { - const receiverStatusMessage: any = { - subject: "main:/receiverStatus" - , data: { - id - , status: { - volume: { - level: status.volume.level - , muted: status.volume.muted - } - } - } - }; - - if (status.applications && status.applications.length) { - const application = status.applications[0]; - - receiverStatusMessage.data.status.application = { - appId: application.appId - , displayName: application.displayName - , isIdleScreen: application.isIdleScreen - , statusText: application.statusText - }; - } - - sendMessage(receiverStatusMessage); - }); - - statusListeners.set(id, listener); - } - - function onStatusBrowserServiceDown (service: mdns.Service) { - const { id } = service.txtRecord; - - if (statusListeners.has(id)) { - statusListeners.get(id)!.deregister(); - statusListeners.delete(id); - } - } -} +}); diff --git a/app/src/bridge/lib/messaging.ts b/app/src/bridge/lib/messaging.ts new file mode 100644 index 0000000..419f2e4 --- /dev/null +++ b/app/src/bridge/lib/messaging.ts @@ -0,0 +1,15 @@ +"use strict"; + +import { DecodeTransform, EncodeTransform } from "../../transforms"; +import { Message } from "../types"; + + +export const decodeTransform = new DecodeTransform(); +export const encodeTransform = new EncodeTransform(); + +process.stdin.pipe(decodeTransform); +encodeTransform.pipe(process.stdout); + +export function sendMessage (message: Message) { + encodeTransform.write(message); +} diff --git a/app/src/bridge/lib/subtitles.ts b/app/src/bridge/lib/subtitles.ts new file mode 100644 index 0000000..fe8c230 --- /dev/null +++ b/app/src/bridge/lib/subtitles.ts @@ -0,0 +1,56 @@ +"use strict"; + +import fs from "fs"; + + +/** + * Reads a SubRip file and outputs text content as WebVTT. + */ +export async function convertSrtToVtt (srtFilePath: string) { + const fileStream = fs.createReadStream( + srtFilePath, { encoding: "utf-8" }); + + let fileContents = ""; + for await (let chunk of fileStream) { + // Omit BOM if present + if (!fileContents && chunk[0] === "\uFEFF") { + chunk = chunk.slice(1); + } + + // Normalize line endings + fileContents += chunk.replace(/$\r\n/gm, "\n"); + } + + + let vttText = "WEBVTT\n"; + + /** + * Matches a caption group within an SubRip file. Match groups + * are the index (followed by a new line), the time range + * (followed by a new line), then any text content until a blank + * line. + */ + const REGEX_CAPTION = /(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g; + + /** + * WebVTT is very similar to SubRip, the main differences being + * the "WEBVTT" specifier and optional metadata at the head of + * the file, the optional caption indicies and the timecode + * millisecond separator. + */ + for (const groups of fileContents.matchAll(REGEX_CAPTION)) { + const captionSource = groups[0]; + const captionIndex = groups[1]; + const captionTime = groups[2]; + const captionText = groups[3]; + + vttText += `\n${captionIndex}\n`; + vttText += `${captionTime.replace(/,/g, ".")}\n`; + + if (captionText) { + vttText += `${captionText}`; + } + } + + return vttText; +} diff --git a/app/src/bridge/types.ts b/app/src/bridge/types.ts index a6ce696..b03e21a 100644 --- a/app/src/bridge/types.ts +++ b/app/src/bridge/types.ts @@ -1,11 +1,80 @@ "use strict"; -import { ReceiverStatus } from "./castTypes"; +export interface ReceiverStatus { + volume: { + muted: boolean; + stepInterval: number; + controlType: string; + level: number; + }; + applications?: Array<{ + displayName: string; + statusText: string; + transportId: string; + isIdleScreen: boolean; + sessionId: string; + namespaces: Array<{ name: string }>; + appId: string; + }>; + userEq?: {}; +} -export interface Message { - subject: string; - data?: any; - _id?: string; +export interface MediaStatus { + mediaSessionId: number; + supportedMediaCommands: number; + currentTime: number; + media: { + duration: number; + contentId: string; + streamType: string; + contentType: string; + }; + playbackRate: number; + volume: { + muted: boolean; + level: number; + }; + currentItemId: number; + idleReason: string; + playerState: string; + extendedStatus: { + playerState: string; + media: { + contentId: string; + streamType: string; + contentType: string; + metadata: { + images: Array<{ url: string }>; + metadataType: number; + artist: string; + title: string; + }; + } + }; +} + +export enum ReceiverSelectorMediaType { + App = 1 + , Tab = 2 + , Screen = 4 + , File = 8 +} + +export enum ReceiverSelectionActionType { + Cast = 1 + , Stop = 2 +} + +export interface ReceiverSelectionCast { + actionType: ReceiverSelectionActionType.Cast; + receiver: Receiver; + mediaType: ReceiverSelectorMediaType; + filePath?: string; +} + +export interface ReceiverSelectionStop { + actionType: ReceiverSelectionActionType.Stop; + receiver: Receiver; } export interface Receiver { @@ -16,4 +85,206 @@ export interface Receiver { status?: ReceiverStatus; } -export type SendMessageCallback = (message: Message) => void; + +export type Messages = [ + { + subject: "shim:/serviceUp" + , data: { id: Receiver["id"] } + } + , { + subject: "shim:/serviceDown" + , data: { id: Receiver["id"] } + } + , { + subject: "shim:/launchApp" + , data: { receiver: Receiver } + } + + // Session messages + , { + subject: "shim:/session/stopped" + } + , { + subject: "shim:/session/connected" + , data: { + sessionId: string; + namespaces: Array<{ name: string }>; + displayName: string; + statusText: string; + } + } + , { + subject: "shim:/session/updateStatus" + , data: any + } + , { + subject: "shim:/session/impl_addMessageListener" + , data: { namespace: string, data: string } + } + , { + subject: "shim:/session/impl_sendMessage" + , data: { messageId: string, error: boolean } + } + , { + subject: "shim:/session/impl_setReceiverMuted" + , data: { volumeId: string, error: boolean } + } + , { + subject: "shim:/session/impl_setReceiverVolumeLevel" + , data: { volumeId: string, error: boolean } + } + , { + subject: "shim:/session/impl_stop" + , data: { stopId: string, error: boolean } + } + + // Bridge session messages + , { + subject: "bridge:/session/initialize" + , data: { + address: string + , port: number + , appId: string + , sessionId: string + } + , _id: string; + } + , { + subject: "bridge:/session/close" + , _id: string; + } + , { + subject: "bridge:/session/impl_leave" + , data: { id: string } + , _id: string + } + , { + subject: "bridge:/session/impl_sendMessage" + , data: { namespace: string, message: any, messageId: string } + , _id: string + } + , { + subject: "bridge:/session/impl_setReceiverMuted" + , data: { muted: boolean, volumeId: string } + , _id: string + } + , { + subject: "bridge:/session/impl_setReceiverVolumeLevel" + , data: { newLevel: number, volumeId: string } + , _id: string + } + , { + subject: "bridge:/session/impl_stop" + , data: { stopId: string } + , _id: string + } + , { + subject: "bridge:/session/impl_addMessageListener" + , data: { namespace: string } + , _id: string + } + + // Media messages + , { + subject: "shim:/media/update" + , data: { + currentTime: number + , _lastCurrentTime: number + , customData: any + , playbackRate: number + , playerState: string + , repeatMode: string + , _volumeLevel: number + , _volumeMuted: boolean + , media: any + , mediaSessionId: number + } + } + , { + subject: "shim:/media/sendMediaMessageResponse" + , data: { messageId: string, error: boolean } + } + + // Bridge media messages + , { + subject: "bridge:/media/initialize" + , data: { + sessionId: string + , mediaSessionId: number + , _internalSessionId: string + } + , _id: string; + } + , { + subject: "bridge:/media/sendMediaMessage" + , data: { message: any, messageId: string } + , _id: string; + } + + // Bridge messages + , { + subject: "main:/receiverSelector/selected" + , data: ReceiverSelectionCast + } + , { + subject: "main:/receiverSelector/error" + , data: string + } + , { + subject: "main:/receiverSelector/close" + } + , { + subject: "main:/receiverSelector/stop" + , data: ReceiverSelectionStop + } + , { + subject: "bridge:/getInfo" + } + , { + subject: "bridge:/initialize" + , data: { shouldWatchStatus: boolean } + } + , { + subject: "bridge:/receiverSelector/open" + , data: any } + , { + subject: "bridge:/receiverSelector/close" + } + , { + subject: "bridge:/stopReceiverApp" + , data: { receiver: Receiver } + } + , { + subject: "bridge:/mediaServer/start" + , data: { filePath: string, port: number } + } + , { + subject: "bridge:/mediaServer/stop" + } + + , { + subject: "mediaCast:/mediaServer/started" + , data: { mediaPath: string, subtitlePaths: string[] } + } + , { + subject: "mediaCast:/mediaServer/stopped" + } + , { + subject: "mediaCast:/mediaServer/error" + } + + , { + subject: "main:/serviceUp" + , data: Receiver + } + , { + subject: "main:/serviceDown" + , data: { id: string } + } + , { + subject: "main:/receiverStatus" + , data: { id: string, status: ReceiverStatus } + } +]; + +export type Message = Messages[number]; diff --git a/app/src/daemon/index.ts b/app/src/daemon.ts similarity index 96% rename from app/src/daemon/index.ts rename to app/src/daemon.ts index f1233e6..d99b35d 100644 --- a/app/src/daemon/index.ts +++ b/app/src/daemon.ts @@ -7,7 +7,7 @@ import minimist from "minimist"; import WebSocket from "ws"; import { DecodeTransform - , EncodeTransform } from "../transforms"; + , EncodeTransform } from "./transforms"; export function init (port: number) {