Add snap support (#60)

* Initial app daemon implementation

* Pass script path to child bridge processes

* Change WebSocket server port

* Fix error sending message whilst WebSocket connection is closing

* Initial ext daemon connection implementation
This commit is contained in:
Matt Hensman
2019-05-15 11:30:30 +01:00
committed by GitHub
parent 8cb097c963
commit 474dbad1aa
20 changed files with 753 additions and 375 deletions

113
app/src/bridge/Media.ts Normal file
View File

@@ -0,0 +1,113 @@
"use strict";
import { Channel } from "castv2";
import Session from "./Session";
import { Message
, SendMessageCallback } from "./types";
const MEDIA_NAMESPACE = "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 sessionId: number;
private mediaSessionId: number;
private referenceId: string;
private session: Session;
private channel: Channel;
private sendMessageCallback: SendMessageCallback;
constructor (
sessionId: number
, mediaSessionId: number
, referenceId: string
, session: Session
, sendMessageCallback: SendMessageCallback) {
this.sessionId = sessionId;
this.mediaSessionId = mediaSessionId;
this.referenceId = referenceId;
this.session = session;
this.sendMessageCallback = sendMessageCallback;
this.session.createChannel(MEDIA_NAMESPACE);
this.channel = this.session.channelMap.get(MEDIA_NAMESPACE);
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 = {
_lastCurrentTime: Date.now() / 1000
, _volumeLevel: status.volume.level
, _volumeMuted: status.volume.muted
, currentTime: status.currentTime
, customData: status.customData
, playbackRate: status.playbackRate
, playerState: status.playerState
, repeatMode: status.repeatMode
} as UpdateMessageData;
if (status.media) {
messageData.media = status.media;
}
if (status.mediaSessionId) {
messageData.mediaSessionId = status.mediaSessionId;
}
this.sendMessage("shim:/media/update", messageData);
// Update ID
if (status.mediaSessionId) {
this.mediaSessionId = status.mediaSessionId;
}
}
});
}
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 = {}) {
this.sendMessageCallback({
subject
, data
, _id: this.referenceId
});
}
}

View File

@@ -0,0 +1,73 @@
"use strict";
import EventEmitter from "events";
import fs from "fs";
import http from "http";
import mime from "mime-types";
import { Message
, SendMessageCallback } from "./types";
export default class MediaServer extends EventEmitter {
private httpServer: http.Server;
constructor (
private filePath: string
, private port: number) {
super();
this.httpServer = http.createServer(this.requestListener.bind(this));
}
public start () {
this.httpServer.listen(this.port, () => {
this.emit("started");
});
}
public stop () {
if (this.httpServer && this.httpServer.listening) {
this.httpServer.close(() => {
this.emit("stopped");
});
}
}
private requestListener (
req: http.IncomingMessage
, res: http.ServerResponse) {
const { size: fileSize } = fs.statSync(this.filePath);
const { range } = req.headers;
const contentType = mime.lookup(this.filePath) || "video/mp4";
// 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;
const chunkSize = (end - start) + 1;
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": chunkSize
, "Content-Type": contentType
});
fs.createReadStream(this.filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
fs.createReadStream(this.filePath).pipe(res);
}
}
}

281
app/src/bridge/Session.ts Normal file
View File

