Rename directory: app -> bridge

This commit is contained in:
hensm
2023-02-26 18:04:22 +00:00
parent 3864ffdbf5
commit 33bcbc0dca
38 changed files with 29 additions and 26 deletions

5
bridge/.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}

15
bridge/@types/bplist-creator/index.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="node" />
declare module "bplist-creator" {
import Buffer from "buffer";
function bplist(dicts: object): Buffer;
export = bplist;
namespace bplist {
export class Real {
public value: number;
constructor(value: number);
}
}
}

22
bridge/@types/bplist-parser/index.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/// <reference types="node" />
declare module "bplist-parser" {
import Buffer from "buffer";
export var maxObjectSize: number;
export var maxObjectCount: number;
export class UID {
constructor(id: number);
UID: number;
}
type ParseFileCallback = (err: string, result?: Buffer) => void;
export function parseFile(
fileNameOrBuffer: Buffer | string,
callback: ParseFileCallback
): void;
export function parseBuffer(buffer: Buffer): any;
}

71
bridge/@types/castv2/index.d.ts vendored Normal file
View File

@@ -0,0 +1,71 @@
/// <reference types="node" />
declare module "castv2" {
import { EventEmitter } from "events";
interface ClientConnectOptions {
host: string;
port?: number;
}
type CallbackFunction = () => void;
export interface Channel extends EventEmitter {
bus: Client;
sourceId: string;
destinationId: string;
namespace: string;
encoding: string;
send(data: any): void;
close(): void;
}
export interface DeviceAuthMessage {
parse(data: any): any;
serialize(data: any): any;
}
export class Client extends EventEmitter {
public connect(
options: ClientConnectOptions | string,
callback?: CallbackFunction
): void;
public close(): void;
public send(
sourceId: string,
destinationId: string,
namespace: string,
data: Buffer | string
): void;
public createChannel(
sourceId: string,
destinationId: string,
namespace: string,
encoding: string
): Channel;
}
export class Server extends EventEmitter {
constructor(options: object);
public listen(
port: number,
host: string,
callback?: CallbackFunction
): void;
public send(
clientId: string,
sourceId: string,
destinationId: string,
namespace: string,
data: Buffer | string
): void;
public close(): void;
}
}

50
bridge/@types/fast-srp-hap/index.d.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
/// <reference types="node" />
declare module "fast-srp-hap" {
import Buffer from "buffer";
interface Param {
N_length_bits: number;
N: any;
g: any;
hash: string;
}
export const params: { [key: number]: Param };
type GenKeyCallback = (err: string, buf: Buffer) => void;
export function genKey(bytes: number, callback: GenKeyCallback): void;
export function computeVerifier(
params: object,
salt: Buffer,
I: Buffer,
P: Buffer
): Buffer;
export class Client {
constructor(
params: object,
salt_buf: Buffer,
identity_buf: Buffer,
password_buf: Buffer,
secret1_buf: Buffer
);
computeA(): Buffer;
setB(B_buf: Buffer): void;
computeM1(): Buffer;
checkM2(serverM2_buf: Buffer): void;
computeK(): Buffer;
}
export class Server {
constructor(params: object, verifier_buf: Buffer, secret2_buf: Buffer);
computeB(): Buffer;
setA(A_buf: Buffer): void;
checkM1(clientM1_buf: Buffer): Buffer;
computeK(): Buffer;
}
}

566
bridge/bin/build.js Normal file
View File

