mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
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:
@@ -180,11 +180,17 @@ async function build () {
|
||||
fs.writeFileSync(path.join(BUILD_PATH, "package.json")
|
||||
, JSON.stringify(pkgManifest))
|
||||
|
||||
/**
|
||||
* With the BUILD_PATH/bridge dir, cannot build to
|
||||
* BUILD_PATH/bridge file.
|
||||
*/
|
||||
const tempExecutableName = `${executableName[argv.platform]}.temp`;
|
||||
|
||||
// Run pkg to create a single executable
|
||||
await pkg.exec([
|
||||
BUILD_PATH
|
||||
, "--target", `${pkgPlatform[argv.platform]}-${argv.arch}`
|
||||
, "--output", path.join(BUILD_PATH, executableName[argv.platform])
|
||||
, "--output", path.join(BUILD_PATH, tempExecutableName)
|
||||
]);
|
||||
|
||||
// Build NativeMacReceiverSelector
|
||||
@@ -226,16 +232,14 @@ async function build () {
|
||||
, { overwrite: true });
|
||||
}
|
||||
} else {
|
||||
const builtExecutableName = executableName[argv.platform];
|
||||
|
||||
// Move executable and app manifest to dist
|
||||
fs.moveSync(
|
||||
path.join(BUILD_PATH, manifestName)
|
||||
, path.join(DIST_PATH, manifestName)
|
||||
, { overwrite: true });
|
||||
fs.moveSync(
|
||||
path.join(BUILD_PATH, builtExecutableName)
|
||||
, path.join(DIST_PATH, builtExecutableName)
|
||||
path.join(BUILD_PATH, tempExecutableName)
|
||||
, path.join(DIST_PATH, executableName[argv.platform])
|
||||
, { overwrite: true });
|
||||
|
||||
if (isBuildingForMacOnMac) {
|
||||
|
||||
307
app/src/bridge/index.ts
Executable file
307
app/src/bridge/index.ts
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/src/daemon/index.ts
Normal file
61
app/src/daemon/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import path from "path";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { DecodeTransform
|
||||
, EncodeTransform } from "../transforms";
|
||||
|
||||
|
||||
const wss = new WebSocket.Server({ port: 9556 });
|
||||
|
||||
wss.on("connection", socket => {
|
||||
|
||||
/**
|
||||
* 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] ]);
|
||||
|
||||
// Stream for incoming WebSocket messages
|
||||
const messageStream = new Readable({
|
||||
objectMode: true
|
||||
});
|
||||
|
||||
// tslint:disable-next-line:no-empty
|
||||
messageStream._read = () => {};
|
||||
|
||||
/**
|
||||
* Incoming JSON messages from the extension over the
|
||||
* WebSocket connection are parsed and re-encoded to be sent
|
||||
* to bridge stdin.
|
||||
*/
|
||||
socket.on("message", (message: string) => {
|
||||
messageStream.push(JSON.parse(message));
|
||||
});
|
||||
|
||||
messageStream
|
||||
.pipe(new EncodeTransform())
|
||||
.pipe(bridge.stdin);
|
||||
|
||||
/**
|
||||
* Incoming messages from the bridge are decoded and
|
||||
* stringified and sent to the extension over the WebSocket
|
||||
* connection.
|
||||
*/
|
||||
bridge.stdout
|
||||
.pipe(new DecodeTransform())
|
||||
.on("data", data => {
|
||||
// Socket can be CLOSING here
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle termination
|
||||
socket.on("close", () => bridge.kill());
|
||||
bridge.on("exit", () => socket.close());
|
||||
});
|
||||
301
app/src/main.ts
Executable file → Normal file
301
app/src/main.ts
Executable file → Normal file
@@ -1,299 +1,18 @@
|
||||
import dnssd from "dnssd";
|
||||
"use strict";
|
||||
|
||||
import child_process from "child_process";
|
||||
import events from "events";
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import mime from "mime-types";
|
||||
import path from "path";
|
||||
|
||||
import Media from "./Media";
|
||||
import MediaServer from "./MediaServer";
|
||||
import Session from "./Session";
|
||||
import StatusListener from "./StatusListener";
|
||||
import * as transforms from "./transforms";
|
||||
|
||||
import { MediaStatus, ReceiverStatus } from "./castTypes";
|
||||
|
||||
import { Message } from "./types";
|
||||
|
||||
import { __applicationName
|
||||
, __applicationVersion } from "../package.json";
|
||||
import minimist from "minimist";
|
||||
|
||||
|
||||
// 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 argv = minimist(process.argv.slice(2), {
|
||||
boolean: [ "daemon" ]
|
||||
, default: {
|
||||
daemon: false
|
||||
}
|
||||
});
|
||||
|
||||
// stdin -> stdout
|
||||
process.stdin
|
||||
.pipe(transforms.decode)
|
||||
.pipe(transforms.response(handleMessage))
|
||||
.pipe(transforms.encode)
|
||||
.pipe(process.stdout);
|
||||
|
||||
/**
|
||||
* Encode and send a message to the extension.
|
||||
*/
|
||||
function sendMessage (message: object) {
|
||||
try {
|
||||
transforms.encode.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);
|
||||
}
|
||||
}
|
||||
if (argv.daemon) {
|
||||
import("./daemon");
|
||||
} else {
|
||||
import("./bridge");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
import { Transform } from "stream";
|
||||
import { Message } from "./types";
|
||||
import { Message } from "./bridge/types";
|
||||
|
||||
|
||||
type ResponseHandlerFunction = (message: Message) => Promise<any>;
|
||||
@@ -10,12 +10,21 @@ type ResponseHandlerFunction = (message: Message) => Promise<any>;
|
||||
* Takes a handler function that implements the transform
|
||||
* and calls the transform callback.
|
||||
*/
|
||||
export const response = (handler: ResponseHandlerFunction) => new Transform({
|
||||
readableObjectMode: true
|
||||
, writableObjectMode: true
|
||||
export class ResponseTransform extends Transform {
|
||||
constructor (private _handler: ResponseHandlerFunction) {
|
||||
super({
|
||||
readableObjectMode: true
|
||||
, writableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
, transform (chunk: Message, encoding, callback) {
|
||||
Promise.resolve(handler(chunk))
|
||||
public _transform (
|
||||
chunk: Message
|
||||
, encoding: string
|
||||
// tslint:disable-next-line:ban-types
|
||||
, callback: Function) {
|
||||
|
||||
Promise.resolve(this._handler(chunk))
|
||||
.then(res => {
|
||||
if (res) {
|
||||
callback(null, res);
|
||||
@@ -24,81 +33,100 @@ export const response = (handler: ResponseHandlerFunction) => new Transform({
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Takes input, decodes the message string, parses as JSON
|
||||
* and outputs the parsed result.
|
||||
*/
|
||||
export const decode = new Transform({
|
||||
readableObjectMode: true
|
||||
export class DecodeTransform extends Transform {
|
||||
// Message data
|
||||
private _messageBuffer = Buffer.alloc(0);
|
||||
private _messageLength: number = null;
|
||||
|
||||
, transform (chunk, encoding, callback) {
|
||||
const self = this as any;
|
||||
|
||||
// Setup persistent data
|
||||
if (!this.hasOwnProperty("_buf")
|
||||
&& !this.hasOwnProperty("_messageLength")) {
|
||||
|
||||
self._buf = Buffer.alloc(0);
|
||||
self._messageLength = null;
|
||||
}
|
||||
|
||||
// Append next chunk to buffer
|
||||
self._buf = Buffer.concat([ self._buf, chunk ]);
|
||||
|
||||
while (true) {
|
||||
if (self._messageLength === null) {
|
||||
if (self._buf.length >= 4) {
|
||||
|
||||
// Read message length
|
||||
self._messageLength = self._buf.readUInt32LE(0);
|
||||
|
||||
// Offset buffer
|
||||
self._buf = self._buf.slice(4);
|
||||
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (self._buf.length >= self._messageLength) {
|
||||
const message = JSON.parse(self._buf.slice(
|
||||
0, self._messageLength));
|
||||
|
||||
this.push(message);
|
||||
|
||||
// Cleanup persistent data
|
||||
self._buf = self._buf.slice(self._messageLength);
|
||||
self._messageLength = null;
|
||||
|
||||
// Parse next message
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No complete messages left
|
||||
callback();
|
||||
break;
|
||||
}
|
||||
constructor () {
|
||||
super({
|
||||
readableObjectMode: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
public _transform (
|
||||
chunk: any
|
||||
, encoding: string
|
||||
// tslint:disable-next-line:ban-types
|
||||
, callback: Function) {
|
||||
|
||||
// Append next chunk to buffer
|
||||
this._messageBuffer = Buffer.concat([
|
||||
this._messageBuffer
|
||||
, chunk
|
||||
]);
|
||||
|
||||
for (;;) {
|
||||
if (this._messageLength === null) {
|
||||
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 = null;
|
||||
|
||||
// Next message
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No complete messages left
|
||||
callback();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Takes input, encodes the message length and content and
|
||||
* outputs the encoded result.
|
||||
*/
|
||||
export const encode = new Transform({
|
||||
writableObjectMode: true
|
||||
export class EncodeTransform extends Transform {
|
||||
constructor () {
|
||||
super({
|
||||
writableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform (
|
||||
chunk: any
|
||||
, encoding: string
|
||||
// tslint:disable-next-line:ban-types
|
||||
, callback: Function) {
|
||||
|
||||
, transform (chunk, encoding, callback) {
|
||||
const messageLength = Buffer.alloc(4);
|
||||
const message = Buffer.from(JSON.stringify(chunk));
|
||||
|
||||
// Write message length
|
||||
messageLength.writeUInt32LE(message.length, 0);
|
||||
|
||||
// Output joined message length and content
|
||||
callback(null, Buffer.concat([messageLength, message]));
|
||||
// Output joined length and content
|
||||
callback(null, Buffer.concat([
|
||||
messageLength
|
||||
, message
|
||||
]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
import semver from "semver";
|
||||
import nativeMessaging from "./nativeMessaging";
|
||||
|
||||
export interface BridgeInfo {
|
||||
name: string;
|
||||
@@ -15,7 +16,7 @@ export interface BridgeInfo {
|
||||
export default async function getBridgeInfo (): Promise<BridgeInfo> {
|
||||
let applicationVersion: string;
|
||||
try {
|
||||
applicationVersion = await browser.runtime.sendNativeMessage(
|
||||
applicationVersion = await nativeMessaging.sendNativeMessage(
|
||||
APPLICATION_NAME
|
||||
, { subject: "bridge:/getInfo"
|
||||
, data: EXTENSION_VERSION });
|
||||
|
||||
181
ext/src/lib/nativeMessaging.ts
Normal file
181
ext/src/lib/nativeMessaging.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
"use strict";
|
||||
|
||||
import { Message } from "../types";
|
||||
|
||||
|
||||
const WEBSOCKET_DAEMON_URL = "ws://localhost:9556";
|
||||
|
||||
|
||||
type DisconnectListener = () => void;
|
||||
type MessageListener = (message: any) => void;
|
||||
|
||||
function connectNative (application: string) {
|
||||
/**
|
||||
* In order to preserve the synchronous API, messages are
|
||||
* queued before either the native messaging host or the
|
||||
* WebSocket connection is ready to send data.
|
||||
*/
|
||||
let messageQueue: object[] = [];
|
||||
|
||||
/**
|
||||
* Set once the native messaging host is known to be either
|
||||
* present/missing. Determines whether messages go to the
|
||||
* message queue.
|
||||
*/
|
||||
let isNativeHostStatusKnown = false;
|
||||
|
||||
const port = browser.runtime.connectNative(application);
|
||||
|
||||
|
||||
let socket: WebSocket;
|
||||
|
||||
const onDisconnectListeners = new Set<DisconnectListener>();
|
||||
const onMessageListeners = new Set<MessageListener>();
|
||||
|
||||
// Port proxy API
|
||||
const portObject: browser.runtime.Port = {
|
||||
error: null as any
|
||||
, name: ""
|
||||
|
||||
, onDisconnect: {
|
||||
addListener (cb: DisconnectListener) {
|
||||
onDisconnectListeners.add(cb);
|
||||
}
|
||||
, removeListener (cb: DisconnectListener) {
|
||||
onDisconnectListeners.delete(cb);
|
||||
}
|
||||
, hasListener (cb: DisconnectListener) {
|
||||
return onDisconnectListeners.has(cb);
|
||||
}
|
||||
}
|
||||
, onMessage: {
|
||||
addListener (cb: MessageListener) {
|
||||
onMessageListeners.add(cb);
|
||||
}
|
||||
, removeListener (cb: MessageListener) {
|
||||
onMessageListeners.delete(cb);
|
||||
}
|
||||
, hasListener (cb: MessageListener) {
|
||||
return onMessageListeners.has(cb);
|
||||
}
|
||||
|
||||
// Workaround for modified types
|
||||
, hasListeners () { return false; }
|
||||
}
|
||||
|
||||
, disconnect () {
|
||||
if (socket) {
|
||||
socket.close();
|
||||
} else {
|
||||
port.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
, postMessage (message) {
|
||||
if (socket) {
|
||||
switch (socket.readyState) {
|
||||
case WebSocket.CONNECTING: {
|
||||
// Queue message until WebSocket is ready
|
||||
messageQueue.push(message);
|
||||
break;
|
||||
}
|
||||
|
||||
case WebSocket.OPEN: {
|
||||
socket.send(JSON.stringify(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!isNativeHostStatusKnown) {
|
||||
// Queue message until native messaging host is ready
|
||||
messageQueue.push(message);
|
||||
}
|
||||
|
||||
port.postMessage(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (port.error && !isNativeHostStatusKnown) {
|
||||
isNativeHostStatusKnown = true;
|
||||
|
||||
socket = new WebSocket(WEBSOCKET_DAEMON_URL);
|
||||
|
||||
socket.addEventListener("open", ev => {
|
||||
// Send all messages in queue
|
||||
while (messageQueue.length) {
|
||||
const message = messageQueue.pop();
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("message", ev => {
|
||||
for (const listener of onMessageListeners) {
|
||||
listener(JSON.parse(ev.data));
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", ev => {
|
||||
if (ev.code !== 1000) {
|
||||
this.error = {
|
||||
// TODO: Set a proper error message
|
||||
message: ""
|
||||
};
|
||||
}
|
||||
|
||||
for (const listener of onDisconnectListeners) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
port.onMessage.addListener((message: any) => {
|
||||
if (!isNativeHostStatusKnown) {
|
||||
isNativeHostStatusKnown = true;
|
||||
messageQueue = [];
|
||||
}
|
||||
|
||||
for (const listener of onMessageListeners) {
|
||||
listener(message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return portObject;
|
||||
}
|
||||
|
||||
async function sendNativeMessage (
|
||||
application: string
|
||||
, message: any) {
|
||||
|
||||
try {
|
||||
return await browser.runtime.sendNativeMessage(application, message);
|
||||
} catch (err) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(WEBSOCKET_DAEMON_URL);
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
ws.addEventListener("message", ev => {
|
||||
ws.close();
|
||||
resolve(JSON.parse(ev.data));
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
console.error("fx_cast (Debug): No bridge application found.");
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
connectNative
|
||||
, sendNativeMessage
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
import semver from "semver";
|
||||
|
||||
import defaultOptions, { Options } from "./defaultOptions";
|
||||
import getBridgeInfo from "./lib/getBridgeInfo";
|
||||
import messageRouter from "./lib/messageRouter";
|
||||
import nativeMessaging from "./lib/nativeMessaging";
|
||||
|
||||
import { getChromeUserAgent } from "./lib/userAgents";
|
||||
import { getWindowCenteredProps } from "./lib/utils";
|
||||
@@ -18,8 +21,6 @@ import { ReceiverStatusMessage
|
||||
, ServiceDownMessage
|
||||
, ServiceUpMessage } from "./messageTypes";
|
||||
|
||||
import semver from "semver";
|
||||
|
||||
|
||||
const _ = browser.i18n.getMessage;
|
||||
|
||||
@@ -226,7 +227,7 @@ browser.menus.onShown.addListener(info => {
|
||||
* Split url path into segments and add menu items for each
|
||||
* partial path as the segments are removed.
|
||||
*/
|
||||
{
|
||||
{
|
||||
const pathTrimmed = url.pathname.endsWith("/")
|
||||
? url.pathname.substring(0, url.pathname.length - 1)
|
||||
: url.pathname;
|
||||
@@ -536,11 +537,68 @@ interface Shim {
|
||||
|
||||
const shimMap = new Map<string, Shim>();
|
||||
|
||||
const statusBridge = browser.runtime.connectNative(APPLICATION_NAME);
|
||||
let statusBridge: browser.runtime.Port;
|
||||
const statusBridgeReceivers = new Map<string, Receiver>();
|
||||
|
||||
statusBridge.onMessage.addListener(async (message: Message) => {
|
||||
|
||||
/**
|
||||
* Create status bridge, set event handlers and initialize.
|
||||
*/
|
||||
function initStatusBridge () {
|
||||
statusBridge = nativeMessaging.connectNative(APPLICATION_NAME);
|
||||
statusBridge.onDisconnect.addListener(onStatusBridgeDisconnect);
|
||||
statusBridge.onMessage.addListener(onStatusBridgeMessage);
|
||||
|
||||
statusBridge.postMessage({
|
||||
subject: "bridge:/initialize"
|
||||
, data: {
|
||||
mode: "status"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initStatusBridge();
|
||||
|
||||
/**
|
||||
* Runs once the status bridge has disconnected. Sends
|
||||
* serviceDown messages for all receivers to all shims to
|
||||
* update receiver availability, then clears the receiver
|
||||
* list.
|
||||
*
|
||||
* Attempts to reinitialize the status bridge after 10
|
||||
* seconds. If it fails immediately, this handler will be
|
||||
* triggered again and the timer is reset for another 10
|
||||
* seconds.
|
||||
*/
|
||||
function onStatusBridgeDisconnect () {
|
||||
// Notify shims for receiver availability
|
||||
for (const [ , receiver ] of statusBridgeReceivers) {
|
||||
for (const [, shim ] of shimMap) {
|
||||
shim.port.postMessage({
|
||||
subject: "shim:/serviceDown"
|
||||
, data: { id: receiver.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
statusBridgeReceivers.clear();
|
||||
statusBridge.onDisconnect.removeListener(onStatusBridgeDisconnect);
|
||||
statusBridge.onMessage.removeListener(onStatusBridgeMessage);
|
||||
statusBridge = null;
|
||||
|
||||
// After 10 seconds, attempt to reinitialize
|
||||
window.setTimeout(() => {
|
||||
initStatusBridge();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming status bridge messages.
|
||||
*/
|
||||
async function onStatusBridgeMessage (message: Message) {
|
||||
switch (message.subject) {
|
||||
|
||||
case "shim:/serviceUp": {
|
||||
const receiver = (message as ServiceUpMessage).data;
|
||||
statusBridgeReceivers.set(receiver.id, receiver);
|
||||
@@ -591,14 +649,7 @@ statusBridge.onMessage.addListener(async (message: Message) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
statusBridge.postMessage({
|
||||
subject: "bridge:/initialize"
|
||||
, data: {
|
||||
mode: "status"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function onConnectShim (port: browser.runtime.Port) {
|
||||
@@ -664,7 +715,7 @@ async function onConnectShim (port: browser.runtime.Port) {
|
||||
}
|
||||
|
||||
// Spawn bridge app instance
|
||||
const bridgePort = browser.runtime.connectNative(APPLICATION_NAME);
|
||||
const bridgePort = nativeMessaging.connectNative(APPLICATION_NAME);
|
||||
|
||||
if (bridgePort.error) {
|
||||
console.error(`Failed connect to ${APPLICATION_NAME}:`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
import nativeMessaging from "../../lib/nativeMessaging";
|
||||
|
||||
import ReceiverSelectorManager, {
|
||||
ReceiverSelectorMediaType } from "../ReceiverSelectorManager";
|
||||
|
||||
@@ -27,7 +29,7 @@ export default class NativeMacReceiverSelectorManager
|
||||
receivers: Receiver[]
|
||||
, defaultMediaType: ReceiverSelectorMediaType): Promise<void> {
|
||||
|
||||
this.bridgePort = browser.runtime.connectNative(APPLICATION_NAME);
|
||||
this.bridgePort = nativeMessaging.connectNative(APPLICATION_NAME);
|
||||
|
||||
this.bridgePort.onMessage.addListener((message: Message) => {
|
||||
switch (message.subject) {
|
||||
|
||||
@@ -122,7 +122,7 @@ class PopupApp extends Component<{}, PopupAppState> {
|
||||
<ReceiverEntry receiver={ receiver }
|
||||
onCast={ this.onCast }
|
||||
isLoading={ this.state.isLoading }
|
||||
key={ i }/> )}
|
||||
key={ i }/> ))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -2,6 +2,18 @@
|
||||
"requires": true,
|
||||
"lockfileVersion": 1,
|
||||
"dependencies": {
|
||||
"@types/events": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
|
||||
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/minimist": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.0.tgz",
|
||||
"integrity": "sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "11.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-11.9.5.tgz",
|
||||
@@ -23,6 +35,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/ws": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.1.tgz",
|
||||
"integrity": "sha512-EzH8k1gyZ4xih/MaZTXwT2xOkPiIMSrhQ9b8wrlX88L0T02eYsddatQlwVFlEPyEqV0ChpdpNnE51QPH6NVT4Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/events": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
"lint:ext": "npm run lint --prefix ./ext"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.0",
|
||||
"@types/semver": "^5.5.0",
|
||||
"@types/uuid": "^3.4.4",
|
||||
"@types/ws": "^6.0.1",
|
||||
"fs-extra": "^7.0.1",
|
||||
"glob": "^7.1.3",
|
||||
"jasmine-console-reporter": "^3.1.0",
|
||||
|
||||
Reference in New Issue
Block a user