Rename directory: ext -> extension

This commit is contained in:
hensm
2023-02-26 18:21:59 +00:00
parent 33bcbc0dca
commit a9406fde11
119 changed files with 40 additions and 42 deletions

View File

@@ -0,0 +1,297 @@
import logger from "../lib/logger";
import messaging, { Port, Message } from "../messaging";
import options from "../lib/options";
import { TypedEventTarget } from "../lib/TypedEventTarget";
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
import type {
ReceiverDevice,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../types";
const POPUP_URL = browser.runtime.getURL("ui/popup/index.html");
export interface ReceiverSelection {
device: ReceiverDevice;
mediaType: ReceiverSelectorMediaType;
}
export interface ReceiverSelectorReceiverMessage {
deviceId: string;
message: SenderMessage;
}
export interface ReceiverSelectorMediaMessage {
deviceId: string;
message: SenderMediaMessage;
}
interface ReceiverSelectorEvents {
selected: ReceiverSelection;
cancelled: void;
stop: { deviceId: string };
error: string;
close: void;
receiverMessage: ReceiverSelectorReceiverMessage;
mediaMessage: ReceiverSelectorMediaMessage;
}
/**
* Manages the receiver selector popup window and communication with the
* extension page hosted within.
*/
export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorEvents> {
/** Popup window ID. */
private windowId?: number;
/** Message port to extension page. */
private messagePort?: Port;
private messagePortDisconnected?: boolean;
private devices?: ReceiverDevice[];
private defaultMediaType?: ReceiverSelectorMediaType;
private availableMediaTypes?: ReceiverSelectorMediaType;
private wasReceiverSelected = false;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
constructor(private isBridgeCompatible: boolean) {
super();
this.onConnect = this.onConnect.bind(this);
this.onPopupMessage = this.onPopupMessage.bind(this);
this.onWindowsRemoved = this.onWindowsRemoved.bind(this);
this.onWindowsFocusChanged = this.onWindowsFocusChanged.bind(this);
browser.windows.onRemoved.addListener(this.onWindowsRemoved);
browser.windows.onFocusChanged.addListener(this.onWindowsFocusChanged);
/**
* Handle incoming message channel connection from popup
* window script.
*/
messaging.onConnect.addListener(this.onConnect);
}
/** Is receiver selector window currently open. */
get isOpen() {
return this.windowId !== undefined;
}
/**
* Creates and opens a receiver selector window.
*/
public async open(opts: {
devices: ReceiverDevice[];
defaultMediaType: ReceiverSelectorMediaType;
availableMediaTypes: ReceiverSelectorMediaType;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
}) {
this.appInfo = opts.appInfo;
this.pageInfo = opts.pageInfo;
// If popup already exists, close it
if (this.windowId) {
await browser.windows.remove(this.windowId);
}
this.devices = opts.devices;
this.defaultMediaType = opts.defaultMediaType;
this.availableMediaTypes = opts.availableMediaTypes;
const popupSizePosition = {
width: 400,
height: 200,
left: 100,
top: 100
};
/**
* Get current browser window and calculate relative centered
* left/top positions for the popup.
*/
const refWin = await browser.windows.getCurrent();
if (refWin.width && refWin.height && refWin.left && refWin.top) {
const centerX = refWin.left + refWin.width / 2;
const centerY = refWin.top + refWin.height / 3;
popupSizePosition.left = Math.floor(
centerX - popupSizePosition.width / 2
);
popupSizePosition.top = Math.floor(
centerY - popupSizePosition.height / 2
);
} else {
logger.log("Reference window missing positional properties.");
}
// Create popup window
const popup = await browser.windows.create({
url: POPUP_URL,
type: "popup",
...popupSizePosition
});
if (popup?.id === undefined) {
throw logger.error("Failed to create receiver selector popup.");
}
// Size/position not set correctly on creation (bug 1396881)
await browser.windows.update(popup.id, {
...popupSizePosition
});
this.windowId = popup.id;
}
/** Updates receiver devices displayed in the receiver selector. */
public update(
devices: ReceiverDevice[],
isBridgeCompatible: boolean,
connectedSessionIds: string[]
) {
this.devices = devices;
this.messagePort?.postMessage({
subject: "popup:update",
data: { devices, isBridgeCompatible, connectedSessionIds }
});
}
/** Closes the receiver selector (if open). */
public async close() {
if (this.windowId) {
await browser.windows.remove(this.windowId);
}
if (this.messagePort && !this.messagePortDisconnected) {
this.messagePort.disconnect();
}
}
/**
* Handles incoming port connection from the extension page and
* sends init data.
*/
private onConnect(port: Port) {
// Keep history state clean
browser.history.deleteUrl({ url: POPUP_URL });
if (port.name !== "popup") {
return;
}
this.messagePort?.disconnect();
this.messagePort = port;
this.messagePort.onMessage.addListener(this.onPopupMessage);
this.messagePort.onDisconnect.addListener(() => {
this.messagePortDisconnected = true;
});
if (
this.devices === undefined ||
this.defaultMediaType === undefined ||
this.availableMediaTypes === undefined
) {
this.dispatchEvent(
new CustomEvent("error", {
detail: "Popup receiver data not found."
})
);
return;
}
this.messagePort.postMessage({
subject: "popup:init",
data: {
appInfo: this.appInfo,
pageInfo: this.pageInfo
}
});
this.messagePort.postMessage({
subject: "popup:update",
data: {
devices: this.devices,
isBridgeCompatible: this.isBridgeCompatible,
defaultMediaType: this.defaultMediaType,
availableMediaTypes: this.availableMediaTypes
}
});
}
/** Handles messages from the popup extension page. */
private onPopupMessage(message: Message) {
switch (message.subject) {
case "main:receiverSelected":
this.wasReceiverSelected = true;
this.dispatchEvent(
new CustomEvent("selected", { detail: message.data })
);
break;
case "main:receiverStopped":
this.dispatchEvent(
new CustomEvent("stop", { detail: message.data })
);
break;
case "main:sendReceiverMessage":
this.dispatchEvent(
new CustomEvent("receiverMessage", { detail: message.data })
);
break;
case "main:sendMediaMessage":
this.dispatchEvent(
new CustomEvent("mediaMessage", { detail: message.data })
);
break;
}
}
/**
* Handles cancellation state where the popup window is closed
* before a receiver is selected.
*/
private onWindowsRemoved(windowId: number) {
// Only care about popup window
if (windowId !== this.windowId) {
return;
}
browser.windows.onRemoved.removeListener(this.onWindowsRemoved);
browser.windows.onFocusChanged.removeListener(
this.onWindowsFocusChanged
);
if (!this.wasReceiverSelected) {
this.dispatchEvent(new CustomEvent("cancelled"));
}
this.dispatchEvent(new CustomEvent("close"));
delete this.windowId;
}
/**
* Closes popup window if another browser window is brought into
* focus. Doesn't apply if no window is focused `WINDOW_ID_NONE`
* or if the popup window is re-focused.
*/
private async onWindowsFocusChanged(windowId: number) {
if (!this.windowId) return;
if (
windowId !== browser.windows.WINDOW_ID_NONE &&
windowId !== this.windowId
) {
if (await options.get("receiverSelectorCloseIfFocusLost")) {
browser.windows.remove(this.windowId);
}
}
}
}

View File

@@ -0,0 +1,60 @@
import logger from "../lib/logger";
import castManager from "./castManager";
const _ = browser.i18n.getMessage;
const ACTION_ICON_DEFAULT_DARK = "icons/cast-default-dark.svg";
const ACTION_ICON_DEFAULT_LIGHT = "icons/cast-default-light.svg";
const ACTION_ICON_CONNECTING_DARK = "icons/cast-connecting-dark.svg";
const ACTION_ICON_CONNECTING_LIGHT = "icons/cast-connecting-light.svg";
const ACTION_ICON_CONNECTED = "icons/cast-connected.svg";
const isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches;
export enum ActionState {
Default,
Connecting,
Connected
}
/** Updates action details depending on given state. */
export function updateActionState(state: ActionState, tabId?: number) {
let title: string;
let path = isDarkTheme
? ACTION_ICON_DEFAULT_LIGHT
: ACTION_ICON_DEFAULT_DARK;
switch (state) {
case ActionState.Default:
title = _("actionTitleDefault");
break;
case ActionState.Connecting:
title = _("actionTitleConnecting");
path = isDarkTheme
? ACTION_ICON_CONNECTING_LIGHT
: ACTION_ICON_CONNECTING_DARK;
break;
case ActionState.Connected:
title = _("actionTitleConnected");
path = ACTION_ICON_CONNECTED;
break;
}
browser.browserAction.setTitle({ tabId, title });
browser.browserAction.setIcon({ tabId, path });
}
export function initAction() {
logger.info("init (action)");
updateActionState(ActionState.Default);
browser.browserAction.onClicked.addListener(async tab => {
if (tab.id === undefined) {
logger.error("Tab ID not found in browser action handler.");
return;
}
castManager.triggerCast(tab.id);
});
}

View File

@@ -0,0 +1,147 @@
import logger from "../lib/logger";
import options from "../lib/options";
import bridge, { BridgeInfo } from "../lib/bridge";
import { baseConfigStorage, fetchBaseConfig } from "../lib/chromecastConfigApi";
import defaultOptions from "../defaultOptions";
import messaging from "../messaging";
import castManager from "./castManager";
import deviceManager from "./deviceManager";
import { initAction } from "./action";
import { initMenus } from "./menus";
import { initWhitelist } from "./whitelist";
const _ = browser.i18n.getMessage;
/**
* On install, set the default options before initializing the
* extension. On update, handle any unset values and set to the new
* defaults.
*/
browser.runtime.onInstalled.addListener(async details => {
switch (details.reason) {
case "install": {
// Set defaults
await options.setAll(defaultOptions);
// Extension initialization
init();
break;
}
case "update": {
// Set new defaults
await options.update(defaultOptions);
break;
}
}
});
/**
* Checks whether the bridge can be reached and is compatible with the
* current version of the extension. If not, triggers a notification
* with the appropriate info.
*/
async function notifyBridgeCompat() {
logger.info("checking for bridge...");
let info: BridgeInfo;
try {
info = await bridge.getInfo();
} catch (err) {
logger.info("... bridge issue!");
return;
}
if (info.isVersionCompatible) {
logger.info("... bridge compatible!");
} else {
logger.info("... bridge incompatible!");
const updateNotificationId = await browser.notifications.create({
type: "basic",
title: `${_("extensionName")}${_(
"optionsBridgeIssueStatusTitle"
)}`,
message: info.isVersionOlder
? _("optionsBridgeOlderAction")
: _("optionsBridgeNewerAction")
});
browser.notifications.onClicked.addListener(notificationId => {
if (notificationId !== updateNotificationId) {
return;
}
browser.tabs.create({
url: `https://github.com/hensm/fx_cast/releases/tag/v${info.expectedVersion}`
});
});
}
}
/**
* Updates locally-stored base config data if never downloaded or since
* expired.
*/
async function cacheBaseConfig() {
const { baseConfigUpdated } = await baseConfigStorage.get(
"baseConfigUpdated"
);
// If never updated or updated more than 48 hours ago
if (
!baseConfigUpdated ||
(Date.now() - baseConfigUpdated) / 1000 >= 172800
) {
logger.info("Fetching updated Chromecast base config...");
const baseConfig = await fetchBaseConfig();
if (baseConfig) {
await baseConfigStorage.set({
baseConfig,
baseConfigUpdated: Date.now()
});
}
}
}
let isInitialized = false;
async function init() {
if (isInitialized) {
return;
}
/**
* If options haven't been set yet, we can't properly initialize,
* so wait until init is called again in the onInstalled listener.
*/
if (!(await options.getAll())) {
return;
}
logger.info("init");
isInitialized = true;
await notifyBridgeCompat();
await deviceManager.init();
await castManager.init();
await initAction();
await initMenus();
await initWhitelist();
messaging.onMessage.addListener(message => {
switch (message.subject) {
case "main:refreshDeviceManager":
deviceManager.refresh();
break;
}
});
}
cacheBaseConfig();
init();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
import bridge, { BridgeInfo } from "../lib/bridge";
import logger from "../lib/logger";
import { TypedEventTarget } from "../lib/TypedEventTarget";
import type { Message, Port } from "../messaging";
import type { ReceiverDevice } from "../types";
import type {
MediaStatus,
ReceiverStatus,
SenderMediaMessage,
SenderMessage
} from "../cast/sdk/types";
import { PlayerState } from "../cast/sdk/media/enums";
import { ActionState, updateActionState } from "./action";
interface EventMap {
deviceUp: { deviceInfo: ReceiverDevice };
deviceDown: { deviceId: string };
deviceUpdated: { deviceId: string; status: ReceiverStatus };
deviceMediaUpdated: { deviceId: string; status: MediaStatus };
applicationFound: { deviceId: string; appId: string };
applicationClosed: { deviceId: string; appId: string; sessionId: string };
}
export default new (class extends TypedEventTarget<EventMap> {
/**
* Map of receiver device IDs to devices. Updated as receiverDevice
* messages are received from the bridge.
*/
private receiverDevices = new Map<string, ReceiverDevice>();
private bridgePort?: Port;
private bridgeInfo?: BridgeInfo;
async init() {
if (!this.bridgePort) {
await this.refresh();
}
}
/**
* Initializes (or re-initializes) a bridge connection to start
* dispatching events.
*/
async refresh() {
this.bridgePort?.disconnect();
try {
this.bridgeInfo = await bridge.getInfo();
// eslint-disable-next-line no-empty
} catch {}
if (this.bridgeInfo?.isVersionCompatible) {
this.bridgePort = await bridge.connect();
this.bridgePort.onMessage.addListener(this.onBridgeMessage);
this.bridgePort.onDisconnect.addListener(this.onBridgeDisconnect);
this.bridgePort.postMessage({
subject: "bridge:startDiscovery",
data: {
// Also send back status messages
shouldWatchStatus: true
}
});
}
}
getBridgeInfo() {
return this.bridgeInfo;
}
/** Gets a list of receiver devices. */
getDevices() {
return Array.from(this.receiverDevices.values());
}
/** Gets a device by ID. */
getDeviceById(deviceId: string) {
return this.receiverDevices.get(deviceId);
}
/** Sends an NS_RECEIVER message to a given device. */
sendReceiverMessage(deviceId: string, message: SenderMessage) {
if (!this.bridgePort) {
logger.error(
"Failed to send receiver message (no bridge connection)"
);
return;
}
const device = this.receiverDevices.get(deviceId);
if (!device) {
logger.error(
"Failed to send receiver message (could not find device)"
);
return;
}
this.bridgePort?.postMessage({
subject: "bridge:sendReceiverMessage",
data: { deviceId, message }
});
}
/** Sends an NS_MEDIA message to a given device. */
sendMediaMessage(deviceId: string, message: SenderMediaMessage) {
if (!this.bridgePort) {
logger.error("Failed to send media message (no bridge connection)");
return;
}
const device = this.receiverDevices.get(deviceId);
if (!device) {
logger.error(
"Failed to send media message (could not find device)"
);
return;
}
this.bridgePort?.postMessage({
subject: "bridge:sendMediaMessage",
data: { deviceId, message }
});
}
private onBridgeMessage = (message: Message) => {
switch (message.subject) {
case "main:deviceUp": {
const { deviceId, deviceInfo } = message.data;
this.receiverDevices.set(deviceId, deviceInfo);
// Sort devices by friendly name
this.receiverDevices = new Map(
[...this.receiverDevices].sort(([, deviceA], [, deviceB]) =>
deviceA.friendlyName.localeCompare(deviceB.friendlyName)
)
);
this.dispatchEvent(
new CustomEvent("deviceUp", {
detail: { deviceInfo }
})
);
break;
}
case "main:deviceDown": {
const { deviceId } = message.data;
if (this.receiverDevices.has(deviceId)) {
this.receiverDevices.delete(deviceId);
}
this.dispatchEvent(
new CustomEvent("deviceDown", {
detail: { deviceId: deviceId }
})
);
break;
}
case "main:receiverDeviceStatusUpdated": {
const { deviceId, status } = message.data;
const device = this.receiverDevices.get(deviceId);
if (!device) break;
const oldApplication = device.status?.applications?.[0];
// Clear media status when app status changes
const application = status.applications?.[0];
if (!application || application.isIdleScreen) {
delete device.mediaStatus;
// Send application closed event
if (oldApplication && !oldApplication.isIdleScreen) {
this.dispatchEvent(
new CustomEvent("applicationClosed", {
detail: {
deviceId,
appId: oldApplication.appId,
sessionId: oldApplication.transportId
}
})
);
}
}
device.status = status;
this.dispatchEvent(
new CustomEvent("deviceUpdated", {
detail: {
deviceId,
status: device.status
}
})
);
// Send new application found event
if (
!oldApplication &&
application &&
!application.isIdleScreen
) {
this.dispatchEvent(
new CustomEvent("applicationFound", {
detail: { deviceId, appId: application.appId }
})
);
}
break;
}
case "main:receiverDeviceMediaStatusUpdated": {
const { deviceId, status } = message.data;
const device = this.receiverDevices.get(deviceId);
if (!device) break;
if (device.mediaStatus) {
device.mediaStatus = { ...device.mediaStatus, ...status };
if (status.playerState === PlayerState.IDLE) {
delete device.mediaStatus.media;
}
} else {
device.mediaStatus = status;
}
this.dispatchEvent(
new CustomEvent("deviceMediaUpdated", {
detail: {
deviceId,
status: device.mediaStatus
}
})
);
break;
}
}
};
private onBridgeDisconnect = () => {
const deviceIds = [...this.receiverDevices.keys()];
delete this.bridgeInfo;
this.receiverDevices.clear();
// Notify listeners of device availablility
for (const deviceId of deviceIds) {
const event = new CustomEvent("deviceDown", {
detail: { deviceId }
});
this.dispatchEvent(event);
}
/**
* Reconnect 10 seconds after disconnect if not already
* reconnected (like immediately after a refresh).
*/
window.setTimeout(() => {
if (!this.bridgeInfo) {
this.refresh();
}
}, 10000);
};
})();

View File

@@ -0,0 +1,347 @@
import logger from "../lib/logger";
import options from "../lib/options";
import { stringify } from "../lib/utils";
import * as menuIds from "../menuIds";
import castManager from "./castManager";
const _ = browser.i18n.getMessage;
const URL_PATTERN_HTTP = "http://*/*";
const URL_PATTERN_HTTPS = "https://*/*";
const URL_PATTERN_FILE = "file://*/*";
const URL_PATTERNS_REMOTE = [URL_PATTERN_HTTP, URL_PATTERN_HTTPS];
const URL_PATTERNS_ALL = [...URL_PATTERNS_REMOTE, URL_PATTERN_FILE];
type MenuId = string | number;
let menuIdCast: MenuId;
let menuIdCastMedia: MenuId;
let menuIdWhitelist: MenuId;
let menuIdWhitelistRecommended: MenuId;
/** Match patterns for the whitelist option menus. */
const whitelistChildMenuPatterns = new Map<MenuId, string>();
/** Handles initial menu setup. */
export async function initMenus() {
logger.info("init (menus)");
const opts = await options.getAll();
// Global "Cast..." menu item
menuIdCast = browser.menus.create({
contexts: ["browser_action", "page", "tools_menu"],
title: _("contextCast"),
documentUrlPatterns: ["http://*/*", "https://*/*"],
icons: { "16": "icons/icon.svg" } // browser_action context
});
// <video>/<audio> "Cast..." context menu item
menuIdCastMedia = browser.menus.create({
contexts: ["audio", "video", "image"],
title: _("contextCast"),
visible: opts.mediaEnabled,
targetUrlPatterns: opts.localMediaEnabled
? URL_PATTERNS_ALL
: URL_PATTERNS_REMOTE
});
menuIdWhitelist = browser.menus.create({
contexts: ["browser_action"],
title: _("contextAddToWhitelist"),
enabled: false
});
menuIdWhitelistRecommended = browser.menus.create({
title: _("contextAddToWhitelistRecommended"),
parentId: menuIdWhitelist
});
browser.menus.create({
type: "separator",
parentId: menuIdWhitelist
});
// Popup context menus
const createPopupMenu = (props: browser.menus._CreateCreateProperties) =>
browser.menus.create({
visible: false,
documentUrlPatterns: [`${browser.runtime.getURL("ui/popup")}/*`],
...props
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_PLAY_PAUSE,
title: _("popupMediaPlay")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_MUTE,
type: "checkbox",
title: _("popupMediaMute")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_SKIP_PREVIOUS,
title: _("popupMediaSkipPrevious")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_SKIP_NEXT,
title: _("popupMediaSkipNext")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_CC,
title: _("popupMediaSubtitlesCaptions")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_CC_OFF,
parentId: menuIds.POPUP_MEDIA_CC,
type: "radio",
title: _("popupMediaSubtitlesCaptionsOff")
});
createPopupMenu({ id: menuIds.POPUP_MEDIA_SEPARATOR, type: "separator" });
createPopupMenu({
id: menuIds.POPUP_CAST,
title: _("popupCastButtonTitle"),
icons: { 16: "icons/icon.svg" }
});
createPopupMenu({
id: menuIds.POPUP_STOP,
title: _("popupStopButtonTitle")
});
browser.menus.onShown.addListener(onMenuShown);
browser.menus.onClicked.addListener(onMenuClicked);
options.addEventListener("changed", async ev => {
const alteredOpts = ev.detail;
const newOpts = await options.getAll();
if (menuIdCastMedia && alteredOpts.includes("mediaEnabled")) {
browser.menus.update(menuIdCastMedia, {
visible: newOpts.mediaEnabled
});
}
if (menuIdCastMedia && alteredOpts.includes("localMediaEnabled")) {
browser.menus.update(menuIdCastMedia, {
targetUrlPatterns: newOpts.localMediaEnabled
? URL_PATTERNS_ALL
: URL_PATTERNS_REMOTE
});
}
});
}
/** Handle updating menus when shown. */
async function onMenuShown(info: browser.menus._OnShownInfo) {
const menuIds = info.menuIds as unknown as number[];
// Only rebuild menus if whitelist menu present
if (menuIds.includes(menuIdWhitelist as number)) {
updateWhitelistMenu(info.pageUrl);
return;
}
}
/** Handle menu click events */
async function onMenuClicked(
info: browser.menus.OnClickData,
tab?: browser.tabs.Tab
) {
// Handle whitelist menus
if (info.parentMenuItemId === menuIdWhitelist) {
const pattern = whitelistChildMenuPatterns.get(info.menuItemId);
if (!pattern) {
throw logger.error(
`Whitelist pattern not found for menu item ID ${info.menuItemId}.`
);
}
const whitelist = await options.get("siteWhitelist");
if (!whitelist.find(item => item.pattern === pattern)) {
// Add to whitelist and update options
whitelist.push({ pattern, isEnabled: true });
await options.set("siteWhitelist", whitelist);
}
return;
}
if (tab?.id === undefined) {
logger.error("Menu handler tab ID not found.");
return;
}
switch (info.menuItemId) {
case menuIdCast: {
castManager.triggerCast(tab.id, info.frameId);
break;
}
case menuIdCastMedia:
if (info.srcUrl) {
await browser.tabs.executeScript(tab.id, {
code: stringify`
window.mediaUrl = ${info.srcUrl};
window.targetElementId = ${info.targetElementId};
`,
frameId: info.frameId
});
await browser.tabs.executeScript(tab.id, {
file: "cast/senders/media.js",
frameId: info.frameId
});
}
break;
}
}
/** Handles updating the whitelist menus for a given URL */
async function updateWhitelistMenu(pageUrl?: string) {
/**
* If page URL doesn't exist, we're not on a page and have nothing
* to whitelist, so disable the menu and return.
*/
if (!pageUrl) {
browser.menus.update(menuIdWhitelist, {
enabled: false
});
browser.menus.refresh();
return;
}
const url = new URL(pageUrl);
const urlHasOrigin = url.origin !== "null";
/**
* If the page URL doesn't have an origin, we're not on a
* remote page and have nothing to whitelist, so disable the
* menu and return.
*/
if (!urlHasOrigin) {
browser.menus.update(menuIdWhitelist, {
enabled: false
});
browser.menus.refresh();
return;
}
// Enable the whitelist menu
browser.menus.update(menuIdWhitelist, {
enabled: true
});
for (const [menuId] of whitelistChildMenuPatterns) {
// Clear all page-specific temporary menus
if (menuId !== menuIdWhitelistRecommended) {
browser.menus.remove(menuId);
}
whitelistChildMenuPatterns.delete(menuId);
}
// If there is more than one subdomain, get the base domain
const baseDomain =
(url.hostname.match(/\./g) || []).length > 1
? url.hostname.substring(url.hostname.indexOf(".") + 1)
: url.hostname;
const portlessOrigin = `${url.protocol}//${url.hostname}`;
const patternRecommended = `${portlessOrigin}/*`;
const patternSearch = `${portlessOrigin}${url.pathname}${url.search}`;
const patternWildcardProtocol = `*://${url.hostname}/*`;
const patternWildcardSubdomain = `${url.protocol}//*.${baseDomain}/*`;
const patternWildcardProtocolAndSubdomain = `*://*.${baseDomain}/*`;
// Update recommended menu item
browser.menus.update(menuIdWhitelistRecommended, {
title: _("contextAddToWhitelistRecommended", patternRecommended)
});
whitelistChildMenuPatterns.set(
menuIdWhitelistRecommended,
patternRecommended
);
if (url.search) {
const whitelistSearchMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternSearch),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(whitelistSearchMenuId, patternSearch);
}
/**
* Split URL path into segments and add menu items for each
* partial path as the segments are removed.
*/
{
const pathTrimmed = url.pathname.endsWith("/")
? url.pathname.substring(0, url.pathname.length - 1)
: url.pathname;
const pathSegments = pathTrimmed
.split("/")
.filter(segment => segment)
.reverse();
if (pathSegments.length) {
for (let i = 0; i < pathSegments.length; i++) {
const partialPath = pathSegments.slice(i).reverse().join("/");
const pattern = `${portlessOrigin}/${partialPath}/*`;
const partialPathMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", pattern),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(partialPathMenuId, pattern);
}
}
}
const wildcardProtocolMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternWildcardProtocol),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardProtocolMenuId,
patternWildcardProtocol
);
const wildcardSubdomainMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternWildcardSubdomain),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardSubdomainMenuId,
patternWildcardSubdomain
);
const wildcardProtocolAndSubdomainMenuId = browser.menus.create({
title: _(
"contextAddToWhitelistAdvancedAdd",
patternWildcardProtocolAndSubdomain
),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardProtocolAndSubdomainMenuId,
patternWildcardProtocolAndSubdomain
);
await browser.menus.refresh();
}

