diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index ddd542881b..2a6a32ab1c 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -4050,6 +4050,8 @@ declare module 'shared/models/project-lookup.service-model' { /** Local object of functions to run locally on each process as part of the project lookup service */ export const projectLookupServiceBase: ProjectLookupServiceType; /** + * Gets project metadata from PDPFs filtered down by various filtering options + * * Note: If there are multiple PDPs available whose metadata matches the conditions provided by the * parameters, their project metadata will all be combined, so all available `projectInterface`s * provided by the PDP Factory with the matching id (or all PDP Factories if no id is specified) for @@ -4111,6 +4113,7 @@ declare module 'shared/models/project-lookup.service-model' { internalGetMetadata: typeof internalGetMetadata; compareProjectDataProviderFactoryMetadataInfoMinimalMatch: typeof compareProjectDataProviderFactoryMetadataInfoMinimalMatch; transformGetMetadataForProjectParametersToFilter: typeof transformGetMetadataForProjectParametersToFilter; + LOAD_TIME_GRACE_PERIOD_MS: number; }; } declare module 'shared/services/project-lookup.service' { diff --git a/src/extension-host/services/contribution.service.ts b/src/extension-host/services/contribution.service.ts new file mode 100644 index 0000000000..8ddf54564a --- /dev/null +++ b/src/extension-host/services/contribution.service.ts @@ -0,0 +1,217 @@ +import { ExtensionManifest } from '@extension-host/extension-types/extension-manifest.model'; +import { AsyncVariable, JsonDocumentLike, Unsubscriber } from 'platform-bible-utils'; +import * as nodeFS from '@node/services/node-file-system.service'; +import { joinUriPaths } from '@node/utils/util'; +import logger from '@shared/services/logger.service'; +import SettingsDocumentCombiner from '@shared/utils/settings-document-combiner'; +import { platformSettings } from '@extension-host/data/core-settings-info.data'; +import MenuDocumentCombiner from '@shared/utils/menu-document-combiner'; +import menuDataObject from '@extension-host/data/menu.data.json'; +import ProjectSettingsDocumentCombiner from '@shared/utils/project-settings-document-combiner'; +import { platformProjectSettings } from '@extension-host/data/core-project-settings-info.data'; +import LocalizedStringsDocumentCombiner from '@shared/utils/localized-strings-document-combiner'; + +// #region document combiners - manage the extension contributions. Services should layer over these + +/** + * Object that keeps track of all settings contributions in the platform. To listen to updates to + * the settings contributions, subscribe to its `onDidRebuild` event (consider debouncing as each + * contribution will trigger a rebuild). + * + * Keeping this object separate from the data provider and disabling the `set` calls in the data + * provider prevents random services from changing system settings contributions unexpectedly. + */ +export const settingsDocumentCombiner = new SettingsDocumentCombiner(platformSettings); +/** + * Object that keeps track of all active menus in the platform. Call + * {@link MenuDataDataProviderEngine.rebuildMenus} in the service host after updating this object. + * + * Keeping this object separate from the data provider and disabling the `set` calls in the data + * provider prevents random services from changing system menus unexpectedly. + */ +export const menuDocumentCombiner = new MenuDocumentCombiner(menuDataObject); +/** + * Object that keeps track of all project settings contributions in the platform. To listen to + * updates to the project settings contributions, subscribe to its `onDidRebuild` event (consider + * debouncing as each contribution will trigger a rebuild). + * + * Keeping this object separate from the network object prevents random services from changing + * system project settings contributions unexpectedly. + */ +export const projectSettingsDocumentCombiner = new ProjectSettingsDocumentCombiner( + platformProjectSettings, +); +/** + * Object that keeps track of all localized string contributions in the platform. To listen to + * updates to the localized string contributions, subscribe to its `onDidRebuild` event (consider + * debouncing as each contribution will trigger a rebuild). + * + * Keeping this object separate from the data provider and disabling the `set` calls in the data + * provider prevents random services from changing system localized string contributions + * unexpectedly. + */ +export const localizedStringsDocumentCombiner = new LocalizedStringsDocumentCombiner({}); + +// #endregion + +const onDidResyncContributionsSubscriptions: (() => Promise)[] = []; +/** + * Event that runs asynchronous functions after extension contributions are finished being set up + * and after they are re-synced. Function calls are awaited + */ +export function onDidResyncContributions(callback: () => Promise): Unsubscriber { + onDidResyncContributionsSubscriptions.push(callback); + return () => { + const callbackIndex = onDidResyncContributionsSubscriptions.indexOf(callback); + if (callbackIndex < 0) return false; + onDidResyncContributionsSubscriptions.splice(callbackIndex, 1); + return true; + }; +} + +let resyncContributionsCount = -1; +function createNewResyncContributionsVariable() { + resyncContributionsCount += 1; + return new AsyncVariable(`resyncContributions ${resyncContributionsCount}`, -1); +} +let resyncContributionsVariable = createNewResyncContributionsVariable(); + +/** + * Returns a promise that resolves when the extension service is done resyncing contributions. If it + * has already set up contributions before and is not currently resyncing contributions, the promise + * will already be resolved. + * + * This should be used when attempting to access contribution information to ensure you aren't + * accessing incomplete or out-of-date contribution information + */ +export async function waitForResyncContributions() { + return resyncContributionsVariable.promise; +} + +/** Clears extension contributions and re-adds them. Only call from `extension.service.ts`! */ +export async function resyncContributions( + extensionsToAdd: Readonly[], +) { + if (resyncContributionsVariable.hasSettled) + resyncContributionsVariable = createNewResyncContributionsVariable(); + + menuDocumentCombiner.deleteAllContributions(); + settingsDocumentCombiner.deleteAllContributions(); + projectSettingsDocumentCombiner.deleteAllContributions(); + localizedStringsDocumentCombiner.deleteAllContributions(); + + // Load up all the extension contributions asynchronously + const extensionsContributions = await Promise.all( + extensionsToAdd.map(async (extension) => { + let localizedStringsDocument: JsonDocumentLike | undefined; + if (extension.localizedStrings) { + try { + // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir + const localizedStringsJson = await nodeFS.readFileText( + joinUriPaths(extension.dirUri, extension.localizedStrings), + ); + localizedStringsDocument = JSON.parse(localizedStringsJson); + } catch (error) { + logger.warn( + `Could not load localized strings contribution for ${extension.name}: ${error}`, + ); + } + } + let menuDocument: JsonDocumentLike | undefined; + if (extension.menus) { + try { + // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir + const menuJson = await nodeFS.readFileText( + joinUriPaths(extension.dirUri, extension.menus), + ); + menuDocument = JSON.parse(menuJson); + } catch (error) { + logger.warn(`Could not load menu contribution for ${extension.name}: ${error}`); + } + } + let settingsDocument: JsonDocumentLike | undefined; + if (extension.settings) { + try { + // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir + const settingsJson = await nodeFS.readFileText( + joinUriPaths(extension.dirUri, extension.settings), + ); + settingsDocument = JSON.parse(settingsJson); + } catch (error) { + logger.warn(`Could not load settings contribution for ${extension.name}: ${error}`); + } + } + let projectSettingsDocument: JsonDocumentLike | undefined; + if (extension.projectSettings) { + try { + // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir + const projectSettingsJson = await nodeFS.readFileText( + joinUriPaths(extension.dirUri, extension.projectSettings), + ); + projectSettingsDocument = JSON.parse(projectSettingsJson); + } catch (error) { + logger.warn( + `Could not load project settings contribution for ${extension.name}: ${error}`, + ); + } + } + + return { + name: extension.name, + localizedStringsDocument, + menuDocument, + settingsDocument, + projectSettingsDocument, + }; + }), + ); + + // Load contributions in the order in which the extensions are loaded + extensionsContributions.forEach( + ({ + name, + localizedStringsDocument, + menuDocument, + settingsDocument, + projectSettingsDocument, + }) => { + if (localizedStringsDocument) + try { + localizedStringsDocumentCombiner.addOrUpdateContribution(name, localizedStringsDocument); + } catch (error) { + logger.warn(`Could not add localized strings contribution for ${name}: ${error}`); + } + if (menuDocument) + try { + menuDocumentCombiner.addOrUpdateContribution(name, menuDocument); + } catch (error) { + logger.warn(`Could not add menu contribution for ${name}: ${error}`); + } + if (settingsDocument) + try { + settingsDocumentCombiner.addOrUpdateContribution(name, settingsDocument); + } catch (error) { + logger.warn(`Could not add settings contribution for ${name}: ${error}`); + } + if (projectSettingsDocument) + try { + projectSettingsDocumentCombiner.addOrUpdateContribution(name, projectSettingsDocument); + } catch (error) { + logger.warn(`Could not add project settings contribution for ${name}: ${error}`); + } + }, + ); + + resyncContributionsVariable.resolveToValue(undefined, true); + + // Do things in response to finishing resyncing contributions + await Promise.all( + [...onDidResyncContributionsSubscriptions].map(async (callback, i) => { + try { + await callback(); + } catch (e) { + logger.warn(`onDidResyncContributionsSubscriptions threw on callback ${i}! ${e}`); + } + }), + ); +} diff --git a/src/extension-host/services/extension.service.ts b/src/extension-host/services/extension.service.ts index ba61013963..66499ae2de 100644 --- a/src/extension-host/services/extension.service.ts +++ b/src/extension-host/services/extension.service.ts @@ -27,16 +27,10 @@ import { stringLength, startsWith, slice, - JsonDocumentLike, } from 'platform-bible-utils'; import LogError from '@shared/log-error.model'; import { ExtensionManifest } from '@extension-host/extension-types/extension-manifest.model'; -import { menuDocumentCombiner } from '@extension-host/services/menu-data.service-host'; -import menuDataService from '@shared/services/menu-data.service'; -import { localizedStringsDocumentCombiner } from '@extension-host/services/localization.service-host'; -import { settingsDocumentCombiner } from '@extension-host/services/settings.service-host'; import { PLATFORM_NAMESPACE } from '@shared/data/platform.data'; -import { projectSettingsDocumentCombiner } from '@extension-host/services/project-settings.service-host'; import { ElevatedPrivilegeNames, ElevatedPrivileges, @@ -51,6 +45,7 @@ import { 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'; /** * The way to use `require` directly - provided by webpack because they overwrite normal `require`. @@ -1035,119 +1030,6 @@ async function deactivateExtensions(extensions: ExtensionInfo[]): Promise } } -async function resyncContributions( - extensionsToAdd: Readonly[], -) { - menuDocumentCombiner.deleteAllContributions(); - settingsDocumentCombiner.deleteAllContributions(); - projectSettingsDocumentCombiner.deleteAllContributions(); - localizedStringsDocumentCombiner.deleteAllContributions(); - - // Load up all the extension contributions asynchronously - const extensionsContributions = await Promise.all( - extensionsToAdd.map(async (extension) => { - let localizedStringsDocument: JsonDocumentLike | undefined; - if (extension.localizedStrings) { - try { - // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir - const localizedStringsJson = await nodeFS.readFileText( - joinUriPaths(extension.dirUri, extension.localizedStrings), - ); - localizedStringsDocument = JSON.parse(localizedStringsJson); - } catch (error) { - logger.warn( - `Could not load localized strings contribution for ${extension.name}: ${error}`, - ); - } - } - let menuDocument: JsonDocumentLike | undefined; - if (extension.menus) { - try { - // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir - const menuJson = await nodeFS.readFileText( - joinUriPaths(extension.dirUri, extension.menus), - ); - menuDocument = JSON.parse(menuJson); - } catch (error) { - logger.warn(`Could not load menu contribution for ${extension.name}: ${error}`); - } - } - let settingsDocument: JsonDocumentLike | undefined; - if (extension.settings) { - try { - // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir - const settingsJson = await nodeFS.readFileText( - joinUriPaths(extension.dirUri, extension.settings), - ); - settingsDocument = JSON.parse(settingsJson); - } catch (error) { - logger.warn(`Could not load settings contribution for ${extension.name}: ${error}`); - } - } - let projectSettingsDocument: JsonDocumentLike | undefined; - if (extension.projectSettings) { - try { - // TODO: Provide a way to make sure extensions don't tell us to read files outside their dir - const projectSettingsJson = await nodeFS.readFileText( - joinUriPaths(extension.dirUri, extension.projectSettings), - ); - projectSettingsDocument = JSON.parse(projectSettingsJson); - } catch (error) { - logger.warn( - `Could not load project settings contribution for ${extension.name}: ${error}`, - ); - } - } - - return { - name: extension.name, - localizedStringsDocument, - menuDocument, - settingsDocument, - projectSettingsDocument, - }; - }), - ); - - // Load contributions in the order in which the extensions are loaded - extensionsContributions.forEach( - ({ - name, - localizedStringsDocument, - menuDocument, - settingsDocument, - projectSettingsDocument, - }) => { - if (localizedStringsDocument) - try { - localizedStringsDocumentCombiner.addOrUpdateContribution(name, localizedStringsDocument); - } catch (error) { - logger.warn(`Could not add localized strings contribution for ${name}: ${error}`); - } - if (menuDocument) - try { - menuDocumentCombiner.addOrUpdateContribution(name, menuDocument); - } catch (error) { - logger.warn(`Could not add menu contribution for ${name}: ${error}`); - } - if (settingsDocument) - try { - settingsDocumentCombiner.addOrUpdateContribution(name, settingsDocument); - } catch (error) { - logger.warn(`Could not add settings contribution for ${name}: ${error}`); - } - if (projectSettingsDocument) - try { - projectSettingsDocumentCombiner.addOrUpdateContribution(name, projectSettingsDocument); - } catch (error) { - logger.warn(`Could not add project settings contribution for ${name}: ${error}`); - } - }, - ); - - await menuDataService.rebuildMenus(); -} - async function reloadExtensions( shouldDeactivateExtensions: boolean, shouldEmitDidReloadEvent: boolean, diff --git a/src/extension-host/services/localization.service-host.test.ts b/src/extension-host/services/localization.service-host.test.ts index abc0522fa0..dea8b61f27 100644 --- a/src/extension-host/services/localization.service-host.test.ts +++ b/src/extension-host/services/localization.service-host.test.ts @@ -58,6 +58,11 @@ jest.mock('@shared/services/logger.service', () => ({ warn: jest.fn(() => {}), }, })); +jest.mock('@extension-host/services/contribution.service', () => ({ + ...jest.requireActual('@extension-host/services/contribution.service'), + // Don't actually wait because we're not syncing any contributions in these tests + waitForResyncContributions: async () => {}, +})); let localizationDataProviderEngine: Awaited< ReturnType diff --git a/src/extension-host/services/localization.service-host.ts b/src/extension-host/services/localization.service-host.ts index fbd66d9ae7..41becdc62e 100644 --- a/src/extension-host/services/localization.service-host.ts +++ b/src/extension-host/services/localization.service-host.ts @@ -23,24 +23,16 @@ import logger from '@shared/services/logger.service'; import { joinUriPaths } from '@node/utils/util'; import path from 'path'; import settingsService from '@shared/services/settings.service'; -import LocalizedStringsDocumentCombiner from '@shared/utils/localized-strings-document-combiner'; +import { + localizedStringsDocumentCombiner, + waitForResyncContributions, +} from '@extension-host/services/contribution.service'; const LOCALIZATION_ROOT_URI = joinUriPaths('resources://', 'assets', 'localization'); // BCP 47 validation regex from https://stackoverflow.com/questions/7035825/regular-expression-for-a-language-tag-as-defined-by-bcp47 const LANGUAGE_CODE_REGEX = /^(?(?:en-GB-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|t(?:a[oy]|su))|sgn-(?:BE-(?:FR|NL)|CH-DE))|(?:art-lojban|cel-gaulish|no-(?:bok|nyn)|zh-(?:guoyu|hakka|min(?:-nan)?|xiang)))|(?:(?(?:[A-Za-z]{2,3}(?:-(?[A-Za-z]{3}(?:-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-(?