@@ -0,0 +1,566 @@
// @ts-check
import fs from "fs-extra";
import os from "os";
import path from "path";
import url from "url";
import { spawnSync } from "child_process";
import mustache from "mustache";
import pkg from "pkg";
import yargs from "yargs";
import config from "./lib/config.js";
import * as paths from "./lib/paths.js";
const argv = await yargs()
.help()
.version(false)
.option("package", {
describe: "Create installer package",
type: "boolean"
})
.option("package-type", {
describe: "Linux package type",
choices: ["deb", "rpm"],
default: "deb"
})
.option("use-pkg", {
describe: "Create single binary with pkg",
type: "boolean"
})
.option("arch", {
describe: "Set build architecture",
default: os.arch()
})
.option("node-version", {
describe: "Node.js version to target",
default: "16"
})
.conflicts("use-pkg", "package")
.parse(process.argv);
const supportedTargets = {
win32: ["x86", "x64"],
darwin: ["x64", "arm64"],
linux: ["x64"]
};
if (!supportedTargets[process.platform]?.includes(argv.arch)) {
console.error(
`Error: Unsupported target! (${
paths.pkgPlatformMap[process.platform]
}-${argv.arch})`
);
process.exit(1);
}
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT_PATH = path.join(__dirname, "..");
const BUILD_PATH = path.join(ROOT_PATH, "build");
const spawnOptions = {
shell: true,
stdio: [process.stdin, process.stdout, process.stderr]
};
/**
* Shouldn't exist, but cleanup and re-create any existing
* build directories, just in case.
*/
fs.rmSync(BUILD_PATH, { force: true, recursive: true });
fs.rmSync(paths.DIST_PATH, { force: true, recursive: true });
fs.mkdirSync(BUILD_PATH, { recursive: true });
fs.mkdirSync(paths.DIST_PATH, { recursive: true });
const MDNS_BINDING_PATH = path.join(
__dirname,
"../node_modules/mdns/build/Release/"
);
const MDNS_BINDING_NAME = "dns_sd_bindings.node";
async function build() {
// Run tsc
spawnSync(
`tsc --project ${ROOT_PATH} \
--outDir ${BUILD_PATH}`,
spawnOptions
);
/**
* Native app manifest
* https://mdn.io/Native_manifests#Native_messaging_manifests
*/
const manifest = {
name: config.applicationName,
description: "",
type: "stdio",
allowed_extensions: [config.extensionId]
};
/**
* If packaging, add the installed executable path, otherwise
* add the path to the built executable in the dist folder.
*/
if (argv.package || argv.usePkg) {
// Need a minimal package.json for pkg.
const pkgManifest = {
bin: "main.js",
pkg: {
/**
* Workaround for pkg asset detection
* https://github.com/thibauts/node-castv2/issues/46
*/
assets: "../../node_modules/castv2/lib/cast_channel.proto"
}
};
const executableName = paths.getExecutableName(process.platform);
const executablePath = paths.getExecutableDirectory(
process.platform,
argv.arch
);
// Write pkg manifest
fs.writeFileSync(
path.join(BUILD_PATH, "src/package.json"),
JSON.stringify(pkgManifest)
);
// Run pkg to create a single executable
await pkg.exec([
path.join(BUILD_PATH, "src"),
"--target",
`node${argv.nodeVersion}-${
paths.pkgPlatformMap[process.platform]
}-${argv.arch}`,
"--output",
path.join(BUILD_PATH, executableName)
]);
fs.copySync(
path.join(MDNS_BINDING_PATH, MDNS_BINDING_NAME),
path.join(BUILD_PATH, MDNS_BINDING_NAME)
);
fs.rmSync(path.join(BUILD_PATH, "src"), {
recursive: true,
force: true
});
manifest.path =
!argv.package && argv.usePkg
? path.join(paths.DIST_PATH, executableName)
: path.join(executablePath, executableName);
} else {
let launcherPath = path.join(
BUILD_PATH,
config.applicationExecutableName
);
const modulesDir = path.join(ROOT_PATH, "node_modules");
// Write launcher script
switch (process.platform) {
case "win32": {
launcherPath += ".bat";
fs.writeFileSync(
launcherPath,
`@echo off
setlocal
set NODE_PATH=${modulesDir}
node %~dp0src\\main.js --__name %~n0%~x0 %*
endlocal
`
);
break;
}
case "linux":
case "darwin": {
launcherPath += ".sh";
fs.writeFileSync(
launcherPath,
`#!/usr/bin/env sh
NODE_PATH="${modulesDir}" node $(dirname $0)/src/main.js --__name $(basename $0) "$@"
`
);
break;
}
}
manifest.path = path.join(paths.DIST_PATH, path.basename(launcherPath));
}
// Write app manifest
fs.writeFileSync(
path.join(BUILD_PATH, paths.MANIFEST_NAME),
JSON.stringify(manifest, null, 4)
);
// Ensure file permissions are correct
for (const file of fs.readdirSync(BUILD_PATH)) {
fs.chmodSync(path.resolve(BUILD_PATH, file), 0o755);
}
/**
* If packaging, create an installer package and move it to
* dist, otherwise move the built executable and app manifest
* to dist.
*/
if (argv.package) {
const installerName = await packageApp(process.platform, argv.arch);
if (installerName) {
// Move installer to dist
fs.moveSync(
path.join(BUILD_PATH, installerName),
path.join(paths.DIST_PATH, path.basename(installerName)),
{ overwrite: true }
);
}
} else {
// Move tsc output and launcher to dist
fs.moveSync(BUILD_PATH, paths.DIST_PATH, { overwrite: true });
}
// Remove build directory
fs.rmSync(BUILD_PATH, { force: true, recursive: true });
}
/**
* Takes a platform and returns the path of the created
* installer package.
*
* @param {string} platform
* @param {string} arch
*/
async function packageApp(platform, arch) {
/** @type {[ string, string, string, string ]} */
const packageFnArgs = [
arch,
paths.getExecutableName(platform),
paths.getExecutableDirectory(platform, arch),
paths.getManifestDirectory(platform, arch, argv.packageType)
];
switch (platform) {
case "win32":
// Pass without manifest
return packageWin32(
packageFnArgs[0],
packageFnArgs[1],
packageFnArgs[2]
);
case "darwin":
return packageDarwin(...packageFnArgs);
case "linux": {
switch (argv.packageType) {
case "deb":
return packageLinuxDeb(...packageFnArgs);
case "rpm":
return packageLinuxRpm(...packageFnArgs);
}
break;
}
}
}
/**
* Builds a macOS Installer package.
*
* Creates a root directory with the installed file system
* structure for package files, bundles the postinstall
* script (packaging/mac/scripts/postinstall), then creates
* a component package.
* Distribution package is built from the component package
* and distribution file (packaging/mac/distribution.xml).
*
* Requires the pkgbuild and productbuild command line
* utilities. Only possible on macOS.
*
* @param {string} arch
* @param {string} platformExecutableName
* @param {string} platformExecutableDirectory
* @param {string} platformManifestDirectory
*/
function packageDarwin(
arch,
platformExecutableName,
platformExecutableDirectory,
platformManifestDirectory
) {
const outputName = `${config.applicationName}-${config.applicationVersion}-${arch}.pkg`;
const componentName = `${config.applicationName}_component.pkg`;
const packagingDir = path.join(__dirname, "../packaging/mac/");
const packagingOutputDir = path.join(BUILD_PATH, "packaging");
// Create pkgbuild root
const rootPath = path.join(BUILD_PATH, "root");
const rootExecutableDirectory = path.join(
rootPath,
platformExecutableDirectory
);
const rootManifestDirectory = path.join(
rootPath,
platformManifestDirectory
);
// Create install locations
fs.mkdirSync(rootExecutableDirectory, { recursive: true });
fs.mkdirSync(rootManifestDirectory, { recursive: true });
// Move files to root
fs.moveSync(
path.join(BUILD_PATH, platformExecutableName),
path.join(rootExecutableDirectory, platformExecutableName)
);
fs.moveSync(
path.join(BUILD_PATH, MDNS_BINDING_NAME),
path.join(rootExecutableDirectory, MDNS_BINDING_NAME)
);
fs.moveSync(
path.join(BUILD_PATH, paths.MANIFEST_NAME),
path.join(rootManifestDirectory, paths.MANIFEST_NAME)
);
// Copy static files to be processed
fs.copySync(packagingDir, packagingOutputDir);
const view = {
applicationName: config.applicationName,
manifestName: paths.MANIFEST_NAME,
componentName,
packageId: `tf.matt.${config.applicationName}`,
executablePath: platformExecutableDirectory,
manifestPath: platformManifestDirectory
};
// Template paths
const templatePaths = [
path.join(packagingOutputDir, "scripts/postinstall"),
path.join(packagingOutputDir, "distribution.xml")
];
// Do templating on static files
for (const templatePath of templatePaths) {
const templateContent = fs.readFileSync(templatePath).toString();
fs.writeFileSync(templatePath, mustache.render(templateContent, view));
}
// Build component package
spawnSync(
`pkgbuild --root ${rootPath} \
--identifier "tf.matt.${config.applicationName}" \
--version "${config.applicationVersion}" \
--scripts ${path.join(packagingOutputDir, "scripts")} \
${path.join(BUILD_PATH, componentName)}`,
spawnOptions
);
// Distribution XML file
const distFilePath = path.join(packagingOutputDir, "distribution.xml");
// Build installer package
spawnSync(
`productbuild --distribution ${distFilePath} \
--package-path ${BUILD_PATH} \
${path.join(BUILD_PATH, outputName)}`,
spawnOptions
);
return outputName;
}
/**
* Builds a DEB package for Debian, Ubuntu, Mint, etc...
*
* Creates a root directory with the installed file system
* structure for package files, copies control file
* (packaging/linux/deb/DEBIAN/control) to root, then builds
* package from root.
* Requires the dpkg-deb command line utility.
*
* @param {string} arch
* @param {string} platformExecutableName
* @param {string} platformExecutableDirectory
* @param {string} platformManifestDirectory
*/
function packageLinuxDeb(
arch,
platformExecutableName,
platformExecutableDirectory,
platformManifestDirectory
) {
const outputName = `${config.applicationName}-${config.applicationVersion}-${arch}.deb`;
// Create root
const rootPath = path.join(BUILD_PATH, "root");
const rootExecutableDirectory = path.join(
rootPath,
platformExecutableDirectory
);
const rootManifestDirectory = path.join(
rootPath,
platformManifestDirectory
);
fs.mkdirSync(rootExecutableDirectory, { recursive: true });
fs.mkdirSync(rootManifestDirectory, { recursive: true });
// Move files to root
fs.moveSync(
path.join(BUILD_PATH, platformExecutableName),
path.join(rootExecutableDirectory, platformExecutableName)
);
fs.moveSync(
path.join(BUILD_PATH, MDNS_BINDING_NAME),
path.join(rootExecutableDirectory, MDNS_BINDING_NAME)
);
fs.moveSync(
path.join(BUILD_PATH, paths.MANIFEST_NAME),
path.join(rootManifestDirectory, paths.MANIFEST_NAME)
);
const controlDir = path.join(__dirname, "../packaging/linux/deb/DEBIAN/");
const controlOutputDir = path.join(rootPath, path.basename(controlDir));
const controlFilePath = path.join(controlOutputDir, "control");
// Copy package info to root
fs.copySync(controlDir, controlOutputDir);
const view = {
// Debian package names can't contain underscores
packageName: config.applicationName.replace(/_/g, "-"),
applicationName: config.applicationName,
applicationVersion: config.applicationVersion,
author: config.author
};
// Do templating on control file
fs.writeFileSync(
controlFilePath,
mustache.render(fs.readFileSync(controlFilePath).toString(), view)
);
// Build .deb package
spawnSync(
`dpkg-deb --build ${rootPath} \
${path.join(BUILD_PATH, outputName)}`,
spawnOptions
);
return outputName;
}
/**
* Builds an RPM package for Fedora, openSUSE, etc...
*
* Templates and uses the spec file
* (packaging/linux/rpm/package.spec) to build the package.
* Requires the rpmbuild command line utility.
*
* @param {string} arch
* @param {string} platformExecutableName
* @param {string} platformExecutableDirectory
* @param {string} platformManifestDirectory
*/
function packageLinuxRpm(
arch,
platformExecutableName,
platformExecutableDirectory,
platformManifestDirectory
) {
const outputName = `${config.applicationName}-${config.applicationVersion}-${arch}.rpm`;
const specPath = path.join(
__dirname,
"../packaging/linux/rpm/package.spec"
);
const specOutputPath = path.join(BUILD_PATH, path.basename(specPath));
const view = {
packageName: config.applicationName,
applicationName: config.applicationName,
applicationVersion: config.applicationVersion,
executablePath: platformExecutableDirectory,
manifestPath: platformManifestDirectory,
executableName: platformExecutableName,
manifestName: paths.MANIFEST_NAME,
bindingName: MDNS_BINDING_NAME
};
fs.writeFileSync(
specOutputPath,
mustache.render(fs.readFileSync(specPath).toString(), view)
);
const rpmArchMap = { x86: "i386", x64: "x86_64" };
spawnSync(
`rpmbuild -bb ${specOutputPath} \
--define "_distdir ${BUILD_PATH}" \
--define "_rpmdir ${BUILD_PATH}" \
--define "_rpmfilename ${outputName}" \
--target=${rpmArchMap[arch]}-linux`,
spawnOptions
);
return outputName;
}
/**
* Builds a Windows installer.
*
* Uses NSIS to create a GUI installer with an installer
* script (packaging/win/installer.nsi). Requires the
* makensis command line utility.
*
* @param {string} arch
* @param {string} platformExecutableName
* @param {string} platformExecutableDirectory
*/
function packageWin32(
arch,
platformExecutableName,
platformExecutableDirectory
) {
const outputName = `${config.applicationName}-${config.applicationVersion}-${arch}.exe`;
const scriptPath = path.join(__dirname, "../packaging/win/installer.nsi");
const scriptOutputPath = path.join(BUILD_PATH, path.basename(scriptPath));
const view = {
applicationName: config.applicationName,
applicationVersion: config.applicationVersion,
executableName: platformExecutableName,
executablePath: platformExecutableDirectory,
manifestName: paths.MANIFEST_NAME,
bindingName: MDNS_BINDING_NAME,
winRegistryKey: paths.REGISTRY_KEY,
outputName,
licensePath: paths.LICENSE_PATH,
// Uninstaller keys
registryPublisher: config.author,
registryUrlInfoAbout: config.homepageUrl
};
// Write templated script to build dir
fs.writeFileSync(
scriptOutputPath,
mustache.render(fs.readFileSync(scriptPath).toString(), view)
);
spawnSync(`makensis /DARCH=${arch} ${scriptOutputPath}`, spawnOptions);
return outputName;
}
build().catch(e => {
console.error("Error: Build failed!", e);
process.exit(1);
});

