mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-10 01:29:58 +00:00
Rename directory: app -> bridge
This commit is contained in:
232
bridge/src/bridge/components/airplay/auth.ts
Normal file
232
bridge/src/bridge/components/airplay/auth.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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
bridge/src/bridge/components/airplay/bplist.ts
Normal file
4
bridge/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 };
|
||||
193
bridge/src/bridge/components/cast/Session.ts
Normal file
193
bridge/src/bridge/components/cast/Session.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { Channel } from "castv2";
|
||||
|
||||
import messaging from "../../messaging";
|
||||
|
||||
import type { ReceiverDevice } from "../../messagingTypes";
|
||||
import type { ReceiverMessage } from "./types";
|
||||
|
||||
import CastClient, { NS_CONNECTION, NS_HEARTBEAT } from "./client";
|
||||
|
||||
type OnSessionCreatedCallback = (sessionId: string) => void;
|
||||
|
||||
export default class Session extends CastClient {
|
||||
// Assigned by the receiver once the session is established
|
||||
public sessionId?: string;
|
||||
|
||||
// Receiver app messaging
|
||||
private transportId?: string;
|
||||
private transportConnection?: Channel;
|
||||
private transportHeartbeat?: Channel;
|
||||
|
||||
// Channels created by `sendCastSessionMessage` messages
|
||||
private namespaceChannelMap = new Map<string, Channel>();
|
||||
|
||||
/**
|
||||
* Request ID used to correlate the launch request with the
|
||||
* RECEIVER_STATUS message associated with session creation.
|
||||
*/
|
||||
private launchRequestId?: number;
|
||||
|
||||
private establishAppConnection(transportId: string) {
|
||||
this.transportConnection = this.createChannel(
|
||||
NS_CONNECTION,
|
||||
this.sourceId,
|
||||
transportId
|
||||
);
|
||||
this.transportHeartbeat = this.createChannel(
|
||||
NS_HEARTBEAT,
|
||||
this.sourceId,
|
||||
transportId
|
||||
);
|
||||
|
||||
this.transportConnection.send({ type: "CONNECT" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming receiver messages.
|
||||
*/
|
||||
private onReceiverMessage = (message: ReceiverMessage) => {
|
||||
switch (message.type) {
|
||||
case "RECEIVER_STATUS": {
|
||||
const { status } = message;
|
||||
const application = status.applications?.find(
|
||||
app => app.appId === this.appId
|
||||
);
|
||||
|
||||
/**
|
||||
* If application isn't set, still waiting on the launch
|
||||
* request response.
|
||||
*/
|
||||
if (!this.sessionId) {
|
||||
// Match request ID on the response to the launch request ID.
|
||||
if (message.requestId !== this.launchRequestId) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (application) {
|
||||
this.sessionId = application.sessionId;
|
||||
this.transportId = application.transportId;
|
||||
|
||||
this.establishAppConnection(this.transportId);
|
||||
this.onSessionCreated?.(this.sessionId);
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "main:castSessionCreated",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
statusText: application.statusText,
|
||||
namespaces: application.namespaces,
|
||||
volume: status.volume,
|
||||
appId: application.appId,
|
||||
displayName: application.displayName,
|
||||
receiverId: this.receiverDevice.id,
|
||||
receiverFriendlyName:
|
||||
this.receiverDevice.friendlyName,
|
||||
transportId: this.sessionId,
|
||||
|
||||
// TODO: Fix this
|
||||
senderApps: [],
|
||||
appImages: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle session stop
|
||||
if (!application) {
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "main:castSessionUpdated",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
statusText: application.statusText,
|
||||
namespaces: application.namespaces,
|
||||
volume: message.status.volume
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "LAUNCH_ERROR": {
|
||||
console.error(`err: LAUNCH_ERROR, ${message.reason}`);
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendMessage(namespace: string, message: unknown) {
|
||||
let channel = this.namespaceChannelMap.get(namespace);
|
||||
if (!channel) {
|
||||
channel = this.createChannel(
|
||||
namespace,
|
||||
this.sourceId,
|
||||
this.transportId
|
||||
);
|
||||
|
||||
channel.on("message", messageData => {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageData = JSON.stringify(messageData);
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "cast:sessionMessageReceived",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
namespace,
|
||||
messageData
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.namespaceChannelMap.set(namespace, channel);
|
||||
}
|
||||
|
||||
channel.send(message);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private appId: string,
|
||||
private receiverDevice: ReceiverDevice,
|
||||
private onSessionCreated?: OnSessionCreatedCallback
|
||||
) {
|
||||
super();
|
||||
|
||||
super
|
||||
.connect(receiverDevice.host, {
|
||||
onHeartbeat: () => {
|
||||
// Include transport heartbeat with platform heartbeat
|
||||
if (this.transportHeartbeat) {
|
||||
this.transportHeartbeat.send({ type: "PING" });
|
||||
}
|
||||
},
|
||||
onReceiverMessage: message => {
|
||||
this.onReceiverMessage(message);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Send a launch request and store the request ID for reference
|
||||
this.launchRequestId = this.sendReceiverMessage({
|
||||
type: "LAUNCH",
|
||||
appId: this.appId
|
||||
});
|
||||
});
|
||||
|
||||
// Handle client connection closed
|
||||
this.client.on("close", () => {
|
||||
if (this.sessionId) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:sessionStopped",
|
||||
data: { sessionId: this.sessionId }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
115
bridge/src/bridge/components/cast/client.ts
Normal file
115
bridge/src/bridge/components/cast/client.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Channel, Client } from "castv2";
|
||||
|
||||
import type { ReceiverMessage, SenderMessage } from "./types";
|
||||
|
||||
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";
|
||||
|
||||
const DEFAULT_PORT = 8009;
|
||||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||
|
||||
interface CastClientConnectOptions {
|
||||
port?: number;
|
||||
onReceiverMessage?: (message: ReceiverMessage) => void;
|
||||
onHeartbeat?: () => void;
|
||||
}
|
||||
|
||||
export default class CastClient {
|
||||
protected client = new Client();
|
||||
|
||||
protected connectionChannel?: Channel;
|
||||
protected heartbeatChannel?: Channel;
|
||||
protected heartbeatIntervalId?: NodeJS.Timeout;
|
||||
|
||||
// Platform messaging
|
||||
private receiverChannel?: Channel;
|
||||
private receiverRequestId = Math.floor(Math.random() * 1e6);
|
||||
|
||||
constructor(
|
||||
protected sourceId = "sender-0",
|
||||
protected destinationId = "receiver-0"
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a channel on the client connection with a given
|
||||
* namespace.
|
||||
*/
|
||||
protected createChannel(
|
||||
namespace: string,
|
||||
sourceId = this.sourceId,
|
||||
destinationId = this.destinationId
|
||||
) {
|
||||
return this.client.createChannel(
|
||||
sourceId,
|
||||
destinationId,
|
||||
namespace,
|
||||
"JSON"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message on the receiver channel with the correct
|
||||
* request ID.
|
||||
*/
|
||||
sendReceiverMessage(message: DistributiveOmit<SenderMessage, "requestId">) {
|
||||
if (!this.receiverChannel) return;
|
||||
|
||||
const requestId = this.receiverRequestId++;
|
||||
this.receiverChannel.send({ ...message, requestId });
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a cast receiver at a given host, returning a
|
||||
* promise that resolves once the client is connected.
|
||||
*/
|
||||
connect(host: string, options?: CastClientConnectOptions) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Handle errors
|
||||
this.client.on("error", reject);
|
||||
this.client.on("close", () => {
|
||||
if (this.heartbeatChannel && this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
}
|
||||
});
|
||||
|
||||
this.client.connect(
|
||||
{
|
||||
host,
|
||||
port: options?.port ?? DEFAULT_PORT
|
||||
},
|
||||
// On connection callback
|
||||
() => {
|
||||
this.connectionChannel = this.createChannel(NS_CONNECTION);
|
||||
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);
|
||||
|
||||
// Handle receiver messages
|
||||
this.receiverChannel = this.createChannel(NS_RECEIVER);
|
||||
this.receiverChannel.on("message", message => {
|
||||
options?.onReceiverMessage?.(message);
|
||||
});
|
||||
|
||||
this.connectionChannel.send({ type: "CONNECT" });
|
||||
this.heartbeatChannel.send({ type: "PING" });
|
||||
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.heartbeatChannel?.send({ type: "PING" });
|
||||
options?.onHeartbeat?.();
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
}
|
||||
|
||||
this.connectionChannel?.send({ type: "CLOSE" });
|
||||
this.client.close();
|
||||
}
|
||||
}
|
||||
87
bridge/src/bridge/components/cast/discovery.ts
Normal file
87
bridge/src/bridge/components/cast/discovery.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import mdns from "mdns";
|
||||
|
||||
import type { ReceiverDevice } from "../../messagingTypes";
|
||||
|
||||
/**
|
||||
* Chromecast TXT record
|
||||
*/
|
||||
interface CastRecord {
|
||||
// Device ID
|
||||
id: string;
|
||||
// Model name (e.g. Chromecast, Google Nest Mini, etc...)
|
||||
md: string;
|
||||
// Friendly name (user-visible)
|
||||
fn: string;
|
||||
// Capabilities
|
||||
ca: string;
|
||||
// Version (?)
|
||||
ve: string;
|
||||
// Icon path (?)
|
||||
ic: string;
|
||||
|
||||
cd: string;
|
||||
rm: string;
|
||||
st: string;
|
||||
bs: string;
|
||||
nf: string;
|
||||
rs: string;
|
||||
}
|
||||
|
||||
interface DiscoveryOptions {
|
||||
onDeviceFound(device: ReceiverDevice): void;
|
||||
onDeviceDown(deviceId: string): void;
|
||||
}
|
||||
|
||||
export default class Discovery {
|
||||
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()
|
||||
]
|
||||
});
|
||||
|
||||
constructor(opts: DiscoveryOptions) {
|
||||
/**
|
||||
* When a service is found, gather device info from service
|
||||
* object and TXT record, then send a `main:deviceUp` message.
|
||||
*/
|
||||
this.browser.on("serviceUp", service => {
|
||||
// Filter invalid results
|
||||
if (!service.txtRecord || !service.name) return;
|
||||
|
||||
const record = service.txtRecord as CastRecord;
|
||||
const device: ReceiverDevice = {
|
||||
id: record.id,
|
||||
friendlyName: record.fn,
|
||||
modelName: record.md,
|
||||
capabilities: parseInt(record.ca),
|
||||
host: service.addresses[0],
|
||||
port: service.port
|
||||
};
|
||||
|
||||
opts.onDeviceFound(device);
|
||||
});
|
||||
|
||||
/**
|
||||
* When a service is lost, send a `main:deviceDown` message with
|
||||
* the service name as the `deviceId`.
|
||||
*/
|
||||
this.browser.on("serviceDown", service => {
|
||||
// Filter invalid results
|
||||
if (!service.name) return;
|
||||
|
||||
opts.onDeviceDown(service.name);
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.browser.start();
|
||||
}
|
||||
stop() {
|
||||
this.browser.stop();
|
||||
}
|
||||
}
|
||||
120
bridge/src/bridge/components/cast/index.ts
Normal file
120
bridge/src/bridge/components/cast/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import messaging, { Message } from "../../messaging";
|
||||
|
||||
import Session from "./Session";
|
||||
import CastClient from "./client";
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
export function handleCastMessage(message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:createCastSession": {
|
||||
const { appId, receiverDevice } = message.data;
|
||||
|
||||
// Connect and store with returned ID
|
||||
const session = new Session(appId, receiverDevice, sessionId => {
|
||||
sessions.set(sessionId, session);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendCastReceiverMessage": {
|
||||
const { sessionId, messageData, messageId } = message.data;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: "Session does not exist",
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
session.sendReceiverMessage(messageData);
|
||||
} catch (err) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: `Failed to send message (${err})`,
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Success
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: { sessionId, messageId }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendCastSessionMessage": {
|
||||
const { namespace, sessionId, messageId } = message.data;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: "Session does not exist",
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle string messages
|
||||
let { messageData } = message.data;
|
||||
if (typeof messageData === "string") {
|
||||
messageData = JSON.parse(messageData);
|
||||
}
|
||||
|
||||
session.sendMessage(namespace, messageData);
|
||||
} catch (err) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: `Failed to send message (${err})`,
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Success
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: { sessionId, messageId }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:stopCastSession": {
|
||||
const { receiverDevice } = message.data;
|
||||
|
||||
const client = new CastClient();
|
||||
client.connect(receiverDevice.host).then(() => {
|
||||
(client.sendReceiverMessage as any)({ type: "STOP" });
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
bridge/src/bridge/components/cast/remote.ts
Normal file
121
bridge/src/bridge/components/cast/remote.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import CastClient from "./client";
|
||||
|
||||
import type {
|
||||
MediaStatus,
|
||||
ReceiverMessage,
|
||||
ReceiverMediaMessage,
|
||||
ReceiverStatus,
|
||||
SenderMediaMessage
|
||||
} from "./types";
|
||||
|
||||
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
||||
|
||||
interface CastRemoteOptions {
|
||||
onApplicationFound?: () => void;
|
||||
onApplicationClose?: () => void;
|
||||
onReceiverStatusUpdate?: (status: ReceiverStatus) => void;
|
||||
onMediaStatusUpdate?: (status?: MediaStatus) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* castv2 client for receiver tracking.
|
||||
*/
|
||||
export default class Remote extends CastClient {
|
||||
private transportClient?: RemoteTransport;
|
||||
|
||||
constructor(private host: string, private options?: CastRemoteOptions) {
|
||||
super();
|
||||
super
|
||||
.connect(host, {
|
||||
onReceiverMessage: message => {
|
||||
this.onReceiverMessage(message);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.sendReceiverMessage({ type: "GET_STATUS" });
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect();
|
||||
this.transportClient?.disconnect();
|
||||
}
|
||||
|
||||
sendMediaMessage(message: SenderMediaMessage) {
|
||||
this.transportClient?.sendMediaMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `NS_RECEIVER` messages from the receiver device.
|
||||
* On initial connection, a `GET_STATUS` message is sent that
|
||||
* results in a `RECEIVER_STATUS` response. If an application
|
||||
* is running, get the transport ID and make a connection to
|
||||
* receive media status updates.
|
||||
*/
|
||||
private onReceiverMessage(message: ReceiverMessage) {
|
||||
if (message.type !== "RECEIVER_STATUS") {
|
||||
return;
|
||||
}
|
||||
|
||||
const application = message.status.applications?.[0];
|
||||
if (!application || application.isIdleScreen) {
|
||||
// Handle app close
|
||||
if (this.transportClient) {
|
||||
this.transportClient = undefined;
|
||||
this.options?.onApplicationClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Update status before possible transport init
|
||||
this.options?.onReceiverStatusUpdate?.(message.status);
|
||||
|
||||
// Handle app creation/discovery
|
||||
if (application && !this.transportClient) {
|
||||
this.transportClient = new RemoteTransport(
|
||||
application.transportId,
|
||||
message => this.onMediaMessage(message)
|
||||
);
|
||||
|
||||
this.transportClient.connect(this.host).then(() => {
|
||||
this.transportClient?.sendMediaMessage({
|
||||
type: "GET_STATUS",
|
||||
requestId: 0
|
||||
});
|
||||
});
|
||||
|
||||
this.options?.onApplicationFound?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `NS_MEDIA` messages from the receiver application.
|
||||
* On initial connection. a `GET_STATUS` message is sent that
|
||||
* results in a `MEDIA_STATUS` response.
|
||||
*/
|
||||
private onMediaMessage(message: ReceiverMediaMessage) {
|
||||
if (message.type !== "MEDIA_STATUS") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options?.onMediaStatusUpdate?.(message.status[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* castv2 client for receiver application tracking.
|
||||
*/
|
||||
class RemoteTransport extends CastClient {
|
||||
private mediaChannel = this.createChannel(NS_MEDIA);
|
||||
|
||||
constructor(
|
||||
transportId: string,
|
||||
onMediaMessage: (message: ReceiverMediaMessage) => void
|
||||
) {
|
||||
super(undefined, transportId);
|
||||
this.mediaChannel.on("message", message => onMediaMessage(message));
|
||||
}
|
||||
|
||||
sendMediaMessage(message: SenderMediaMessage) {
|
||||
this.mediaChannel.send(message);
|
||||
}
|
||||
}
|
||||
444
bridge/src/bridge/components/cast/types.ts
Normal file
444
bridge/src/bridge/components/cast/types.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
export interface Image {
|
||||
url: string;
|
||||
height: Nullable<number>;
|
||||
width: Nullable<number>;
|
||||
}
|
||||
|
||||
enum Capability {
|
||||
VIDEO_OUT = "video_out",
|
||||
AUDIO_OUT = "audio_out",
|
||||
VIDEO_IN = "video_in",
|
||||
AUDIO_IN = "audio_in",
|
||||
MULTIZONE_GROUP = "multizone_group"
|
||||
}
|
||||
|
||||
enum ReceiverType {
|
||||
CAST = "cast",
|
||||
DIAL = "dial",
|
||||
HANGOUT = "hangout",
|
||||
CUSTOM = "custom"
|
||||
}
|
||||
|
||||
export interface SenderApplication {
|
||||
packageId: Nullable<string>;
|
||||
platform: string;
|
||||
url: Nullable<string>;
|
||||
}
|
||||
|
||||
enum VolumeControlType {
|
||||
ATTENUATION = "attenuation",
|
||||
FIXED = "fixed",
|
||||
MASTER = "master"
|
||||
}
|
||||
|
||||
export interface Volume {
|
||||
controlType?: VolumeControlType;
|
||||
stepInterval?: number;
|
||||
level: Nullable<number>;
|
||||
muted: Nullable<boolean>;
|
||||
}
|
||||
|
||||
// Media
|
||||
|
||||
enum IdleReason {
|
||||
CANCELLED = "CANCELLED",
|
||||
INTERRUPTED = "INTERRUPTED",
|
||||
FINISHED = "FINISHED",
|
||||
ERROR = "ERROR"
|
||||
}
|
||||
|
||||
enum HlsSegmentFormat {
|
||||
AAC = "aac",
|
||||
AC3 = "ac3",
|
||||
MP3 = "mp3",
|
||||
TS = "ts",
|
||||
TS_AAC = "ts_aac",
|
||||
E_AC3 = "e_ac3",
|
||||
FMP4 = "fmp4"
|
||||
}
|
||||
|
||||
export enum HlsVideoSegmentFormat {
|
||||
MPEG2_TS = "mpeg2_ts",
|
||||
FMP4 = "fmp4"
|
||||
}
|
||||
|
||||
enum MetadataType {
|
||||
GENERIC,
|
||||
MOVIE,
|
||||
TV_SHOW,
|
||||
MUSIC_TRACK,
|
||||
PHOTO,
|
||||
AUDIOBOOK_CHAPTER
|
||||
}
|
||||
|
||||
enum PlayerState {
|
||||
IDLE = "IDLE",
|
||||
PLAYING = "PLAYING",
|
||||
PAUSED = "PAUSED",
|
||||
BUFFERING = "BUFFERING"
|
||||
}
|
||||
|
||||
enum RepeatMode {
|
||||
OFF = "REPEAT_OFF",
|
||||
ALL = "REPEAT_ALL",
|
||||
SINGLE = "REPEAT_SINGLE",
|
||||
ALL_AND_SHUFFLE = "REPEAT_ALL_AND_SHUFFLE"
|
||||
}
|
||||
|
||||
enum ResumeState {
|
||||
PLAYBACK_START = "PLAYBACK_START",
|
||||
PLAYBACK_PAUSE = "PLAYBACK_PAUSE"
|
||||
}
|
||||
|
||||
enum StreamType {
|
||||
BUFFERED = "BUFFERED",
|
||||
LIVE = "LIVE",
|
||||
OTHER = "OTHER"
|
||||
}
|
||||
|
||||
enum TrackType {
|
||||
TEXT = "TEXT",
|
||||
AUDIO = "AUDIO",
|
||||
VIDEO = "VIDEO"
|
||||
}
|
||||
|
||||
export enum UserAction {
|
||||
LIKE = "LIKE",
|
||||
DISLIKE = "DISLIKE",
|
||||
FOLLOW = "FOLLOW",
|
||||
UNFOLLOW = "UNFOLLOW"
|
||||
}
|
||||
|
||||
interface Break {
|
||||
breakClipIds: string[];
|
||||
duration?: number;
|
||||
id: string;
|
||||
isEmbedded?: boolean;
|
||||
isWatched: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface BreakClip {
|
||||
clickThroughUrl?: string;
|
||||
contentId?: string;
|
||||
contentType?: string;
|
||||
contentUrl?: string;
|
||||
customData?: unknown;
|
||||
duration?: number;
|
||||
id: string;
|
||||
hlsSegmentFormat?: HlsSegmentFormat;
|
||||
posterUrl?: string;
|
||||
title?: string;
|
||||
vastAdsRequest?: VastAdsRequest;
|
||||
whenSkippable?: number;
|
||||
}
|
||||
|
||||
interface TextTrackStyle {
|
||||
backgroundColor: Nullable<string>;
|
||||
customData: unknown;
|
||||
edgeColor: Nullable<string>;
|
||||
edgeType: Nullable<string>;
|
||||
fontFamily: Nullable<string>;
|
||||
fontGenericFamily: Nullable<string>;
|
||||
fontScale: Nullable<number>;
|
||||
fontStyle: Nullable<string>;
|
||||
foregroundColor: Nullable<string>;
|
||||
windowColor: Nullable<string>;
|
||||
windowRoundedCornerRadius: Nullable<number>;
|
||||
windowType: Nullable<string>;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
customData: unknown;
|
||||
language: Nullable<string>;
|
||||
name: Nullable<string>;
|
||||
subtype: Nullable<string>;
|
||||
trackContentId: Nullable<string>;
|
||||
trackContentType: Nullable<string>;
|
||||
trackId: string;
|
||||
type: TrackType;
|
||||
}
|
||||
|
||||
interface UserActionState {
|
||||
customData: unknown;
|
||||
userAction: UserAction;
|
||||
}
|
||||
|
||||
interface VastAdsRequest {
|
||||
adsResponse?: string;
|
||||
adTagUrl?: string;
|
||||
}
|
||||
|
||||
type Metadata =
|
||||
| GenericMediaMetadata
|
||||
| MovieMediaMetadata
|
||||
| MusicTrackMediaMetadata
|
||||
| PhotoMediaMetadata
|
||||
| TvShowMediaMetadata;
|
||||
|
||||
interface MediaInformation {
|
||||
atvEntity?: string;
|
||||
breakClips?: BreakClip[];
|
||||
breaks?: Break[];
|
||||
contentId: string;
|
||||
contentType: string;
|
||||
contentUrl?: string;
|
||||
customData: unknown;
|
||||
duration: Nullable<number>;
|
||||
entity?: string;
|
||||
hlsSegmentFormat?: HlsSegmentFormat;
|
||||
hlsVideoSegmentFormat?: HlsVideoSegmentFormat;
|
||||
metadata: Nullable<Metadata>;
|
||||
startAbsoluteTime?: number;
|
||||
streamType: StreamType;
|
||||
textTrackStyle: Nullable<TextTrackStyle>;
|
||||
tracks: Nullable<Track[]>;
|
||||
userActionStates?: UserActionState[];
|
||||
vmapAdsRequest?: VastAdsRequest;
|
||||
}
|
||||
|
||||
interface GenericMediaMetadata {
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
type: MetadataType.GENERIC;
|
||||
}
|
||||
|
||||
interface MovieMediaMetadata {
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
studio?: string;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
type: MetadataType.MOVIE;
|
||||
}
|
||||
|
||||
interface TvShowMediaMetadata {
|
||||
episode?: number;
|
||||
episodeNumber?: number;
|
||||
episodeTitle?: string;
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
originalAirdate?: string;
|
||||
releaseYear?: number;
|
||||
season?: number;
|
||||
seasonNumber?: number;
|
||||
seriesTitle?: string;
|
||||
title?: string;
|
||||
type: MetadataType.TV_SHOW;
|
||||
}
|
||||
|
||||
interface MusicTrackMediaMetadata {
|
||||
albumArtist?: string;
|
||||
albumName?: string;
|
||||
artist?: string;
|
||||
artistName?: string;
|
||||
composer?: string;
|
||||
discNumber?: number;
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
songName?: string;
|
||||
title?: string;
|
||||
trackNumber?: number;
|
||||
type: MetadataType.MUSIC_TRACK;
|
||||
}
|
||||
|
||||
interface PhotoMediaMetadata {
|
||||
artist?: string;
|
||||
creationDateTime?: string;
|
||||
height?: number;
|
||||
images?: Image[];
|
||||
latitude?: number;
|
||||
location?: string;
|
||||
longitude?: number;
|
||||
metadataType: number;
|
||||
title?: string;
|
||||
type: MetadataType.PHOTO;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
activeTrackIds: Nullable<number[]>;
|
||||
autoplay: boolean;
|
||||
customData: unknown;
|
||||
itemId: Nullable<number>;
|
||||
media: MediaInformation;
|
||||
playbackDuration: Nullable<number>;
|
||||
preloadTime: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export interface MediaStatus {
|
||||
mediaSessionId: number;
|
||||
media?: MediaInformation;
|
||||
playbackRate: number;
|
||||
playerState: PlayerState;
|
||||
idleReason?: IdleReason;
|
||||
items?: QueueItem[];
|
||||
currentTime: Nullable<number>;
|
||||
supportedMediaCommands: number;
|
||||
repeatMode: RepeatMode;
|
||||
volume: Volume;
|
||||
customData: unknown;
|
||||
}
|
||||
|
||||
interface ReceiverDisplayStatus {
|
||||
showStop: Nullable<boolean>;
|
||||
statusText: string;
|
||||
appImages: Image[];
|
||||
}
|
||||
|
||||
export interface Receiver {
|
||||
displayStatus: Nullable<ReceiverDisplayStatus>;
|
||||
isActiveInput: Nullable<boolean>;
|
||||
receiverType: ReceiverType;
|
||||
label: string;
|
||||
friendlyName: string;
|
||||
capabilities: Capability[];
|
||||
volume: Nullable<Volume>;
|
||||
}
|
||||
|
||||
export interface ReceiverApplication {
|
||||
appId: string;
|
||||
appType: string;
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isIdleScreen: boolean;
|
||||
launchedFromCloud: boolean;
|
||||
namespaces: Array<{ name: string }>;
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
transportId: string;
|
||||
universalAppId: string;
|
||||
}
|
||||
|
||||
export interface ReceiverStatus {
|
||||
applications?: ReceiverApplication[];
|
||||
isActiveInput?: boolean;
|
||||
isStandBy?: boolean;
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
interface ReqBase {
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
// NS: urn:x-cast:com.google.cast.receiver
|
||||
export type SenderMessage =
|
||||
| (ReqBase & { type: "LAUNCH"; appId: string })
|
||||
| (ReqBase & { type: "STOP"; sessionId: string })
|
||||
| (ReqBase & { type: "GET_STATUS" })
|
||||
| (ReqBase & { type: "GET_APP_AVAILABILITY"; appId: string[] })
|
||||
| (ReqBase & { type: "SET_VOLUME"; volume: Volume });
|
||||
|
||||
export type ReceiverMessage =
|
||||
| (ReqBase & { type: "RECEIVER_STATUS"; status: ReceiverStatus })
|
||||
| (ReqBase & { type: "LAUNCH_ERROR"; reason: string });
|
||||
|
||||
interface MediaReqBase extends ReqBase {
|
||||
mediaSessionId: number;
|
||||
customData?: unknown;
|
||||
}
|
||||
|
||||
// NS: urn:x-cast:com.google.cast.media
|
||||
export type SenderMediaMessage =
|
||||
| (MediaReqBase & { type: "PLAY" })
|
||||
| (MediaReqBase & { type: "PAUSE" })
|
||||
| {
|
||||
type: "MEDIA_GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| {
|
||||
type: "GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| (MediaReqBase & { type: "STOP" })
|
||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
|
||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||
| (ReqBase & {
|
||||
type: "LOAD";
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
atvCredentials?: string;
|
||||
atvCredentialsType?: string;
|
||||
autoplay?: Nullable<boolean>;
|
||||
currentTime?: Nullable<number>;
|
||||
customData?: unknown;
|
||||
media: MediaInformation;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "SEEK";
|
||||
resumeState?: Nullable<ResumeState>;
|
||||
currentTime?: Nullable<number>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "EDIT_TRACKS_INFO";
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
textTrackStyle?: Nullable<string>;
|
||||
})
|
||||
// QueueLoadRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_LOAD";
|
||||
items: QueueItem[];
|
||||
startIndex: number;
|
||||
repeatMode: string;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueInsertItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_INSERT";
|
||||
items: QueueItem[];
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueUpdateItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
items: QueueItem[];
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueJumpRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
jump?: Nullable<number>;
|
||||
currentItemId?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueRemoveItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REMOVE";
|
||||
itemIds: number[];
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueReorderItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REORDER";
|
||||
itemIds: number[];
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueSetPropertiesRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
repeatMode?: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
});
|
||||
|
||||
export type ReceiverMediaMessage =
|
||||
| (MediaReqBase & { type: "MEDIA_STATUS"; status: MediaStatus[] })
|
||||
| (MediaReqBase & { type: "INVALID_PLAYER_STATE" })
|
||||
| (MediaReqBase & { type: "LOAD_FAILED" })
|
||||
| (MediaReqBase & { type: "LOAD_CANCELLED" })
|
||||
| (MediaReqBase & { type: "INVALID_REQUEST" });
|
||||
197
bridge/src/bridge/components/mediaServer.ts
Normal file
197
bridge/src/bridge/components/mediaServer.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import stream from "stream";
|
||||
|
||||
import mime from "mime-types";
|
||||
|
||||
import messaging from "../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 {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Media path is not a file."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Failed to find media path."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = mime.lookup(filePath);
|
||||
if (!contentType) {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Failed to find media type."
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error(`Error: Failed to find/convert subtitles (${filePath}).`);
|
||||
}
|
||||
|
||||
mediaServer = http.createServer(async (req, res) => {
|
||||
if (!req.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decodedUrl = decodeURIComponent(req.url);
|
||||
// Drop leading slash
|
||||
if (decodedUrl.startsWith("/")) {
|
||||
decodedUrl = decodedUrl.slice(1);
|
||||
}
|
||||
|
||||
switch (decodedUrl) {
|
||||
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("close", () => {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerStopped"
|
||||
});
|
||||
});
|
||||
mediaServer.on("error", err => {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: err.message
|
||||
});
|
||||
});
|
||||
|
||||
mediaServer.listen(port, () => {
|
||||
const localAddresses: string[] = [];
|
||||
for (const iface of Object.values(os.networkInterfaces())) {
|
||||
const matchingIface = iface?.find(
|
||||
details => details.family === "IPv4" && !details.internal
|
||||
);
|
||||
if (matchingIface) {
|
||||
localAddresses.push(matchingIface.address);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localAddresses.length) {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Failed to get local address."
|
||||
});
|
||||
stopMediaServer();
|
||||
return;
|
||||
}
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerStarted",
|
||||
data: {
|
||||
mediaPath: fileName,
|
||||
subtitlePaths: Array.from(subtitles.keys()),
|
||||
localAddress: localAddresses[0]
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function stopMediaServer() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!mediaServer?.listening) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
mediaServer.close(err => {
|
||||
if (err) {
|
||||
reject();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
mediaServer = undefined;
|
||||
});
|
||||
}
|
||||
131
bridge/src/bridge/index.ts
Executable file
131
bridge/src/bridge/index.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import messaging, { Message } from "./messaging";
|
||||
|
||||
import { handleCastMessage } from "./components/cast";
|
||||
import Discovery from "./components/cast/discovery";
|
||||
import Remote from "./components/cast/remote";
|
||||
|
||||
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
|
||||
|
||||
import { applicationVersion } from "../../config.json";
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
discovery?.stop();
|
||||
try {
|
||||
await stopMediaServer();
|
||||
} catch (err) {
|
||||
console.error("Error stopping media server!", err);
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
let discovery: Discovery | null = null;
|
||||
const remotes = new Map<string, Remote>();
|
||||
|
||||
/**
|
||||
* Handle incoming messages from the extension and forward
|
||||
* them to the appropriate handlers.
|
||||
*
|
||||
* Initializes the counterpart objects and is responsible
|
||||
* for managing existing ones.
|
||||
*/
|
||||
messaging.on("message", (message: Message) => {
|
||||
switch (message.subject) {
|
||||
case "bridge:getInfo":
|
||||
case "bridge:/getInfo": {
|
||||
messaging.send(applicationVersion);
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:startDiscovery": {
|
||||
const { shouldWatchStatus } = message.data;
|
||||
|
||||
discovery = new Discovery({
|
||||
onDeviceFound(device) {
|
||||
messaging.sendMessage({
|
||||
subject: "main:deviceUp",
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
deviceInfo: device
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldWatchStatus) {
|
||||
remotes.set(
|
||||
device.id,
|
||||
new Remote(device.host, {
|
||||
// RECEIVER_STATUS
|
||||
onReceiverStatusUpdate(status) {
|
||||
messaging.sendMessage({
|
||||
subject:
|
||||
"main:receiverDeviceStatusUpdated",
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
status
|
||||
}
|
||||
});
|
||||
},
|
||||
// MEDIA_STATUS
|
||||
onMediaStatusUpdate(status) {
|
||||
if (!status) return;
|
||||
|
||||
messaging.sendMessage({
|
||||
subject:
|
||||
"main:receiverDeviceMediaStatusUpdated",
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
status
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
onDeviceDown(deviceId) {
|
||||
messaging.sendMessage({
|
||||
subject: "main:deviceDown",
|
||||
data: { deviceId }
|
||||
});
|
||||
|
||||
if (shouldWatchStatus) {
|
||||
if (remotes.has(deviceId)) {
|
||||
remotes.get(deviceId)?.disconnect();
|
||||
remotes.delete(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
discovery.start();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendReceiverMessage": {
|
||||
const { deviceId, message: receiverMessage } = message.data;
|
||||
remotes.get(deviceId)?.sendReceiverMessage(receiverMessage);
|
||||
break;
|
||||
}
|
||||
case "bridge:sendMediaMessage": {
|
||||
const { deviceId, message: mediaMessage } = message.data;
|
||||
remotes.get(deviceId)?.sendMediaMessage(mediaMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
// Media server
|
||||
case "bridge:startMediaServer": {
|
||||
const { filePath, port } = message.data;
|
||||
startMediaServer(filePath, port);
|
||||
break;
|
||||
}
|
||||
case "bridge:stopMediaServer": {
|
||||
stopMediaServer();
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
handleCastMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
51
bridge/src/bridge/lib/subtitles.ts
Normal file
51
bridge/src/bridge/lib/subtitles.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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 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;
|
||||
}
|
||||
255
bridge/src/bridge/messaging.ts
Normal file
255
bridge/src/bridge/messaging.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { TypedEmitter } from "tiny-typed-emitter";
|
||||
|
||||
import { DecodeTransform, EncodeTransform } from "../transforms";
|
||||
|
||||
import type {
|
||||
MediaStatus,
|
||||
ReceiverStatus,
|
||||
SenderMediaMessage,
|
||||
SenderMessage
|
||||
} from "./components/cast/types";
|
||||
|
||||
import type {
|
||||
ReceiverDevice,
|
||||
CastSessionCreatedDetails,
|
||||
CastSessionUpdatedDetails
|
||||
} from "./messagingTypes";
|
||||
|
||||
/**
|
||||
* IMPORTANT:
|
||||
* Messages that cross the native messaging channel. MUST keep
|
||||
* in-sync with the extension's version at:
|
||||
* ext/src/messaging.ts > AppMessageDefinitions
|
||||
*/
|
||||
type MessageDefinitions = {
|
||||
/**
|
||||
* First message sent by the extension to the bridge.
|
||||
* Includes extension version string. Responds directly with version
|
||||
* string of the bridge to compare.
|
||||
*
|
||||
* Still uses `:/` message separator for compat talking to older
|
||||
* bridge versions.
|
||||
*/
|
||||
"bridge:getInfo": string;
|
||||
"bridge:/getInfo": string;
|
||||
|
||||
/**
|
||||
* Tells a bridge to begin service discovery (and whether to
|
||||
* establish connections to monitor the status of the receiver
|
||||
* devices).
|
||||
*/
|
||||
"bridge:startDiscovery": {
|
||||
shouldWatchStatus: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to extension from the bridge whenever a receiver device is
|
||||
* found.
|
||||
*/
|
||||
"main:deviceUp": { deviceId: string; deviceInfo: ReceiverDevice };
|
||||
/**
|
||||
* Sent to extension from the bridge whenever a previously found
|
||||
* receiver device is lost.
|
||||
*/
|
||||
"main:deviceDown": { deviceId: string };
|
||||
|
||||
/**
|
||||
* Sent to the extension from the bridge whenever a
|
||||
* `RECEIVER_STATUS` message (`NS_RECEIVER`) is received.
|
||||
*/
|
||||
"main:receiverDeviceStatusUpdated": {
|
||||
deviceId: string;
|
||||
status: ReceiverStatus;
|
||||
};
|
||||
/**
|
||||
* Sent to the extension from the bridge whenever a
|
||||
* `MEDIA_STATUS` message (`NS_RECEIVER`) is received.
|
||||
*/
|
||||
"main:receiverDeviceMediaStatusUpdated": {
|
||||
deviceId: string;
|
||||
status: MediaStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to the bridge when non-session related receiver messages
|
||||
* need to be sent (e.g. volume control, application stop, etc...).
|
||||
*/
|
||||
"bridge:sendReceiverMessage": {
|
||||
deviceId: string;
|
||||
message: SenderMessage;
|
||||
};
|
||||
/**
|
||||
* Sent to the bridge when the receiver selector media UI is used
|
||||
* to control media playback.
|
||||
*/
|
||||
"bridge:sendMediaMessage": {
|
||||
deviceId: string;
|
||||
message: SenderMediaMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to bridge from cast API instance when a session request is
|
||||
* initiated.
|
||||
*/
|
||||
"bridge:createCastSession": {
|
||||
appId: string;
|
||||
receiverDevice: ReceiverDevice;
|
||||
};
|
||||
/**
|
||||
* Connects to, and sends a `STOP` message on the `NS_RECEIVER`
|
||||
* channel for the given receiver device.
|
||||
*/
|
||||
"bridge:stopCastSession": {
|
||||
receiverDevice: ReceiverDevice;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to cast API instances whenever a session is created or
|
||||
* updates. Updated details is a mutable subset of session details
|
||||
* otherwise fixed on creation.
|
||||
*/
|
||||
"main:castSessionCreated": CastSessionCreatedDetails;
|
||||
"main:castSessionUpdated": CastSessionUpdatedDetails;
|
||||
/**
|
||||
* Sent to cast API instances whenever a session is stopped.
|
||||
*/
|
||||
"cast:sessionStopped": {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to bridge from cast API instance whenever an `NS_RECEIVER`
|
||||
* message needs to be sent.
|
||||
*/
|
||||
"bridge:sendCastReceiverMessage": {
|
||||
sessionId: string;
|
||||
messageData: SenderMessage;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to bridge from cast API instance whenever a application
|
||||
* session message needs to be sent (via
|
||||
* `chrome.cast.Session#sendMessage`).
|
||||
*/
|
||||
"bridge:sendCastSessionMessage": {
|
||||
sessionId: string;
|
||||
namespace: string;
|
||||
messageData: object | string;
|
||||
messageId: string;
|
||||
};
|
||||
/**
|
||||
* Sent to cast API instance from bridge when session message
|
||||
* received from a receiver device.
|
||||
*/
|
||||
"cast:sessionMessageReceived": {
|
||||
sessionId: string;
|
||||
namespace: string;
|
||||
messageData: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to cast API instance from bridge whenever a message
|
||||
* operation is completed. If an error ocurred, an error string will
|
||||
* be passed as the `error` data property.
|
||||
*/
|
||||
"cast:impl_sendMessage": {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to the bridge to start an HTTP media server at a given file
|
||||
* path on the given port.
|
||||
*/
|
||||
"bridge:startMediaServer": {
|
||||
filePath: string;
|
||||
port: number;
|
||||
};
|
||||
/**
|
||||
* Sent to media sender from bridge when the media server is ready
|
||||
* to serve files.
|
||||
*/
|
||||
"mediaCast:mediaServerStarted": {
|
||||
mediaPath: string;
|
||||
subtitlePaths: string[];
|
||||
localAddress: string;
|
||||
};
|
||||
/**
|
||||
* Sent to bridge to stop HTTP media server.
|
||||
*/
|
||||
"bridge:stopMediaServer": undefined;
|
||||
/**
|
||||
* Sent to media sender from bridge when the media server has
|
||||
* stopped.
|
||||
*/
|
||||
"mediaCast:mediaServerStopped": undefined;
|
||||
/**
|
||||
* Sent to media sender from bridge when the media server has
|
||||
* encountered an error.
|
||||
*/
|
||||
"mediaCast:mediaServerError": string;
|
||||
};
|
||||
|
||||
interface MessageBase<K extends keyof MessageDefinitions> {
|
||||
subject: K;
|
||||
data: MessageDefinitions[K];
|
||||
}
|
||||
|
||||
type Messages = {
|
||||
[K in keyof MessageDefinitions]: MessageBase<K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make message data key optional if specified as blank or with
|
||||
* all-optional keys.
|
||||
*/
|
||||
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
|
||||
L extends unknown
|
||||
? undefined extends L["data"]
|
||||
? Omit<L, "data"> & Partial<L>
|
||||
: L
|
||||
: never;
|
||||
|
||||
export type Message = NarrowedMessage<Messages[keyof Messages]>;
|
||||
|
||||
interface MessengerEvents {
|
||||
message: (message: Message) => void;
|
||||
}
|
||||
|
||||
class Messenger extends TypedEmitter<MessengerEvents> {
|
||||
// Native messaging transforms
|
||||
private decodeTransform = new DecodeTransform();
|
||||
private encodeTransform = new EncodeTransform();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Hook up stdin -> stdout
|
||||
process.stdin.pipe(this.decodeTransform);
|
||||
this.encodeTransform.pipe(process.stdout);
|
||||
|
||||
this.decodeTransform.on("error", err =>
|
||||
console.error("err (message decode):", err)
|
||||
);
|
||||
this.encodeTransform.on("error", err =>
|
||||
console.error("err (message encode):", err)
|
||||
);
|
||||
|
||||
this.decodeTransform.on("data", (message: Message) => {
|
||||
this.emit("message", message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends a message to the extension. */
|
||||
sendMessage(message: Message) {
|
||||
this.encodeTransform.write(message);
|
||||
}
|
||||
|
||||
send(data: unknown) {
|
||||
this.encodeTransform.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Messenger();
|
||||
41
bridge/src/bridge/messagingTypes.ts
Normal file
41
bridge/src/bridge/messagingTypes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
Image,
|
||||
ReceiverStatus,
|
||||
SenderApplication,
|
||||
Volume
|
||||
} from "./components/cast/types";
|
||||
|
||||
export enum ReceiverDeviceCapabilities {
|
||||
NONE = 0,
|
||||
VIDEO_OUT = 1,
|
||||
VIDEO_IN = 2,
|
||||
AUDIO_OUT = 4,
|
||||
AUDIO_IN = 8,
|
||||
MULTIZONE_GROUP = 32
|
||||
}
|
||||
|
||||
export interface ReceiverDevice {
|
||||
id: string;
|
||||
friendlyName: string;
|
||||
modelName: string;
|
||||
capabilities: ReceiverDeviceCapabilities;
|
||||
host: string;
|
||||
port: number;
|
||||
status?: ReceiverStatus;
|
||||
}
|
||||
|
||||
export interface CastSessionUpdatedDetails {
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
namespaces: Array<{ name: string }>;
|
||||
volume: Volume;
|
||||
}
|
||||
export interface CastSessionCreatedDetails extends CastSessionUpdatedDetails {
|
||||
appId: string;
|
||||
appImages: Image[];
|
||||
displayName: string;
|
||||
receiverId: string;
|
||||
receiverFriendlyName: string;
|
||||
senderApps: SenderApplication[];
|
||||
transportId: string;
|
||||
}
|
||||
147
bridge/src/daemon.ts
Normal file
147
bridge/src/daemon.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { DecodeTransform, EncodeTransform } from "./transforms.js";
|
||||
|
||||
const bridgeInstances = new Set<ChildProcess>();
|
||||
|
||||
// Ensure child processes are killed on exit
|
||||
process.on("SIGTERM", async () => {
|
||||
for (const bridge of bridgeInstances) {
|
||||
bridge.kill();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export interface DaemonOpts {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
secure?: boolean;
|
||||
key?: Buffer;
|
||||
cert?: Buffer;
|
||||
}
|
||||
|
||||
export function init(opts: DaemonOpts) {
|
||||
const server = !opts.secure
|
||||
? http.createServer()
|
||||
: https.createServer({
|
||||
key: opts.key,
|
||||
cert: opts.cert
|
||||
});
|
||||
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
wss.on("connection", socket => {
|
||||
// Stream for incoming WebSocket messages
|
||||
const messageStream = new Readable({ objectMode: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
messageStream._read = () => {};
|
||||
|
||||
socket.on("message", (message: string) => {
|
||||
try {
|
||||
messageStream.push(JSON.parse(message));
|
||||
} catch (err) {
|
||||
// Catch parse errors and close socket
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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]]);
|
||||
bridgeInstances.add(bridge);
|
||||
|
||||
// socket -> bridge.stdin
|
||||
messageStream.pipe(new EncodeTransform()).pipe(bridge.stdin);
|
||||
|
||||
// bridge.stdout -> socket
|
||||
bridge.stdout.pipe(new DecodeTransform()).on("data", data => {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(data));
|
||||
});
|
||||
|
||||
// Handle termination
|
||||
socket.on("close", () => bridge.kill());
|
||||
bridge.on("exit", () => {
|
||||
socket.close();
|
||||
bridgeInstances.delete(bridge);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Authenticates requests by checking password URL param against
|
||||
* server password specified in launch options.
|
||||
*/
|
||||
function authenticate(req: http.IncomingMessage) {
|
||||
if (!opts.password) return true;
|
||||
|
||||
const password = new URL(
|
||||
req.url!,
|
||||
`http://${req.headers.host}`
|
||||
).searchParams.get("password");
|
||||
|
||||
return password === opts.password;
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
/**
|
||||
* Only accept authenticated WebSocket requests from extension
|
||||
* origins.
|
||||
*/
|
||||
if (
|
||||
req.headers.origin?.startsWith("moz-extension://") &&
|
||||
authenticate(req)
|
||||
) {
|
||||
wss.handleUpgrade(req, socket, head, ws => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
/**
|
||||
* Browser WebSocket API does not allow access to connection errors,
|
||||
* so provide an endpoint for feedback on invalid authentication.
|
||||
*/
|
||||
server.on("request", (req, res) => {
|
||||
/**
|
||||
* Requests from extensions have their origin header stripped,
|
||||
* so block all requests with origin headers.
|
||||
*/
|
||||
if ("origin" in req.headers) {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(authenticate(req) ? 200 : 401);
|
||||
res.end();
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
`Starting WebSocket server at ${opts.secure ? "wss" : "ws"}://${
|
||||
opts.host.includes(":") ? `[${opts.host}]` : opts.host
|
||||
}:${opts.port}... `
|
||||
);
|
||||
server.listen({ port: opts.port, host: opts.host }, () => {
|
||||
process.stdout.write("Done!\n");
|
||||
});
|
||||
|
||||
server.on("error", err => {
|
||||
console.error("Failed!");
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
5
bridge/src/global.d.ts
vendored
Normal file
5
bridge/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare type Nullable<T> = T | null;
|
||||
|
||||
declare type DistributiveOmit<T, K extends keyof any> = T extends any
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
123
bridge/src/main.ts
Normal file
123
bridge/src/main.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import yargs from "yargs";
|
||||
|
||||
import type { DaemonOpts } from "./daemon";
|
||||
|
||||
import { applicationName, applicationVersion } from "../config.json";
|
||||
|
||||
const argv = yargs()
|
||||
.scriptName(applicationName)
|
||||
.usage("$0 [args]")
|
||||
.help()
|
||||
.alias("help", "h")
|
||||
.version(`v${applicationVersion}`)
|
||||
.alias("version", "v")
|
||||
.config("config", parseConfig)
|
||||
.option("daemon", {
|
||||
alias: "d",
|
||||
describe: `Launch in daemon mode. This starts a WebSocket server that \
|
||||
the extension can be configured to connect to under bridge options.`,
|
||||
type: "boolean"
|
||||
})
|
||||
.option("host", {
|
||||
alias: "n",
|
||||
describe: `Host for daemon WebSocket server. This must match the host \
|
||||
set in the extension options.`,
|
||||
default: "localhost"
|
||||
})
|
||||
.option("port", {
|
||||
alias: "p",
|
||||
describe: `Port number for daemon WebSocket server. This must match \
|
||||
the port set in the extension options.`,
|
||||
default: 9556
|
||||
})
|
||||
.option("password", {
|
||||
alias: "P",
|
||||
describe: `Set an optional password for the daemon WebSocket server. \
|
||||
This must match the password set in the extension options.
|
||||
Note: If using this option it is highly recommended that you enable secure \
|
||||
connections to avoid leaking plaintext passwords!`,
|
||||
type: "string"
|
||||
})
|
||||
.option("secure", {
|
||||
alias: "s",
|
||||
describe: `Use a secure HTTPS server for WebSocket connections. \
|
||||
Requires key/cert file options to be specified.`,
|
||||
type: "boolean",
|
||||
default: false
|
||||
})
|
||||
.option("key-file", {
|
||||
alias: "k",
|
||||
describe: `Path to the private key PEM file to use for the \
|
||||
HTTPS server.`,
|
||||
type: "string"
|
||||
})
|
||||
.option("cert-file", {
|
||||
alias: "c",
|
||||
describe: `Path to the certificate PEM file to use for the \
|
||||
HTTPS server.`,
|
||||
type: "string"
|
||||
})
|
||||
.check(argv => {
|
||||
// Ensure valid port range
|
||||
if (argv.port < 1025 || argv.port > 65535) {
|
||||
throw new Error("Invalid port specified!");
|
||||
}
|
||||
// Ensure secure options are valid
|
||||
if (argv.secure) {
|
||||
if (!argv["key-file"] || !argv["cert-file"]) {
|
||||
throw new Error("Missing required key/cert files.");
|
||||
}
|
||||
if (
|
||||
!fs.existsSync(argv["key-file"]) ||
|
||||
!fs.existsSync(argv["cert-file"])
|
||||
) {
|
||||
throw new Error("Specified key/cert files do not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.parseSync(process.argv);
|
||||
|
||||
/** Reads and parses yargs config file. */
|
||||
function parseConfig(configPath: string) {
|
||||
let config: any;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, { encoding: "utf-8" }));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse config file!`);
|
||||
}
|
||||
|
||||
// Resolve key/cert paths relative to config
|
||||
const configDirName = path.dirname(configPath);
|
||||
if (typeof config["key-file"] === "string") {
|
||||
config["key-file"] = path.resolve(configDirName, config["key-file"]);
|
||||
}
|
||||
if (typeof config["cert-file"] === "string") {
|
||||
config["cert-file"] = path.resolve(configDirName, config["cert-file"]);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
if (argv.daemon) {
|
||||
import("./daemon").then(daemon => {
|
||||
const daemonOpts: DaemonOpts = {
|
||||
host: argv.host,
|
||||
port: argv.port,
|
||||
password: argv.password
|
||||
};
|
||||
if (argv.secure) {
|
||||
daemonOpts.secure = true;
|
||||
daemonOpts.key = fs.readFileSync(argv.keyFile!);
|
||||
daemonOpts.cert = fs.readFileSync(argv.certFile!);
|
||||
}
|
||||
|
||||
daemon.init(daemonOpts);
|
||||
});
|
||||
} else {
|
||||
import("./bridge");
|
||||
}
|
||||
122
bridge/src/transforms.ts
Executable file
122
bridge/src/transforms.ts
Executable file
@@ -0,0 +1,122 @@
|
||||
import { Transform, TransformCallback } from "stream";
|
||||
import type { Message } from "./bridge/messaging";
|
||||
|
||||
type ResponseHandlerFunction = (message: Message) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Takes a handler function that implements the transform
|
||||
* and calls the transform callback.
|
||||
*/
|
||||
export class ResponseTransform extends Transform {
|
||||
constructor(private _handler: ResponseHandlerFunction) {
|
||||
super({
|
||||
readableObjectMode: true,
|
||||
writableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: Message,
|
||||
_encoding: string,
|
||||
callback: TransformCallback
|
||||
) {
|
||||
Promise.resolve(this._handler(chunk)).then(res => {
|
||||
if (res) {
|
||||
callback(null, res);
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes input, decodes the message string, parses as JSON
|
||||
* and outputs the parsed result.
|
||||
*/
|
||||
export class DecodeTransform extends Transform {
|
||||
// Message data
|
||||
private _messageBuffer = Buffer.alloc(0);
|
||||
private _messageLength?: number;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
readableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: Uint8Array,
|
||||
_encoding: string,
|
||||
// tslint:disable-next-line:ban-types
|
||||
callback: TransformCallback
|
||||
) {
|
||||
// Append next chunk to buffer
|
||||
this._messageBuffer = Buffer.concat([this._messageBuffer, chunk]);
|
||||
|
||||
for (;;) {
|
||||
if (this._messageLength === undefined) {
|
||||
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 = undefined;
|
||||
|
||||
// Next message
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No complete messages left
|
||||
callback();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes input, encodes the message length and content and
|
||||
* outputs the encoded result.
|
||||
*/
|
||||
export class EncodeTransform extends Transform {
|
||||
constructor() {
|
||||
super({
|
||||
writableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: Uint8Array,
|
||||
_encoding: string,
|
||||
// tslint:disable-next-line:ban-types
|
||||
callback: TransformCallback
|
||||
) {
|
||||
const messageLength = Buffer.alloc(4);
|
||||
const message = Buffer.from(JSON.stringify(chunk));
|
||||
|
||||
// Write message length
|
||||
messageLength.writeUInt32LE(message.length, 0);
|
||||
|
||||
// Output joined length and content
|
||||
callback(null, Buffer.concat([messageLength, message]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user