@@ -0,0 +1,281 @@
"use strict";
import uuid from "uuid";
import { Channel, Client } from "castv2";
import { Message
, SendMessageCallback } from "./types";
const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
export default class Session {
public channelMap = new Map<string, Channel>();
private sendMessageCallback: SendMessageCallback;
private sessionId: number;
private referenceId: string;
private client: Client;
private clientConnection: Channel;
private clientHeartbeat: Channel;
private clientReceiver: Channel;
private clientHeartbeatIntervalId: NodeJS.Timer;
private isSessionCreated = false;
private clientId: string;
private transportId: string;
private transportConnection: Channel;
private app: any;
constructor (
host: string
, port: number
, appId: string
, sessionId: number
, referenceId: string
, sendMessageCallback: SendMessageCallback) {
this.sessionId = sessionId;
this.referenceId = referenceId;
this.sendMessageCallback = sendMessageCallback;
this.client = new Client();
this.client.connect({ host, port }, () => {
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
, requestId: 1
});
this.clientReceiver.on("message", (message: any) => {
if (message.type === "RECEIVER_STATUS") {
this.sendMessage("shim:/session/updateStatus"
, message.status);
if (!message.status.applications) {
return;
}
const receiverApp = message.status.applications[0];
const receiverAppId = receiverApp.appId;
this.app = receiverApp;
if (receiverAppId !== 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" });
if (this.transportConnection) {
this.transportConnection.send({ type: "CLOSE" });
}
}
private sendMessage (subject: string, data: any = {}) {
this.sendMessageCallback({
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: object
, messageId: string) {
let error = false;
try {
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
});
}
}

View File

@@ -0,0 +1,82 @@
"use strict";
import { Channel, Client } from "castv2";
import { EventEmitter } from "events";
const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
/**
* 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: number;
constructor (
private host: string
, private port: number) {
super();
this.client = new Client();
this.client.connect({ host, port }, this.onConnect.bind(this));
this.client.on("close", () => {
clearInterval(this.clientHeartbeatIntervalId);
});
}
/**
* Closes status listener connection.
*/
public deregister (): void {
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" });
});
}
}

View File

@@ -0,0 +1,229 @@
/**
* 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) {
if (clientId && clientSk) {
this.clientId = clientId;
this.clientSk = clientSk;
} 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) {
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);
}
}

View File

@@ -0,0 +1,4 @@
import create from "bplist-creator";
import parse from "bplist-parser";
export default { create, parse };

View File

@@ -0,0 +1,54 @@
"use strict";
export interface ReceiverStatus {
volume: {
muted: boolean;
stepInterval: number;
controlType: string;
level: number;
};
applications?: Array<{
displayName: string;
statusText: string;
transportId: string;
isIdleScreen: boolean;
sessionId: string;
namespaces: Array<{ name: string }>;
appId: string;
}>;
userEq?: {};
}
export interface MediaStatus {
mediaSessionId: number;
supportedMediaCommands: number;
currentTime: number;
media: {
duration: number;
contentId: string;
streamType: string;
contentType: string;
};
playbackRate: number;
volume: {
muted: boolean;
level: number;
};
currentItemId: number;
idleReason: string;
playerState: string;
extendedStatus: {
playerState: string;
media: {
contentId: string;
streamType: string;
contentType: string;
metadata: {
images: Array<{ url: string }>;
metadataType: number;
artist: string;
title: string;
};
}
};
}

307
app/src/bridge/index.ts Executable file
View File

@@ -0,0 +1,307 @@
import dnssd from "dnssd";
import child_process from "child_process";
import events from "events";
import fs from "fs";
import http from "http";
import mime from "mime-types";
import path from "path";
import Media from "./Media";
import MediaServer from "./MediaServer";
import Session from "./Session";
import StatusListener from "./StatusListener";
import { DecodeTransform
, EncodeTransform
, ResponseTransform } from "../transforms";
import { MediaStatus
, ReceiverStatus } from "./castTypes";
import { Message } from "./types";
import { __applicationName
, __applicationVersion } from "../../package.json";
// Increase listener limit
events.EventEmitter.defaultMaxListeners = 50;
const browser = new dnssd.Browser(dnssd.tcp("googlecast"));
// Local media server
let mediaServer: MediaServer;
process.on("SIGTERM", () => {
if (mediaServer) {
mediaServer.stop();
}
});
const decodeTransform = new DecodeTransform();
const encodeTransform = new EncodeTransform();
// stdin -> stdout
process.stdin
.pipe(decodeTransform)
.pipe(new ResponseTransform(handleMessage))
.pipe(encodeTransform)
.pipe(process.stdout);
/**
* Encode and send a message to the extension.
*/
function sendMessage (message: object) {
try {
encodeTransform.write(message);
} catch (err) {
console.error("Failed to encode message");
}
}
interface InitializeOptions {
shouldWatchStatus?: boolean;
}
// Existing counterpart Media/Session objects
const existingSessions: Map<string, Session> = new Map();
const existingMedia: Map<string, Media> = new Map();
let receiverSelectorApp: child_process.ChildProcess;
/**
* Handle incoming messages from the extension and forward
* them to the appropriate handlers.
*
* Initializes the counterpart objects and is responsible
* for managing existing ones.
*/
async function handleMessage (message: Message) {
if (message.subject.startsWith("bridge:/media/")) {
if (existingMedia.has(message._id)) {
// Forward message to instance message handler
existingMedia.get(message._id).messageHandler(message);
} else {
if (message.subject.endsWith("/initialize")) {
// Get Session object media belongs to
const parentSession = existingSessions.get(
message.data._internalSessionId);
// Create Media
existingMedia.set(message._id, new Media(
message.data.sessionId
, message.data.mediaSessionId
, message._id
, parentSession
, sendMessage));
}
}
return;
}
if (message.subject.startsWith("bridge:/session/")) {
if (existingSessions.has(message._id)) {
// Forward message to instance message handler
existingSessions.get(message._id).messageHandler(message);
} else {
if (message.subject.endsWith("/initialize")) {
// Create Session
existingSessions.set(message._id, new Session(
message.data.address
, message.data.port
, message.data.appId
, message.data.sessionId
, message._id
, sendMessage));
}
}
return;
}
switch (message.subject) {
case "bridge:/getInfo": {
const extensionVersion = message.data;
return __applicationVersion;
}
case "bridge:/initialize": {
const options: InitializeOptions = message.data;
initialize(options);
break;
}
case "bridge:/receiverSelector/open": {
const receiverSelectorData = message.data;
if (process.platform !== "darwin") {
console.error("Invalid platform for native selector.");
process.exit(1);
}
if (!receiverSelectorData) {
console.error("Missing native selector data.");
process.exit(1);
} else {
try {
JSON.parse(receiverSelectorData);
} catch (err) {
console.error("Invalid native selector data.");
}
}
receiverSelectorApp = child_process.spawn(
path.join(process.cwd(), "selector")
, [ receiverSelectorData ]);
receiverSelectorApp.stdout.setEncoding("utf8");
receiverSelectorApp.stdout.on("data", data => {
sendMessage({
subject: "main:/receiverSelector/selected"
, data: JSON.parse(data)
});
});
receiverSelectorApp.addListener("error", err => {
sendMessage({
subject: "main:/receiverSelector/error"
, data: err.message
});
});
receiverSelectorApp.on("close", () => {
sendMessage({
subject: "main:/receiverSelector/close"
});
});
break;
}
case "bridge:/receiverSelector/close": {
receiverSelectorApp.kill();
break;
}
case "bridge:/mediaServer/start": {
const { filePath, port } = message.data;
mediaServer = new MediaServer(filePath, port);
mediaServer.start();
mediaServer.on("started", () => {
sendMessage({
subject: "mediaCast:/mediaServer/started"
});
});
mediaServer.on("stopped", () => {
sendMessage({
subject: "mediaCast:/mediaServer/stopped"
});
});
break;
}
case "bridge:/mediaServer/stop": {
if (mediaServer) {
mediaServer.stop();
}
break;
}
}
}
function initialize (options: InitializeOptions) {
if (options.shouldWatchStatus) {
browser.on("serviceUp", onStatusBrowserServiceUp);
browser.on("serviceDown", onStatusBrowserServiceDown);
}
browser.on("serviceUp", onBrowserServiceUp);
browser.on("servicedown", onBrowserServiceDown);
browser.start();
function onBrowserServiceUp (service: dnssd.Service) {
sendMessage({
subject: "shim:/serviceUp"
, data: {
host: service.addresses[0]
, port: service.port
, id: service.txt.id
, friendlyName: service.txt.fn
}
});
}
function onBrowserServiceDown (service: dnssd.Service) {
sendMessage({
subject: "shim:/serviceDown"
, data: {
id: service.txt.id
}
});
}
// Receiver status listeners for status mode
const statusListeners = new Map<string, StatusListener>();
function onStatusBrowserServiceUp (service: dnssd.Service) {
const { id } = service.txt;
const listener = new StatusListener(
service.addresses[0]
, service.port);
listener.on("receiverStatus", (status: ReceiverStatus) => {
const receiverStatusMessage: any = {
subject: "receiverStatus"
, data: {
id
, status: {
volume: {
level: status.volume.level
, muted: status.volume.muted
}
}
}
};
if ("applications" in status) {
const application = status.applications[0];
receiverStatusMessage.data.status.application = {
displayName: application.displayName
, isIdleScreen: application.isIdleScreen
, statusText: application.statusText
};
}
sendMessage(receiverStatusMessage);
});
statusListeners.set(id, listener);
}
function onStatusBrowserServiceDown (service: dnssd.Service) {
const { id } = service.txt;
if (statusListeners.has(id)) {
statusListeners.get(id).deregister();
statusListeners.delete(id);
}
}
}

9
app/src/bridge/types.ts Normal file
View File

@@ -0,0 +1,9 @@
"use strict";
export interface Message {
subject: string;
data?: any;
_id?: string;
}
export type SendMessageCallback = (message: Message) => void;