From b0f3f6d619f1a55af74e999dcd537c7711746dce Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Wed, 13 Nov 2024 09:48:33 -0600 Subject: [PATCH 1/3] Add Web View Controllers and postMessageToWebView --- extensions/src/hello-world/src/main.ts | 122 +- .../hello-world/src/types/hello-world.d.ts | 28 +- .../hello-world-project-viewer.web-view.tsx | 49 +- .../hello-world-project.web-view.tsx | 36 +- lib/papi-dts/papi.d.ts | 2022 ++++++++++------- src/declarations/papi-shared-types.ts | 60 + .../services/extension.service.ts | 107 +- .../services/papi-backend.service.ts | 6 + src/main/main.ts | 4 +- src/main/services/extension-host.service.ts | 14 +- src/main/services/rpc-server.ts | 4 +- .../platform-dock-layout.component.tsx | 54 +- .../components/web-view.component.tsx | 65 +- src/renderer/hooks/papi-hooks/index.ts | 1 + .../use-web-view-controller.hook.ts | 55 + src/renderer/services/dialog.service-host.ts | 4 +- .../services/web-view.service-host.ts | 116 +- src/shared/global-this.model.ts | 3 + src/shared/models/docking-framework.model.ts | 10 +- src/shared/models/web-view-factory.model.ts | 199 ++ src/shared/models/web-view-provider.model.ts | 81 +- src/shared/models/web-view.model.ts | 56 +- src/shared/services/papi-core.service.ts | 6 +- .../services/web-view-provider.service.ts | 181 +- src/shared/services/web-view.service-model.ts | 119 +- src/shared/services/web-view.service.ts | 9 + 26 files changed, 2473 insertions(+), 938 deletions(-) create mode 100644 src/renderer/hooks/papi-hooks/use-web-view-controller.hook.ts create mode 100644 src/shared/models/web-view-factory.model.ts diff --git a/extensions/src/hello-world/src/main.ts b/extensions/src/hello-world/src/main.ts index 4d0fa013c7..ee58571c56 100644 --- a/extensions/src/hello-world/src/main.ts +++ b/extensions/src/hello-world/src/main.ts @@ -1,4 +1,4 @@ -import papi, { logger } from '@papi/backend'; +import papi, { logger, WebViewFactory } from '@papi/backend'; import type { ExecutionActivationContext, WebViewContentType, @@ -8,7 +8,7 @@ import type { GetWebViewOptions, } from '@papi/core'; import { PlatformEventEmitter } from 'platform-bible-utils'; -import type { HelloWorldEvent } from 'hello-world'; +import type { HelloWorldEvent, HelloWorldProjectWebViewController } from 'hello-world'; import helloWorldReactWebView from './web-views/hello-world.web-view?inline'; import helloWorldReactWebViewStyles from './web-views/hello-world.web-view.scss?inline'; import helloWorldReactWebView2 from './web-views/hello-world-2.web-view?inline'; @@ -18,6 +18,7 @@ import HelloWorldProjectDataProviderEngineFactory from './models/hello-world-pro import helloWorldProjectWebView from './web-views/hello-world-project/hello-world-project.web-view?inline'; import helloWorldProjectWebViewStyles from './web-views/hello-world-project/hello-world-project.web-view.scss?inline'; import helloWorldProjectViewerWebView from './web-views/hello-world-project/hello-world-project-viewer.web-view?inline'; +import tailwindStyles from './tailwind.css?inline'; import { HTML_COLOR_NAMES } from './util'; import { HELLO_WORLD_PROJECT_INTERFACES } from './models/hello-world-project-data-provider-engine.model'; import { checkDetails, createHelloCheck } from './checks'; @@ -93,18 +94,26 @@ const reactWebView2Provider: IWebViewProviderWithType = { // #region Hello World Project Web View, Command, etc. +interface HelloWorldProjectOptions extends GetWebViewOptions { + /** The project ID this viewer should focus on */ + projectId?: string; +} + +const HELLO_WORLD_PROJECT_WEB_VIEW_TYPE = 'helloWorld.projectWebView'; /** Simple web view provider that provides helloWorld project web views when papi requests them */ -const helloWorldProjectWebViewProvider: IWebViewProviderWithType = { - webViewType: 'helloWorld.projectWebView', - async getWebView( +class HelloWorldProjectWebViewFactory extends WebViewFactory< + typeof HELLO_WORLD_PROJECT_WEB_VIEW_TYPE +> { + constructor() { + super(HELLO_WORLD_PROJECT_WEB_VIEW_TYPE); + } + + // No need to use `this` and implementing an abstract method so can't be static + // eslint-disable-next-line class-methods-use-this + async getWebViewDefinition( savedWebView: SavedWebViewDefinition, - getWebViewOptions: HelloWorldProjectViewerOptions, + getWebViewOptions: HelloWorldProjectOptions, ): Promise { - if (savedWebView.webViewType !== this.webViewType) - throw new Error( - `${this.webViewType} provider received request to provide a ${savedWebView.webViewType} web view`, - ); - const projectId = getWebViewOptions.projectId || savedWebView.projectId || undefined; return { title: projectId @@ -119,13 +128,38 @@ const helloWorldProjectWebViewProvider: IWebViewProviderWithType = { styles: helloWorldProjectWebViewStyles, projectId, }; - }, -}; + } -interface HelloWorldProjectViewerOptions extends GetWebViewOptions { - projectId: string | undefined; + // No need to use `this` and implementing an abstract method so can't be static + // eslint-disable-next-line class-methods-use-this + async createWebViewController( + webViewDefinition: WebViewDefinition, + webViewNonce: string, + ): Promise { + return { + async focusName(name) { + try { + logger.info( + `Hello World Project Web View Controller ${webViewDefinition.id} received request to focus ${name}`, + ); + await papi.webViewProviders.postMessageToWebView(webViewDefinition.id, webViewNonce, { + method: 'focusName', + name, + }); + return true; + } catch (e) { + logger.warn( + `Hello World Project Web View Controller ${webViewDefinition.id} threw while running focusName! ${e}`, + ); + return false; + } + }, + }; + } } +const helloWorldProjectWebViewProvider = new HelloWorldProjectWebViewFactory(); + /** * Function to prompt for a project and open it in the hello world project web view. Registered as a * command handler. @@ -143,8 +177,12 @@ async function openHelloWorldProjectWebView( } if (!projectIdForWebView) return undefined; - const options: HelloWorldProjectViewerOptions = { projectId: projectIdForWebView }; - return papi.webViews.getWebView(helloWorldProjectWebViewProvider.webViewType, undefined, options); + const options: HelloWorldProjectOptions = { projectId: projectIdForWebView }; + return papi.webViews.openWebView( + helloWorldProjectWebViewProvider.webViewType, + undefined, + options, + ); } // #endregion @@ -157,6 +195,11 @@ function selectProjectToDelete(): Promise { }); } +interface HelloWorldProjectViewerOptions extends HelloWorldProjectOptions { + /** The id of the web view that opened this viewer if opened from a web view */ + callerWebViewId?: string; +} + /** * Simple web view provider that provides helloWorld project viewer web views when papi requests * them @@ -184,8 +227,12 @@ const helloWorldProjectViewerProvider: IWebViewProviderWithType = { : 'Hello World Project Viewer', ...savedWebView, content: helloWorldProjectViewerWebView, - styles: helloWorldProjectWebViewStyles, + styles: tailwindStyles, projectId, + state: { + ...savedWebView.state, + callerWebViewId: getWebViewOptions.callerWebViewId || savedWebView.state?.callerWebViewId, + }, }; }, }; @@ -275,7 +322,7 @@ export async function activate(context: ExecutionActivationContext): Promise { let projectId: string | undefined; if (webViewId) { - const webViewDefinition = await papi.webViews.getSavedWebViewDefinition(webViewId); + const webViewDefinition = await papi.webViews.getOpenWebViewDefinition(webViewId); projectId = webViewDefinition?.projectId; } const projectIdToDelete = projectId ?? (await selectProjectToDelete()); @@ -293,7 +340,7 @@ export async function activate(context: ExecutionActivationContext): Promise { let projectId: string | undefined; if (webViewId) { - const webViewDefinition = await papi.webViews.getSavedWebViewDefinition(webViewId); + const webViewDefinition = await papi.webViews.getOpenWebViewDefinition(webViewId); projectId = webViewDefinition?.projectId; } const projectIdForWebView = @@ -306,10 +353,13 @@ export async function activate(context: ExecutionActivationContext): Promise logger.error(`Could not get data from example.com! Reason: ${e}`)); - const peopleDataProvider = await papi.dataProviders.get('helloSomeone.people'); - if (peopleDataProvider) { - // Test subscribing to a data provider - const unsubGreetings = await peopleDataProvider.subscribeGreeting( - 'Bill', - (billGreeting: string | undefined) => logger.debug(`Bill's greeting: ${billGreeting}`), - ); - - context.registrations.add(unsubGreetings); - } - const checkPromise = papi.commands.sendCommand( 'platformScripture.registerCheck', checkDetails, @@ -417,5 +456,20 @@ export async function activate(context: ExecutionActivationContext): Promise logger.debug(`Bill's greeting: ${billGreeting}`), + ); + + context.registrations.add(unsubGreetings); + } + } catch (e) { + logger.error(`Hello world error! Could not get people data provider ${e}`); + } + logger.info('Hello World is finished activating!'); } diff --git a/extensions/src/hello-world/src/types/hello-world.d.ts b/extensions/src/hello-world/src/types/hello-world.d.ts index ff6232375b..0227c1e975 100644 --- a/extensions/src/hello-world/src/types/hello-world.d.ts +++ b/extensions/src/hello-world/src/types/hello-world.d.ts @@ -1,6 +1,10 @@ declare module 'hello-world' { - // @ts-ignore: TS2307 - Cannot find module '@papi/core' or its corresponding type declarations - import type { DataProviderDataType, MandatoryProjectDataTypes } from '@papi/core'; + import type { + DataProviderDataType, + MandatoryProjectDataTypes, + NetworkableObject, + // @ts-ignore: TS2307 - Cannot find module '@papi/core' or its corresponding type declarations + } from '@papi/core'; import type { IBaseProjectDataProvider } from 'papi-shared-types'; export type HelloWorldProjectDataTypes = MandatoryProjectDataTypes & { @@ -37,6 +41,16 @@ declare module 'hello-world' { times: number; }; + export type HelloWorldProjectWebViewController = NetworkableObject<{ + /** + * Attempts to focus a specific name in the web view + * + * @returns `true` if the name is in the project associated with this web view; `false` + * otherwise + */ + focusName: (name: string) => Promise; + }>; + /** All html color names according to https://htmlcolorcodes.com/color-names/ */ type HTMLColorNames = | 'IndianRed' @@ -183,7 +197,11 @@ declare module 'hello-world' { } declare module 'papi-shared-types' { - import type { IHelloWorldProjectDataProvider, HTMLColorNames } from 'hello-world'; + import type { + IHelloWorldProjectDataProvider, + HelloWorldProjectWebViewController, + HTMLColorNames, + } from 'hello-world'; export interface CommandHandlers { 'helloWorld.helloWorld': () => string; @@ -252,4 +270,8 @@ declare module 'papi-shared-types' { */ 'helloWorld.headerColor': HTMLColorNames; } + + export interface WebViewControllers { + 'helloWorld.projectWebView': HelloWorldProjectWebViewController; + } } diff --git a/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project-viewer.web-view.tsx b/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project-viewer.web-view.tsx index c2b8bc1cc8..49607cd92b 100644 --- a/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project-viewer.web-view.tsx +++ b/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project-viewer.web-view.tsx @@ -1,26 +1,55 @@ import { WebViewProps } from '@papi/core'; -import { useProjectData, useProjectSetting } from '@papi/frontend/react'; +import { useProjectData, useProjectSetting, useWebViewController } from '@papi/frontend/react'; +import { Button } from 'platform-bible-react'; import { CSSProperties, useMemo } from 'react'; const namesDefault: string[] = []; -globalThis.webViewComponent = function HelloWorldProjectViewer({ projectId }: WebViewProps) { +globalThis.webViewComponent = function HelloWorldProjectViewer({ + projectId, + useWebViewState, +}: WebViewProps) { + const [callerWebViewId] = useWebViewState('callerWebViewId', undefined); + + const callerWebViewController = useWebViewController( + 'helloWorld.projectWebView', + callerWebViewId, + ); + const [names] = useProjectData('helloWorld', projectId).Names(undefined, namesDefault); const [headerSize] = useProjectSetting(projectId, 'helloWorld.headerSize', 15); const [headerColor] = useProjectSetting(projectId, 'helloWorld.headerColor', 'Black'); - const headerStyle = useMemo( - () => ({ fontSize: `${headerSize}pt`, color: headerColor }), - [headerSize, headerColor], - ); + const headerStyle = useMemo(() => { + const colorPropertyName = callerWebViewController ? 'backgroundColor' : 'color'; + return { fontSize: `${headerSize}pt`, [colorPropertyName]: headerColor }; + }, [callerWebViewController, headerSize, headerColor]); return ( -
- {names.map((name) => ( -
Hello, {name}!
- ))} +
+ {callerWebViewController && ( +
Click a name to focus it on the Hello World Project web view!
+ )} + {names.map((name) => { + const textContent = `Hello, ${name}!`; + return callerWebViewController ? ( +
+ +
+ ) : ( +
+ {textContent} +
+ ); + })}
); }; diff --git a/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project.web-view.tsx b/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project.web-view.tsx index 6fe7be7bb2..427b9ac365 100644 --- a/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project.web-view.tsx +++ b/extensions/src/hello-world/src/web-views/hello-world-project/hello-world-project.web-view.tsx @@ -1,7 +1,7 @@ import { WebViewProps } from '@papi/core'; import { logger } from '@papi/frontend'; import { useProjectData, useProjectDataProvider } from '@papi/frontend/react'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import useHelloWorldProjectSettings from './use-hello-world-project-settings.hook'; import ProjectSettingsEditor from './project-settings-editor.component'; @@ -16,6 +16,28 @@ globalThis.webViewComponent = function HelloWorldProjectWebView({ projectId, useWebViewState, }: WebViewProps) { + const [focusedName, setFocusedName] = useWebViewState('focusedName', ''); + + useEffect(() => { + const webViewMessageListener = ({ data: { method, name } }: MessageEvent) => { + switch (method) { + case 'focusName': + setFocusedName(focusedName === name ? '' : name); + break; + default: + // Unknown method name + logger.info(`Received event with unknown method ${method} and name ${name}`); + break; + } + }; + + window.addEventListener('message', webViewMessageListener); + + return () => { + window.removeEventListener('message', webViewMessageListener); + }; + }, [focusedName, setFocusedName]); + const [max, setMax] = useWebViewState('max', 1); const pdp = useProjectDataProvider('helloWorld', projectId); @@ -39,7 +61,7 @@ globalThis.webViewComponent = function HelloWorldProjectWebView({ }, [pdp, currentName, setCurrentName]); const helloWorldProjectSettings = useHelloWorldProjectSettings(pdp); - const { headerStyle } = helloWorldProjectSettings; + const { headerStyle, headerColor } = helloWorldProjectSettings; return (
@@ -68,12 +90,16 @@ globalThis.webViewComponent = function HelloWorldProjectWebView({
Names:{' '} {names.map((name) => ( - - {name} + + + {name} + diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 20e4b41689..9f4e8102b4 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -147,7 +147,61 @@ declare module 'shared/models/web-view.model' { webViewType: WebViewType; /** Unique ID among webviews specific to this webview instance. */ id: WebViewId; - /** The code for the WebView that papi puts into an iframe */ + /** + * The content for the WebView that papi puts into an iframe. This field differs significantly + * depending on which `contentType` you use in your `WebViewDefinition` as described below. If you + * are using a React or HTML WebView, you will probably want to use a bundler to bundle your code + * together and provide it here. + * [`paranext-extension-template`](https://github.com/paranext/paranext-extension-template) is set + * up for this use case. Feel free to use it for your extension! + * + * --- + * + * **For React WebViews (default):** string containing all the code you want to run in the iframe + * on the frontend. You should set a function component to `globalThis.webViewComponent` in this + * code. + * + * For example, you could pass the bundled output of the following code to as your React Web View + * `content`: + * + * ```tsx + * globalThis.webViewComponent = function MyWebView() { + * return
Hello World!! This is my React WebView!
; + * } + * ``` + * + * **For HTML WebViews:** string containing all the code you want to run in the iframe on the + * frontend. This should be a complete HTML document. Usually, + * + * For example, you could pass the following string as your HTML Web View `content`: + * + * ```html + * + * + * + * + * + *
Hello World!! This is my HTML Web View!
+ * + * + * ``` + * + * --- + * + * **For URL WebViews:** the url you want to load into the iframe on the frontend. + * + * Note: URL WebViews must have `papi-extension:` or `https:` urls. + * + * For example, you could pass the following string as your URL Web View `content`: + * + * ```plain + * https://example.com/ + * ``` + */ content: string; /** * Url of image to show on the title bar of the tab @@ -563,6 +617,7 @@ declare module 'shared/global-this.model' { UseWebViewScrollGroupScrRefHook, UseWebViewStateHook, WebViewDefinitionUpdateInfo, + WebViewId, WebViewProps, } from 'shared/models/web-view.model'; /** @@ -586,6 +641,8 @@ declare module 'shared/global-this.model' { * in WebView iframes. */ var webViewComponent: FunctionComponent; + /** The id of the current web view. Only used in WebView iframes. */ + var webViewId: WebViewId; /** * * A React hook for working with a state object tied to a webview. Returns a WebView state value and @@ -2112,935 +2169,1283 @@ declare module 'shared/models/extract-data-provider-data-types.model' { : never; export default ExtractDataProviderDataTypes; } -declare module 'papi-shared-types' { - import type { ScriptureReference, UnsubscriberAsync } from 'platform-bible-utils'; - import type { - DataProviderDataType, - DataProviderDataTypes, - DataProviderSubscriberOptions, - DataProviderUpdateInstructions, - } from 'shared/models/data-provider.model'; - import type { - MandatoryProjectDataTypes, - PROJECT_INTERFACE_PLATFORM_BASE, - WithProjectDataProviderEngineExtensionDataMethods, - } from 'shared/models/project-data-provider.model'; - import type { IDisposableDataProvider } from 'shared/models/data-provider.interface'; - import type IDataProvider from 'shared/models/data-provider.interface'; - import type ExtractDataProviderDataTypes from 'shared/models/extract-data-provider-data-types.model'; +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'; /** - * Function types for each command available on the papi. Each extension can extend this interface - * to add commands that it registers on the papi with `papi.commands.registerCommand`. + * An object associated with a specific `webViewType` that provides a {@link WebViewDefinition} when + * the PAPI wants to open a web view with that `webViewType`. An extension registers a web view + * provider with `papi.webViewProviders.register`. * - * Note: Command names must consist of two strings separated by at least one period. We recommend - * one period and lower camel case in case we expand the api in the future to allow dot notation. - * - * An extension can extend this interface to add types for the commands it registers by adding the - * following to its `.d.ts` file: - * - * @example + * Web View Providers provide the contents of all web views in Platform.Bible. * - * ```typescript - * declare module 'papi-shared-types' { - * export interface CommandHandlers { - * 'myExtension.myCommand1': (foo: string, bar: number) => string; - * 'myExtension.myCommand2': (foo: string) => Promise; - * } - * } - * ``` + * If you want to provide {@link WebViewControllers} to facilitate interaction between your web views + * and extensions, you can extend the abstract class {@link WebViewFactory} to make the process + * easier. Alternatively, if you want to manage web view controllers manually, you can register them + * in {@link IWebViewProvider.getWebView}. */ - interface CommandHandlers { - 'test.echo': (message: string) => string; - 'test.echoExtensionHost': (message: string) => Promise; - 'test.throwError': (message: string) => void; - 'platform.restartExtensionHost': () => Promise; - /** Shut down the application */ - 'platform.quit': () => Promise; - /** Restart the application */ - 'platform.restart': () => Promise; - 'platform.openProjectSettings': (webViewId: string) => Promise; - 'platform.openUserSettings': () => Promise; - 'test.addMany': (...nums: number[]) => number; - 'test.throwErrorExtensionHost': (message: string) => void; + export interface IWebViewProvider extends NetworkableObject { + /** + * Receives a {@link SavedWebViewDefinition} and fills it out into a full {@link WebViewDefinition}, + * providing the contents of the web view and other properties that are important for displaying + * the web view. + * + * The PAPI calls this method as part of opening a new web view or (re)loading an existing web + * view. If you want to create {@link WebViewControllers} for the web views the PAPI creates from + * this method, you should register it using `papi.webViewProviders.registerWebViewController` + * before returning from this method (resolving the returned promise). The {@link WebViewFactory} + * abstract class handles this for you, so please consider extending it. + * + * @param savedWebViewDefinition The saved web view information from which to build a complete web + * view definition. Filled out with all {@link SavedWebViewDefinition} properties of the existing + * web view if an existing webview is being called for (matched by ID). Just provides the + * minimal properties required on {@link SavedWebViewDefinition} if this is a new request or if + * the web view with the existing ID was not found. + * @param getWebViewOptions Various options that affect what calling `papi.webViews.openWebView` + * should do. When options are passed to `papi.webViews.openWebView`, some defaults are set up + * on the options, then those options are passed directly through to this method. That way, if + * you want to adjust what this method does based on the contents of the options passed to + * `papi.WebViews.openWebView`, you can. You can even read other properties on these options if + * someone passes options with other properties to `papi.webViews.openWebView`. + * @param webViewNonce Nonce used to perform privileged interactions with the web view created + * from this method's returned {@link WebViewDefinition} such as + * `papi.webViewProviders.postMessageToWebView`. The web view service generates this nonce and + * sends it _only here_ to this web view provider that creates the web view with this id. It is + * generally recommended that this web view provider not share this nonce with anyone else but + * only use it within itself and in the web view controller created for this web view if + * applicable (See `papi.webViewProviders.registerWebViewController`). + * @returns Full {@link WebViewDefinition} including the content and other important display + * properties based on the {@link SavedWebViewDefinition} provided + */ + getWebView( + savedWebViewDefinition: SavedWebViewDefinition, + getWebViewOptions: GetWebViewOptions, + webViewNonce: string, + ): Promise; } /** - * Names for each command available on the papi. + * A web view provider that has been registered with the PAPI. * - * Automatically includes all extensions' commands that are added to {@link CommandHandlers}. + * This is what the papi gives on `webViewProviderService.get` (not exposed on the PAPI). Basically + * a layer over NetworkObject * - * @example 'platform.quit'; + * This type is internal to core and is not used by extensions */ - type CommandNames = keyof CommandHandlers; + export interface IRegisteredWebViewProvider extends NetworkObject {} /** - * Types corresponding to each user setting available in Platform.Bible. Keys are setting names, - * and values are setting data types. Extensions can add more user setting types with - * corresponding user setting IDs by adding details to their `.d.ts` file. - * - * Note: Setting names must consist of two strings separated by at least one period. We recommend - * one period and lower camel case in case we expand the api in the future to allow dot notation. - * - * An extension can extend this interface to add types for the user settings it registers by - * adding the following to its `.d.ts` file (in this example, we are adding the - * `myExtension.highlightColor` setting): + * A web view provider that has been registered with the PAPI and returned to the extension that + * registered it. It is able to be disposed with `dispose`. * - * @example - * - * ```typescript - * declare module 'papi-shared-types' { - * export interface SettingTypes { - * 'myExtension.highlightColor': string | { r: number; g: number; b: number }; - * } - * } - * ``` + * The PAPI returns this type from `papi.webViewProviders.register`. */ - interface SettingTypes { - /** - * Current Verse Reference for Scroll Group A. Deprecated - please use `papi.scrollGroups` and - * `useWebViewScrollGroupScrRef` - */ - 'platform.verseRef': ScriptureReference; - /** - * List of locales to use when localizing the interface. First in the list receives highest - * priority. Please always add 'en' (English) at the end when using this setting so everything - * localizes to English if it does not have a localization in a higher-priority locale. - */ - 'platform.interfaceLanguage': string[]; + export interface IDisposableWebViewProvider extends DisposableNetworkObject {} +} +declare module 'shared/models/network-object-status.service-model' { + import { NetworkObjectDetails } from 'shared/models/network-object.model'; + export interface NetworkObjectStatusRemoteServiceType { /** - * Mementos managed in the dotnet process and used for interacting with PtxUtils. Mementos are - * persisted objects containing some data. They are stored as xml strings. + * Get details about all available network objects + * + * @returns Object whose keys are the names of the network objects and whose values are the + * {@link NetworkObjectDetails} for each network object */ - 'platform.ptxUtilsMementoData': { - [key: string]: string; - }; + getAllNetworkObjectDetails: () => Promise>; + } + /** + * + * Provides functions related to the set of available network objects + */ + export interface NetworkObjectStatusServiceType extends NetworkObjectStatusRemoteServiceType { /** - * Tracking last S/R registry data cache time managed in the dotnet process and used for - * interacting with ParatextData. + * Get a promise that resolves when a network object is registered or rejects if a timeout is hit + * + * @param objectDetailsToMatch Subset of object details on the network object to wait for. + * Compared to object details using {@link isSubset} + * @param timeoutInMS Max duration to wait for the network object. If not provided, it will wait + * indefinitely + * @returns Promise that either resolves to the {@link NetworkObjectDetails} for a network object + * once the network object is registered, or rejects if a timeout is provided and the timeout is + * reached before the network object is registered */ - 'platform.paratextDataLastRegistryDataCachedTimes': { - [key: string]: string; - }; - /** Enable reading and writing comments in projects. This is an experimental feature. */ - 'platform.commentsEnabled': boolean; + waitForNetworkObject: ( + objectDetailsToMatch: Partial, + timeoutInMS?: number, + ) => Promise; } + export const networkObjectStatusServiceNetworkObjectName = 'NetworkObjectStatusService'; +} +declare module 'shared/services/network-object-status.service' { + import { NetworkObjectStatusServiceType } from 'shared/models/network-object-status.service-model'; /** - * Names for each user setting available on the papi. - * - * Automatically includes all extensions' user settings that are added to {@link SettingTypes}. * - * @example 'platform.verseRef' + * Provides functions related to the set of available network objects */ - type SettingNames = keyof SettingTypes; + const networkObjectStatusService: NetworkObjectStatusServiceType; + export default networkObjectStatusService; +} +declare module 'shared/models/docking-framework.model' { + import { MutableRefObject, ReactNode } from 'react'; + import { DockLayout, DropDirection, LayoutBase } from 'rc-dock'; + import { WebViewDefinition, WebViewDefinitionUpdateInfo } from 'shared/models/web-view.model'; + import { LocalizeKey } from 'platform-bible-utils'; /** - * Types corresponding to each project setting available in Platform.Bible. Keys are project - * setting names, and values are project setting data types. Extensions can add more project - * setting types with corresponding project setting IDs by adding details to their `.d.ts` file. - * - * Note: Project setting names must consist of two strings separated by at least one period. We - * recommend one period and lower camel case in case we expand the api in the future to allow dot - * notation. - * - * An extension can extend this interface to add types for the project settings it registers by - * adding the following to its `.d.ts` file (in this example, we are adding the - * `myExtension.highlightColor` project setting): - * - * @example + * Saved information used to recreate a tab. * - * ```typescript - * declare module 'papi-shared-types' { - * export interface ProjectSettingTypes { - * 'myExtension.highlightColor': string | { r: number; g: number; b: number }; - * } - * } - * ``` + * - {@link TabLoader} loads this into {@link TabInfo} + * - {@link TabSaver} saves {@link TabInfo} into this */ - interface ProjectSettingTypes { - /** - * Localized name of the language in which this project is written. This will be displayed - * directly in the UI. - * - * @example 'English' - */ - 'platform.language': string; + export type SavedTabInfo = { /** - * Short name of the project (not necessarily unique). This will be displayed directly in the - * UI. - * - * @example 'WEB' + * Tab ID - a unique identifier that identifies this tab. If this tab is a WebView, this ID will + * match the `WebViewDefinition.id` */ - 'platform.name': string; + 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 & { /** - * Localized full name of the project. This will be displayed directly in the UI. + * Url of image to show on the title bar of the tab * - * @example 'World English Bible' + * Defaults to the software's standard logo. */ - 'platform.fullName': string; + tabIconUrl?: string; /** - * Whether or not the project is editable. This is a general "editable", not necessarily that it - * is editable by the current user. - * - * Projects that are not editable are sometimes called "resources". + * Text to show (or a localizeKey that will automatically be localized) on the title bar of the + * tab */ - 'platform.isEditable': boolean; - } + tabTitle: string | LocalizeKey; + /** Text to show when hovering over the title bar of the tab */ + tabTooltip?: 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; + }; /** - * Names for each user setting available on the papi. + * Function that takes a {@link SavedTabInfo} and creates a Paranext tab out of it. Each type of tab + * must provide a {@link TabLoader}. * - * Automatically includes all extensions' user settings that are added to - * {@link ProjectSettingTypes}. + * 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}). * - * @example 'platform.fullName' + * @param tabInfo The Paranext tab to save + * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab */ - type ProjectSettingNames = keyof ProjectSettingTypes; + export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; + /** Information about a tab in a panel */ + interface TabLayout { + type: 'tab'; + } /** - * The `Setting` methods required for a Project Data Provider Engine to fulfill the requirements - * of {@link MandatoryProjectDataTypes}'s `Setting` data type. + * 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 WithProjectDataProviderEngineSettingMethods< - TProjectDataTypes extends DataProviderDataTypes, - > = { + 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; + /** Props that are passed to the web view tab component */ + export type WebViewTabProps = WebViewDefinition; + /** + * Rc-dock's onLayoutChange prop made asynchronous with `webViewDefinition` added. The dock layout + * component calls this on the web view service when the layout changes. + * + * @param webViewDefinition The web view definition if the edit was on a web view; `undefined` + * otherwise + * @returns Promise that resolves when finished doing things + */ + export type OnLayoutChangeRCDock = ( + newLayout: LayoutBase, + currentTabId?: string, + direction?: DropDirection, + webViewDefinition?: WebViewDefinition, + ) => 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; /** - * Set the value of the specified project setting on this project. - * - * Note for implementing: `setSetting` must call `papi.projectSettings.isValid` before allowing - * the setting change. + * 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 key The string id of the project setting to change - * @param newSetting The value that is to be set to the project setting. - * @returns Information that papi uses to interpret whether to send out updates. Defaults to - * `true` (meaning send updates only for this data type). - * @throws If the setting validator failed. - * @see {@link DataProviderUpdateInstructions} for more info on what to return + * @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` */ - setSetting: ( - key: ProjectSettingName, - newSetting: ProjectSettingTypes[ProjectSettingName], - ) => Promise>; + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => Layout | undefined; /** - * Get the value of the specified project setting. + * Add or update a webview in the layout * - * Note: This is good for retrieving a project setting once. If you want to keep the value - * up-to-date, use `subscribeSetting` instead, which can immediately give you the value and keep - * it up-to-date. + * @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 * - * Note for implementing: `getSetting` must call `papi.projectSettings.getDefault` if this - * project does not have a value for this setting + * @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 key The string id of the project setting to get - * @returns The value of the specified project setting. Returns default setting value if the - * project setting does not exist on the project. - * @throws If no default value is available for the setting. + * @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 */ - getSetting: ( - key: ProjectSettingName, - ) => Promise; + getWebViewDefinition: (webViewId: string) => WebViewDefinition | undefined; /** - * Deletes the specified project setting, setting it back to its default value. + * Updates the WebView with the specified ID with the specified properties * - * Note for implementing: `resetSetting` should remove the value for this setting for this - * project such that calling `getSetting` later would cause it to call - * `papi.projectSettings.getDefault` and return the default value. + * @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. * - * @param key The string id of the project setting to reset - * @returns `true` if successfully reset the project setting, `false` otherwise + * 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 */ - resetSetting: ( - key: ProjectSettingName, - ) => Promise; + testLayout: LayoutBase; }; +} +declare module 'shared/services/web-view.service-model' { + import { + GetWebViewOptions, + SavedWebViewDefinition, + WebViewId, + WebViewType, + } from 'shared/models/web-view.model'; + import { Layout } from 'shared/models/docking-framework.model'; + import { PlatformEvent } from 'platform-bible-utils'; + import { WebViewControllers, WebViewControllerTypes } from 'papi-shared-types'; + import { NetworkObject } from 'shared/models/network-object.model'; /** - * An object on the papi for interacting with that project data. Created by the papi and layers - * over an {@link IProjectDataProviderEngine} provided by an extension. Returned from getting a - * project data provider with `papi.projectDataProviders.get`. * - * Project Data Providers are a specialized version of {@link IDataProvider} that work with - * projects by exposing methods according to a set of `projectInterface`s. For each project - * available, a Project Data Provider Factory that supports that project with some set of - * `projectInterface`s creates a new instance of a PDP with the supported `projectInterface`s. - * - * Often, these objects are Layering PDPs, meaning they manipulate data provided by Base PDPs - * which actually control the saving and loading of the data. Base PDPs must implement - * {@link IBaseProjectDataProvider}, which imposes additional requirements. + * Service exposing various functions related to using webViews * - * See more information, including the difference between Base and Layering PDPs, at - * {@link ProjectDataProviderInterfaces}. + * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either + * HTML or React components. */ - type IProjectDataProvider = - IDataProvider; + export interface WebViewServiceType { + /** Event that emits with webView info when a webView is added */ + onDidAddWebView: PlatformEvent; + /** Event that emits with webView info when a webView is updated */ + onDidUpdateWebView: PlatformEvent; + /** Event that emits with webView info when a webView is closed */ + onDidCloseWebView: PlatformEvent; + /** @deprecated 6 November 2024. Renamed to {@link openWebView}. */ + getWebView: ( + webViewType: WebViewType, + layout?: Layout, + options?: GetWebViewOptions, + ) => Promise; + /** + * 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 + */ + openWebView: ( + webViewType: WebViewType, + layout?: Layout, + options?: GetWebViewOptions, + ) => Promise; + /** @deprecated 6 November 2024. Renamed to {@link getOpenWebViewDefinition} */ + getSavedWebViewDefinition(webViewId: string): Promise; + /** + * Gets the saved properties on the WebView definition with the specified ID + * + * Note: this only returns a representation of the current web view definition, not the actual web + * view definition itself. Changing properties on the returned definition does not affect the + * actual web view definition. You can possibly change the actual web view definition by calling + * {@link WebViewServiceType.openWebView} with certain `options`, depending on what options the web + * view provider has made available. + * + * @param webViewId The ID of the WebView whose saved properties to get + * @returns Saved properties of the WebView definition with the specified ID or undefined if not + * found + */ + getOpenWebViewDefinition(webViewId: string): Promise; + /** + * Get an existing web view controller for an open web view. + * + * A Web View Controller is a network object that represents a web view and whose methods + * facilitate communication between its associated web view and extensions that want to interact + * with it. + * + * Web View Controllers are registered on the web view provider service. + * + * @param webViewType Type of webview controller you expect to get. If the web view controller's + * `webViewType` does not match this, an error will be thrown + * @param webViewId Id of web view for which to get the corresponding web view controller if one + * exists + * @returns Web view controller with the given name if one exists, undefined otherwise + */ + getWebViewController( + webViewType: WebViewType, + webViewId: WebViewId, + ): Promise | undefined>; + } + /** Get request type for posting a message to a web view */ + export function getWebViewMessageRequestType(webViewId: WebViewId): `${string}:${string}`; /** - * An object on the papi for interacting with that project data. Created by the papi and layers - * over an {@link IBaseProjectDataProviderEngine} provided by an extension. Sometimes returned from - * getting a project data provider with `papi.projectDataProviders.get` (depending on if the PDP - * supports the `platform.base` `projectInterface`). - * - * Project Data Providers are a specialized version of {@link IDataProvider} that work with - * projects by exposing methods according to a set of `projectInterface`s. For each project - * available, a Project Data Provider Factory that supports that project with some set of - * `projectInterface`s creates a new instance of a PDP with the supported `projectInterface`s. - * - * Every Base PDP **must** fulfill the requirements of this interface in order to support the - * methods the PAPI requires for interacting with project data. + * Type of function to receive messages sent to a web view. * - * See more information, including the difference between Base and Layering PDPs, at - * {@link ProjectDataProviderInterfaces}. + * See `web-view-provider.service.ts`'s `postMessageToWebView` and `web-view.component` for + * information on this type */ - type IBaseProjectDataProvider = - IProjectDataProvider & - WithProjectDataProviderEngineSettingMethods & - WithProjectDataProviderEngineExtensionDataMethods & { - /** - * Subscribe to receive updates to the specified project setting. - * - * Note: By default, this `subscribeSetting` function automatically retrieves the current - * project setting value and runs the provided callback as soon as possible. That way, if - * you want to keep your data up-to-date, you do not also have to run `getSetting`. You can - * turn this functionality off in the `options` parameter. - * - * @param key The string id of the project setting for which to listen to changes - * @param callback Function to run with the updated project setting value - * @param options Various options to adjust how the subscriber emits updates - * @returns Unsubscriber to stop listening for updates - */ - subscribeSetting: ( - key: ProjectSettingName, - callback: (value: ProjectSettingTypes[ProjectSettingName]) => void, - options: DataProviderSubscriberOptions, - ) => Promise; - }; - /** This is just a simple example so we have more than one. It's not intended to be real. */ - type NotesOnlyProjectDataTypes = MandatoryProjectDataTypes & { - Notes: DataProviderDataType; + export type WebViewMessageRequestHandler = ( + webViewNonce: string, + message: unknown, + targetOrigin?: string, + ) => Promise; + /** Gets the id for the web view controller network object with the given name */ + export const getWebViewControllerObjectId: (webViewId: string) => string; + /** Network object type for web view controllers */ + export const WEB_VIEW_CONTROLLER_OBJECT_TYPE = 'webViewController'; + export function getWebViewController( + webViewType: WebViewType, + webViewId: WebViewId, + ): Promise | undefined>; + /** 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}`; + /** Event emitted when webViews are created */ + export type AddWebViewEvent = { + webView: SavedWebViewDefinition; + layout: Layout; + }; + /** Name to use when creating a network event that is fired when webViews are updated */ + export const EVENT_NAME_ON_DID_UPDATE_WEB_VIEW: `${string}:${string}`; + /** Event emitted when webViews are updated */ + export type UpdateWebViewEvent = { + webView: SavedWebViewDefinition; + }; + /** Name to use when creating a network event that is fired when webViews are closed */ + export const EVENT_NAME_ON_DID_CLOSE_WEB_VIEW: `${string}:${string}`; + /** Event emitted when webViews are closed */ + export type CloseWebViewEvent = { + webView: SavedWebViewDefinition; }; + 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/web-view-provider.service' { /** - * {@link IProjectDataProvider} types for each `projectInterface` supported by PAPI. Extensions can - * add more Project Data Providers with corresponding `projectInterface`s by adding details to - * their `.d.ts` file and registering a Project Data Provider factory with the corresponding - * `projectInterface`. - * - * There are two types of Project Data Providers (and Project Data Provider Factories that serve - * them): + * Handles registering web view providers and serving web views around the papi. Exposed on the + * papi. + */ + import { + IDisposableWebViewProvider, + IWebViewProvider, + IRegisteredWebViewProvider, + } from 'shared/models/web-view-provider.model'; + import { WebViewControllers, WebViewControllerTypes } from 'papi-shared-types'; + import { DisposableNetworkObject } from 'shared/models/network-object.model'; + import { WebViewId } from 'shared/models/web-view.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. * - * 1. Base Project Data Provider - provides project data via some `projectInterface`s for its own - * projects with **its own unique project ids**. These PDPs **must support the `platform.base` - * `projectInterface` by implementing {@link IBaseProjectDataProvider}**. More information - * below. - * 2. Layering Project Data Provider - layers over other PDPs and provides additional - * `projectInterface`s for projects on other PDPs. Likely **does not provide its own unique - * project ids** but rather layers over base PDPs' project ids. These PDPs **do not need to - * support the `platform.base` `projectInterface` and should instead implement - * {@link IProjectDataProvider}**. Instead of providing projects themselves, they likely use the - * `ExtensionData` data type exposed via the `platform.base` `projectInterface` on Base PDPs to - * provide additional project data on top of Base PDPs. + * @param webViewType Type of webView to check for + */ + function hasKnownWebViewProvider(webViewType: string): boolean; + /** + * Register a web view provider to serve webViews for a specified type of webViews * - * All Base Project Data Provider Interfaces' data types **must** implement - * {@link IBaseProjectDataProvider} (which extends {@link MandatoryProjectDataTypes}) like in the - * following example. Please see its documentation for information on how Project Data Providers - * can implement this interface. + * @param webViewType Type of web view to provide + * @param webViewProvider Object to register as a webView provider including control over disposing + * of it. * - * Note: The keys of this interface are the `projectInterface`s for the associated Project Data - * Provider Interfaces. `projectInterface`s represent standardized sets of methods on a PDP. + * WARNING: setting a webView provider mutates the provided object. + * @returns `webViewProvider` modified to be a network object and able to be disposed with `dispose` + */ + function register( + webViewType: string, + webViewProvider: IWebViewProvider, + ): Promise; + /** + * Get a web view provider that has previously been set up * - * WARNING: Each Base Project Data Provider **must** fulfill certain requirements for its - * `getSetting`, `setSetting`, `resetSetting`, `getExtensionData`, and `setExtensionData` methods. - * See {@link IBaseProjectDataProvider} and {@link MandatoryProjectDataTypes} for more information. + * @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; + /** + * Register a web view controller to represent a web view. It is expected that a web view provider + * calls this to register a web view controller for a web view that is being created. If a web view + * provider extends {@link WebViewFactory}, it will call this function automatically. + * + * A Web View Controller is a network object that represents a web view and whose methods facilitate + * communication between its associated web view and extensions that want to interact with it. + * + * You can get web view controllers with {@link webViewService.getWebViewController}. + * + * @param webViewType Type of web view for which you are providing this web view controller + * @param webViewId Id of web view for which to register the web view controller + * @param webViewController Object to register as a web view controller including control over + * disposing of it. Note: the web view controller will be disposed automatically when the web view + * is closed + * + * WARNING: setting a web view controller mutates the provided object. + * @returns `webViewController` modified to be a network object + */ + function registerWebViewController( + webViewType: WebViewType, + webViewId: WebViewId, + webViewController: WebViewControllers[WebViewType], + ): Promise>; + /** + * Sends a message to the specified web view. Expected to be used only by the + * {@link IWebViewProvider} that created the web view or the {@link WebViewControllers} that + * represents the web view created by the Web View Provider. + * + * [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) is used to + * deliver the message to the web view iframe. The web view can use + * [`window.addEventListener("message", + * ...)`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#the_dispatched_event) + * in order to receive these messages. + * + * @param webViewId Id of the web view to which to send a message. + * @param webViewNonce Nonce used to perform privileged interactions with the web view. Pass in the + * nonce the web view provider received from {@link IWebViewProvider.getWebView}'s `webViewNonce` + * parameter or from {@link WebViewFactory.createWebViewController}'s `webViewNonce` parameter + * @param message Data to send to the web view. Can only send serializable information + * @param targetOrigin Expected origin of the web view. Does not send the message if the web view's + * origin does not match. See [`postMessage`'s + * `targetOrigin`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin) + * for more information. Defaults to same origin only (works automatically with React and HTML web + * views) + */ + function postMessageToWebView( + webViewId: WebViewId, + webViewNonce: string, + message: unknown, + targetOrigin?: string, + ): Promise; + export interface WebViewProviderService { + initialize: typeof initialize; + hasKnown: typeof hasKnownWebViewProvider; + register: typeof register; + get: typeof get; + registerWebViewController: typeof registerWebViewController; + postMessageToWebView: typeof postMessageToWebView; + } + export interface PapiWebViewProviderService { + register: typeof register; + registerWebViewController: typeof registerWebViewController; + postMessageToWebView: typeof postMessageToWebView; + } + const webViewProviderService: WebViewProviderService; + /** * - * An extension can extend this interface to add types for the Project Data Provider Interfaces - * its registered factory provides by adding the following to its `.d.ts` file (in this example, - * we are adding a Base Project Data Provider interface for the `MyExtensionBaseProjectInterface` - * `projectInterface` and a Layering Project Data Provider interface for the - * `MyExtensionLayeringProjectInterface` `projectInterface`): + * Interface for registering webView providers + */ + export const papiWebViewProviderService: PapiWebViewProviderService; + export default webViewProviderService; +} +declare module 'shared/models/web-view-factory.model' { + import { WebViewControllers, WebViewControllerTypes } from 'papi-shared-types'; + import type { IWebViewProvider } from 'shared/models/web-view-provider.model'; + import { + SavedWebViewDefinition, + GetWebViewOptions, + WebViewDefinition, + } from 'shared/models/web-view.model'; + /** * - * @example + * A partial implementation of {@link IWebViewProvider} that includes creating + * {@link WebViewControllers} for each web view served by the web view provider. This class handles + * registering, disposing, and making sure there is only one web view controller for each web view + * for you. * - * ```typescript - * declare module 'papi-shared-types' { - * export type MyBaseProjectDataTypes = { - * MyProjectData: DataProviderDataType; - * }; + * You can create a new class extending this abstract class to create a web view provider that + * serves web views and web view controllers of a specific `webViewType` to facilitate interaction + * between those web views and other extensions. You can register it with the PAPI using + * `papi.webViewProviders.register`. * - * export type MyLayeringProjectDataTypes = { - * MyOtherProjectData: DataProviderDataType; - * }; + * If you want to change your existing `IWebViewProvider` from a plain object to extending this + * class, you will need to change your object's existing method named `getWebView` + * ({@link IWebViewProvider.getWebView}) to be named `getWebViewDefinition` + * ({@link WebViewFactory.getWebViewDefinition}), which is a drop-in replacement. You likely do NOT + * want to overwrite this class's `getWebView` because that will eliminate most of the benefits + * associated with using this class. * - * export interface ProjectDataProviderInterfaces { - * // Note that the base PDP implements `I**Base**ProjectDataProvider` - * MyExtensionBaseProjectInterface: IBaseProjectDataProvider; - * // Note that the layering PDP only implements `IProjectDataProvider` because the base PDP already - * // provides the `platform.base` data types - * MyExtensionLayeringProjectInterface: IProjectDataProvider; - * } - * } - * ``` + * @see {@link IWebViewProvider} for more information on extending this class. */ - interface ProjectDataProviderInterfaces { + export abstract class WebViewFactory + implements IWebViewProvider + { + readonly webViewType: WebViewType; + private readonly webViewControllersMutexMap; + private readonly webViewControllersCleanupList; + private readonly webViewControllersById; + constructor(webViewType: WebViewType); /** - * Base `projectInterface` that all PDPs that expose their own unique project ids must - * implement. + * Receives a {@link SavedWebViewDefinition} and fills it out into a full {@link WebViewDefinition}, + * providing the contents of the web view and other properties that are important for displaying + * the web view. * - * There should be a PDP that provides `platform.base` for all available project ids. + * WARNING: This method must NOT be overridden if you want any of the benefits of this class. You + * are probably looking for {@link WebViewFactory.getWebViewDefinition}. + * + * If you are transferring a web view provider using a plain object to extending this class, you + * should rename your existing `getWebView` to `getWebViewDefinition`. */ - [PROJECT_INTERFACE_PLATFORM_BASE]: IBaseProjectDataProvider; - 'platform.notesOnly': IProjectDataProvider; - 'platform.placeholder': IProjectDataProvider; + getWebView( + savedWebViewDefinition: SavedWebViewDefinition, + getWebViewOptions: GetWebViewOptions, + webViewNonce: string, + ): Promise; + /** Disposes of all WVCs that were created by this provider */ + dispose(): Promise; + /** + * Receives a {@link SavedWebViewDefinition} and fills it out into a full {@link WebViewDefinition}, + * providing the contents of the web view and other properties that are important for displaying + * the web view. + * + * {@link WebViewFactory} calls this as part of its {@link getWebView}, which is called by the PAPI + * as part of opening a new web view. It will also create a web view controller after running this + * if applicable. + * + * See {@link IWebViewProvider.getWebView} for more information on how this method works. + * + * @param savedWebViewDefinition The saved web view information from which to build a complete web + * view definition. Filled out with all {@link SavedWebViewDefinition} properties of the existing + * web view if an existing webview is being called for (matched by ID). Just provides the + * minimal properties required on {@link SavedWebViewDefinition} if this is a new request or if + * the web view with the existing ID was not found. + * @param getWebViewOptions Various options that affect what calling `papi.webViews.openWebView` + * should do. When options are passed to `papi.webViews.openWebView`, some defaults are set up + * on the options, then those options are passed directly through to this method. That way, if + * you want to adjust what this method does based on the contents of the options passed to + * `papi.WebViews.openWebView`, you can. You can even read other properties on these options if + * someone passes options with other properties to `papi.webViews.openWebView`. + * @param webViewNonce Nonce used to perform privileged interactions with the web view created + * from this method's returned {@link WebViewDefinition} such as + * `papi.webViewProviders.postMessageToWebView`. The web view service generates this nonce and + * sends it _only here_ to this web view provider that creates the web view with this id. It is + * generally recommended that this web view provider not share this nonce with anyone else but + * only use it within itself and in the web view controller created for this web view if + * applicable (See `papi.webViewProviders.registerWebViewController`). + * @returns Full {@link WebViewDefinition} including the content and other important display + * properties based on the {@link SavedWebViewDefinition} provided + */ + abstract getWebViewDefinition( + savedWebViewDefinition: SavedWebViewDefinition, + getWebViewOptions: GetWebViewOptions, + webViewNonce: string, + ): Promise; + /** + * Creates a {@link WebViewController} that corresponds to the {@link WebViewDefinition} provided. + * {@link WebViewFactory} calls this as part of its {@link getWebView}. {@link WebViewFactory} will + * automatically register this controller with the web view provider service + * (`papi.webViewProviders.registerWebViewController`), run its `dispose` when the web view is + * closed or this `WebViewFactory` is disposed, and make sure just one web view controller is + * created for each web view. + * + * Alternatively, if you do not want to create web view controllers (or a controller for a + * specific web view), feel free to return `undefined` from this method. + * + * @param webViewDefinition The definition for the web view for which to create a web view + * controller + * @param webViewNonce Nonce used to perform privileged interactions with the web view created + * from the `webViewDefinition` parameter such as `papi.webViewProviders.postMessageToWebView`. + * The web view service generates this nonce and sends it _only here_ to this web view provider + * that creates the web view with this id. It is generally recommended that this web view + * provider not share this nonce with anyone else but only use it within itself and here in the + * web view controller created for this web view. + * @returns Web view controller for the web view with the `webViewDefinition` provided. Or + * `undefined` if you do not want to create a web view controller for this web view. + */ + abstract createWebViewController( + webViewDefinition: WebViewDefinition, + webViewNonce: string, + ): Promise; } +} +declare module 'papi-shared-types' { + import type { ScriptureReference, UnsubscriberAsync } from 'platform-bible-utils'; + import type { + DataProviderDataType, + DataProviderDataTypes, + DataProviderSubscriberOptions, + DataProviderUpdateInstructions, + } from 'shared/models/data-provider.model'; + import type { + MandatoryProjectDataTypes, + PROJECT_INTERFACE_PLATFORM_BASE, + WithProjectDataProviderEngineExtensionDataMethods, + } from 'shared/models/project-data-provider.model'; + import type { IDisposableDataProvider } from 'shared/models/data-provider.interface'; + import type IDataProvider from 'shared/models/data-provider.interface'; + import type ExtractDataProviderDataTypes from 'shared/models/extract-data-provider-data-types.model'; + import type { NetworkableObject } from 'shared/models/network-object.model'; /** - * Names for each `projectInterface` available on the papi. `projectInterface`s represent - * standardized sets of methods on a PDP. Extensions can register a Project Data Provider Factory - * with one or more `projectInterface`s to indicate that factory provides Project Data Providers - * that have the methods associated with those `projectInterface`s. - * - * Automatically includes all extensions' `projectInterface`s that are added to - * {@link ProjectDataProviderInterfaces}. - * - * @example 'platform.notesOnly' - */ - type ProjectInterfaces = keyof ProjectDataProviderInterfaces; - /** - * `DataProviderDataTypes` for each Project Data Provider Interface supported by PAPI. These are - * the data types served by Project Data Providers for each `projectInterface`. + * Function types for each command available on the papi. Each extension can extend this interface + * to add commands that it registers on the papi with `papi.commands.registerCommand`. * - * Automatically includes all extensions' `projectInterface`s that are added to - * {@link ProjectDataProviderInterfaces}. + * Note: Command names must consist of two strings separated by at least one period. We recommend + * one period and lower camel case in case we expand the api in the future to allow dot notation. * - * Note: The keys of this interface are the `projectInterface`s for the associated project data - * provider interface data types. `projectInterface`s represent standardized sets of methods on a - * PDP. + * An extension can extend this interface to add types for the commands it registers by adding the + * following to its `.d.ts` file: * * @example * * ```typescript - * ProjectInterfaceDataTypes['MyExtensionProjectInterfaceName'] => MandatoryProjectDataTypes & { - * MyProjectData: DataProviderDataType; + * declare module 'papi-shared-types' { + * export interface CommandHandlers { + * 'myExtension.myCommand1': (foo: string, bar: number) => string; + * 'myExtension.myCommand2': (foo: string) => Promise; * } + * } * ``` */ - type ProjectInterfaceDataTypes = { - [ProjectInterface in ProjectInterfaces]: ExtractDataProviderDataTypes< - ProjectDataProviderInterfaces[ProjectInterface] - >; - }; - type StuffDataTypes = { - Stuff: DataProviderDataType; - }; - type PlaceholderDataTypes = { - Placeholder: DataProviderDataType< - { - thing: number; - }, - string[], - number - >; - }; + interface CommandHandlers { + 'test.echo': (message: string) => string; + 'test.echoExtensionHost': (message: string) => Promise; + 'test.throwError': (message: string) => void; + 'platform.restartExtensionHost': () => Promise; + /** Shut down the application */ + 'platform.quit': () => Promise; + /** Restart the application */ + 'platform.restart': () => Promise; + 'platform.openProjectSettings': (webViewId: string) => Promise; + 'platform.openUserSettings': () => Promise; + 'test.addMany': (...nums: number[]) => number; + 'test.throwErrorExtensionHost': (message: string) => void; + } /** - * {@link IDataProvider} types for each data provider supported by PAPI. Extensions can add more - * data providers with corresponding data provider IDs by adding details to their `.d.ts` file and - * registering a data provider engine in their `activate` function with - * `papi.dataProviders.registerEngine`. + * Names for each command available on the papi. * - * Note: Data Provider names must consist of two strings separated by at least one period. We - * recommend one period and lower camel case in case we expand the api in the future to allow dot - * notation. + * Automatically includes all extensions' commands that are added to {@link CommandHandlers}. * - * An extension can extend this interface to add types for the data provider it registers by + * @example 'platform.quit'; + */ + type CommandNames = keyof CommandHandlers; + /** + * Types corresponding to each user setting available in Platform.Bible. Keys are setting names, + * and values are setting data types. Extensions can add more user setting types with + * corresponding user setting IDs by adding details to their `.d.ts` file. + * + * Note: Setting names must consist of two strings separated by at least one period. We recommend + * one period and lower camel case in case we expand the api in the future to allow dot notation. + * + * An extension can extend this interface to add types for the user settings it registers by * adding the following to its `.d.ts` file (in this example, we are adding the - * `'helloSomeone.people'` data provider types): + * `myExtension.highlightColor` setting): * * @example * * ```typescript * declare module 'papi-shared-types' { - * export type PeopleDataTypes = { - * Greeting: DataProviderDataType; - * Age: DataProviderDataType; - * People: DataProviderDataType; - * }; - * - * export type PeopleDataMethods = { - * deletePerson(name: string): Promise; - * testRandomMethod(things: string): Promise; - * }; - * - * export type PeopleDataProvider = IDataProvider & PeopleDataMethods; - * - * export interface DataProviders { - * 'helloSomeone.people': PeopleDataProvider; + * export interface SettingTypes { + * 'myExtension.highlightColor': string | { r: number; g: number; b: number }; * } * } * ``` */ - interface DataProviders { - 'platform.stuff': IDataProvider; - 'platform.placeholder': IDataProvider; + interface SettingTypes { + /** + * Current Verse Reference for Scroll Group A. Deprecated - please use `papi.scrollGroups` and + * `useWebViewScrollGroupScrRef` + */ + 'platform.verseRef': ScriptureReference; + /** + * List of locales to use when localizing the interface. First in the list receives highest + * priority. Please always add 'en' (English) at the end when using this setting so everything + * localizes to English if it does not have a localization in a higher-priority locale. + */ + 'platform.interfaceLanguage': string[]; + /** + * Mementos managed in the dotnet process and used for interacting with PtxUtils. Mementos are + * persisted objects containing some data. They are stored as xml strings. + */ + 'platform.ptxUtilsMementoData': { + [key: string]: string; + }; + /** + * Tracking last S/R registry data cache time managed in the dotnet process and used for + * interacting with ParatextData. + */ + 'platform.paratextDataLastRegistryDataCachedTimes': { + [key: string]: string; + }; + /** Enable reading and writing comments in projects. This is an experimental feature. */ + 'platform.commentsEnabled': boolean; } /** - * Names for each data provider available on the papi. + * Names for each user setting available on the papi. * - * Automatically includes all extensions' data providers that are added to {@link DataProviders}. + * Automatically includes all extensions' user settings that are added to {@link SettingTypes}. * - * @example 'platform.placeholder' + * @example 'platform.verseRef' */ - type DataProviderNames = keyof DataProviders; + type SettingNames = keyof SettingTypes; /** - * `DataProviderDataTypes` for each data provider supported by PAPI. These are the data types - * served by each data provider. + * Types corresponding to each project setting available in Platform.Bible. Keys are project + * setting names, and values are project setting data types. Extensions can add more project + * setting types with corresponding project setting IDs by adding details to their `.d.ts` file. * - * Automatically includes all extensions' data providers that are added to {@link DataProviders}. + * Note: Project setting names must consist of two strings separated by at least one period. We + * recommend one period and lower camel case in case we expand the api in the future to allow dot + * notation. + * + * An extension can extend this interface to add types for the project settings it registers by + * adding the following to its `.d.ts` file (in this example, we are adding the + * `myExtension.highlightColor` project setting): * * @example * * ```typescript - * DataProviderTypes['helloSomeone.people'] => { - * Greeting: DataProviderDataType; - * Age: DataProviderDataType; - * People: DataProviderDataType; + * declare module 'papi-shared-types' { + * export interface ProjectSettingTypes { + * 'myExtension.highlightColor': string | { r: number; g: number; b: number }; * } + * } * ``` */ - type DataProviderTypes = { - [DataProviderName in DataProviderNames]: ExtractDataProviderDataTypes< - DataProviders[DataProviderName] - >; - }; - /** - * Disposable version of each data provider type supported by PAPI. These objects are only - * returned from `papi.dataProviders.registerEngine` - only the one who registers a data provider - * engine is allowed to dispose of the data provider. - * - * Automatically includes all extensions' data providers that are added to {@link DataProviders}. - */ - type DisposableDataProviders = { - [DataProviderName in DataProviderNames]: IDisposableDataProvider< - DataProviders[DataProviderName] - >; - }; -} -declare module 'shared/services/command.service' { - import { UnsubscriberAsync } from 'platform-bible-utils'; - import { CommandHandlers } from 'papi-shared-types'; - /** - * Register a command on the papi to be handled here - * - * @param commandName Command name to register for handling here - * - * - Note: Command names must consist of two string separated by at least one period. We recommend one - * period and lower camel case in case we expand the api in the future to allow dot notation. - * - * @param handler Function to run when the command is invoked - * @returns Promise that resolves if the command successfully registered and unsubscriber function - * to run to stop the passed-in function from handling commands - */ - export const registerCommand: ( - commandName: CommandName, - handler: CommandHandlers[CommandName], - ) => Promise; - /** Send a command to the backend. */ - export const sendCommand: ( - commandName: CommandName, - ...args: Parameters - ) => Promise>>; + interface ProjectSettingTypes { + /** + * Localized name of the language in which this project is written. This will be displayed + * directly in the UI. + * + * @example 'English' + */ + 'platform.language': string; + /** + * Short name of the project (not necessarily unique). This will be displayed directly in the + * UI. + * + * @example 'WEB' + */ + 'platform.name': string; + /** + * Localized full name of the project. This will be displayed directly in the UI. + * + * @example 'World English Bible' + */ + 'platform.fullName': string; + /** + * Whether or not the project is editable. This is a general "editable", not necessarily that it + * is editable by the current user. + * + * Projects that are not editable are sometimes called "resources". + */ + 'platform.isEditable': boolean; + } /** - * Creates a function that is a command function with a baked commandName. This is also nice because - * you get TypeScript type support using this function. + * Names for each user setting available on the papi. * - * @param commandName Command name for command function - * @returns Function to call with arguments of command that sends the command and resolves with the - * result of the command - */ - export const createSendCommandFunction: ( - commandName: CommandName, - ) => ( - ...args: Parameters - ) => Promise>>; - /** + * Automatically includes all extensions' user settings that are added to + * {@link ProjectSettingTypes}. * - * The command service allows you to exchange messages with other components in the platform. You - * can register a command that other services and extensions can send you. You can send commands to - * other services and extensions that have registered commands. + * @example 'platform.fullName' */ - export type moduleSummaryComments = {}; -} -declare module 'shared/models/docking-framework.model' { - import { MutableRefObject, ReactNode } from 'react'; - import { DockLayout, DropDirection, LayoutBase } from 'rc-dock'; - import { WebViewDefinition, WebViewDefinitionUpdateInfo } from 'shared/models/web-view.model'; - import { LocalizeKey } from 'platform-bible-utils'; + type ProjectSettingNames = keyof ProjectSettingTypes; /** - * Saved information used to recreate a tab. - * - * - {@link TabLoader} loads this into {@link TabInfo} - * - {@link TabSaver} saves {@link TabInfo} into this + * The `Setting` methods required for a Project Data Provider Engine to fulfill the requirements + * of {@link MandatoryProjectDataTypes}'s `Setting` data type. */ - export type SavedTabInfo = { + type WithProjectDataProviderEngineSettingMethods< + TProjectDataTypes extends DataProviderDataTypes, + > = { /** - * Tab ID - a unique identifier that identifies this tab. If this tab is a WebView, this ID will - * match the `WebViewDefinition.id` + * Set the value of the specified project setting on this project. + * + * Note for implementing: `setSetting` must call `papi.projectSettings.isValid` before allowing + * the setting change. + * + * @param key The string id of the project setting to change + * @param newSetting The value that is to be set to the project setting. + * @returns Information that papi uses to interpret whether to send out updates. Defaults to + * `true` (meaning send updates only for this data type). + * @throws If the setting validator failed. + * @see {@link DataProviderUpdateInstructions} for more info on what to return */ - 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 & { + setSetting: ( + key: ProjectSettingName, + newSetting: ProjectSettingTypes[ProjectSettingName], + ) => Promise>; /** - * Url of image to show on the title bar of the tab + * Get the value of the specified project setting. * - * Defaults to the software's standard logo. + * Note: This is good for retrieving a project setting once. If you want to keep the value + * up-to-date, use `subscribeSetting` instead, which can immediately give you the value and keep + * it up-to-date. + * + * Note for implementing: `getSetting` must call `papi.projectSettings.getDefault` if this + * project does not have a value for this setting + * + * @param key The string id of the project setting to get + * @returns The value of the specified project setting. Returns default setting value if the + * project setting does not exist on the project. + * @throws If no default value is available for the setting. */ - tabIconUrl?: string; + getSetting: ( + key: ProjectSettingName, + ) => Promise; /** - * Text to show (or a localizeKey that will automatically be localized) on the title bar of the - * tab + * Deletes the specified project setting, setting it back to its default value. + * + * Note for implementing: `resetSetting` should remove the value for this setting for this + * project such that calling `getSetting` later would cause it to call + * `papi.projectSettings.getDefault` and return the default value. + * + * @param key The string id of the project setting to reset + * @returns `true` if successfully reset the project setting, `false` otherwise */ - tabTitle: string | LocalizeKey; - /** Text to show when hovering over the title bar of the tab */ - tabTooltip?: 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; + resetSetting: ( + key: ProjectSettingName, + ) => Promise; }; /** - * Function that takes a {@link SavedTabInfo} and creates a Paranext tab out of it. Each type of tab - * must provide a {@link TabLoader}. + * An object on the papi for interacting with that project data. Created by the papi and layers + * over an {@link IProjectDataProviderEngine} provided by an extension. Returned from getting a + * project data provider with `papi.projectDataProviders.get`. * - * For now all tab creators must do their own data type verification + * Project Data Providers are a specialized version of {@link IDataProvider} that work with + * projects by exposing methods according to a set of `projectInterface`s. For each project + * available, a Project Data Provider Factory that supports that project with some set of + * `projectInterface`s creates a new instance of a PDP with the supported `projectInterface`s. + * + * Often, these objects are Layering PDPs, meaning they manipulate data provided by Base PDPs + * which actually control the saving and loading of the data. Base PDPs must implement + * {@link IBaseProjectDataProvider}, which imposes additional requirements. + * + * See more information, including the difference between Base and Layering PDPs, at + * {@link ProjectDataProviderInterfaces}. */ - export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; + type IProjectDataProvider = + IDataProvider; /** - * 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}). + * An object on the papi for interacting with that project data. Created by the papi and layers + * over an {@link IBaseProjectDataProviderEngine} provided by an extension. Sometimes returned from + * getting a project data provider with `papi.projectDataProviders.get` (depending on if the PDP + * supports the `platform.base` `projectInterface`). * - * @param tabInfo The Paranext tab to save - * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab + * Project Data Providers are a specialized version of {@link IDataProvider} that work with + * projects by exposing methods according to a set of `projectInterface`s. For each project + * available, a Project Data Provider Factory that supports that project with some set of + * `projectInterface`s creates a new instance of a PDP with the supported `projectInterface`s. + * + * Every Base PDP **must** fulfill the requirements of this interface in order to support the + * methods the PAPI requires for interacting with project data. + * + * See more information, including the difference between Base and Layering PDPs, at + * {@link ProjectDataProviderInterfaces}. */ - export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; - /** Information about a tab in a panel */ - interface TabLayout { - type: 'tab'; - } + type IBaseProjectDataProvider = + IProjectDataProvider & + WithProjectDataProviderEngineSettingMethods & + WithProjectDataProviderEngineExtensionDataMethods & { + /** + * Subscribe to receive updates to the specified project setting. + * + * Note: By default, this `subscribeSetting` function automatically retrieves the current + * project setting value and runs the provided callback as soon as possible. That way, if + * you want to keep your data up-to-date, you do not also have to run `getSetting`. You can + * turn this functionality off in the `options` parameter. + * + * @param key The string id of the project setting for which to listen to changes + * @param callback Function to run with the updated project setting value + * @param options Various options to adjust how the subscriber emits updates + * @returns Unsubscriber to stop listening for updates + */ + subscribeSetting: ( + key: ProjectSettingName, + callback: (value: ProjectSettingTypes[ProjectSettingName]) => void, + options: DataProviderSubscriberOptions, + ) => Promise; + }; + /** This is just a simple example so we have more than one. It's not intended to be real. */ + type NotesOnlyProjectDataTypes = MandatoryProjectDataTypes & { + Notes: DataProviderDataType; + }; /** - * Indicates where to display a floating window + * {@link IProjectDataProvider} types for each `projectInterface` supported by PAPI. Extensions can + * add more Project Data Providers with corresponding `projectInterface`s by adding details to + * their `.d.ts` file and registering a Project Data Provider factory with the corresponding + * `projectInterface`. * - * - `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 + * There are two types of Project Data Providers (and Project Data Provider Factories that serve + * them): + * + * 1. Base Project Data Provider - provides project data via some `projectInterface`s for its own + * projects with **its own unique project ids**. These PDPs **must support the `platform.base` + * `projectInterface` by implementing {@link IBaseProjectDataProvider}**. More information + * below. + * 2. Layering Project Data Provider - layers over other PDPs and provides additional + * `projectInterface`s for projects on other PDPs. Likely **does not provide its own unique + * project ids** but rather layers over base PDPs' project ids. These PDPs **do not need to + * support the `platform.base` `projectInterface` and should instead implement + * {@link IProjectDataProvider}**. Instead of providing projects themselves, they likely use the + * `ExtensionData` data type exposed via the `platform.base` `projectInterface` on Base PDPs to + * provide additional project data on top of Base PDPs. + * + * All Base Project Data Provider Interfaces' data types **must** implement + * {@link IBaseProjectDataProvider} (which extends {@link MandatoryProjectDataTypes}) like in the + * following example. Please see its documentation for information on how Project Data Providers + * can implement this interface. + * + * Note: The keys of this interface are the `projectInterface`s for the associated Project Data + * Provider Interfaces. `projectInterface`s represent standardized sets of methods on a PDP. + * + * WARNING: Each Base Project Data Provider **must** fulfill certain requirements for its + * `getSetting`, `setSetting`, `resetSetting`, `getExtensionData`, and `setExtensionData` methods. + * See {@link IBaseProjectDataProvider} and {@link MandatoryProjectDataTypes} for more information. + * + * An extension can extend this interface to add types for the Project Data Provider Interfaces + * its registered factory provides by adding the following to its `.d.ts` file (in this example, + * we are adding a Base Project Data Provider interface for the `MyExtensionBaseProjectInterface` + * `projectInterface` and a Layering Project Data Provider interface for the + * `MyExtensionLayeringProjectInterface` `projectInterface`): + * + * @example + * + * ```typescript + * declare module 'papi-shared-types' { + * export type MyBaseProjectDataTypes = { + * MyProjectData: DataProviderDataType; + * }; + * + * export type MyLayeringProjectDataTypes = { + * MyOtherProjectData: DataProviderDataType; + * }; + * + * export interface ProjectDataProviderInterfaces { + * // Note that the base PDP implements `I**Base**ProjectDataProvider` + * MyExtensionBaseProjectInterface: IBaseProjectDataProvider; + * // Note that the layering PDP only implements `IProjectDataProvider` because the base PDP already + * // provides the `platform.base` data types + * MyExtensionLayeringProjectInterface: IProjectDataProvider; + * } + * } + * ``` */ - 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; - /** 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; + interface ProjectDataProviderInterfaces { /** - * The layout to use as the default layout if the dockLayout doesn't have a layout loaded. + * Base `projectInterface` that all PDPs that expose their own unique project ids must + * implement. * - * 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 + * There should be a PDP that provides `platform.base` for all available project ids. */ - testLayout: LayoutBase; + [PROJECT_INTERFACE_PLATFORM_BASE]: IBaseProjectDataProvider; + 'platform.notesOnly': IProjectDataProvider; + 'platform.placeholder': IProjectDataProvider; + } + /** + * Names for each `projectInterface` available on the papi. `projectInterface`s represent + * standardized sets of methods on a PDP. Extensions can register a Project Data Provider Factory + * with one or more `projectInterface`s to indicate that factory provides Project Data Providers + * that have the methods associated with those `projectInterface`s. + * + * Automatically includes all extensions' `projectInterface`s that are added to + * {@link ProjectDataProviderInterfaces}. + * + * @example 'platform.notesOnly' + */ + type ProjectInterfaces = keyof ProjectDataProviderInterfaces; + /** + * `DataProviderDataTypes` for each Project Data Provider Interface supported by PAPI. These are + * the data types served by Project Data Providers for each `projectInterface`. + * + * Automatically includes all extensions' `projectInterface`s that are added to + * {@link ProjectDataProviderInterfaces}. + * + * Note: The keys of this interface are the `projectInterface`s for the associated project data + * provider interface data types. `projectInterface`s represent standardized sets of methods on a + * PDP. + * + * @example + * + * ```typescript + * ProjectInterfaceDataTypes['MyExtensionProjectInterfaceName'] => MandatoryProjectDataTypes & { + * MyProjectData: DataProviderDataType; + * } + * ``` + */ + type ProjectInterfaceDataTypes = { + [ProjectInterface in ProjectInterfaces]: ExtractDataProviderDataTypes< + ProjectDataProviderInterfaces[ProjectInterface] + >; }; -} -declare module 'shared/services/web-view.service-model' { - import { - GetWebViewOptions, - SavedWebViewDefinition, - WebViewId, - WebViewType, - } from 'shared/models/web-view.model'; - import { Layout } from 'shared/models/docking-framework.model'; - import { PlatformEvent } from 'platform-bible-utils'; + type StuffDataTypes = { + Stuff: DataProviderDataType; + }; + type PlaceholderDataTypes = { + Placeholder: DataProviderDataType< + { + thing: number; + }, + string[], + number + >; + }; + /** + * {@link IDataProvider} types for each data provider supported by PAPI. Extensions can add more + * data providers with corresponding data provider IDs by adding details to their `.d.ts` file and + * registering a data provider engine in their `activate` function with + * `papi.dataProviders.registerEngine`. + * + * Note: Data Provider names must consist of two strings separated by at least one period. We + * recommend one period and lower camel case in case we expand the api in the future to allow dot + * notation. + * + * An extension can extend this interface to add types for the data provider it registers by + * adding the following to its `.d.ts` file (in this example, we are adding the + * `'helloSomeone.people'` data provider types): + * + * @example + * + * ```typescript + * declare module 'papi-shared-types' { + * export type PeopleDataTypes = { + * Greeting: DataProviderDataType; + * Age: DataProviderDataType; + * People: DataProviderDataType; + * }; + * + * export type PeopleDataMethods = { + * deletePerson(name: string): Promise; + * testRandomMethod(things: string): Promise; + * }; + * + * export type PeopleDataProvider = IDataProvider & PeopleDataMethods; + * + * export interface DataProviders { + * 'helloSomeone.people': PeopleDataProvider; + * } + * } + * ``` + */ + interface DataProviders { + 'platform.stuff': IDataProvider; + 'platform.placeholder': IDataProvider; + } /** + * Names for each data provider available on the papi. * - * Service exposing various functions related to using webViews + * Automatically includes all extensions' data providers that are added to {@link DataProviders}. * - * WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either - * HTML or React components. + * @example 'platform.placeholder' */ - export interface WebViewServiceType { - /** Event that emits with webView info when a webView is added */ - onDidAddWebView: PlatformEvent; - /** Event that emits with webView info when a webView is updated */ - onDidUpdateWebView: PlatformEvent; - /** - * 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; - /** - * Gets the saved properties on the WebView definition with the specified ID - * - * Note: this only returns a representation of the current web view definition, not the actual web - * view definition itself. Changing properties on the returned definition does not affect the - * actual web view definition. You can possibly change the actual web view definition by calling - * {@link WebViewServiceType.getWebView} with certain `options`, depending on what options the web - * view provider has made available. - * - * @param webViewId The ID of the WebView whose saved properties to get - * @returns Saved properties of the WebView definition with the specified ID or undefined if not - * found - */ - getSavedWebViewDefinition(webViewId: string): 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}`; - /** Event emitted when webViews are created */ - export type AddWebViewEvent = { - webView: SavedWebViewDefinition; - layout: Layout; - }; - /** Name to use when creating a network event that is fired when webViews are updated */ - export const EVENT_NAME_ON_DID_UPDATE_WEB_VIEW: `${string}:${string}`; - /** Event emitted when webViews are updated */ - export type UpdateWebViewEvent = { - webView: SavedWebViewDefinition; - }; - export const NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE = 'WebViewService'; -} -declare module 'shared/models/network-object-status.service-model' { - import { NetworkObjectDetails } from 'shared/models/network-object.model'; - export interface NetworkObjectStatusRemoteServiceType { - /** - * Get details about all available network objects - * - * @returns Object whose keys are the names of the network objects and whose values are the - * {@link NetworkObjectDetails} for each network object - */ - getAllNetworkObjectDetails: () => Promise>; - } + type DataProviderNames = keyof DataProviders; /** + * `DataProviderDataTypes` for each data provider supported by PAPI. These are the data types + * served by each data provider. * - * Provides functions related to the set of available network objects + * Automatically includes all extensions' data providers that are added to {@link DataProviders}. + * + * @example + * + * ```typescript + * DataProviderTypes['helloSomeone.people'] => { + * Greeting: DataProviderDataType; + * Age: DataProviderDataType; + * People: DataProviderDataType; + * } + * ``` */ - export interface NetworkObjectStatusServiceType extends NetworkObjectStatusRemoteServiceType { - /** - * Get a promise that resolves when a network object is registered or rejects if a timeout is hit - * - * @param objectDetailsToMatch Subset of object details on the network object to wait for. - * Compared to object details using {@link isSubset} - * @param timeoutInMS Max duration to wait for the network object. If not provided, it will wait - * indefinitely - * @returns Promise that either resolves to the {@link NetworkObjectDetails} for a network object - * once the network object is registered, or rejects if a timeout is provided and the timeout is - * reached before the network object is registered - */ - waitForNetworkObject: ( - objectDetailsToMatch: Partial, - timeoutInMS?: number, - ) => Promise; - } - export const networkObjectStatusServiceNetworkObjectName = 'NetworkObjectStatusService'; -} -declare module 'shared/services/network-object-status.service' { - import { NetworkObjectStatusServiceType } from 'shared/models/network-object-status.service-model'; + type DataProviderTypes = { + [DataProviderName in DataProviderNames]: ExtractDataProviderDataTypes< + DataProviders[DataProviderName] + >; + }; /** + * Disposable version of each data provider type supported by PAPI. These objects are only + * returned from `papi.dataProviders.registerEngine` - only the one who registers a data provider + * engine is allowed to dispose of the data provider. * - * Provides functions related to the set of available network objects + * Automatically includes all extensions' data providers that are added to {@link DataProviders}. */ - const networkObjectStatusService: NetworkObjectStatusServiceType; - export default networkObjectStatusService; -} -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/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 'platform-bible-utils'; - 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' { + type DisposableDataProviders = { + [DataProviderName in DataProviderNames]: IDisposableDataProvider< + DataProviders[DataProviderName] + >; + }; /** - * Handles registering web view providers and serving web views around the papi. Exposed on the - * papi. + * {@link NetworkableObject} types for each web view controller supported by PAPI. A Web View + * Controller is a network object that represents a web view and whose methods facilitate + * communication between its associated web view and extensions that want to interact with it. + * `WebViewControllers` can be created by {@link IWebViewProvider} of the same `webViewType`. + * Extensions can add web view controllers with corresponding `webViewType`s by adding details to + * their `.d.ts` file and registering a web view controller in their web view provider's + * `getWebView` function with `papi.webViewProviders.registerWebViewController`. If you want to + * create web view controllers, we recommend you create a class derived from the abstract class + * {@link WebViewFactory}, which automatically manage the lifecycle of the web view controllers for + * you. + * + * Note: The keys of this interface are the `webViewType`s for the web views associated with these + * web view controllers. They must consist of two strings separated by at least one period. We + * recommend one period and lower camel case in case we expand the api in the future to allow dot + * notation. + * + * An extension can extend this interface to add a type for the web view controller it registers + * by adding the following to its `.d.ts` file (in this example, we are adding the + * `'helloSomeone.peopleWebView'` web view controller type): + * + * @example + * + * ```typescript + * declare module 'papi-shared-types' { + * export type PeopleWebViewController = NetworkableObject<{ + * setSelectedPerson(name: string): Promise; + * testRandomMethod(things: string): Promise; + * }>; + * + * export interface WebViewControllers { + * 'helloSomeone.peopleWebView': PeopleWebViewController; + * } + * } + * ``` */ - 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; + interface WebViewControllers { + 'platform.stuffWebView': NetworkableObject<{ + doStuff(thing: string): Promise; + }>; + 'platform.placeholderWebView': NetworkableObject<{ + runPlaceholderStuff(thing: string): 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. + * `webViewType`s for each web view controller available on the papi. * - * @param webViewType Type of webView to check for + * Automatically includes all extensions' web view controllers that are added to + * {@link WebViewControllers}. + * + * @example 'platform.placeholderWebView' */ - function hasKnown(webViewType: string): boolean; + type WebViewControllerTypes = keyof WebViewControllers; +} +declare module 'shared/services/command.service' { + import { UnsubscriberAsync } from 'platform-bible-utils'; + import { CommandHandlers } from 'papi-shared-types'; /** - * Register a web view provider to serve webViews for a specified type of webViews + * Register a command on the papi to be handled here * - * @param webViewType Type of web view to provide - * @param webViewProvider Object to register as a webView provider including control over disposing - * of it. + * @param commandName Command name to register for handling here * - * WARNING: setting a webView provider mutates the provided object. - * @returns `webViewProvider` modified to be a network object + * - Note: Command names must consist of two string separated by at least one period. We recommend one + * period and lower camel case in case we expand the api in the future to allow dot notation. + * + * @param handler Function to run when the command is invoked + * @returns Promise that resolves if the command successfully registered and unsubscriber function + * to run to stop the passed-in function from handling commands */ - function register( - webViewType: string, - webViewProvider: IWebViewProvider, - ): Promise; + export const registerCommand: ( + commandName: CommandName, + handler: CommandHandlers[CommandName], + ) => Promise; + /** Send a command to the backend. */ + export const sendCommand: ( + commandName: CommandName, + ...args: Parameters + ) => Promise>>; /** - * Get a web view provider that has previously been set up + * Creates a function that is a command function with a baked commandName. This is also nice because + * you get TypeScript type support using this function. * - * @param webViewType Type of webview provider to get - * @returns Web view provider with the given name if one exists, undefined otherwise + * @param commandName Command name for command function + * @returns Function to call with arguments of command that sends the command and resolves with the + * result of the command */ - 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 const createSendCommandFunction: ( + commandName: CommandName, + ) => ( + ...args: Parameters + ) => Promise>>; /** * - * Interface for registering webView providers + * The command service allows you to exchange messages with other components in the platform. You + * can register a command that other services and extensions can send you. You can send commands to + * other services and extensions that have registered commands. */ - export const papiWebViewProviderService: PapiWebViewProviderService; - export default webViewProviderService; + export type moduleSummaryComments = {}; } declare module 'shared/services/internet.service' { /** Our shim over fetch. Allows us to control internet access. */ @@ -5593,6 +5998,7 @@ declare module '@papi/core' { export type { WithNotifyUpdate } from 'shared/models/data-provider-engine.model'; export type { default as IDataProviderEngine } from 'shared/models/data-provider-engine.model'; export type { DialogOptions } from 'shared/models/dialog-options.model'; + export type { NetworkableObject, NetworkObject } from 'shared/models/network-object.model'; export type { ExtensionDataScope, MandatoryProjectDataTypes, @@ -5626,7 +6032,10 @@ declare module '@papi/core' { WebViewDefinition, WebViewProps, } from 'shared/models/web-view.model'; - export type { IWebViewProvider } from 'shared/models/web-view-provider.model'; + export type { + IDisposableWebViewProvider, + IWebViewProvider, + } from 'shared/models/web-view-provider.model'; export type { SimultaneousProjectSettingsChanges, ProjectSettingValidator, @@ -5823,6 +6232,7 @@ declare module '@papi/backend' { import { NetworkObjectStatusServiceType } from 'shared/models/network-object-status.service-model'; import { ISettingsService } from 'shared/services/settings.service-model'; import { IProjectSettingsService } from 'shared/services/project-settings.service-model'; + import { WebViewFactory as PapiWebViewFactory } from 'shared/models/web-view-factory.model'; const papi: { /** * @@ -5877,6 +6287,28 @@ declare module '@papi/backend' { * requirements. */ LayeringProjectDataProviderEngineFactory: typeof PapiLayeringProjectDataProviderEngineFactory; + /** + * + * A partial implementation of {@link IWebViewProvider} that includes creating + * {@link WebViewControllers} for each web view served by the web view provider. This class handles + * registering, disposing, and making sure there is only one web view controller for each web view + * for you. + * + * You can create a new class extending this abstract class to create a web view provider that + * serves web views and web view controllers of a specific `webViewType` to facilitate interaction + * between those web views and other extensions. You can register it with the PAPI using + * `papi.webViewProviders.register`. + * + * If you want to change your existing `IWebViewProvider` from a plain object to extending this + * class, you will need to change your object's existing method named `getWebView` + * ({@link IWebViewProvider.getWebView}) to be named `getWebViewDefinition` + * ({@link WebViewFactory.getWebViewDefinition}), which is a drop-in replacement. You likely do NOT + * want to overwrite this class's `getWebView` because that will eliminate most of the benefits + * associated with using this class. + * + * @see {@link IWebViewProvider} for more information on extending this class. + */ + WebViewFactory: typeof PapiWebViewFactory; /** This is just an alias for internet.fetch */ fetch: typeof globalThis.fetch; /** @@ -6058,6 +6490,28 @@ declare module '@papi/backend' { * requirements. */ export const LayeringProjectDataProviderEngineFactory: typeof PapiLayeringProjectDataProviderEngineFactory; + /** + * + * A partial implementation of {@link IWebViewProvider} that includes creating + * {@link WebViewControllers} for each web view served by the web view provider. This class handles + * registering, disposing, and making sure there is only one web view controller for each web view + * for you. + * + * You can create a new class extending this abstract class to create a web view provider that + * serves web views and web view controllers of a specific `webViewType` to facilitate interaction + * between those web views and other extensions. You can register it with the PAPI using + * `papi.webViewProviders.register`. + * + * If you want to change your existing `IWebViewProvider` from a plain object to extending this + * class, you will need to change your object's existing method named `getWebView` + * ({@link IWebViewProvider.getWebView}) to be named `getWebViewDefinition` + * ({@link WebViewFactory.getWebViewDefinition}), which is a drop-in replacement. You likely do NOT + * want to overwrite this class's `getWebView` because that will eliminate most of the benefits + * associated with using this class. + * + * @see {@link IWebViewProvider} for more information on extending this class. + */ + export const WebViewFactory: typeof PapiWebViewFactory; /** This is just an alias for internet.fetch */ export const fetch: typeof globalThis.fetch; /** @@ -6853,6 +7307,33 @@ declare module 'renderer/hooks/papi-hooks/use-localized-strings-hook' { ) => [localizedStrings: LocalizationData, isLoading: boolean]; export default useLocalizedStrings; } +declare module 'renderer/hooks/papi-hooks/use-web-view-controller.hook' { + import { WebViewControllers } from 'papi-shared-types'; + import { NetworkObject } from 'shared/models/network-object.model'; + import { WebViewId } from 'shared/models/web-view.model'; + /** + * Gets a Web View Controller with specified provider name + * + * @param webViewType `webViewType` that the web view controller must support. The TypeScript type + * for the returned web view controller will have the web view controller interface type + * associated with this `webViewType`. If the web view controller does not implement this + * `webViewType` (according to its metadata), an error will be thrown. + * @param webViewId Id of the web view for which to get the web view controller OR + * `webViewController` (result of `useWebViewController`, if you want this hook to just return the + * controller again) + * @param pdpFactoryId Optional ID of the PDP factory from which to get the Web View Controller if + * the PDP factory supports this project id and project interface. If not provided, then look in + * all available PDP factories for the given project ID. + * @returns `undefined` if the Web View Controller has not been retrieved, the requested Web View + * Controller if it has been retrieved and is not disposed, and undefined again if the Web View + * Controller is disposed + */ + const useWebViewController: ( + webViewType: WebViewType, + webViewId: WebViewId | NetworkObject | undefined, + ) => NetworkObject | undefined; + export default useWebViewController; +} declare module 'renderer/hooks/papi-hooks/index' { export { default as useDataProvider } from 'renderer/hooks/papi-hooks/use-data-provider.hook'; export { default as useData } from 'renderer/hooks/papi-hooks/use-data.hook'; @@ -6864,6 +7345,7 @@ declare module 'renderer/hooks/papi-hooks/index' { export { default as useDialogCallback } from 'renderer/hooks/papi-hooks/use-dialog-callback.hook'; export { default as useDataProviderMulti } from 'renderer/hooks/papi-hooks/use-data-provider-multi.hook'; export { default as useLocalizedStrings } from 'renderer/hooks/papi-hooks/use-localized-strings-hook'; + export { default as useWebViewController } from 'renderer/hooks/papi-hooks/use-web-view-controller.hook'; } declare module '@papi/frontend/react' { export * from 'renderer/hooks/papi-hooks/index'; diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index b7d964ecca..cf91ee2df5 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -14,6 +14,13 @@ declare module 'papi-shared-types' { import type { IDisposableDataProvider } from '@shared/models/data-provider.interface'; import type IDataProvider from '@shared/models/data-provider.interface'; import type ExtractDataProviderDataTypes from '@shared/models/extract-data-provider-data-types.model'; + import type { NetworkableObject } from '@shared/models/network-object.model'; + // Used in JSDocs + // eslint-disable-next-line @typescript-eslint/no-unused-vars + import type { WebViewFactory } from '@shared/models/web-view-factory.model'; + // Used in JSDocs + // eslint-disable-next-line @typescript-eslint/no-unused-vars + import type { IWebViewProvider } from '@shared/models/web-view-provider.model'; // #region Commands @@ -530,5 +537,58 @@ declare module 'papi-shared-types' { >; }; + /** + * {@link NetworkableObject} types for each web view controller supported by PAPI. A Web View + * Controller is a network object that represents a web view and whose methods facilitate + * communication between its associated web view and extensions that want to interact with it. + * `WebViewControllers` can be created by {@link IWebViewProvider} of the same `webViewType`. + * Extensions can add web view controllers with corresponding `webViewType`s by adding details to + * their `.d.ts` file and registering a web view controller in their web view provider's + * `getWebView` function with `papi.webViewProviders.registerWebViewController`. If you want to + * create web view controllers, we recommend you create a class derived from the abstract class + * {@link WebViewFactory}, which automatically manage the lifecycle of the web view controllers for + * you. + * + * Note: The keys of this interface are the `webViewType`s for the web views associated with these + * web view controllers. They must consist of two strings separated by at least one period. We + * recommend one period and lower camel case in case we expand the api in the future to allow dot + * notation. + * + * An extension can extend this interface to add a type for the web view controller it registers + * by adding the following to its `.d.ts` file (in this example, we are adding the + * `'helloSomeone.peopleWebView'` web view controller type): + * + * @example + * + * ```typescript + * declare module 'papi-shared-types' { + * export type PeopleWebViewController = NetworkableObject<{ + * setSelectedPerson(name: string): Promise; + * testRandomMethod(things: string): Promise; + * }>; + * + * export interface WebViewControllers { + * 'helloSomeone.peopleWebView': PeopleWebViewController; + * } + * } + * ``` + */ + export interface WebViewControllers { + 'platform.stuffWebView': NetworkableObject<{ doStuff(thing: string): Promise }>; + 'platform.placeholderWebView': NetworkableObject<{ + runPlaceholderStuff(thing: string): Promise; + }>; + } + + /** + * `webViewType`s for each web view controller available on the papi. + * + * Automatically includes all extensions' web view controllers that are added to + * {@link WebViewControllers}. + * + * @example 'platform.placeholderWebView' + */ + export type WebViewControllerTypes = keyof WebViewControllers; + // #endregion } diff --git a/src/extension-host/services/extension.service.ts b/src/extension-host/services/extension.service.ts index e843263066..a7e24a0043 100644 --- a/src/extension-host/services/extension.service.ts +++ b/src/extension-host/services/extension.service.ts @@ -115,6 +115,12 @@ const MANIFEST_FILE_NAME = 'manifest.json'; /** List of all forbidden extension names. Extensions with these names will not work */ const FORBIDDEN_EXTENSION_NAMES = ['', PLATFORM_NAMESPACE]; +/** + * Debounce time and time to wait to restart immediately after restarting if another restart was + * requested + */ +const RESTART_DELAY_MS = 2000; + /** Save the original `require` function. */ const requireOriginal = Module.prototype.require; @@ -162,6 +168,11 @@ let availableExtensions: ExtensionInfo[]; */ let reloadFinishedEventEmitter: platformBibleUtils.PlatformEventEmitter; +/** Whether we are currently reloading extensions */ +let isReloading = false; +/** Whether we should reload extensions again once finished currently reloading */ +let shouldReload = false; + /** Parse string extension manifest into an object and perform any transformations needed */ function parseManifest(extensionManifestJson: string): ExtensionManifest { const extensionManifest: ExtensionManifest = deserialize(extensionManifestJson); @@ -581,13 +592,11 @@ function watchForExtensionChanges(): UnsubscriberAsync { const reloadExtensionsDebounced = debounce(async (shouldDeactivateExtensions) => { try { logger.debug('Reload extensions from watching'); - await reloadExtensions(shouldDeactivateExtensions); - reloadFinishedEventEmitter.emit(true); + await reloadExtensions(shouldDeactivateExtensions, true); } catch (e) { - reloadFinishedEventEmitter.emit(false); throw new LogError(`Reload extensions from watching failed. Investigate: ${e}`); } - }); + }, RESTART_DELAY_MS); const watcher = chokidar .watch( @@ -832,6 +841,7 @@ function prepareElevatedPrivileges(manifest: ExtensionManifest): Readonly { + logger.info(`extension.service: importing ${extension.name}`); // Import the extension file. Tell webpack to ignore it because extension files are not in the // bundle and should not be looked up in the bundle. Assert a more ambiguous type. // DO NOT REMOVE THE webpackIgnore COMMENT. It is a webpack "Magic Comment" https://webpack.js.org/api/module-methods/#magic-comments @@ -839,6 +849,7 @@ async function activateExtension(extension: ExtensionInfo): Promise { - if (shouldDeactivateExtensions && availableExtensions) { - await deactivateExtensions(availableExtensions); +async function reloadExtensions( + shouldDeactivateExtensions: boolean, + shouldEmitDidReloadEvent: boolean, +): Promise { + if (isReloading) { + shouldReload = true; + return; } + isReloading = true; - await unzipCompressedExtensionFiles(); - - // Get a list of all extensions found - const allExtensions = await getExtensions(); + let errorMessage = ''; - // Cache type declarations in development - if (!globalThis.isPackaged) - try { - await cacheExtensionTypeDeclarations(allExtensions); - } catch (e) { - logger.warn(`Could not cache extension type declarations: ${e}`); + try { + if (shouldDeactivateExtensions && availableExtensions) { + await deactivateExtensions(availableExtensions); } - // Save extensions that have JavaScript to run - // If main is an empty string, having no JavaScript is intentional. Do not load this extension - availableExtensions = allExtensions.filter((extension) => extension.main); + await unzipCompressedExtensionFiles(); - // Store their base URIs in the extension storage service - const uriMap: Map = new Map(); - availableExtensions.forEach((extensionInfo) => { - uriMap.set(extensionInfo.name, extensionInfo.dirUri); - logger.info(`Extension ${extensionInfo.name} loaded from ${extensionInfo.dirUri}`); - }); - setExtensionUris(uriMap); + // Get a list of all extensions found + const allExtensions = await getExtensions(); - // Update the menus, settings, etc. - all json contributions the extensions make - await resyncContributions(allExtensions); + // Cache type declarations in development + if (!globalThis.isPackaged) + try { + await cacheExtensionTypeDeclarations(allExtensions); + } catch (e) { + logger.warn(`Could not cache extension type declarations: ${e}`); + } + + // Save extensions that have JavaScript to run + // If main is an empty string, having no JavaScript is intentional. Do not load this extension + availableExtensions = allExtensions.filter((extension) => extension.main); + + // Store their base URIs in the extension storage service + const uriMap: Map = new Map(); + availableExtensions.forEach((extensionInfo) => { + uriMap.set(extensionInfo.name, extensionInfo.dirUri); + logger.info(`Extension ${extensionInfo.name} loaded from ${extensionInfo.dirUri}`); + }); + setExtensionUris(uriMap); + + // Update the menus, settings, etc. - all json contributions the extensions make + await resyncContributions(allExtensions); + + // Active the extensions + await activateExtensions(availableExtensions); + } catch (e) { + errorMessage = platformBibleUtils.getErrorMessage(e); + } + + if (shouldEmitDidReloadEvent) { + reloadFinishedEventEmitter.emit(!errorMessage); + } + + isReloading = false; + if (shouldReload) { + (async () => { + await platformBibleUtils.wait(RESTART_DELAY_MS); + shouldReload = false; + reloadExtensions(shouldDeactivateExtensions, shouldEmitDidReloadEvent); + })(); + } - // Active the extensions - await activateExtensions(availableExtensions); + if (errorMessage) throw new Error(errorMessage); } /** @@ -1189,7 +1232,7 @@ export const initialize = () => { await normalizeExtensionFileNames(); - await reloadExtensions(false); + await reloadExtensions(false, false); watchForExtensionChanges(); diff --git a/src/extension-host/services/papi-backend.service.ts b/src/extension-host/services/papi-backend.service.ts index 2da5567379..1f7ebe5e57 100644 --- a/src/extension-host/services/papi-backend.service.ts +++ b/src/extension-host/services/papi-backend.service.ts @@ -46,6 +46,7 @@ import { ISettingsService } from '@shared/services/settings.service-model'; import settingsService from '@shared/services/settings.service'; import { IProjectSettingsService } from '@shared/services/project-settings.service-model'; import projectSettingsService from '@shared/services/project-settings.service'; +import { WebViewFactory as PapiWebViewFactory } from '@shared/models/web-view-factory.model'; // IMPORTANT NOTES: // 1) When adding new services here, consider whether they also belong in papi-frontend.service.ts. @@ -65,6 +66,8 @@ const papi = { BaseProjectDataProviderEngine: PapiBaseProjectDataProviderEngine, /** JSDOC DESTINATION LayeringProjectDataProviderEngineFactory */ LayeringProjectDataProviderEngineFactory: PapiLayeringProjectDataProviderEngineFactory, + /** JSDOC DESTINATION WebViewFactory */ + WebViewFactory: PapiWebViewFactory, // Functions /** This is just an alias for internet.fetch */ @@ -130,6 +133,9 @@ Object.freeze(papi.BaseProjectDataProviderEngine); /** JSDOC DESTINATION LayeringProjectDataProviderEngineFactory */ export const { LayeringProjectDataProviderEngineFactory } = papi; Object.freeze(papi.LayeringProjectDataProviderEngineFactory); +/** JSDOC DESTINATION WebViewFactory */ +export const { WebViewFactory } = papi; +Object.freeze(papi.WebViewFactory); /** This is just an alias for internet.fetch */ export const { fetch } = papi; Object.freeze(papi.fetch); diff --git a/src/main/main.ts b/src/main/main.ts index 2cf398e1a1..cbd351b0bd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -393,9 +393,7 @@ async function main() { async function restartExtensionHost() { logger.info('Restarting extension host'); - await extensionHostService.waitForClose(PROCESS_CLOSE_TIME_OUT); - logger.debug('Extension host closed, restarting now'); - await extensionHostService.start(); + await extensionHostService.restart(PROCESS_CLOSE_TIME_OUT); } (async () => { diff --git a/src/main/services/extension-host.service.ts b/src/main/services/extension-host.service.ts index 24f7fc5687..802b984db7 100644 --- a/src/main/services/extension-host.service.ts +++ b/src/main/services/extension-host.service.ts @@ -80,6 +80,17 @@ async function waitForExtensionHost(maxWaitTimeInMS: number) { if (!didExit) logger.warn(`Extension host did not exit within ${maxWaitTimeInMS.toString()} ms`); } +async function restartExtensionHost(maxWaitTimeInMS: number) { + if (globalThis.isPackaged) { + await waitForExtensionHost(maxWaitTimeInMS); + logger.debug('Extension host closed, restarting now'); + return startExtensionHost(); + } + // Tells nodemon to restart the process https://github.com/remy/nodemon/blob/HEAD/doc/events.md#using-nodemon-as-child-process + extensionHost?.send('restart'); + return undefined; +} + function hardKillExtensionHost() { if (!extensionHost) return; @@ -165,7 +176,7 @@ async function startExtensionHost() { ...sharedArgs, ], { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], env: { ...process.env, NODE_ENV: 'development' }, }, ); @@ -198,6 +209,7 @@ const extensionHostService = { start: startExtensionHost, kill: hardKillExtensionHost, waitForClose: waitForExtensionHost, + restart: restartExtensionHost, }; export default extensionHostService; diff --git a/src/main/services/rpc-server.ts b/src/main/services/rpc-server.ts index aa55996e72..7ee8e7853c 100644 --- a/src/main/services/rpc-server.ts +++ b/src/main/services/rpc-server.ts @@ -189,8 +189,10 @@ export default class RpcServer implements IRpcHandler { this.removeEventListenersFromWebSocket(); this.connectionStatus = ConnectionStatus.Disconnected; this.rpcHandlerByMethodName.forEach((handler, methodName) => { + if (handler !== this) return; + logger.info(`Method '${methodName}' removed since websocket ${this.name} closed`); - if (handler === this) this.rpcHandlerByMethodName.delete(methodName); + this.rpcHandlerByMethodName.delete(methodName); }); } diff --git a/src/renderer/components/docking/platform-dock-layout.component.tsx b/src/renderer/components/docking/platform-dock-layout.component.tsx index 679682d229..671f2a0e50 100644 --- a/src/renderer/components/docking/platform-dock-layout.component.tsx +++ b/src/renderer/components/docking/platform-dock-layout.component.tsx @@ -1,7 +1,10 @@ import { useRef, useEffect } from 'react'; import DockLayout from 'rc-dock'; -import { WebViewDefinitionUpdatableProperties } from '@shared/models/web-view.model'; +import { + WebViewDefinition, + WebViewDefinitionUpdatableProperties, +} from '@shared/models/web-view.model'; import { SavedTabInfo, Layout, @@ -13,8 +16,10 @@ import { DialogData } from '@shared/models/dialog-options.model'; import testLayout from '@renderer/testing/test-layout.data'; import { registerDockLayout } from '@renderer/services/web-view.service-host'; import { hasDialogRequest, resolveDialogRequest } from '@renderer/services/dialog.service-host'; +import logger from '@shared/services/logger.service'; +import { TAB_TYPE_WEBVIEW } from '@renderer/components/web-view.component'; -import DockLayoutWrapper from './dock-layout-wrapper.component'; +import DockLayoutWrapper from '@renderer/components/docking/dock-layout-wrapper.component'; import { addTabToDock, addWebViewToDock, @@ -22,8 +27,11 @@ import { loadTab, saveTab, updateWebViewDefinition, -} from './platform-dock-layout-storage.util'; -import { isTab, RCDockTabInfo } from './docking-framework-internal.model'; +} from '@renderer/components/docking/platform-dock-layout-storage.util'; +import { + isTab, + RCDockTabInfo, +} from '@renderer/components/docking/docking-framework-internal.model'; export default function PlatformDockLayout() { // This ref will always be defined @@ -76,20 +84,40 @@ export default function PlatformDockLayout() { saveTab={saveTab} onLayoutChange={(...args) => { const [, currentTabId, direction] = args; - // If a dialog was closed, tell the dialog service - if (currentTabId && direction === 'remove') { - // Assert the more specific type. - /* eslint-disable no-type-assertion/no-type-assertion */ - const removedTab = dockLayoutRef.current.find(currentTabId) as RCDockTabInfo; - if ((removedTab.data as DialogData)?.isDialog && hasDialogRequest(currentTabId)) - /* eslint-enable */ - resolveDialogRequest(currentTabId, undefined, false); + + let webViewDefinition: WebViewDefinition | undefined; + + if (currentTabId) { + const currentDockItem = dockLayoutRef.current.find(currentTabId); + if (isTab(currentDockItem)) { + // Assert the more specific type. + /* eslint-disable no-type-assertion/no-type-assertion */ + const currentTab = currentDockItem as RCDockTabInfo; + + // If a dialog was closed, tell the dialog service + if (direction === 'remove') { + if ((currentTab.data as DialogData)?.isDialog && hasDialogRequest(currentTabId)) + /* eslint-enable */ + resolveDialogRequest(currentTabId, undefined, false); + } + + if (currentTab.tabType === TAB_TYPE_WEBVIEW) + try { + webViewDefinition = currentTabId + ? getWebViewDefinition(currentTabId, dockLayoutRef.current) + : undefined; + } catch (e) { + logger.error( + `dockLayout.onLayoutChange tried to get web view definition for ${currentTabId} but threw! ${e}`, + ); + } + } } (async () => { if (onLayoutChangeRef.current) { try { - await onLayoutChangeRef.current(...args); + await onLayoutChangeRef.current(...args, webViewDefinition); } catch (e) { throw new Error( `platform-dock-layout.component error: Failed to run onLayoutChangeRef.current! currentTabId: ${currentTabId}, direction: ${direction}`, diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index d6c1b75e22..54cd4390ff 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -3,7 +3,7 @@ import { WebViewContentType, WebViewDefinition } from '@shared/models/web-view.m import { SavedTabInfo, TabInfo, WebViewTabProps } from '@shared/models/docking-framework.model'; import { convertWebViewDefinitionToSaved, - getWebView, + openWebView, saveTabInfoBase, IFRAME_SANDBOX_ALLOW_SAME_ORIGIN, IFRAME_SANDBOX_ALLOW_SCRIPTS, @@ -11,19 +11,29 @@ import { WEBVIEW_IFRAME_SRCDOC_SANDBOX, IFRAME_SANDBOX_ALLOW_POPUPS, updateWebViewDefinitionSync, + getWebViewNonce, } from '@renderer/services/web-view.service-host'; import logger from '@shared/services/logger.service'; -import { BookChapterControl, ScrollGroupSelector, useEvent } from 'platform-bible-react'; -import './web-view.component.css'; -import { useLocalizedStrings, useScrollGroupScrRef } from '@renderer/hooks/papi-hooks'; -import { availableScrollGroupIds } from '@renderer/services/scroll-group.service-host'; -import { getNetworkEvent } from '@shared/services/network.service'; import { formatReplacementString, isLocalizeKey, serialize, getLocalizeKeysForScrollGroupIds, } from 'platform-bible-utils'; +import { + BookChapterControl, + ScrollGroupSelector, + useEvent, + useEventAsync, +} from 'platform-bible-react'; +import './web-view.component.css'; +import { useLocalizedStrings, useScrollGroupScrRef } from '@renderer/hooks/papi-hooks'; +import { availableScrollGroupIds } from '@renderer/services/scroll-group.service-host'; +import { getNetworkEvent, registerRequestHandler } from '@shared/services/network.service'; +import { + getWebViewMessageRequestType, + WebViewMessageRequestHandler, +} from '@shared/services/web-view.service-model'; export const TAB_TYPE_WEBVIEW = 'webView'; @@ -36,7 +46,7 @@ const scrollGroupLocalizedStringKeys = getLocalizeKeysForScrollGroupIds(availabl * @param data Web view definition to load */ async function retrieveWebViewContent(webViewType: string, id: string): Promise { - const loadedId = await getWebView(webViewType, undefined, { + const loadedId = await openWebView(webViewType, undefined, { existingId: id, createNewIfNotFound: false, }); @@ -55,9 +65,44 @@ export default function WebView({ allowPopups, scrollGroupScrRef, }: WebViewTabProps) { - // This ref will always be defined - // eslint-disable-next-line no-type-assertion/no-type-assertion - const iframeRef = useRef(undefined!); + // React starts refs as null + // eslint-disable-next-line no-null/no-null + const iframeRef = useRef(null); + + useEventAsync( + useCallback( + (callback: (args: Parameters) => void) => { + return registerRequestHandler( + getWebViewMessageRequestType(id), + (...args: Parameters) => callback(args), + ); + }, + [id], + ), + useCallback( + ([webViewNonce, message, targetOrigin]: Parameters) => { + if (webViewNonce !== getWebViewNonce(id)) + throw new Error( + `Web View Component ${id} (type ${webViewType}) received a message with an invalid nonce!`, + ); + if (!iframeRef.current) { + logger.error( + `Web View Component ${id} (type ${webViewType}) received a message but could not route it to the iframe because its ref was not set!`, + ); + return; + } + if (!iframeRef.current.contentWindow) { + logger.error( + `Web View Component ${id} (type ${webViewType}) received a message but could not route it to the iframe because its contentWindow was falsy!`, + ); + return; + } + + iframeRef.current.contentWindow.postMessage(message, { targetOrigin }); + }, + [id, webViewType], + ), + ); useEvent( getNetworkEvent('platform.onDidReloadExtensions'), diff --git a/src/renderer/hooks/papi-hooks/index.ts b/src/renderer/hooks/papi-hooks/index.ts index 0620f4b156..eac660fee3 100644 --- a/src/renderer/hooks/papi-hooks/index.ts +++ b/src/renderer/hooks/papi-hooks/index.ts @@ -8,3 +8,4 @@ export { default as useProjectSetting } from '@renderer/hooks/papi-hooks/use-pro export { default as useDialogCallback } from '@renderer/hooks/papi-hooks/use-dialog-callback.hook'; export { default as useDataProviderMulti } from '@renderer/hooks/papi-hooks/use-data-provider-multi.hook'; export { default as useLocalizedStrings } from '@renderer/hooks/papi-hooks/use-localized-strings-hook'; +export { default as useWebViewController } from '@renderer/hooks/papi-hooks/use-web-view-controller.hook'; diff --git a/src/renderer/hooks/papi-hooks/use-web-view-controller.hook.ts b/src/renderer/hooks/papi-hooks/use-web-view-controller.hook.ts new file mode 100644 index 0000000000..3379c26395 --- /dev/null +++ b/src/renderer/hooks/papi-hooks/use-web-view-controller.hook.ts @@ -0,0 +1,55 @@ +import { WebViewControllerTypes, WebViewControllers } from 'papi-shared-types'; +import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-use-network-object-hook.util'; +import { NetworkObject } from '@shared/models/network-object.model'; +import webViewService from '@shared/services/web-view.service'; +import { WebViewId } from '@shared/models/web-view.model'; + +/** + * Takes the parameters passed into the hook and returns the `webViewId` associated with those + * parameters. + * + * @param webViewType `webViewType` that the web view controller must support. The TypeScript type + * for the returned web view controller will have the web view controller interface type + * associated with this `webViewType`. If the web view controller does not implement this + * `webViewType` (according to its metadata), an error will be thrown. + * @param webViewId Id of the web view for which to get the web view controller OR + * `webViewController` (result of `useWebViewController`, if you want this hook to just return the + * controller again) + * @returns `webViewId` for getting the web view controller + */ +function mapParametersToWebViewId( + _webViewType: WebViewType, + webViewId: WebViewId | NetworkObject | undefined, +) { + return webViewId; +} + +/** + * Gets a Web View Controller with specified provider name + * + * @param webViewType `webViewType` that the web view controller must support. The TypeScript type + * for the returned web view controller will have the web view controller interface type + * associated with this `webViewType`. If the web view controller does not implement this + * `webViewType` (according to its metadata), an error will be thrown. + * @param webViewId Id of the web view for which to get the web view controller OR + * `webViewController` (result of `useWebViewController`, if you want this hook to just return the + * controller again) + * @param pdpFactoryId Optional ID of the PDP factory from which to get the Web View Controller if + * the PDP factory supports this project id and project interface. If not provided, then look in + * all available PDP factories for the given project ID. + * @returns `undefined` if the Web View Controller has not been retrieved, the requested Web View + * Controller if it has been retrieved and is not disposed, and undefined again if the Web View + * Controller is disposed + */ + +// Assert to specific data type for this hook. +// eslint-disable-next-line no-type-assertion/no-type-assertion +const useWebViewController = createUseNetworkObjectHook( + webViewService.getWebViewController, + mapParametersToWebViewId, +) as ( + webViewType: WebViewType, + webViewId: WebViewId | NetworkObject | undefined, +) => NetworkObject | undefined; + +export default useWebViewController; diff --git a/src/renderer/services/dialog.service-host.ts b/src/renderer/services/dialog.service-host.ts index 81b0cc6593..bc16a68900 100644 --- a/src/renderer/services/dialog.service-host.ts +++ b/src/renderer/services/dialog.service-host.ts @@ -88,7 +88,7 @@ export function resolveDialogRequest( // We're not awaiting closing it. Doesn't really matter right now if we do or don't successfully close it (async () => { try { - const didClose = await webViewService.removeTab(id); + const didClose = await webViewService.closeTab(id); if (!didClose) logger.error( `DialogService error: dialog ${id} that was resolved with data ${serialize( @@ -132,7 +132,7 @@ export function rejectDialogRequest(id: string, message: string) { // We're not awaiting closing it. Doesn't really matter right now if we do or don't successfully close it (async () => { try { - const didClose = await webViewService.removeTab(id); + const didClose = await webViewService.closeTab(id); if (!didClose) logger.error( `DialogService error: dialog ${id} that was rejected with error message ${message} was not found in the dock layout in order to close. Please investigate`, diff --git a/src/renderer/services/web-view.service-host.ts b/src/renderer/services/web-view.service-host.ts index fbd6e1c9ea..83dae65ee0 100644 --- a/src/renderer/services/web-view.service-host.ts +++ b/src/renderer/services/web-view.service-host.ts @@ -47,8 +47,11 @@ import LogError from '@shared/log-error.model'; import memoizeOne from 'memoize-one'; import { AddWebViewEvent, + CloseWebViewEvent, EVENT_NAME_ON_DID_ADD_WEB_VIEW, + EVENT_NAME_ON_DID_CLOSE_WEB_VIEW, EVENT_NAME_ON_DID_UPDATE_WEB_VIEW, + getWebViewController, NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, UpdateWebViewEvent, WebViewServiceType, @@ -83,6 +86,14 @@ const onDidUpdateWebViewEmitter = createNetworkEventEmitter( /** Event that emits with webView info when a webView is added */ export const onDidUpdateWebView = onDidUpdateWebViewEmitter.event; +/** Emitter for when a webview is removed */ +const onDidCloseWebViewEmitter = createNetworkEventEmitter( + EVENT_NAME_ON_DID_CLOSE_WEB_VIEW, +); + +/** Event that emits with webView info when a webView is removed */ +export const onDidCloseWebView = onDidCloseWebViewEmitter.event; + /** * Alias for `window.open` because `window.open` is deleted to prevent web views from accessing it. * Do not give web views access to this function @@ -521,7 +532,15 @@ function setDockLayout(dockLayout: PapiDockLayout | undefined): void { * @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) => { +const onLayoutChange: OnLayoutChangeRCDock = async ( + newLayout, + _currentTabId, + direction, + webViewDefinition, +) => { + if (direction === 'remove' && webViewDefinition) + onDidCloseWebViewEmitter.emit({ webView: convertWebViewDefinitionToSaved(webViewDefinition) }); + return saveLayout(newLayout); }; @@ -620,12 +639,12 @@ export const addTab = async ( }; /** - * Remove a tab in the layout + * Closes a tab in the layout * - * @param tabId ID of the tab to remove - * @returns True if successfully found the tab to remove + * @param tabId ID of the tab to close + * @returns True if successfully found the tab to close */ -export const removeTab = async (tabId: string): Promise => { +export const closeTab = async (tabId: string): Promise => { return (await getDockLayout()).removeTabFromDock(tabId); }; @@ -657,7 +676,7 @@ export function saveTabInfoBase(tabInfo: TabInfo): SavedTabInfo { * @throws If the papi dock layout has not been registered */ export function updateWebViewDefinitionSync( - webViewId: string, + webViewId: WebViewId, webViewDefinitionUpdateInfo: WebViewDefinitionUpdateInfo, ): boolean { const didUpdateWebView = getDockLayoutSync().updateWebViewDefinition( @@ -733,8 +752,8 @@ export function convertWebViewDefinitionToSaved( } /** Explanation in web-view.service-model.ts */ -async function getSavedWebViewDefinition( - webViewId: string, +async function getOpenWebViewDefinition( + webViewId: WebViewId, ): Promise { const webViewDefinition = (await getDockLayout()).getWebViewDefinition(webViewId); if (webViewDefinition === undefined) return undefined; @@ -751,7 +770,7 @@ async function getSavedWebViewDefinition( * @throws If the papi dock layout has not been registered */ export function getSavedWebViewDefinitionSync( - webViewId: string, + webViewId: WebViewId, ): SavedWebViewDefinition | undefined { const webViewDefinition = getDockLayoutSync().getWebViewDefinition(webViewId); if (webViewDefinition === undefined) return undefined; @@ -774,14 +793,63 @@ function getWebViewOptionsDefaults(options: GetWebViewOptions): GetWebViewOption // #endregion -// #region Set up global variables to use in `getWebView`'s `imports` below +// #region webViewNonce + +/** + * Map of web view id to `webViewNonce` for that web view. `webViewNonce`s are used to perform + * privileged interactions with the web view such as `papi.webViewProviders.postMessageToWebView`. + * The web view service generates this nonce and sends it _only_ to the web view provider that + * creates the web view. It is generally recommended that this web view provider not share this + * nonce with anyone else but only use it within itself and in the web view controller created for + * this web view if applicable (See `papi.webViewProviders.registerWebViewController`) + */ +const webViewNoncesById = new Map(); + +/** + * Get an existing `webViewNonce` or generate one if one did not already exist. + * + * WARNING: DO NOT SHARE THIS VALUE. `webViewNonce`s are PRIVILEGED INFORMATION and are not to be + * shared except with the web view provider that creates a web view. See {@link webViewNoncesById} + * for more info. + */ +export function getWebViewNonce(id: WebViewId) { + const existingNonce = webViewNoncesById.get(id); + + if (existingNonce) return existingNonce; + + const nonce = newNonce(); + webViewNoncesById.set(id, nonce); + + return nonce; +} + +/** + * Delete a web view nonce. Should be done when the web view is closed. + * + * @returns `true` if successfully deleted a nonce for this id; `false` if there was not a nonce for + * this id + */ +function deleteWebViewNonce(id: WebViewId) { + return webViewNoncesById.delete(id); +} + +onDidCloseWebView(({ webView: { id, webViewType } }) => { + if (!deleteWebViewNonce(id)) + logger.warn( + `Tried to delete webViewNonce for web view with id ${id} (type ${webViewType}), but a nonce was not found. May not be an issue, but worth investigating`, + ); +}); + +// #endregion + +// #region Set up global variables to use in `openWebView`'s `imports` below globalThis.getSavedWebViewDefinitionById = getSavedWebViewDefinitionSync; globalThis.updateWebViewDefinitionById = updateWebViewDefinitionSync; // #endregion -// #region getWebView +// #region openWebView /** * Creates a new web view or gets an existing one depending on if you request an existing one and if @@ -795,7 +863,7 @@ globalThis.updateWebViewDefinitionById = updateWebViewDefinitionSync; * 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 ( +export const openWebView = async ( webViewType: WebViewType, layout: Layout = { type: 'tab' }, options: GetWebViewOptions = {}, @@ -853,10 +921,17 @@ export const getWebView = async ( } // Create the new webview or load if it already existed - const webView = await webViewProvider.getWebView(existingSavedWebView, optionsDefaulted); + const webView = await webViewProvider.getWebView( + existingSavedWebView, + optionsDefaulted, + getWebViewNonce(existingSavedWebView.id), + ); // The web view provider didn't want to create this web view - if (!webView) return undefined; + if (!webView) { + deleteWebViewNonce(existingSavedWebView.id); + return undefined; + } // Set up WebViewDefinition default values /** WebView.contentType is assumed to be React by default. Extensions can specify otherwise */ @@ -917,6 +992,7 @@ export const getWebView = async ( var getWebViewStateById = window.parent.getWebViewStateById; var setWebViewStateById = window.parent.setWebViewStateById; var resetWebViewStateById = window.parent.resetWebViewStateById; + window.webViewId = '${webView.id}'; window.getWebViewState = (stateKey, defaultValue) => { return getWebViewStateById('${webView.id}', stateKey, defaultValue) }; window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) }; window.resetWebViewState = (stateKey) => { resetWebViewStateById('${webView.id}', stateKey) }; @@ -1300,13 +1376,17 @@ export const initialize = () => { const papiWebViewService: WebViewServiceType = { onDidAddWebView, onDidUpdateWebView, - getWebView, - getSavedWebViewDefinition, + onDidCloseWebView, + getWebView: openWebView, + openWebView, + getSavedWebViewDefinition: getOpenWebViewDefinition, + getOpenWebViewDefinition, + getWebViewController, }; -async function openProjectSettingsTab(webViewId: string): Promise { +async function openProjectSettingsTab(webViewId: WebViewId): Promise { const settingsTabId = newGuid(); - const projectIdFromWebView = (await getSavedWebViewDefinition(webViewId))?.projectId; + const projectIdFromWebView = (await getOpenWebViewDefinition(webViewId))?.projectId; if (!projectIdFromWebView) return undefined; diff --git a/src/shared/global-this.model.ts b/src/shared/global-this.model.ts index aa314d02d9..532561b599 100644 --- a/src/shared/global-this.model.ts +++ b/src/shared/global-this.model.ts @@ -7,6 +7,7 @@ import { UseWebViewScrollGroupScrRefHook, UseWebViewStateHook, WebViewDefinitionUpdateInfo, + WebViewId, WebViewProps, } from '@shared/models/web-view.model'; @@ -33,6 +34,8 @@ declare global { * in WebView iframes. */ var webViewComponent: FunctionComponent; + /** The id of the current web view. Only used in WebView iframes. */ + var webViewId: WebViewId; /** JSDOC DESTINATION UseWebViewStateHook */ var useWebViewState: UseWebViewStateHook; /** JSDOC DESTINATION UseWebViewScrollGroupScrRefHook */ diff --git a/src/shared/models/docking-framework.model.ts b/src/shared/models/docking-framework.model.ts index a7aeb5f674..d9ae1777ce 100644 --- a/src/shared/models/docking-framework.model.ts +++ b/src/shared/models/docking-framework.model.ts @@ -119,11 +119,19 @@ export type Layout = TabLayout | FloatLayout | PanelLayout; /** Props that are passed to the web view tab component */ export type WebViewTabProps = WebViewDefinition; -/** Rc-dock's onLayoutChange prop made asynchronous - resolves */ +/** + * Rc-dock's onLayoutChange prop made asynchronous with `webViewDefinition` added. The dock layout + * component calls this on the web view service when the layout changes. + * + * @param webViewDefinition The web view definition if the edit was on a web view; `undefined` + * otherwise + * @returns Promise that resolves when finished doing things + */ export type OnLayoutChangeRCDock = ( newLayout: LayoutBase, currentTabId?: string, direction?: DropDirection, + webViewDefinition?: WebViewDefinition, ) => Promise; /** Properties related to the dock layout */ diff --git a/src/shared/models/web-view-factory.model.ts b/src/shared/models/web-view-factory.model.ts new file mode 100644 index 0000000000..3ef611b276 --- /dev/null +++ b/src/shared/models/web-view-factory.model.ts @@ -0,0 +1,199 @@ +/* eslint-disable import/prefer-default-export */ +import { WebViewControllers, WebViewControllerTypes } from 'papi-shared-types'; +import type { IWebViewProvider } from '@shared/models/web-view-provider.model'; +import { + SavedWebViewDefinition, + GetWebViewOptions, + WebViewDefinition, +} from '@shared/models/web-view.model'; +import { MutexMap, UnsubscriberAsyncList } from 'platform-bible-utils'; +import { DisposableNetworkObject } from '@shared/models/network-object.model'; +import webViewProviderService from '@shared/services/web-view-provider.service'; +import logger from '@shared/services/logger.service'; +import { overrideDispose } from '@shared/services/network-object.service'; + +/** + * JSDOC SOURCE WebViewFactory + * + * A partial implementation of {@link IWebViewProvider} that includes creating + * {@link WebViewControllers} for each web view served by the web view provider. This class handles + * registering, disposing, and making sure there is only one web view controller for each web view + * for you. + * + * You can create a new class extending this abstract class to create a web view provider that + * serves web views and web view controllers of a specific `webViewType` to facilitate interaction + * between those web views and other extensions. You can register it with the PAPI using + * `papi.webViewProviders.register`. + * + * If you want to change your existing `IWebViewProvider` from a plain object to extending this + * class, you will need to change your object's existing method named `getWebView` + * ({@link IWebViewProvider.getWebView}) to be named `getWebViewDefinition` + * ({@link WebViewFactory.getWebViewDefinition}), which is a drop-in replacement. You likely do NOT + * want to overwrite this class's `getWebView` because that will eliminate most of the benefits + * associated with using this class. + * + * @see {@link IWebViewProvider} for more information on extending this class. + */ +export abstract class WebViewFactory + implements IWebViewProvider +{ + private readonly webViewControllersMutexMap = new MutexMap(); + private readonly webViewControllersCleanupList: UnsubscriberAsyncList; + private readonly webViewControllersById = new Map< + string, + DisposableNetworkObject + >(); + + constructor(readonly webViewType: WebViewType) { + this.webViewControllersCleanupList = new UnsubscriberAsyncList( + `WebViewFactory for webViewType ${webViewType}`, + ); + } + + /** + * Receives a {@link SavedWebViewDefinition} and fills it out into a full {@link WebViewDefinition}, + * providing the contents of the web view and other properties that are important for displaying + * the web view. + * + * WARNING: This method must NOT be overridden if you want any of the benefits of this class. You + * are probably looking for {@link WebViewFactory.getWebViewDefinition}. + * + * If you are transferring a web view provider using a plain object to extending this class, you + * should rename your existing `getWebView` to `getWebViewDefinition`. + */ + async getWebView( + savedWebViewDefinition: SavedWebViewDefinition, + getWebViewOptions: GetWebViewOptions, + webViewNonce: string, + ): Promise { + const webViewId = savedWebViewDefinition.id; + // Don't allow simultaneous gets to run for the same web view id as an easy way to make sure we + // don't create multiple of the same web view controller. Not really expecting this to happen, + // but it's good to be sure. + const lock = this.webViewControllersMutexMap.get(webViewId); + return lock.runExclusive(async () => { + if (this.webViewType !== savedWebViewDefinition.webViewType) + throw new Error( + `${this.webViewType} WebViewFactory received request to provide a ${savedWebViewDefinition.webViewType} web view`, + ); + + const webViewDefinition = await this.getWebViewDefinition( + savedWebViewDefinition, + getWebViewOptions, + webViewNonce, + ); + + // If the web view provider doesn't want to create a web view right now, don't create a web view + // controller + if (!webViewDefinition) return webViewDefinition; + + if (webViewDefinition.id !== webViewId) + logger.warn( + `${this.webViewType} WebViewFactory changed web view id from ${webViewId} to ${webViewDefinition.id} while in getWebViewDefinition. This is not expected and could cause problems. Attempting to continue with new id.`, + ); + + // If there is already a web view controller for this web view (so the web view is reloading), + // return instead of creating a new web view controller + const existingWebViewController = this.webViewControllersById.get(webViewDefinition.id); + if (existingWebViewController) return webViewDefinition; + + // Create the web view controller (implementation-dependent) + const unregisteredWebViewController = await this.createWebViewController( + webViewDefinition, + webViewNonce, + ); + + // Make sure the web view controller gets removed from this provider's map so we can create a + // new one later if needed for some reason + overrideDispose(unregisteredWebViewController, async () => { + if (!this.webViewControllersById.delete(webViewDefinition.id)) + logger.warn( + `${this.webViewType} web view controller with id ${webViewDefinition.id} was not found in its WebViewFactory in order to remove from the map. This could cause issues with not creating a new web view controller if needed. Attempting to continue.`, + ); + return true; + }); + + // Register the web view controller + const webViewController = await webViewProviderService.registerWebViewController( + this.webViewType, + webViewDefinition.id, + unregisteredWebViewController, + ); + + this.webViewControllersCleanupList.add(webViewController); + this.webViewControllersById.set(webViewDefinition.id, webViewController); + + return webViewDefinition; + }); + } + + /** Disposes of all WVCs that were created by this provider */ + async dispose(): Promise { + return this.webViewControllersCleanupList.runAllUnsubscribers(); + } + + /** + * Receives a {@link SavedWebViewDefinition} and fills it out into a full {@link WebViewDefinition}, + * providing the contents of the web view and other properties that are important for displaying + * the web view. + * + * {@link WebViewFactory} calls this as part of its {@link getWebView}, which is called by the PAPI + * as part of opening a new web view. It will also create a web view controller after running this + * if applicable. + * + * See {@link IWebViewProvider.getWebView} for more information on how this method works. + * + * @param savedWebViewDefinition The saved web view information from which to build a complete web + * view definition. Filled out with all {@link SavedWebViewDefinition} properties of the existing + * web view if an existing webview is being called for (matched by ID). Just provides the + * minimal properties required on {@link SavedWebViewDefinition} if this is a new request or if + * the web view with the existing ID was not found. + * @param getWebViewOptions Various options that affect what calling `papi.webViews.openWebView` + * should do. When options are passed to `papi.webViews.openWebView`, some defaults are set up + * on the options, then those options are passed directly through to this method. That way, if + * you want to adjust what this method does based on the contents of the options passed to + * `papi.WebViews.openWebView`, you can. You can even read other properties on these options if + * someone passes options with other properties to `papi.webViews.openWebView`. + * @param webViewNonce Nonce used to perform privileged interactions with the web view created + * from this method's returned {@link WebViewDefinition} such as + * `papi.webViewProviders.postMessageToWebView`. The web view service generates this nonce and + * sends it _only here_ to this web view provider that creates the web view with this id. It is + * generally recommended that this web view provider not share this nonce with anyone else but + * only use it within itself and in the web view controller created for this web view if + * applicable (See `papi.webViewProviders.registerWebViewController`). + * @returns Full {@link WebViewDefinition} including the content and other important display + * properties based on the {@link SavedWebViewDefinition} provided + */ + abstract getWebViewDefinition( + savedWebViewDefinition: SavedWebViewDefinition, + getWebViewOptions: GetWebViewOptions, + webViewNonce: string, + ): Promise; + + /** + * Creates a {@link WebViewController} that corresponds to the {@link WebViewDefinition} provided. + * {@link WebViewFactory} calls this as part of its {@link getWebView}. {@link WebViewFactory} will + * automatically register this controller with the web view provider service + * (`papi.webViewProviders.registerWebViewController`), run its `dispose` when the web view is + * closed or this `WebViewFactory` is disposed, and make sure just one web view controller is + * created for each web view. + * + * Alternatively, if you do not want to create web view controllers (or a controller for a + * specific web view), feel free to return `undefined` from this method. + * + * @param webViewDefinition The definition for the web view for which to create a web view + * controller + * @param webViewNonce Nonce used to perform privileged interactions with the web view created + * from the `webViewDefinition` parameter such as `papi.webViewProviders.postMessageToWebView`. + * The web view service generates this nonce and sends it _only here_ to this web view provider + * that creates the web view with this id. It is generally recommended that this web view + * provider not share this nonce with anyone else but only use it within itself and here in the + * web view controller created for this web view. + * @returns Web view controller for the web view with the `webViewDefinition` provided. Or + * `undefined` if you do not want to create a web view controller for this web view. + */ + abstract createWebViewController( + webViewDefinition: WebViewDefinition, + webViewNonce: string, + ): Promise; +} diff --git a/src/shared/models/web-view-provider.model.ts b/src/shared/models/web-view-provider.model.ts index 78b7d50fa2..7cf95e9527 100644 --- a/src/shared/models/web-view-provider.model.ts +++ b/src/shared/models/web-view-provider.model.ts @@ -8,28 +8,79 @@ import { NetworkObject, NetworkableObject, } from '@shared/models/network-object.model'; -import { CanHaveOnDidDispose } from 'platform-bible-utils'; +// Used in JSDocs +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { WebViewFactory } from '@shared/models/web-view-factory.model'; +// Used in JSDocs +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { WebViewControllers } from 'papi-shared-types'; -// What the developer registers +/** + * An object associated with a specific `webViewType` that provides a {@link WebViewDefinition} when + * the PAPI wants to open a web view with that `webViewType`. An extension registers a web view + * provider with `papi.webViewProviders.register`. + * + * Web View Providers provide the contents of all web views in Platform.Bible. + * + * If you want to provide {@link WebViewControllers} to facilitate interaction between your web views + * and extensions, you can extend the abstract class {@link WebViewFactory} to make the process + * easier. Alternatively, if you want to manage web view controllers manually, you can register them + * in {@link IWebViewProvider.getWebView}. + */ 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 + * Receives a {@link SavedWebViewDefinition} and fills it out into a full {@link WebViewDefinition}, + * providing the contents of the web view and other properties that are important for displaying + * the web view. + * + * The PAPI calls this method as part of opening a new web view or (re)loading an existing web + * view. If you want to create {@link WebViewControllers} for the web views the PAPI creates from + * this method, you should register it using `papi.webViewProviders.registerWebViewController` + * before returning from this method (resolving the returned promise). The {@link WebViewFactory} + * abstract class handles this for you, so please consider extending it. + * + * @param savedWebViewDefinition The saved web view information from which to build a complete web + * view definition. Filled out with all {@link SavedWebViewDefinition} properties of the existing + * web view if an existing webview is being called for (matched by ID). Just provides the + * minimal properties required on {@link SavedWebViewDefinition} if this is a new request or if + * the web view with the existing ID was not found. + * @param getWebViewOptions Various options that affect what calling `papi.webViews.openWebView` + * should do. When options are passed to `papi.webViews.openWebView`, some defaults are set up + * on the options, then those options are passed directly through to this method. That way, if + * you want to adjust what this method does based on the contents of the options passed to + * `papi.WebViews.openWebView`, you can. You can even read other properties on these options if + * someone passes options with other properties to `papi.webViews.openWebView`. + * @param webViewNonce Nonce used to perform privileged interactions with the web view created + * from this method's returned {@link WebViewDefinition} such as + * `papi.webViewProviders.postMessageToWebView`. The web view service generates this nonce and + * sends it _only here_ to this web view provider that creates the web view with this id. It is + * generally recommended that this web view provider not share this nonce with anyone else but + * only use it within itself and in the web view controller created for this web view if + * applicable (See `papi.webViewProviders.registerWebViewController`). + * @returns Full {@link WebViewDefinition} including the content and other important display + * properties based on the {@link SavedWebViewDefinition} provided */ getWebView( - savedWebView: SavedWebViewDefinition, + savedWebViewDefinition: SavedWebViewDefinition, getWebViewOptions: GetWebViewOptions, + webViewNonce: string, ): Promise; } -// What the papi gives on get. Basically a layer over NetworkObject -export interface WebViewProvider - extends NetworkObject, - CanHaveOnDidDispose {} +/** + * A web view provider that has been registered with the PAPI. + * + * This is what the papi gives on `webViewProviderService.get` (not exposed on the PAPI). Basically + * a layer over NetworkObject + * + * This type is internal to core and is not used by extensions + */ +export interface IRegisteredWebViewProvider extends NetworkObject {} -// What the papi returns on register. Basically a layer over DisposableNetworkObject -export interface DisposableWebViewProvider - extends DisposableNetworkObject, - // Need to omit dispose here because it is optional on WebViewProvider but is required on DisposableNetworkObject - Omit {} +/** + * A web view provider that has been registered with the PAPI and returned to the extension that + * registered it. It is able to be disposed with `dispose`. + * + * The PAPI returns this type from `papi.webViewProviders.register`. + */ +export interface IDisposableWebViewProvider extends DisposableNetworkObject {} diff --git a/src/shared/models/web-view.model.ts b/src/shared/models/web-view.model.ts index 990e381fb5..e3baf7cdd9 100644 --- a/src/shared/models/web-view.model.ts +++ b/src/shared/models/web-view.model.ts @@ -32,7 +32,61 @@ type WebViewDefinitionBase = { webViewType: WebViewType; /** Unique ID among webviews specific to this webview instance. */ id: WebViewId; - /** The code for the WebView that papi puts into an iframe */ + /** + * The content for the WebView that papi puts into an iframe. This field differs significantly + * depending on which `contentType` you use in your `WebViewDefinition` as described below. If you + * are using a React or HTML WebView, you will probably want to use a bundler to bundle your code + * together and provide it here. + * [`paranext-extension-template`](https://github.com/paranext/paranext-extension-template) is set + * up for this use case. Feel free to use it for your extension! + * + * --- + * + * **For React WebViews (default):** string containing all the code you want to run in the iframe + * on the frontend. You should set a function component to `globalThis.webViewComponent` in this + * code. + * + * For example, you could pass the bundled output of the following code to as your React Web View + * `content`: + * + * ```tsx + * globalThis.webViewComponent = function MyWebView() { + * return
Hello World!! This is my React WebView!
; + * } + * ``` + * + * **For HTML WebViews:** string containing all the code you want to run in the iframe on the + * frontend. This should be a complete HTML document. Usually, + * + * For example, you could pass the following string as your HTML Web View `content`: + * + * ```html + * + * + * + * + * + *
Hello World!! This is my HTML Web View!
+ * + * + * ``` + * + * --- + * + * **For URL WebViews:** the url you want to load into the iframe on the frontend. + * + * Note: URL WebViews must have `papi-extension:` or `https:` urls. + * + * For example, you could pass the following string as your URL Web View `content`: + * + * ```plain + * https://example.com/ + * ``` + */ content: string; /** * Url of image to show on the title bar of the tab diff --git a/src/shared/services/papi-core.service.ts b/src/shared/services/papi-core.service.ts index 6a0e34c6f3..69a74a7b32 100644 --- a/src/shared/services/papi-core.service.ts +++ b/src/shared/services/papi-core.service.ts @@ -25,6 +25,7 @@ export type { export type { WithNotifyUpdate } from '@shared/models/data-provider-engine.model'; export type { default as IDataProviderEngine } from '@shared/models/data-provider-engine.model'; export type { DialogOptions } from '@shared/models/dialog-options.model'; +export type { NetworkableObject, NetworkObject } from '@shared/models/network-object.model'; export type { ExtensionDataScope, MandatoryProjectDataTypes, @@ -58,7 +59,10 @@ export type { WebViewDefinition, WebViewProps, } from '@shared/models/web-view.model'; -export type { IWebViewProvider } from '@shared/models/web-view-provider.model'; +export type { + IDisposableWebViewProvider, + IWebViewProvider, +} from '@shared/models/web-view-provider.model'; export type { SimultaneousProjectSettingsChanges, ProjectSettingValidator, diff --git a/src/shared/services/web-view-provider.service.ts b/src/shared/services/web-view-provider.service.ts index 98c7c433aa..a220609f66 100644 --- a/src/shared/services/web-view-provider.service.ts +++ b/src/shared/services/web-view-provider.service.ts @@ -4,23 +4,59 @@ */ import { - DisposableWebViewProvider, + IDisposableWebViewProvider, IWebViewProvider, - WebViewProvider, + IRegisteredWebViewProvider, } from '@shared/models/web-view-provider.model'; -import networkObjectService from '@shared/services/network-object.service'; +import networkObjectService, { overrideDispose } from '@shared/services/network-object.service'; import * as networkService from '@shared/services/network.service'; import logger from '@shared/services/logger.service'; import { isSerializable } from 'platform-bible-utils'; import networkObjectStatusService from '@shared/services/network-object-status.service'; - -/** Suffix on network objects that indicates that the network object is a data provider */ +import { WebViewControllers, WebViewControllerTypes } from 'papi-shared-types'; +import { DisposableNetworkObject } from '@shared/models/network-object.model'; +import webViewService from '@shared/services/web-view.service'; +import { + getWebViewControllerObjectId, + getWebViewMessageRequestType, + WEB_VIEW_CONTROLLER_OBJECT_TYPE, + WebViewMessageRequestHandler, +} from '@shared/services/web-view.service-model'; +import { WebViewId } from '@shared/models/web-view.model'; + +/** Suffix on network objects that indicates that the network object is a web view provider */ const WEB_VIEW_PROVIDER_LABEL = 'webViewProvider'; -/** Gets the id for the web view network object with the given name */ +/** Gets the id for the web view provider network object with the given name */ const getWebViewProviderObjectId = (webViewType: string) => `${webViewType}-${WEB_VIEW_PROVIDER_LABEL}`; +/** Network object type for web view providers */ +const WEB_VIEW_PROVIDER_OBJECT_TYPE = 'webViewProvider'; + +/** + * Map of web view controllers by web view id. Used to dispose of web view controllers when their + * web view closes + */ +const webViewControllersById = new Map< + string, + DisposableNetworkObject +>(); + +// Dispose of web view controllers when their associated web view is closed +webViewService.onDidCloseWebView(async ({ webView }) => { + const webViewController = webViewControllersById.get(webView.id); + if (!webViewController) return; + + try { + if (!(await webViewController.dispose())) throw new Error('dispose returned false!'); + } catch (e) { + logger.warn( + `Web View Provider service failed to dispose of web view controller for id ${webView.id} (type ${webView.webViewType}) when the web view was closed! ${e}`, + ); + } +}); + /** Whether this service has finished setting up */ let isInitialized = false; @@ -50,7 +86,7 @@ const initialize = () => { * * @param webViewType Type of webView to check for */ -function hasKnown(webViewType: string): boolean { +function hasKnownWebViewProvider(webViewType: string): boolean { return networkObjectService.hasKnown(getWebViewProviderObjectId(webViewType)); } @@ -62,15 +98,15 @@ function hasKnown(webViewType: string): boolean { * of it. * * WARNING: setting a webView provider mutates the provided object. - * @returns `webViewProvider` modified to be a network object + * @returns `webViewProvider` modified to be a network object and able to be disposed with `dispose` */ async function register( webViewType: string, webViewProvider: IWebViewProvider, -): Promise { +): Promise { await initialize(); - if (hasKnown(webViewType)) + if (hasKnownWebViewProvider(webViewType)) throw new Error(`WebView provider for WebView type ${webViewType} is already registered`); // Validate that the WebView provider has what it needs @@ -99,9 +135,11 @@ async function register( const webViewProviderObjectId = getWebViewProviderObjectId(webViewType); // Set up the WebView provider to be a network object so other processes can use it - const disposableWebViewProvider: DisposableWebViewProvider = await networkObjectService.set( + const disposableWebViewProvider: IDisposableWebViewProvider = await networkObjectService.set( webViewProviderObjectId, webViewProvider, + WEB_VIEW_PROVIDER_OBJECT_TYPE, + { webViewType }, ); return disposableWebViewProvider; @@ -113,7 +151,7 @@ async function register( * @param webViewType Type of webview provider to get * @returns Web view provider with the given name if one exists, undefined otherwise */ -async function get(webViewType: string): Promise { +async function get(webViewType: string): Promise { await initialize(); // Get the object id for this web view provider name @@ -125,7 +163,8 @@ async function get(webViewType: string): Promise { 20000, ); - const webViewProvider = await networkObjectService.get(webViewProviderObjectId); + const webViewProvider = + await networkObjectService.get(webViewProviderObjectId); if (!webViewProvider) { logger.info(`No WebView provider found for WebView type ${webViewType}`); @@ -135,23 +174,133 @@ async function get(webViewType: string): Promise { return webViewProvider; } +/** + * 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 hasKnownWebViewController(webViewId: WebViewId): boolean { + return ( + networkObjectService.hasKnown(getWebViewControllerObjectId(webViewId)) || + webViewControllersById.has(webViewId) + ); +} + +/** + * Register a web view controller to represent a web view. It is expected that a web view provider + * calls this to register a web view controller for a web view that is being created. If a web view + * provider extends {@link WebViewFactory}, it will call this function automatically. + * + * A Web View Controller is a network object that represents a web view and whose methods facilitate + * communication between its associated web view and extensions that want to interact with it. + * + * You can get web view controllers with {@link webViewService.getWebViewController}. + * + * @param webViewType Type of web view for which you are providing this web view controller + * @param webViewId Id of web view for which to register the web view controller + * @param webViewController Object to register as a web view controller including control over + * disposing of it. Note: the web view controller will be disposed automatically when the web view + * is closed + * + * WARNING: setting a web view controller mutates the provided object. + * @returns `webViewController` modified to be a network object + */ +async function registerWebViewController( + webViewType: WebViewType, + webViewId: WebViewId, + webViewController: WebViewControllers[WebViewType], +): Promise> { + await initialize(); + + if (hasKnownWebViewController(webViewId)) + throw new Error( + `WebView controller for WebView Id ${webViewId} (type ${webViewType}) is already registered`, + ); + + // Get the object id for this web view controller name + const webViewControllerObjectId = getWebViewControllerObjectId(webViewId); + + // Set up the WebView Controller to be a network object so other processes can use it + const disposableWebViewController: DisposableNetworkObject = + await networkObjectService.set( + webViewControllerObjectId, + webViewController, + WEB_VIEW_CONTROLLER_OBJECT_TYPE, + { webViewType, webViewId }, + ); + + overrideDispose(disposableWebViewController, async () => { + return webViewControllersById.delete(webViewId); + }); + + if (webViewControllersById.has(webViewId)) + logger.warn( + `Web view provider service is setting web view controller with id ${webViewId} (type ${webViewType}) in the map over an existing web view. This is not expected.`, + ); + webViewControllersById.set(webViewId, disposableWebViewController); + + return disposableWebViewController; +} + +/** + * Sends a message to the specified web view. Expected to be used only by the + * {@link IWebViewProvider} that created the web view or the {@link WebViewControllers} that + * represents the web view created by the Web View Provider. + * + * [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) is used to + * deliver the message to the web view iframe. The web view can use + * [`window.addEventListener("message", + * ...)`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#the_dispatched_event) + * in order to receive these messages. + * + * @param webViewId Id of the web view to which to send a message. + * @param webViewNonce Nonce used to perform privileged interactions with the web view. Pass in the + * nonce the web view provider received from {@link IWebViewProvider.getWebView}'s `webViewNonce` + * parameter or from {@link WebViewFactory.createWebViewController}'s `webViewNonce` parameter + * @param message Data to send to the web view. Can only send serializable information + * @param targetOrigin Expected origin of the web view. Does not send the message if the web view's + * origin does not match. See [`postMessage`'s + * `targetOrigin`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin) + * for more information. Defaults to same origin only (works automatically with React and HTML web + * views) + */ +async function postMessageToWebView( + webViewId: WebViewId, + webViewNonce: string, + message: unknown, + targetOrigin?: string, +): Promise { + return networkService.request< + Parameters, + ReturnType + >(getWebViewMessageRequestType(webViewId), webViewNonce, message, targetOrigin); +} + // Declare interfaces for the objects we're exporting so that JSDoc comments propagate export interface WebViewProviderService { initialize: typeof initialize; - hasKnown: typeof hasKnown; + hasKnown: typeof hasKnownWebViewProvider; register: typeof register; get: typeof get; + registerWebViewController: typeof registerWebViewController; + postMessageToWebView: typeof postMessageToWebView; } export interface PapiWebViewProviderService { register: typeof register; + registerWebViewController: typeof registerWebViewController; + postMessageToWebView: typeof postMessageToWebView; } const webViewProviderService: WebViewProviderService = { initialize, - hasKnown, + hasKnown: hasKnownWebViewProvider, register, get, + registerWebViewController, + postMessageToWebView, }; /** @@ -161,6 +310,8 @@ const webViewProviderService: WebViewProviderService = { */ export const papiWebViewProviderService: PapiWebViewProviderService = { register, + registerWebViewController, + postMessageToWebView, }; export default webViewProviderService; diff --git a/src/shared/services/web-view.service-model.ts b/src/shared/services/web-view.service-model.ts index e1a1f42523..d63743b61d 100644 --- a/src/shared/services/web-view.service-model.ts +++ b/src/shared/services/web-view.service-model.ts @@ -7,6 +7,11 @@ import { import { Layout } from '@shared/models/docking-framework.model'; import { PlatformEvent } from 'platform-bible-utils'; import { serializeRequestType } from '@shared/utils/util'; +import { WebViewControllers, WebViewControllerTypes } from 'papi-shared-types'; +import { NetworkObject } from '@shared/models/network-object.model'; +import networkObjectStatusService from '@shared/services/network-object-status.service'; +import networkObjectService from '@shared/services/network-object.service'; +import logger from '@shared/services/logger.service'; /** * JSDOC SOURCE papiWebViewService @@ -23,6 +28,16 @@ export interface WebViewServiceType { /** Event that emits with webView info when a webView is updated */ onDidUpdateWebView: PlatformEvent; + /** Event that emits with webView info when a webView is closed */ + onDidCloseWebView: PlatformEvent; + + /** @deprecated 6 November 2024. Renamed to {@link openWebView}. */ + getWebView: ( + webViewType: WebViewType, + layout?: Layout, + options?: GetWebViewOptions, + ) => Promise; + /** * 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). @@ -35,30 +50,117 @@ export interface WebViewServiceType { * not create a WebView for this request. * @throws If something went wrong like the provider for the webViewType was not found */ - getWebView: ( + openWebView: ( webViewType: WebViewType, layout?: Layout, options?: GetWebViewOptions, ) => Promise; + + /** @deprecated 6 November 2024. Renamed to {@link getOpenWebViewDefinition} */ + getSavedWebViewDefinition(webViewId: string): Promise; + /** * Gets the saved properties on the WebView definition with the specified ID * * Note: this only returns a representation of the current web view definition, not the actual web * view definition itself. Changing properties on the returned definition does not affect the * actual web view definition. You can possibly change the actual web view definition by calling - * {@link WebViewServiceType.getWebView} with certain `options`, depending on what options the web + * {@link WebViewServiceType.openWebView} with certain `options`, depending on what options the web * view provider has made available. * * @param webViewId The ID of the WebView whose saved properties to get * @returns Saved properties of the WebView definition with the specified ID or undefined if not * found */ - getSavedWebViewDefinition(webViewId: string): Promise; + getOpenWebViewDefinition(webViewId: string): Promise; + + /** + * Get an existing web view controller for an open web view. + * + * A Web View Controller is a network object that represents a web view and whose methods + * facilitate communication between its associated web view and extensions that want to interact + * with it. + * + * Web View Controllers are registered on the web view provider service. + * + * @param webViewType Type of webview controller you expect to get. If the web view controller's + * `webViewType` does not match this, an error will be thrown + * @param webViewId Id of web view for which to get the corresponding web view controller if one + * exists + * @returns Web view controller with the given name if one exists, undefined otherwise + */ + getWebViewController( + webViewType: WebViewType, + webViewId: WebViewId, + ): Promise | undefined>; } /** Prefix on requests that indicates that the request is related to webView operations */ const CATEGORY_WEB_VIEW = 'webView'; +/** Suffix on network objects that indicates that the network object is a web view controller */ +const WEB_VIEW_CONTROLLER_LABEL = 'webViewController'; + +/** Prefix on requests that indicate that the request is related to web view messages */ +const CATEGORY_WEB_VIEW_MESSAGE = 'webViewMessage'; + +/** Get request type for posting a message to a web view */ +export function getWebViewMessageRequestType(webViewId: WebViewId) { + return serializeRequestType(CATEGORY_WEB_VIEW_MESSAGE, webViewId); +} + +/** + * Type of function to receive messages sent to a web view. + * + * See `web-view-provider.service.ts`'s `postMessageToWebView` and `web-view.component` for + * information on this type + */ +export type WebViewMessageRequestHandler = ( + webViewNonce: string, + message: unknown, + targetOrigin?: string, +) => Promise; + +/** Gets the id for the web view controller network object with the given name */ +export const getWebViewControllerObjectId = (webViewId: string) => + `${WEB_VIEW_CONTROLLER_LABEL}${webViewId}`; + +/** Network object type for web view controllers */ +export const WEB_VIEW_CONTROLLER_OBJECT_TYPE = 'webViewController'; + +// See `WebViewServiceType` for explanation +export async function getWebViewController( + webViewType: WebViewType, + webViewId: WebViewId, +): Promise | undefined> { + // Get the object id for this web view Controller name + const webViewControllerObjectId = getWebViewControllerObjectId(webViewId); + + const webViewControllerDetails = await networkObjectStatusService.waitForNetworkObject( + { id: webViewControllerObjectId }, + // Wait up to 20 seconds for the web view Controller to appear + 20000, + ); + + if ( + !webViewControllerDetails.attributes || + webViewControllerDetails.attributes.webViewType !== webViewType + ) + throw new Error( + `Found web view controller with network object id ${webViewControllerObjectId} for web view id ${webViewId}, but its type was not what was expected! Expected: ${webViewType}; received ${webViewControllerDetails.attributes?.webViewType}`, + ); + + const webViewController = + await networkObjectService.get(webViewControllerObjectId); + + if (!webViewController) { + logger.info(`No WebView Controller found for WebView id ${webViewId} (type ${webViewType})`); + return undefined; + } + + return webViewController; +} + /** 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, @@ -82,4 +184,15 @@ export type UpdateWebViewEvent = { webView: SavedWebViewDefinition; }; +/** Name to use when creating a network event that is fired when webViews are closed */ +export const EVENT_NAME_ON_DID_CLOSE_WEB_VIEW = serializeRequestType( + CATEGORY_WEB_VIEW, + 'onDidCloseWebView', +); + +/** Event emitted when webViews are closed */ +export type CloseWebViewEvent = { + webView: SavedWebViewDefinition; +}; + 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 d34ec86357..c2b376282b 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -3,10 +3,13 @@ import { getNetworkEvent } from '@shared/services/network.service'; import { AddWebViewEvent, EVENT_NAME_ON_DID_ADD_WEB_VIEW, + EVENT_NAME_ON_DID_CLOSE_WEB_VIEW, EVENT_NAME_ON_DID_UPDATE_WEB_VIEW, NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, + CloseWebViewEvent, UpdateWebViewEvent, WebViewServiceType, + getWebViewController, } from '@shared/services/web-view.service-model'; import networkObjectService from '@shared/services/network-object.service'; import networkObjectStatusService from './network-object-status.service'; @@ -19,6 +22,10 @@ const onDidUpdateWebView: PlatformEvent = getNetworkEvent = getNetworkEvent( + EVENT_NAME_ON_DID_CLOSE_WEB_VIEW, +); + let networkObject: WebViewServiceType; let initializationPromise: Promise; async function initialize(): Promise { @@ -59,6 +66,8 @@ const webViewService = createSyncProxyForAsyncObject( { onDidAddWebView, onDidUpdateWebView, + onDidCloseWebView, + getWebViewController, }, ); From e28a3191d1fea83c71078f4e850841fd2b858eeb Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Wed, 13 Nov 2024 12:49:15 -0600 Subject: [PATCH 2/3] Aliased and deprecated more things for consistent naming --- extensions/src/hello-world/src/main.ts | 10 +-- lib/papi-dts/papi.d.ts | 48 +++++++------- .../components/web-view.component.tsx | 16 ++--- .../services/web-view.service-host.ts | 62 +++++++++++++++---- src/shared/models/docking-framework.model.ts | 4 ++ src/shared/models/web-view-factory.model.ts | 2 +- .../services/web-view-provider.service.ts | 38 +++++++----- src/shared/services/web-view.service-model.ts | 17 +++-- src/shared/services/web-view.service.ts | 11 ++-- 9 files changed, 135 insertions(+), 73 deletions(-) diff --git a/extensions/src/hello-world/src/main.ts b/extensions/src/hello-world/src/main.ts index ee58571c56..6e08958066 100644 --- a/extensions/src/hello-world/src/main.ts +++ b/extensions/src/hello-world/src/main.ts @@ -380,27 +380,27 @@ export async function activate(context: ExecutionActivationContext): Promise HTML_COLOR_NAMES.includes(newValue), ); - const helloWorldProjectWebViewProviderPromise = papi.webViewProviders.register( + const helloWorldProjectWebViewProviderPromise = papi.webViewProviders.registerWebViewProvider( helloWorldProjectWebViewProvider.webViewType, helloWorldProjectWebViewProvider, ); - const helloWorldProjectViewerProviderPromise = papi.webViewProviders.register( + const helloWorldProjectViewerProviderPromise = papi.webViewProviders.registerWebViewProvider( helloWorldProjectViewerProvider.webViewType, helloWorldProjectViewerProvider, ); - const htmlWebViewProviderPromise = papi.webViewProviders.register( + const htmlWebViewProviderPromise = papi.webViewProviders.registerWebViewProvider( htmlWebViewProvider.webViewType, htmlWebViewProvider, ); - const reactWebViewProviderPromise = papi.webViewProviders.register( + const reactWebViewProviderPromise = papi.webViewProviders.registerWebViewProvider( reactWebViewProvider.webViewType, reactWebViewProvider, ); - const reactWebView2ProviderPromise = papi.webViewProviders.register( + const reactWebView2ProviderPromise = papi.webViewProviders.registerWebViewProvider( reactWebView2Provider.webViewType, reactWebView2Provider, ); diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 9f4e8102b4..ddd542881b 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -2406,6 +2406,10 @@ declare module 'shared/models/docking-framework.model' { * Rc-dock's onLayoutChange prop made asynchronous with `webViewDefinition` added. The dock layout * component calls this on the web view service when the layout changes. * + * @param newLayout The changed layout to save. + * @param currentTabId The tab being changed + * @param direction The direction the tab is being moved (or deleted or other things - RCDock uses + * the word "direction" here loosely) * @param webViewDefinition The web view definition if the edit was on a web view; `undefined` * otherwise * @returns Promise that resolves when finished doing things @@ -2498,8 +2502,10 @@ declare module 'shared/services/web-view.service-model' { * HTML or React components. */ export interface WebViewServiceType { - /** Event that emits with webView info when a webView is added */ - onDidAddWebView: PlatformEvent; + /** @deprecated 13 November 2024. Renamed to {@link onDidOpenWebView} */ + onDidAddWebView: PlatformEvent; + /** Event that emits with webView info when a webView is created */ + onDidOpenWebView: PlatformEvent; /** Event that emits with webView info when a webView is updated */ onDidUpdateWebView: PlatformEvent; /** Event that emits with webView info when a webView is closed */ @@ -2584,10 +2590,12 @@ declare module 'shared/services/web-view.service-model' { webViewType: WebViewType, webViewId: WebViewId, ): Promise | undefined>; - /** Name to use when creating a network event that is fired when webViews are created */ + /** @deprecated 13 November 2024. Renamed to {@link EVENT_NAME_ON_DID_OPEN_WEB_VIEW} */ export const EVENT_NAME_ON_DID_ADD_WEB_VIEW: `${string}:${string}`; + /** Name to use when creating a network event that is fired when webViews are created */ + export const EVENT_NAME_ON_DID_OPEN_WEB_VIEW: `${string}:${string}`; /** Event emitted when webViews are created */ - export type AddWebViewEvent = { + export type OpenWebViewEvent = { webView: SavedWebViewDefinition; layout: Layout; }; @@ -2625,14 +2633,6 @@ declare module 'shared/services/web-view-provider.service' { import { WebViewId } from 'shared/models/web-view.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 hasKnownWebViewProvider(webViewType: string): boolean; /** * Register a web view provider to serve webViews for a specified type of webViews * @@ -2643,7 +2643,7 @@ declare module 'shared/services/web-view-provider.service' { * WARNING: setting a webView provider mutates the provided object. * @returns `webViewProvider` modified to be a network object and able to be disposed with `dispose` */ - function register( + function registerWebViewProvider( webViewType: string, webViewProvider: IWebViewProvider, ): Promise; @@ -2653,7 +2653,7 @@ declare module 'shared/services/web-view-provider.service' { * @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; + function getWebViewProvider(webViewType: string): Promise; /** * Register a web view controller to represent a web view. It is expected that a web view provider * calls this to register a web view controller for a web view that is being created. If a web view @@ -2708,21 +2708,25 @@ declare module 'shared/services/web-view-provider.service' { ): Promise; export interface WebViewProviderService { initialize: typeof initialize; - hasKnown: typeof hasKnownWebViewProvider; - register: typeof register; - get: typeof get; + registerWebViewProvider: typeof registerWebViewProvider; + getWebViewProvider: typeof getWebViewProvider; registerWebViewController: typeof registerWebViewController; postMessageToWebView: typeof postMessageToWebView; } export interface PapiWebViewProviderService { - register: typeof register; + /** @deprecated 13 November 2024. Renamed to {@link registerWebViewProvider} */ + register: ( + ...args: Parameters + ) => ReturnType; + registerWebViewProvider: typeof registerWebViewProvider; registerWebViewController: typeof registerWebViewController; postMessageToWebView: typeof postMessageToWebView; } const webViewProviderService: WebViewProviderService; /** * - * Interface for registering webView providers + * Interface for registering webView providers, registering webView controllers, and performing + * privileged interactions with web views */ export const papiWebViewProviderService: PapiWebViewProviderService; export default webViewProviderService; @@ -6328,7 +6332,8 @@ declare module '@papi/backend' { webViews: WebViewServiceType; /** * - * Interface for registering webView providers + * Interface for registering webView providers, registering webView controllers, and performing + * privileged interactions with web views */ webViewProviders: PapiWebViewProviderService; /** @@ -6531,7 +6536,8 @@ declare module '@papi/backend' { export const webViews: WebViewServiceType; /** * - * Interface for registering webView providers + * Interface for registering webView providers, registering webView controllers, and performing + * privileged interactions with web views */ export const webViewProviders: PapiWebViewProviderService; /** diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 54cd4390ff..6eda73b9dd 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -11,7 +11,7 @@ import { WEBVIEW_IFRAME_SRCDOC_SANDBOX, IFRAME_SANDBOX_ALLOW_POPUPS, updateWebViewDefinitionSync, - getWebViewNonce, + isWebViewNonceCorrect, } from '@renderer/services/web-view.service-host'; import logger from '@shared/services/logger.service'; import { @@ -81,22 +81,18 @@ export default function WebView({ ), useCallback( ([webViewNonce, message, targetOrigin]: Parameters) => { - if (webViewNonce !== getWebViewNonce(id)) + if (!isWebViewNonceCorrect(id, webViewNonce)) throw new Error( `Web View Component ${id} (type ${webViewType}) received a message with an invalid nonce!`, ); - if (!iframeRef.current) { - logger.error( + if (!iframeRef.current) + throw new Error( `Web View Component ${id} (type ${webViewType}) received a message but could not route it to the iframe because its ref was not set!`, ); - return; - } - if (!iframeRef.current.contentWindow) { - logger.error( + if (!iframeRef.current.contentWindow) + throw new Error( `Web View Component ${id} (type ${webViewType}) received a message but could not route it to the iframe because its contentWindow was falsy!`, ); - return; - } iframeRef.current.contentWindow.postMessage(message, { targetOrigin }); }, diff --git a/src/renderer/services/web-view.service-host.ts b/src/renderer/services/web-view.service-host.ts index 83dae65ee0..6ae04ffe9d 100644 --- a/src/renderer/services/web-view.service-host.ts +++ b/src/renderer/services/web-view.service-host.ts @@ -46,10 +46,11 @@ import logger from '@shared/services/logger.service'; import LogError from '@shared/log-error.model'; import memoizeOne from 'memoize-one'; import { - AddWebViewEvent, + OpenWebViewEvent, CloseWebViewEvent, EVENT_NAME_ON_DID_ADD_WEB_VIEW, EVENT_NAME_ON_DID_CLOSE_WEB_VIEW, + EVENT_NAME_ON_DID_OPEN_WEB_VIEW, EVENT_NAME_ON_DID_UPDATE_WEB_VIEW, getWebViewController, NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE, @@ -70,20 +71,39 @@ import { } from '@renderer/components/settings-tabs/settings-tab.component'; import THEME, { SCROLLBAR_STYLES, MUI_OVERRIDES } from '@renderer/theme'; -/** Emitter for when a webview is added */ -const onDidAddWebViewEmitter = createNetworkEventEmitter( +/** + * @deprecated 13 November 2024. Changed to {@link onDidOpenWebViewEmitter}. This remains for now to + * support anyone listening to this event over websocket + */ +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; +/** Emitter for when a webview is created */ +const onDidOpenWebViewEmitter = createNetworkEventEmitter( + EVENT_NAME_ON_DID_OPEN_WEB_VIEW, +); + +/** + * Emits an event for when a web view is created + * + * Actually emits two updates to support backwards compatibility with deprecated + * {@link onDidAddWebViewEmitter}, but this will likely be removed at some point + */ +function emitOnDidOpenWebView(event: OpenWebViewEvent) { + onDidAddWebViewEmitter.emit(event); + onDidOpenWebViewEmitter.emit(event); +} + +/** Event that emits with webView info when a webView is created */ +export const onDidOpenWebView = onDidOpenWebViewEmitter.event; /** Emitter for when a webview is updated */ const onDidUpdateWebViewEmitter = createNetworkEventEmitter( EVENT_NAME_ON_DID_UPDATE_WEB_VIEW, ); -/** Event that emits with webView info when a webView is added */ +/** Event that emits with webView info when a webView is updated */ export const onDidUpdateWebView = onDidUpdateWebViewEmitter.event; /** Emitter for when a webview is removed */ @@ -526,10 +546,15 @@ function setDockLayout(dockLayout: PapiDockLayout | undefined): void { } /** - * When rc-dock detects a changed layout, save it. This function is given to the registered - * papiDockLayout to run when the dock layout changes. + * When rc-dock detects a changed layout, save it and do other processing as needed. This function + * is given to the registered papiDockLayout to run when the dock layout changes. * * @param newLayout The changed layout to save. + * @param _currentTabId The tab being changed + * @param direction The direction the tab is being moved (or deleted or other things - RCDock uses + * the word "direction" here loosely) + * @param webViewDefinition The web view definition if the edit was on a web view; `undefined` + * otherwise */ // TODO: We could filter whether we need to save based on the `direction` argument. - IJH 2023-05-1 const onLayoutChange: OnLayoutChangeRCDock = async ( @@ -812,7 +837,7 @@ const webViewNoncesById = new Map(); * shared except with the web view provider that creates a web view. See {@link webViewNoncesById} * for more info. */ -export function getWebViewNonce(id: WebViewId) { +function getWebViewNonce(id: WebViewId) { const existingNonce = webViewNoncesById.get(id); if (existingNonce) return existingNonce; @@ -823,6 +848,18 @@ export function getWebViewNonce(id: WebViewId) { return nonce; } +/** + * Determine whether a nonce is valid for a specific web view + * + * @param id Id of the web view whose nonce to check against + * @param webViewNonce Nonce to test against the real web view nonce. See {@link webViewNoncesById} + * for more info. + * @returns `true` if the provided `webViewNonce` is correct and valid; `false` otherwise + */ +export function isWebViewNonceCorrect(id: WebViewId, webViewNonce: string) { + return webViewNonce === getWebViewNonce(id); +} + /** * Delete a web view nonce. Should be done when the web view is closed. * @@ -874,7 +911,7 @@ export const openWebView = async ( // 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); + const webViewProvider = await webViewProviderService.getWebViewProvider(webViewType); if (!webViewProvider) throw new Error(`getWebView: Cannot find Web View Provider for webview type ${webViewType}`); @@ -1223,7 +1260,7 @@ export const openWebView = async ( // 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 (finalLayout) - onDidAddWebViewEmitter.emit({ + emitOnDidOpenWebView({ webView: convertWebViewDefinitionToSaved(finalWebView), layout: finalLayout, }); @@ -1374,7 +1411,8 @@ export const initialize = () => { // #endregion const papiWebViewService: WebViewServiceType = { - onDidAddWebView, + onDidAddWebView: onDidOpenWebView, + onDidOpenWebView, onDidUpdateWebView, onDidCloseWebView, getWebView: openWebView, diff --git a/src/shared/models/docking-framework.model.ts b/src/shared/models/docking-framework.model.ts index d9ae1777ce..d84f69ea03 100644 --- a/src/shared/models/docking-framework.model.ts +++ b/src/shared/models/docking-framework.model.ts @@ -123,6 +123,10 @@ export type WebViewTabProps = WebViewDefinition; * Rc-dock's onLayoutChange prop made asynchronous with `webViewDefinition` added. The dock layout * component calls this on the web view service when the layout changes. * + * @param newLayout The changed layout to save. + * @param currentTabId The tab being changed + * @param direction The direction the tab is being moved (or deleted or other things - RCDock uses + * the word "direction" here loosely) * @param webViewDefinition The web view definition if the edit was on a web view; `undefined` * otherwise * @returns Promise that resolves when finished doing things diff --git a/src/shared/models/web-view-factory.model.ts b/src/shared/models/web-view-factory.model.ts index 3ef611b276..17535c4453 100644 --- a/src/shared/models/web-view-factory.model.ts +++ b/src/shared/models/web-view-factory.model.ts @@ -88,7 +88,7 @@ export abstract class WebViewFactory if (!webViewDefinition) return webViewDefinition; if (webViewDefinition.id !== webViewId) - logger.warn( + throw new Error( `${this.webViewType} WebViewFactory changed web view id from ${webViewId} to ${webViewDefinition.id} while in getWebViewDefinition. This is not expected and could cause problems. Attempting to continue with new id.`, ); diff --git a/src/shared/services/web-view-provider.service.ts b/src/shared/services/web-view-provider.service.ts index a220609f66..c2cef90c04 100644 --- a/src/shared/services/web-view-provider.service.ts +++ b/src/shared/services/web-view-provider.service.ts @@ -100,7 +100,7 @@ function hasKnownWebViewProvider(webViewType: string): boolean { * WARNING: setting a webView provider mutates the provided object. * @returns `webViewProvider` modified to be a network object and able to be disposed with `dispose` */ -async function register( +async function registerWebViewProvider( webViewType: string, webViewProvider: IWebViewProvider, ): Promise { @@ -151,7 +151,9 @@ async function register( * @param webViewType Type of webview provider to get * @returns Web view provider with the given name if one exists, undefined otherwise */ -async function get(webViewType: string): Promise { +async function getWebViewProvider( + webViewType: string, +): Promise { await initialize(); // Get the object id for this web view provider name @@ -175,11 +177,12 @@ async function get(webViewType: string): Promise + ) => ReturnType; + registerWebViewProvider: typeof registerWebViewProvider; registerWebViewController: typeof registerWebViewController; postMessageToWebView: typeof postMessageToWebView; } const webViewProviderService: WebViewProviderService = { initialize, - hasKnown: hasKnownWebViewProvider, - register, - get, + registerWebViewProvider, + getWebViewProvider, registerWebViewController, postMessageToWebView, }; @@ -306,10 +312,12 @@ const webViewProviderService: WebViewProviderService = { /** * JSDOC SOURCE papiWebViewProviderService * - * Interface for registering webView providers + * Interface for registering webView providers, registering webView controllers, and performing + * privileged interactions with web views */ export const papiWebViewProviderService: PapiWebViewProviderService = { - register, + register: registerWebViewProvider, + registerWebViewProvider, registerWebViewController, postMessageToWebView, }; diff --git a/src/shared/services/web-view.service-model.ts b/src/shared/services/web-view.service-model.ts index d63743b61d..f50edfeffe 100644 --- a/src/shared/services/web-view.service-model.ts +++ b/src/shared/services/web-view.service-model.ts @@ -22,8 +22,11 @@ import logger from '@shared/services/logger.service'; * HTML or React components. */ export interface WebViewServiceType { - /** Event that emits with webView info when a webView is added */ - onDidAddWebView: PlatformEvent; + /** @deprecated 13 November 2024. Renamed to {@link onDidOpenWebView} */ + onDidAddWebView: PlatformEvent; + + /** Event that emits with webView info when a webView is created */ + onDidOpenWebView: PlatformEvent; /** Event that emits with webView info when a webView is updated */ onDidUpdateWebView: PlatformEvent; @@ -161,14 +164,20 @@ export async function getWebViewController = getNetworkEvent( - EVENT_NAME_ON_DID_ADD_WEB_VIEW, +const onDidOpenWebView: PlatformEvent = getNetworkEvent( + EVENT_NAME_ON_DID_OPEN_WEB_VIEW, ); const onDidUpdateWebView: PlatformEvent = getNetworkEvent( @@ -64,7 +64,8 @@ const webViewService = createSyncProxyForAsyncObject( return networkObject; }, { - onDidAddWebView, + onDidAddWebView: onDidOpenWebView, + onDidOpenWebView, onDidUpdateWebView, onDidCloseWebView, getWebViewController, From 2a5a503ec7b55daa7b0acbdebe6534c2fb3c8248 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Wed, 13 Nov 2024 17:25:01 -0600 Subject: [PATCH 3/3] Better error handling --- src/declarations/papi-shared-types.ts | 2 ++ src/extension-host/services/extension.service.ts | 10 +++++++--- src/main/services/extension-host.service.ts | 1 - src/shared/services/web-view-provider.service.ts | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index cf91ee2df5..7bb796a20b 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -489,6 +489,7 @@ declare module 'papi-shared-types' { * ``` */ export interface DataProviders { + // These are examples. Feel free to take them out if we actually need to provide real ones in core 'platform.stuff': IDataProvider; 'platform.placeholder': IDataProvider; } @@ -574,6 +575,7 @@ declare module 'papi-shared-types' { * ``` */ export interface WebViewControllers { + // These are examples. Feel free to take them out if we actually need to provide real ones in core 'platform.stuffWebView': NetworkableObject<{ doStuff(thing: string): Promise }>; 'platform.placeholderWebView': NetworkableObject<{ runPlaceholderStuff(thing: string): Promise; diff --git a/src/extension-host/services/extension.service.ts b/src/extension-host/services/extension.service.ts index a7e24a0043..ba61013963 100644 --- a/src/extension-host/services/extension.service.ts +++ b/src/extension-host/services/extension.service.ts @@ -1206,9 +1206,13 @@ async function reloadExtensions( isReloading = false; if (shouldReload) { (async () => { - await platformBibleUtils.wait(RESTART_DELAY_MS); - shouldReload = false; - reloadExtensions(shouldDeactivateExtensions, shouldEmitDidReloadEvent); + try { + await platformBibleUtils.wait(RESTART_DELAY_MS); + shouldReload = false; + await reloadExtensions(shouldDeactivateExtensions, shouldEmitDidReloadEvent); + } catch (e) { + logger.error(`Subsequent reload after initial reload extensions failed! ${e}`); + } })(); } diff --git a/src/main/services/extension-host.service.ts b/src/main/services/extension-host.service.ts index 802b984db7..0b64e4f6fd 100644 --- a/src/main/services/extension-host.service.ts +++ b/src/main/services/extension-host.service.ts @@ -88,7 +88,6 @@ async function restartExtensionHost(maxWaitTimeInMS: number) { } // Tells nodemon to restart the process https://github.com/remy/nodemon/blob/HEAD/doc/events.md#using-nodemon-as-child-process extensionHost?.send('restart'); - return undefined; } function hardKillExtensionHost() { diff --git a/src/shared/services/web-view-provider.service.ts b/src/shared/services/web-view-provider.service.ts index c2cef90c04..cdcd486fb1 100644 --- a/src/shared/services/web-view-provider.service.ts +++ b/src/shared/services/web-view-provider.service.ts @@ -239,8 +239,8 @@ async function registerWebViewController