View File

@@ -0,0 +1,77 @@
// @ts-check
import fs from "fs-extra";
import os from "os";
import path from "path";
import { spawnSync } from "child_process";
import yargs from "yargs";
import * as paths from "./lib/paths.js";
const argv = yargs()
.help()
.version(false)
.option("remove", {
describe: "Uninstall manifest",
type: "boolean"
})
.parseSync(process.argv);
// Path to newly-built manifest
const newManifestPath = path.join(paths.DIST_PATH, paths.MANIFEST_NAME);
if (!fs.existsSync(newManifestPath) && !argv.remove) {
console.error("Error: No manifest to install!");
process.exit(1);
}
console.info(`${argv.remove ? "Uninstalling" : "Installing"} manifest... `);
const platform = os.platform();
switch (platform) {
// File-based manifests
case "darwin":
case "linux": {
// User-specific manifest within home directory
const manifestDirectory = path.join(
os.homedir(),
platform === "linux"
? ".mozilla/native-messaging-hosts"
: paths.getManifestDirectory(platform, os.arch())
);
const manifestPath = path.join(manifestDirectory, paths.MANIFEST_NAME);
if (argv.remove) {
// Uninstall manifest
fs.rmSync(manifestPath);
} else {
// Install manifest
fs.mkdirSync(manifestDirectory, { recursive: true });
fs.copyFileSync(newManifestPath, manifestPath);
}
break;
}
case "win32": {
const registryKey = `HKCU\\SOFTWARE\\Mozilla\\NativeMessagingHosts\\${paths.REGISTRY_KEY}`;
// Call reg command
spawnSync(
argv.remove
? `reg delete ${registryKey} /f`
: `reg add ${registryKey} /ve /d "${newManifestPath}" /f`,
{
shell: true,
stdio: [process.stdin, process.stdout, process.stderr]
}
);
break;
}
default:
console.error("Error: Unsupported platform!");
process.exit(1);
}

