Restructure background script (#70)

Splits some background script functionality into separate modules:
 - Receiver selector handling is moved to ./SelectorManager.
 - Status bridge handling is moved to ./StatusManager.
 - Menu creation and updates are handled in ./createMenus.
 - Shim creation is handled in ./createShim.

TypedEventTarget allows EventTarget-derived classes to export typed events.

Options type definition is moved to ./lib/options, module assumes more responsibility for update handling and provides a "changed" event.

Private cast._requestSession method allows bypassing receiver selector.
This commit is contained in:
Matt Hensman
2019-07-26 00:09:51 +01:00
committed by GitHub
parent 2fe72ed24c
commit ba8c28bf39
40 changed files with 1751 additions and 1241 deletions

View File

@@ -6,6 +6,23 @@ import nativeMessaging from "./nativeMessaging";
import options from "./options";
async function connect (): Promise<browser.runtime.Port> {
const applicationName = await options.get("bridgeApplicationName");
const bridgePort = nativeMessaging.connectNative(applicationName);
bridgePort.onDisconnect.addListener(() => {
if (bridgePort.error) {
console.error(`${applicationName} disconnected:`
, this.bridgePort.error.message);
} else {
console.info(`${applicationName} disconnected`);
}
});
return bridgePort;
}
export interface BridgeInfo {
name: string;
version: string;
@@ -16,7 +33,7 @@ export interface BridgeInfo {
isVersionNewer: boolean;
}
export default async function getBridgeInfo (): Promise<BridgeInfo> {
async function getInfo (): Promise<BridgeInfo> {
const applicationName = await options.get("bridgeApplicationName");
let applicationVersion: string;
@@ -65,3 +82,9 @@ export default async function getBridgeInfo (): Promise<BridgeInfo> {
, isVersionNewer
};
}
export default {
connect
, getInfo
};

45
ext/src/lib/endpoints.ts Normal file
View File

@@ -0,0 +1,45 @@
"use strict";
/**
* Cast sender API loader script URL.
*
* Since the actual cast sender API script is hosted locally
* within Chrome, this script just acts a loader script for
* the real script whilst also doing some UA string
* checking.
*/
export const CAST_LOADER_SCRIPT_URL =
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js";
/**
* Framework API loader script URL.
*
* Same URL as the usual loader script, but the additional
* search parameter is checked from within the script and
* the framework API script is conditionally loaded in
* addition to the regular API script.
*/
export const CAST_FRAMEWORK_LOADER_SCRIPT_URL =
`${CAST_LOADER_SCRIPT_URL}?loadCastFramework=1`;
/**
* Cast API script URLs.
*
* Cast functionality in Chrome was previously provided by
* an extension. The cast API script is still provided via
* chrome-extension URLs for compatibility reasons.
*/
export const CAST_SCRIPT_URLS = [
"chrome-extension://pkedcjkdefgpdelpbcmbmeomcjbeemfm/cast_sender.js"
, "chrome-extension://enhhojjnijigcajfphajepfemndkmdlo/cast_sender.js"
];
/**
* Framework API script URL.
*
* Unlike the basic cast sender API, the framework API is
* not hosted locally within Chrome and is the only script
* fetched directly from Google servers.
*/
export const CAST_FRAMEWORK_SCRIPT_URL =
"https://www.gstatic.com/cast/sdk/libs/sender/1.0/cast_framework.js";

52
ext/src/lib/loadSender.ts Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
import { stringify } from "./utils";
import { ReceiverSelection
, ReceiverSelectorMediaType } from "../receiver_selectors";
interface LoadSenderOptions {
tabId: number;
frameId: number;
selection: ReceiverSelection;
}
/**
* Loads the appropriate sender for a given receiver
* selector response.
*/
export default async function loadSender (opts: LoadSenderOptions) {
// Cancelled
if (!opts.selection) {
return;
}
switch (opts.selection.mediaType) {
case ReceiverSelectorMediaType.Tab:
case ReceiverSelectorMediaType.Screen: {
await browser.tabs.executeScript(opts.tabId, {
code: stringify`
window.selectedMedia = ${opts.selection.mediaType};
window.selectedReceiver = ${opts.selection.receiver};
`
, frameId: opts.frameId
});
await browser.tabs.executeScript(opts.tabId, {
file: "senders/mirroringCast.js"
, frameId: opts.frameId
});
break;
}
case ReceiverSelectorMediaType.File: {
const fileUrl = new URL(`file://${opts.selection.filePath}`);
const mediaSession = await mediaCasting.loadMediaUrl(
fileUrl.href, opts.selection.receiver);
break;
}
}
}

View File

@@ -0,0 +1,81 @@
"use strict";
import cast, { ensureInit } from "../shim/export";
import options from "./options";
import { Receiver } from "../types";
function getMediaSession (
receiver?: Receiver): Promise<cast.Session> {
return new Promise(async (resolve, reject) => {
await ensureInit();
/**
* If a receiver is available, call requestSession. If a
* specific receiver was specified, bypass receiver selector
* and create session directly.
*/
function receiverListener (availability: string) {
if (availability === cast.ReceiverAvailability.AVAILABLE) {
if (receiver) {
cast._requestSession(receiver, resolve, reject);
} else {
cast.requestSession(resolve, reject);
}
}
}
const sessionRequest = new cast.SessionRequest(
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID);
const apiConfig = new cast.ApiConfig(
sessionRequest
, null // sessionListener
, receiverListener); // receiverListener
cast.initialize(apiConfig);
});
}
function loadMediaUrl (
mediaUrl: string
, receiver: Receiver): Promise<cast.Session> {
return new Promise(async (resolve, reject) => {
const isLocalMedia = mediaUrl.startsWith("file://");
const isLocalMediaEnabled = await options.get("localMediaEnabled");
if (isLocalMedia && !isLocalMediaEnabled) {
console.error("fx_cast (Debug): Local media casting not enabled");
return;
}
const mediaUrlObject = new URL(mediaUrl);
const mediaInfo = new cast.media.MediaInfo(mediaUrlObject.href, null);
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
mediaInfo.metadata.title = mediaUrlObject.pathname;
const mediaSession = await getMediaSession(receiver);
const loadRequest = new cast.media.LoadRequest(mediaInfo);
loadRequest.autoplay = false;
mediaSession.loadMedia(loadRequest
, null // successCallback
, () => { reject(); }); // errorCallback
});
}
export default {
getMediaSession
, loadMediaUrl
};

View File

@@ -1,35 +0,0 @@
"use strict";
import { Message } from "../types";
interface Details {
tabId: number;
frameId: number;
}
type SenderCallback = (message: Message, details: Details) => void;
const routeMap = new Map<string, SenderCallback>();
function register (routeName: string, senderCallback: SenderCallback) {
routeMap.set(routeName, senderCallback);
}
function deregister (routeName: string) {
routeMap.delete(routeName);
}
function handleMessage (message: Message, details?: Details) {
const destination = message.subject.split(":")[0];
if (routeMap.has(destination)) {
routeMap.get(destination)(message, details);
}
}
export default {
register
, deregister
, handleMessage
};

View File

@@ -1,79 +1,149 @@
"use strict";
import defaultOptions, { Options } from "../defaultOptions";
import defaultOptions from "../defaultOptions";
import { Message } from "../types";
import { TypedEventTarget } from "./typedEvents";
/**
* Fetches `options` key from storage and returns it as
* Options interface type.
*/
async function getAll (): Promise<Options> {
const { options }: { options: Options } =
await browser.storage.sync.get("options");
export interface Options {
bridgeApplicationName: string;
mediaEnabled: boolean;
mediaSyncElement: boolean;
mediaStopOnUnload: boolean;
localMediaEnabled: boolean;
localMediaServerPort: number;
mirroringEnabled: boolean;
mirroringAppId: string;
userAgentWhitelistEnabled: boolean;
userAgentWhitelist: string[];
return options;
[key: string]: Options[keyof Options];
}
/**
* Takes Options object and sets to `options` storage key.
* Returns storage promise.
*/
async function setAll (options: Options): Promise<void> {
return browser.storage.sync.set({ options });
interface EventMap {
"changed": Array<keyof Options>;
}
/**
* Gets specific option from storage and returns it as its
* type from Options interface type.
*/
async function get<T extends keyof Options> (name: T): Promise<Options[T]> {
const options = await getAll();
// tslint:disable-next-line:new-parens
export default new class extends TypedEventTarget<EventMap> {
constructor () {
super();
if (options.hasOwnProperty(name)) {
return options[name];
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync") {
return;
}
// Types issue
const _changes = changes as {
[key: string]: browser.storage.StorageChange
};
if ("options" in _changes) {
const { oldValue, newValue } = _changes.options;
const changedKeys = [];
for (const key in newValue) {
// Don't track added keys
if (!(key in oldValue)) {
continue;
}
const oldKeyValue = oldValue[key];
const newKeyValue = newValue[key];
// Equality comparison
if (oldKeyValue === newKeyValue) {
continue;
}
// Array comparison
if (oldKeyValue instanceof Array
&& newKeyValue instanceof Array) {
if (oldKeyValue.length === newKeyValue.length
&& oldKeyValue.every((value, index) =>
value === newKeyValue[index])) {
continue;
}
}
changedKeys.push(key);
}
this.dispatchEvent(new CustomEvent("changed", {
detail: changedKeys
}));
}
});
}
}
/**
* Sets specific option to storage. Returns storage
* promise.
*/
async function set<T extends keyof Options> (
name: T
, value: Options[T]): Promise<void> {
/**
* Fetches `options` key from storage and returns it as
* Options interface type.
*/
public async getAll (): Promise<Options> {
const { options }: { options: Options } =
await browser.storage.sync.get("options");
const options = await getAll();
options[name] = value;
return setAll(options);
}
return options;
}
/**
* Takes Options object and sets to `options` storage key.
* Returns storage promise.
*/
public async setAll (options: Options): Promise<void> {
return browser.storage.sync.set({ options });
}
/**
* Gets existing options from storage and compares it
* against defaults. Any options in defaults and not in
* storage are set. Does not override any existing options.
*/
async function update (defaults = defaultOptions): Promise<void> {
const oldOpts = await getAll();
const newOpts: Partial<Options> = {};
/**
* Gets specific option from storage and returns it as its
* type from Options interface type.
*/
public async get<T extends keyof Options> (name: T): Promise<Options[T]> {
const options = await this.getAll();
// Find options not already in storage
for (const [ optName, optVal ] of Object.entries(defaults)) {
if (!oldOpts.hasOwnProperty(optName)) {
newOpts[optName] = optVal;
if (options.hasOwnProperty(name)) {
return options[name];
}
}
// Update storage with default values of new options
return setAll({
...oldOpts
, ...newOpts
});
}
/**
* Sets specific option to storage. Returns storage
* promise.
*/
public async set<T extends keyof Options> (
name: T
, value: Options[T]): Promise<void> {
const options = await this.getAll();
options[name] = value;
return this.setAll(options);
}
export default {
get, getAll
, set, setAll
, update
/**
* Gets existing options from storage and compares it
* against defaults. Any options in defaults and not in
* storage are set. Does not override any existing options.
*/
public async update (defaults = defaultOptions): Promise<void> {
const oldOpts = await this.getAll();
const newOpts: Partial<Options> = {};
// Find options not already in storage
for (const [ optName, optVal ] of Object.entries(defaults)) {
if (!oldOpts.hasOwnProperty(optName)) {
newOpts[optName] = optVal;
}
}
// Update storage with default values of new options
return this.setAll({
...oldOpts
, ...newOpts
});
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
export interface TypedEvents {
[key: string]: any;
}
export class TypedEventTarget<T extends TypedEvents> extends EventTarget {
public addEventListener<K extends keyof T> (
type: K, listener: (ev: CustomEvent<T[K]>) => void): void {
super.addEventListener(type as string, listener);
}
public removeEventListener<K extends keyof T> (
type: K, listener: (ev: CustomEvent<T[K]>) => void): void {
super.removeEventListener(type as string, listener);
}
public dispatchEvent<K extends keyof T> (ev: CustomEvent<T[K]>): boolean {
return super.dispatchEvent(ev);
}
}

View File

@@ -9,6 +9,28 @@ export function getNextEllipsis (ellipsis: string): string {
/* tslint:enable:curly */
}
/**
* Template literal tag function, JSON-encodes substitutions.
*/
export function stringify (
templateStrings: TemplateStringsArray
, ...substitutions: any[]) {
let formattedString = "";
for (const templateString of templateStrings) {
if (!formattedString) {
formattedString += templateString;
continue;
}
formattedString += JSON.stringify(substitutions.shift());
formattedString += templateString;
}
return formattedString;
}
interface WindowCenteredProps {
width: number;