Files
fx_cast/app/src/bridge/index.ts

407 lines
11 KiB
TypeScript
Executable File

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 Session from "./Session";
import StatusListener from "./StatusListener";
import { DecodeTransform
, EncodeTransform } from "../transforms";
import { ReceiverStatus } from "./castTypes";
import { Message } from "./types";
import { __applicationName
, __applicationVersion } from "../../package.json";
// 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: dnssd.Browser;
// Existing counterpart Media/Session objects
const existingSessions: Map<string, Session> = new Map();
const existingMedia: Map<string, Media> = new Map();
process.on("SIGTERM", () => {
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
if (receiverSelectorApp && !receiverSelectorAppClosed) {
receiverSelectorApp.kill()
}
browser.stop();
});
/**
* 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 (!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;
}
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));
}
}
return;
}
if (message.subject.startsWith("bridge:/receiverSelector/")) {
handleReceiverSelectorMessage(message);
}
if (message.subject.startsWith("bridge:/mediaServer/")) {
handleMediaServerMessage(message);
}
switch (message.subject) {
case "bridge:/getInfo": {
encodeTransform.write(__applicationVersion);
}
case "bridge:/initialize": {
const options: InitializeOptions = message.data;
initialize(options);
break;
}
}
}
function handleReceiverSelectorMessage (message: Message) {
switch (message.subject) {
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();
}
receiverSelectorApp = child_process.spawn(
path.join(process.cwd(), "selector")
, [ receiverSelectorData ]);
receiverSelectorAppClosed = false;
receiverSelectorApp.stdout!.setEncoding("utf8");
receiverSelectorApp.stdout!.on("data", data => {
sendMessage({
subject: "main:/receiverSelector/selected"
, data: JSON.parse(data)
});
});
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;
}
case "bridge:/receiverSelector/close": {
receiverSelectorApp.kill();
receiverSelectorAppClosed = true;
break;
}
}
}
function handleMediaServerMessage (message: Message) {
switch (message.subject) {
case "bridge:/mediaServer/start": {
const { filePath, port }
: { filePath: string, port: number } = message.data;
const contentType = mime.lookup(filePath);
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("listening", () => {
sendMessage("mediaCast:/mediaServer/started");
});
mediaServer.on("close", () => {
sendMessage("mediaCast:/mediaServer/stopped");
});
mediaServer.on("error", () => {
sendMessage("mediaCast:/mediaServer/error");
});
mediaServer.listen(port);
break;
}
case "bridge:/mediaServer/stop": {
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
break;
}
}
}
function initialize (options: InitializeOptions) {
browser = new dnssd.Browser(dnssd.tcp("googlecast"));
browser.on("error", err => {
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: 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<string, StatusListener>();
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 (status.applications && status.applications.length) {
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);
}
}
}