35
bridge/bin/lib/config.js Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import fs from "fs";
import path from "path";
import url from "url";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
/**
* @typedef {object} Config
* @prop {string} author
* @prop {string} homepageUrl
* @prop {string} applicationName
* @prop {string} applicationVersion
* @prop {string} applicationDirectoryName
* @prop {string} applicationExecutableName
* @prop {string} extensionId
*/
/** @type {Config} */
//
let config;
try {
config = JSON.parse(
fs.readFileSync(path.join(__dirname, "../../config.json"), {
encoding: "utf-8"
})
);
} catch (err) {
console.error("Error: Failed to load build config!", err);
process.exit(1);
}
export default config;

98
bridge/bin/lib/paths.js Normal file
View File

@@ -0,0 +1,98 @@
// @ts-check
import path from "path";
import url from "url";
import config from "./config.js";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const rootPath = path.join(__dirname, "../../../");
export const DIST_PATH = path.join(rootPath, "dist/bridge");
export const LICENSE_PATH = path.join(rootPath, "LICENSE");
export const REGISTRY_KEY = config.applicationName;
export const pkgPlatformMap = {
win32: "win",
darwin: "macos",
linux: "linux"
};
export const MANIFEST_NAME = `${config.applicationName}.json`;
/**
* @param {string} platform
* @returns {string}
*/
export function getExecutableName(platform) {
switch (platform) {
case "win32":
return `${config.applicationExecutableName}.exe`;
case "darwin":
case "linux":
return config.applicationExecutableName;
}
throw new Error("No executable name for specified platform!");
}
/**
* @param {string} platform
* @param {string} arch
* @returns {string}
*/
export function getExecutableDirectory(platform, arch) {
const EXECUTABLE_DIR_WIN32_X64 = `C:\\Program Files\\${config.applicationDirectoryName}\\`;
const EXECUTABLE_DIR_WIN32_X86 = `C:\\Program Files (x86)\\${config.applicationDirectoryName}\\`;
const EXECUTABLE_DIR_DARWIN = `/Library/Application Support/${config.applicationDirectoryName}/`;
const EXECUTABLE_DIR_LINUX = `/opt/${config.applicationDirectoryName}/`;
switch (platform) {
case "win32":
switch (arch) {
case "x86":
return EXECUTABLE_DIR_WIN32_X86;
case "x64":
return EXECUTABLE_DIR_WIN32_X64;
}
break;
case "darwin":
return EXECUTABLE_DIR_DARWIN;
case "linux":
return EXECUTABLE_DIR_LINUX;
}
throw new Error("No executable directory for specified platform!");
}
/**
* @param {string} platform
* @param {string} arch
* @param {string} [linuxPackageType]
* @returns {string}
*/
export function getManifestDirectory(platform, arch, linuxPackageType) {
const MANIFEST_DIR_DARWIN =
"/Library/Application Support/Mozilla/NativeMessagingHosts/";
const MANIFEST_DIR_LINUX_DEB = "/usr/lib/mozilla/native-messaging-hosts/";
const MANIFEST_DIR_LINUX_RPM = "/usr/lib64/mozilla/native-messaging-hosts/";
switch (platform) {
case "win32":
return getExecutableDirectory(platform, arch);
case "darwin":
return MANIFEST_DIR_DARWIN;
case "linux":
switch (linuxPackageType) {
case "deb":
return MANIFEST_DIR_LINUX_DEB;
case "rpm":
return MANIFEST_DIR_LINUX_RPM;
}
break;
}
throw new Error("No manifest directory for specified platform!");
}

