diff --git a/extensions/src/hello-someone/hello-someone.ts b/extensions/src/hello-someone/hello-someone.ts index b6f799f0d1..8ac4c5d7ef 100644 --- a/extensions/src/hello-someone/hello-someone.ts +++ b/extensions/src/hello-someone/hello-someone.ts @@ -4,7 +4,7 @@ import type { WebViewContentType, WebViewDefinition, SavedWebViewDefinition, -} from 'shared/data/web-view.model'; +} from 'shared/models/web-view.model'; import type { PeopleData, PeopleDataMethods, PeopleDataTypes, Person } from 'hello-someone'; import type { DataProviderUpdateInstructions } from 'shared/models/data-provider.model'; import type IDataProviderEngine from 'shared/models/data-provider-engine.model'; diff --git a/extensions/src/hello-world/hello-world.ts b/extensions/src/hello-world/hello-world.ts index 49d0a99e0c..42e7070b5a 100644 --- a/extensions/src/hello-world/hello-world.ts +++ b/extensions/src/hello-world/hello-world.ts @@ -4,7 +4,7 @@ import type { WebViewContentType, WebViewDefinition, SavedWebViewDefinition, -} from 'shared/data/web-view.model'; +} from 'shared/models/web-view.model'; import type { IWebViewProvider } from 'shared/models/web-view-provider.model'; import type PapiEventEmitter from 'shared/models/papi-event-emitter.model'; import type { HelloWorldEvent } from 'hello-world'; diff --git a/extensions/src/hello-world/web-views/hello-world.web-view.tsx b/extensions/src/hello-world/web-views/hello-world.web-view.tsx index a22afed393..5bd0eaa6d8 100644 --- a/extensions/src/hello-world/web-views/hello-world.web-view.tsx +++ b/extensions/src/hello-world/web-views/hello-world.web-view.tsx @@ -23,7 +23,7 @@ import { import { Key, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { HelloWorldEvent } from 'hello-world'; import type { DialogTypes } from 'renderer/components/dialogs/dialog-definition.model'; -import type { WebViewProps } from 'shared/data/web-view.model'; +import type { WebViewProps } from 'shared/models/web-view.model'; import Clock from './components/clock.component'; import Logo from '../assets/offline.svg'; diff --git a/extensions/src/resource-viewer/resource-viewer.ts b/extensions/src/resource-viewer/resource-viewer.ts index e37b611c3d..84fee4d312 100644 --- a/extensions/src/resource-viewer/resource-viewer.ts +++ b/extensions/src/resource-viewer/resource-viewer.ts @@ -5,7 +5,7 @@ import type { GetWebViewOptions, SavedWebViewDefinition, WebViewDefinition, -} from 'shared/data/web-view.model'; +} from 'shared/models/web-view.model'; import type { ExecutionActivationContext } from 'extension-host/extension-types/extension-activation-context.model'; import resourceViewerWebView from './resource-viewer.web-view?inline'; import resourceViewerWebViewStyles from './resource-viewer.web-view.scss?inline'; diff --git a/extensions/src/resource-viewer/resource-viewer.web-view.tsx b/extensions/src/resource-viewer/resource-viewer.web-view.tsx index b938fcadad..b9d90952a8 100644 --- a/extensions/src/resource-viewer/resource-viewer.web-view.tsx +++ b/extensions/src/resource-viewer/resource-viewer.web-view.tsx @@ -4,7 +4,7 @@ import { useProjectData, useSetting } from 'papi-frontend/react'; import { ScriptureReference } from 'papi-components'; import { JSX, useMemo } from 'react'; import UsxEditor from 'usxeditor'; -import type { WebViewProps } from 'shared/data/web-view.model'; +import type { WebViewProps } from 'shared/models/web-view.model'; /** Characteristics of a marker style */ interface StyleInfo { diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 4d5b793c90..22fa7ebbae 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -1,63 +1,8 @@ /// /// /// -declare module 'shared/data/web-view.model' { - import { Dispatch, ReactNode, SetStateAction } from 'react'; - /** - * Saved information used to recreate a tab. - * - * - {@link TabLoader} loads this into {@link TabInfo} - * - {@link TabSaver} saves {@link TabInfo} into this - */ - export type SavedTabInfo = { - /** - * Tab ID - a unique identifier that identifies this tab. If this tab is a WebView, this ID will - * match the `WebViewDefinition.id` - */ - id: string; - /** Type of tab - indicates what kind of built-in tab this info represents */ - tabType: string; - /** Data needed to load the tab */ - data?: unknown; - }; - /** - * Information that Paranext uses to create a tab in the dock layout. - * - * - {@link TabLoader} loads {@link SavedTabInfo} into this - * - {@link TabSaver} saves this into {@link SavedTabInfo} - */ - export type TabInfo = SavedTabInfo & { - /** - * Url of image to show on the title bar of the tab - * - * Defaults to Platform.Bible logo - */ - tabIconUrl?: string; - /** Text to show on the title bar of the tab */ - tabTitle: string; - /** Content to show inside the tab. */ - content: ReactNode; - /** (optional) Minimum width that the tab can become in CSS `px` units */ - minWidth?: number; - /** (optional) Minimum height that the tab can become in CSS `px` units */ - minHeight?: number; - }; - /** - * Function that takes a {@link SavedTabInfo} and creates a Paranext tab out of it. Each type of tab - * must provide a {@link TabLoader}. - * - * For now all tab creators must do their own data type verification - */ - export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; - /** - * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can - * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are - * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). - * - * @param tabInfo The Paranext tab to save - * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab - */ - export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; +declare module 'shared/models/web-view.model' { + import { Dispatch, SetStateAction } from 'react'; /** The type of code that defines a webview's content */ export enum WebViewContentType { /** @@ -214,8 +159,6 @@ declare module 'shared/data/web-view.model' { | Partial> ) & Pick; - /** Props that are passed to the web view tab component */ - export type WebViewTabProps = WebViewDefinition; /** The properties on a WebViewDefinition that may be updated when that webview is already displayed */ export type WebViewDefinitionUpdatableProperties = Pick< WebViewDefinitionBase, @@ -338,55 +281,6 @@ declare module 'shared/data/web-view.model' { */ updateWebViewDefinition: UpdateWebViewDefinition; }; - /** Information about a tab in a panel */ - interface TabLayout { - type: 'tab'; - } - /** - * Indicates where to display a floating window - * - * - `cascade` - place the window a bit below and to the right of the previously created floating - * window - * - `center` - center the window in the dock layout - */ - type FloatPosition = 'cascade' | 'center'; - /** The dimensions for a floating tab in CSS `px` units */ - export type FloatSize = { - width: number; - height: number; - }; - /** Information about a floating window */ - export interface FloatLayout { - type: 'float'; - floatSize?: FloatSize; - /** Where to display the floating window. Defaults to `cascade` */ - position?: FloatPosition; - } - export type PanelDirection = - | 'left' - | 'right' - | 'bottom' - | 'top' - | 'before-tab' - | 'after-tab' - | 'maximize' - | 'move' - | 'active' - | 'update'; - /** Information about a panel */ - interface PanelLayout { - type: 'panel'; - direction?: PanelDirection; - /** If undefined, it will add in the `direction` relative to the previously added tab. */ - targetTabId?: string; - } - /** Information about how a Paranext tab fits into the dock layout */ - export type Layout = TabLayout | FloatLayout | PanelLayout; - /** Event emitted when webViews are created */ - export type AddWebViewEvent = { - webView: SavedWebViewDefinition; - layout: Layout; - }; /** Options that affect what `webViews.getWebView` does */ export type GetWebViewOptions = { /** @@ -422,7 +316,7 @@ declare module 'shared/global-this.model' { WebViewDefinitionUpdatableProperties, WebViewDefinitionUpdateInfo, WebViewProps, - } from 'shared/data/web-view.model'; + } from 'shared/models/web-view.model'; /** * Variables that are defined in global scope. These must be defined in main.ts (main), index.ts * (renderer), and extension-host.ts (extension host) @@ -2641,166 +2535,128 @@ declare module 'shared/services/command.service' { */ export type moduleSummaryComments = {}; } -declare module 'shared/models/web-view-provider.model' { +declare module 'shared/models/docking-framework.model' { + import { MutableRefObject, ReactNode } from 'react'; + import { DockLayout, DropDirection, LayoutBase } from 'rc-dock'; import { - GetWebViewOptions, - WebViewDefinition, SavedWebViewDefinition, - } from 'shared/data/web-view.model'; - import { - DisposableNetworkObject, - NetworkObject, - NetworkableObject, - } from 'shared/models/network-object.model'; - import { CanHaveOnDidDispose } from 'shared/models/disposal.model'; - export interface IWebViewProvider extends NetworkableObject { - /** - * @param savedWebView Filled out if an existing webview is being called for (matched by ID). Just - * ID if this is a new request or if the web view with the existing ID was not found - * @param getWebViewOptions - */ - getWebView( - savedWebView: SavedWebViewDefinition, - getWebViewOptions: GetWebViewOptions, - ): Promise; - } - export interface WebViewProvider - extends NetworkObject, - CanHaveOnDidDispose {} - export interface DisposableWebViewProvider - extends DisposableNetworkObject, - Omit {} -} -declare module 'shared/services/web-view-provider.service' { - /** - * Handles registering web view providers and serving web views around the papi. Exposed on the - * papi. - */ - import { - DisposableWebViewProvider, - IWebViewProvider, - WebViewProvider, - } from 'shared/models/web-view-provider.model'; - /** Sets up the service. Only runs once and always returns the same promise after that */ - const initialize: () => Promise; + WebViewDefinition, + WebViewDefinitionUpdateInfo, + } from 'shared/models/web-view.model'; /** - * Indicate if we are aware of an existing web view provider with the given type. If a web view - * provider with the given type is somewhere else on the network, this function won't tell you about - * it unless something else in the existing process is subscribed to it. + * Saved information used to recreate a tab. * - * @param webViewType Type of webView to check for + * - {@link TabLoader} loads this into {@link TabInfo} + * - {@link TabSaver} saves {@link TabInfo} into this */ - function hasKnown(webViewType: string): boolean; + export type SavedTabInfo = { + /** + * Tab ID - a unique identifier that identifies this tab. If this tab is a WebView, this ID will + * match the `WebViewDefinition.id` + */ + id: string; + /** Type of tab - indicates what kind of built-in tab this info represents */ + tabType: string; + /** Data needed to load the tab */ + data?: unknown; + }; /** - * Register a web view provider to serve webViews for a specified type of webViews - * - * @param webViewType Type of web view to provide - * @param webViewProvider Object to register as a webView provider including control over disposing - * of it. + * Information that Paranext uses to create a tab in the dock layout. * - * WARNING: setting a webView provider mutates the provided object. - * @returns `webViewProvider` modified to be a network object + * - {@link TabLoader} loads {@link SavedTabInfo} into this + * - {@link TabSaver} saves this into {@link SavedTabInfo} */ - function register( - webViewType: string, - webViewProvider: IWebViewProvider, - ): Promise; + export type TabInfo = SavedTabInfo & { + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + tabIconUrl?: string; + /** Text to show on the title bar of the tab */ + tabTitle: string; + /** Content to show inside the tab. */ + content: ReactNode; + /** (optional) Minimum width that the tab can become in CSS `px` units */ + minWidth?: number; + /** (optional) Minimum height that the tab can become in CSS `px` units */ + minHeight?: number; + }; /** - * Get a web view provider that has previously been set up + * Function that takes a {@link SavedTabInfo} and creates a Paranext tab out of it. Each type of tab + * must provide a {@link TabLoader}. * - * @param webViewType Type of webview provider to get - * @returns Web view provider with the given name if one exists, undefined otherwise + * For now all tab creators must do their own data type verification */ - function get(webViewType: string): Promise; - export interface WebViewProviderService { - initialize: typeof initialize; - hasKnown: typeof hasKnown; - register: typeof register; - get: typeof get; - } - export interface PapiWebViewProviderService { - register: typeof register; - } - const webViewProviderService: WebViewProviderService; + export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; /** + * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can + * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are + * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). * - * Interface for registering webView providers + * @param tabInfo The Paranext tab to save + * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab */ - export const papiWebViewProviderService: PapiWebViewProviderService; - export default webViewProviderService; -} -declare module 'shared/log-error.model' { - /** Error that force logs the error message before throwing. Useful for debugging in some situations. */ - export default class LogError extends Error { - constructor(message?: string); + export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; + /** Information about a tab in a panel */ + interface TabLayout { + type: 'tab'; } -} -declare module 'renderer/services/web-view-state.service' { - /** - * Get the web view state associated with the given ID This function is only intended to be used at - * startup. getWebViewState is intended for web views to call. - * - * @param id ID of the web view - * @returns State object of the given web view - */ - export function getFullWebViewStateById(id: string): Record; /** - * Set the web view state associated with the given ID This function is only intended to be used at - * startup. setWebViewState is intended for web views to call. - * - * @param id ID of the web view - * @param state State to set for the given web view - */ - export function setFullWebViewStateById(id: string, state: Record): void; - /** - * Get the web view state associated with the given ID - * - * @param id ID of the web view - * @param stateKey Key used to retrieve the state value - * @returns String (if it exists) containing the state for the given key of the given web view - */ - export function getWebViewStateById(id: string, stateKey: string): T | undefined; - /** - * Set the web view state object associated with the given ID + * Indicates where to display a floating window * - * @param id ID of the web view - * @param stateKey Key for the associated state - * @param stateValue Value of the state for the given key of the given web view - must work with - * JSON.stringify/parse - */ - export function setWebViewStateById(id: string, stateKey: string, stateValue: T): void; - /** - * Purge any web view state that hasn't been touched since the process has been running. Only call - * this once all web views have been loaded. + * - `cascade` - place the window a bit below and to the right of the previously created floating + * window + * - `center` - center the window in the dock layout */ - export function cleanupOldWebViewState(): void; -} -declare module 'shared/services/web-view.service' { - import { Unsubscriber } from 'shared/utils/papi-util'; - import { MutableRefObject } from 'react'; - import { - AddWebViewEvent, - Layout, - SavedTabInfo, - TabInfo, - WebViewTabProps, - WebViewType, - WebViewId, - GetWebViewOptions, - WebViewDefinition, - SavedWebViewDefinition, - WebViewDefinitionUpdateInfo, - WebViewDefinitionUpdatableProperties, - } from 'shared/data/web-view.model'; - import { DockLayout, DropDirection, LayoutBase } from 'rc-dock'; + type FloatPosition = 'cascade' | 'center'; + /** The dimensions for a floating tab in CSS `px` units */ + export type FloatSize = { + width: number; + height: number; + }; + /** Information about a floating window */ + export interface FloatLayout { + type: 'float'; + floatSize?: FloatSize; + /** Where to display the floating window. Defaults to `cascade` */ + position?: FloatPosition; + } + export type PanelDirection = + | 'left' + | 'right' + | 'bottom' + | 'top' + | 'before-tab' + | 'after-tab' + | 'maximize' + | 'move' + | 'active' + | 'update'; + /** Information about a panel */ + interface PanelLayout { + type: 'panel'; + direction?: PanelDirection; + /** If undefined, it will add in the `direction` relative to the previously added tab. */ + targetTabId?: string; + } + /** Information about how a Paranext tab fits into the dock layout */ + export type Layout = TabLayout | FloatLayout | PanelLayout; + /** Event emitted when webViews are created */ + export type AddWebViewEvent = { + webView: SavedWebViewDefinition; + layout: Layout; + }; + /** Props that are passed to the web view tab component */ + export type WebViewTabProps = WebViewDefinition; /** Rc-dock's onLayoutChange prop made asynchronous - resolves */ export type OnLayoutChangeRCDock = ( newLayout: LayoutBase, currentTabId?: string, direction?: DropDirection, ) => Promise; - /** Properties related to the dock layout provided by `platform-dock-layout.component.tsx` */ - type PapiDockLayout = { + /** Properties related to the dock layout */ + export type PapiDockLayout = { /** The rc-dock dock layout React element ref. Used to perform operations on the layout */ dockLayout: DockLayout; /** @@ -2861,258 +2717,11 @@ declare module 'shared/services/web-view.service' { */ testLayout: LayoutBase; }; - /** - * The iframe [sandbox attribute] - * (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if - * scripts are allowed to run on an iframe - */ - export const IFRAME_SANDBOX_ALLOW_SCRIPTS = 'allow-scripts'; - /** - * The iframe [sandbox attribute] - * (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if an - * iframe is allowed to interact with its parent as a same-origin website. The iframe must still be - * on the same origin as its parent in order to interact same-origin. - */ - export const IFRAME_SANDBOX_ALLOW_SAME_ORIGIN = 'allow-same-origin'; - /** - * The only `sandbox` attribute values we allow iframes with `src` to have including URL WebView - * iframes. These are separate than iframes with `srcdoc` for a few reasons: - * - * - These iframes cannot be on the same origin as the parent window even if `allow-same-origin` is - * present (unless they are literally on the same origin) because we do not allow `frame-src - * blob:` - * - `src` iframes do not inherit the CSP of their parent window. - * - We are not able to modify the `srcdoc` before inserting it to ensure it has a CSP that we control - * to attempt to prevent arbitrary code execution on same origin. We are trusting the browser's - * ability to create a strong and safe boundary between parent and child iframe in different - * origin. - * - * TODO: consider using `csp` attribute on iframe to mitigate this issue - * - Extension developers do not know what code they are executing if they use some random URL in - * `src` WebViews. - * - * The `sandbox` attribute controls what privileges iframe scripts and other things have: - * - * - `allow-same-origin` so the iframe can access the storage APIs (localstorage, cookies, etc) and - * other same-origin connections for its own origin. `blob:` iframes are considered part of the - * parent origin, but we block them with the CSP in `index.ejs`. For more information, see - * https://web.dev/articles/sandboxed-iframes - * - `allow-scripts` so the iframe can actually do things. Defaults to not present since src iframes - * can get scripts from anywhere. Extension developers should only enable this if needed as this - * increases the possibility of a security threat occurring. Defaults to false - * - * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON - * - * Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts' - * allows the child scripts to remove this sandbox attribute from the iframe. This should only be - * possible on iframes that are on the same origin as the parent including those that use `srcdoc` - * to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from - * creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any - * iframes that do not comply with these sandbox requirements. This successfully prevents iframes - * with too many privileges from executing as of July 2023. However, this means the sandboxing could - * do nothing for a determined hacker if they ever find a way around all this. We must distrust the - * whole renderer due to this issue. We will probably want to stay vigilant on security in this - * area. - */ - export const ALLOWED_IFRAME_SRC_SANDBOX_VALUES: string[]; - /** - * The minimal `src` WebView iframe sandboxing. This is applied to WebView iframes that use `src` in - * `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRC_SANDBOX_VALUES} for more information on - * our sandboxing methods and why we chose these values. - * - * Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added - * conditionally depending on the WebViewDefinition in `web-view.component.tsx` - */ - export const WEBVIEW_IFRAME_SRC_SANDBOX: string; - /** - * The only `sandbox` attribute values we allow iframes with `srcdoc` to have including HTML and - * React WebView iframes. These are separate than iframes with `src` for a few reasons: - * - * - These iframes will be on the same origin as the parent window if `allow-same-origin` is present. - * This is very serious and demands significant security risk consideration. - * - `srcdoc` iframes inherit the CSP of their parent window (in our case, `index.ejs`) - * - We are modifying the `srcdoc` before inserting it to ensure it has a CSP that we control to - * attempt to prevent unintended code execution on same origin - * - Extension developers should know exactly what code they're running in `srcdoc` WebViews, whereas - * they could include some random URL in `src` WebViews - * - * TODO: consider requiring `srcdoc` WebView content to come directly from `papi-extension://` - * instead of assuming extension developers will bundle their WebView code? This would mean the - * only code that runs on same origin is code that extension developers definitely included in - * their extension bundle https://github.com/paranext/paranext-core/issues/604 - * - * The `sandbox` attribute controls what privileges iframe scripts and other things have: - * - * - `allow-same-origin` so the iframe can get papi and communicate and such - * - `allow-scripts` so the iframe can actually do things - * - * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON - * - * Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts' - * allows the child scripts to remove this sandbox attribute from the iframe. This should only be - * possible on iframes that are on the same origin as the parent including those that use `srcdoc` - * to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from - * creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any - * iframes that do not comply with these sandbox requirements. This successfully prevents iframes - * with too many privileges from executing as of July 2023. However, this means the sandboxing could - * do nothing for a determined hacker if they ever find a way around all this. We must distrust the - * whole renderer due to this issue. We will probably want to stay vigilant on security in this - * area. - */ - export const ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES: string[]; - /** - * The minimal `srcdoc` WebView iframe sandboxing. This is applied to WebView iframes that use - * `srcDoc` in `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES} for more - * information on our sandboxing methods and why we chose these values. - * - * Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added - * conditionally depending on the WebViewDefinition in `web-view.component.tsx` - */ - export const WEBVIEW_IFRAME_SRCDOC_SANDBOX: string; - /** Event that emits with webView info when a webView is added */ - export const onDidAddWebView: import('shared/models/papi-event.model').PapiEvent; - /** - * Basic `saveTabInfo` that simply strips the properties added by {@link TabInfo} off of the object - * and returns it as a {@link SavedTabInfo}. Runs as the {@link TabSaver} by default if the tab type - * does not have a specific `TabSaver` - */ - export function saveTabInfoBase(tabInfo: TabInfo): SavedTabInfo; - /** - * Converts web view definition used in an actual docking tab into saveable web view information by - * stripping out the members we don't want to save - * - * @param webViewDefinition Web view to save - * @returns Saveable web view information based on `webViewDefinition` - */ - export function convertWebViewDefinitionToSaved( - webViewDefinition: WebViewDefinition, - ): SavedWebViewDefinition; - /** - * Register a dock layout React element to be used by this service to perform layout-related - * operations - * - * @param dockLayout Dock layout element to register along with other important properties - * @returns Function used to unregister this dock layout - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ - export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber; - /** - * Remove a tab in the layout - * - * @param tabId ID of the tab to remove - * @returns True if successfully found the tab to remove - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ - export const removeTab: (tabId: string) => Promise; - /** - * Add or update a tab in the layout - * - * @param savedTabInfo Info for tab to add or update - * @param layout Information about where to put a new tab - * @returns If tab added, final layout used to display the new tab. If existing tab updated, - * `undefined` - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ - export const addTab: ( - savedTabInfo: SavedTabInfo & { - data?: TData | undefined; - }, - layout: Layout, - ) => Promise; - /** - * Get just the updatable properties of a web view definition - * - * @param webViewDefinition Web view definition or update info to get updatable properties from - * @returns Updatable properties of the web view definition - * - * Not exposed on the papi - */ - export function getUpdatablePropertiesFromWebViewDefinition( - webViewDefinition: - | SavedWebViewDefinition - | WebViewDefinition - | WebViewDefinitionUpdatableProperties - | WebViewDefinitionUpdateInfo, - ): WebViewDefinitionUpdatableProperties; - /** - * Merges web view definition updates into a web view definition. Does not modify the original web - * view definition but returns a new object. - * - * @param webViewDefinition Web view definition to merge into - * @param updateInfo Updates to merge into the web view definition - * @returns New copy of web view definition with updates applied - * - * Not exposed on the papi - */ - export function mergeUpdatablePropertiesIntoWebViewDefinition( - webViewDefinition: T, - updateInfo: WebViewDefinitionUpdateInfo, - ): T; - /** - * Gets the updatable properties on the WebView definition with the specified ID - * - * @param webViewId The ID of the WebView whose updatable properties to get - * @returns Updatable properties of the WebView definition with the specified ID or undefined if not - * found - * @throws If the papi dock layout has not been registered - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ - export function getWebViewDefinitionUpdatablePropertiesSync( - webViewId: string, - ): WebViewDefinitionUpdatableProperties | undefined; - /** - * Updates the WebView with the specified ID with the specified properties - * - * @param webViewId The ID of the WebView to update - * @param webViewDefinitionUpdateInfo Properties to update on the WebView. Any unspecified - * properties will stay the same - * @returns True if successfully found the WebView to update; false otherwise - * @throws If the papi dock layout has not been registered - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ - export function updateWebViewDefinitionSync( - webViewId: string, - webViewDefinitionUpdateInfo: WebViewDefinitionUpdateInfo, - ): boolean; - /** - * Creates a new web view or gets an existing one depending on if you request an existing one and if - * the web view provider decides to give that existing one to you (it is up to the provider). - * - * @param webViewType Type of WebView to create - * @param layout Information about where you want the web view to go. Defaults to adding as a tab - * @param options Options that affect what this function does. For example, you can provide an - * existing web view ID to request an existing web view with that ID. - * @returns Promise that resolves to the ID of the webview we got or undefined if the provider did - * not create a WebView for this request. - * @throws If something went wrong like the provider for the webViewType was not found - */ - export const getWebView: ( - webViewType: WebViewType, - layout?: Layout, - options?: GetWebViewOptions, - ) => Promise; - /** Sets up the WebViewService. Runs only once */ - export const initialize: () => Promise; - export interface PapiWebViewService { - onDidAddWebView: typeof onDidAddWebView; - getWebView: typeof getWebView; - initialize: typeof initialize; - } +} +declare module 'shared/services/web-view.service-model' { + import { GetWebViewOptions, WebViewId, WebViewType } from 'shared/models/web-view.model'; + import { AddWebViewEvent, Layout } from 'shared/models/docking-framework.model'; + import { PapiEvent } from 'shared/models/papi-event.model'; /** * * Service exposing various functions related to using webViews @@ -3120,7 +2729,35 @@ declare module 'shared/services/web-view.service' { * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either * HTML or React components. */ - export const papiWebViewService: PapiWebViewService; + export interface WebViewServiceType { + /** Event that emits with webView info when a webView is added */ + onDidAddWebView: PapiEvent; + /** + * Creates a new web view or gets an existing one depending on if you request an existing one and + * if the web view provider decides to give that existing one to you (it is up to the provider). + * + * @param webViewType Type of WebView to create + * @param layout Information about where you want the web view to go. Defaults to adding as a tab + * @param options Options that affect what this function does. For example, you can provide an + * existing web view ID to request an existing web view with that ID. + * @returns Promise that resolves to the ID of the webview we got or undefined if the provider did + * not create a WebView for this request. + * @throws If something went wrong like the provider for the webViewType was not found + */ + getWebView: ( + webViewType: WebViewType, + layout?: Layout, + options?: GetWebViewOptions, + ) => Promise; + } + /** Name to use when creating a network event that is fired when webViews are created */ + export const EVENT_NAME_ON_DID_ADD_WEB_VIEW: `${string}:${string}`; + export const NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE = 'WebViewService'; +} +declare module 'shared/services/web-view.service' { + import { WebViewServiceType } from 'shared/services/web-view.service-model'; + const webViewService: WebViewServiceType; + export default webViewService; } declare module 'shared/services/internet.service' { /** Our shim over fetch. Allows us to control internet access. */ @@ -3525,7 +3162,7 @@ declare module 'shared/models/dialog-options.model' { }; } declare module 'renderer/components/dialogs/dialog-base.data' { - import { FloatSize, TabLoader, TabSaver } from 'shared/data/web-view.model'; + import { FloatSize, TabLoader, TabSaver } from 'shared/models/docking-framework.model'; import { DialogData } from 'shared/models/dialog-options.model'; import { ReactElement } from 'react'; /** Base type for DialogDefinition. Contains reasonable defaults for dialogs */ @@ -4287,7 +3924,7 @@ declare module 'papi-frontend' { import * as commandService from 'shared/services/command.service'; import * as papiUtil from 'shared/utils/papi-util'; import { PapiNetworkService } from 'shared/services/network.service'; - import { PapiWebViewService } from 'shared/services/web-view.service'; + import { WebViewServiceType } from 'shared/services/web-view.service-model'; import { InternetService } from 'shared/services/internet.service'; import { DataProviderService } from 'shared/services/data-provider.service'; import { ProjectLookupServiceType } from 'shared/services/project-lookup.service-model'; @@ -4328,7 +3965,7 @@ declare module 'papi-frontend' { * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either * HTML or React components. */ - webViews: PapiWebViewService; + webViews: WebViewServiceType; /** * * Prompt the user for responses with dialogs @@ -4410,7 +4047,7 @@ declare module 'papi-frontend' { * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either * HTML or React components. */ - export const webViews: PapiWebViewService; + export const webViews: WebViewServiceType; /** * * Prompt the user for responses with dialogs @@ -4460,6 +4097,94 @@ declare module 'papi-frontend' { export const settings: SettingsService; export type Papi = typeof papi; } +declare module 'shared/models/web-view-provider.model' { + import { + GetWebViewOptions, + WebViewDefinition, + SavedWebViewDefinition, + } from 'shared/models/web-view.model'; + import { + DisposableNetworkObject, + NetworkObject, + NetworkableObject, + } from 'shared/models/network-object.model'; + import { CanHaveOnDidDispose } from 'shared/models/disposal.model'; + export interface IWebViewProvider extends NetworkableObject { + /** + * @param savedWebView Filled out if an existing webview is being called for (matched by ID). Just + * ID if this is a new request or if the web view with the existing ID was not found + * @param getWebViewOptions + */ + getWebView( + savedWebView: SavedWebViewDefinition, + getWebViewOptions: GetWebViewOptions, + ): Promise; + } + export interface WebViewProvider + extends NetworkObject, + CanHaveOnDidDispose {} + export interface DisposableWebViewProvider + extends DisposableNetworkObject, + Omit {} +} +declare module 'shared/services/web-view-provider.service' { + /** + * Handles registering web view providers and serving web views around the papi. Exposed on the + * papi. + */ + import { + DisposableWebViewProvider, + IWebViewProvider, + WebViewProvider, + } from 'shared/models/web-view-provider.model'; + /** Sets up the service. Only runs once and always returns the same promise after that */ + const initialize: () => Promise; + /** + * Indicate if we are aware of an existing web view provider with the given type. If a web view + * provider with the given type is somewhere else on the network, this function won't tell you about + * it unless something else in the existing process is subscribed to it. + * + * @param webViewType Type of webView to check for + */ + function hasKnown(webViewType: string): boolean; + /** + * Register a web view provider to serve webViews for a specified type of webViews + * + * @param webViewType Type of web view to provide + * @param webViewProvider Object to register as a webView provider including control over disposing + * of it. + * + * WARNING: setting a webView provider mutates the provided object. + * @returns `webViewProvider` modified to be a network object + */ + function register( + webViewType: string, + webViewProvider: IWebViewProvider, + ): Promise; + /** + * Get a web view provider that has previously been set up + * + * @param webViewType Type of webview provider to get + * @returns Web view provider with the given name if one exists, undefined otherwise + */ + function get(webViewType: string): Promise; + export interface WebViewProviderService { + initialize: typeof initialize; + hasKnown: typeof hasKnown; + register: typeof register; + get: typeof get; + } + export interface PapiWebViewProviderService { + register: typeof register; + } + const webViewProviderService: WebViewProviderService; + /** + * + * Interface for registering webView providers + */ + export const papiWebViewProviderService: PapiWebViewProviderService; + export default webViewProviderService; +} declare module 'shared/data/file-system.model' { /** Types to use with file system operations */ /** @@ -4734,7 +4459,7 @@ declare module 'papi-backend' { import * as commandService from 'shared/services/command.service'; import * as papiUtil from 'shared/utils/papi-util'; import { PapiNetworkService } from 'shared/services/network.service'; - import { PapiWebViewService } from 'shared/services/web-view.service'; + import { WebViewServiceType } from 'shared/services/web-view.service-model'; import { PapiWebViewProviderService } from 'shared/services/web-view-provider.service'; import { InternetService } from 'shared/services/internet.service'; import { @@ -4787,7 +4512,7 @@ declare module 'papi-backend' { * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either * HTML or React components. */ - webViews: PapiWebViewService; + webViews: WebViewServiceType; /** * * Interface for registering webView providers @@ -4880,7 +4605,7 @@ declare module 'papi-backend' { * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either * HTML or React components. */ - export const webViews: PapiWebViewService; + export const webViews: WebViewServiceType; /** * * Interface for registering webView providers diff --git a/src/extension-host/services/papi-backend.service.ts b/src/extension-host/services/papi-backend.service.ts index 69dc9651cc..192c81e6bb 100644 --- a/src/extension-host/services/papi-backend.service.ts +++ b/src/extension-host/services/papi-backend.service.ts @@ -9,7 +9,8 @@ import * as commandService from '@shared/services/command.service'; import * as papiUtil from '@shared/utils/papi-util'; import papiLogger from '@shared/services/logger.service'; import { papiNetworkService, PapiNetworkService } from '@shared/services/network.service'; -import { papiWebViewService, PapiWebViewService } from '@shared/services/web-view.service'; +import { WebViewServiceType } from '@shared/services/web-view.service-model'; +import webViewService from '@shared/services/web-view.service'; import { papiWebViewProviderService, PapiWebViewProviderService, @@ -56,7 +57,7 @@ const papi = { /** JSDOC DESTINATION papiUtil */ util: papiUtil, /** JSDOC DESTINATION papiWebViewService */ - webViews: papiWebViewService as PapiWebViewService, + webViews: webViewService as WebViewServiceType, /** JSDOC DESTINATION papiWebViewProviderService */ webViewProviders: papiWebViewProviderService as PapiWebViewProviderService, /** JSDOC DESTINATION dialogService */ diff --git a/src/renderer/components/basic-list/basic-list.component.tsx b/src/renderer/components/basic-list/basic-list.component.tsx index 54deb4483a..e86abc8257 100644 --- a/src/renderer/components/basic-list/basic-list.component.tsx +++ b/src/renderer/components/basic-list/basic-list.component.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { ExpandedState, useReactTable, diff --git a/src/renderer/components/dialogs/dialog-base.data.ts b/src/renderer/components/dialogs/dialog-base.data.ts index 9c0aed4da9..d189d65bbd 100644 --- a/src/renderer/components/dialogs/dialog-base.data.ts +++ b/src/renderer/components/dialogs/dialog-base.data.ts @@ -1,4 +1,4 @@ -import { FloatSize, TabLoader, TabSaver } from '@shared/data/web-view.model'; +import { FloatSize, TabLoader, TabSaver } from '@shared/models/docking-framework.model'; import { DialogData } from '@shared/models/dialog-options.model'; import logger from '@shared/services/logger.service'; import { ReactElement, createElement } from 'react'; diff --git a/src/renderer/components/docking/error-tab.component.tsx b/src/renderer/components/docking/error-tab.component.tsx index e95bea230e..75c5616792 100644 --- a/src/renderer/components/docking/error-tab.component.tsx +++ b/src/renderer/components/docking/error-tab.component.tsx @@ -1,4 +1,4 @@ -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { newGuid } from '@shared/utils/util'; export type ErrorTabData = { errorMessage: string }; diff --git a/src/renderer/components/docking/platform-dock-layout.component.test.ts b/src/renderer/components/docking/platform-dock-layout.component.test.ts index be5a5d8161..caebc0310b 100644 --- a/src/renderer/components/docking/platform-dock-layout.component.test.ts +++ b/src/renderer/components/docking/platform-dock-layout.component.test.ts @@ -16,7 +16,12 @@ jest.mock( import DockLayout, { FloatPosition } from 'rc-dock'; import { anything, instance, mock, verify, when } from 'ts-mockito'; -import { FloatLayout, Layout, SavedTabInfo, WebViewTabProps } from '@shared/data/web-view.model'; +import { + FloatLayout, + Layout, + SavedTabInfo, + WebViewTabProps, +} from '@shared/models/docking-framework.model'; import { addTabToDock, addWebViewToDock, diff --git a/src/renderer/components/docking/platform-dock-layout.component.tsx b/src/renderer/components/docking/platform-dock-layout.component.tsx index 8937fd87f4..7dc285daab 100644 --- a/src/renderer/components/docking/platform-dock-layout.component.tsx +++ b/src/renderer/components/docking/platform-dock-layout.component.tsx @@ -31,6 +31,11 @@ import { loadQuickVerseHeresyTab, TAB_TYPE_QUICK_VERSE_HERESY, } from '@renderer/testing/test-quick-verse-heresy-panel.component'; +import { + WebViewDefinitionUpdateInfo, + WebViewDefinition, + WebViewDefinitionUpdatableProperties, +} from '@shared/models/web-view.model'; import { FloatLayout, FloatSize, @@ -39,18 +44,17 @@ import { TabInfo, TabSaver, Layout, - WebViewTabProps, PanelDirection, - WebViewDefinitionUpdateInfo, - WebViewDefinition, -} from '@shared/data/web-view.model'; + OnLayoutChangeRCDock, + WebViewTabProps, +} from '@shared/models/docking-framework.model'; + import LogError from '@shared/log-error.model'; import { - OnLayoutChangeRCDock, mergeUpdatablePropertiesIntoWebViewDefinition, registerDockLayout, saveTabInfoBase, -} from '@shared/services/web-view.service'; +} from '@renderer/services/web-view.service-host'; import { getErrorMessage } from '@shared/utils/util'; import { loadDownloadUpdateProjectTab, @@ -594,9 +598,12 @@ export default function PlatformDockLayout() { // Return whether or not we found the tab to remove return !!tabToRemove; }, - getWebViewDefinition: (webViewId) => getWebViewDefinition(webViewId, dockLayoutRef.current), - updateWebViewDefinition: (webViewId, updateInfo) => - updateWebViewDefinition(webViewId, updateInfo, dockLayoutRef.current), + getWebViewDefinition: (webViewId: string) => + getWebViewDefinition(webViewId, dockLayoutRef.current), + updateWebViewDefinition: ( + webViewId: string, + updateInfo: Partial, + ) => updateWebViewDefinition(webViewId, updateInfo, dockLayoutRef.current), testLayout, }); return () => { diff --git a/src/renderer/components/extension-manager/extension-manager-tab.component.tsx b/src/renderer/components/extension-manager/extension-manager-tab.component.tsx index 1537134e86..caa872bee8 100644 --- a/src/renderer/components/extension-manager/extension-manager-tab.component.tsx +++ b/src/renderer/components/extension-manager/extension-manager-tab.component.tsx @@ -1,4 +1,4 @@ -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { useMemo, useState } from 'react'; import logger from '@shared/services/logger.service'; import { Typography } from '@mui/material'; diff --git a/src/renderer/components/projects/download-update-project-tab.component.tsx b/src/renderer/components/projects/download-update-project-tab.component.tsx index 6c9f2daff0..eed61f434b 100644 --- a/src/renderer/components/projects/download-update-project-tab.component.tsx +++ b/src/renderer/components/projects/download-update-project-tab.component.tsx @@ -1,4 +1,4 @@ -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { List, ListItem, diff --git a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx index 20023b88cd..e742cfc16a 100644 --- a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx +++ b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx @@ -1,4 +1,4 @@ -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { Button, ScriptureReference, getChaptersForBook } from 'papi-components'; import logger from '@shared/services/logger.service'; import { Typography } from '@mui/material'; diff --git a/src/renderer/components/settings-dialog/settings-tab.component.tsx b/src/renderer/components/settings-dialog/settings-tab.component.tsx index 7fd32663bf..98145eb067 100644 --- a/src/renderer/components/settings-dialog/settings-tab.component.tsx +++ b/src/renderer/components/settings-dialog/settings-tab.component.tsx @@ -9,7 +9,7 @@ import { Tabs, Typography, } from '@mui/material'; -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { SyntheticEvent, useState, diff --git a/src/renderer/components/web-view.component.test.ts b/src/renderer/components/web-view.component.test.ts index eb5ed4bc51..4c15371d2f 100644 --- a/src/renderer/components/web-view.component.test.ts +++ b/src/renderer/components/web-view.component.test.ts @@ -1,4 +1,5 @@ -import { WebViewContentType, WebViewTabProps } from '../../shared/data/web-view.model'; +import { WebViewContentType } from '@shared/models/web-view.model'; +import { WebViewTabProps } from '@shared/models/docking-framework.model'; import { getTitle } from './web-view.component'; describe('WebView Component', () => { diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 84e75d9575..d626b4f8fb 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -1,21 +1,19 @@ import { useRef } from 'react'; import { - SavedTabInfo, - TabInfo, WebViewContentType, WebViewDefinition, SavedWebViewDefinition, - WebViewTabProps, -} from '@shared/data/web-view.model'; +} from '@shared/models/web-view.model'; +import { SavedTabInfo, TabInfo, WebViewTabProps } from '@shared/models/docking-framework.model'; import { + convertWebViewDefinitionToSaved, getWebView, saveTabInfoBase, - convertWebViewDefinitionToSaved, - WEBVIEW_IFRAME_SRCDOC_SANDBOX, - WEBVIEW_IFRAME_SRC_SANDBOX, - IFRAME_SANDBOX_ALLOW_SCRIPTS, IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, -} from '@shared/services/web-view.service'; + IFRAME_SANDBOX_ALLOW_SCRIPTS, + WEBVIEW_IFRAME_SRC_SANDBOX, + WEBVIEW_IFRAME_SRCDOC_SANDBOX, +} from '@renderer/services/web-view.service-host'; import logger from '@shared/services/logger.service'; export const TAB_TYPE_WEBVIEW = 'webView'; diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 4234cf047e..cdf8d79098 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -2,7 +2,7 @@ import '@renderer/global-this.model'; import { createRoot } from 'react-dom/client'; import * as networkService from '@shared/services/network.service'; import * as commandService from '@shared/services/command.service'; -import * as webViewService from '@shared/services/web-view.service'; +import { startWebViewService } from '@renderer/services/web-view.service-host'; import logger from '@shared/services/logger.service'; import webViewProviderService from '@shared/services/web-view-provider.service'; import { startDialogService } from '@renderer/services/dialog.service-host'; @@ -40,7 +40,7 @@ logger.info('Starting renderer'); })(); (async () => { try { - await webViewService.initialize(); + await startWebViewService(); } catch (e) { logger.error(`WebView service failed to initialize! Error: ${e}`); } diff --git a/src/renderer/services/dialog.service-host.ts b/src/renderer/services/dialog.service-host.ts index ad08d43de1..c5a201818d 100644 --- a/src/renderer/services/dialog.service-host.ts +++ b/src/renderer/services/dialog.service-host.ts @@ -2,7 +2,7 @@ import { DialogData } from '@shared/models/dialog-options.model'; import { CATEGORY_DIALOG, DialogService } from '@shared/services/dialog.service-model'; import * as networkService from '@shared/services/network.service'; import { aggregateUnsubscriberAsyncs, serializeRequestType } from '@shared/utils/papi-util'; -import * as webViewService from '@shared/services/web-view.service'; +import * as webViewService from '@renderer/services/web-view.service-host'; import { newGuid } from '@shared/utils/util'; import logger from '@shared/services/logger.service'; import SELECT_PROJECT_DIALOG from '@renderer/components/dialogs/select-project.dialog'; diff --git a/src/renderer/services/papi-frontend.service.ts b/src/renderer/services/papi-frontend.service.ts index db497dcac5..991ece8feb 100644 --- a/src/renderer/services/papi-frontend.service.ts +++ b/src/renderer/services/papi-frontend.service.ts @@ -9,7 +9,8 @@ import * as commandService from '@shared/services/command.service'; import * as papiUtil from '@shared/utils/papi-util'; import papiLogger from '@shared/services/logger.service'; import { papiNetworkService, PapiNetworkService } from '@shared/services/network.service'; -import { papiWebViewService, PapiWebViewService } from '@shared/services/web-view.service'; +import { WebViewServiceType } from '@shared/services/web-view.service-model'; +import webViewService from '@shared/services/web-view.service'; import internetService, { InternetService } from '@shared/services/internet.service'; import dataProviderService, { DataProviderService } from '@shared/services/data-provider.service'; import { ProjectLookupServiceType } from '@shared/services/project-lookup.service-model'; @@ -46,7 +47,7 @@ const papi = { /** JSDOC DESTINATION papiUtil */ util: papiUtil, /** JSDOC DESTINATION papiWebViewService */ - webViews: papiWebViewService as PapiWebViewService, + webViews: webViewService as WebViewServiceType, /** JSDOC DESTINATION dialogService */ dialogs: dialogService as DialogService, /** JSDOC DESTINATION papiNetworkService */ diff --git a/src/renderer/services/web-view.service-host.ts b/src/renderer/services/web-view.service-host.ts new file mode 100644 index 0000000000..6a7ec9d048 --- /dev/null +++ b/src/renderer/services/web-view.service-host.ts @@ -0,0 +1,1223 @@ +/** + * Service that handles WebView-related operations + * + * Don't expose this whole service on papi, just specific operations. The remaining exports are only + * for services in the renderer to call. + */ +import cloneDeep from 'lodash/cloneDeep'; +import { Unsubscriber } from '@shared/utils/papi-util'; +import { isString, newGuid, newNonce } from '@shared/utils/util'; +import { createNetworkEventEmitter } from '@shared/services/network.service'; +import { + GetWebViewOptions, + SavedWebViewDefinition, + WebViewContentType, + WebViewDefinition, + WebViewDefinitionReact, + WebViewDefinitionUpdateInfo, + WebViewDefinitionUpdatableProperties, + WebViewId, + WebViewType, +} from '@shared/models/web-view.model'; +import { + AddWebViewEvent, + Layout, + OnLayoutChangeRCDock, + PapiDockLayout, + SavedTabInfo, + TabInfo, + WebViewTabProps, +} from '@shared/models/docking-framework.model'; +import webViewProviderService from '@shared/services/web-view-provider.service'; +import { LayoutBase } from 'rc-dock'; +import AsyncVariable from '@shared/utils/async-variable'; +import logger from '@shared/services/logger.service'; +import LogError from '@shared/log-error.model'; +import memoizeOne from 'memoize-one'; +import { + EVENT_NAME_ON_DID_ADD_WEB_VIEW, + NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, + WebViewServiceType, +} from '@shared/services/web-view.service-model'; +import networkObjectService from '@shared/services/network-object.service'; +import { + getFullWebViewStateById, + setFullWebViewStateById, +} from '@renderer/services/web-view-state.service'; + +/** Emitter for when a webview is added */ +const onDidAddWebViewEmitter = createNetworkEventEmitter( + EVENT_NAME_ON_DID_ADD_WEB_VIEW, +); + +/** Event that emits with webView info when a webView is added */ +export const onDidAddWebView = onDidAddWebViewEmitter.event; + +// #region Security + +/** + * The iframe [sandbox attribute] + * (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if + * scripts are allowed to run on an iframe + */ +export const IFRAME_SANDBOX_ALLOW_SCRIPTS = 'allow-scripts'; + +/** + * The iframe [sandbox attribute] + * (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if an + * iframe is allowed to interact with its parent as a same-origin website. The iframe must still be + * on the same origin as its parent in order to interact same-origin. + */ +export const IFRAME_SANDBOX_ALLOW_SAME_ORIGIN = 'allow-same-origin'; + +/** + * The only `sandbox` attribute values we allow iframes with `src` to have including URL WebView + * iframes. These are separate than iframes with `srcdoc` for a few reasons: + * + * - These iframes cannot be on the same origin as the parent window even if `allow-same-origin` is + * present (unless they are literally on the same origin) because we do not allow `frame-src + * blob:` + * - `src` iframes do not inherit the CSP of their parent window. + * - We are not able to modify the `srcdoc` before inserting it to ensure it has a CSP that we control + * to attempt to prevent arbitrary code execution on same origin. We are trusting the browser's + * ability to create a strong and safe boundary between parent and child iframe in different + * origin. + * + * TODO: consider using `csp` attribute on iframe to mitigate this issue + * - Extension developers do not know what code they are executing if they use some random URL in + * `src` WebViews. + * + * The `sandbox` attribute controls what privileges iframe scripts and other things have: + * + * - `allow-same-origin` so the iframe can access the storage APIs (localstorage, cookies, etc) and + * other same-origin connections for its own origin. `blob:` iframes are considered part of the + * parent origin, but we block them with the CSP in `index.ejs`. For more information, see + * https://web.dev/articles/sandboxed-iframes + * - `allow-scripts` so the iframe can actually do things. Defaults to not present since src iframes + * can get scripts from anywhere. Extension developers should only enable this if needed as this + * increases the possibility of a security threat occurring. Defaults to false + * + * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON + * + * Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts' + * allows the child scripts to remove this sandbox attribute from the iframe. This should only be + * possible on iframes that are on the same origin as the parent including those that use `srcdoc` + * to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from + * creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any + * iframes that do not comply with these sandbox requirements. This successfully prevents iframes + * with too many privileges from executing as of July 2023. However, this means the sandboxing could + * do nothing for a determined hacker if they ever find a way around all this. We must distrust the + * whole renderer due to this issue. We will probably want to stay vigilant on security in this + * area. + */ +const ALLOWED_IFRAME_SRC_SANDBOX_VALUES = [ + IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, + IFRAME_SANDBOX_ALLOW_SCRIPTS, +]; + +/** + * The minimal `src` WebView iframe sandboxing. This is applied to WebView iframes that use `src` in + * `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRC_SANDBOX_VALUES} for more information on + * our sandboxing methods and why we chose these values. + * + * Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added + * conditionally depending on the WebViewDefinition in `web-view.component.tsx` + */ +export const WEBVIEW_IFRAME_SRC_SANDBOX = ALLOWED_IFRAME_SRC_SANDBOX_VALUES.filter( + (value) => value !== IFRAME_SANDBOX_ALLOW_SCRIPTS && value !== IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, +).join(' '); + +/** + * The only `sandbox` attribute values we allow iframes with `srcdoc` to have including HTML and + * React WebView iframes. These are separate than iframes with `src` for a few reasons: + * + * - These iframes will be on the same origin as the parent window if `allow-same-origin` is present. + * This is very serious and demands significant security risk consideration. + * - `srcdoc` iframes inherit the CSP of their parent window (in our case, `index.ejs`) + * - We are modifying the `srcdoc` before inserting it to ensure it has a CSP that we control to + * attempt to prevent unintended code execution on same origin + * - Extension developers should know exactly what code they're running in `srcdoc` WebViews, whereas + * they could include some random URL in `src` WebViews + * + * TODO: consider requiring `srcdoc` WebView content to come directly from `papi-extension://` + * instead of assuming extension developers will bundle their WebView code? This would mean the + * only code that runs on same origin is code that extension developers definitely included in + * their extension bundle https://github.com/paranext/paranext-core/issues/604 + * + * The `sandbox` attribute controls what privileges iframe scripts and other things have: + * + * - `allow-same-origin` so the iframe can get papi and communicate and such + * - `allow-scripts` so the iframe can actually do things + * + * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON + * + * Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts' + * allows the child scripts to remove this sandbox attribute from the iframe. This should only be + * possible on iframes that are on the same origin as the parent including those that use `srcdoc` + * to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from + * creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any + * iframes that do not comply with these sandbox requirements. This successfully prevents iframes + * with too many privileges from executing as of July 2023. However, this means the sandboxing could + * do nothing for a determined hacker if they ever find a way around all this. We must distrust the + * whole renderer due to this issue. We will probably want to stay vigilant on security in this + * area. + */ +export const ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES = [...ALLOWED_IFRAME_SRC_SANDBOX_VALUES]; + +/** + * The minimal `srcdoc` WebView iframe sandboxing. This is applied to WebView iframes that use + * `srcDoc` in `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES} for more + * information on our sandboxing methods and why we chose these values. + * + * Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added + * conditionally depending on the WebViewDefinition in `web-view.component.tsx` + */ +export const WEBVIEW_IFRAME_SRCDOC_SANDBOX = ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES.filter( + (value) => value !== IFRAME_SANDBOX_ALLOW_SCRIPTS && value !== IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, +).join(' '); + +/** + * Get Regex to test stack traces against for creating script and iframe tags on the renderer + * document. Only renderer code is allowed to create script and iframe tags. script and iframe tags + * coming from any other source throw an error. + * + * Note that sourceURLs can't have spaces in them, so we explicitly test for a space before the + * source so bad actors can't put these special words into their sourceURL + */ +/* In development, safe errors look like this: +Error + at document.createElement (http://localhost/renderer.dev.js...) + at __webpack_require__.l (http://localhost/renderer.dev.js...) + ... +*/ +/* In development, bad errors look more like this: +Error + at document.createElement (http://localhost/renderer.dev.js...) + at evil.web-view.htmlfile://app.asar +*/ +/* In production, safe errors look like this: +Error + at Qt.document.createElement (file:///C:/Users/app.asar/dist/renderer/renderer.js...) + at i.l (file:///C:/Users/app.asar/dist/renderer/renderer.js...) + ... +*/ +/* In production, bad errors look more like this: +Error + at Qt.document.createElement (file:///C:/Users/app.asar/dist/renderer/stuffnthings) + at evil.web-view.htmlfile://app.asar +*/ +const getRendererScriptRegex = memoizeOne(() => + globalThis.isPackaged + ? /^.+\s+.+ \S*document\.createElement \(file:\/\/\S*app.asar\/dist\/renderer\/renderer\.js\S*\)\s+.+ \(file:\/\/\S*app.asar\/dist\/renderer\/renderer\.js\S*\)/ + : /^.+\s+.+ \S*document\.createElement \(https?:\/\/\S*\/renderer\.dev\.js\S*\)\s+.+ \(https?:\/\/\S*\/renderer\.dev\.js\S*\)/, +); +/** + * The HTML tags that are not allowed at all in the main renderer window. Our MutationObserver + * deletes these immediately if it sees them. + * + * WARNING: These are all untested. The MutationObserver was not fast enough to remove script tags + * before they executed code, so there is some chance these could do bad things too. + * + * TODO: Test these sometime + */ +// Maybe we don't actually need this... Maybe we should evaluate if we want this. +// Would lag things up if we changed our MutationObserver to use getElementsByTagName +const FORBIDDEN_HTML_TAGS = ['object', 'embed', 'frame', 'frameset']; +/** + * The HTML tags that are only allowed in the main renderer window if created by the renderer. Our + * monkey-patch on `document.createElement` protects these. + * + * Technically, all elements should really be created only by the renderer, but we must choose the + * security-related ones to guard closely since this is an inefficient check. + * + * Note: this only applies to tags added to the document after initial load, so the document + * metadata tags are not normally hit. + * + * WARNING: A stack trace has to be created each time any of these are created, so it is not very + * efficient when one of these tags is created. Please avoid using these tags where possible. + */ +const RESTRICTED_HTML_TAGS = [ + // All the [Document metadata](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#document_metadata) + // tags except `style` because honestly there are just too many of them. They flood the logs and + // took 100ms on reload. If it becomes an issue, we can worry about it then. Maybe we can try + // checking for style when the first WebView is loaded in or something + 'base', + 'head', + 'link', + 'meta', + // See comment above for why not style + // 'style', + 'title', + // The [Sectioning root](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#sectioning_root) + 'body', + // Tags that have [href](https://www.w3schools.com/tags/att_href.asp) for navigating + 'a', + 'area', + // Can navigate + 'form', + // Don't want to let extensions block the UI + 'dialog', + // Very dangerous tags that we need to be careful to restrict - we do not want extension code to + // run in renderer context + 'script', + 'iframe', + // Weird tag to preview a site that we probably don't need + 'portal', +]; + +/** + * Checks a node and its children recursively to determine if they are forbidden and removes them + * from the dom if so. + * + * @param node The node to check recursively + * @param parent Node from which to remove this node if it is forbidden + */ +function removeNodeIfForbidden(node: Node) { + if (node.nodeType !== Node.ELEMENT_NODE) return; + + // This is an element node. + // eslint-disable-next-line no-type-assertion/no-type-assertion + const element = node as Element; + + /** Remove the element */ + const removeElement = (info: string) => { + logger.warn( + `${info} rejected! An extension may have been trying to execute code with higher privileges!`, + ); + element.remove(); + }; + + function validateElementThenChildren(currentElement: Element) { + const currentTag = currentElement.tagName.toLowerCase(); + + // If the element is forbidden, remove this whole tree + if (currentTag === 'iframe') { + const sandbox = currentElement.attributes.getNamedItem('sandbox'); + if (!sandbox) { + removeElement('iframe with no sandbox'); + return; + } + if (!isString(sandbox.value)) { + removeElement(`iframe with a non-string sandbox value ${sandbox.value}`); + return; + } + const sandboxValues = sandbox.value.split(' '); + const src = currentElement.attributes.getNamedItem('src'); + // If the iframe has `src`, only allow `src` sandbox values because browsers that do not + // support `srcdoc` fall back to `src` so we should be more strict + const allowedSandboxValues = src + ? ALLOWED_IFRAME_SRC_SANDBOX_VALUES + : ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES; + if ( + sandboxValues.some( + (sandboxValue) => sandboxValue !== '' && !allowedSandboxValues.includes(sandboxValue), + ) + ) { + removeElement( + `iframe with \`${ + src ? 'src' : 'srcdoc' + }\` attribute and disallowed sandbox attribute value '${sandbox.value}'`, + ); + return; + } + } + if (FORBIDDEN_HTML_TAGS.includes(currentTag)) { + removeElement(currentTag); + return; + } + + // Check the element's children to see if they are forbidden + for (let i = 0; i < currentElement.children?.length; i++) { + validateElementThenChildren(currentElement.children[i]); + } + } + + // Validate the new element and all children recursively. If anything is forbidden, the top + // element will be removed + validateElementThenChildren(element); +} + +/** + * Reads through the list of document changes detected by our MutationObserver and deletes forbidden + * elements including iframes with improper sandboxing + */ +function removeForbiddenElements(mutationList: MutationRecord[]) { + // If this becomes too slow, it may be necessary to use getElementsByTagName instead of looping + // through the mutations. Thanks for the idea to https://stackoverflow.com/a/39332340 + mutationList.forEach((m) => { + // If `src` or `srcdoc` attributes changed, validate the element + if (m.type === 'attributes') { + if (!m.target.parentNode) { + logger.warn( + `MutationObserver couldn't find parent for node that changed attributes! This doesn't make sense. Investigate`, + m.target, + ); + } + removeNodeIfForbidden(m.target); + return; + } + // If for some reason this mutation is not added or removed nodes, forget it + if (m.type !== 'childList') return; + // Check if each added node is a forbidden element + m.addedNodes.forEach((node) => removeNodeIfForbidden(node)); + }); +} + +// #endregion + +// #region Dock layouts + +/** `localstorage` key for saving and loading the dock layout */ +const DOCK_LAYOUT_KEY = 'dock-saved-layout'; + +/** Create a new dock layout promise variable */ +function createDockLayoutAsyncVar(): AsyncVariable { + return new AsyncVariable('web-view.service-host.platformDockLayout'); +} + +/** + * WARNING: DO NOT USE THIS VARIABLE DIRECTLY. USE `getDockLayout()` + * + * Asynchronously accessed variable that will hold the rc-dock dock layout along with a couple other + * props. This is populated by `platform-dock-layout.component.tsx` registering its dock layout with + * this service, allowing this service to manage layouts and such. + * + * Do not save this variable out anywhere because it can change, invalidating the old one (see + * `registerDockLayout`) + */ +let papiDockLayoutVar = createDockLayoutAsyncVar(); + +/** + * WARNING: DO NOT USE THIS VARIABLE DIRECTLY. USE `getDockLayoutSync()` + * + * Synchronously accessed variable that will hold the rc-dock dock layout along with a couple other + * props. This is populated by `platform-dock-layout.component.tsx` registering its dock layout with + * this service, allowing this service to manage layouts and such. + * + * Do not save this variable out anywhere because it can change, invalidating the old one (see + * `registerDockLayout`) + */ +let papiDockLayoutVarSync: PapiDockLayout | undefined; + +/** + * Get the papi dock layout promise. It will resolve to the papi dock layout when it is registered. + * + * Do not save the returned variable out anywhere because it can change, invalidating the old one + * (see `registerDockLayout`) + * + * @returns Promise that resolves to the papi dock layout + */ +function getDockLayout(): Promise { + return papiDockLayoutVar.promise; +} + +/** + * Get the papi dock layout synchronously _assuming_ it has been registered. This should be safe to + * assume if you are accessing this from inside a tab's code + * + * Do not save the returned variable out anywhere because it can change, invalidating the old one + * (see `registerDockLayout`) + * + * @returns The papi dock layout + * @throws If the papi dock layout has not been registered + */ +function getDockLayoutSync(): PapiDockLayout { + if (!papiDockLayoutVarSync) + throw new Error( + 'WebView Service error: Dock layout was requested synchronously, but the dock layout has not been registered!', + ); + return papiDockLayoutVarSync; +} + +/** + * Set the papi dock layout (async and sync). Resolves `getDockLayout()` calls. + * + * This should very likely only be used in `registerDockLayout`. + * + * @param dockLayout The papi dock layout to set or undefined to reset the dock layout + */ +function setDockLayout(dockLayout: PapiDockLayout | undefined): void { + if (dockLayout === undefined) { + // Create a new async var to empty out the dock layout only if the dock layout was previously + // set. That way, async callers to the dock layout who are awaiting a resolved value don't get + // lost or rejected needlessly + // TODO: Would creating a new async var create any problems...? I guess only if someone saves + // dockLayoutVar somewhere else + if (papiDockLayoutVar.hasSettled) papiDockLayoutVar = createDockLayoutAsyncVar(); + papiDockLayoutVarSync = undefined; + } else { + // Set the dock layout as the promise var. Throws if already resolved + papiDockLayoutVar.resolveToValue(dockLayout, true); + if (papiDockLayoutVarSync) + throw new Error( + 'WebView Service error: papiDockLayoutVarSync is already set when trying to set it!', + ); + papiDockLayoutVarSync = dockLayout; + } +} + +/** + * When rc-dock detects a changed layout, save it. This function is given to the registered + * papiDockLayout to run when the dock layout changes. + * + * @param newLayout The changed layout to save. + */ +// TODO: We could filter whether we need to save based on the `direction` argument. - IJH 2023-05-1 +const onLayoutChange: OnLayoutChangeRCDock = async (newLayout) => { + return saveLayout(newLayout); +}; + +/** + * Loads layout information into the dock layout. + * + * @param layout If this parameter is provided, loads that layout information. If not provided, gets + * the persisted layout information and loads it into the dock layout. + */ +async function loadLayout(layout?: LayoutBase): Promise { + const dockLayoutVar = await getDockLayout(); + const layoutToLoad = layout || getStorageValue(DOCK_LAYOUT_KEY, dockLayoutVar.testLayout); + + dockLayoutVar.dockLayout.loadLayout(layoutToLoad); + if (layout) { + // A layout was provided, meaning this is a layout change. Since `dockLayout.loadLayout` doesn't + // run `onLayoutChange`, we run it manually + await onLayoutChange(layoutToLoad); + } +} + +/** + * Safely load a value from local storage. + * + * @param key Of the value. + * @param defaultValue To return if the key is not found. + * @returns The value of the key fetched from local storage, or the default value if not found. + */ +function getStorageValue(key: string, defaultValue: T): T { + const saved = localStorage.getItem(key); + const initial = saved ? JSON.parse(saved) : undefined; + return initial || defaultValue; +} + +/** + * Persists the current dock layout information. + * + * @param layout Layout to persist + */ +async function saveLayout(layout: LayoutBase): Promise { + const currentLayout = layout; + localStorage.setItem(DOCK_LAYOUT_KEY, JSON.stringify(currentLayout)); +} + +/** + * Register a dock layout React element to be used by this service to perform layout-related + * operations + * + * @param dockLayout Dock layout element to register along with other important properties + * @returns Function used to unregister this dock layout + */ +export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber { + // Save the current async var so we know if it changed before we unsubscribed + const currentPapiDockLayoutVar = papiDockLayoutVar; + + setDockLayout(dockLayout); + + // TODO: Strange pattern that we are setting a ref to a service function. Investigate changing + // this pattern in some way. Maybe just export `onLayoutChange`? + dockLayout.onLayoutChangeRef.current = onLayoutChange; + + // Will we ever need to await this? For now, seems like it unnecessarily complicates registering + // because making this function async would probably be annoying in React + loadLayout(); + + // Return an unsubscriber to unregister this dock layout. The primary situation in which I see + // this happening is when you change something on the renderer that causes a live hot reload + return () => { + // Somehow this is not the registered dock layout anymore + if (papiDockLayoutVar !== currentPapiDockLayoutVar) + throw new Error('Tried to unregister an old dock layout'); + + setDockLayout(undefined); + + return true; + }; +} + +// #endregion + +// #region Tabs + +/** + * Add or update a tab in the layout + * + * @param savedTabInfo Info for tab to add or update + * @param layout Information about where to put a new tab + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` + */ +export const addTab = async ( + savedTabInfo: SavedTabInfo & { data?: TData }, + layout: Layout, +): Promise => { + return (await getDockLayout()).addTabToDock(savedTabInfo, layout); +}; + +/** + * Remove a tab in the layout + * + * @param tabId ID of the tab to remove + * @returns True if successfully found the tab to remove + */ +export const removeTab = async (tabId: string): Promise => { + return (await getDockLayout()).removeTabFromDock(tabId); +}; + +/** + * Basic `saveTabInfo` that simply strips the properties added by {@link TabInfo} off of the object + * and returns it as a {@link SavedTabInfo}. Runs as the {@link TabSaver} by default if the tab type + * does not have a specific `TabSaver` + */ +export function saveTabInfoBase(tabInfo: TabInfo): SavedTabInfo { + // We don't need to use the other properties, but we need to remove them + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { tabTitle, tabIconUrl, content, minWidth, minHeight, ...savedTabInfo } = tabInfo; + return savedTabInfo; +} + +// #endregion + +// #region Web view definitions + +/** + * Updates the WebView with the specified ID with the specified properties + * + * @param webViewId The ID of the WebView to update + * @param webViewDefinitionUpdateInfo Properties to update on the WebView. Any unspecified + * properties will stay the same + * @returns True if successfully found the WebView to update; false otherwise + * @throws If the papi dock layout has not been registered + */ +export function updateWebViewDefinitionSync( + webViewId: string, + webViewDefinitionUpdateInfo: WebViewDefinitionUpdateInfo, +): boolean { + return getDockLayoutSync().updateWebViewDefinition(webViewId, webViewDefinitionUpdateInfo); +} + +/** + * Get just the updatable properties of a web view definition + * + * @param webViewDefinition Web view definition or update info to get updatable properties from + * @returns Updatable properties of the web view definition + */ +export function getUpdatablePropertiesFromWebViewDefinition( + webViewDefinition: + | SavedWebViewDefinition + | WebViewDefinition + | WebViewDefinitionUpdatableProperties + | WebViewDefinitionUpdateInfo, +): WebViewDefinitionUpdatableProperties { + // Make sure we're only including the specific properties we allow updates on + const { iconUrl, title } = webViewDefinition; + return { iconUrl, title }; +} + +/** + * Merges web view definition updates into a web view definition. Does not modify the original web + * view definition but returns a new object. + * + * @param webViewDefinition Web view definition to merge into + * @param updateInfo Updates to merge into the web view definition + * @returns New copy of web view definition with updates applied + */ +export function mergeUpdatablePropertiesIntoWebViewDefinition( + webViewDefinition: T, + updateInfo: WebViewDefinitionUpdateInfo, +): T { + const webViewUpdate = getUpdatablePropertiesFromWebViewDefinition(updateInfo); + // If update properties aren't specified, keep the original values + const mergedProperties = Object.fromEntries( + Object.entries(webViewUpdate).map(([key, value]) => [ + key, + // Reminding TypeScript that key is from entries of updatable properties + // eslint-disable-next-line no-type-assertion/no-type-assertion + value || webViewDefinition[key as keyof WebViewDefinitionUpdatableProperties], + ]), + ); + return { + ...webViewDefinition, + ...mergedProperties, + }; +} + +/** + * Gets the updatable properties on the WebView definition with the specified ID + * + * @param webViewId The ID of the WebView whose updatable properties to get + * @returns Updatable properties of the WebView definition with the specified ID or undefined if not + * found + * @throws If the papi dock layout has not been registered + */ +export function getWebViewDefinitionUpdatablePropertiesSync( + webViewId: string, +): WebViewDefinitionUpdatableProperties | undefined { + const webViewDefinition = getDockLayoutSync().getWebViewDefinition(webViewId); + if (webViewDefinition === undefined) return undefined; + + return getUpdatablePropertiesFromWebViewDefinition(webViewDefinition); +} + +/** + * Converts web view definition used in an actual docking tab into saveable web view information by + * stripping out the members we don't want to save + * + * @param webViewDefinition Web view to save + * @returns Saveable web view information based on `webViewDefinition` + */ +export function convertWebViewDefinitionToSaved( + webViewDefinition: WebViewDefinition, +): SavedWebViewDefinition { + const webViewDefinitionCloned: Omit & + Partial< + Pick< + WebViewDefinition, + 'content' | 'allowScripts' | 'allowSameOrigin' | 'allowedFrameSources' + > + > & + Partial> = { ...webViewDefinition }; + // We don't want to keep the webView content so the web view provider can provide it again when + // deserializing + delete webViewDefinitionCloned.content; + delete webViewDefinitionCloned.styles; + // We don't want to keep security-related properties so the web view doesn't get loaded with the + // wrong security somehow. The web view provider should provide this every time it provides the + // content + delete webViewDefinitionCloned.allowScripts; + delete webViewDefinitionCloned.allowSameOrigin; + delete webViewDefinitionCloned.allowedFrameSources; + return webViewDefinitionCloned; +} + +// #endregion + +// #region Web view options + +/** Set up defaults for options for getting a web view */ +function getWebViewOptionsDefaults(options: GetWebViewOptions): GetWebViewOptions { + const optionsDefaulted = cloneDeep(options); + if ('existingId' in optionsDefaulted && !('createNewIfNotFound' in optionsDefaulted)) + optionsDefaulted.createNewIfNotFound = true; + + return optionsDefaulted; +} + +// #endregion + +// #region Set up global variables to use in `getWebView`'s `imports` below + +globalThis.getWebViewDefinitionUpdatablePropertiesById = + getWebViewDefinitionUpdatablePropertiesSync; +globalThis.updateWebViewDefinitionById = updateWebViewDefinitionSync; + +// #endregion + +// #region getWebView + +/** + * Creates a new web view or gets an existing one depending on if you request an existing one and if + * the web view provider decides to give that existing one to you (it is up to the provider). + * + * @param webViewType Type of WebView to create + * @param layout Information about where you want the web view to go. Defaults to adding as a tab + * @param options Options that affect what this function does. For example, you can provide an + * existing web view ID to request an existing web view with that ID. + * @returns Promise that resolves to the ID of the webview we got or undefined if the provider did + * not create a WebView for this request. + * @throws If something went wrong like the provider for the webViewType was not found + */ +export const getWebView = async ( + webViewType: WebViewType, + layout?: Layout, + options?: GetWebViewOptions, +): Promise => { + // Parameter defaulting doesn't work with network objects, so do it first thing here + /* eslint-disable no-param-reassign */ + if (!layout) layout = { type: 'tab' }; + if (!options) options = {}; + /* eslint-enable no-param-reassign */ + + const optionsDefaulted = getWebViewOptionsDefaults(options); + // ENHANCEMENT: If they aren't looking for an existingId, we could get the webview without + // searching for an existing webview and send it to the renderer, skipping the part where we send + // to the renderer, then search for an existing webview, then get the webview + + // Get the webview definition from the webview provider + const webViewProvider = await webViewProviderService.get(webViewType); + + if (!webViewProvider) + throw new Error(`getWebView: Cannot find Web View Provider for webview type ${webViewType}`); + + // Find existing webView if one exists + /** Either the existing webview with the specified ID or a placeholder webview if one was not found */ + let existingSavedWebView: SavedWebViewDefinition | undefined; + // Look for existing webview + if (optionsDefaulted.existingId) { + // Expect this to be a tab. + // eslint-disable-next-line no-type-assertion/no-type-assertion + const existingWebView = (await getDockLayout()).dockLayout.find( + optionsDefaulted.existingId === '?' + ? // If they provided '?', that means look for any webview with a matching webViewType + (item) => { + // This is not a webview + if (!('data' in item)) return false; + + // Find any webview with the specified webViewType. Type assert the unknown `data`. + // eslint-disable-next-line no-type-assertion/no-type-assertion + return (item.data as WebViewDefinition).webViewType === webViewType; + } + : // If they provided any other string, look for a webview with that ID + optionsDefaulted.existingId, + ) as TabInfo | undefined; + if (existingWebView) { + // We found the webview! Save it to send to the web view provider + existingSavedWebView = convertWebViewDefinitionToSaved( + // Type assert the unknown `data`. + // eslint-disable-next-line no-type-assertion/no-type-assertion + existingWebView.data as WebViewDefinition, + ); + // Load the web view state since the web view provider doesn't have access to the data store + existingSavedWebView.state = getFullWebViewStateById(existingWebView.id); + } + } + + // We didn't find an existing web view with the ID + if (!existingSavedWebView) { + // If we are not looking to create a new webview, then don't. + if ('existingId' in optionsDefaulted && !optionsDefaulted.createNewIfNotFound) return undefined; + // If we want to create a new webview, set a placeholder with a new ID + existingSavedWebView = { webViewType, id: newGuid() }; + } + + // Create the new webview or load if it already existed + const webView = await webViewProvider.getWebView(existingSavedWebView, optionsDefaulted); + + // The web view provider didn't want to create this web view + if (!webView) return undefined; + + // Set up WebViewDefinition default values + /** WebView.contentType is assumed to be React by default. Extensions can specify otherwise */ + const contentType = webView.contentType ? webView.contentType : WebViewContentType.React; + /** Default allowScripts to false for WebViewContentType.URL and true otherwise */ + let { allowScripts } = webView; + if (contentType !== WebViewContentType.URL) allowScripts = webView.allowScripts ?? true; + /** Default allowSameOrigin to true */ + const allowSameOrigin = webView.allowSameOrigin ?? true; + /** + * Only allow connecting to `papi-extension:` and `https:` urls. For HTML and React WebViews, this + * controls the `frame-src` directive and therefore which urls can be iframe `src`es in the + * WebView. For URL WebViews, this controls what urls the WebView can be. + */ + let { allowedFrameSources } = webView; + if (contentType !== WebViewContentType.URL && allowedFrameSources) + allowedFrameSources = allowedFrameSources.filter( + (hostValue) => hostValue.startsWith('https:') || hostValue.startsWith('papi-extension:'), + ); + + // Validate the WebViewDefinition to make sure it is acceptable + // If this is a URL WebView, it must match at least one of its `allowedFrameSources` Regex strings + // if any are supplied + if ( + contentType === WebViewContentType.URL && + allowedFrameSources && + !allowedFrameSources.some((regexString) => new RegExp(regexString).test(webView.content)) + ) + throw new Error( + `getWebView: URL WebView content ${webView.content} did not match any of its allowedFrameSources!`, + ); + + if (webView.state) + // The web view provider might have updated the web view state, so save it + setFullWebViewStateById(webView.id, webView.state); + + // `webViewRequire`, `getWebViewStateById`, and `setWebViewStateById` below are defined in `src\renderer\global-this.model.ts` + // `useWebViewState` below is defined in `src\shared\global-this.model.ts` + // We have to bind `useWebViewState` to the current `window` context because calls within PAPI don't have access to a webview's `window` context + /** + * String that sets up 'import' statements in the webview to pull in libraries and clear out + * internet access and such + * + * WARNING: `window.top` is not deletable as a security feature (websites need to know if they are + * running embedded in an iframe), so the child iframes are NOT isolated from their parents. We + * perform a number of tasks to mitigate this issue, but it would be very nice to find a way to + * properly delete `window.top` + */ + const imports = ` + window.papi = window.parent.papi; + window.React = window.parent.React; + window.ReactJsxRuntime = window.parent.ReactJsxRuntime; + window.ReactDom = window.parent.ReactDom; + window.ReactDOMClient = window.parent.ReactDOMClient; + window.createRoot = window.parent.createRoot; + window.SillsdevScripture = window.parent.SillsdevScripture; + var require = window.parent.webViewRequire; + var getWebViewStateById = window.parent.getWebViewStateById; + var setWebViewStateById = window.parent.setWebViewStateById; + window.getWebViewState = (stateKey) => { return getWebViewStateById('${webView.id}', stateKey) }; + window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) }; + window.useWebViewState = window.parent.useWebViewState.bind(window); + var getWebViewDefinitionUpdatablePropertiesById = window.parent.getWebViewDefinitionUpdatablePropertiesById; + window.getWebViewDefinitionUpdatableProperties = () => { return getWebViewDefinitionUpdatablePropertiesById('${webView.id}')} + var updateWebViewDefinitionById = window.parent.updateWebViewDefinitionById; + window.updateWebViewDefinition = (webViewDefinitionUpdateInfo) => { return updateWebViewDefinitionById('${webView.id}', webViewDefinitionUpdateInfo)} + window.fetch = papi.fetch; + delete window.parent; + delete window.top; + delete window.frameElement; + delete window.XMLHttpRequest; + delete window.WebSocket; + `; + + /** Nonce used to allow scripts and styles to run */ + // TODO: Generating nonces every time causes webviews to rerender every time `getWebView` is used + // on an existing webview such as when the extension host is restarted. Should we save webview + // nonces so the `content` can be the same and not have to rerender? + // Or this could solve the problem as well https://github.com/paranext/paranext-core/issues/282 + const srcNonce = newNonce(); + + // Build the contents of the iframe + let webViewContent: string; + /** CSP for allowing only certain scripts and styles */ + let specificSrcPolicy: string; + switch (contentType) { + case WebViewContentType.HTML: + // Add wrapping to turn a plain string into an iframe + webViewContent = webView.content.includes('${webView.content}`; + // TODO: Please combine our CSP with HTML-provided CSP so we can add the import nonce and they can add nonces and stuff instead of allowing 'unsafe-inline' + specificSrcPolicy = "'unsafe-inline'"; + break; + case WebViewContentType.URL: + webViewContent = webView.content; + // CSP does not apply to these webViews. If we ever add a `csp` attribute to WebView iframes, + // we might need to add this URL's schema to the CSP + specificSrcPolicy = ''; + break; + default: { + // Defaults to React webview definition. + // eslint-disable-next-line no-type-assertion/no-type-assertion + const reactWebView = webView as WebViewDefinitionReact; + + // Add the component as a script + // WARNING: DO NOT add anything between the closing of the script tag and the insertion of + // reactWebView.contents. Doing so would mess up debugging web views + webViewContent = ` + + + ${ + reactWebView.styles + ? `` + : '' + } + + +
+
+ + + `; + specificSrcPolicy = `'nonce-${srcNonce}'`; + break; + } + } + + /** + * Content security policy header for the webview - controls what resources scripts and other + * things can access. + * + * Design decisions and guiding principles at + * https://github.com/paranext/paranext/wiki/Content-Security-Policy-Design + * + * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON + * + * Please uncomment the image creation arbitrary code execution in `evil.js`'s WebView when you + * make changes so we can double check it is still successfully blocked. + */ + // default-src 'none' so things can't happen unless we allow them + // script-src-elem allows script tags but not in-line attribute scripts. Using this instead of + // just `script-src` for lower chance of arbitrary code execution (and because index.ejs CSP has + // it) + // 'self' so scripts can be loaded from us + // 'wasm-unsafe-eval' because webview iframes want to use wasm + // papi-extension: so scripts can be loaded from installed extensions + // TODO: this probably doesn't work right now because it is purposely not included in the CSP + // in index.ejs. Test this once we fix webview code to be retrieved from the backend paranext-core#89 + // ${specificSrcPolicy} so we can load the specific scripts needed from the iframe + // style-src allows them to use style/link tags and style attributes on tags + // 'self' so styles can be loaded from us + // papi-extension: so scripts can be loaded from installed extensions + // 'unsafe-inline' because that's how bundled libraries' styles are loaded in :( like MUI + // frame-src determines what iframes can be loaded + // This is derived from the WebViewDefinition's `allowedFrameSources`. WebViews must specify + // the host values they want to be listed here. Since this CSP inherits from the `index.ejs` + // CSP, these values must be within 'self', papi-extension:, and https: + // See `index.ejs` for more info on why these sources are allowed + // object-src 'none' to prevent insecure object and embed until we have a reason to use them + // worker-src determines from where they can run web workers + // 'none' - we can consider changing if someone gives us a reason to run workers in the renderer + // manifest-src determines what manifest can be loaded for this iframe + // for now, inherit 'none' from default-src - not sure why they would need a manifest + // connect-src only communicate over the network through JS APIs as we allow + // 'self' so the iframe can only communicate over the internet with us and not outside the + // iframe + // Note: because webview iframes are on same origin as parent window, they can still use things + // that are imported to their script via the imports string above and can call the parent + // window's objects directly. Objects passed through from the parent window still have full + // internet access. We must essentially assume they can find a way to access the internet + // through the same connect-src as index.ejs. However, it is probably best for them to use only + // things we give them from parent, so might as well keep it restricted here. + // img-src load images + // 'self' so images can be loaded from us + // papi-extension: so images can be loaded from installed extensions + // https: so they can load images over secure connections + // data: so they can load data urls + // media-src load audio, video, etc + // 'self' so media can be loaded from us + // papi-extension: so media can be loaded from installed extensions + // https: so media can be loaded over secure connections + // data: so they can load data urls + // font-src load fonts + // 'self' so fonts can be loaded from us + // papi-extension: so fonts can be loaded from installed extensions + // https: so fonts can be loaded over secure connections + // data: so they can load data urls + // form-action 'self' lets the form submit to us + // TODO: not sure if this is needed. If we can attach handlers to forms, we can probably remove + // this + // navigate-to 'none' prevents them from redirecting this iframe somewhere else + // WARNING: This is experimental and does not work as of July 2023! It is here for future + // compatibility in case they add support for it + const contentSecurityPolicy = ``; + + // Add a script at the start of the head to give access to papi + const headStart = webViewContent.indexOf('', headStart); + + // Inject the CSP and import scripts into the html if it is not a URL iframe + if (contentType !== WebViewContentType.URL) + webViewContent = `${webViewContent.substring(0, headEnd + 1)} + ${contentSecurityPolicy} + ${webViewContent.substring(headEnd + 1)}`; + + const updatedWebView: WebViewTabProps = { + ...webView, + contentType, + content: webViewContent, + allowScripts, + allowSameOrigin, + allowedFrameSources, + }; + + const updatedLayout = (await getDockLayout()).addWebViewToDock(updatedWebView, layout); + + // If we received a layout (meaning it created a new webview instead of updating an existing one), + // inform web view consumers that we added a new web view + if (updatedLayout) + onDidAddWebViewEmitter.emit({ + webView: convertWebViewDefinitionToSaved(updatedWebView), + layout: updatedLayout, + }); + + return webView.id; +}; + +// #endregion + +// #region Initialization + +/** Whether this service has finished setting up */ +let isInitialized = false; + +/** Promise that resolves when this service is finished initializing */ +let initializePromise: Promise | undefined; + +/** Sets up the WebViewService. Runs only once */ +export const initialize = () => { + if (initializePromise) return initializePromise; + + initializePromise = (async (): Promise => { + if (isInitialized) return; + + // Set up subscriptions that the service needs to work + + // We do not want iframes to be able to create their own iframes and scripts in the main window + // context so they cannot execute arbitrary scripts without sandboxing. This prevents them from + // showing modals, navigating to different pages, etc. + // These methods work as of July 2023 + + // Create a MutationObserver that watches the document for added iframes that do not have + // permission to be running and removes them before they execute any code. + const observer = new MutationObserver(removeForbiddenElements); + // We want the observer to watch for all elements added or removed in this document + // This does not pay attention to elements in iframes. They already have sandboxing, so there + // is no need + // We also want to watch the 'src' and 'srcdoc' attributes on iframes to catch forbidden + // iframes + // We don't need to watch the sandbox attribute to make sure it doesn't change because sandbox + // doesn't update unless an iframe is removed and added + // https://stackoverflow.com/a/16135502/8535752 + observer.observe(document, { + subtree: true, + childList: true, + attributeFilter: ['src', 'srcdoc'], + }); + + // #region delete some things on `window` for a quick prevention for same-origin child iframes + // like HTML and React WebViews from doing things we don't want them to do + // We can change these to monkey patches with validation that they are coming from the + // renderer if we need them in the renderer or we can save out variables and use those + + // Following are a number of deletions that correspond to various iframe sandbox values + // as noted in comments. HTML and React WebView iframes have access to these through + // `window.top` because they are on the same origin, so we must prevent access in addition to + // sandboxing + + // Remove the ability to do presentations + // Corresponds to iframe sandbox `allow-presentation` + // `window.navigator` does not have a setter but is configurable, so we redefine the property + Object.defineProperty(window, 'navigator', { + writable: false, + value: new Proxy(globalThis.navigator, { + get(obj, prop) { + if (prop === 'presentation') return undefined; + // Get the property on the object - doesn't matter what it is + // eslint-disable-next-line no-type-assertion/no-type-assertion + return obj[prop as keyof typeof obj]; + }, + }), + }); + + // Remove the ability to show modals + // Corresponds to iframe sandbox `allow-modals` + // @ts-expect-error we want to remove the ability to show modals + delete globalThis.alert; + // @ts-expect-error we want to remove the ability to show modals + delete globalThis.confirm; + // @ts-expect-error we want to remove the ability to show modals + delete globalThis.print; + // @ts-expect-error we want to remove the ability to show modals + delete globalThis.prompt; + + // TODO: Remove the ability to change the screen orientation? https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/lock + // Corresponds to iframe sandbox `allow-orientation-lock` + + // TODO: Remove the ability to lock the pointer? https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API + // Corresponds to iframe sandbox `allow-pointer-lock` + + // Remove the ability to create popups + // Corresponds to iframe sandbox `allow-popups` + // @ts-expect-error we want to remove the ability to create popups + delete globalThis.open; + // @ts-expect-error we want to remove the ability to create popups + delete globalThis.showModalDialog; + + // #endregion + + // #region monkey patches on `window` to prevent same-origin child iframes like HTML and React + // WebViews from doing things we don't want them to do + // WARNING: calling these requires us to generate a call stack, so all of these things should + // be used as sparingly as possible since they are now less performant than usual + + // Monkey-patch document.createElement so new script tags cannot be added by anything but our + // code (since we load renderer files in chunks) + const createElementOriginal = document.createElement.bind(document); + // If we name this function, we will need to change the regex testing the stack traces, and we + // may also have trouble with minifying production code. Leaving this function unnamed keeps + // things simpler + // eslint-disable-next-line func-names + document.createElement = function (...args: Parameters) { + const [tagNameCaps] = args; + + const tagName = tagNameCaps.toLowerCase(); + if (FORBIDDEN_HTML_TAGS.includes(tagName) || RESTRICTED_HTML_TAGS.includes(tagName)) { + const stackTrace = Error().stack ?? ''; + const isInRenderer = getRendererScriptRegex().test(stackTrace); + if (isInRenderer) { + logger.debug( + `Allowed ${tagName} on renderer document. If this isn't recognized, this is a very serious security violation.\nStack: ${stackTrace}`, + ); + } else { + const message = `Rejected creating new ${tagName} tag on renderer document! Not allowed.\nStack: ${stackTrace}`; + // LogError puts an error in the console and throws an error. We don't want to scare + // anyone with the script and iframe tags evil adds to test this feature, so let's not + // log an error in development. But no exceptions when packaged + if (globalThis.isPackaged || !stackTrace.includes('at evil.web-view.html')) + throw new LogError(message); + throw new Error(message); + } + } + return createElementOriginal(...args); + }; + + // #endregion + + isInitialized = true; + })(); + + return initializePromise; +}; + +// #endregion + +const papiWebViewService: WebViewServiceType = { + onDidAddWebView, + getWebView, +}; + +/** Register the network object that backs the PAPI webview service */ +// To use this service, you should use `web-view.service.ts` +export async function startWebViewService(): Promise { + await initialize(); + await networkObjectService.set( + NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, + papiWebViewService, + ); +} diff --git a/src/renderer/testing/about-panel.component.tsx b/src/renderer/testing/about-panel.component.tsx index ab9dbdc01c..0591e85dae 100644 --- a/src/renderer/testing/about-panel.component.tsx +++ b/src/renderer/testing/about-panel.component.tsx @@ -1,5 +1,5 @@ import icon from '@assets/icon.png'; -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; export const TAB_TYPE_ABOUT = 'about'; diff --git a/src/renderer/testing/test-buttons-panel.component.tsx b/src/renderer/testing/test-buttons-panel.component.tsx index 77cc938e52..d2d7310097 100644 --- a/src/renderer/testing/test-buttons-panel.component.tsx +++ b/src/renderer/testing/test-buttons-panel.component.tsx @@ -5,7 +5,7 @@ import * as networkService from '@shared/services/network.service'; import * as commandService from '@shared/services/command.service'; import { debounce, getErrorMessage, isString } from '@shared/utils/util'; import logger from '@shared/services/logger.service'; -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; import useData from '@renderer/hooks/papi-hooks/use-data.hook'; import useDataProvider from '@renderer/hooks/papi-hooks/use-data-provider.hook'; diff --git a/src/renderer/testing/test-layout.data.ts b/src/renderer/testing/test-layout.data.ts index 17bdc69306..700d7a8a93 100644 --- a/src/renderer/testing/test-layout.data.ts +++ b/src/renderer/testing/test-layout.data.ts @@ -1,4 +1,4 @@ -import { SavedTabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo } from '@shared/models/docking-framework.model'; import { LayoutBase } from 'rc-dock'; import { TAB_TYPE_ABOUT } from '@renderer/testing/about-panel.component'; import { TAB_TYPE_BUTTONS } from '@renderer/testing/test-buttons-panel.component'; diff --git a/src/renderer/testing/test-panel.component.tsx b/src/renderer/testing/test-panel.component.tsx index 4dcf70260c..90bcc19f98 100644 --- a/src/renderer/testing/test-panel.component.tsx +++ b/src/renderer/testing/test-panel.component.tsx @@ -1,4 +1,4 @@ -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; export const TAB_TYPE_TEST = 'tab'; diff --git a/src/renderer/testing/test-quick-verse-heresy-panel.component.tsx b/src/renderer/testing/test-quick-verse-heresy-panel.component.tsx index dd256b2d23..a5ab8b50cb 100644 --- a/src/renderer/testing/test-quick-verse-heresy-panel.component.tsx +++ b/src/renderer/testing/test-quick-verse-heresy-panel.component.tsx @@ -1,6 +1,6 @@ import './test-buttons-panel.component.css'; import useData from '@renderer/hooks/papi-hooks/use-data.hook'; -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import { debounce } from '@shared/utils/util'; import { useState, useMemo, useCallback } from 'react'; import { TextField } from 'papi-components'; diff --git a/src/shared/global-this.model.ts b/src/shared/global-this.model.ts index bd4c7ee73f..3e8b178e76 100644 --- a/src/shared/global-this.model.ts +++ b/src/shared/global-this.model.ts @@ -7,7 +7,7 @@ import { WebViewDefinitionUpdatableProperties, WebViewDefinitionUpdateInfo, WebViewProps, -} from '@shared/data/web-view.model'; +} from '@shared/models/web-view.model'; /** * Variables that are defined in global scope. These must be defined in main.ts (main), index.ts diff --git a/src/shared/models/docking-framework.model.ts b/src/shared/models/docking-framework.model.ts new file mode 100644 index 0000000000..877663b3a1 --- /dev/null +++ b/src/shared/models/docking-framework.model.ts @@ -0,0 +1,191 @@ +import { MutableRefObject, ReactNode } from 'react'; +import { DockLayout, DropDirection, LayoutBase } from 'rc-dock'; +import { + SavedWebViewDefinition, + WebViewDefinition, + WebViewDefinitionUpdateInfo, +} from '@shared/models/web-view.model'; + +/** + * Saved information used to recreate a tab. + * + * - {@link TabLoader} loads this into {@link TabInfo} + * - {@link TabSaver} saves {@link TabInfo} into this + */ +export type SavedTabInfo = { + /** + * Tab ID - a unique identifier that identifies this tab. If this tab is a WebView, this ID will + * match the `WebViewDefinition.id` + */ + id: string; + /** Type of tab - indicates what kind of built-in tab this info represents */ + tabType: string; + /** Data needed to load the tab */ + data?: unknown; +}; + +/** + * Information that Paranext uses to create a tab in the dock layout. + * + * - {@link TabLoader} loads {@link SavedTabInfo} into this + * - {@link TabSaver} saves this into {@link SavedTabInfo} + */ +export type TabInfo = SavedTabInfo & { + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + tabIconUrl?: string; + /** Text to show on the title bar of the tab */ + tabTitle: string; + /** Content to show inside the tab. */ + content: ReactNode; + /** (optional) Minimum width that the tab can become in CSS `px` units */ + minWidth?: number; + /** (optional) Minimum height that the tab can become in CSS `px` units */ + minHeight?: number; +}; + +/** + * Function that takes a {@link SavedTabInfo} and creates a Paranext tab out of it. Each type of tab + * must provide a {@link TabLoader}. + * + * For now all tab creators must do their own data type verification + */ +export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; + +/** + * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can + * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are + * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). + * + * @param tabInfo The Paranext tab to save + * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab + */ +export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; + +/** Information about a tab in a panel */ +interface TabLayout { + type: 'tab'; +} + +/** + * Indicates where to display a floating window + * + * - `cascade` - place the window a bit below and to the right of the previously created floating + * window + * - `center` - center the window in the dock layout + */ +type FloatPosition = 'cascade' | 'center'; + +/** The dimensions for a floating tab in CSS `px` units */ +export type FloatSize = { width: number; height: number }; + +/** Information about a floating window */ +export interface FloatLayout { + type: 'float'; + floatSize?: FloatSize; + /** Where to display the floating window. Defaults to `cascade` */ + position?: FloatPosition; +} + +export type PanelDirection = + | 'left' + | 'right' + | 'bottom' + | 'top' + // TODO: The following produce a panel but probably aren't useful for panels - IJH 2023-05-5 + | 'before-tab' + | 'after-tab' + | 'maximize' + | 'move' + | 'active' + | 'update'; + +/** Information about a panel */ +interface PanelLayout { + type: 'panel'; + direction?: PanelDirection; + /** If undefined, it will add in the `direction` relative to the previously added tab. */ + targetTabId?: string; +} + +/** Information about how a Paranext tab fits into the dock layout */ +export type Layout = TabLayout | FloatLayout | PanelLayout; + +/** Event emitted when webViews are created */ +export type AddWebViewEvent = { + webView: SavedWebViewDefinition; + layout: Layout; +}; + +/** Props that are passed to the web view tab component */ +export type WebViewTabProps = WebViewDefinition; + +/** Rc-dock's onLayoutChange prop made asynchronous - resolves */ +export type OnLayoutChangeRCDock = ( + newLayout: LayoutBase, + currentTabId?: string, + direction?: DropDirection, +) => Promise; + +/** Properties related to the dock layout */ +export type PapiDockLayout = { + /** The rc-dock dock layout React element ref. Used to perform operations on the layout */ + dockLayout: DockLayout; + /** + * A ref to a function that runs when the layout changes. We set this ref to our + * {@link onLayoutChange} function + */ + onLayoutChangeRef: MutableRefObject; + /** + * Add or update a tab in the layout + * + * @param savedTabInfo Info for tab to add or update + * @param layout Information about where to put a new tab + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` + */ + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => Layout | undefined; + /** + * Add or update a webview in the layout + * + * @param webView Web view to add or update + * @param layout Information about where to put a new webview + * @returns If WebView added, final layout used to display the new webView. If existing webView + * updated, `undefined` + */ + addWebViewToDock: (webView: WebViewTabProps, layout: Layout) => Layout | undefined; + /** + * Remove a tab in the layout + * + * @param tabId ID of the tab to remove + */ + removeTabFromDock: (tabId: string) => boolean; + /** + * Gets the WebView definition for the web view with the specified ID + * + * @param webViewId The ID of the WebView whose web view definition to get + * @returns WebView definition with the specified ID or undefined if not found + */ + getWebViewDefinition: (webViewId: string) => WebViewDefinition | undefined; + /** + * Updates the WebView with the specified ID with the specified properties + * + * @param webViewId The ID of the WebView to update + * @param updateInfo Properties to update on the WebView. Any unspecified properties will stay the + * same + * @returns True if successfully found the WebView to update; false otherwise + */ + updateWebViewDefinition: (webViewId: string, updateInfo: WebViewDefinitionUpdateInfo) => boolean; + /** + * The layout to use as the default layout if the dockLayout doesn't have a layout loaded. + * + * TODO: This should be removed and the `testLayout` imported directly in this file once this + * service is refactored to split the code between processes. The only reason this is passed from + * `platform-dock-layout.component.tsx` is that we cannot import `testLayout` here since this + * service is currently all shared code. Refactor should happen in #203 + */ + testLayout: LayoutBase; +}; diff --git a/src/shared/models/web-view-provider.model.ts b/src/shared/models/web-view-provider.model.ts index fa1e2e7ab8..9c6f3f422e 100644 --- a/src/shared/models/web-view-provider.model.ts +++ b/src/shared/models/web-view-provider.model.ts @@ -2,7 +2,7 @@ import { GetWebViewOptions, WebViewDefinition, SavedWebViewDefinition, -} from '@shared/data/web-view.model'; +} from '@shared/models/web-view.model'; import { DisposableNetworkObject, NetworkObject, diff --git a/src/shared/data/web-view.model.ts b/src/shared/models/web-view.model.ts similarity index 78% rename from src/shared/data/web-view.model.ts rename to src/shared/models/web-view.model.ts index 14eba16058..1249d115cb 100644 --- a/src/shared/data/web-view.model.ts +++ b/src/shared/models/web-view.model.ts @@ -1,63 +1,4 @@ -import { Dispatch, ReactNode, SetStateAction } from 'react'; - -/** - * Saved information used to recreate a tab. - * - * - {@link TabLoader} loads this into {@link TabInfo} - * - {@link TabSaver} saves {@link TabInfo} into this - */ -export type SavedTabInfo = { - /** - * Tab ID - a unique identifier that identifies this tab. If this tab is a WebView, this ID will - * match the `WebViewDefinition.id` - */ - id: string; - /** Type of tab - indicates what kind of built-in tab this info represents */ - tabType: string; - /** Data needed to load the tab */ - data?: unknown; -}; - -/** - * Information that Paranext uses to create a tab in the dock layout. - * - * - {@link TabLoader} loads {@link SavedTabInfo} into this - * - {@link TabSaver} saves this into {@link SavedTabInfo} - */ -export type TabInfo = SavedTabInfo & { - /** - * Url of image to show on the title bar of the tab - * - * Defaults to Platform.Bible logo - */ - tabIconUrl?: string; - /** Text to show on the title bar of the tab */ - tabTitle: string; - /** Content to show inside the tab. */ - content: ReactNode; - /** (optional) Minimum width that the tab can become in CSS `px` units */ - minWidth?: number; - /** (optional) Minimum height that the tab can become in CSS `px` units */ - minHeight?: number; -}; - -/** - * Function that takes a {@link SavedTabInfo} and creates a Paranext tab out of it. Each type of tab - * must provide a {@link TabLoader}. - * - * For now all tab creators must do their own data type verification - */ -export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; - -/** - * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can - * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are - * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). - * - * @param tabInfo The Paranext tab to save - * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab - */ -export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; +import { Dispatch, SetStateAction } from 'react'; /** The type of code that defines a webview's content */ export enum WebViewContentType { @@ -225,9 +166,6 @@ export type SavedWebViewDefinition = ) & Pick; -/** Props that are passed to the web view tab component */ -export type WebViewTabProps = WebViewDefinition; - /** The properties on a WebViewDefinition that may be updated when that webview is already displayed */ // To allow more properties to be updated, add them in // `web-view.service.ts` -> `getUpdatablePropertiesFromWebViewDefinition` @@ -326,62 +264,8 @@ export type WebViewProps = { updateWebViewDefinition: UpdateWebViewDefinition; }; -/** Information about a tab in a panel */ -interface TabLayout { - type: 'tab'; -} - -/** - * Indicates where to display a floating window - * - * - `cascade` - place the window a bit below and to the right of the previously created floating - * window - * - `center` - center the window in the dock layout - */ -type FloatPosition = 'cascade' | 'center'; - -/** The dimensions for a floating tab in CSS `px` units */ -export type FloatSize = { width: number; height: number }; - -/** Information about a floating window */ -export interface FloatLayout { - type: 'float'; - floatSize?: FloatSize; - /** Where to display the floating window. Defaults to `cascade` */ - position?: FloatPosition; -} - -export type PanelDirection = - | 'left' - | 'right' - | 'bottom' - | 'top' - // TODO: The following produce a panel but probably aren't useful for panels - IJH 2023-05-5 - | 'before-tab' - | 'after-tab' - | 'maximize' - | 'move' - | 'active' - | 'update'; - -/** Information about a panel */ -interface PanelLayout { - type: 'panel'; - direction?: PanelDirection; - /** If undefined, it will add in the `direction` relative to the previously added tab. */ - targetTabId?: string; -} - -/** Information about how a Paranext tab fits into the dock layout */ -export type Layout = TabLayout | FloatLayout | PanelLayout; - -/** Event emitted when webViews are created */ -export type AddWebViewEvent = { - webView: SavedWebViewDefinition; - layout: Layout; -}; - /** Options that affect what `webViews.getWebView` does */ +// This can't live in web-view.service-model.ts because extensions can see it export type GetWebViewOptions = { /** * If provided and if a web view with this ID exists, requests from the web view provider an diff --git a/src/shared/services/web-view.service-model.ts b/src/shared/services/web-view.service-model.ts new file mode 100644 index 0000000000..c98f13648b --- /dev/null +++ b/src/shared/services/web-view.service-model.ts @@ -0,0 +1,46 @@ +import { GetWebViewOptions, WebViewId, WebViewType } from '@shared/models/web-view.model'; +import { AddWebViewEvent, Layout } from '@shared/models/docking-framework.model'; +import { PapiEvent } from '@shared/models/papi-event.model'; +import { serializeRequestType } from '@shared/utils/papi-util'; + +/** + * JSDOC SOURCE papiWebViewService + * + * Service exposing various functions related to using webViews + * + * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either + * HTML or React components. + */ +export interface WebViewServiceType { + /** Event that emits with webView info when a webView is added */ + onDidAddWebView: PapiEvent; + + /** + * Creates a new web view or gets an existing one depending on if you request an existing one and + * if the web view provider decides to give that existing one to you (it is up to the provider). + * + * @param webViewType Type of WebView to create + * @param layout Information about where you want the web view to go. Defaults to adding as a tab + * @param options Options that affect what this function does. For example, you can provide an + * existing web view ID to request an existing web view with that ID. + * @returns Promise that resolves to the ID of the webview we got or undefined if the provider did + * not create a WebView for this request. + * @throws If something went wrong like the provider for the webViewType was not found + */ + getWebView: ( + webViewType: WebViewType, + layout?: Layout, + options?: GetWebViewOptions, + ) => Promise; +} + +/** Prefix on requests that indicates that the request is related to webView operations */ +const CATEGORY_WEB_VIEW = 'webView'; + +/** Name to use when creating a network event that is fired when webViews are created */ +export const EVENT_NAME_ON_DID_ADD_WEB_VIEW = serializeRequestType( + CATEGORY_WEB_VIEW, + 'onDidAddWebView', +); + +export const NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE = 'WebViewService'; diff --git a/src/shared/services/web-view.service.ts b/src/shared/services/web-view.service.ts index 6712912983..2d187d9723 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -1,1356 +1,67 @@ -/** - * Service that handles WebView-related operations Likely shouldn't need/want to expose this whole - * service on papi, but most things are exposed via papiWebViewService - */ -import cloneDeep from 'lodash/cloneDeep'; -import { isRenderer } from '@shared/utils/internal-util'; +import { PapiEvent } from '@shared/models/papi-event.model'; +import { GetWebViewOptions, WebViewType } from '@shared/models/web-view.model'; +import { getNetworkEvent } from '@shared/services/network.service'; import { - SerializedRequestType, - Unsubscriber, - aggregateUnsubscriberAsyncs, - serializeRequestType, -} from '@shared/utils/papi-util'; -import { getErrorMessage, isString, newGuid, newNonce, wait } from '@shared/utils/util'; -import { MutableRefObject } from 'react'; -import { createNetworkEventEmitter } from '@shared/services/network.service'; -import { - AddWebViewEvent, - Layout, - SavedTabInfo, - TabInfo, - WebViewDefinitionReact, - WebViewContentType, - WebViewTabProps, - WebViewType, - WebViewId, - GetWebViewOptions, - WebViewDefinition, - SavedWebViewDefinition, - WebViewDefinitionUpdateInfo, - WebViewDefinitionUpdatableProperties, -} from '@shared/data/web-view.model'; -import * as networkService from '@shared/services/network.service'; -import webViewProviderService from '@shared/services/web-view-provider.service'; -import { DockLayout, DropDirection, LayoutBase } from 'rc-dock'; -import AsyncVariable from '@shared/utils/async-variable'; + EVENT_NAME_ON_DID_ADD_WEB_VIEW, + NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, + WebViewServiceType, +} from '@shared/services/web-view.service-model'; +import { AddWebViewEvent, Layout } from '@shared/models/docking-framework.model'; +import networkObjectService from '@shared/services/network-object.service'; +import { wait } from '@shared/utils/util'; import logger from '@shared/services/logger.service'; -import LogError from '@shared/log-error.model'; -import memoizeOne from 'memoize-one'; - -/** Rc-dock's onLayoutChange prop made asynchronous - resolves */ -export type OnLayoutChangeRCDock = ( - newLayout: LayoutBase, - currentTabId?: string, - direction?: DropDirection, -) => Promise; -/** Properties related to the dock layout provided by `platform-dock-layout.component.tsx` */ -type PapiDockLayout = { - /** The rc-dock dock layout React element ref. Used to perform operations on the layout */ - dockLayout: DockLayout; - /** - * A ref to a function that runs when the layout changes. We set this ref to our - * {@link onLayoutChange} function - */ - onLayoutChangeRef: MutableRefObject; - /** - * Add or update a tab in the layout - * - * @param savedTabInfo Info for tab to add or update - * @param layout Information about where to put a new tab - * @returns If tab added, final layout used to display the new tab. If existing tab updated, - * `undefined` - */ - addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => Layout | undefined; - /** - * Add or update a webview in the layout - * - * @param webView Web view to add or update - * @param layout Information about where to put a new webview - * @returns If WebView added, final layout used to display the new webView. If existing webView - * updated, `undefined` - */ - addWebViewToDock: (webView: WebViewTabProps, layout: Layout) => Layout | undefined; - /** - * Remove a tab in the layout - * - * @param tabId ID of the tab to remove - */ - removeTabFromDock: (tabId: string) => boolean; - /** - * Gets the WebView definition for the web view with the specified ID - * - * @param webViewId The ID of the WebView whose web view definition to get - * @returns WebView definition with the specified ID or undefined if not found - */ - getWebViewDefinition: (webViewId: string) => WebViewDefinition | undefined; - /** - * Updates the WebView with the specified ID with the specified properties - * - * @param webViewId The ID of the WebView to update - * @param updateInfo Properties to update on the WebView. Any unspecified properties will stay the - * same - * @returns True if successfully found the WebView to update; false otherwise - */ - updateWebViewDefinition: (webViewId: string, updateInfo: WebViewDefinitionUpdateInfo) => boolean; - /** - * The layout to use as the default layout if the dockLayout doesn't have a layout loaded. - * - * TODO: This should be removed and the `testLayout` imported directly in this file once this - * service is refactored to split the code between processes. The only reason this is passed from - * `platform-dock-layout.component.tsx` is that we cannot import `testLayout` here since this - * service is currently all shared code. Refactor should happen in #203 - */ - testLayout: LayoutBase; -}; - -/** - * The iframe [sandbox attribute] - * (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if - * scripts are allowed to run on an iframe - */ -export const IFRAME_SANDBOX_ALLOW_SCRIPTS = 'allow-scripts'; -/** - * The iframe [sandbox attribute] - * (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if an - * iframe is allowed to interact with its parent as a same-origin website. The iframe must still be - * on the same origin as its parent in order to interact same-origin. - */ -export const IFRAME_SANDBOX_ALLOW_SAME_ORIGIN = 'allow-same-origin'; -/** - * The only `sandbox` attribute values we allow iframes with `src` to have including URL WebView - * iframes. These are separate than iframes with `srcdoc` for a few reasons: - * - * - These iframes cannot be on the same origin as the parent window even if `allow-same-origin` is - * present (unless they are literally on the same origin) because we do not allow `frame-src - * blob:` - * - `src` iframes do not inherit the CSP of their parent window. - * - We are not able to modify the `srcdoc` before inserting it to ensure it has a CSP that we control - * to attempt to prevent arbitrary code execution on same origin. We are trusting the browser's - * ability to create a strong and safe boundary between parent and child iframe in different - * origin. - * - * TODO: consider using `csp` attribute on iframe to mitigate this issue - * - Extension developers do not know what code they are executing if they use some random URL in - * `src` WebViews. - * - * The `sandbox` attribute controls what privileges iframe scripts and other things have: - * - * - `allow-same-origin` so the iframe can access the storage APIs (localstorage, cookies, etc) and - * other same-origin connections for its own origin. `blob:` iframes are considered part of the - * parent origin, but we block them with the CSP in `index.ejs`. For more information, see - * https://web.dev/articles/sandboxed-iframes - * - `allow-scripts` so the iframe can actually do things. Defaults to not present since src iframes - * can get scripts from anywhere. Extension developers should only enable this if needed as this - * increases the possibility of a security threat occurring. Defaults to false - * - * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON - * - * Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts' - * allows the child scripts to remove this sandbox attribute from the iframe. This should only be - * possible on iframes that are on the same origin as the parent including those that use `srcdoc` - * to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from - * creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any - * iframes that do not comply with these sandbox requirements. This successfully prevents iframes - * with too many privileges from executing as of July 2023. However, this means the sandboxing could - * do nothing for a determined hacker if they ever find a way around all this. We must distrust the - * whole renderer due to this issue. We will probably want to stay vigilant on security in this - * area. - */ -export const ALLOWED_IFRAME_SRC_SANDBOX_VALUES = [ - IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, - IFRAME_SANDBOX_ALLOW_SCRIPTS, -]; -/** - * The minimal `src` WebView iframe sandboxing. This is applied to WebView iframes that use `src` in - * `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRC_SANDBOX_VALUES} for more information on - * our sandboxing methods and why we chose these values. - * - * Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added - * conditionally depending on the WebViewDefinition in `web-view.component.tsx` - */ -export const WEBVIEW_IFRAME_SRC_SANDBOX = ALLOWED_IFRAME_SRC_SANDBOX_VALUES.filter( - (value) => value !== IFRAME_SANDBOX_ALLOW_SCRIPTS && value !== IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, -).join(' '); -/** - * The only `sandbox` attribute values we allow iframes with `srcdoc` to have including HTML and - * React WebView iframes. These are separate than iframes with `src` for a few reasons: - * - * - These iframes will be on the same origin as the parent window if `allow-same-origin` is present. - * This is very serious and demands significant security risk consideration. - * - `srcdoc` iframes inherit the CSP of their parent window (in our case, `index.ejs`) - * - We are modifying the `srcdoc` before inserting it to ensure it has a CSP that we control to - * attempt to prevent unintended code execution on same origin - * - Extension developers should know exactly what code they're running in `srcdoc` WebViews, whereas - * they could include some random URL in `src` WebViews - * - * TODO: consider requiring `srcdoc` WebView content to come directly from `papi-extension://` - * instead of assuming extension developers will bundle their WebView code? This would mean the - * only code that runs on same origin is code that extension developers definitely included in - * their extension bundle https://github.com/paranext/paranext-core/issues/604 - * - * The `sandbox` attribute controls what privileges iframe scripts and other things have: - * - * - `allow-same-origin` so the iframe can get papi and communicate and such - * - `allow-scripts` so the iframe can actually do things - * - * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON - * - * Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts' - * allows the child scripts to remove this sandbox attribute from the iframe. This should only be - * possible on iframes that are on the same origin as the parent including those that use `srcdoc` - * to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from - * creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any - * iframes that do not comply with these sandbox requirements. This successfully prevents iframes - * with too many privileges from executing as of July 2023. However, this means the sandboxing could - * do nothing for a determined hacker if they ever find a way around all this. We must distrust the - * whole renderer due to this issue. We will probably want to stay vigilant on security in this - * area. - */ -export const ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES = [...ALLOWED_IFRAME_SRC_SANDBOX_VALUES]; -/** - * The minimal `srcdoc` WebView iframe sandboxing. This is applied to WebView iframes that use - * `srcDoc` in `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES} for more - * information on our sandboxing methods and why we chose these values. - * - * Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added - * conditionally depending on the WebViewDefinition in `web-view.component.tsx` - */ -export const WEBVIEW_IFRAME_SRCDOC_SANDBOX = ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES.filter( - (value) => value !== IFRAME_SANDBOX_ALLOW_SCRIPTS && value !== IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, -).join(' '); -/** - * Get Regex to test stack traces against for creating script and iframe tags on the renderer - * document. Only renderer code is allowed to create script and iframe tags. script and iframe tags - * coming from any other source throw an error. - * - * Note that sourceURLs can't have spaces in them, so we explicitly test for a space before the - * source so bad actors can't put these special words into their sourceURL - */ -/* In development, safe errors look like this: -Error - at document.createElement (http://localhost/renderer.dev.js...) - at __webpack_require__.l (http://localhost/renderer.dev.js...) - ... -*/ -/* In development, bad errors look more like this: -Error - at document.createElement (http://localhost/renderer.dev.js...) - at evil.web-view.htmlfile://app.asar -*/ -/* In production, safe errors look like this: -Error - at Qt.document.createElement (file:///C:/Users/app.asar/dist/renderer/renderer.js...) - at i.l (file:///C:/Users/app.asar/dist/renderer/renderer.js...) - ... -*/ -/* In production, bad errors look more like this: -Error - at Qt.document.createElement (file:///C:/Users/app.asar/dist/renderer/stuffnthings) - at evil.web-view.htmlfile://app.asar -*/ -const getRendererScriptRegex = memoizeOne(() => - globalThis.isPackaged - ? /^.+\s+.+ \S*document\.createElement \(file:\/\/\S*app.asar\/dist\/renderer\/renderer\.js\S*\)\s+.+ \(file:\/\/\S*app.asar\/dist\/renderer\/renderer\.js\S*\)/ - : /^.+\s+.+ \S*document\.createElement \(https?:\/\/\S*\/renderer\.dev\.js\S*\)\s+.+ \(https?:\/\/\S*\/renderer\.dev\.js\S*\)/, +const onDidAddWebView: PapiEvent = getNetworkEvent( + EVENT_NAME_ON_DID_ADD_WEB_VIEW, ); -/** - * The HTML tags that are not allowed at all in the main renderer window. Our MutationObserver - * deletes these immediately if it sees them. - * - * WARNING: These are all untested. The MutationObserver was not fast enough to remove script tags - * before they executed code, so there is some chance these could do bad things too. - * - * TODO: Test these sometime - */ -// Maybe we don't actually need this... Maybe we should evaluate if we want this. -// Would lag things up if we changed our MutationObserver to use getElementsByTagName -const FORBIDDEN_HTML_TAGS = ['object', 'embed', 'frame', 'frameset']; -/** - * The HTML tags that are only allowed in the main renderer window if created by the renderer. Our - * monkey-patch on `document.createElement` protects these. - * - * Technically, all elements should really be created only by the renderer, but we must choose the - * security-related ones to guard closely since this is an inefficient check. - * - * Note: this only applies to tags added to the document after initial load, so the document - * metadata tags are not normally hit. - * - * WARNING: A stack trace has to be created each time any of these are created, so it is not very - * efficient when one of these tags is created. Please avoid using these tags where possible. - */ -const RESTRICTED_HTML_TAGS = [ - // All the [Document metadata](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#document_metadata) - // tags except `style` because honestly there are just too many of them. They flood the logs and - // took 100ms on reload. If it becomes an issue, we can worry about it then. Maybe we can try - // checking for style when the first WebView is loaded in or something - 'base', - 'head', - 'link', - 'meta', - // See comment above for why not style - // 'style', - 'title', - // The [Sectioning root](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#sectioning_root) - 'body', - // Tags that have [href](https://www.w3schools.com/tags/att_href.asp) for navigating - 'a', - 'area', - // Can navigate - 'form', - // Don't want to let extensions block the UI - 'dialog', - // Very dangerous tags that we need to be careful to restrict - we do not want extension code to - // run in renderer context - 'script', - 'iframe', - // Weird tag to preview a site that we probably don't need - 'portal', -]; - -/** Prefix on requests that indicates that the request is related to webView operations */ -const CATEGORY_WEB_VIEW = 'webView'; - -/** Name for request to get a web view */ -const GET_WEB_VIEW_REQUEST = 'getWebView'; - -/** `localstorage` key for saving and loading the dock layout */ -const DOCK_LAYOUT_KEY = 'dock-saved-layout'; - -/** Whether this service has finished setting up */ -let isInitialized = false; - -/** Promise that resolves when this service is finished initializing */ -let initializePromise: Promise | undefined; - -/** Emitter for when a webview is added */ -const onDidAddWebViewEmitter = createNetworkEventEmitter( - serializeRequestType(CATEGORY_WEB_VIEW, 'onDidAddWebView'), -); -/** Event that emits with webView info when a webView is added */ -export const onDidAddWebView = onDidAddWebViewEmitter.event; - -/** - * WARNING: DO NOT USE THIS VARIABLE DIRECTLY. USE `getDockLayout()` - * - * Asynchronously accessed variable that will hold the rc-dock dock layout along with a couple other - * props. This is populated by `platform-dock-layout.component.tsx` registering its dock layout with - * this service, allowing this service to manage layouts and such. - * - * WARNING: YOU CAN ONLY USE THIS VARIABLE IN THE RENDERER. Also please do not save this variable - * out anywhere because it can change, invalidating the old one (see `registerDockLayout`) - */ -let papiDockLayoutVar = createDockLayoutAsyncVar(); -/** - * WARNING: DO NOT USE THIS VARIABLE DIRECTLY. USE `getDockLayoutSync()` - * - * Synchronously accessed variable that will hold the rc-dock dock layout along with a couple other - * props. This is populated by `platform-dock-layout.component.tsx` registering its dock layout with - * this service, allowing this service to manage layouts and such. - * - * WARNING: YOU CAN ONLY USE THIS VARIABLE IN THE RENDERER. Also please do not save this variable - * out anywhere because it can change, invalidating the old one (see `registerDockLayout`) - */ -let papiDockLayoutVarSync: PapiDockLayout | undefined; -/** - * Set the papi dock layout (async and sync). Resolves `getDockLayout()` calls. - * - * This should very likely only be used in `registerDockLayout`. - * - * @param dockLayout The papi dock layout to set or undefined to reset the dock layout - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER. - */ -function setDockLayout(dockLayout: PapiDockLayout | undefined): void { - if (dockLayout === undefined) { - // Create a new async var to empty out the dock layout only if the dock layout was previously - // set. That way, async callers to the dock layout who are awaiting a resolved value don't get - // lost or rejected needlessly - // TODO: Would creating a new async var create any problems...? I guess only if someone saves - // dockLayoutVar somewhere else - if (papiDockLayoutVar.hasSettled) papiDockLayoutVar = createDockLayoutAsyncVar(); - papiDockLayoutVarSync = undefined; - } else { - // Set the dock layout as the promise var. Throws if already resolved - papiDockLayoutVar.resolveToValue(dockLayout, true); - if (papiDockLayoutVarSync) - throw new Error( - 'WebView Service error: papiDockLayoutVarSync is already set when trying to set it!', - ); - papiDockLayoutVarSync = dockLayout; - } -} -/** - * Get the papi dock layout promise. It will resolve to the papi dock layout when it is registered. - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER. Also please do not save the returned - * variable out anywhere because it can change, invalidating the old one (see `registerDockLayout`) - * - * @returns Promise that resolves to the papi dock layout - */ -function getDockLayout(): Promise { - return papiDockLayoutVar.promise; -} -/** - * Get the papi dock layout synchronously _assuming_ it has been registered. This should be safe to - * assume if you are accessing this from inside a tab's code - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER. Also please do not save the returned - * variable out anywhere because it can change, invalidating the old one (see `registerDockLayout`) - * - * @returns The papi dock layout - * @throws If the papi dock layout has not been registered - */ -function getDockLayoutSync(): PapiDockLayout { - if (!papiDockLayoutVarSync) - throw new Error( - 'WebView Service error: Dock layout was requested synchronously, but the dock layout has not been registered!', - ); - return papiDockLayoutVarSync; -} - -// #region functions related to the dock layout - -/** - * Basic `saveTabInfo` that simply strips the properties added by {@link TabInfo} off of the object - * and returns it as a {@link SavedTabInfo}. Runs as the {@link TabSaver} by default if the tab type - * does not have a specific `TabSaver` - */ -export function saveTabInfoBase(tabInfo: TabInfo): SavedTabInfo { - // We don't need to use the other properties, but we need to remove them - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { tabTitle, tabIconUrl, content, minWidth, minHeight, ...savedTabInfo } = tabInfo; - return savedTabInfo; -} - -/** - * Converts web view definition used in an actual docking tab into saveable web view information by - * stripping out the members we don't want to save - * - * @param webViewDefinition Web view to save - * @returns Saveable web view information based on `webViewDefinition` - */ -export function convertWebViewDefinitionToSaved( - webViewDefinition: WebViewDefinition, -): SavedWebViewDefinition { - const webViewDefinitionCloned: Omit & - Partial< - Pick< - WebViewDefinition, - 'content' | 'allowScripts' | 'allowSameOrigin' | 'allowedFrameSources' - > - > & - Partial> = { ...webViewDefinition }; - // We don't want to keep the webView content so the web view provider can provide it again when - // deserializing - delete webViewDefinitionCloned.content; - delete webViewDefinitionCloned.styles; - // We don't want to keep security-related properties so the web view doesn't get loaded with the - // wrong security somehow. The web view provider should provide this every time it provides the - // content - delete webViewDefinitionCloned.allowScripts; - delete webViewDefinitionCloned.allowSameOrigin; - delete webViewDefinitionCloned.allowedFrameSources; - return webViewDefinitionCloned; -} - -/** Create a new dock layout promise variable */ -function createDockLayoutAsyncVar(): AsyncVariable { - return new AsyncVariable( - 'web-view.service.platformDockLayout', - // Use default timeout on renderer, but never timeout anywhere else because we will not be - // resolving this. One of the serious pains of not having #203 - isRenderer() ? undefined : -1, - ); -} - -/** - * When rc-dock detects a changed layout, save it. This function is given to the registered - * papiDockLayout to run when the dock layout changes. - * - * @param newLayout The changed layout to save. - */ -// TODO: We could filter whether we need to save based on the `direction` argument. - IJH 2023-05-1 -const onLayoutChange: OnLayoutChangeRCDock = async (newLayout) => { - return saveLayout(newLayout); -}; - -/** - * Safely load a value from local storage. - * - * @param key Of the value. - * @param defaultValue To return if the key is not found. - * @returns The value of the key fetched from local storage, or the default value if not found. - */ -function getStorageValue(key: string, defaultValue: T): T { - const saved = localStorage.getItem(key); - const initial = saved ? JSON.parse(saved) : undefined; - return initial || defaultValue; -} - -/** - * Persists the current dock layout information. - * - * @param layout Layout to persist - */ -async function saveLayout(layout: LayoutBase): Promise { - const currentLayout = layout; - localStorage.setItem(DOCK_LAYOUT_KEY, JSON.stringify(currentLayout)); -} - -/** - * Loads layout information into the dock layout. - * - * @param layout If this parameter is provided, loads that layout information. If not provided, gets - * the persisted layout information and loads it into the dock layout. - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - */ -async function loadLayout(layout?: LayoutBase): Promise { - const dockLayoutVar = await getDockLayout(); - const layoutToLoad = layout || getStorageValue(DOCK_LAYOUT_KEY, dockLayoutVar.testLayout); - - dockLayoutVar.dockLayout.loadLayout(layoutToLoad); - if (layout) { - // A layout was provided, meaning this is a layout change. Since `dockLayout.loadLayout` doesn't - // run `onLayoutChange`, we run it manually - await onLayoutChange(layoutToLoad); - } -} - -/** - * Register a dock layout React element to be used by this service to perform layout-related - * operations - * - * @param dockLayout Dock layout element to register along with other important properties - * @returns Function used to unregister this dock layout - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ -export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber { - // Save the current async var so we know if it changed before we unsubscribed - const currentPapiDockLayoutVar = papiDockLayoutVar; - setDockLayout(dockLayout); - - // TODO: Strange pattern that we are setting a ref to a service function. Investigate changing - // this pattern in some way. Maybe just export `onLayoutChange`? - dockLayout.onLayoutChangeRef.current = onLayoutChange; - - // Will we ever need to await this? For now, seems like it unnecessarily complicates registering - // because making this function async would probably be annoying in React - loadLayout(); - - // Return an unsubscriber to unregister this dock layout. The primary situation in which I see - // this happening is when you change something on the renderer that causes a live hot reload - return () => { - // Somehow this is not the registered dock layout anymore - if (papiDockLayoutVar !== currentPapiDockLayoutVar) - throw new Error('Tried to unregister an old dock layout'); - - setDockLayout(undefined); - - return true; - }; -} - -// #endregion - -/** Set up defaults for options for getting a web view */ -function getWebViewOptionsDefaults(options: GetWebViewOptions): GetWebViewOptions { - const optionsDefaulted = cloneDeep(options); - if ('existingId' in optionsDefaulted && !('createNewIfNotFound' in optionsDefaulted)) - optionsDefaulted.createNewIfNotFound = true; - - return optionsDefaulted; -} - -/** - * Remove a tab in the layout - * - * @param tabId ID of the tab to remove - * @returns True if successfully found the tab to remove - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ -export const removeTab = async (tabId: string): Promise => { - return (await getDockLayout()).removeTabFromDock(tabId); -}; - -/** - * Add or update a tab in the layout - * - * @param savedTabInfo Info for tab to add or update - * @param layout Information about where to put a new tab - * @returns If tab added, final layout used to display the new tab. If existing tab updated, - * `undefined` - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ -export const addTab = async ( - savedTabInfo: SavedTabInfo & { data?: TData }, - layout: Layout, -): Promise => { - return (await getDockLayout()).addTabToDock(savedTabInfo, layout); -}; - -/** - * Get just the updatable properties of a web view definition - * - * @param webViewDefinition Web view definition or update info to get updatable properties from - * @returns Updatable properties of the web view definition - * - * Not exposed on the papi - */ -export function getUpdatablePropertiesFromWebViewDefinition( - webViewDefinition: - | SavedWebViewDefinition - | WebViewDefinition - | WebViewDefinitionUpdatableProperties - | WebViewDefinitionUpdateInfo, -): WebViewDefinitionUpdatableProperties { - // Make sure we're only including the specific properties we allow updates on - const { iconUrl, title } = webViewDefinition; - return { iconUrl, title }; -} - -/** - * Merges web view definition updates into a web view definition. Does not modify the original web - * view definition but returns a new object. - * - * @param webViewDefinition Web view definition to merge into - * @param updateInfo Updates to merge into the web view definition - * @returns New copy of web view definition with updates applied - * - * Not exposed on the papi - */ -export function mergeUpdatablePropertiesIntoWebViewDefinition( - webViewDefinition: T, - updateInfo: WebViewDefinitionUpdateInfo, -): T { - const webViewUpdate = getUpdatablePropertiesFromWebViewDefinition(updateInfo); - // If update properties aren't specified, keep the original values - const mergedProperties = Object.fromEntries( - Object.entries(webViewUpdate).map(([key, value]) => [ - key, - // Reminding TypeScript that key is from entries of updatable properties - // eslint-disable-next-line no-type-assertion/no-type-assertion - value || webViewDefinition[key as keyof WebViewDefinitionUpdatableProperties], - ]), - ); - return { - ...webViewDefinition, - ...mergedProperties, - }; -} - -/** - * Gets the updatable properties on the WebView definition with the specified ID - * - * @param webViewId The ID of the WebView whose updatable properties to get - * @returns Updatable properties of the WebView definition with the specified ID or undefined if not - * found - * @throws If the papi dock layout has not been registered - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ -export function getWebViewDefinitionUpdatablePropertiesSync( - webViewId: string, -): WebViewDefinitionUpdatableProperties | undefined { - const webViewDefinition = getDockLayoutSync().getWebViewDefinition(webViewId); - if (webViewDefinition === undefined) return undefined; - - return getUpdatablePropertiesFromWebViewDefinition(webViewDefinition); -} - -/** - * Updates the WebView with the specified ID with the specified properties - * - * @param webViewId The ID of the WebView to update - * @param webViewDefinitionUpdateInfo Properties to update on the WebView. Any unspecified - * properties will stay the same - * @returns True if successfully found the WebView to update; false otherwise - * @throws If the papi dock layout has not been registered - * - * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER - * - * Not exposed on the papi - */ -export function updateWebViewDefinitionSync( - webViewId: string, - webViewDefinitionUpdateInfo: WebViewDefinitionUpdateInfo, -): boolean { - return getDockLayoutSync().updateWebViewDefinition(webViewId, webViewDefinitionUpdateInfo); -} - -// #region Set up global variables to use in `getWebView`'s `imports` below - -globalThis.getWebViewDefinitionUpdatablePropertiesById = - getWebViewDefinitionUpdatablePropertiesSync; -globalThis.updateWebViewDefinitionById = updateWebViewDefinitionSync; - -// #endregion - -/** - * Creates a new web view or gets an existing one depending on if you request an existing one and if - * the web view provider decides to give that existing one to you (it is up to the provider). - * - * @param webViewType Type of WebView to create - * @param layout Information about where you want the web view to go. Defaults to adding as a tab - * @param options Options that affect what this function does. For example, you can provide an - * existing web view ID to request an existing web view with that ID. - * @returns Promise that resolves to the ID of the webview we got or undefined if the provider did - * not create a WebView for this request. - * @throws If something went wrong like the provider for the webViewType was not found - */ -export const getWebView = async ( - webViewType: WebViewType, - layout: Layout = { type: 'tab' }, - options: GetWebViewOptions = {}, -): Promise => { - const optionsDefaulted = getWebViewOptionsDefaults(options); - // ENHANCEMENT: If they aren't looking for an existingId, we could get the webview without - // searching for an existing webview and send it to the renderer, skipping the part where we send - // to the renderer, then search for an existing webview, then get the webview - - // Create the webview - if (!isRenderer()) { - // HACK: Quick fix for https://github.com/paranext/paranext-core/issues/52 - // Try to run getWebView several times until the renderer is up - // Once we implement a way to track dependencies across processes, this can go away - // Note that requests are retried, so there is another loop - // within this loop deeper down. - for (let attemptsRemaining = 5; attemptsRemaining > 0; attemptsRemaining--) { - try { - // eslint-disable-next-line no-await-in-loop - return await networkService.request< - [WebViewType, Layout, GetWebViewOptions], - WebViewId | undefined - >( - serializeRequestType(CATEGORY_WEB_VIEW, GET_WEB_VIEW_REQUEST), - webViewType, - layout, - optionsDefaulted, - ); - } catch (error) { - // If we are out of tries or the error returned is not that the renderer is down, stop - // trying to resend and just throw - if ( - attemptsRemaining === 1 || - getErrorMessage(error) !== - `No handler was found to process the request of type ${serializeRequestType( - CATEGORY_WEB_VIEW, - GET_WEB_VIEW_REQUEST, - )}` - ) - throw error; - // eslint-disable-next-line no-await-in-loop - await wait(1000); - } - } - throw new Error(`getWebView failed, but you should have seen a different error than this!`); - } - - // Conditional import when inside the renderer - const { getFullWebViewStateById, setFullWebViewStateById } = await import( - '@renderer/services/web-view-state.service' - ); - - // Get the webview definition from the webview provider - const webViewProvider = await webViewProviderService.get(webViewType); - - if (!webViewProvider) - throw new Error(`getWebView: Cannot find Web View Provider for webview type ${webViewType}`); +let networkObject: WebViewServiceType; +let initializationPromise: Promise; +async function initialize(): Promise { + if (!initializationPromise) { + initializationPromise = new Promise((resolve, reject) => { + const executor = async () => { + try { + // Normally automatic retrying within the network object service is sufficient + // However, in this case we know webViewService is being requested before it is registered + // Give it some extra time to be registered by the renderer + let localWebViewService: WebViewServiceType | undefined; + const maxAttempts: number = 3; + for (let attemptsRemaining = maxAttempts; attemptsRemaining > 0; attemptsRemaining--) { + // eslint-disable-next-line no-await-in-loop + localWebViewService = await networkObjectService.get( + NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, + ); + if (localWebViewService) break; - // Find existing webView if one exists - /** Either the existing webview with the specified ID or a placeholder webview if one was not found */ - let existingSavedWebView: SavedWebViewDefinition | undefined; - // Look for existing webview - if (optionsDefaulted.existingId) { - // Expect this to be a tab. - // eslint-disable-next-line no-type-assertion/no-type-assertion - const existingWebView = (await getDockLayout()).dockLayout.find( - optionsDefaulted.existingId === '?' - ? // If they provided '?', that means look for any webview with a matching webViewType - (item) => { - // This is not a webview - if (!('data' in item)) return false; + // eslint-disable-next-line no-await-in-loop + await wait(1000); - // Find any webview with the specified webViewType. Type assert the unknown `data`. - // eslint-disable-next-line no-type-assertion/no-type-assertion - return (item.data as WebViewDefinition).webViewType === webViewType; + logger.debug(`Retrying to get the web view service`); } - : // If they provided any other string, look for a webview with that ID - optionsDefaulted.existingId, - ) as TabInfo | undefined; - if (existingWebView) { - // We found the webview! Save it to send to the web view provider - existingSavedWebView = convertWebViewDefinitionToSaved( - // Type assert the unknown `data`. - // eslint-disable-next-line no-type-assertion/no-type-assertion - existingWebView.data as WebViewDefinition, - ); - // Load the web view state since the web view provider doesn't have access to the data store - existingSavedWebView.state = getFullWebViewStateById(existingWebView.id); - } - } - - // We didn't find an existing web view with the ID - if (!existingSavedWebView) { - // If we are not looking to create a new webview, then don't. - if ('existingId' in optionsDefaulted && !optionsDefaulted.createNewIfNotFound) return undefined; - // If we want to create a new webview, set a placeholder with a new ID - existingSavedWebView = { webViewType, id: newGuid() }; - } - - // Create the new webview or load if it already existed - const webView = await webViewProvider.getWebView(existingSavedWebView, optionsDefaulted); - - // The web view provider didn't want to create this web view - if (!webView) return undefined; - - // Set up WebViewDefinition default values - /** WebView.contentType is assumed to be React by default. Extensions can specify otherwise */ - const contentType = webView.contentType ? webView.contentType : WebViewContentType.React; - /** Default allowScripts to false for WebViewContentType.URL and true otherwise */ - let { allowScripts } = webView; - if (contentType !== WebViewContentType.URL) allowScripts = webView.allowScripts ?? true; - /** Default allowSameOrigin to true */ - const allowSameOrigin = webView.allowSameOrigin ?? true; - /** - * Only allow connecting to `papi-extension:` and `https:` urls. For HTML and React WebViews, this - * controls the `frame-src` directive and therefore which urls can be iframe `src`es in the - * WebView. For URL WebViews, this controls what urls the WebView can be. - */ - let { allowedFrameSources } = webView; - if (contentType !== WebViewContentType.URL && allowedFrameSources) - allowedFrameSources = allowedFrameSources.filter( - (hostValue) => hostValue.startsWith('https:') || hostValue.startsWith('papi-extension:'), - ); - - // Validate the WebViewDefinition to make sure it is acceptable - // If this is a URL WebView, it must match at least one of its `allowedFrameSources` Regex strings - // if any are supplied - if ( - contentType === WebViewContentType.URL && - allowedFrameSources && - !allowedFrameSources.some((regexString) => new RegExp(regexString).test(webView.content)) - ) - throw new Error( - `getWebView: URL WebView content ${webView.content} did not match any of its allowedFrameSources!`, - ); - - if (webView.state) - // The web view provider might have updated the web view state, so save it - setFullWebViewStateById(webView.id, webView.state); - - // `webViewRequire`, `getWebViewStateById`, and `setWebViewStateById` below are defined in `src\renderer\global-this.model.ts` - // `useWebViewState` below is defined in `src\shared\global-this.model.ts` - // We have to bind `useWebViewState` to the current `window` context because calls within PAPI don't have access to a webview's `window` context - /** - * String that sets up 'import' statements in the webview to pull in libraries and clear out - * internet access and such - * - * WARNING: `window.top` is not deletable as a security feature (websites need to know if they are - * running embedded in an iframe), so the child iframes are NOT isolated from their parents. We - * perform a number of tasks to mitigate this issue, but it would be very nice to find a way to - * properly delete `window.top` - */ - const imports = ` - window.papi = window.parent.papi; - window.React = window.parent.React; - window.ReactJsxRuntime = window.parent.ReactJsxRuntime; - window.ReactDom = window.parent.ReactDom; - window.ReactDOMClient = window.parent.ReactDOMClient; - window.createRoot = window.parent.createRoot; - window.SillsdevScripture = window.parent.SillsdevScripture; - var require = window.parent.webViewRequire; - var getWebViewStateById = window.parent.getWebViewStateById; - var setWebViewStateById = window.parent.setWebViewStateById; - window.getWebViewState = (stateKey) => { return getWebViewStateById('${webView.id}', stateKey) }; - window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) }; - window.useWebViewState = window.parent.useWebViewState.bind(window); - var getWebViewDefinitionUpdatablePropertiesById = window.parent.getWebViewDefinitionUpdatablePropertiesById; - window.getWebViewDefinitionUpdatableProperties = () => { return getWebViewDefinitionUpdatablePropertiesById('${webView.id}')} - var updateWebViewDefinitionById = window.parent.updateWebViewDefinitionById; - window.updateWebViewDefinition = (webViewDefinitionUpdateInfo) => { return updateWebViewDefinitionById('${webView.id}', webViewDefinitionUpdateInfo)} - window.fetch = papi.fetch; - delete window.parent; - delete window.top; - delete window.frameElement; - delete window.XMLHttpRequest; - delete window.WebSocket; - `; - - /** Nonce used to allow scripts and styles to run */ - // TODO: Generating nonces every time causes webviews to rerender every time `getWebView` is used - // on an existing webview such as when the extension host is restarted. Should we save webview - // nonces so the `content` can be the same and not have to rerender? - // Or this could solve the problem as well https://github.com/paranext/paranext-core/issues/282 - const srcNonce = newNonce(); - - // Build the contents of the iframe - let webViewContent: string; - /** CSP for allowing only certain scripts and styles */ - let specificSrcPolicy: string; - switch (contentType) { - case WebViewContentType.HTML: - // Add wrapping to turn a plain string into an iframe - webViewContent = webView.content.includes('${webView.content}`; - // TODO: Please combine our CSP with HTML-provided CSP so we can add the import nonce and they can add nonces and stuff instead of allowing 'unsafe-inline' - specificSrcPolicy = "'unsafe-inline'"; - break; - case WebViewContentType.URL: - webViewContent = webView.content; - // CSP does not apply to these webViews. If we ever add a `csp` attribute to WebView iframes, - // we might need to add this URL's schema to the CSP - specificSrcPolicy = ''; - break; - default: { - // Defaults to React webview definition. - // eslint-disable-next-line no-type-assertion/no-type-assertion - const reactWebView = webView as WebViewDefinitionReact; - - // Add the component as a script - // WARNING: DO NOT add anything between the closing of the script tag and the insertion of - // reactWebView.contents. Doing so would mess up debugging web views - webViewContent = ` - - - ${ - reactWebView.styles - ? `` - : '' - } - - -
-
- - - `; - specificSrcPolicy = `'nonce-${srcNonce}'`; - break; - } - } - - /** - * Content security policy header for the webview - controls what resources scripts and other - * things can access. - * - * Design decisions and guiding principles at - * https://github.com/paranext/paranext/wiki/Content-Security-Policy-Design - * - * DO NOT CHANGE THIS WITHOUT A SERIOUS REASON - * - * Please uncomment the image creation arbitrary code execution in `evil.js`'s WebView when you - * make changes so we can double check it is still successfully blocked. - */ - // default-src 'none' so things can't happen unless we allow them - // script-src-elem allows script tags but not in-line attribute scripts. Using this instead of - // just `script-src` for lower chance of arbitrary code execution (and because index.ejs CSP has - // it) - // 'self' so scripts can be loaded from us - // 'wasm-unsafe-eval' because webview iframes want to use wasm - // papi-extension: so scripts can be loaded from installed extensions - // TODO: this probably doesn't work right now because it is purposely not included in the CSP - // in index.ejs. Test this once we fix webview code to be retrieved from the backend paranext-core#89 - // ${specificSrcPolicy} so we can load the specific scripts needed from the iframe - // style-src allows them to use style/link tags and style attributes on tags - // 'self' so styles can be loaded from us - // papi-extension: so scripts can be loaded from installed extensions - // 'unsafe-inline' because that's how bundled libraries' styles are loaded in :( like MUI - // frame-src determines what iframes can be loaded - // This is derived from the WebViewDefinition's `allowedFrameSources`. WebViews must specify - // the host values they want to be listed here. Since this CSP inherits from the `index.ejs` - // CSP, these values must be within 'self', papi-extension:, and https: - // See `index.ejs` for more info on why these sources are allowed - // object-src 'none' to prevent insecure object and embed until we have a reason to use them - // worker-src determines from where they can run web workers - // 'none' - we can consider changing if someone gives us a reason to run workers in the renderer - // manifest-src determines what manifest can be loaded for this iframe - // for now, inherit 'none' from default-src - not sure why they would need a manifest - // connect-src only communicate over the network through JS APIs as we allow - // 'self' so the iframe can only communicate over the internet with us and not outside the - // iframe - // Note: because webview iframes are on same origin as parent window, they can still use things - // that are imported to their script via the imports string above and can call the parent - // window's objects directly. Objects passed through from the parent window still have full - // internet access. We must essentially assume they can find a way to access the internet - // through the same connect-src as index.ejs. However, it is probably best for them to use only - // things we give them from parent, so might as well keep it restricted here. - // img-src load images - // 'self' so images can be loaded from us - // papi-extension: so images can be loaded from installed extensions - // https: so they can load images over secure connections - // data: so they can load data urls - // media-src load audio, video, etc - // 'self' so media can be loaded from us - // papi-extension: so media can be loaded from installed extensions - // https: so media can be loaded over secure connections - // data: so they can load data urls - // font-src load fonts - // 'self' so fonts can be loaded from us - // papi-extension: so fonts can be loaded from installed extensions - // https: so fonts can be loaded over secure connections - // data: so they can load data urls - // form-action 'self' lets the form submit to us - // TODO: not sure if this is needed. If we can attach handlers to forms, we can probably remove - // this - // navigate-to 'none' prevents them from redirecting this iframe somewhere else - // WARNING: This is experimental and does not work as of July 2023! It is here for future - // compatibility in case they add support for it - const contentSecurityPolicy = ``; - - // Add a script at the start of the head to give access to papi - const headStart = webViewContent.indexOf('', headStart); - - // Inject the CSP and import scripts into the html if it is not a URL iframe - if (contentType !== WebViewContentType.URL) - webViewContent = `${webViewContent.substring(0, headEnd + 1)} - ${contentSecurityPolicy} - ${webViewContent.substring(headEnd + 1)}`; - - const updatedWebView: WebViewTabProps = { - ...webView, - contentType, - content: webViewContent, - allowScripts, - allowSameOrigin, - allowedFrameSources, - }; - - const updatedLayout = (await getDockLayout()).addWebViewToDock(updatedWebView, layout); - - // If we received a layout (meaning it created a new webview instead of updating an existing one), - // inform web view consumers that we added a new web view - if (updatedLayout) - onDidAddWebViewEmitter.emit({ - webView: convertWebViewDefinitionToSaved(updatedWebView), - layout: updatedLayout, - }); - - return webView.id; -}; - -/** - * Commands that this process will handle if it is the renderer. Registered automatically at - * initialization - */ -const rendererRequestHandlers = { - [serializeRequestType(CATEGORY_WEB_VIEW, GET_WEB_VIEW_REQUEST)]: getWebView, -}; - -/** - * Checks a node and its children recursively to determine if they are forbidden and removes them - * from the dom if so. - * - * @param node The node to check recursively - * @param parent Node from which to remove this node if it is forbidden - */ -function removeNodeIfForbidden(node: Node) { - if (node.nodeType !== Node.ELEMENT_NODE) return; - - // This is an element node. - // eslint-disable-next-line no-type-assertion/no-type-assertion - const element = node as Element; - - /** Remove the element */ - const removeElement = (info: string) => { - logger.warn( - `${info} rejected! An extension may have been trying to execute code with higher privileges!`, - ); - element.remove(); - }; - - function validateElementThenChildren(currentElement: Element) { - const currentTag = currentElement.tagName.toLowerCase(); - // If the element is forbidden, remove this whole tree - if (currentTag === 'iframe') { - const sandbox = currentElement.attributes.getNamedItem('sandbox'); - if (!sandbox) { - removeElement('iframe with no sandbox'); - return; - } - if (!isString(sandbox.value)) { - removeElement(`iframe with a non-string sandbox value ${sandbox.value}`); - return; - } - const sandboxValues = sandbox.value.split(' '); - const src = currentElement.attributes.getNamedItem('src'); - // If the iframe has `src`, only allow `src` sandbox values because browsers that do not - // support `srcdoc` fall back to `src` so we should be more strict - const allowedSandboxValues = src - ? ALLOWED_IFRAME_SRC_SANDBOX_VALUES - : ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES; - if ( - sandboxValues.some( - (sandboxValue) => sandboxValue !== '' && !allowedSandboxValues.includes(sandboxValue), - ) - ) { - removeElement( - `iframe with \`${ - src ? 'src' : 'srcdoc' - }\` attribute and disallowed sandbox attribute value '${sandbox.value}'`, - ); - return; - } - } - if (FORBIDDEN_HTML_TAGS.includes(currentTag)) { - removeElement(currentTag); - return; - } - - // Check the element's children to see if they are forbidden - for (let i = 0; i < currentElement.children?.length; i++) { - validateElementThenChildren(currentElement.children[i]); - } - } - - // Validate the new element and all children recursively. If anything is forbidden, the top - // element will be removed - validateElementThenChildren(element); -} -/** - * Reads through the list of document changes detected by our MutationObserver and deletes forbidden - * elements including iframes with improper sandboxing - */ -function removeForbiddenElements(mutationList: MutationRecord[]) { - // If this becomes too slow, it may be necessary to use getElementsByTagName instead of looping - // through the mutations. Thanks for the idea to https://stackoverflow.com/a/39332340 - mutationList.forEach((m) => { - // If `src` or `srcdoc` attributes changed, validate the element - if (m.type === 'attributes') { - if (!m.target.parentNode) { - logger.warn( - `MutationObserver couldn't find parent for node that changed attributes! This doesn't make sense. Investigate`, - m.target, - ); - } - removeNodeIfForbidden(m.target); - return; - } - // If for some reason this mutation is not added or removed nodes, forget it - if (m.type !== 'childList') return; - // Check if each added node is a forbidden element - m.addedNodes.forEach((node) => removeNodeIfForbidden(node)); - }); -} - -/** Sets up the WebViewService. Runs only once */ -export const initialize = () => { - if (initializePromise) return initializePromise; - - initializePromise = (async (): Promise => { - if (isInitialized) return; - - // Set up subscriptions that the service needs to work - - // Do some setup only in the renderer - if (isRenderer()) { - // We do not want iframes to be able to create their own iframes and scripts in the main window - // context so they cannot execute arbitrary scripts without sandboxing. This prevents them from - // showing modals, navigating to different pages, etc. - // These methods work as of July 2023 - - // Create a MutationObserver that watches the document for added iframes that do not have - // permission to be running and removes them before they execute any code. - const observer = new MutationObserver(removeForbiddenElements); - // We want the observer to watch for all elements added or removed in this document - // This does not pay attention to elements in iframes. They already have sandboxing, so there - // is no need - // We also want to watch the 'src' and 'srcdoc' attributes on iframes to catch forbidden - // iframes - // We don't need to watch the sandbox attribute to make sure it doesn't change because sandbox - // doesn't update unless an iframe is removed and added - // https://stackoverflow.com/a/16135502/8535752 - observer.observe(document, { - subtree: true, - childList: true, - attributeFilter: ['src', 'srcdoc'], - }); - - // #region delete some things on `window` for a quick prevention for same-origin child iframes - // like HTML and React WebViews from doing things we don't want them to do - // We can change these to monkey patches with validation that they are coming from the - // renderer if we need them in the renderer or we can save out varaibles and use those - - // Following are a number of deletions that correspond to various iframe sandbox values - // as noted in comments. HTML and React WebView iframes have access to these through - // `window.top` because they are on the same origin, so we must prevent access in addition to - // sandboxing - - // Remove the ability to do presentations - // Corresponds to iframe sandbox `allow-presentation` - // `window.navigator` does not have a setter but is configurable, so we redefine the property - Object.defineProperty(window, 'navigator', { - writable: false, - value: new Proxy(globalThis.navigator, { - get(obj, prop) { - if (prop === 'presentation') return undefined; - // Get the property on the object - doesn't matter what it is - // eslint-disable-next-line no-type-assertion/no-type-assertion - return obj[prop as keyof typeof obj]; - }, - }), - }); - - // Remove the ability to show modals - // Corresponds to iframe sandbox `allow-modals` - // @ts-expect-error we want to remove the ability to show modals - delete globalThis.alert; - // @ts-expect-error we want to remove the ability to show modals - delete globalThis.confirm; - // @ts-expect-error we want to remove the ability to show modals - delete globalThis.print; - // @ts-expect-error we want to remove the ability to show modals - delete globalThis.prompt; - - // TODO: Remove the ability to change the screen orientation? https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/lock - // Corresponds to iframe sandbox `allow-orientation-lock` - - // TODO: Remove the ability to lock the pointer? https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API - // Corresponds to iframe sandbox `allow-pointer-lock` - - // Remove the ability to create popups - // Corresponds to iframe sandbox `allow-popups` - // @ts-expect-error we want to remove the ability to create popups - delete globalThis.open; - // @ts-expect-error we want to remove the ability to create popups - delete globalThis.showModalDialog; - - // #endregion - - // #region monkey patches on `window` to prevent same-origin child iframes like HTML and React - // WebViews from doing things we don't want them to do - // WARNING: calling these requires us to generate a call stack, so all of these things should - // be used as sparingly as possible since they are now less performant than usual - - // Monkey-patch document.createElement so new script tags cannot be added by anything but our - // code (since we load renderer files in chunks) - const createElementOriginal = document.createElement.bind(document); - // If we name this function, we will need to change the regex testing the stack traces, and we - // may also have trouble with minifying production code. Leaving this function unnamed keeps - // things simpler - // eslint-disable-next-line func-names - document.createElement = function (...args: Parameters) { - const [tagNameCaps] = args; - - const tagName = tagNameCaps.toLowerCase(); - if (FORBIDDEN_HTML_TAGS.includes(tagName) || RESTRICTED_HTML_TAGS.includes(tagName)) { - const stackTrace = Error().stack ?? ''; - const isInRenderer = getRendererScriptRegex().test(stackTrace); - if (isInRenderer) { - logger.debug( - `Allowed ${tagName} on renderer document. If this isn't recognized, this is a very serious security violation.\nStack: ${stackTrace}`, + if (!localWebViewService) + throw new Error( + `${NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE} is not available as a network object`, ); - } else { - const message = `Rejected creating new ${tagName} tag on renderer document! Not allowed.\nStack: ${stackTrace}`; - // LogError puts an error in the console and throws an error. We don't want to scare - // anyone with the script and iframe tags evil adds to test this feature, so let's not - // log an error in development. But no exceptions when packaged - if (globalThis.isPackaged || !stackTrace.includes('at evil.web-view.html')) - throw new LogError(message); - throw new Error(message); - } + networkObject = localWebViewService; + resolve(); + } catch (error) { + reject(error); } - return createElementOriginal(...args); }; - - // #endregion - - // Register built-in requests - // TODO: make a registerRequestHandlers function that we use here and in NetworkService.initialize? - const unsubPromises = Object.entries(rendererRequestHandlers).map(([requestType, handler]) => - // Fix type after passing through the `map` function. - // eslint-disable-next-line no-type-assertion/no-type-assertion - networkService.registerRequestHandler(requestType as SerializedRequestType, handler), - ); - - // Wait to successfully register all requests - const unsubscribeRequests = aggregateUnsubscriberAsyncs(await Promise.all(unsubPromises)); - - // On closing, try to remove request listeners - // TODO: should do this on the server when the connection closes or when the server exits as well - window.addEventListener('beforeunload', async () => { - await unsubscribeRequests(); - }); - } - - isInitialized = true; - })(); - - return initializePromise; -}; - -// Declare an interface for the object we're exporting so that JSDoc comments propagate -export interface PapiWebViewService { - onDidAddWebView: typeof onDidAddWebView; - getWebView: typeof getWebView; - initialize: typeof initialize; + executor(); + }); + } + return initializationPromise; } -/** - * JSDOC SOURCE papiWebViewService - * - * Service exposing various functions related to using webViews - * - * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either - * HTML or React components. - */ -export const papiWebViewService: PapiWebViewService = { +const webViewService: WebViewServiceType = { + getWebView: async (webViewType: WebViewType, layout?: Layout, options?: GetWebViewOptions) => { + await initialize(); + return networkObject.getWebView(webViewType, layout, options); + }, onDidAddWebView, - getWebView, - initialize, }; + +export default webViewService;