mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-11 10:09:59 +00:00
App refactor (#140)
* Add additional types * Split components from single index module into smaller modules * Misc smaller changes
This commit is contained in:
234
app/src/bridge/components/airplay/auth.ts
Normal file
234
app/src/bridge/components/airplay/auth.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* AirPlay device auth implementation.
|
||||
*
|
||||
* References:
|
||||
* - https://htmlpreview.github.io/?https://github.com/philippe44/RAOP-Player/blob/master/doc/auth_protocol.html
|
||||
* - https://github.com/funtax/AirPlayAuth
|
||||
* - https://github.com/ldiqual/chrome-airplay
|
||||
* - https://github.com/postlund/pyatv/blob/master/docs/airplay.rst
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import srp6a from "fast-srp-hap";
|
||||
import fetch, { Headers } from "node-fetch";
|
||||
import nacl from "tweetnacl";
|
||||
import bplist from "./bplist";
|
||||
|
||||
|
||||
const AIRPLAY_PORT = 7000;
|
||||
const MIMETYPE_BPLIST = "application/x-apple-binary-plist";
|
||||
|
||||
/**
|
||||
* Client ID and keypair
|
||||
*/
|
||||
export class AirPlayAuthCredentials {
|
||||
public clientId: string;
|
||||
public clientSk: Uint8Array;
|
||||
public clientPk: Uint8Array;
|
||||
|
||||
constructor (
|
||||
clientId?: string
|
||||
, clientSk?: Uint8Array
|
||||
, clientPk?: Uint8Array) {
|
||||
|
||||
if (clientId && clientSk && clientPk) {
|
||||
this.clientId = clientId;
|
||||
this.clientSk = clientSk;
|
||||
this.clientPk = clientPk;
|
||||
} else {
|
||||
// If specified without arguments, generate new credentials
|
||||
const keyPair = nacl.sign.keyPair();
|
||||
|
||||
// Random 16-len string
|
||||
this.clientId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.clientSk = keyPair.secretKey.slice(0, 32);
|
||||
this.clientPk = keyPair.publicKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AirPlayAuth {
|
||||
private address: string;
|
||||
private credentials: AirPlayAuthCredentials;
|
||||
private baseUrl: URL;
|
||||
|
||||
constructor (address: string, credentials: AirPlayAuthCredentials) {
|
||||
this.address = address;
|
||||
this.credentials = credentials;
|
||||
|
||||
this.baseUrl = new URL(`http://${this.address}:${AIRPLAY_PORT}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins pairing process.
|
||||
*/
|
||||
public async beginPairing () {
|
||||
return this.sendPostRequest("/pair-pin-start");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs client with receiver. Must be called after
|
||||
* beginPairing(). Coordinates the three pairing stages and
|
||||
* manages request responses.
|
||||
*/
|
||||
public async finishPairing (pin: string) {
|
||||
// Stage 1 response
|
||||
const { pk: serverPk
|
||||
, salt: serverSalt } = await this.pairSetupPin1();
|
||||
|
||||
// SRP params must 2048-bit SHA1
|
||||
const srpParams = srp6a.params[2048];
|
||||
srpParams.hash = "sha1";
|
||||
|
||||
// Create SRP client
|
||||
const srpClient = new srp6a.Client(
|
||||
srpParams // Params
|
||||
, serverSalt // Receiver salt
|
||||
, Buffer.from(this.credentials.clientId) // Username
|
||||
, Buffer.from(pin) // Password (receiver pin)
|
||||
, Buffer.from(this.credentials.clientSk)); // Client secret key
|
||||
|
||||
// Add receiver's public key
|
||||
srpClient.setB(serverPk);
|
||||
|
||||
// Stage 2 response
|
||||
await this.pairSetupPin2(
|
||||
srpClient.computeA() // SRP public key
|
||||
, srpClient.computeM1()); // SRP proof
|
||||
|
||||
// Stage 3 response
|
||||
await this.pairSetupPin3(srpClient.computeK());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing Stage 1
|
||||
* ---------------
|
||||
* Triggering the receiver passcode display and receiving
|
||||
* its public key / salt.
|
||||
*/
|
||||
public async pairSetupPin1 (): Promise<any> {
|
||||
const [ response ] = await this.sendPostRequestBplist(
|
||||
"/pair-setup-pin"
|
||||
, {
|
||||
method: "pin"
|
||||
, user: this.credentials.clientId
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing Stage 2
|
||||
* ---------------
|
||||
* Generating SRP public key and proof with the client/server
|
||||
* public keys, sending them to the receiver and receiving its
|
||||
* proof.
|
||||
*/
|
||||
public async pairSetupPin2 (
|
||||
pk: Buffer
|
||||
, proof: Buffer): Promise<any> {
|
||||
|
||||
const [ response ] = await this.sendPostRequestBplist(
|
||||
"/pair-setup-pin"
|
||||
, { pk, proof });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing Stage 3
|
||||
* ---------------
|
||||
* AES encoding the client public key with the SRP shared
|
||||
* secret hash and sending it to the receiver. Receiver then
|
||||
* responds confirming the pairing is complete.
|
||||
*/
|
||||
public async pairSetupPin3 (
|
||||
sharedSecretHash: crypto.BinaryLike): Promise<any> {
|
||||
|
||||
// Create AES key
|
||||
const aesKey = crypto.createHash("sha512")
|
||||
.update("Pair-Setup-AES-Key")
|
||||
.update(sharedSecretHash)
|
||||
.digest()
|
||||
.slice(0, 16);
|
||||
|
||||
// Create AES IV
|
||||
const aesIv = crypto.createHash("sha512")
|
||||
.update("Pair-Setup-AES-IV")
|
||||
.update(sharedSecretHash)
|
||||
.digest()
|
||||
.slice(0, 16);
|
||||
|
||||
aesIv[15]++;
|
||||
|
||||
|
||||
const cipher = crypto.createCipheriv("aes-128-gcm", aesKey, aesIv);
|
||||
|
||||
// Encode client public key
|
||||
const epk = cipher.update(this.credentials.clientPk);
|
||||
cipher.final();
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const [ response ] = await this.sendPostRequestBplist(
|
||||
"/pair-setup-pin"
|
||||
, { epk, authTag });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends a POST request to receiver and returns the
|
||||
* response.
|
||||
*/
|
||||
public async sendPostRequest (
|
||||
path: string
|
||||
, contentType?: string
|
||||
, data?: Buffer | string): Promise<any> {
|
||||
|
||||
// Create URL from base receiver URL and path
|
||||
const requestUrl = new URL(path, this.baseUrl);
|
||||
|
||||
const requestHeaders = new Headers({
|
||||
"User-Agent": "AirPlay/320.20"
|
||||
});
|
||||
|
||||
// Append Content-Type header if request has body
|
||||
if (data && contentType) {
|
||||
requestHeaders.append("Content-Type", contentType);
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl.href, {
|
||||
method: "POST"
|
||||
, headers: requestHeaders
|
||||
, body: data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AirPlay request error: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.buffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes binary plist data, sends a POST request to
|
||||
* receiver, then decodes and returns the response.
|
||||
*/
|
||||
public async sendPostRequestBplist (
|
||||
path: string
|
||||
, data?: object): Promise<any> {
|
||||
|
||||
// Convert data to compatible type
|
||||
const requestBody = data
|
||||
? bplist.create(data)
|
||||
: undefined;
|
||||
|
||||
const response = await this.sendPostRequest(
|
||||
path, MIMETYPE_BPLIST, requestBody);
|
||||
|
||||
// Convert response data to Buffer for bplist-parser
|
||||
return bplist.parse.parseBuffer(response);
|
||||
}
|
||||
}
|
||||
4
app/src/bridge/components/airplay/bplist.ts
Normal file
4
app/src/bridge/components/airplay/bplist.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import create from "bplist-creator";
|
||||
import parse from "bplist-parser";
|
||||
|
||||
export default { create, parse };
|
||||
97
app/src/bridge/components/chromecast/Media.ts
Normal file
97
app/src/bridge/components/chromecast/Media.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
import castv2 from "castv2";
|
||||
|
||||
import Session from "./Session";
|
||||
|
||||
import { Message } from "../../types";
|
||||
import { sendMessage } from "../../lib/messaging"
|
||||
|
||||
|
||||
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
||||
|
||||
export interface UpdateMessageData {
|
||||
_volumeLevel?: number;
|
||||
_volumeMuted?: boolean;
|
||||
_lastCurrentTime: number;
|
||||
currentTime: number;
|
||||
customData?: any;
|
||||
playbackRate: number;
|
||||
playerState: string;
|
||||
repeatMode?: string;
|
||||
media?: any;
|
||||
mediaSessionId?: number;
|
||||
}
|
||||
|
||||
|
||||
export default class Media {
|
||||
private channel: castv2.Channel;
|
||||
|
||||
constructor (
|
||||
private referenceId: string
|
||||
, private session: Session) {
|
||||
|
||||
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"
|
||||
&& data.status && data.status.length > 0) {
|
||||
|
||||
const status = data.status[0];
|
||||
|
||||
const messageData: UpdateMessageData = {
|
||||
_lastCurrentTime: Date.now() / 1000
|
||||
|
||||
, currentTime: status.currentTime
|
||||
, customData: status.customData
|
||||
, playbackRate: status.playbackRate
|
||||
, playerState: status.playerState
|
||||
, repeatMode: status.repeatMode
|
||||
};
|
||||
|
||||
if (status.volume) {
|
||||
messageData._volumeLevel = status.volume.level;
|
||||
messageData._volumeMuted = status.volume.muted;
|
||||
}
|
||||
|
||||
if (status.media) {
|
||||
messageData.media = status.media;
|
||||
}
|
||||
if (status.mediaSessionId) {
|
||||
messageData.mediaSessionId = status.mediaSessionId;
|
||||
}
|
||||
|
||||
this.sendMessage("shim:/media/update", messageData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public messageHandler (message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:/media/sendMediaMessage": {
|
||||
let error = false;
|
||||
try {
|
||||
this.channel.send(message.data.message);
|
||||
} catch (err) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
this.sendMessage("shim:/media/sendMediaMessageResponse", {
|
||||
messageId: message.data.messageId
|
||||
, error
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage (subject: string, data: any) {
|
||||
(sendMessage as any)({
|
||||
subject
|
||||
, data
|
||||
, _id: this.referenceId
|
||||
});
|
||||
}
|
||||
}
|
||||
276
app/src/bridge/components/chromecast/Session.ts
Normal file
276
app/src/bridge/components/chromecast/Session.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
"use strict";
|
||||
|
||||
import { Channel, Client } from "castv2";
|
||||
|
||||
import { Message } from "../../types";
|
||||
import { sendMessage } from "../../lib/messaging";
|
||||
|
||||
|
||||
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
|
||||
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
|
||||
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
|
||||
|
||||
export default class Session {
|
||||
public channelMap = new Map<string, Channel>();
|
||||
|
||||
private client: Client;
|
||||
private clientConnection?: Channel;
|
||||
private clientHeartbeat?: Channel;
|
||||
private clientReceiver?: Channel;
|
||||
private clientHeartbeatIntervalId?: NodeJS.Timeout;
|
||||
|
||||
private isSessionCreated = false;
|
||||
|
||||
private clientId?: string;
|
||||
private transportId?: string;
|
||||
private transportConnection?: Channel;
|
||||
private app: any;
|
||||
|
||||
constructor (
|
||||
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));
|
||||
}
|
||||
|
||||
private onConnect () {
|
||||
let transportHeartbeat: Channel;
|
||||
|
||||
const sourceId = "sender-0";
|
||||
const destinationId = "receiver-0";
|
||||
|
||||
this.clientConnection = this.client.createChannel(
|
||||
sourceId, destinationId, NS_CONNECTION, "JSON");
|
||||
this.clientHeartbeat = this.client.createChannel(
|
||||
sourceId, destinationId, NS_HEARTBEAT, "JSON");
|
||||
this.clientReceiver = this.client.createChannel(
|
||||
sourceId, destinationId, NS_RECEIVER, "JSON");
|
||||
|
||||
this.clientConnection.send({ type: "CONNECT" });
|
||||
this.clientHeartbeat.send({ type: "PING" });
|
||||
|
||||
this.clientHeartbeatIntervalId = setInterval(() => {
|
||||
if (transportHeartbeat) {
|
||||
transportHeartbeat.send({ type: "PING" });
|
||||
}
|
||||
|
||||
this.clientHeartbeat!.send({ type: "PING" });
|
||||
}, 5000);
|
||||
|
||||
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) {
|
||||
const receiverApp = message.status.applications[0];
|
||||
const receiverAppId = receiverApp.appId;
|
||||
|
||||
this.app = receiverApp;
|
||||
|
||||
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
|
||||
, displayName: this.app.displayName
|
||||
, statusText: this.app.displayName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public messageHandler (message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:/session/close":
|
||||
this.close();
|
||||
break;
|
||||
|
||||
case "bridge:/session/impl_addMessageListener":
|
||||
this._impl_addMessageListener(message.data.namespace);
|
||||
break;
|
||||
|
||||
case "bridge:/session/impl_sendMessage":
|
||||
this._impl_sendMessage(
|
||||
message.data.namespace
|
||||
, message.data.message
|
||||
, message.data.messageId);
|
||||
break;
|
||||
|
||||
case "bridge:/session/impl_setReceiverMuted":
|
||||
this._impl_setReceiverMuted(
|
||||
message.data.muted
|
||||
, message.data.volumeId);
|
||||
break;
|
||||
|
||||
case "bridge:/session/impl_setReceiverVolumeLevel":
|
||||
this._impl_setReceiverVolumeLevel(
|
||||
message.data.newLevel
|
||||
, message.data.volumeId);
|
||||
break;
|
||||
|
||||
case "bridge:/session/impl_stop":
|
||||
this._impl_stop(message.data.stopId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public createChannel (namespace: string) {
|
||||
if (!this.channelMap.has(namespace)) {
|
||||
this.channelMap.set(namespace, this.client.createChannel(
|
||||
this.clientId!, this.transportId!
|
||||
, namespace, "JSON"));
|
||||
}
|
||||
}
|
||||
|
||||
public close () {
|
||||
this.clientConnection?.send({ type: "CLOSE" });
|
||||
this.transportConnection?.send({ type: "CLOSE" });
|
||||
}
|
||||
|
||||
public stop () {
|
||||
this.clientConnection?.send({ type: "STOP" });
|
||||
}
|
||||
|
||||
private sendMessage (subject: string, data: any = {}) {
|
||||
sendMessage({
|
||||
// @ts-ignore
|
||||
subject
|
||||
, data
|
||||
, _id: this.referenceId
|
||||
});
|
||||
}
|
||||
|
||||
private _impl_addMessageListener (namespace: string) {
|
||||
this.createChannel(namespace);
|
||||
this.channelMap.get(namespace)?.on("message", (data: any) => {
|
||||
this.sendMessage("shim:/session/impl_addMessageListener", {
|
||||
namespace
|
||||
, data: JSON.stringify(data)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _impl_sendMessage (
|
||||
namespace: string
|
||||
, message: {} | string
|
||||
, messageId: string) {
|
||||
|
||||
let error = false;
|
||||
|
||||
try {
|
||||
// Decode string messages
|
||||
if (typeof message === "string") {
|
||||
message = JSON.parse(message);
|
||||
}
|
||||
|
||||
this.createChannel(namespace);
|
||||
this.channelMap.get(namespace)!.send(message);
|
||||
} catch (err) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
this.sendMessage("shim:/session/impl_sendMessage", {
|
||||
messageId
|
||||
, error
|
||||
});
|
||||
}
|
||||
|
||||
private _impl_setReceiverMuted (muted: boolean, volumeId: string) {
|
||||
|
||||
let error = false;
|
||||
|
||||
try {
|
||||
this.clientReceiver!.send({
|
||||
type: "SET_VOLUME"
|
||||
, volume: { muted }
|
||||
, requestId: 0
|
||||
});
|
||||
} catch (err) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
this.sendMessage("shim:/session/impl_setReceiverMuted", {
|
||||
volumeId
|
||||
, error
|
||||
});
|
||||
}
|
||||
|
||||
private _impl_setReceiverVolumeLevel (newLevel: number, volumeId: string) {
|
||||
|
||||
let error = false;
|
||||
|
||||
try {
|
||||
this.clientReceiver!.send({
|
||||
type: "SET_VOLUME"
|
||||
, volume: { level: newLevel }
|
||||
, requestId: 0
|
||||
});
|
||||
} catch (err) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
this.sendMessage("shim:/session/impl_setReceiverVolumeLevel", {
|
||||
volumeId
|
||||
, error
|
||||
});
|
||||
}
|
||||
|
||||
private _impl_stop (stopId: string) {
|
||||
let error = false;
|
||||
|
||||
try {
|
||||
this.clientReceiver!.send({
|
||||
type: "STOP"
|
||||
, sessionId: this.sessionId
|
||||
, requestId: 0
|
||||
});
|
||||
} catch (err) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
this.client.close();
|
||||
|
||||
clearInterval(this.clientHeartbeatIntervalId!);
|
||||
|
||||
this.sendMessage("shim:/session/impl_stop", {
|
||||
stopId
|
||||
, error
|
||||
});
|
||||
}
|
||||
}
|
||||
86
app/src/bridge/components/chromecast/StatusListener.ts
Normal file
86
app/src/bridge/components/chromecast/StatusListener.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
"use strict";
|
||||
|
||||
import { Channel, Client } from "castv2";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { NS_CONNECTION
|
||||
, NS_HEARTBEAT
|
||||
, NS_RECEIVER } from "./Session";
|
||||
|
||||
|
||||
/**
|
||||
* Creates a connection to a receiver device and forwards
|
||||
* RECEIVER_STATUS updates to the extension.
|
||||
*/
|
||||
export default class StatusListener extends EventEmitter {
|
||||
private client: Client;
|
||||
private clientReceiver?: Channel;
|
||||
private clientHeartbeatIntervalId?: NodeJS.Timeout;
|
||||
|
||||
constructor (host: string, port: number) {
|
||||
super();
|
||||
|
||||
this.client = new Client();
|
||||
this.client.connect({ host, port }, this.onConnect.bind(this));
|
||||
|
||||
this.client.on("close", () => {
|
||||
clearInterval(this.clientHeartbeatIntervalId!);
|
||||
});
|
||||
|
||||
this.client.on("error", () => {
|
||||
clearInterval(this.clientHeartbeatIntervalId!);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes status listener connection.
|
||||
*/
|
||||
public deregister (): void {
|
||||
if (this.clientReceiver) {
|
||||
this.clientReceiver.send({ type: "CLOSE" });
|
||||
}
|
||||
|
||||
this.client.close();
|
||||
}
|
||||
|
||||
|
||||
private onConnect (): void {
|
||||
const sourceId = "sender-0";
|
||||
const destinationId = "receiver-0";
|
||||
|
||||
const clientConnection = this.client.createChannel(
|
||||
sourceId, destinationId, NS_CONNECTION, "JSON");
|
||||
const clientHeartbeat = this.client.createChannel(
|
||||
sourceId, destinationId, NS_HEARTBEAT, "JSON");
|
||||
const clientReceiver = this.client.createChannel(
|
||||
sourceId, destinationId, NS_RECEIVER, "JSON");
|
||||
|
||||
clientReceiver.on("message", data => {
|
||||
switch (data.type) {
|
||||
case "CLOSE": {
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
|
||||
case "RECEIVER_STATUS": {
|
||||
this.emit("receiverStatus", data.status);
|
||||
break;
|
||||
}
|
||||
case "MEDIA_STATUS": {
|
||||
this.emit("mediaStatus", data.status);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
clientConnection.send({ type: "CONNECT" });
|
||||
clientHeartbeat.send({ type: "PING" });
|
||||
clientReceiver.send({ type: "GET_STATUS", requestId: 1 });
|
||||
|
||||
this.clientReceiver = clientReceiver;
|
||||
|
||||
this.clientHeartbeatIntervalId = setInterval(() => {
|
||||
clientHeartbeat.send({ type: "PING" });
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
79
app/src/bridge/components/chromecast/index.ts
Normal file
79
app/src/bridge/components/chromecast/index.ts
Normal file
@@ -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<string, Session> = new Map();
|
||||
const existingMedia: Map<string, Media> = 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 });
|
||||
});
|
||||
}
|
||||
111
app/src/bridge/components/discovery.ts
Normal file
111
app/src/bridge/components/discovery.ts
Normal file
@@ -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<string, StatusListener>();
|
||||
|
||||
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();
|
||||
}
|
||||
157
app/src/bridge/components/mediaServer.ts
Normal file
157
app/src/bridge/components/mediaServer.ts
Normal file
@@ -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<string, string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
app/src/bridge/components/receiverSelector.ts
Normal file
88
app/src/bridge/components/receiverSelector.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user