9
bridge/config.json Normal file
View File

@@ -0,0 +1,9 @@
{
"author": "Matt Hensman <m@matt.tf>",
"homepageUrl": "https://hensm.github.io/fx_cast",
"applicationName": "fx_cast_bridge",
"applicationVersion": "0.3.0",
"applicationDirectoryName": "fx_cast",
"applicationExecutableName": "fx_cast_bridge",
"extensionId": "fx_cast@matt.tf"
}

3949
bridge/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
bridge/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"type": "module",
"scripts": {
"build": "node bin/build.js",
"package": "node bin/build.js --package",
"install-manifest": "node bin/install-manifest.js",
"remove-manifest": "node bin/install-manifest.js --remove"
},
"dependencies": {
"bplist-creator": "^0.1.0",
"bplist-parser": "^0.3.1",
"castv2": "^0.1.10",
"fast-srp-hap": "^2.0.4",
"mdns": "^2.7.2",
"mime-types": "^2.1.35",
"node-fetch": "^3.2.3",
"tweetnacl": "^1.0.3",
"ws": "^8.5.0",
"yargs": "^17.5.1"
},
"devDependencies": {
"@types/mdns": "^0.0.34",
"@types/mime-types": "^2.1.1",
"@types/minimist": "^1.2.2",
"@types/node": "^17.0.26",
"@types/node-fetch": "^2.6.1",
"@types/ws": "^8.5.3",
"@types/yargs": "^17.0.11",
"fs-extra": "^10.1.0",
"mustache": "^4.2.0",
"pkg": "^5.6.0",
"tiny-typed-emitter": "^2.1.0",
"typescript": "^4.6.3"
}
}

