From 18ab61c30e436e1aa407deb3ff0466e0ce3d80f4 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Thu, 23 Jan 2025 15:45:08 -0600 Subject: [PATCH] Prepare for OAuth: Added handleUri, platform.openWindow, and papi.dataProtection --- README.md | 2 + electron-builder.json5 | 4 + extensions/src/hello-world/manifest.json | 3 +- extensions/src/hello-world/src/main.ts | 45 ++++ lib/papi-dts/papi.d.ts | 216 ++++++++++++++++++ release/app/package-lock.json | 4 +- release/app/package.json | 2 +- src/declarations/papi-shared-types.ts | 6 + .../extension-manifest.model.ts | 2 + .../services/extension.service.ts | 122 +++++++++- .../services/papi-backend.service.ts | 6 + src/main/main.ts | 148 +++++++++++- .../services/data-protection.service-host.ts | 57 +++++ src/main/services/rpc-websocket-listener.ts | 6 +- src/node/services/extension.service-model.ts | 10 + src/shared/data/platform.data.ts | 27 +++ .../models/data-protection.service-model.ts | 65 ++++++ .../models/elevated-privileges.model.ts | 7 + .../models/handle-uri-privilege.model.ts | 54 +++++ .../services/data-protection.service.ts | 54 +++++ src/shared/services/papi-core.service.ts | 5 + 21 files changed, 828 insertions(+), 17 deletions(-) create mode 100644 src/main/services/data-protection.service-host.ts create mode 100644 src/node/services/extension.service-model.ts create mode 100644 src/shared/models/data-protection.service-model.ts create mode 100644 src/shared/models/handle-uri-privilege.model.ts create mode 100644 src/shared/services/data-protection.service.ts diff --git a/README.md b/README.md index bfa78ed17c..7ee881b78b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ sudo apt install libfuse2 Then simply [execute/run](https://github.com/AppImage/AppImageKit/wiki) the `.AppImage` file, which you can download from [Releases](https://github.com/paranext/paranext-core/releases). +Some users may find that not everything works properly in Linux without some additional setup. Please see [How to set up Platform.Bible on Linux](https://github.com/paranext/paranext/wiki/How-to-set-up-Platform.Bible-on-Linux) for more information. + ### Mac Users If you download and run the ARM release of Platform.Bible from [a computer running Apple Silicon](https://support.apple.com/en-us/116943), you will likely encounter a warning from Apple's Gatekeeper stating that "Platform.Bible is damaged and can't be opened. You should move it to the Trash." or something very similar: diff --git a/electron-builder.json5 b/electron-builder.json5 index 0235667019..14ca5c6d4d 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -5,6 +5,10 @@ productName: 'Platform.Bible', appId: 'org.paranext.PlatformBible', copyright: 'Copyright © 2017-2025 SIL Global and United Bible Societies', + protocols: { + name: 'platform-bible', + schemes: ['platform-bible'], + }, asar: true, asarUnpack: '**\\*.{node,dll}', files: ['dist', 'node_modules', 'package.json'], diff --git a/extensions/src/hello-world/manifest.json b/extensions/src/hello-world/manifest.json index 4a430ed298..eb28f626a6 100644 --- a/extensions/src/hello-world/manifest.json +++ b/extensions/src/hello-world/manifest.json @@ -1,12 +1,13 @@ { "name": "helloWorld", "version": "0.0.1", + "publisher": "platform", "displayData": "assets/displayData.json", "author": "Paranext", "license": "MIT", "main": "src/main.ts", "extensionDependencies": {}, - "elevatedPrivileges": [], + "elevatedPrivileges": ["handleUri"], "types": "src/types/hello-world.d.ts", "menus": "contributions/menus.json", "settings": "contributions/settings.json", diff --git a/extensions/src/hello-world/src/main.ts b/extensions/src/hello-world/src/main.ts index e1a38fb52e..e8a89d679a 100644 --- a/extensions/src/hello-world/src/main.ts +++ b/extensions/src/hello-world/src/main.ts @@ -259,6 +259,51 @@ function helloException(message: string) { export async function activate(context: ExecutionActivationContext): Promise { logger.info('Hello world is activating!'); + if (!context.elevatedPrivileges.handleUri) { + logger.warn( + 'Hello World could not get handleUri. Maybe need to add handleUri in elevatedPrivileges', + ); + } else { + context.registrations.add( + context.elevatedPrivileges.handleUri.registerUriHandler(async (uri) => { + const url = new URL(uri); + switch (url?.pathname) { + case '/greet': + logger.info(`Hello, ${url.searchParams.get('name')}!`); + break; + case '/greetAndOpen': { + const avatarUrl = `https://ui-avatars.com/api/?background=random&${url.searchParams}`; + logger.info( + `Hello, ${url.searchParams.get('name')}! Pulling up a generated avatar for you at ${avatarUrl}`, + ); + await papi.commands.sendCommand('platform.openWindow', avatarUrl); + break; + } + default: + logger.info(`Hello World extension received a uri at an unknown path! ${uri}`); + break; + } + }), + ); + logger.info( + `Hello world is listening to URIs. You can navigate to ${context.elevatedPrivileges.handleUri.redirectUri}/greet?name=your_name to say hello`, + ); + } + + // test data protection + try { + const data = 'Hello, world!'; + const isEncryptionAvailable = await papi.dataProtection.isEncryptionAvailable(); + const dataEncrypted = await papi.dataProtection.encryptString(data); + const dataDecrypted = await papi.dataProtection.decryptString(dataEncrypted); + if (!isEncryptionAvailable || data === dataEncrypted || data !== dataDecrypted) + logger.warn( + `Hello World Data Protection test failed! data = '${data}'. dataEncrypted = '${dataEncrypted}'. dataDecrypted = '${dataDecrypted}'. isEncryptionAvailable = ${isEncryptionAvailable}`, + ); + } catch (e) { + logger.warn(`Hello World Data Protection test failed! ${e}`); + } + async function readRawDataForAllProjects(): Promise { try { return await papi.storage.readUserData(context.executionToken, allProjectDataStorageKey); diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index d67167cd2c..7f84278fa1 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -3096,6 +3096,11 @@ declare module 'papi-shared-types' { 'platform.restart': () => Promise; /** Open a browser to the platform's OpenRPC documentation */ 'platform.openDeveloperDocumentationUrl': () => Promise; + /** + * Open a link in a new browser window. Like `window.open` in the frontend with + * `target='_blank'` + */ + 'platform.openWindow': (url: string) => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ 'platform.openProjectSettings': (webViewId: string) => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ @@ -5464,13 +5469,68 @@ declare module 'shared/models/manage-extensions-privilege.model' { getInstalledExtensions: GetInstalledExtensionsFunction; }; } +declare module 'shared/models/handle-uri-privilege.model' { + import { Unsubscriber } from 'platform-bible-utils'; + /** Function that is called when the system navigates to a URI that this handler is set up to handle. */ + export type UriHandler = (uri: string) => Promise | void; + /** + * Function that registers a {@link UriHandler} to be called when the system navigates to a URI that + * matches the handler's scope + */ + export type RegisterUriHandler = (uriHandler: UriHandler) => Unsubscriber; + /** + * Functions and properties related to listening for when the system navigates to a URI built for an + * extension + */ + export type HandleUri = { + /** + * Register a handler function to listen for when the system navigates to a URI built for this + * extension. Each extension can only register one uri handler at a time. + * + * Each extension has its own exclusive URI that it can handle. Extensions cannot handle each + * others' URIs. The URIs this extension's handler will receive will have the following + * structure: + * + * ``; + * + * - `` is {@link HandleUri.redirectUri}. + * - `` is anything else that is on the URI as the application receives it. This + * could include path, query (aka parameters), and fragment (aka anchor). + * + * Handling URIs is useful for authentication workflows and other interactions with this extension + * from outside the application. + * + * Note: There is currently no check in place to guarantee that a call to this handler will only + * come from navigating to the uri; a process connecting over the PAPI WebSocket could fake a call + * to this handler. However, there is no expectation for this to happen. + */ + registerUriHandler: RegisterUriHandler; + /** + * The most basic URI this extension can handle with {@link HandleUri.registerUriHandler}. This + * `redirectUri` has the following structure: + * + * `://.`; + * + * - `` is the URI scheme this application supports. TODO: link name here + * - `` is the publisher id of this extension as specified in the extension + * manifest + * - `` is the name of this extension as specified in the extension manifest + * + * Additional data can be added to the end of the URI; this is just the scheme and authority. See + * {@link HandleUri.registerUriHandler} for more information. + */ + redirectUri: string; + }; +} declare module 'shared/models/elevated-privileges.model' { import { CreateProcess } from 'shared/models/create-process-privilege.model'; import { ManageExtensions } from 'shared/models/manage-extensions-privilege.model'; + import { HandleUri } from 'shared/models/handle-uri-privilege.model'; /** String constants that are listed in an extension's manifest.json to state needed privileges */ export enum ElevatedPrivilegeNames { createProcess = 'createProcess', manageExtensions = 'manageExtensions', + handleUri = 'handleUri', } /** Object that contains properties with special capabilities for extensions that required them */ export type ElevatedPrivileges = { @@ -5478,6 +5538,11 @@ declare module 'shared/models/elevated-privileges.model' { createProcess: CreateProcess | undefined; /** Functions that can be run to manage what extensions are running */ manageExtensions: ManageExtensions | undefined; + /** + * Functions and properties related to listening for when the system navigates to a URI built for + * this extension + */ + handleUri: HandleUri | undefined; }; } declare module 'extension-host/extension-types/extension-activation-context.model' { @@ -5785,6 +5850,23 @@ declare module 'shared/data/platform.data' { * Platform.Bible core */ export const PLATFORM_NAMESPACE = 'platform'; + /** + * Name of this application like `platform-bible`. + * + * Note: this is an identifier for the application, not this application's executable file name + */ + export const APP_NAME: string; + /** Version of this application in [semver](https://semver.org/) format. */ + export const APP_VERSION: string; + /** + * URI scheme that this application handles. Navigating to a URI with this scheme will open this + * application. This application will handle the URI as it sees fit. For example, the URI may be + * handled by an extension - see {@link ElevatedPrivileges.registerUriHandler } for more + * information. + * + * This is the same as {@link APP_NAME}. + */ + export const APP_URI_SCHEME: string; /** Query string passed to the renderer when starting if it should enable noisy dev mode */ export const DEV_MODE_RENDERER_INDICATOR = '?noisyDevMode'; } @@ -6214,6 +6296,11 @@ declare module '@papi/core' { InstalledExtensions, ManageExtensions, } from 'shared/models/manage-extensions-privilege.model'; + export type { + HandleUri, + RegisterUriHandler, + UriHandler, + } from 'shared/models/handle-uri-privilege.model'; export type { DialogTypes } from 'renderer/components/dialogs/dialog-definition.model'; export type { UseDialogCallbackOptions } from 'renderer/hooks/papi-hooks/use-dialog-callback.hook'; export type { @@ -6441,6 +6528,103 @@ declare module 'shared/services/project-settings.service' { const projectSettingsService: IProjectSettingsService; export default projectSettingsService; } +declare module 'shared/models/data-protection.service-model' { + /** + * + * Provides functions related to encrypting and decrypting strings like user data, secrets, etc. + * + * Uses Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. + * + * Note that these encryption mechanisms are not transferrable between computers. We recommend using + * them with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt data + * to be stored securely in local files. It is not intended to protect data passed over a network + * connection. Please note that using this service passes the unencrypted string between local + * processes using the PAPI WebSocket. + */ + export interface IDataProtectionService { + /** + * Encrypts a string using Electron's + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. Transforms the + * returned buffer to a base64-encoded string using + * [`buffer.toString('base64')`](https://nodejs.org/api/buffer.html#buftostringencoding-start-end). + * + * This method throws if the encryption mechanism is not available such as on Linux without a + * supported package installed. See + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) for more information. + * + * Note that this encryption mechanism is not transferrable between computers. We recommend using + * it with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt + * data to be stored securely in local files. It is not intended to protect data passed over a + * network connection. Please note that using this service passes the unencrypted string between + * local processes using the PAPI WebSocket. + * + * @param text String to encrypt + * @returns Encrypted string. Use `papi.dataProtection.decryptString` to decrypt + */ + encryptString(text: string): Promise; + /** + * Decrypts a string using Electron's + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. Transforms the + * input base64-encoded string to a buffer using [`Buffer.from(text, + * 'base64')`](https://nodejs.org/api/buffer.html#static-method-bufferfromstring-encoding). + * + * This method throws if the decryption mechanism is not available such as on Linux without a + * supported package installed. See + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) for more information. + * + * Note that this encryption mechanism is not transferrable between computers. We recommend using + * it with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt + * data to be stored securely in local files. It is not intended to protect data passed over a + * network connection. Please note that using this service passes the unencrypted string between + * local processes using the PAPI WebSocket. + * + * @param encryptedText String to decrypt. This string should have been encrypted by + * `papi.dataProtection.encryptString` + * @returns Decrypted string + */ + decryptString(encryptedText: string): Promise; + /** + * Returns `true` if encryption is currently available. Returns `false` if the decryption + * mechanism is not available such as on Linux without a supported package installed. See + * Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API for + * more information. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt + * data to be stored securely in local files. It is not intended to protect data passed over a + * network connection. Please note that using this service passes the unencrypted string between + * local processes using the PAPI WebSocket. + * + * @returns `true` if encryption is currently available; `false` otherwise + */ + isEncryptionAvailable(): Promise; + } + export const dataProtectionServiceNetworkObjectName = 'DataProtectionService'; +} +declare module 'shared/services/data-protection.service' { + import { IDataProtectionService } from 'shared/models/data-protection.service-model'; + /** + * + * Provides functions related to encrypting and decrypting strings like user data, secrets, etc. + * + * Uses Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. + * + * Note that these encryption mechanisms are not transferrable between computers. We recommend using + * them with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt data + * to be stored securely in local files. It is not intended to protect data passed over a network + * connection. Please note that using this service passes the unencrypted string between local + * processes using the PAPI WebSocket. + */ + const dataProtectionService: IDataProtectionService; + export default dataProtectionService; +} declare module '@papi/backend' { /** * Unified module for accessing API features in the extension host. @@ -6554,6 +6738,21 @@ declare module '@papi/backend' { * other services and extensions that have registered commands. */ commands: typeof commandService; + /** + * + * Provides functions related to encrypting and decrypting strings like user data, secrets, etc. + * + * Uses Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. + * + * Note that these encryption mechanisms are not transferrable between computers. We recommend using + * them with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt data + * to be stored securely in local files. It is not intended to protect data passed over a network + * connection. Please note that using this service passes the unencrypted string between local + * processes using the PAPI WebSocket. + */ + dataProtection: import('shared/models/data-protection.service-model').IDataProtectionService; /** * * Service exposing various functions related to using webViews @@ -6758,6 +6957,21 @@ declare module '@papi/backend' { * other services and extensions that have registered commands. */ export const commands: typeof commandService; + /** + * + * Provides functions related to encrypting and decrypting strings like user data, secrets, etc. + * + * Uses Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. + * + * Note that these encryption mechanisms are not transferrable between computers. We recommend using + * them with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt data + * to be stored securely in local files. It is not intended to protect data passed over a network + * connection. Please note that using this service passes the unencrypted string between local + * processes using the PAPI WebSocket. + */ + export const dataProtection: import('shared/models/data-protection.service-model').IDataProtectionService; /** * * Service exposing various functions related to using webViews @@ -6947,6 +7161,8 @@ declare module 'extension-host/extension-types/extension-manifest.model' { * implemented. */ activationEvents: string[]; + /** Id of publisher who published this extension on the extension marketplace */ + publisher?: string; }; } declare module 'renderer/hooks/hook-generators/create-use-network-object-hook.util' { diff --git a/release/app/package-lock.json b/release/app/package-lock.json index 33d3a073c1..e6a3ff4fd0 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -1,11 +1,11 @@ { - "name": "platform.bible", + "name": "platform-bible", "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "platform.bible", + "name": "platform-bible", "version": "0.3.0", "hasInstallScript": true, "license": "MIT" diff --git a/release/app/package.json b/release/app/package.json index 08de947c02..52d31fe4a1 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,5 +1,5 @@ { - "name": "platform.bible", + "name": "platform-bible", "version": "0.3.0", "description": "Extensible Bible translation software", "license": "MIT", diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index 1ffc8bee24..11b3166da6 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -60,6 +60,12 @@ declare module 'papi-shared-types' { 'platform.restart': () => Promise; /** Open a browser to the platform's OpenRPC documentation */ 'platform.openDeveloperDocumentationUrl': () => Promise; + /** + * Open a link in a new browser window. Like `window.open` in the frontend with + * `target='_blank'` + */ + 'platform.openWindow': (url: string) => Promise; + // These commands are provided in `web-view.service-host.ts` /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ 'platform.openProjectSettings': (webViewId: string) => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ diff --git a/src/extension-host/extension-types/extension-manifest.model.ts b/src/extension-host/extension-types/extension-manifest.model.ts index a5acab1085..4cf8092d61 100644 --- a/src/extension-host/extension-types/extension-manifest.model.ts +++ b/src/extension-host/extension-types/extension-manifest.model.ts @@ -47,4 +47,6 @@ export type ExtensionManifest = { * implemented. */ activationEvents: string[]; + /** Id of publisher who published this extension on the extension marketplace */ + publisher?: string; }; diff --git a/src/extension-host/services/extension.service.ts b/src/extension-host/services/extension.service.ts index 66499ae2de..939a8d6b80 100644 --- a/src/extension-host/services/extension.service.ts +++ b/src/extension-host/services/extension.service.ts @@ -14,7 +14,7 @@ import * as platformBibleUtils from 'platform-bible-utils'; import logger from '@shared/services/logger.service'; import { getCommandLineArgumentsGroup, COMMAND_LINE_ARGS } from '@node/utils/command-line.util'; import { setExtensionUris } from '@extension-host/services/extension-storage.service'; -import papi, { network, fetch as papiFetch } from '@extension-host/services/papi-backend.service'; +import papi, { fetch as papiFetch } from '@extension-host/services/papi-backend.service'; import executionTokenService from '@node/services/execution-token.service'; import { ExecutionActivationContext } from '@extension-host/extension-types/extension-activation-context.model'; import { @@ -30,7 +30,7 @@ import { } from 'platform-bible-utils'; import LogError from '@shared/log-error.model'; import { ExtensionManifest } from '@extension-host/extension-types/extension-manifest.model'; -import { PLATFORM_NAMESPACE } from '@shared/data/platform.data'; +import { APP_URI_SCHEME, PLATFORM_NAMESPACE } from '@shared/data/platform.data'; import { ElevatedPrivilegeNames, ElevatedPrivileges, @@ -46,6 +46,16 @@ import { CreateProcess } from '@shared/models/create-process-privilege.model'; import { wrappedFork, wrappedSpawn } from '@extension-host/services/create-process.service'; import os from 'os'; import { resyncContributions } from '@extension-host/services/contribution.service'; +import { + HandleUri, + RegisterUriHandler, + UriHandler, +} from '@shared/models/handle-uri-privilege.model'; +import { + createNetworkEventEmitter, + registerRequestHandler, +} from '@shared/services/network.service'; +import { HANDLE_URI_REQUEST_TYPE } from '@node/services/extension.service-model'; /** * The way to use `require` directly - provided by webpack because they overwrite normal `require`. @@ -101,6 +111,14 @@ type DtsInfo = { base: string; }; +/** + * Key to uniquely identify an extension with some extra certainty that the extension is who it says + * it is. + * + * Format: `.` + */ +type ExtensionKey = `${string}.${string}`; + /** * Name of the file describing the extension and its capabilities. Provided by the extension * developer @@ -168,6 +186,12 @@ let isReloading = false; /** Whether we should reload extensions again once finished currently reloading */ let shouldReload = false; +/** Map of registered URI handlers for each extension keyed by the extension publisher name and name */ +const uriHandlersByExtensionKey = new Map(); + +/** Regex matching to spaces */ +const spaceRegex = /\s/; + /** Parse string extension manifest into an object and perform any transformations needed */ function parseManifest(extensionManifestJson: string): ExtensionManifest { const extensionManifest: ExtensionManifest = deserialize(extensionManifestJson); @@ -794,10 +818,82 @@ async function getInstalledExtensions(): Promise { // #endregion +// #region Extension URI handling privileges + +function getExtensionKey(manifest: ExtensionManifest): ExtensionKey { + if (!manifest.publisher || spaceRegex.test(manifest.publisher)) + throw new Error('Extension publisher must not be empty string, undefined, or contain spaces'); + if (!manifest.name || spaceRegex.test(manifest.name)) + throw new Error('Extension name must not be empty string, undefined, or contain spaces'); + const extensionKey: ExtensionKey = `${manifest.publisher}.${manifest.name}`; + return extensionKey; +} + +function getRedirectUri(manifest: ExtensionManifest) { + return `${APP_URI_SCHEME}://${getExtensionKey(manifest)}`; +} + +function createRegisterUriHandlerFunction(manifest: ExtensionManifest): RegisterUriHandler { + return (uriHandler) => { + const extensionKey = getExtensionKey(manifest); + if (uriHandlersByExtensionKey.has(extensionKey)) + throw new Error( + `Extension ${extensionKey} already has a registered Uri handler. Cannot have multiple.`, + ); + + uriHandlersByExtensionKey.set(extensionKey, uriHandler); + + return () => uriHandlersByExtensionKey.delete(extensionKey); + }; +} + +function handleExtensionUri(uri: string) { + // need to use `new URL` instead of `URL.parse` because Node<22.1.0 doesn't have it. Can change + // when we get there + let url: URL; + try { + url = new URL(uri); + } catch (e) { + logger.warn(`Extension service received uri ${uri} but could not parse it. ${e}`); + return; + } + if (url.protocol !== `${APP_URI_SCHEME}:`) { + logger.warn( + `Extension service received uri ${uri} but protocol does not match ${APP_URI_SCHEME}`, + ); + return; + } + + // Validating the map keys when setting in createRegisterUriHandlerFunction, so this won't match + // to anything if it is not properly formatted. Implicitly validating as ExtensionKey + // eslint-disable-next-line no-type-assertion/no-type-assertion + const extensionKey = url?.hostname as ExtensionKey; + const uriHandler = uriHandlersByExtensionKey.get(extensionKey); + + if (!uriHandler) { + logger.warn( + `Extension service received uri ${uri}, but there was not a registered URI handler for ${extensionKey}`, + ); + return; + } + + (async () => { + try { + logger.debug(`Extension service sending uri ${uri} to extension ${extensionKey} to handle`); + await uriHandler(uri); + } catch (e) { + logger.warn(`Extension service ran uri handler for ${uri}, but it threw. ${e}`); + } + })(); +} + +// #endregion + function prepareElevatedPrivileges(manifest: ExtensionManifest): Readonly { const retVal: ElevatedPrivileges = { createProcess: undefined, manageExtensions: undefined, + handleUri: undefined, }; if (manifest.elevatedPrivileges?.find((p) => p === ElevatedPrivilegeNames.createProcess)) { const createProcess: CreateProcess = { @@ -822,6 +918,14 @@ function prepareElevatedPrivileges(manifest: ExtensionManifest): Readonly p === ElevatedPrivilegeNames.handleUri)) { + const handleUri: HandleUri = { + redirectUri: getRedirectUri(manifest), + registerUriHandler: createRegisterUriHandlerFunction(manifest), + }; + Object.freeze(handleUri); + retVal.handleUri = handleUri; + } Object.freeze(retVal); return retVal; } @@ -1002,6 +1106,16 @@ async function deactivateExtension(extension: ExtensionInfo): Promise { initializePromise = (async (): Promise => { if (isInitialized) return; - reloadFinishedEventEmitter = network.createNetworkEventEmitter( + reloadFinishedEventEmitter = createNetworkEventEmitter( 'platform.onDidReloadExtensions', ); + await registerRequestHandler(HANDLE_URI_REQUEST_TYPE, handleExtensionUri); + await normalizeExtensionFileNames(); await reloadExtensions(false, false); diff --git a/src/extension-host/services/papi-backend.service.ts b/src/extension-host/services/papi-backend.service.ts index 1f7ebe5e57..42c926f34a 100644 --- a/src/extension-host/services/papi-backend.service.ts +++ b/src/extension-host/services/papi-backend.service.ts @@ -47,6 +47,7 @@ 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'; +import dataProtectionService from '@shared/services/data-protection.service'; // IMPORTANT NOTES: // 1) When adding new services here, consider whether they also belong in papi-frontend.service.ts. @@ -76,6 +77,8 @@ const papi = { // Services/modules /** JSDOC DESTINATION commandService */ commands: commandService, + /** JSDOC DESTINATION dataProtectionService */ + dataProtection: dataProtectionService, /** JSDOC DESTINATION papiWebViewService */ webViews: webViewService as WebViewServiceType, /** JSDOC DESTINATION papiWebViewProviderService */ @@ -142,6 +145,9 @@ Object.freeze(papi.fetch); /** JSDOC DESTINATION commandService */ export const { commands } = papi; Object.freeze(papi.commands); +/** JSDOC DESTINATION dataProtectionService */ +export const { dataProtection } = papi; +Object.freeze(papi.dataProtection); /** JSDOC DESTINATION papiWebViewService */ export const { webViews } = papi; Object.freeze(papi.webViews); diff --git a/src/main/main.ts b/src/main/main.ts index be43d35dc5..93d77e3aad 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -25,10 +25,29 @@ import { SerializedRequestType } from '@shared/utils/util'; import { get } from '@shared/services/project-data-provider.service'; import { VerseRef } from '@sillsdev/scripture'; import { startNetworkObjectStatusService } from '@main/services/network-object-status.service-host'; -import { DEV_MODE_RENDERER_INDICATOR } from '@shared/data/platform.data'; +import { APP_URI_SCHEME, DEV_MODE_RENDERER_INDICATOR } from '@shared/data/platform.data'; import { startProjectLookupService } from '@main/services/project-lookup.service-host'; import { PROJECT_INTERFACE_PLATFORM_BASE } from '@shared/models/project-data-provider.model'; import { GET_METHODS } from '@shared/data/rpc.model'; +import { HANDLE_URI_REQUEST_TYPE } from '@node/services/extension.service-model'; +import { startDataProtectionService } from '@main/services/data-protection.service-host'; + +// #region Prevent multiple instances of the app. This needs to stay at the top of the app! + +// Prevent multiple instances because an instance launched after the first is likely a URL redirect +// to our protocol client. We handle URI redirects below in `second-instance` + +/** Whether this is the first instance of this application. */ +const isFirstInstance = app.requestSingleInstanceLock(); + +if (!isFirstInstance) { + logger.info( + `Application launched but not first instance. Exiting. This probably means the application just handled a URL. process.argv: ${process.argv}`, + ); + app.exit(); +} + +// #endregion const PROCESS_CLOSE_TIME_OUT = 2000; /** @@ -37,6 +56,24 @@ const PROCESS_CLOSE_TIME_OUT = 2000; */ let willRestart = false; +/** + * Open a link in the browser following the restrictions we put in place in Platform.Bible + * + * Make sure not to allow just any link. See + * https://benjamin-altpeter.de/shell-openexternal-dangers/ + */ +async function openExternal(url: string) { + if (!url.startsWith('https://')) throw new Error(`URL must start with 'https://': ${url}`); + try { + await shell.openExternal(url); + } catch (e) { + logger.warn(e); + throw e; + } + + return true; +} + async function main() { // The network service relies on nothing else, and other things rely on it, so start it first await networkService.initialize(); @@ -52,6 +89,10 @@ async function main() { // TODO (maybe): Wait for signal from the .NET data provider process that it is ready + // Need to start the data protection service before starting the extension host because the extension + // host uses it + await startDataProtectionService(); + // The extension host service relies on the network service. // Extensions inside the extension host might rely on the .NET data provider and each other // Some extensions inside the extension host rely on the renderer to accept 'getWebView' commands. @@ -62,6 +103,68 @@ async function main() { // TODO (maybe): Wait for signal from the extension host process that it is ready (except 'getWebView') // We could then wait for the renderer to be ready and signal the extension host + // Keep a global reference of the window object. If you don't, the window will + // be closed automatically when the JavaScript object is garbage collected. + let mainWindow: BrowserWindow | undefined; + + // #region Set up the protocol client to receive navigation to this app's URI scheme + + // Launch the portable app if we're in it; otherwise use the normal path + const launchPath = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath; + const args = process.argv.slice(1); + + function handleUri(uri: string) { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + logger.debug(`Main is handling uri ${uri}`); + // need to use `new URL` instead of `URL.parse` because Node<22.1.0 doesn't have it. Can change + // when we get there + let url: URL; + try { + url = new URL(uri); + } catch (e) { + logger.debug( + `Main received uri ${uri} but could not parse it. If this does not look like a uri, that probably means the user tried to open the application again. This is likely not a problem. ${e}`, + ); + return; + } + if (url.protocol !== `${APP_URI_SCHEME}:`) { + logger.warn(`Main received uri ${uri} but protocol does not match ${APP_URI_SCHEME}`); + return; + } + + (async () => { + try { + await networkService.request(HANDLE_URI_REQUEST_TYPE, uri); + } catch (e) { + logger.warn( + `Main sent request for extension service to handle uri ${uri}, but it threw. ${e}`, + ); + } + })(); + } + // Resolve the path to this file if we're running the electron app itself and passing in this file + // Note that this condition (`process.defaultApp`) is not quite the same as whether we're + // packaged, so we're not using `globalThis.isPackaged` here. + if (process.defaultApp && args.length > 2) args[2] = path.resolve(args[2]); + app.setAsDefaultProtocolClient(APP_URI_SCHEME, launchPath, args); + if (process.platform === 'darwin') { + // Use OSX's event to handle navigation + app.on('open-url', (_event, url) => handleUri(url)); + } else { + // Non-OSX attempts to launch a second instance to handle navigation; detect and handle + // accordingly + app.on('second-instance', (_event, commandLine) => { + // Handle the URL + const uri = commandLine[commandLine.length - 1]; + handleUri(uri); + }); + } + + // #endregion + // #region Start the renderer // Removed until we have a release. See https://github.com/paranext/paranext-core/issues/83 @@ -72,10 +175,6 @@ async function main() { } } */ - // Keep a global reference of the window object. If you don't, the window will - // be closed automatically when the JavaScript object is garbage collected. - let mainWindow: BrowserWindow | undefined; - if (process.env.NODE_ENV === 'production') { const sourceMapSupport = await import('source-map-support'); sourceMapSupport.install(); @@ -162,7 +261,15 @@ async function main() { // target="_blank". Please revise web-view.service-host.ts as necessary if you make changes here mainWindow.webContents.setWindowOpenHandler((handlerDetails) => { // Only allow https urls - if (handlerDetails.url?.startsWith('https://')) shell.openExternal(handlerDetails.url); + (async () => { + try { + openExternal(handlerDetails.url); + } catch (e) { + logger.warn( + `Main could not open external url ${handlerDetails.url} from windowOpenHandler. ${e}`, + ); + } + })(); return { action: 'deny' }; }); @@ -293,7 +400,9 @@ async function main() { 'https://playground.open-rpc.org/?transport=websocket&schemaUrl=ws%3A%2F%2Flocalhost%3A8876%0A&uiSchema[appBar][ui:splitView]=false&uiSchema[appBar][ui:input]=false&uiSchema[appBar][ui:examplesDropdown]=false&uiSchema[appBar][ui:transports]=false&uiSchema[appBar][ui:darkMode]=true&uiSchema[appBar][ui:title]=PAPI'; commandService.registerCommand( 'platform.openDeveloperDocumentationUrl', - async () => shell.openExternal(liveDocsUrl), + async () => { + await openExternal(liveDocsUrl); + }, { method: { summary: 'Open the OpenRPC documentation in a browser', @@ -306,6 +415,31 @@ async function main() { }, ); + commandService.registerCommand( + 'platform.openWindow', + async (url) => { + logger.debug(`Main opening window with url from command: ${url}`); + await openExternal(url); + }, + { + method: { + summary: "Open a link in the user's default browser", + params: [ + { + name: 'url', + required: true, + summary: 'The url to open', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + schema: { type: 'null' }, + }, + }, + }, + ); + // #endregion // #region Noisy dev tests diff --git a/src/main/services/data-protection.service-host.ts b/src/main/services/data-protection.service-host.ts new file mode 100644 index 0000000000..27bec01fb2 --- /dev/null +++ b/src/main/services/data-protection.service-host.ts @@ -0,0 +1,57 @@ +import { + dataProtectionServiceNetworkObjectName, + IDataProtectionService, +} from '@shared/models/data-protection.service-model'; +import networkObjectService from '@shared/services/network-object.service'; +import { safeStorage } from 'electron'; + +/** If encryption is not available, return reason why. If encryption is available, return `undefined` */ +function getReasonEncryptionIsNotAvailable() { + if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend() === 'basic_text') + return 'safeStorage did not find a keyring service it could use for encryption. Please install a supported service. See https://github.com/paranext/paranext/wiki/How-to-set-up-Platform.Bible-on-Linux#install-a-keyring-service for more information'; + if (!safeStorage.isEncryptionAvailable()) { + return 'safeStorage.isEncryptionAvailable returned false'; + } + return undefined; +} + +/** If encryption is not available, throw */ +function validateEncryptionAvailable() { + const reason = getReasonEncryptionIsNotAvailable(); + if (reason) { + throw new Error(reason); + } +} + +async function isEncryptionAvailable() { + return getReasonEncryptionIsNotAvailable() === undefined; +} + +async function encryptString(text: string) { + validateEncryptionAvailable(); + const buffer = safeStorage.encryptString(text); + return buffer.toString('base64'); +} + +async function decryptString(encryptedText: string) { + validateEncryptionAvailable(); + const buffer = Buffer.from(encryptedText, 'base64'); + return safeStorage.decryptString(buffer); +} + +const dataProtectionService: IDataProtectionService = { + isEncryptionAvailable, + encryptString, + decryptString, +}; + +/** Register the network object that backs this service */ +// This doesn't really represent this service module, so we're not making it default. To use this +// service, you should use `data-protection.service.ts` +// eslint-disable-next-line import/prefer-default-export +export async function startDataProtectionService(): Promise { + await networkObjectService.set( + dataProtectionServiceNetworkObjectName, + dataProtectionService, + ); +} diff --git a/src/main/services/rpc-websocket-listener.ts b/src/main/services/rpc-websocket-listener.ts index 1a79d41668..741d0f6b5b 100644 --- a/src/main/services/rpc-websocket-listener.ts +++ b/src/main/services/rpc-websocket-listener.ts @@ -13,7 +13,7 @@ import { WEBSOCKET_PORT, } from '@shared/data/rpc.model'; import { IRpcMethodRegistrar, RegisteredRpcMethodDetails } from '@shared/models/rpc.interface'; -import { Mutex } from 'platform-bible-utils'; +import { getErrorMessage, Mutex } from 'platform-bible-utils'; import { WebSocketServer } from 'ws'; import logger from '@shared/services/logger.service'; import { JSONRPCErrorCode, JSONRPCResponse } from 'json-rpc-2.0'; @@ -118,11 +118,11 @@ export default class RpcWebSocketListener implements IRpcMethodRegistrar { JSONRPCErrorCode.InternalError, ); try { - const result = method(requestParams); + const result = method(...requestParams); const awaitedResult = result instanceof Promise ? await result : result; return createSuccessResponse(awaitedResult); } catch (error) { - return createErrorResponse(JSON.stringify(error), JSONRPCErrorCode.InternalError); + return createErrorResponse(getErrorMessage(error), JSONRPCErrorCode.InternalError); } }, 'rpc-websocket-listener', diff --git a/src/node/services/extension.service-model.ts b/src/node/services/extension.service-model.ts new file mode 100644 index 0000000000..11a589d9aa --- /dev/null +++ b/src/node/services/extension.service-model.ts @@ -0,0 +1,10 @@ +import { serializeRequestType } from '@shared/utils/util'; + +/** Prefix on requests that indicates that the request is related to extension service operations */ +export const CATEGORY_EXTENSION_SERVICE = 'extensionService'; + +/** Serialized request type for request sent from main to extension service to handle a uri redirect */ +export const HANDLE_URI_REQUEST_TYPE = serializeRequestType( + CATEGORY_EXTENSION_SERVICE, + 'handleUri', +); diff --git a/src/shared/data/platform.data.ts b/src/shared/data/platform.data.ts index 89a56fd185..f1c0d89dea 100644 --- a/src/shared/data/platform.data.ts +++ b/src/shared/data/platform.data.ts @@ -1,8 +1,35 @@ +// Used in JSDoc +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { ElevatedPrivileges } from '@shared/models/elevated-privileges.model'; + +// This must be a `require`! Do not change to `import`. Importing outside `src` messes up papi.d.ts +const packageInfo = require('../../../release/app/package.json'); + /** * Namespace to use for features like commands, settings, etc. on the PAPI that are provided by * Platform.Bible core */ export const PLATFORM_NAMESPACE = 'platform'; +/** + * Name of this application like `platform-bible`. + * + * Note: this is an identifier for the application, not this application's executable file name + */ +export const APP_NAME: string = packageInfo.name; + +/** Version of this application in [semver](https://semver.org/) format. */ +export const APP_VERSION: string = packageInfo.version; + +/** + * URI scheme that this application handles. Navigating to a URI with this scheme will open this + * application. This application will handle the URI as it sees fit. For example, the URI may be + * handled by an extension - see {@link ElevatedPrivileges.registerUriHandler } for more + * information. + * + * This is the same as {@link APP_NAME}. + */ +export const APP_URI_SCHEME = APP_NAME; + /** Query string passed to the renderer when starting if it should enable noisy dev mode */ export const DEV_MODE_RENDERER_INDICATOR = '?noisyDevMode'; diff --git a/src/shared/models/data-protection.service-model.ts b/src/shared/models/data-protection.service-model.ts new file mode 100644 index 0000000000..762c9f9836 --- /dev/null +++ b/src/shared/models/data-protection.service-model.ts @@ -0,0 +1,65 @@ +// Functions that are exposed through the network object +/** JSDOC DESTINATION dataProtectionService */ +export interface IDataProtectionService { + /** + * Encrypts a string using Electron's + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. Transforms the + * returned buffer to a base64-encoded string using + * [`buffer.toString('base64')`](https://nodejs.org/api/buffer.html#buftostringencoding-start-end). + * + * This method throws if the encryption mechanism is not available such as on Linux without a + * supported package installed. See + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) for more information. + * + * Note that this encryption mechanism is not transferrable between computers. We recommend using + * it with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt + * data to be stored securely in local files. It is not intended to protect data passed over a + * network connection. Please note that using this service passes the unencrypted string between + * local processes using the PAPI WebSocket. + * + * @param text String to encrypt + * @returns Encrypted string. Use `papi.dataProtection.decryptString` to decrypt + */ + encryptString(text: string): Promise; + /** + * Decrypts a string using Electron's + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. Transforms the + * input base64-encoded string to a buffer using [`Buffer.from(text, + * 'base64')`](https://nodejs.org/api/buffer.html#static-method-bufferfromstring-encoding). + * + * This method throws if the decryption mechanism is not available such as on Linux without a + * supported package installed. See + * [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) for more information. + * + * Note that this encryption mechanism is not transferrable between computers. We recommend using + * it with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt + * data to be stored securely in local files. It is not intended to protect data passed over a + * network connection. Please note that using this service passes the unencrypted string between + * local processes using the PAPI WebSocket. + * + * @param encryptedText String to decrypt. This string should have been encrypted by + * `papi.dataProtection.encryptString` + * @returns Decrypted string + */ + decryptString(encryptedText: string): Promise; + /** + * Returns `true` if encryption is currently available. Returns `false` if the decryption + * mechanism is not available such as on Linux without a supported package installed. See + * Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API for + * more information. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt + * data to be stored securely in local files. It is not intended to protect data passed over a + * network connection. Please note that using this service passes the unencrypted string between + * local processes using the PAPI WebSocket. + * + * @returns `true` if encryption is currently available; `false` otherwise + */ + isEncryptionAvailable(): Promise; +} + +export const dataProtectionServiceNetworkObjectName = 'DataProtectionService'; diff --git a/src/shared/models/elevated-privileges.model.ts b/src/shared/models/elevated-privileges.model.ts index e7a8f191e3..b938c127e0 100644 --- a/src/shared/models/elevated-privileges.model.ts +++ b/src/shared/models/elevated-privileges.model.ts @@ -1,10 +1,12 @@ import { CreateProcess } from '@shared/models/create-process-privilege.model'; import { ManageExtensions } from '@shared/models/manage-extensions-privilege.model'; +import { HandleUri } from '@shared/models/handle-uri-privilege.model'; /** String constants that are listed in an extension's manifest.json to state needed privileges */ export enum ElevatedPrivilegeNames { createProcess = 'createProcess', manageExtensions = 'manageExtensions', + handleUri = 'handleUri', } /** Object that contains properties with special capabilities for extensions that required them */ @@ -13,4 +15,9 @@ export type ElevatedPrivileges = { createProcess: CreateProcess | undefined; /** Functions that can be run to manage what extensions are running */ manageExtensions: ManageExtensions | undefined; + /** + * Functions and properties related to listening for when the system navigates to a URI built for + * this extension + */ + handleUri: HandleUri | undefined; }; diff --git a/src/shared/models/handle-uri-privilege.model.ts b/src/shared/models/handle-uri-privilege.model.ts new file mode 100644 index 0000000000..d1edd5cbb7 --- /dev/null +++ b/src/shared/models/handle-uri-privilege.model.ts @@ -0,0 +1,54 @@ +import { Unsubscriber } from 'platform-bible-utils'; + +/** Function that is called when the system navigates to a URI that this handler is set up to handle. */ +export type UriHandler = (uri: string) => Promise | void; + +/** + * Function that registers a {@link UriHandler} to be called when the system navigates to a URI that + * matches the handler's scope + */ +export type RegisterUriHandler = (uriHandler: UriHandler) => Unsubscriber; + +/** + * Functions and properties related to listening for when the system navigates to a URI built for an + * extension + */ +export type HandleUri = { + /** + * Register a handler function to listen for when the system navigates to a URI built for this + * extension. Each extension can only register one uri handler at a time. + * + * Each extension has its own exclusive URI that it can handle. Extensions cannot handle each + * others' URIs. The URIs this extension's handler will receive will have the following + * structure: + * + * ``; + * + * - `` is {@link HandleUri.redirectUri}. + * - `` is anything else that is on the URI as the application receives it. This + * could include path, query (aka parameters), and fragment (aka anchor). + * + * Handling URIs is useful for authentication workflows and other interactions with this extension + * from outside the application. + * + * Note: There is currently no check in place to guarantee that a call to this handler will only + * come from navigating to the uri; a process connecting over the PAPI WebSocket could fake a call + * to this handler. However, there is no expectation for this to happen. + */ + registerUriHandler: RegisterUriHandler; + /** + * The most basic URI this extension can handle with {@link HandleUri.registerUriHandler}. This + * `redirectUri` has the following structure: + * + * `://.`; + * + * - `` is the URI scheme this application supports. TODO: link name here + * - `` is the publisher id of this extension as specified in the extension + * manifest + * - `` is the name of this extension as specified in the extension manifest + * + * Additional data can be added to the end of the URI; this is just the scheme and authority. See + * {@link HandleUri.registerUriHandler} for more information. + */ + redirectUri: string; +}; diff --git a/src/shared/services/data-protection.service.ts b/src/shared/services/data-protection.service.ts new file mode 100644 index 0000000000..eaeff3f801 --- /dev/null +++ b/src/shared/services/data-protection.service.ts @@ -0,0 +1,54 @@ +import { + dataProtectionServiceNetworkObjectName, + IDataProtectionService, +} from '@shared/models/data-protection.service-model'; +import networkObjectService from '@shared/services/network-object.service'; +import { createSyncProxyForAsyncObject } from 'platform-bible-utils'; + +let networkObject: IDataProtectionService; +let initializationPromise: Promise; +async function initialize(): Promise { + if (!initializationPromise) { + initializationPromise = new Promise((resolve, reject) => { + const executor = async () => { + try { + const localDataProtectionService = await networkObjectService.get( + dataProtectionServiceNetworkObjectName, + ); + if (!localDataProtectionService) + throw new Error( + `${dataProtectionServiceNetworkObjectName} is not available as a network object`, + ); + networkObject = localDataProtectionService; + resolve(); + } catch (error) { + reject(error); + } + }; + executor(); + }); + } + return initializationPromise; +} + +/** + * JSDOC SOURCE dataProtectionService + * + * Provides functions related to encrypting and decrypting strings like user data, secrets, etc. + * + * Uses Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) API. + * + * Note that these encryption mechanisms are not transferrable between computers. We recommend using + * them with `papi.storage` methods to store data safely. + * + * WARNING: The primary purpose of this service is to enable extensions to encrypt and decrypt data + * to be stored securely in local files. It is not intended to protect data passed over a network + * connection. Please note that using this service passes the unencrypted string between local + * processes using the PAPI WebSocket. + */ +const dataProtectionService = createSyncProxyForAsyncObject(async () => { + await initialize(); + return networkObject; +}); + +export default dataProtectionService; diff --git a/src/shared/services/papi-core.service.ts b/src/shared/services/papi-core.service.ts index d1c984c07d..1fd9c98d50 100644 --- a/src/shared/services/papi-core.service.ts +++ b/src/shared/services/papi-core.service.ts @@ -11,6 +11,11 @@ export type { InstalledExtensions, ManageExtensions, } from '@shared/models/manage-extensions-privilege.model'; +export type { + HandleUri, + RegisterUriHandler, + UriHandler, +} from '@shared/models/handle-uri-privilege.model'; export type { DialogTypes } from '@renderer/components/dialogs/dialog-definition.model'; export type { UseDialogCallbackOptions } from '@renderer/hooks/papi-hooks/use-dialog-callback.hook'; export type {