View File

@@ -0,0 +1,268 @@
import logger from "../lib/logger";
import options from "../lib/options";
import { getChromeUserAgent } from "../lib/userAgents";
import { RemoteMatchPattern } from "../lib/matchPattern";
import {
CAST_FRAMEWORK_LOADER_SCRIPT_URL,
CAST_LOADER_SCRIPT_URL
} from "../cast/urls";
// Missing on @types/firefox-webext-browser
type OnBeforeSendHeadersDetails = Parameters<
Parameters<typeof browser.webRequest.onBeforeSendHeaders.addListener>[0]
>[0] & {
frameAncestors?: Array<{ url: string; frameId: number }>;
};
type OnBeforeRequestDetails = Parameters<
Parameters<typeof browser.webRequest.onBeforeRequest.addListener>[0]
>[0] & {
frameAncestors?: Array<{ url: string; frameId: number }>;
};
export interface WhitelistItemData {
pattern: string;
isEnabled: boolean;
isUserAgentDisabled?: boolean;
customUserAgent?: string;
}
let matchPatterns: RemoteMatchPattern[] = [];
let platform: string;
let chromeUserAgent: string | undefined;
let chromeUserAgentHybrid: string | undefined;
let siteWhitelistEnabled = false;
let siteWhitelist: Nullable<WhitelistItemData[]> = null;
let customUserAgent: string | undefined;
export async function initWhitelist() {
logger.info("init (whitelist)");
if (!platform) {
// TODO: Allow hybrid UA to be configurable
platform = (await browser.runtime.getPlatformInfo()).os;
chromeUserAgent = getChromeUserAgent(platform);
chromeUserAgentHybrid = getChromeUserAgent(platform, true);
if (!chromeUserAgent) {
throw logger.error("Failed to get Chrome UA string");
}
customUserAgent = await options.get("siteWhitelistCustomUserAgent");
}
// Register on first run
await registerSiteWhitelist();
options.addEventListener("changed", async ev => {
// Update custom UA on change
if (ev.detail.includes("siteWhitelistCustomUserAgent")) {
customUserAgent = await options.get("siteWhitelistCustomUserAgent");
}
// Re-register whitelist on change
if (
ev.detail.includes("siteWhitelist") ||
ev.detail.includes("siteWhitelistEnabled")
) {
unregisterSiteWhitelist();
registerSiteWhitelist();
}
});
}
/**
* Returns the configured user agent matching the specified URL or
* undefined if the user agent is disabled.
*/
function getUserAgent(url: string, host?: string): Optional<string> {
if (!siteWhitelistEnabled || !siteWhitelist) return;
// Search site-specific user agents
const matchingItem = siteWhitelist.find(
item =>
item.customUserAgent &&
new RemoteMatchPattern(item.pattern).matches(url)
);
if (matchingItem) {
if (!matchingItem.isEnabled || matchingItem.isUserAgentDisabled) return;
return matchingItem.customUserAgent;
}
return (
customUserAgent ||
(host === "www.youtube.com" ? chromeUserAgentHybrid : chromeUserAgent)
);
}
/**
* Override User-Agent header for requests to whitelisted URLs. Sites
* with Chromecast support will usually only load the Cast SDK if they
* detect a Chrome user agent string.
*/
async function onWhitelistedBeforeSendHeaders(
details: OnBeforeSendHeadersDetails
) {
if (!details.requestHeaders) {
throw logger.error(
"OnBeforeSendHeaders handler details missing requestHeaders."
);
}
const host = details.requestHeaders.find(header => header.name === "Host");
for (const header of details.requestHeaders) {
if (header.name === "User-Agent") {
header.value = getUserAgent(details.url, host?.value);
break;
}
}
return {
requestHeaders: details.requestHeaders
};
}
/**
* Override User-Agent header for requests from child frames of
* whitelisted URLs to support embedded players on other origins (e.g.
* CDN domains).
*/
function onWhitelistedChildBeforeSendHeaders(
details: OnBeforeSendHeadersDetails
) {
if (!details.requestHeaders || !details.frameAncestors) {
return;
}
for (const ancestor of details.frameAncestors) {
// If no matching patterns
if (!matchPatterns.some(pattern => pattern.matches(ancestor.url))) {
continue;
}
// Override User-Agent header
const requestHeaders = details.requestHeaders;
for (const header of requestHeaders) {
if (header.name === "User-Agent") {
const host = requestHeaders.find(
header => header.name === "Host"
);
header.value = getUserAgent(details.url, host?.value);
break;
}
}
return { requestHeaders };
}
}
/**
* Handle requests to cast_sender.js SDK loader script and redirect to
* the extension's implementation.
*/
async function onBeforeCastSDKRequest(details: OnBeforeRequestDetails) {
if (!details.originUrl || details.tabId === -1) {
return {};
}
// Test against whitelist if enabled
if (await options.get("siteWhitelistEnabled")) {
/**
* Frame ancestor URLs (if present) or origin URL that the SDK
* is loaded from.
*/
const urls = [
...(details.frameAncestors?.map(ancestor => ancestor.url) ?? []),
details.originUrl
];
// Allow request if no whitelist matches
if (
!urls.some(url =>
matchPatterns.some(pattern => pattern.matches(url))
)
) {
return {};
}
}
await browser.tabs.executeScript(details.tabId, {
code: `
window.isFramework = ${
details.url === CAST_FRAMEWORK_LOADER_SCRIPT_URL
};
`,
frameId: details.frameId,
runAt: "document_start"
});
await browser.tabs.executeScript(details.tabId, {
file: "cast/contentBridge.js",
frameId: details.frameId,
runAt: "document_start"
});
return {
redirectUrl: browser.runtime.getURL("cast/content.js")
};
}
async function registerSiteWhitelist() {
const opts = await options.getAll();
siteWhitelist = opts.siteWhitelist;
siteWhitelistEnabled = opts.siteWhitelistEnabled;
// Parse match patterns once
matchPatterns = siteWhitelist.map(
item => new RemoteMatchPattern(item.pattern)
);
browser.webRequest.onBeforeRequest.addListener(
onBeforeCastSDKRequest,
{ urls: [CAST_LOADER_SCRIPT_URL, CAST_FRAMEWORK_LOADER_SCRIPT_URL] },
["blocking"]
);
// Skip whitelist request listeners if disabled or empty
if (!siteWhitelistEnabled || !siteWhitelist.length) {
return;
}
browser.webRequest.onBeforeSendHeaders.addListener(
onWhitelistedBeforeSendHeaders,
{
// Filter for items with UA enabled
urls: siteWhitelist.flatMap(item =>
item.isEnabled && !item.isUserAgentDisabled
? [item.pattern]
: []
)
},
["blocking", "requestHeaders"]
);
browser.webRequest.onBeforeSendHeaders.addListener(
onWhitelistedChildBeforeSendHeaders,
{ urls: ["<all_urls>"] },
["blocking", "requestHeaders"]
);
browser.contentScripts.register({
matches: siteWhitelist.map(item => item.pattern),
js: [{ file: "cast/contentInitial.js" }],
runAt: "document_start",
allFrames: true
});
}
function unregisterSiteWhitelist() {
browser.webRequest.onBeforeSendHeaders.removeListener(
onWhitelistedBeforeSendHeaders
);
browser.webRequest.onBeforeSendHeaders.removeListener(
onWhitelistedChildBeforeSendHeaders
);
browser.webRequest.onBeforeRequest.removeListener(onBeforeCastSDKRequest);
}