View File

@@ -0,0 +1,7 @@
Package: {{packageName}}
Version: {{applicationVersion}}
Priority: optional
Maintainer: {{{author}}}
Architecture: amd64
Description: {{applicationName}}
Depends: avahi-daemon, libavahi-compat-libdnssd1

View File

@@ -0,0 +1,27 @@
%global __os_install_post %{nil}
%define _source_payload w7.lzdio
%define _binary_payload w7.lzdio
Name: {{packageName}}
Summary: {{applicationName}}
Version: {{applicationVersion}}
Release: 1
License: MIT
Requires: avahi, avahi-compat-libdns_sd, nss-mdns
%description
{{applicationName}}
%install
rm -rf $RPM_BUILD_ROOT
mkdir -p $RPM_BUILD_ROOT/{{{executablePath}}} \
$RPM_BUILD_ROOT/{{{manifestPath}}}
cp %{_distdir}/{{{executableName}}} $RPM_BUILD_ROOT/{{{executablePath}}}
cp %{_distdir}/{{{bindingName}}} $RPM_BUILD_ROOT/{{{executablePath}}}
cp %{_distdir}/{{{manifestName}}} $RPM_BUILD_ROOT/{{{manifestPath}}}
%files
{{{executablePath}}}/{{{executableName}}}
{{{executablePath}}}/{{{bindingName}}}
{{{manifestPath}}}/{{{manifestName}}}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<title>{{applicationName}}</title>
<domains enable_anywhere="false" enable_currentUserHome="true" enable_localSystem="true" />
<pkg-ref id="{{packageId}}"/>
<options customize="never" require-scripts="false"/>
<choices-outline>
<line choice="default">
<line choice="{{packageId}}"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="{{packageId}}" visible="false">
<pkg-ref id="{{packageId}}"/>
</choice>
<pkg-ref id="{{packageId}}" onConclusion="none">{{componentName}}</pkg-ref>
</installer-gui-script>

