diff --git a/ext/bin/build.js b/ext/bin/build.js index 6461b93..e91d045 100644 --- a/ext/bin/build.js +++ b/ext/bin/build.js @@ -47,24 +47,6 @@ const unpackedPath = path.join(distPath, "unpacked"); const outPath = argv.package ? unpackedPath : distPath; -/** @type esbuild.Plugin */ -const preactCompatPlugin = { - /** - * Handle react/react-dom preact compat modules. - */ - name: "preact-compat", - setup(build) { - const preactPath = path.resolve( - __dirname, - "../node_modules/preact/compat/dist/compat.module.js" - ); - - build.onResolve({ filter: /^(react|react-dom)$/ }, () => ({ - path: preactPath - })); - } -}; - /** @type esbuild.BuildOptions */ const buildOpts = { bundle: true, @@ -87,7 +69,7 @@ const buildOpts = { // Mirroring sender path.join(srcPath, "/cast/senders/mirroring.ts"), // UI - path.join(srcPath, "ui/popup/index.tsx"), + path.join(srcPath, "ui/popup/index.ts"), path.join(srcPath, "ui/options/index.ts") ], define: { @@ -96,7 +78,6 @@ const buildOpts = { MIRRORING_APP_ID: `"${argv.mirroringAppId}"` }, plugins: [ - preactCompatPlugin, // @ts-ignore sveltePlugin({ // @ts-ignore @@ -107,7 +88,7 @@ const buildOpts = { copyFilesPlugin({ src: srcPath, dest: outPath, - excludePattern: /^(manifest\.json|.*\.(ts|tsx|js|jsx|svelte))$/ + excludePattern: /^(manifest\.json|.*\.(ts|js|svelte))$/ }) ] }; diff --git a/ext/package-lock.json b/ext/package-lock.json index f444e79..7cdf41e 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -6,12 +6,10 @@ "": { "devDependencies": { "@types/firefox-webext-browser": "^94.0.1", - "@types/react": "^18.0.6", "@types/react-dom": "^18.0.2", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", "esbuild": "^0.14.38", - "preact": "^10.7.1", "semver": "^7.3.7", "ts-loader": "^9.2.8", "typescript": "^4.6.3", @@ -5556,16 +5554,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/preact": { - "version": "10.7.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.7.1.tgz", - "integrity": "sha512-MufnRFz39aIhs9AMFisonjzTud1PK1bY+jcJLo6m2T9Uh8AqjD77w11eAAawmjUogoGOnipECq7e/1RClIKsxg==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11812,12 +11800,6 @@ "source-map-js": "^1.0.2" } }, - "preact": { - "version": "10.7.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.7.1.tgz", - "integrity": "sha512-MufnRFz39aIhs9AMFisonjzTud1PK1bY+jcJLo6m2T9Uh8AqjD77w11eAAawmjUogoGOnipECq7e/1RClIKsxg==", - "dev": true - }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/ext/package.json b/ext/package.json index 72b2f8d..f9b227c 100644 --- a/ext/package.json +++ b/ext/package.json @@ -8,12 +8,10 @@ }, "devDependencies": { "@types/firefox-webext-browser": "^94.0.1", - "@types/react": "^18.0.6", "@types/react-dom": "^18.0.2", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", "esbuild": "^0.14.38", - "preact": "^10.7.1", "semver": "^7.3.7", "ts-loader": "^9.2.8", "typescript": "^4.6.3", diff --git a/ext/src/cast/utils.ts b/ext/src/cast/utils.ts new file mode 100644 index 0000000..c31ba01 --- /dev/null +++ b/ext/src/cast/utils.ts @@ -0,0 +1,29 @@ +import { ReceiverDevice, ReceiverDeviceCapabilities } from "../types"; +import { Capability } from "./sdk/enums"; + +/** + * Check receiver device capabilities bitflags against array of + * capability strings requested by the sender application. + */ +export function hasRequiredCapabilities( + receiverDevice: ReceiverDevice, + requiredCapabilities: Capability[] = [] +) { + const { capabilities } = receiverDevice; + return requiredCapabilities.every(capability => { + switch (capability) { + case Capability.AUDIO_IN: + return capabilities & ReceiverDeviceCapabilities.AUDIO_IN; + case Capability.AUDIO_OUT: + return capabilities & ReceiverDeviceCapabilities.AUDIO_OUT; + case Capability.MULTIZONE_GROUP: + return ( + capabilities & ReceiverDeviceCapabilities.MULTIZONE_GROUP + ); + case Capability.VIDEO_IN: + return capabilities & ReceiverDeviceCapabilities.VIDEO_IN; + case Capability.VIDEO_OUT: + return capabilities & ReceiverDeviceCapabilities.VIDEO_OUT; + } + }); +} diff --git a/ext/src/ui/LoadingIndicator.svelte b/ext/src/ui/LoadingIndicator.svelte new file mode 100644 index 0000000..c54c405 --- /dev/null +++ b/ext/src/ui/LoadingIndicator.svelte @@ -0,0 +1,29 @@ + + +{ellipsis} diff --git a/ext/src/ui/options/Bridge.svelte b/ext/src/ui/options/Bridge.svelte index 5344606..ba09bf9 100644 --- a/ext/src/ui/options/Bridge.svelte +++ b/ext/src/ui/options/Bridge.svelte @@ -2,11 +2,12 @@ import semver from "semver"; import { onMount } from "svelte"; + import LoadingIndicator from "../LoadingIndicator.svelte"; + import bridge, { BridgeInfo, BridgeTimedOutError } from "../../lib/bridge"; import logger from "../../lib/logger"; import { Options } from "../../lib/options"; - import { getNextEllipsis } from "../utils"; const _ = browser.i18n.getMessage; @@ -68,8 +69,6 @@ let isCheckingUpdate = false; let isUpdateAvailable = false; - let checkUpdateEllipsis = ""; - interface GitHubRelease { url: string; tag_name: string; @@ -83,10 +82,6 @@ async function checkUpdate() { isCheckingUpdate = true; - const checkUpdateTimeout = window.setInterval(() => { - checkUpdateEllipsis = getNextEllipsis(checkUpdateEllipsis); - }, 500); - let releases: GitHubRelease[]; try { releases = await fetch( @@ -96,8 +91,6 @@ isCheckingUpdate = false; updateStatus = _("optionsBridgeUpdateStatusError"); return; - } finally { - window.clearTimeout(checkUpdateTimeout); } // Ensure valid response @@ -278,7 +271,8 @@ on:click={checkUpdate} > {#if isCheckingUpdate} - {_("optionsBridgeUpdateChecking", checkUpdateEllipsis)} + {_("optionsBridgeUpdateChecking", "")} {:else} {_("optionsBridgeUpdateCheck")} {/if} diff --git a/ext/src/ui/popup/Popup.svelte b/ext/src/ui/popup/Popup.svelte new file mode 100644 index 0000000..cba7a78 --- /dev/null +++ b/ext/src/ui/popup/Popup.svelte @@ -0,0 +1,451 @@ + + + + +
+
+ {_("popupMediaSelectCastLabel")} +
+
+ +
+
+ {_("popupMediaSelectToLabel")} +
+
+ + diff --git a/ext/src/ui/popup/index.ts b/ext/src/ui/popup/index.ts new file mode 100644 index 0000000..f153280 --- /dev/null +++ b/ext/src/ui/popup/index.ts @@ -0,0 +1,16 @@ +import Popup from "./Popup.svelte"; + +// macOS styles +browser.runtime.getPlatformInfo().then(platformInfo => { + if (platformInfo.os === "mac") { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "styles/mac.css"; + document.head.appendChild(link); + } +}); + +const target = document.getElementById("root"); +if (target) { + new Popup({ target }); +} diff --git a/ext/src/ui/popup/index.tsx b/ext/src/ui/popup/index.tsx deleted file mode 100755 index 8e03251..0000000 --- a/ext/src/ui/popup/index.tsx +++ /dev/null @@ -1,639 +0,0 @@ -/* eslint-disable max-len */ -"use strict"; - -import React, { Component } from "react"; -import ReactDOM from "react-dom"; - -import { menuIdPopupCast, menuIdPopupStop } from "../../background/menus"; -import type { ReceiverSelectorPageInfo } from "../../background/ReceiverSelector"; -import type { WhitelistItemData } from "../../background/whitelist"; - -import knownApps, { KnownApp } from "../../cast/knownApps"; -import options from "../../lib/options"; - -import messaging, { Message, Port } from "../../messaging"; -import { getNextEllipsis } from "../utils"; -import { RemoteMatchPattern } from "../../lib/matchPattern"; - -import { - ReceiverDevice, - ReceiverDeviceCapabilities, - ReceiverSelectionActionType, - ReceiverSelectorMediaType -} from "../../types"; - -import { Capability } from "../../cast/sdk/enums"; - -const _ = browser.i18n.getMessage; - -// macOS styles -browser.runtime.getPlatformInfo().then(platformInfo => { - if (platformInfo.os === "mac") { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = "styles/mac.css"; - document.head.appendChild(link); - } -}); - -/** - * Check receiver device capabilities bitflags against array of - * capability strings requested by the sender application. - */ -function hasRequiredCapabilities( - receiverDevice: ReceiverDevice, - capabilities: Capability[] = [] -) { - const { capabilities: deviceCapabilities } = receiverDevice; - return capabilities.every(capability => { - switch (capability) { - case Capability.AUDIO_IN: - return deviceCapabilities & ReceiverDeviceCapabilities.AUDIO_IN; - case Capability.AUDIO_OUT: - return ( - deviceCapabilities & ReceiverDeviceCapabilities.AUDIO_OUT - ); - case Capability.MULTIZONE_GROUP: - return ( - deviceCapabilities & - ReceiverDeviceCapabilities.MULTIZONE_GROUP - ); - case Capability.VIDEO_IN: - return deviceCapabilities & ReceiverDeviceCapabilities.VIDEO_IN; - case Capability.VIDEO_OUT: - return ( - deviceCapabilities & ReceiverDeviceCapabilities.VIDEO_OUT - ); - } - }); -} - -interface PopupAppProps {} -interface PopupAppState { - /** List of devices to show in receiver list. */ - receiverDevices: ReceiverDevice[]; - - /** Currently selected media type. */ - mediaType: ReceiverSelectorMediaType; - /** Media types available to select. */ - availableMediaTypes: ReceiverSelectorMediaType; - - /** Sender app ID (if available). */ - appId?: string; - /** Page info (if launched from page context). */ - pageInfo?: ReceiverSelectorPageInfo; - - /** App details (if matches known app). */ - knownApp?: KnownApp; - /** Whether current page URL matches a whitelist pattern. */ - isPageWhitelisted: boolean; - - /** Whether casting to a device been initiated from this selector. */ - isConnecting: boolean; - - // Options - mirroringEnabled: boolean; - siteWhitelistEnabled: boolean; - siteWhitelist: WhitelistItemData[]; -} - -class PopupApp extends Component { - private port?: Port; - private browserWindow?: browser.windows.Window; - - private resizeObserver = new ResizeObserver(() => { - this.fitWindowHeight(); - }); - - constructor(props: PopupAppProps) { - super(props); - - this.state = { - receiverDevices: [], - mediaType: ReceiverSelectorMediaType.App, - availableMediaTypes: ReceiverSelectorMediaType.App, - isPageWhitelisted: false, - isConnecting: false, - mirroringEnabled: false, - siteWhitelistEnabled: true, - siteWhitelist: [] - }; - - // Store window ref - browser.windows.getCurrent().then(win => { - this.browserWindow = win; - }); - - this.onMessage = this.onMessage.bind(this); - this.onAddToWhitelist = this.onAddToWhitelist.bind(this); - this.onReceiverCast = this.onReceiverCast.bind(this); - this.onReceiverStop = this.onReceiverStop.bind(this); - - this.onContextMenu = this.onContextMenu.bind(this); - this.onMenuShown = this.onMenuShown.bind(this); - this.onMenuClicked = this.onMenuClicked.bind(this); - } - - private onMessage(message: Message) { - switch (message.subject) { - case "popup:init": - this.setState({ - appId: message.data?.appId, - pageInfo: message.data?.pageInfo - }); - break; - case "popup:close": - window.close(); - break; - - case "popup:update": { - this.setState({ - /** - * Filter receiver devices without the required - * capabilities. - */ - receiverDevices: message.data.receiverDevices.filter( - receiverDevice => { - return hasRequiredCapabilities( - receiverDevice, - this.state.pageInfo?.sessionRequest - ?.capabilities - ); - } - ) - }); - - const { availableMediaTypes, defaultMediaType } = message.data; - if ( - availableMediaTypes !== undefined && - defaultMediaType !== undefined - ) { - this.setState({ - availableMediaTypes, - mediaType: defaultMediaType - }); - } - - this.updateKnownApp(); - break; - } - } - } - - /** Resize browser window to fit content height. */ - private fitWindowHeight() { - if (this.browserWindow?.id === undefined) { - return; - } - - browser.windows.update(this.browserWindow.id, { - height: - document.body.clientHeight + - (window.outerHeight - window.innerHeight) - }); - } - - private updateKnownApp() { - const isAppMediaTypeAvailable = !!( - this.state.availableMediaTypes & ReceiverSelectorMediaType.App - ); - - let knownApp: KnownApp | undefined; - - /** - * Check knownApps for an app with an ID matching the registered - * app on the target page. - */ - if (isAppMediaTypeAvailable && this.state.appId) { - knownApp = knownApps[this.state.appId]; - } else if (this.state.pageInfo) { - const pageUrl = this.state.pageInfo.url; - - /** - * Or if there isn't an registered app, check for an app - * with a match pattern matching the target page URL. - */ - for (const [, app] of Object.entries(knownApps)) { - if (!app.matches) { - continue; - } - - const pattern = new RemoteMatchPattern(app.matches); - if (pattern.matches(pageUrl)) { - knownApp = app; - break; - } - } - } - - let isPageWhitelisted = false; - - // Check if target page URL is whitelisted. - if (this.state.pageInfo) { - for (const item of this.state.siteWhitelist) { - const pattern = new RemoteMatchPattern(item.pattern); - if (pattern.matches(this.state.pageInfo.url)) { - isPageWhitelisted = true; - break; - } - } - } - - this.setState({ knownApp, isPageWhitelisted }); - } - - private async onAddToWhitelist( - app: KnownApp, - pageInfo: ReceiverSelectorPageInfo - ) { - if (!app.matches) { - return; - } - - const whitelist = await options.get("siteWhitelist"); - if (!whitelist.find(item => item.pattern === app.matches)) { - whitelist.push({ pattern: app.matches }); - await options.set("siteWhitelist", whitelist); - - await browser.tabs.reload(pageInfo.tabId); - window.close(); - } - } - - private onReceiverCast(receiverDevice: ReceiverDevice) { - this.setState({ isConnecting: true }); - - this.port?.postMessage({ - subject: "receiverSelector:selected", - data: { - receiverDevice, - actionType: ReceiverSelectionActionType.Cast, - mediaType: this.state.mediaType - } - }); - } - - private onReceiverStop(receiverDevice: ReceiverDevice) { - this.port?.postMessage({ - subject: "receiverSelector:stop", - data: { - receiverDevice, - actionType: ReceiverSelectionActionType.Stop - } - }); - } - - private onContextMenu(ev: MouseEvent) { - if (!(ev.target instanceof Element)) return; - - const receiverElement = ev.target.closest(".receiver"); - if (receiverElement) { - browser.menus.overrideContext({ - showDefaults: false - }); - } - } - - private getDeviceFromElement(target: Element) { - const receiverElement = target.closest(".receiver"); - if (!receiverElement) return; - - const receiverElementIndex = [ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...receiverElement.parentElement!.children - ].indexOf(receiverElement); - - // Match by index rendered receiver element to device array - if (receiverElementIndex > -1) { - return this.state.receiverDevices[receiverElementIndex]; - } - } - - /** Handle show events for receiver context menus. */ - private onMenuShown(info: browser.menus._OnShownInfo) { - if (!info.targetElementId) return; - const target = browser.menus.getTargetElement(info.targetElementId); - if (!target) return; - - const device = this.getDeviceFromElement(target); - if (!device) { - browser.menus.update(menuIdPopupCast, { visible: false }); - browser.menus.update(menuIdPopupStop, { visible: false }); - } else { - const app = device.status?.applications?.[0]; - const isAppRunning = !!(app && !app.isIdleScreen); - - browser.menus.update(menuIdPopupCast, { - visible: true, - title: _("popupCastMenuTitle", device.friendlyName), - enabled: - // Not already connecting to a receiver - !this.state.isConnecting && - // Selected media type available - !!(this.state.availableMediaTypes & this.state.mediaType) - }); - - browser.menus.update(menuIdPopupStop, { - visible: isAppRunning, - title: isAppRunning - ? _("popupStopMenuTitle", [ - app.displayName, - device.friendlyName - ]) - : "" - }); - } - - browser.menus.refresh(); - } - - /** Handle click events for receiver context menus. */ - private onMenuClicked(info: browser.menus.OnClickData) { - if ( - info.menuItemId !== menuIdPopupCast && - info.menuItemId !== menuIdPopupStop - ) { - return; - } - - if (!info.targetElementId) return; - const target = browser.menus.getTargetElement(info.targetElementId); - if (!target) return; - - const device = this.getDeviceFromElement(target); - if (!device) return; - - switch (info.menuItemId) { - case menuIdPopupCast: - this.onReceiverCast(device); - break; - case menuIdPopupStop: - this.onReceiverStop(device); - break; - } - } - - public async componentDidMount() { - this.port = messaging.connect({ name: "popup" }); - this.port.onMessage.addListener(this.onMessage); - - // Start observing content size changes - this.resizeObserver.observe(document.body); - - options.getAll().then(opts => { - this.setState({ - mirroringEnabled: opts.mirroringEnabled, - siteWhitelistEnabled: opts.siteWhitelistEnabled, - siteWhitelist: opts.siteWhitelist - }); - }); - - this.updateKnownApp(); - - window.addEventListener("contextmenu", this.onContextMenu); - browser.menus.onClicked.addListener(this.onMenuClicked); - browser.menus.onShown.addListener(this.onMenuShown); - } - - public componentWillUnmount() { - this.port?.disconnect(); - this.resizeObserver.disconnect(); - - window.removeEventListener("contextmenu", this.onContextMenu); - browser.menus.onClicked.removeListener(this.onMenuClicked); - browser.menus.onShown.removeListener(this.onMenuShown); - } - - public componentDidUpdate() { - setTimeout(() => { - this.fitWindowHeight(); - }, 1); - } - - public render() { - const isAppMediaTypeSelected = - this.state.mediaType === ReceiverSelectorMediaType.App; - const isTabMediaTypeSelected = - this.state.mediaType === ReceiverSelectorMediaType.Tab; - const isScreenMediaTypeSelected = - this.state.mediaType === ReceiverSelectorMediaType.Screen; - - const isAppMediaTypeAvailable = !!( - this.state.availableMediaTypes & ReceiverSelectorMediaType.App - ); - - return ( - <> - - -
-
- {_("popupMediaSelectCastLabel")} -
-
- -
-
- {_("popupMediaSelectToLabel")} -
-
- - - - ); - } -} - -interface ReceiverProps { - details: ReceiverDevice; - isMediaTypeAvailable: boolean; - isAnyConnecting: boolean; - - // Events - onCast(receiverDevice: ReceiverDevice): void; - onStop(receiverDevice: ReceiverDevice): void; -} -interface ReceiverState { - isConnecting: boolean; - connectingEllipsis: string; -} -class Receiver extends Component { - private ellipsisInterval?: number; - - constructor(props: ReceiverProps) { - super(props); - - this.state = { - isConnecting: false, - connectingEllipsis: "" - }; - - this.handleCast = this.handleCast.bind(this); - } - - private handleCast() { - if (!this.props.details.status) { - return; - } - - this.ellipsisInterval = window.setInterval(() => { - this.setState(state => ({ - connectingEllipsis: getNextEllipsis(state.connectingEllipsis) - })); - }, 500); - - this.setState({ isConnecting: true }); - this.props.onCast(this.props.details); - } - - componentWillUnmount() { - window.clearInterval(this.ellipsisInterval); - } - - render() { - const application = this.props.details.status?.applications?.[0]; - - return ( -
  • -
    - {this.props.details.friendlyName} -
    -
    - {application && !application.isIdleScreen - ? application.statusText - : `${this.props.details.host}:${this.props.details.port}`} -
    - -
  • - ); - } -} - -// Render after CSS has loaded -window.addEventListener("load", () => { - ReactDOM.render(, document.querySelector("#root")); -}); diff --git a/ext/src/ui/utils.tsx b/ext/src/ui/utils.tsx deleted file mode 100644 index 153ed13..0000000 --- a/ext/src/ui/utils.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use strict"; - -import React, { useEffect, useState } from "react"; - -export function getNextEllipsis(ellipsis: string): string { - if (ellipsis === "") return "."; - if (ellipsis === ".") return ".."; - if (ellipsis === "..") return "..."; - if (ellipsis === "...") return ""; - - return ""; -} - -export interface LoadingIndicatorProps { - text: string; - duration?: number; -} -export function LoadingIndicator(props: LoadingIndicatorProps) { - const [ellipsis, setEllipsis] = useState(""); - - useEffect(() => { - const interval = window.setInterval(() => { - setEllipsis(prev => getNextEllipsis(prev)); - }, props.duration ?? 500); - - return () => { - window.clearInterval(interval); - }; - }, []); - - return ( -
    - {props.text} - {ellipsis} -
    - ); -} diff --git a/ext/tsconfig.json b/ext/tsconfig.json index da4ad81..220c4d5 100644 --- a/ext/tsconfig.json +++ b/ext/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../tsconfig", "compilerOptions": { - "jsx": "react", "lib": ["ESNext", "DOM", "DOM.Iterable"], "moduleResolution": "node", "sourceMap": true,