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;