View File

@@ -0,0 +1,11 @@
#!/bin/sh
# If the target location isn't root, we need to rewrite
# the manifest path to point to the user directory.
if [ "$2" != "/" ]; then
installedManifestPath=$2"/{{{manifestPath}}}/{{{manifestName}}}"
sed -i.bak 's,{{{executablePath}}},'$2'{{{executablePath}}},g' "$installedManifestPath"
rm "$installedManifestPath.bak"
fi
exit 0

View File

@@ -0,0 +1,153 @@
Unicode True
SetCompressor /SOLID LZMA
# Registry keys
!define KEY_MANIFEST "Software\Mozilla\NativeMessagingHosts\{{applicationName}}"
!define KEY_UNINSTALL "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{winRegistryKey}}"
!include MUI2.nsh
# Save installer language for uninstallation
!define MUI_LANGDLL_REGISTRY_ROOT HKLM
!define MUI_LANGDLL_REGISTRY_KEY "${KEY_MANIFEST}"
!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language"
# MUI general
!define MUI_ABORTWARNING
# Installer pages
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "{{{licensePath}}}"
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
# Uninstaller pages
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
# Translator note: see CONTRIBUTING for more info on how to
# translate NSIS installer strings.
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "Spanish"
!insertmacro MUI_LANGUAGE "German"
# lang:en
LangString MSG__INSTALL_BONJOUR ${LANG_ENGLISH} \
"Install Bonjour dependency?"
LangString MSG__FIREFOX_OPEN ${LANG_ENGLISH} \
"Firefox must be closed during uninstallation if the extension is \
installed. Close Firefox and click $\"Retry$\", click $\"Ignore$\" \
to force close or $\"Abort$\" to cancel uninstallation."
# lang:es
LangString MSG__INSTALL_BONJOUR ${LANG_SPANISH} \
"¿Instalar dependencia Bonjour?"
LangString MSG__FIREFOX_OPEN ${LANG_SPANISH} \
"Firefox debe estar cerrado durante la desinstalación si la extensión \
está instalada. Cierra Firefox y aprieta $\"Reintentar$\", aprieta \
$\"Omitir$\" para forzar el cierre o $\"Anular$\" para cancelar la \
desinstalación."
# lang:de
LangString MSG__INSTALL_BONJOUR ${LANG_GERMAN} \
"Bonjour installieren?"
LangString MSG__FIREFOX_OPEN ${LANG_GERMAN} \
"Firefox muss während der Deinstallation geschlossen werden, wenn die \
Erweiterung installiert ist. Schließen Sie Firefox und klicken Sie auf \
$\"Wiederholen$\", klicken Sie auf $\"Ignorieren$\". um das Schließen \
zu erzwingen oder $\"Abbrechen$\", um die Deinstallation abzubrechen."
# Application name
Name "{{applicationName}} v{{applicationVersion}}"
OutFile "{{outputName}}" # Installer filename
InstallDir "{{executablePath}}" # Installation directory
# Version info
VIProductVersion "{{applicationVersion}}.0"
VIAddVersionKey /LANG=${LANG_ENGLISH} "ProductName" "{{applicationName}}"
VIAddVersionKey /LANG=${LANG_ENGLISH} "LegalCopyright" "© {{{registryPublisher}}}"
VIAddVersionKey /LANG=${LANG_ENGLISH} "FileDescription" "{{applicationName}}"
VIAddVersionKey /LANG=${LANG_ENGLISH} "FileVersion" "{{applicationVersion}}"
# Need admin privileges for global install
RequestExecutionLevel admin
Section
SetRegView 64
SetOutPath $INSTDIR
# Main executable
File "{{executableName}}"
File "{{bindingName}}"
File "{{manifestName}}"
# Install Bonjour
IfFileExists "$SYSDIR\dnssd.dll" skipInstallBonjour
MessageBox MB_YESNO \
$(MSG__INSTALL_BONJOUR) \
IDNO skipInstallBonjour
${If} ${ARCH} == "x86"
File /oname=Bonjour.msi "C:\Program Files\Bonjour SDK\Installer\Bonjour.msi"
${ElseIf} ${ARCH} == "x64"
File /oname=Bonjour.msi "C:\Program Files\Bonjour SDK\Installer\Bonjour64.msi"
${EndIf}
ExecWait "msiexec /i $\"$INSTDIR\Bonjour.msi$\""
skipInstallBonjour:
Delete "$INSTDIR\Bonjour.msi"
# Native manifest key
WriteRegStr HKLM "${KEY_MANIFEST}" "" "$INSTDIR\{{manifestName}}"
# Create and register uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM ${KEY_UNINSTALL} DisplayName "{{applicationName}}"
WriteRegStr HKLM ${KEY_UNINSTALL} DisplayVersion "{{applicationVersion}}"
WriteRegStr HKLM ${KEY_UNINSTALL} Publisher "{{{registryPublisher}}}"
WriteRegStr HKLM ${KEY_UNINSTALL} URLInfoAbout "{{{registryUrlInfoAbout}}}"
WriteRegStr HKLM ${KEY_UNINSTALL} InstallLocation "$\"$INSTDIR$\""
WriteRegStr HKLM ${KEY_UNINSTALL} UninstallString "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM ${KEY_UNINSTALL} QuietUninstallString "$\"$INSTDIR\uninstall.exe$\" /S"
WriteRegDWORD HKLM ${KEY_UNINSTALL} NoModify 1
WriteRegDWORD HKLM ${KEY_UNINSTALL} NoRepair 1
SectionEnd
Section "uninstall"
SetRegView 64
retryUninstall:
FindWindow $0 "MozillaWindowClass"
StrCmp $0 0 continueUninstall
MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION \
$(MSG__FIREFOX_OPEN) \
IDABORT abortUninstall \
IDRETRY retryUninstall
ExecWait "taskkill /f /im firefox.exe /t"
Goto continueUninstall
abortUninstall:
Abort
continueUninstall:
ExecWait "taskkill /f /im '{{executableName}}'"
# Remove uninstaller
Delete "$INSTDIR\uninstall.exe"
DeleteRegKey HKLM ${KEY_UNINSTALL}
# Remove manifest and executable dir
DeleteRegKey HKLM ${KEY_MANIFEST}
Delete "$INSTDIR\{{executableName}}"
Delete "$INSTDIR\{{bindingName}}"
Delete "$INSTDIR\{{manifestName}}"
RMDir $INSTDIR
SectionEnd

View 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);
}
}

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,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 }
});
}
});
}
}

View 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();
}
}

View 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();
}
}

View 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;
}
}
}

View 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);
}
}

View 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" });

View 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
View 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);
}
}
});

View 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;
}

View 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();

View 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
View 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
View 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
View 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
View 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]));
}
}

7
bridge/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig",
"include": ["./src/**/*", "./@types/**/*"],
"compilerOptions": {
"lib": ["ES2020.String", "DOM"]
}
}