mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
Rename directory: app -> bridge
This commit is contained in:
5
bridge/.eslintrc.json
Normal file
5
bridge/.eslintrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rules": {
|
||||
"@typescript-eslint/no-non-null-assertion": "off"
|
||||
}
|
||||
}
|
||||
15
bridge/@types/bplist-creator/index.d.ts
vendored
Normal file
15
bridge/@types/bplist-creator/index.d.ts
vendored
Normal 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
22
bridge/@types/bplist-parser/index.d.ts
vendored
Normal 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
71
bridge/@types/castv2/index.d.ts
vendored
Normal 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
50
bridge/@types/fast-srp-hap/index.d.ts
vendored
Normal 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
566
bridge/bin/build.js
Normal 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);
|
||||
});
|
||||
77
bridge/bin/install-manifest.js
Normal file
77
bridge/bin/install-manifest.js
Normal 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
35
bridge/bin/lib/config.js
Normal 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
98
bridge/bin/lib/paths.js
Normal 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
9
bridge/config.json
Normal 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
3949
bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
bridge/package.json
Normal file
35
bridge/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
bridge/packaging/linux/deb/DEBIAN/control
Normal file
7
bridge/packaging/linux/deb/DEBIAN/control
Normal file
@@ -0,0 +1,7 @@
|
||||
Package: {{packageName}}
|
||||
Version: {{applicationVersion}}
|
||||
Priority: optional
|
||||
Maintainer: {{{author}}}
|
||||
Architecture: amd64
|
||||
Description: {{applicationName}}
|
||||
Depends: avahi-daemon, libavahi-compat-libdnssd1
|
||||
27
bridge/packaging/linux/rpm/package.spec
Normal file
27
bridge/packaging/linux/rpm/package.spec
Normal 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}}}
|
||||
17
bridge/packaging/mac/distribution.xml
Normal file
17
bridge/packaging/mac/distribution.xml
Normal 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>
|
||||
11
bridge/packaging/mac/scripts/postinstall
Executable file
11
bridge/packaging/mac/scripts/postinstall
Executable 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
|
||||
153
bridge/packaging/win/installer.nsi
Normal file
153
bridge/packaging/win/installer.nsi
Normal 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
|
||||
232
bridge/src/bridge/components/airplay/auth.ts
Normal file
232
bridge/src/bridge/components/airplay/auth.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* AirPlay device auth implementation.
|
||||
*
|
||||
* References:
|
||||
* - https://htmlpreview.github.io/?https://github.com/philippe44/RAOP-Player/blob/master/doc/auth_protocol.html
|
||||
* - https://github.com/funtax/AirPlayAuth
|
||||
* - https://github.com/ldiqual/chrome-airplay
|
||||
* - https://github.com/postlund/pyatv/blob/master/docs/airplay.rst
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import srp6a from "fast-srp-hap";
|
||||
import fetch, { Headers } from "node-fetch";
|
||||
import nacl from "tweetnacl";
|
||||
import bplist from "./bplist";
|
||||
|
||||
const AIRPLAY_PORT = 7000;
|
||||
const MIMETYPE_BPLIST = "application/x-apple-binary-plist";
|
||||
|
||||
/**
|
||||
* Client ID and keypair
|
||||
*/
|
||||
export class AirPlayAuthCredentials {
|
||||
public clientId: string;
|
||||
public clientSk: Uint8Array;
|
||||
public clientPk: Uint8Array;
|
||||
|
||||
constructor(
|
||||
clientId?: string,
|
||||
clientSk?: Uint8Array,
|
||||
clientPk?: Uint8Array
|
||||
) {
|
||||
if (clientId && clientSk && clientPk) {
|
||||
this.clientId = clientId;
|
||||
this.clientSk = clientSk;
|
||||
this.clientPk = clientPk;
|
||||
} else {
|
||||
// If specified without arguments, generate new credentials
|
||||
const keyPair = nacl.sign.keyPair();
|
||||
|
||||
// Random 16-len string
|
||||
this.clientId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.clientSk = keyPair.secretKey.slice(0, 32);
|
||||
this.clientPk = keyPair.publicKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AirPlayAuth {
|
||||
private address: string;
|
||||
private credentials: AirPlayAuthCredentials;
|
||||
private baseUrl: URL;
|
||||
|
||||
constructor(address: string, credentials: AirPlayAuthCredentials) {
|
||||
this.address = address;
|
||||
this.credentials = credentials;
|
||||
|
||||
this.baseUrl = new URL(`http://${this.address}:${AIRPLAY_PORT}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins pairing process.
|
||||
*/
|
||||
public async beginPairing() {
|
||||
return this.sendPostRequest("/pair-pin-start");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs client with receiver. Must be called after
|
||||
* beginPairing(). Coordinates the three pairing stages and
|
||||
* manages request responses.
|
||||
*/
|
||||
public async finishPairing(pin: string) {
|
||||
// Stage 1 response
|
||||
const { pk: serverPk, salt: serverSalt } = await this.pairSetupPin1();
|
||||
|
||||
// SRP params must 2048-bit SHA1
|
||||
const srpParams = srp6a.params[2048];
|
||||
srpParams.hash = "sha1";
|
||||
|
||||
// Create SRP client
|
||||
const srpClient = new srp6a.Client(
|
||||
srpParams, // Params
|
||||
serverSalt, // Receiver salt
|
||||
Buffer.from(this.credentials.clientId), // Username
|
||||
Buffer.from(pin), // Password (receiver pin)
|
||||
Buffer.from(this.credentials.clientSk) // Client secret key
|
||||
);
|
||||
|
||||
// Add receiver's public key
|
||||
srpClient.setB(serverPk);
|
||||
|
||||
// Stage 2 response
|
||||
await this.pairSetupPin2(
|
||||
srpClient.computeA(), // SRP public key
|
||||
srpClient.computeM1() // SRP proof
|
||||
);
|
||||
|
||||
// Stage 3 response
|
||||
await this.pairSetupPin3(srpClient.computeK());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing Stage 1
|
||||
* ---------------
|
||||
* Triggering the receiver passcode display and receiving
|
||||
* its public key / salt.
|
||||
*/
|
||||
public async pairSetupPin1(): Promise<any> {
|
||||
const [response] = await this.sendPostRequestBplist("/pair-setup-pin", {
|
||||
method: "pin",
|
||||
user: this.credentials.clientId
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing Stage 2
|
||||
* ---------------
|
||||
* Generating SRP public key and proof with the client/server
|
||||
* public keys, sending them to the receiver and receiving its
|
||||
* proof.
|
||||
*/
|
||||
public async pairSetupPin2(pk: Buffer, proof: Buffer): Promise<any> {
|
||||
const [response] = await this.sendPostRequestBplist("/pair-setup-pin", {
|
||||
pk,
|
||||
proof
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing Stage 3
|
||||
* ---------------
|
||||
* AES encoding the client public key with the SRP shared
|
||||
* secret hash and sending it to the receiver. Receiver then
|
||||
* responds confirming the pairing is complete.
|
||||
*/
|
||||
public async pairSetupPin3(
|
||||
sharedSecretHash: crypto.BinaryLike
|
||||
): Promise<any> {
|
||||
// Create AES key
|
||||
const aesKey = crypto
|
||||
.createHash("sha512")
|
||||
.update("Pair-Setup-AES-Key")
|
||||
.update(sharedSecretHash)
|
||||
.digest()
|
||||
.slice(0, 16);
|
||||
|
||||
// Create AES IV
|
||||
const aesIv = crypto
|
||||
.createHash("sha512")
|
||||
.update("Pair-Setup-AES-IV")
|
||||
.update(sharedSecretHash)
|
||||
.digest()
|
||||
.slice(0, 16);
|
||||
|
||||
aesIv[15]++;
|
||||
|
||||
const cipher = crypto.createCipheriv("aes-128-gcm", aesKey, aesIv);
|
||||
|
||||
// Encode client public key
|
||||
const epk = cipher.update(this.credentials.clientPk);
|
||||
cipher.final();
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const [response] = await this.sendPostRequestBplist("/pair-setup-pin", {
|
||||
epk,
|
||||
authTag
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST request to receiver and returns the
|
||||
* response.
|
||||
*/
|
||||
public async sendPostRequest(
|
||||
path: string,
|
||||
contentType?: string,
|
||||
data?: Buffer | string
|
||||
): Promise<any> {
|
||||
// Create URL from base receiver URL and path
|
||||
const requestUrl = new URL(path, this.baseUrl);
|
||||
|
||||
const requestHeaders = new Headers({
|
||||
"User-Agent": "AirPlay/320.20"
|
||||
});
|
||||
|
||||
// Append Content-Type header if request has body
|
||||
if (data && contentType) {
|
||||
requestHeaders.append("Content-Type", contentType);
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl.href, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body: data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AirPlay request error: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.buffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes binary plist data, sends a POST request to
|
||||
* receiver, then decodes and returns the response.
|
||||
*/
|
||||
public async sendPostRequestBplist(
|
||||
path: string,
|
||||
data?: object
|
||||
): Promise<any> {
|
||||
// Convert data to compatible type
|
||||
const requestBody = data ? bplist.create(data) : undefined;
|
||||
|
||||
const response = await this.sendPostRequest(
|
||||
path,
|
||||
MIMETYPE_BPLIST,
|
||||
requestBody
|
||||
);
|
||||
|
||||
// Convert response data to Buffer for bplist-parser
|
||||
return bplist.parse.parseBuffer(response);
|
||||
}
|
||||
}
|
||||
4
bridge/src/bridge/components/airplay/bplist.ts
Normal file
4
bridge/src/bridge/components/airplay/bplist.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import create from "bplist-creator";
|
||||
import parse from "bplist-parser";
|
||||
|
||||
export default { create, parse };
|
||||
193
bridge/src/bridge/components/cast/Session.ts
Normal file
193
bridge/src/bridge/components/cast/Session.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { Channel } from "castv2";
|
||||
|
||||
import messaging from "../../messaging";
|
||||
|
||||
import type { ReceiverDevice } from "../../messagingTypes";
|
||||
import type { ReceiverMessage } from "./types";
|
||||
|
||||
import CastClient, { NS_CONNECTION, NS_HEARTBEAT } from "./client";
|
||||
|
||||
type OnSessionCreatedCallback = (sessionId: string) => void;
|
||||
|
||||
export default class Session extends CastClient {
|
||||
// Assigned by the receiver once the session is established
|
||||
public sessionId?: string;
|
||||
|
||||
// Receiver app messaging
|
||||
private transportId?: string;
|
||||
private transportConnection?: Channel;
|
||||
private transportHeartbeat?: Channel;
|
||||
|
||||
// Channels created by `sendCastSessionMessage` messages
|
||||
private namespaceChannelMap = new Map<string, Channel>();
|
||||
|
||||
/**
|
||||
* Request ID used to correlate the launch request with the
|
||||
* RECEIVER_STATUS message associated with session creation.
|
||||
*/
|
||||
private launchRequestId?: number;
|
||||
|
||||
private establishAppConnection(transportId: string) {
|
||||
this.transportConnection = this.createChannel(
|
||||
NS_CONNECTION,
|
||||
this.sourceId,
|
||||
transportId
|
||||
);
|
||||
this.transportHeartbeat = this.createChannel(
|
||||
NS_HEARTBEAT,
|
||||
this.sourceId,
|
||||
transportId
|
||||
);
|
||||
|
||||
this.transportConnection.send({ type: "CONNECT" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming receiver messages.
|
||||
*/
|
||||
private onReceiverMessage = (message: ReceiverMessage) => {
|
||||
switch (message.type) {
|
||||
case "RECEIVER_STATUS": {
|
||||
const { status } = message;
|
||||
const application = status.applications?.find(
|
||||
app => app.appId === this.appId
|
||||
);
|
||||
|
||||
/**
|
||||
* If application isn't set, still waiting on the launch
|
||||
* request response.
|
||||
*/
|
||||
if (!this.sessionId) {
|
||||
// Match request ID on the response to the launch request ID.
|
||||
if (message.requestId !== this.launchRequestId) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (application) {
|
||||
this.sessionId = application.sessionId;
|
||||
this.transportId = application.transportId;
|
||||
|
||||
this.establishAppConnection(this.transportId);
|
||||
this.onSessionCreated?.(this.sessionId);
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "main:castSessionCreated",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
statusText: application.statusText,
|
||||
namespaces: application.namespaces,
|
||||
volume: status.volume,
|
||||
appId: application.appId,
|
||||
displayName: application.displayName,
|
||||
receiverId: this.receiverDevice.id,
|
||||
receiverFriendlyName:
|
||||
this.receiverDevice.friendlyName,
|
||||
transportId: this.sessionId,
|
||||
|
||||
// TODO: Fix this
|
||||
senderApps: [],
|
||||
appImages: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle session stop
|
||||
if (!application) {
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "main:castSessionUpdated",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
statusText: application.statusText,
|
||||
namespaces: application.namespaces,
|
||||
volume: message.status.volume
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "LAUNCH_ERROR": {
|
||||
console.error(`err: LAUNCH_ERROR, ${message.reason}`);
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendMessage(namespace: string, message: unknown) {
|
||||
let channel = this.namespaceChannelMap.get(namespace);
|
||||
if (!channel) {
|
||||
channel = this.createChannel(
|
||||
namespace,
|
||||
this.sourceId,
|
||||
this.transportId
|
||||
);
|
||||
|
||||
channel.on("message", messageData => {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageData = JSON.stringify(messageData);
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "cast:sessionMessageReceived",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
namespace,
|
||||
messageData
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.namespaceChannelMap.set(namespace, channel);
|
||||
}
|
||||
|
||||
channel.send(message);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private appId: string,
|
||||
private receiverDevice: ReceiverDevice,
|
||||
private onSessionCreated?: OnSessionCreatedCallback
|
||||
) {
|
||||
super();
|
||||
|
||||
super
|
||||
.connect(receiverDevice.host, {
|
||||
onHeartbeat: () => {
|
||||
// Include transport heartbeat with platform heartbeat
|
||||
if (this.transportHeartbeat) {
|
||||
this.transportHeartbeat.send({ type: "PING" });
|
||||
}
|
||||
},
|
||||
onReceiverMessage: message => {
|
||||
this.onReceiverMessage(message);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Send a launch request and store the request ID for reference
|
||||
this.launchRequestId = this.sendReceiverMessage({
|
||||
type: "LAUNCH",
|
||||
appId: this.appId
|
||||
});
|
||||
});
|
||||
|
||||
// Handle client connection closed
|
||||
this.client.on("close", () => {
|
||||
if (this.sessionId) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:sessionStopped",
|
||||
data: { sessionId: this.sessionId }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
115
bridge/src/bridge/components/cast/client.ts
Normal file
115
bridge/src/bridge/components/cast/client.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Channel, Client } from "castv2";
|
||||
|
||||
import type { ReceiverMessage, SenderMessage } from "./types";
|
||||
|
||||
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
|
||||
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
|
||||
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
|
||||
|
||||
const DEFAULT_PORT = 8009;
|
||||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||
|
||||
interface CastClientConnectOptions {
|
||||
port?: number;
|
||||
onReceiverMessage?: (message: ReceiverMessage) => void;
|
||||
onHeartbeat?: () => void;
|
||||
}
|
||||
|
||||
export default class CastClient {
|
||||
protected client = new Client();
|
||||
|
||||
protected connectionChannel?: Channel;
|
||||
protected heartbeatChannel?: Channel;
|
||||
protected heartbeatIntervalId?: NodeJS.Timeout;
|
||||
|
||||
// Platform messaging
|
||||
private receiverChannel?: Channel;
|
||||
private receiverRequestId = Math.floor(Math.random() * 1e6);
|
||||
|
||||
constructor(
|
||||
protected sourceId = "sender-0",
|
||||
protected destinationId = "receiver-0"
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a channel on the client connection with a given
|
||||
* namespace.
|
||||
*/
|
||||
protected createChannel(
|
||||
namespace: string,
|
||||
sourceId = this.sourceId,
|
||||
destinationId = this.destinationId
|
||||
) {
|
||||
return this.client.createChannel(
|
||||
sourceId,
|
||||
destinationId,
|
||||
namespace,
|
||||
"JSON"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message on the receiver channel with the correct
|
||||
* request ID.
|
||||
*/
|
||||
sendReceiverMessage(message: DistributiveOmit<SenderMessage, "requestId">) {
|
||||
if (!this.receiverChannel) return;
|
||||
|
||||
const requestId = this.receiverRequestId++;
|
||||
this.receiverChannel.send({ ...message, requestId });
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a cast receiver at a given host, returning a
|
||||
* promise that resolves once the client is connected.
|
||||
*/
|
||||
connect(host: string, options?: CastClientConnectOptions) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Handle errors
|
||||
this.client.on("error", reject);
|
||||
this.client.on("close", () => {
|
||||
if (this.heartbeatChannel && this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
}
|
||||
});
|
||||
|
||||
this.client.connect(
|
||||
{
|
||||
host,
|
||||
port: options?.port ?? DEFAULT_PORT
|
||||
},
|
||||
// On connection callback
|
||||
() => {
|
||||
this.connectionChannel = this.createChannel(NS_CONNECTION);
|
||||
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);
|
||||
|
||||
// Handle receiver messages
|
||||
this.receiverChannel = this.createChannel(NS_RECEIVER);
|
||||
this.receiverChannel.on("message", message => {
|
||||
options?.onReceiverMessage?.(message);
|
||||
});
|
||||
|
||||
this.connectionChannel.send({ type: "CONNECT" });
|
||||
this.heartbeatChannel.send({ type: "PING" });
|
||||
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.heartbeatChannel?.send({ type: "PING" });
|
||||
options?.onHeartbeat?.();
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
}
|
||||
|
||||
this.connectionChannel?.send({ type: "CLOSE" });
|
||||
this.client.close();
|
||||
}
|
||||
}
|
||||
87
bridge/src/bridge/components/cast/discovery.ts
Normal file
87
bridge/src/bridge/components/cast/discovery.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import mdns from "mdns";
|
||||
|
||||
import type { ReceiverDevice } from "../../messagingTypes";
|
||||
|
||||
/**
|
||||
* Chromecast TXT record
|
||||
*/
|
||||
interface CastRecord {
|
||||
// Device ID
|
||||
id: string;
|
||||
// Model name (e.g. Chromecast, Google Nest Mini, etc...)
|
||||
md: string;
|
||||
// Friendly name (user-visible)
|
||||
fn: string;
|
||||
// Capabilities
|
||||
ca: string;
|
||||
// Version (?)
|
||||
ve: string;
|
||||
// Icon path (?)
|
||||
ic: string;
|
||||
|
||||
cd: string;
|
||||
rm: string;
|
||||
st: string;
|
||||
bs: string;
|
||||
nf: string;
|
||||
rs: string;
|
||||
}
|
||||
|
||||
interface DiscoveryOptions {
|
||||
onDeviceFound(device: ReceiverDevice): void;
|
||||
onDeviceDown(deviceId: string): void;
|
||||
}
|
||||
|
||||
export default class Discovery {
|
||||
browser = mdns.createBrowser(mdns.tcp("googlecast"), {
|
||||
resolverSequence: [
|
||||
mdns.rst.DNSServiceResolve(),
|
||||
"DNSServiceGetAddrInfo" in mdns.dns_sd
|
||||
? mdns.rst.DNSServiceGetAddrInfo()
|
||||
: // Some issues on Linux with IPv6, so restrict to IPv4
|
||||
mdns.rst.getaddrinfo({ families: [4] }),
|
||||
mdns.rst.makeAddressesUnique()
|
||||
]
|
||||
});
|
||||
|
||||
constructor(opts: DiscoveryOptions) {
|
||||
/**
|
||||
* When a service is found, gather device info from service
|
||||
* object and TXT record, then send a `main:deviceUp` message.
|
||||
*/
|
||||
this.browser.on("serviceUp", service => {
|
||||
// Filter invalid results
|
||||
if (!service.txtRecord || !service.name) return;
|
||||
|
||||
const record = service.txtRecord as CastRecord;
|
||||
const device: ReceiverDevice = {
|
||||
id: record.id,
|
||||
friendlyName: record.fn,
|
||||
modelName: record.md,
|
||||
capabilities: parseInt(record.ca),
|
||||
host: service.addresses[0],
|
||||
port: service.port
|
||||
};
|
||||
|
||||
opts.onDeviceFound(device);
|
||||
});
|
||||
|
||||
/**
|
||||
* When a service is lost, send a `main:deviceDown` message with
|
||||
* the service name as the `deviceId`.
|
||||
*/
|
||||
this.browser.on("serviceDown", service => {
|
||||
// Filter invalid results
|
||||
if (!service.name) return;
|
||||
|
||||
opts.onDeviceDown(service.name);
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.browser.start();
|
||||
}
|
||||
stop() {
|
||||
this.browser.stop();
|
||||
}
|
||||
}
|
||||
120
bridge/src/bridge/components/cast/index.ts
Normal file
120
bridge/src/bridge/components/cast/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import messaging, { Message } from "../../messaging";
|
||||
|
||||
import Session from "./Session";
|
||||
import CastClient from "./client";
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
export function handleCastMessage(message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:createCastSession": {
|
||||
const { appId, receiverDevice } = message.data;
|
||||
|
||||
// Connect and store with returned ID
|
||||
const session = new Session(appId, receiverDevice, sessionId => {
|
||||
sessions.set(sessionId, session);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendCastReceiverMessage": {
|
||||
const { sessionId, messageData, messageId } = message.data;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: "Session does not exist",
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
session.sendReceiverMessage(messageData);
|
||||
} catch (err) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: `Failed to send message (${err})`,
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Success
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: { sessionId, messageId }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendCastSessionMessage": {
|
||||
const { namespace, sessionId, messageId } = message.data;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: "Session does not exist",
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle string messages
|
||||
let { messageData } = message.data;
|
||||
if (typeof messageData === "string") {
|
||||
messageData = JSON.parse(messageData);
|
||||
}
|
||||
|
||||
session.sendMessage(namespace, messageData);
|
||||
} catch (err) {
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: {
|
||||
error: `Failed to send message (${err})`,
|
||||
sessionId,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Success
|
||||
messaging.sendMessage({
|
||||
subject: "cast:impl_sendMessage",
|
||||
data: { sessionId, messageId }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:stopCastSession": {
|
||||
const { receiverDevice } = message.data;
|
||||
|
||||
const client = new CastClient();
|
||||
client.connect(receiverDevice.host).then(() => {
|
||||
(client.sendReceiverMessage as any)({ type: "STOP" });
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
bridge/src/bridge/components/cast/remote.ts
Normal file
121
bridge/src/bridge/components/cast/remote.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import CastClient from "./client";
|
||||
|
||||
import type {
|
||||
MediaStatus,
|
||||
ReceiverMessage,
|
||||
ReceiverMediaMessage,
|
||||
ReceiverStatus,
|
||||
SenderMediaMessage
|
||||
} from "./types";
|
||||
|
||||
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
||||
|
||||
interface CastRemoteOptions {
|
||||
onApplicationFound?: () => void;
|
||||
onApplicationClose?: () => void;
|
||||
onReceiverStatusUpdate?: (status: ReceiverStatus) => void;
|
||||
onMediaStatusUpdate?: (status?: MediaStatus) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* castv2 client for receiver tracking.
|
||||
*/
|
||||
export default class Remote extends CastClient {
|
||||
private transportClient?: RemoteTransport;
|
||||
|
||||
constructor(private host: string, private options?: CastRemoteOptions) {
|
||||
super();
|
||||
super
|
||||
.connect(host, {
|
||||
onReceiverMessage: message => {
|
||||
this.onReceiverMessage(message);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.sendReceiverMessage({ type: "GET_STATUS" });
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect();
|
||||
this.transportClient?.disconnect();
|
||||
}
|
||||
|
||||
sendMediaMessage(message: SenderMediaMessage) {
|
||||
this.transportClient?.sendMediaMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `NS_RECEIVER` messages from the receiver device.
|
||||
* On initial connection, a `GET_STATUS` message is sent that
|
||||
* results in a `RECEIVER_STATUS` response. If an application
|
||||
* is running, get the transport ID and make a connection to
|
||||
* receive media status updates.
|
||||
*/
|
||||
private onReceiverMessage(message: ReceiverMessage) {
|
||||
if (message.type !== "RECEIVER_STATUS") {
|
||||
return;
|
||||
}
|
||||
|
||||
const application = message.status.applications?.[0];
|
||||
if (!application || application.isIdleScreen) {
|
||||
// Handle app close
|
||||
if (this.transportClient) {
|
||||
this.transportClient = undefined;
|
||||
this.options?.onApplicationClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Update status before possible transport init
|
||||
this.options?.onReceiverStatusUpdate?.(message.status);
|
||||
|
||||
// Handle app creation/discovery
|
||||
if (application && !this.transportClient) {
|
||||
this.transportClient = new RemoteTransport(
|
||||
application.transportId,
|
||||
message => this.onMediaMessage(message)
|
||||
);
|
||||
|
||||
this.transportClient.connect(this.host).then(() => {
|
||||
this.transportClient?.sendMediaMessage({
|
||||
type: "GET_STATUS",
|
||||
requestId: 0
|
||||
});
|
||||
});
|
||||
|
||||
this.options?.onApplicationFound?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `NS_MEDIA` messages from the receiver application.
|
||||
* On initial connection. a `GET_STATUS` message is sent that
|
||||
* results in a `MEDIA_STATUS` response.
|
||||
*/
|
||||
private onMediaMessage(message: ReceiverMediaMessage) {
|
||||
if (message.type !== "MEDIA_STATUS") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options?.onMediaStatusUpdate?.(message.status[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* castv2 client for receiver application tracking.
|
||||
*/
|
||||
class RemoteTransport extends CastClient {
|
||||
private mediaChannel = this.createChannel(NS_MEDIA);
|
||||
|
||||
constructor(
|
||||
transportId: string,
|
||||
onMediaMessage: (message: ReceiverMediaMessage) => void
|
||||
) {
|
||||
super(undefined, transportId);
|
||||
this.mediaChannel.on("message", message => onMediaMessage(message));
|
||||
}
|
||||
|
||||
sendMediaMessage(message: SenderMediaMessage) {
|
||||
this.mediaChannel.send(message);
|
||||
}
|
||||
}
|
||||
444
bridge/src/bridge/components/cast/types.ts
Normal file
444
bridge/src/bridge/components/cast/types.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
export interface Image {
|
||||
url: string;
|
||||
height: Nullable<number>;
|
||||
width: Nullable<number>;
|
||||
}
|
||||
|
||||
enum Capability {
|
||||
VIDEO_OUT = "video_out",
|
||||
AUDIO_OUT = "audio_out",
|
||||
VIDEO_IN = "video_in",
|
||||
AUDIO_IN = "audio_in",
|
||||
MULTIZONE_GROUP = "multizone_group"
|
||||
}
|
||||
|
||||
enum ReceiverType {
|
||||
CAST = "cast",
|
||||
DIAL = "dial",
|
||||
HANGOUT = "hangout",
|
||||
CUSTOM = "custom"
|
||||
}
|
||||
|
||||
export interface SenderApplication {
|
||||
packageId: Nullable<string>;
|
||||
platform: string;
|
||||
url: Nullable<string>;
|
||||
}
|
||||
|
||||
enum VolumeControlType {
|
||||
ATTENUATION = "attenuation",
|
||||
FIXED = "fixed",
|
||||
MASTER = "master"
|
||||
}
|
||||
|
||||
export interface Volume {
|
||||
controlType?: VolumeControlType;
|
||||
stepInterval?: number;
|
||||
level: Nullable<number>;
|
||||
muted: Nullable<boolean>;
|
||||
}
|
||||
|
||||
// Media
|
||||
|
||||
enum IdleReason {
|
||||
CANCELLED = "CANCELLED",
|
||||
INTERRUPTED = "INTERRUPTED",
|
||||
FINISHED = "FINISHED",
|
||||
ERROR = "ERROR"
|
||||
}
|
||||
|
||||
enum HlsSegmentFormat {
|
||||
AAC = "aac",
|
||||
AC3 = "ac3",
|
||||
MP3 = "mp3",
|
||||
TS = "ts",
|
||||
TS_AAC = "ts_aac",
|
||||
E_AC3 = "e_ac3",
|
||||
FMP4 = "fmp4"
|
||||
}
|
||||
|
||||
export enum HlsVideoSegmentFormat {
|
||||
MPEG2_TS = "mpeg2_ts",
|
||||
FMP4 = "fmp4"
|
||||
}
|
||||
|
||||
enum MetadataType {
|
||||
GENERIC,
|
||||
MOVIE,
|
||||
TV_SHOW,
|
||||
MUSIC_TRACK,
|
||||
PHOTO,
|
||||
AUDIOBOOK_CHAPTER
|
||||
}
|
||||
|
||||
enum PlayerState {
|
||||
IDLE = "IDLE",
|
||||
PLAYING = "PLAYING",
|
||||
PAUSED = "PAUSED",
|
||||
BUFFERING = "BUFFERING"
|
||||
}
|
||||
|
||||
enum RepeatMode {
|
||||
OFF = "REPEAT_OFF",
|
||||
ALL = "REPEAT_ALL",
|
||||
SINGLE = "REPEAT_SINGLE",
|
||||
ALL_AND_SHUFFLE = "REPEAT_ALL_AND_SHUFFLE"
|
||||
}
|
||||
|
||||
enum ResumeState {
|
||||
PLAYBACK_START = "PLAYBACK_START",
|
||||
PLAYBACK_PAUSE = "PLAYBACK_PAUSE"
|
||||
}
|
||||
|
||||
enum StreamType {
|
||||
BUFFERED = "BUFFERED",
|
||||
LIVE = "LIVE",
|
||||
OTHER = "OTHER"
|
||||
}
|
||||
|
||||
enum TrackType {
|
||||
TEXT = "TEXT",
|
||||
AUDIO = "AUDIO",
|
||||
VIDEO = "VIDEO"
|
||||
}
|
||||
|
||||
export enum UserAction {
|
||||
LIKE = "LIKE",
|
||||
DISLIKE = "DISLIKE",
|
||||
FOLLOW = "FOLLOW",
|
||||
UNFOLLOW = "UNFOLLOW"
|
||||
}
|
||||
|
||||
interface Break {
|
||||
breakClipIds: string[];
|
||||
duration?: number;
|
||||
id: string;
|
||||
isEmbedded?: boolean;
|
||||
isWatched: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface BreakClip {
|
||||
clickThroughUrl?: string;
|
||||
contentId?: string;
|
||||
contentType?: string;
|
||||
contentUrl?: string;
|
||||
customData?: unknown;
|
||||
duration?: number;
|
||||
id: string;
|
||||
hlsSegmentFormat?: HlsSegmentFormat;
|
||||
posterUrl?: string;
|
||||
title?: string;
|
||||
vastAdsRequest?: VastAdsRequest;
|
||||
whenSkippable?: number;
|
||||
}
|
||||
|
||||
interface TextTrackStyle {
|
||||
backgroundColor: Nullable<string>;
|
||||
customData: unknown;
|
||||
edgeColor: Nullable<string>;
|
||||
edgeType: Nullable<string>;
|
||||
fontFamily: Nullable<string>;
|
||||
fontGenericFamily: Nullable<string>;
|
||||
fontScale: Nullable<number>;
|
||||
fontStyle: Nullable<string>;
|
||||
foregroundColor: Nullable<string>;
|
||||
windowColor: Nullable<string>;
|
||||
windowRoundedCornerRadius: Nullable<number>;
|
||||
windowType: Nullable<string>;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
customData: unknown;
|
||||
language: Nullable<string>;
|
||||
name: Nullable<string>;
|
||||
subtype: Nullable<string>;
|
||||
trackContentId: Nullable<string>;
|
||||
trackContentType: Nullable<string>;
|
||||
trackId: string;
|
||||
type: TrackType;
|
||||
}
|
||||
|
||||
interface UserActionState {
|
||||
customData: unknown;
|
||||
userAction: UserAction;
|
||||
}
|
||||
|
||||
interface VastAdsRequest {
|
||||
adsResponse?: string;
|
||||
adTagUrl?: string;
|
||||
}
|
||||
|
||||
type Metadata =
|
||||
| GenericMediaMetadata
|
||||
| MovieMediaMetadata
|
||||
| MusicTrackMediaMetadata
|
||||
| PhotoMediaMetadata
|
||||
| TvShowMediaMetadata;
|
||||
|
||||
interface MediaInformation {
|
||||
atvEntity?: string;
|
||||
breakClips?: BreakClip[];
|
||||
breaks?: Break[];
|
||||
contentId: string;
|
||||
contentType: string;
|
||||
contentUrl?: string;
|
||||
customData: unknown;
|
||||
duration: Nullable<number>;
|
||||
entity?: string;
|
||||
hlsSegmentFormat?: HlsSegmentFormat;
|
||||
hlsVideoSegmentFormat?: HlsVideoSegmentFormat;
|
||||
metadata: Nullable<Metadata>;
|
||||
startAbsoluteTime?: number;
|
||||
streamType: StreamType;
|
||||
textTrackStyle: Nullable<TextTrackStyle>;
|
||||
tracks: Nullable<Track[]>;
|
||||
userActionStates?: UserActionState[];
|
||||
vmapAdsRequest?: VastAdsRequest;
|
||||
}
|
||||
|
||||
interface GenericMediaMetadata {
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
type: MetadataType.GENERIC;
|
||||
}
|
||||
|
||||
interface MovieMediaMetadata {
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
studio?: string;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
type: MetadataType.MOVIE;
|
||||
}
|
||||
|
||||
interface TvShowMediaMetadata {
|
||||
episode?: number;
|
||||
episodeNumber?: number;
|
||||
episodeTitle?: string;
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
originalAirdate?: string;
|
||||
releaseYear?: number;
|
||||
season?: number;
|
||||
seasonNumber?: number;
|
||||
seriesTitle?: string;
|
||||
title?: string;
|
||||
type: MetadataType.TV_SHOW;
|
||||
}
|
||||
|
||||
interface MusicTrackMediaMetadata {
|
||||
albumArtist?: string;
|
||||
albumName?: string;
|
||||
artist?: string;
|
||||
artistName?: string;
|
||||
composer?: string;
|
||||
discNumber?: number;
|
||||
images?: Image[];
|
||||
metadataType: number;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
songName?: string;
|
||||
title?: string;
|
||||
trackNumber?: number;
|
||||
type: MetadataType.MUSIC_TRACK;
|
||||
}
|
||||
|
||||
interface PhotoMediaMetadata {
|
||||
artist?: string;
|
||||
creationDateTime?: string;
|
||||
height?: number;
|
||||
images?: Image[];
|
||||
latitude?: number;
|
||||
location?: string;
|
||||
longitude?: number;
|
||||
metadataType: number;
|
||||
title?: string;
|
||||
type: MetadataType.PHOTO;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
activeTrackIds: Nullable<number[]>;
|
||||
autoplay: boolean;
|
||||
customData: unknown;
|
||||
itemId: Nullable<number>;
|
||||
media: MediaInformation;
|
||||
playbackDuration: Nullable<number>;
|
||||
preloadTime: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export interface MediaStatus {
|
||||
mediaSessionId: number;
|
||||
media?: MediaInformation;
|
||||
playbackRate: number;
|
||||
playerState: PlayerState;
|
||||
idleReason?: IdleReason;
|
||||
items?: QueueItem[];
|
||||
currentTime: Nullable<number>;
|
||||
supportedMediaCommands: number;
|
||||
repeatMode: RepeatMode;
|
||||
volume: Volume;
|
||||
customData: unknown;
|
||||
}
|
||||
|
||||
interface ReceiverDisplayStatus {
|
||||
showStop: Nullable<boolean>;
|
||||
statusText: string;
|
||||
appImages: Image[];
|
||||
}
|
||||
|
||||
export interface Receiver {
|
||||
displayStatus: Nullable<ReceiverDisplayStatus>;
|
||||
isActiveInput: Nullable<boolean>;
|
||||
receiverType: ReceiverType;
|
||||
label: string;
|
||||
friendlyName: string;
|
||||
capabilities: Capability[];
|
||||
volume: Nullable<Volume>;
|
||||
}
|
||||
|
||||
export interface ReceiverApplication {
|
||||
appId: string;
|
||||
appType: string;
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isIdleScreen: boolean;
|
||||
launchedFromCloud: boolean;
|
||||
namespaces: Array<{ name: string }>;
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
transportId: string;
|
||||
universalAppId: string;
|
||||
}
|
||||
|
||||
export interface ReceiverStatus {
|
||||
applications?: ReceiverApplication[];
|
||||
isActiveInput?: boolean;
|
||||
isStandBy?: boolean;
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
interface ReqBase {
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
// NS: urn:x-cast:com.google.cast.receiver
|
||||
export type SenderMessage =
|
||||
| (ReqBase & { type: "LAUNCH"; appId: string })
|
||||
| (ReqBase & { type: "STOP"; sessionId: string })
|
||||
| (ReqBase & { type: "GET_STATUS" })
|
||||
| (ReqBase & { type: "GET_APP_AVAILABILITY"; appId: string[] })
|
||||
| (ReqBase & { type: "SET_VOLUME"; volume: Volume });
|
||||
|
||||
export type ReceiverMessage =
|
||||
| (ReqBase & { type: "RECEIVER_STATUS"; status: ReceiverStatus })
|
||||
| (ReqBase & { type: "LAUNCH_ERROR"; reason: string });
|
||||
|
||||
interface MediaReqBase extends ReqBase {
|
||||
mediaSessionId: number;
|
||||
customData?: unknown;
|
||||
}
|
||||
|
||||
// NS: urn:x-cast:com.google.cast.media
|
||||
export type SenderMediaMessage =
|
||||
| (MediaReqBase & { type: "PLAY" })
|
||||
| (MediaReqBase & { type: "PAUSE" })
|
||||
| {
|
||||
type: "MEDIA_GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| {
|
||||
type: "GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| (MediaReqBase & { type: "STOP" })
|
||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
|
||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||
| (ReqBase & {
|
||||
type: "LOAD";
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
atvCredentials?: string;
|
||||
atvCredentialsType?: string;
|
||||
autoplay?: Nullable<boolean>;
|
||||
currentTime?: Nullable<number>;
|
||||
customData?: unknown;
|
||||
media: MediaInformation;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "SEEK";
|
||||
resumeState?: Nullable<ResumeState>;
|
||||
currentTime?: Nullable<number>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "EDIT_TRACKS_INFO";
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
textTrackStyle?: Nullable<string>;
|
||||
})
|
||||
// QueueLoadRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_LOAD";
|
||||
items: QueueItem[];
|
||||
startIndex: number;
|
||||
repeatMode: string;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueInsertItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_INSERT";
|
||||
items: QueueItem[];
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueUpdateItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
items: QueueItem[];
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueJumpRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
jump?: Nullable<number>;
|
||||
currentItemId?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueRemoveItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REMOVE";
|
||||
itemIds: number[];
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueReorderItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REORDER";
|
||||
itemIds: number[];
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueSetPropertiesRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
repeatMode?: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
});
|
||||
|
||||
export type ReceiverMediaMessage =
|
||||
| (MediaReqBase & { type: "MEDIA_STATUS"; status: MediaStatus[] })
|
||||
| (MediaReqBase & { type: "INVALID_PLAYER_STATE" })
|
||||
| (MediaReqBase & { type: "LOAD_FAILED" })
|
||||
| (MediaReqBase & { type: "LOAD_CANCELLED" })
|
||||
| (MediaReqBase & { type: "INVALID_REQUEST" });
|
||||
197
bridge/src/bridge/components/mediaServer.ts
Normal file
197
bridge/src/bridge/components/mediaServer.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import stream from "stream";
|
||||
|
||||
import mime from "mime-types";
|
||||
|
||||
import messaging from "../messaging";
|
||||
import { convertSrtToVtt } from "../lib/subtitles";
|
||||
|
||||
export let mediaServer: http.Server | undefined;
|
||||
|
||||
export async function startMediaServer(filePath: string, port: number) {
|
||||
if (mediaServer?.listening) {
|
||||
await stopMediaServer();
|
||||
}
|
||||
|
||||
let fileDir: string;
|
||||
let fileName: string;
|
||||
let fileSize: number;
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(filePath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
fileDir = path.dirname(filePath);
|
||||
fileName = path.basename(filePath);
|
||||
fileSize = stat.size;
|
||||
} else {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Media path is not a file."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Failed to find media path."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = mime.lookup(filePath);
|
||||
if (!contentType) {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Failed to find media type."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any SubRip files within the same directory and
|
||||
* convert to WebVTT source.
|
||||
*/
|
||||
const subtitles = new Map<string, string>();
|
||||
try {
|
||||
const dirEntries = await fs.promises.readdir(fileDir, {
|
||||
withFileTypes: true
|
||||
});
|
||||
|
||||
for (const dirEntry of dirEntries) {
|
||||
if (
|
||||
dirEntry.isFile() &&
|
||||
mime.lookup(dirEntry.name) === "application/x-subrip"
|
||||
) {
|
||||
subtitles.set(
|
||||
dirEntry.name,
|
||||
await convertSrtToVtt(path.join(fileDir, dirEntry.name))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: Failed to find/convert subtitles (${filePath}).`);
|
||||
}
|
||||
|
||||
mediaServer = http.createServer(async (req, res) => {
|
||||
if (!req.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decodedUrl = decodeURIComponent(req.url);
|
||||
// Drop leading slash
|
||||
if (decodedUrl.startsWith("/")) {
|
||||
decodedUrl = decodedUrl.slice(1);
|
||||
}
|
||||
|
||||
switch (decodedUrl) {
|
||||
case fileName: {
|
||||
const { range } = req.headers;
|
||||
|
||||
// Partial content HTTP 206
|
||||
if (range) {
|
||||
const bounds = range.substring(6).split("-");
|
||||
const start = parseInt(bounds[0]);
|
||||
const end = bounds[1] ? parseInt(bounds[1]) : fileSize - 1;
|
||||
|
||||
res.writeHead(206, {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||
"Content-Length": end - start + 1,
|
||||
"Content-Type": contentType
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
"Content-Length": fileSize,
|
||||
"Content-Type": contentType
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
if (subtitles.has(req.url)) {
|
||||
const vttSource = subtitles.get(req.url)!;
|
||||
const vttStream = stream.Readable.from(vttSource);
|
||||
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
|
||||
vttStream.pipe(res);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mediaServer.on("close", () => {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerStopped"
|
||||
});
|
||||
});
|
||||
mediaServer.on("error", err => {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: err.message
|
||||
});
|
||||
});
|
||||
|
||||
mediaServer.listen(port, () => {
|
||||
const localAddresses: string[] = [];
|
||||
for (const iface of Object.values(os.networkInterfaces())) {
|
||||
const matchingIface = iface?.find(
|
||||
details => details.family === "IPv4" && !details.internal
|
||||
);
|
||||
if (matchingIface) {
|
||||
localAddresses.push(matchingIface.address);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localAddresses.length) {
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerError",
|
||||
data: "Failed to get local address."
|
||||
});
|
||||
stopMediaServer();
|
||||
return;
|
||||
}
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "mediaCast:mediaServerStarted",
|
||||
data: {
|
||||
mediaPath: fileName,
|
||||
subtitlePaths: Array.from(subtitles.keys()),
|
||||
localAddress: localAddresses[0]
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function stopMediaServer() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!mediaServer?.listening) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
mediaServer.close(err => {
|
||||
if (err) {
|
||||
reject();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
mediaServer = undefined;
|
||||
});
|
||||
}
|
||||
131
bridge/src/bridge/index.ts
Executable file
131
bridge/src/bridge/index.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import messaging, { Message } from "./messaging";
|
||||
|
||||
import { handleCastMessage } from "./components/cast";
|
||||
import Discovery from "./components/cast/discovery";
|
||||
import Remote from "./components/cast/remote";
|
||||
|
||||
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
|
||||
|
||||
import { applicationVersion } from "../../config.json";
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
discovery?.stop();
|
||||
try {
|
||||
await stopMediaServer();
|
||||
} catch (err) {
|
||||
console.error("Error stopping media server!", err);
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
let discovery: Discovery | null = null;
|
||||
const remotes = new Map<string, Remote>();
|
||||
|
||||
/**
|
||||
* Handle incoming messages from the extension and forward
|
||||
* them to the appropriate handlers.
|
||||
*
|
||||
* Initializes the counterpart objects and is responsible
|
||||
* for managing existing ones.
|
||||
*/
|
||||
messaging.on("message", (message: Message) => {
|
||||
switch (message.subject) {
|
||||
case "bridge:getInfo":
|
||||
case "bridge:/getInfo": {
|
||||
messaging.send(applicationVersion);
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:startDiscovery": {
|
||||
const { shouldWatchStatus } = message.data;
|
||||
|
||||
discovery = new Discovery({
|
||||
onDeviceFound(device) {
|
||||
messaging.sendMessage({
|
||||
subject: "main:deviceUp",
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
deviceInfo: device
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldWatchStatus) {
|
||||
remotes.set(
|
||||
device.id,
|
||||
new Remote(device.host, {
|
||||
// RECEIVER_STATUS
|
||||
onReceiverStatusUpdate(status) {
|
||||
messaging.sendMessage({
|
||||
subject:
|
||||
"main:receiverDeviceStatusUpdated",
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
status
|
||||
}
|
||||
});
|
||||
},
|
||||
// MEDIA_STATUS
|
||||
onMediaStatusUpdate(status) {
|
||||
if (!status) return;
|
||||
|
||||
messaging.sendMessage({
|
||||
subject:
|
||||
"main:receiverDeviceMediaStatusUpdated",
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
status
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
onDeviceDown(deviceId) {
|
||||
messaging.sendMessage({
|
||||
subject: "main:deviceDown",
|
||||
data: { deviceId }
|
||||
});
|
||||
|
||||
if (shouldWatchStatus) {
|
||||
if (remotes.has(deviceId)) {
|
||||
remotes.get(deviceId)?.disconnect();
|
||||
remotes.delete(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
discovery.start();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendReceiverMessage": {
|
||||
const { deviceId, message: receiverMessage } = message.data;
|
||||
remotes.get(deviceId)?.sendReceiverMessage(receiverMessage);
|
||||
break;
|
||||
}
|
||||
case "bridge:sendMediaMessage": {
|
||||
const { deviceId, message: mediaMessage } = message.data;
|
||||
remotes.get(deviceId)?.sendMediaMessage(mediaMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
// Media server
|
||||
case "bridge:startMediaServer": {
|
||||
const { filePath, port } = message.data;
|
||||
startMediaServer(filePath, port);
|
||||
break;
|
||||
}
|
||||
case "bridge:stopMediaServer": {
|
||||
stopMediaServer();
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
handleCastMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
51
bridge/src/bridge/lib/subtitles.ts
Normal file
51
bridge/src/bridge/lib/subtitles.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Reads a SubRip file and outputs text content as WebVTT.
|
||||
*/
|
||||
export async function convertSrtToVtt(srtFilePath: string) {
|
||||
const fileStream = fs.createReadStream(srtFilePath, { encoding: "utf-8" });
|
||||
|
||||
let fileContents = "";
|
||||
for await (let chunk of fileStream) {
|
||||
// Omit BOM if present
|
||||
if (!fileContents && chunk[0] === "\uFEFF") {
|
||||
chunk = chunk.slice(1);
|
||||
}
|
||||
|
||||
// Normalize line endings
|
||||
fileContents += chunk.replace(/$\r\n/gm, "\n");
|
||||
}
|
||||
|
||||
let vttText = "WEBVTT\n";
|
||||
|
||||
/**
|
||||
* Matches a caption group within an SubRip file. Match groups
|
||||
* are the index (followed by a new line), the time range
|
||||
* (followed by a new line), then any text content until a blank
|
||||
* line.
|
||||
*/
|
||||
const REGEX_CAPTION =
|
||||
/(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g;
|
||||
|
||||
/**
|
||||
* WebVTT is very similar to SubRip, the main differences being
|
||||
* the "WEBVTT" specifier and optional metadata at the head of
|
||||
* the file, the optional caption indicies and the timecode
|
||||
* millisecond separator.
|
||||
*/
|
||||
for (const groups of fileContents.matchAll(REGEX_CAPTION)) {
|
||||
const captionIndex = groups[1];
|
||||
const captionTime = groups[2];
|
||||
const captionText = groups[3];
|
||||
|
||||
vttText += `\n${captionIndex}\n`;
|
||||
vttText += `${captionTime.replace(/,/g, ".")}\n`;
|
||||
|
||||
if (captionText) {
|
||||
vttText += `${captionText}`;
|
||||
}
|
||||
}
|
||||
|
||||
return vttText;
|
||||
}
|
||||
255
bridge/src/bridge/messaging.ts
Normal file
255
bridge/src/bridge/messaging.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { TypedEmitter } from "tiny-typed-emitter";
|
||||
|
||||
import { DecodeTransform, EncodeTransform } from "../transforms";
|
||||
|
||||
import type {
|
||||
MediaStatus,
|
||||
ReceiverStatus,
|
||||
SenderMediaMessage,
|
||||
SenderMessage
|
||||
} from "./components/cast/types";
|
||||
|
||||
import type {
|
||||
ReceiverDevice,
|
||||
CastSessionCreatedDetails,
|
||||
CastSessionUpdatedDetails
|
||||
} from "./messagingTypes";
|
||||
|
||||
/**
|
||||
* IMPORTANT:
|
||||
* Messages that cross the native messaging channel. MUST keep
|
||||
* in-sync with the extension's version at:
|
||||
* ext/src/messaging.ts > AppMessageDefinitions
|
||||
*/
|
||||
type MessageDefinitions = {
|
||||
/**
|
||||
* First message sent by the extension to the bridge.
|
||||
* Includes extension version string. Responds directly with version
|
||||
* string of the bridge to compare.
|
||||
*
|
||||
* Still uses `:/` message separator for compat talking to older
|
||||
* bridge versions.
|
||||
*/
|
||||
"bridge:getInfo": string;
|
||||
"bridge:/getInfo": string;
|
||||
|
||||
/**
|
||||
* Tells a bridge to begin service discovery (and whether to
|
||||
* establish connections to monitor the status of the receiver
|
||||
* devices).
|
||||
*/
|
||||
"bridge:startDiscovery": {
|
||||
shouldWatchStatus: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to extension from the bridge whenever a receiver device is
|
||||
* found.
|
||||
*/
|
||||
"main:deviceUp": { deviceId: string; deviceInfo: ReceiverDevice };
|
||||
/**
|
||||
* Sent to extension from the bridge whenever a previously found
|
||||
* receiver device is lost.
|
||||
*/
|
||||
"main:deviceDown": { deviceId: string };
|
||||
|
||||
/**
|
||||
* Sent to the extension from the bridge whenever a
|
||||
* `RECEIVER_STATUS` message (`NS_RECEIVER`) is received.
|
||||
*/
|
||||
"main:receiverDeviceStatusUpdated": {
|
||||
deviceId: string;
|
||||
status: ReceiverStatus;
|
||||
};
|
||||
/**
|
||||
* Sent to the extension from the bridge whenever a
|
||||
* `MEDIA_STATUS` message (`NS_RECEIVER`) is received.
|
||||
*/
|
||||
"main:receiverDeviceMediaStatusUpdated": {
|
||||
deviceId: string;
|
||||
status: MediaStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to the bridge when non-session related receiver messages
|
||||
* need to be sent (e.g. volume control, application stop, etc...).
|
||||
*/
|
||||
"bridge:sendReceiverMessage": {
|
||||
deviceId: string;
|
||||
message: SenderMessage;
|
||||
};
|
||||
/**
|
||||
* Sent to the bridge when the receiver selector media UI is used
|
||||
* to control media playback.
|
||||
*/
|
||||
"bridge:sendMediaMessage": {
|
||||
deviceId: string;
|
||||
message: SenderMediaMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to bridge from cast API instance when a session request is
|
||||
* initiated.
|
||||
*/
|
||||
"bridge:createCastSession": {
|
||||
appId: string;
|
||||
receiverDevice: ReceiverDevice;
|
||||
};
|
||||
/**
|
||||
* Connects to, and sends a `STOP` message on the `NS_RECEIVER`
|
||||
* channel for the given receiver device.
|
||||
*/
|
||||
"bridge:stopCastSession": {
|
||||
receiverDevice: ReceiverDevice;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to cast API instances whenever a session is created or
|
||||
* updates. Updated details is a mutable subset of session details
|
||||
* otherwise fixed on creation.
|
||||
*/
|
||||
"main:castSessionCreated": CastSessionCreatedDetails;
|
||||
"main:castSessionUpdated": CastSessionUpdatedDetails;
|
||||
/**
|
||||
* Sent to cast API instances whenever a session is stopped.
|
||||
*/
|
||||
"cast:sessionStopped": {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to bridge from cast API instance whenever an `NS_RECEIVER`
|
||||
* message needs to be sent.
|
||||
*/
|
||||
"bridge:sendCastReceiverMessage": {
|
||||
sessionId: string;
|
||||
messageData: SenderMessage;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to bridge from cast API instance whenever a application
|
||||
* session message needs to be sent (via
|
||||
* `chrome.cast.Session#sendMessage`).
|
||||
*/
|
||||
"bridge:sendCastSessionMessage": {
|
||||
sessionId: string;
|
||||
namespace: string;
|
||||
messageData: object | string;
|
||||
messageId: string;
|
||||
};
|
||||
/**
|
||||
* Sent to cast API instance from bridge when session message
|
||||
* received from a receiver device.
|
||||
*/
|
||||
"cast:sessionMessageReceived": {
|
||||
sessionId: string;
|
||||
namespace: string;
|
||||
messageData: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to cast API instance from bridge whenever a message
|
||||
* operation is completed. If an error ocurred, an error string will
|
||||
* be passed as the `error` data property.
|
||||
*/
|
||||
"cast:impl_sendMessage": {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to the bridge to start an HTTP media server at a given file
|
||||
* path on the given port.
|
||||
*/
|
||||
"bridge:startMediaServer": {
|
||||
filePath: string;
|
||||
port: number;
|
||||
};
|
||||
/**
|
||||
* Sent to media sender from bridge when the media server is ready
|
||||
* to serve files.
|
||||
*/
|
||||
"mediaCast:mediaServerStarted": {
|
||||
mediaPath: string;
|
||||
subtitlePaths: string[];
|
||||
localAddress: string;
|
||||
};
|
||||
/**
|
||||
* Sent to bridge to stop HTTP media server.
|
||||
*/
|
||||
"bridge:stopMediaServer": undefined;
|
||||
/**
|
||||
* Sent to media sender from bridge when the media server has
|
||||
* stopped.
|
||||
*/
|
||||
"mediaCast:mediaServerStopped": undefined;
|
||||
/**
|
||||
* Sent to media sender from bridge when the media server has
|
||||
* encountered an error.
|
||||
*/
|
||||
"mediaCast:mediaServerError": string;
|
||||
};
|
||||
|
||||
interface MessageBase<K extends keyof MessageDefinitions> {
|
||||
subject: K;
|
||||
data: MessageDefinitions[K];
|
||||
}
|
||||
|
||||
type Messages = {
|
||||
[K in keyof MessageDefinitions]: MessageBase<K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make message data key optional if specified as blank or with
|
||||
* all-optional keys.
|
||||
*/
|
||||
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
|
||||
L extends unknown
|
||||
? undefined extends L["data"]
|
||||
? Omit<L, "data"> & Partial<L>
|
||||
: L
|
||||
: never;
|
||||
|
||||
export type Message = NarrowedMessage<Messages[keyof Messages]>;
|
||||
|
||||
interface MessengerEvents {
|
||||
message: (message: Message) => void;
|
||||
}
|
||||
|
||||
class Messenger extends TypedEmitter<MessengerEvents> {
|
||||
// Native messaging transforms
|
||||
private decodeTransform = new DecodeTransform();
|
||||
private encodeTransform = new EncodeTransform();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Hook up stdin -> stdout
|
||||
process.stdin.pipe(this.decodeTransform);
|
||||
this.encodeTransform.pipe(process.stdout);
|
||||
|
||||
this.decodeTransform.on("error", err =>
|
||||
console.error("err (message decode):", err)
|
||||
);
|
||||
this.encodeTransform.on("error", err =>
|
||||
console.error("err (message encode):", err)
|
||||
);
|
||||
|
||||
this.decodeTransform.on("data", (message: Message) => {
|
||||
this.emit("message", message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends a message to the extension. */
|
||||
sendMessage(message: Message) {
|
||||
this.encodeTransform.write(message);
|
||||
}
|
||||
|
||||
send(data: unknown) {
|
||||
this.encodeTransform.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Messenger();
|
||||
41
bridge/src/bridge/messagingTypes.ts
Normal file
41
bridge/src/bridge/messagingTypes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
Image,
|
||||
ReceiverStatus,
|
||||
SenderApplication,
|
||||
Volume
|
||||
} from "./components/cast/types";
|
||||
|
||||
export enum ReceiverDeviceCapabilities {
|
||||
NONE = 0,
|
||||
VIDEO_OUT = 1,
|
||||
VIDEO_IN = 2,
|
||||
AUDIO_OUT = 4,
|
||||
AUDIO_IN = 8,
|
||||
MULTIZONE_GROUP = 32
|
||||
}
|
||||
|
||||
export interface ReceiverDevice {
|
||||
id: string;
|
||||
friendlyName: string;
|
||||
modelName: string;
|
||||
capabilities: ReceiverDeviceCapabilities;
|
||||
host: string;
|
||||
port: number;
|
||||
status?: ReceiverStatus;
|
||||
}
|
||||
|
||||
export interface CastSessionUpdatedDetails {
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
namespaces: Array<{ name: string }>;
|
||||
volume: Volume;
|
||||
}
|
||||
export interface CastSessionCreatedDetails extends CastSessionUpdatedDetails {
|
||||
appId: string;
|
||||
appImages: Image[];
|
||||
displayName: string;
|
||||
receiverId: string;
|
||||
receiverFriendlyName: string;
|
||||
senderApps: SenderApplication[];
|
||||
transportId: string;
|
||||
}
|
||||
147
bridge/src/daemon.ts
Normal file
147
bridge/src/daemon.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { DecodeTransform, EncodeTransform } from "./transforms.js";
|
||||
|
||||
const bridgeInstances = new Set<ChildProcess>();
|
||||
|
||||
// Ensure child processes are killed on exit
|
||||
process.on("SIGTERM", async () => {
|
||||
for (const bridge of bridgeInstances) {
|
||||
bridge.kill();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export interface DaemonOpts {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
secure?: boolean;
|
||||
key?: Buffer;
|
||||
cert?: Buffer;
|
||||
}
|
||||
|
||||
export function init(opts: DaemonOpts) {
|
||||
const server = !opts.secure
|
||||
? http.createServer()
|
||||
: https.createServer({
|
||||
key: opts.key,
|
||||
cert: opts.cert
|
||||
});
|
||||
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
wss.on("connection", socket => {
|
||||
// Stream for incoming WebSocket messages
|
||||
const messageStream = new Readable({ objectMode: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
messageStream._read = () => {};
|
||||
|
||||
socket.on("message", (message: string) => {
|
||||
try {
|
||||
messageStream.push(JSON.parse(message));
|
||||
} catch (err) {
|
||||
// Catch parse errors and close socket
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Daemon and bridge are the same binary, so spawn a new
|
||||
* version of self in bridge mode.
|
||||
*/
|
||||
const bridge = spawn(process.execPath, [process.argv[1]]);
|
||||
bridgeInstances.add(bridge);
|
||||
|
||||
// socket -> bridge.stdin
|
||||
messageStream.pipe(new EncodeTransform()).pipe(bridge.stdin);
|
||||
|
||||
// bridge.stdout -> socket
|
||||
bridge.stdout.pipe(new DecodeTransform()).on("data", data => {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(data));
|
||||
});
|
||||
|
||||
// Handle termination
|
||||
socket.on("close", () => bridge.kill());
|
||||
bridge.on("exit", () => {
|
||||
socket.close();
|
||||
bridgeInstances.delete(bridge);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Authenticates requests by checking password URL param against
|
||||
* server password specified in launch options.
|
||||
*/
|
||||
function authenticate(req: http.IncomingMessage) {
|
||||
if (!opts.password) return true;
|
||||
|
||||
const password = new URL(
|
||||
req.url!,
|
||||
`http://${req.headers.host}`
|
||||
).searchParams.get("password");
|
||||
|
||||
return password === opts.password;
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
/**
|
||||
* Only accept authenticated WebSocket requests from extension
|
||||
* origins.
|
||||
*/
|
||||
if (
|
||||
req.headers.origin?.startsWith("moz-extension://") &&
|
||||
authenticate(req)
|
||||
) {
|
||||
wss.handleUpgrade(req, socket, head, ws => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
/**
|
||||
* Browser WebSocket API does not allow access to connection errors,
|
||||
* so provide an endpoint for feedback on invalid authentication.
|
||||
*/
|
||||
server.on("request", (req, res) => {
|
||||
/**
|
||||
* Requests from extensions have their origin header stripped,
|
||||
* so block all requests with origin headers.
|
||||
*/
|
||||
if ("origin" in req.headers) {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(authenticate(req) ? 200 : 401);
|
||||
res.end();
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
`Starting WebSocket server at ${opts.secure ? "wss" : "ws"}://${
|
||||
opts.host.includes(":") ? `[${opts.host}]` : opts.host
|
||||
}:${opts.port}... `
|
||||
);
|
||||
server.listen({ port: opts.port, host: opts.host }, () => {
|
||||
process.stdout.write("Done!\n");
|
||||
});
|
||||
|
||||
server.on("error", err => {
|
||||
console.error("Failed!");
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
5
bridge/src/global.d.ts
vendored
Normal file
5
bridge/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare type Nullable<T> = T | null;
|
||||
|
||||
declare type DistributiveOmit<T, K extends keyof any> = T extends any
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
123
bridge/src/main.ts
Normal file
123
bridge/src/main.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import yargs from "yargs";
|
||||
|
||||
import type { DaemonOpts } from "./daemon";
|
||||
|
||||
import { applicationName, applicationVersion } from "../config.json";
|
||||
|
||||
const argv = yargs()
|
||||
.scriptName(applicationName)
|
||||
.usage("$0 [args]")
|
||||
.help()
|
||||
.alias("help", "h")
|
||||
.version(`v${applicationVersion}`)
|
||||
.alias("version", "v")
|
||||
.config("config", parseConfig)
|
||||
.option("daemon", {
|
||||
alias: "d",
|
||||
describe: `Launch in daemon mode. This starts a WebSocket server that \
|
||||
the extension can be configured to connect to under bridge options.`,
|
||||
type: "boolean"
|
||||
})
|
||||
.option("host", {
|
||||
alias: "n",
|
||||
describe: `Host for daemon WebSocket server. This must match the host \
|
||||
set in the extension options.`,
|
||||
default: "localhost"
|
||||
})
|
||||
.option("port", {
|
||||
alias: "p",
|
||||
describe: `Port number for daemon WebSocket server. This must match \
|
||||
the port set in the extension options.`,
|
||||
default: 9556
|
||||
})
|
||||
.option("password", {
|
||||
alias: "P",
|
||||
describe: `Set an optional password for the daemon WebSocket server. \
|
||||
This must match the password set in the extension options.
|
||||
Note: If using this option it is highly recommended that you enable secure \
|
||||
connections to avoid leaking plaintext passwords!`,
|
||||
type: "string"
|
||||
})
|
||||
.option("secure", {
|
||||
alias: "s",
|
||||
describe: `Use a secure HTTPS server for WebSocket connections. \
|
||||
Requires key/cert file options to be specified.`,
|
||||
type: "boolean",
|
||||
default: false
|
||||
})
|
||||
.option("key-file", {
|
||||
alias: "k",
|
||||
describe: `Path to the private key PEM file to use for the \
|
||||
HTTPS server.`,
|
||||
type: "string"
|
||||
})
|
||||
.option("cert-file", {
|
||||
alias: "c",
|
||||
describe: `Path to the certificate PEM file to use for the \
|
||||
HTTPS server.`,
|
||||
type: "string"
|
||||
})
|
||||
.check(argv => {
|
||||
// Ensure valid port range
|
||||
if (argv.port < 1025 || argv.port > 65535) {
|
||||
throw new Error("Invalid port specified!");
|
||||
}
|
||||
// Ensure secure options are valid
|
||||
if (argv.secure) {
|
||||
if (!argv["key-file"] || !argv["cert-file"]) {
|
||||
throw new Error("Missing required key/cert files.");
|
||||
}
|
||||
if (
|
||||
!fs.existsSync(argv["key-file"]) ||
|
||||
!fs.existsSync(argv["cert-file"])
|
||||
) {
|
||||
throw new Error("Specified key/cert files do not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.parseSync(process.argv);
|
||||
|
||||
/** Reads and parses yargs config file. */
|
||||
function parseConfig(configPath: string) {
|
||||
let config: any;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, { encoding: "utf-8" }));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse config file!`);
|
||||
}
|
||||
|
||||
// Resolve key/cert paths relative to config
|
||||
const configDirName = path.dirname(configPath);
|
||||
if (typeof config["key-file"] === "string") {
|
||||
config["key-file"] = path.resolve(configDirName, config["key-file"]);
|
||||
}
|
||||
if (typeof config["cert-file"] === "string") {
|
||||
config["cert-file"] = path.resolve(configDirName, config["cert-file"]);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
if (argv.daemon) {
|
||||
import("./daemon").then(daemon => {
|
||||
const daemonOpts: DaemonOpts = {
|
||||
host: argv.host,
|
||||
port: argv.port,
|
||||
password: argv.password
|
||||
};
|
||||
if (argv.secure) {
|
||||
daemonOpts.secure = true;
|
||||
daemonOpts.key = fs.readFileSync(argv.keyFile!);
|
||||
daemonOpts.cert = fs.readFileSync(argv.certFile!);
|
||||
}
|
||||
|
||||
daemon.init(daemonOpts);
|
||||
});
|
||||
} else {
|
||||
import("./bridge");
|
||||
}
|
||||
122
bridge/src/transforms.ts
Executable file
122
bridge/src/transforms.ts
Executable file
@@ -0,0 +1,122 @@
|
||||
import { Transform, TransformCallback } from "stream";
|
||||
import type { Message } from "./bridge/messaging";
|
||||
|
||||
type ResponseHandlerFunction = (message: Message) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Takes a handler function that implements the transform
|
||||
* and calls the transform callback.
|
||||
*/
|
||||
export class ResponseTransform extends Transform {
|
||||
constructor(private _handler: ResponseHandlerFunction) {
|
||||
super({
|
||||
readableObjectMode: true,
|
||||
writableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: Message,
|
||||
_encoding: string,
|
||||
callback: TransformCallback
|
||||
) {
|
||||
Promise.resolve(this._handler(chunk)).then(res => {
|
||||
if (res) {
|
||||
callback(null, res);
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes input, decodes the message string, parses as JSON
|
||||
* and outputs the parsed result.
|
||||
*/
|
||||
export class DecodeTransform extends Transform {
|
||||
// Message data
|
||||
private _messageBuffer = Buffer.alloc(0);
|
||||
private _messageLength?: number;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
readableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: Uint8Array,
|
||||
_encoding: string,
|
||||
// tslint:disable-next-line:ban-types
|
||||
callback: TransformCallback
|
||||
) {
|
||||
// Append next chunk to buffer
|
||||
this._messageBuffer = Buffer.concat([this._messageBuffer, chunk]);
|
||||
|
||||
for (;;) {
|
||||
if (this._messageLength === undefined) {
|
||||
if (this._messageBuffer.length >= 4) {
|
||||
// Read message length and offset buffer
|
||||
this._messageLength = this._messageBuffer.readUInt32LE(0);
|
||||
this._messageBuffer = this._messageBuffer.slice(4);
|
||||
|
||||
// Next message chunk
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (this._messageBuffer.length >= this._messageLength) {
|
||||
const message = JSON.parse(
|
||||
this._messageBuffer
|
||||
.slice(0, this._messageLength)
|
||||
.toString()
|
||||
);
|
||||
|
||||
// Push message content
|
||||
this.push(message);
|
||||
|
||||
// Offset buffer to start of next message
|
||||
this._messageBuffer = this._messageBuffer.slice(
|
||||
this._messageLength
|
||||
);
|
||||
this._messageLength = undefined;
|
||||
|
||||
// Next message
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No complete messages left
|
||||
callback();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes input, encodes the message length and content and
|
||||
* outputs the encoded result.
|
||||
*/
|
||||
export class EncodeTransform extends Transform {
|
||||
constructor() {
|
||||
super({
|
||||
writableObjectMode: true
|
||||
});
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: Uint8Array,
|
||||
_encoding: string,
|
||||
// tslint:disable-next-line:ban-types
|
||||
callback: TransformCallback
|
||||
) {
|
||||
const messageLength = Buffer.alloc(4);
|
||||
const message = Buffer.from(JSON.stringify(chunk));
|
||||
|
||||
// Write message length
|
||||
messageLength.writeUInt32LE(message.length, 0);
|
||||
|
||||
// Output joined length and content
|
||||
callback(null, Buffer.concat([messageLength, message]));
|
||||
}
|
||||
}
|
||||
7
bridge/tsconfig.json
Normal file
7
bridge/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig",
|
||||
"include": ["./src/**/*", "./@types/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2020.String", "DOM"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user