From 4a231c64505ade4e393aecc193863dfe6585fa6d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 6 Feb 2025 23:54:18 +0000 Subject: [PATCH 1/2] Initial support for runtime modules (#29104) * Initial runtime Modules work Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Comments Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- docs/config.md | 1 + jest.config.ts | 2 + package.json | 1 + playwright/e2e/modules/loader.spec.ts | 35 +++++++++++ playwright/sample-files/example-module.js | 16 +++++ src/@types/global.d.ts | 4 ++ src/IConfigOptions.ts | 2 + src/customisations/Alias.ts | 17 +---- src/customisations/ChatExport.ts | 26 ++------ src/customisations/ComponentVisibility.ts | 24 +------- src/customisations/Directory.ts | 15 +---- src/customisations/Lifecycle.ts | 14 +---- src/customisations/Media.ts | 26 +++++--- src/customisations/RoomList.ts | 27 +------- src/customisations/UserIdentifier.ts | 11 +--- src/customisations/WidgetPermissions.ts | 29 +-------- src/customisations/WidgetVariables.ts | 37 +---------- src/modules/Api.ts | 75 +++++++++++++++++++++++ src/vector/app.tsx | 4 -- src/vector/index.ts | 6 +- src/vector/init.tsx | 23 +++++++ yarn.lock | 5 ++ 22 files changed, 209 insertions(+), 191 deletions(-) create mode 100644 playwright/e2e/modules/loader.spec.ts create mode 100644 playwright/sample-files/example-module.js create mode 100644 src/modules/Api.ts diff --git a/docs/config.md b/docs/config.md index 8ca4ba4eb8..6376b55f82 100644 --- a/docs/config.md +++ b/docs/config.md @@ -592,3 +592,4 @@ The following are undocumented or intended for developer use only. 2. `sync_timeline_limit` 3. `dangerously_allow_unsafe_and_insecure_passwords` 4. `latex_maths_delims`: An optional setting to override the default delimiters used for maths parsing. See https://github.com/matrix-org/matrix-react-sdk/pull/5939 for details. Only used when `feature_latex_maths` is enabled. +5. `modules`: An optional list of modules to load. This is used for testing and development purposes only. diff --git a/jest.config.ts b/jest.config.ts index b70b21bc97..ad31f2fecc 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -38,6 +38,8 @@ const config: Config = { "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", "^fetch-mock$": "/node_modules/fetch-mock", + // Requires ESM which is incompatible with our current Jest setup + "^@element-hq/element-web-module-api$": "/__mocks__/empty.js", }, transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"], collectCoverageFrom: [ diff --git a/package.json b/package.json index fa6755b79c..8558639470 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", + "@element-hq/element-web-module-api": "^0.1.1", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", diff --git a/playwright/e2e/modules/loader.spec.ts b/playwright/e2e/modules/loader.spec.ts new file mode 100644 index 0000000000..e21b5c2d92 --- /dev/null +++ b/playwright/e2e/modules/loader.spec.ts @@ -0,0 +1,35 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Module loading", () => { + test.use({ + displayName: "Manny", + }); + + test.describe("Example Module", () => { + test.use({ + config: { + modules: ["/modules/example-module.js"], + }, + page: async ({ page }, use) => { + await page.route("/modules/example-module.js", async (route) => { + await route.fulfill({ path: "playwright/sample-files/example-module.js" }); + }); + await use(page); + }, + }); + + test("should show alert", async ({ page }) => { + const dialogPromise = page.waitForEvent("dialog"); + await page.goto("/"); + const dialog = await dialogPromise; + expect(dialog.message()).toBe("Testing module loading successful!"); + }); + }); +}); diff --git a/playwright/sample-files/example-module.js b/playwright/sample-files/example-module.js new file mode 100644 index 0000000000..cb9b80a93b --- /dev/null +++ b/playwright/sample-files/example-module.js @@ -0,0 +1,16 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +export default class ExampleModule { + static moduleApiVersion = "^0.1.0"; + constructor(api) { + this.api = api; + } + async load() { + alert("Testing module loading successful!"); + } +} diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 0829a8d47e..1df84ad344 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import "@types/modernizr"; +import type { ModuleLoader } from "@element-hq/element-web-module-api"; import type { logger } from "matrix-js-sdk/src/logger"; import type ContentMessages from "../ContentMessages"; import { type IMatrixClientPeg } from "../MatrixClientPeg"; @@ -45,6 +46,7 @@ import { type MatrixDispatcher } from "../dispatcher/dispatcher"; import { type DeepReadonly } from "./common"; import type MatrixChat from "../components/structures/MatrixChat"; import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; +import { type ModuleApiType } from "../modules/Api.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -122,6 +124,8 @@ declare global { mxRoomScrollStateStore?: RoomScrollStateStore; mxActiveWidgetStore?: ActiveWidgetStore; mxOnRecaptchaLoaded?: () => void; + mxModuleLoader: ModuleLoader; + mxModuleApi: ModuleApiType; // electron-only electron?: Electron; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 33ebba98e3..2eebd9c760 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -206,6 +206,8 @@ export interface IConfigOptions { policy_uri?: string; contacts?: string[]; }; + + modules?: string[]; } export interface ISsoRedirectOptions { diff --git a/src/customisations/Alias.ts b/src/customisations/Alias.ts index 6e5c60be58..742de9cd45 100644 --- a/src/customisations/Alias.ts +++ b/src/customisations/Alias.ts @@ -6,19 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getDisplayAliasForAliasSet(canonicalAlias: string | null, altAliases: string[]): string | null { - // E.g. prefer one of the aliases over another - return null; -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IAliasCustomisations { - getDisplayAliasForAliasSet?: typeof getDisplayAliasForAliasSet; -} +import type { AliasCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the -// customisation points that make up `IAliasCustomisations`. -export default {} as IAliasCustomisations; +// customisation points that make up `AliasCustomisations`. +export default {} as AliasCustomisations; diff --git a/src/customisations/ChatExport.ts b/src/customisations/ChatExport.ts index edb634bb96..0b5c73a92c 100644 --- a/src/customisations/ChatExport.ts +++ b/src/customisations/ChatExport.ts @@ -6,20 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { type ChatExportCustomisations } from "@element-hq/element-web-module-api"; + import { type ExportFormat, type ExportType } from "../utils/exportUtils/exportUtils"; -export type ForceChatExportParameters = { - format?: ExportFormat; - range?: ExportType; - // must be < 10**8 - // only used when range is 'LastNMessages' - // default is 100 - numberOfMessages?: number; - includeAttachments?: boolean; - // maximum size of exported archive - // must be > 0 and < 8000 - sizeMb?: number; -}; +export type ForceChatExportParameters = ReturnType< + ChatExportCustomisations["getForceChatExportParameters"] +>; /** * Force parameters in room chat export @@ -30,15 +23,8 @@ const getForceChatExportParameters = (): ForceChatExportParameters => { return {}; }; -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IChatExportCustomisations { - getForceChatExportParameters: typeof getForceChatExportParameters; -} - // A real customisation module will define and export one or more of the // customisation points that make up `IChatExportCustomisations`. export default { getForceChatExportParameters, -} as IChatExportCustomisations; +} as ChatExportCustomisations; diff --git a/src/customisations/ComponentVisibility.ts b/src/customisations/ComponentVisibility.ts index 19a3192894..210863f0e3 100644 --- a/src/customisations/ComponentVisibility.ts +++ b/src/customisations/ComponentVisibility.ts @@ -12,29 +12,7 @@ Please see LICENSE files in the repository root for full details. // Populate this class with the details of your customisations when copying it. -import { type UIComponent } from "../settings/UIFeature"; - -/** - * Determines whether or not the active MatrixClient user should be able to use - * the given UI component. If shown, the user might still not be able to use the - * component depending on their contextual permissions. For example, invite options - * might be shown to the user but they won't have permission to invite users to - * the current room: the button will appear disabled. - * @param {UIComponent} component The component to check visibility for. - * @returns {boolean} True (default) if the user is able to see the component, false - * otherwise. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function shouldShowComponent(component: UIComponent): boolean { - return true; // default to visible -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IComponentVisibilityCustomisations { - shouldShowComponent?: typeof shouldShowComponent; -} +import { type ComponentVisibilityCustomisations as IComponentVisibilityCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up the interface above. diff --git a/src/customisations/Directory.ts b/src/customisations/Directory.ts index c553680125..a85283774f 100644 --- a/src/customisations/Directory.ts +++ b/src/customisations/Directory.ts @@ -6,19 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function requireCanonicalAliasAccessToPublish(): boolean { - // Some environments may not care about this requirement and could return false - return true; -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IDirectoryCustomisations { - requireCanonicalAliasAccessToPublish?: typeof requireCanonicalAliasAccessToPublish; -} +import type { DirectoryCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up `IDirectoryCustomisations`. -export default {} as IDirectoryCustomisations; +export default {} as DirectoryCustomisations; diff --git a/src/customisations/Lifecycle.ts b/src/customisations/Lifecycle.ts index 2873093414..e8e3cca38a 100644 --- a/src/customisations/Lifecycle.ts +++ b/src/customisations/Lifecycle.ts @@ -6,18 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function onLoggedOutAndStorageCleared(): void { - // E.g. redirect user or call other APIs after logout -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface ILifecycleCustomisations { - onLoggedOutAndStorageCleared?: typeof onLoggedOutAndStorageCleared; -} +import type { LifecycleCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up `ILifecycleCustomisations`. -export default {} as ILifecycleCustomisations; +export default {} as LifecycleCustomisations; diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index ee963ce822..1763ade3a8 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -10,6 +10,7 @@ import { type MatrixClient, parseErrorResponse, type ResizeMethod } from "matrix import { type MediaEventContent } from "matrix-js-sdk/src/types"; import { type Optional } from "matrix-events-sdk"; +import type { MediaCustomisations, Media } from "@element-hq/element-web-module-api"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { type IPreparedMedia, prepEventContentAsMedia } from "./models/IMediaEventContent"; import { UserFriendlyError } from "../languageHandler"; @@ -25,7 +26,7 @@ import { UserFriendlyError } from "../languageHandler"; * A media object is a representation of a "source media" and an optional * "thumbnail media", derived from event contents or external sources. */ -export class Media { +class MediaImplementation implements Media { private client: MatrixClient; // Per above, this constructor signature can be whatever is helpful for you. @@ -149,22 +150,27 @@ export class Media { } } +export type { Media }; + +type BaseMedia = MediaCustomisations, MatrixClient, IPreparedMedia>; + /** * Creates a media object from event content. * @param {MediaEventContent} content The event content. - * @param {MatrixClient} client? Optional client to use. - * @returns {Media} The media object. + * @param {MatrixClient} client Optional client to use. + * @returns {MediaImplementation} The media object. */ -export function mediaFromContent(content: Partial, client?: MatrixClient): Media { - return new Media(prepEventContentAsMedia(content), client); -} +export const mediaFromContent: BaseMedia["mediaFromContent"] = ( + content: Partial, + client?: MatrixClient, +): Media => new MediaImplementation(prepEventContentAsMedia(content), client); /** * Creates a media object from an MXC URI. * @param {string} mxc The MXC URI. - * @param {MatrixClient} client? Optional client to use. - * @returns {Media} The media object. + * @param {MatrixClient} client Optional client to use. + * @returns {MediaImplementation} The media object. */ -export function mediaFromMxc(mxc?: string, client?: MatrixClient): Media { +export const mediaFromMxc: BaseMedia["mediaFromMxc"] = (mxc?: string, client?: MatrixClient): Media => { return mediaFromContent({ url: mxc }, client); -} +}; diff --git a/src/customisations/RoomList.ts b/src/customisations/RoomList.ts index 077056936e..1460d9a7b2 100644 --- a/src/customisations/RoomList.ts +++ b/src/customisations/RoomList.ts @@ -8,31 +8,10 @@ import { type Room } from "matrix-js-sdk/src/matrix"; +import type { RoomListCustomisations as IRoomListCustomisations } from "@element-hq/element-web-module-api"; + // Populate this file with the details of your customisations when copying it. -/** - * Determines if a room is visible in the room list or not. By default, - * all rooms are visible. Where special handling is performed by Element, - * those rooms will not be able to override their visibility in the room - * list - Element will make the decision without calling this function. - * - * This function should be as fast as possible to avoid slowing down the - * client. - * @param {Room} room The room to check the visibility of. - * @returns {boolean} True if the room should be visible, false otherwise. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function isRoomVisible(room: Room): boolean { - return true; -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IRoomListCustomisations { - isRoomVisible?: typeof isRoomVisible; -} - // A real customisation module will define and export one or more of the // customisation points that make up the interface above. -export const RoomListCustomisations: IRoomListCustomisations = {}; +export const RoomListCustomisations: IRoomListCustomisations = {}; diff --git a/src/customisations/UserIdentifier.ts b/src/customisations/UserIdentifier.ts index cc36a1d8c7..9c96a80fad 100644 --- a/src/customisations/UserIdentifier.ts +++ b/src/customisations/UserIdentifier.ts @@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import type { UserIdentifierCustomisations } from "@element-hq/element-web-module-api"; + /** * Customise display of the user identifier * hide userId for guests, display 3pid @@ -19,15 +21,8 @@ function getDisplayUserIdentifier( return userId; } -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IUserIdentifierCustomisations { - getDisplayUserIdentifier: typeof getDisplayUserIdentifier; -} - // A real customisation module will define and export one or more of the // customisation points that make up `IUserIdentifierCustomisations`. export default { getDisplayUserIdentifier, -} as IUserIdentifierCustomisations; +} as UserIdentifierCustomisations; diff --git a/src/customisations/WidgetPermissions.ts b/src/customisations/WidgetPermissions.ts index 4f2e6c5308..785ca46c2e 100644 --- a/src/customisations/WidgetPermissions.ts +++ b/src/customisations/WidgetPermissions.ts @@ -9,33 +9,8 @@ // Populate this class with the details of your customisations when copying it. import { type Capability, type Widget } from "matrix-widget-api"; -/** - * Approves the widget for capabilities that it requested, if any can be - * approved. Typically this will be used to give certain widgets capabilities - * without having to prompt the user to approve them. This cannot reject - * capabilities that Element will be automatically granting, such as the - * ability for Jitsi widgets to stay on screen - those will be approved - * regardless. - * @param {Widget} widget The widget to approve capabilities for. - * @param {Set} requestedCapabilities The capabilities the widget requested. - * @returns {Set} Resolves to the capabilities that are approved for use - * by the widget. If none are approved, this should return an empty Set. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function preapproveCapabilities( - widget: Widget, - requestedCapabilities: Set, -): Promise> { - return new Set(); // no additional capabilities approved -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IWidgetPermissionCustomisations { - preapproveCapabilities?: typeof preapproveCapabilities; -} +import type { WidgetPermissionsCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up the interface above. -export const WidgetPermissionCustomisations: IWidgetPermissionCustomisations = {}; +export const WidgetPermissionCustomisations: WidgetPermissionsCustomisations = {}; diff --git a/src/customisations/WidgetVariables.ts b/src/customisations/WidgetVariables.ts index 2ac1a6c13c..135dfa36d0 100644 --- a/src/customisations/WidgetVariables.ts +++ b/src/customisations/WidgetVariables.ts @@ -7,41 +7,8 @@ */ // Populate this class with the details of your customisations when copying it. -import { type ITemplateParams } from "matrix-widget-api"; - -/** - * Provides a partial set of the variables needed to render any widget. If - * variables are missing or not provided then they will be filled with the - * application-determined defaults. - * - * This will not be called until after isReady() resolves. - * @returns {Partial>} The variables. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function provideVariables(): Partial> { - return {}; -} - -/** - * Resolves to whether or not the customisation point is ready for variables - * to be provided. This will block widgets being rendered. - * @returns {Promise} Resolves when ready. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function isReady(): Promise { - return; // default no waiting -} - -// This interface summarises all available customisation points and also marks -// them all as optional. This allows customisers to only define and export the -// customisations they need while still maintaining type safety. -export interface IWidgetVariablesCustomisations { - provideVariables?: typeof provideVariables; - - // If not provided, the app will assume that the customisation is always ready. - isReady?: typeof isReady; -} +import { type WidgetVariablesCustomisations } from "@element-hq/element-web-module-api"; // A real customisation module will define and export one or more of the // customisation points that make up the interface above. -export const WidgetVariableCustomisations: IWidgetVariablesCustomisations = {}; +export const WidgetVariableCustomisations: WidgetVariablesCustomisations = {}; diff --git a/src/modules/Api.ts b/src/modules/Api.ts new file mode 100644 index 0000000000..ad87088840 --- /dev/null +++ b/src/modules/Api.ts @@ -0,0 +1,75 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Api, RuntimeModuleConstructor, Config } from "@element-hq/element-web-module-api"; +import { ModuleRunner } from "./ModuleRunner.ts"; +import AliasCustomisations from "../customisations/Alias.ts"; +import { RoomListCustomisations } from "../customisations/RoomList.ts"; +import ChatExportCustomisations from "../customisations/ChatExport.ts"; +import { ComponentVisibilityCustomisations } from "../customisations/ComponentVisibility.ts"; +import DirectoryCustomisations from "../customisations/Directory.ts"; +import LifecycleCustomisations from "../customisations/Lifecycle.ts"; +import * as MediaCustomisations from "../customisations/Media.ts"; +import UserIdentifierCustomisations from "../customisations/UserIdentifier.ts"; +import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissions.ts"; +import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts"; +import SdkConfig from "../SdkConfig.ts"; + +const legacyCustomisationsFactory = (baseCustomisations: T) => { + let used = false; + return (customisations: T) => { + if (used) throw new Error("Legacy customisations can only be registered by one module"); + Object.assign(baseCustomisations, customisations); + used = true; + }; +}; + +class ConfigApi { + public get(): Config; + public get(key: K): Config[K]; + public get(key?: K): Config | Config[K] { + if (key === undefined) { + return SdkConfig.get() as Config; + } + return SdkConfig.get(key); + } +} + +/** + * Implementation of the @element-hq/element-web-module-api runtime module API. + */ +class ModuleApi implements Api { + /* eslint-disable @typescript-eslint/naming-convention */ + public async _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise { + ModuleRunner.instance.registerModule((api) => new LegacyModule(api)); + } + public readonly _registerLegacyAliasCustomisations = legacyCustomisationsFactory(AliasCustomisations); + public readonly _registerLegacyChatExportCustomisations = legacyCustomisationsFactory(ChatExportCustomisations); + public readonly _registerLegacyComponentVisibilityCustomisations = legacyCustomisationsFactory( + ComponentVisibilityCustomisations, + ); + public readonly _registerLegacyDirectoryCustomisations = legacyCustomisationsFactory(DirectoryCustomisations); + public readonly _registerLegacyLifecycleCustomisations = legacyCustomisationsFactory(LifecycleCustomisations); + public readonly _registerLegacyMediaCustomisations = legacyCustomisationsFactory(MediaCustomisations); + public readonly _registerLegacyRoomListCustomisations = legacyCustomisationsFactory(RoomListCustomisations); + public readonly _registerLegacyUserIdentifierCustomisations = + legacyCustomisationsFactory(UserIdentifierCustomisations); + public readonly _registerLegacyWidgetPermissionsCustomisations = + legacyCustomisationsFactory(WidgetPermissionCustomisations); + public readonly _registerLegacyWidgetVariablesCustomisations = + legacyCustomisationsFactory(WidgetVariableCustomisations); + /* eslint-enable @typescript-eslint/naming-convention */ + + public readonly config = new ConfigApi(); +} + +export type ModuleApiType = ModuleApi; + +if (!window.mxModuleApi) { + window.mxModuleApi = new ModuleApi(); +} +export default window.mxModuleApi; diff --git a/src/vector/app.tsx b/src/vector/app.tsx index c16c9a8b75..d0c689a2b4 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -31,10 +31,6 @@ import { parseQs } from "./url_utils"; import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing"; import { UserFriendlyError } from "../languageHandler"; -// add React and ReactPerf to the global namespace, to make them easier to access via the console -// this incidentally means we can forget our React imports in JSX files without penalty. -window.React = React; - logger.log(`Application is running in ${process.env.NODE_ENV} mode`); window.matrixLogger = logger; diff --git a/src/vector/index.ts b/src/vector/index.ts index c398c0b788..af557c241b 100644 --- a/src/vector/index.ts +++ b/src/vector/index.ts @@ -114,6 +114,7 @@ async function start(): Promise { loadTheme, loadApp, loadModules, + loadPlugins, showError, showIncompatibleBrowser, _t, @@ -159,10 +160,12 @@ async function start(): Promise { // now that the config is ready, try to persist logs const persistLogsPromise = setupLogStorage(); - // Load modules before language to ensure any custom translations are respected, and any app + // Load modules & plugins before language to ensure any custom translations are respected, and any app // startup functionality is run const loadModulesPromise = loadModules(); await settled(loadModulesPromise); + const loadPluginsPromise = loadPlugins(); + await settled(loadPluginsPromise); // Load language after loading config.json so that settingsDefaults.language can be applied const loadLanguagePromise = loadLanguage(); @@ -215,6 +218,7 @@ async function start(): Promise { // app load critical path starts here // assert things started successfully // ################################## + await loadPluginsPromise; await loadModulesPromise; await loadThemePromise; await loadLanguagePromise; diff --git a/src/vector/init.tsx b/src/vector/init.tsx index 82c2e1647d..1169a6df28 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details. import { createRoot } from "react-dom/client"; import React, { StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { ModuleLoader } from "@element-hq/element-web-module-api"; import type { QueryDict } from "matrix-js-sdk/src/utils"; import * as languageHandler from "../languageHandler"; @@ -24,6 +25,7 @@ import ElectronPlatform from "./platform/ElectronPlatform"; import PWAPlatform from "./platform/PWAPlatform"; import WebPlatform from "./platform/WebPlatform"; import { initRageshake, initRageshakeStore } from "./rageshakesetup"; +import ModuleApi from "../modules/Api.ts"; export const rageshakePromise = initRageshake(); @@ -125,6 +127,9 @@ export async function showIncompatibleBrowser(onAccept: () => void): Promise { const { INSTALLED_MODULES } = await import("../modules.js"); for (const InstalledModule of INSTALLED_MODULES) { @@ -132,6 +137,24 @@ export async function loadModules(): Promise { } } +export async function loadPlugins(): Promise { + // Add React to the global namespace, this is part of the new Module API contract to avoid needing + // every single module to ship its own copy of React. This also makes it easier to access via the console + // and incidentally means we can forget our React imports in JSX files without penalty. + window.React = React; + + const modules = SdkConfig.get("modules"); + if (!modules?.length) return; + const moduleLoader = new ModuleLoader(ModuleApi); + window.mxModuleLoader = moduleLoader; + for (const src of modules) { + // We need to instruct webpack to not mangle this import as it is not available at compile time + const module = await import(/* webpackIgnore: true */ src); + await moduleLoader.load(module); + } + await moduleLoader.start(); +} + export { _t } from "../languageHandler"; export { extractErrorMessageFromError } from "../components/views/dialogs/ErrorDialog"; diff --git a/yarn.lock b/yarn.lock index 6df92c61c9..48d61b7d14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1522,6 +1522,11 @@ resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz#519c1549b0e147759e7825701ecffd25e5819f7b" integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg== +"@element-hq/element-web-module-api@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-0.1.1.tgz#e2b24aa38aa9f7b6af3c4993e6402a8b7e2f3cb5" + integrity sha512-qtEQD5nFaRJ+vfAis7uhKB66SyCjrz7O+qGz/hKJjgNhBLT/6C5DK90waKINXSw0J3stFR43IWzEk5GBOrTMow== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" From 7b3ce5d9b25e2bc25bb189c52ff2243bea0adea5 Mon Sep 17 00:00:00 2001 From: Florian D Date: Fri, 7 Feb 2025 10:39:36 +0100 Subject: [PATCH 2/2] e2e test: by default the bot should not use a passphrase to create the recovery key (#29214) * test(crypto): by default do not use a passphrase to create the recovery key * test(crypto): update tests --- playwright/e2e/crypto/device-verification.spec.ts | 2 +- playwright/e2e/crypto/toasts.spec.ts | 1 - playwright/e2e/crypto/utils.ts | 3 +++ .../encryption-user-tab/encryption-tab.spec.ts | 4 ++-- playwright/e2e/settings/encryption-user-tab/index.ts | 5 +---- .../e2e/settings/encryption-user-tab/recovery.spec.ts | 9 ++++++--- playwright/pages/bot.ts | 11 ++++++++--- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index 551f9302a9..7757d813eb 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -29,7 +29,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { let expectedBackupVersion: string; test.beforeEach(async ({ page, homeserver, credentials }) => { - const res = await createBot(page, homeserver, credentials); + const res = await createBot(page, homeserver, credentials, true); aliceBotClient = res.botClient; expectedBackupVersion = res.expectedBackupVersion; }); diff --git a/playwright/e2e/crypto/toasts.spec.ts b/playwright/e2e/crypto/toasts.spec.ts index 76d9ebea7a..8b19b6ea61 100644 --- a/playwright/e2e/crypto/toasts.spec.ts +++ b/playwright/e2e/crypto/toasts.spec.ts @@ -34,7 +34,6 @@ test.describe("Key storage out of sync toast", () => { await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png"); await page.getByRole("button", { name: "Enter recovery key" }).click(); - await page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }).click(); await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey); await page.getByRole("button", { name: "Continue" }).click(); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index ccdb320b94..8d54a32fb2 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -28,11 +28,13 @@ import { Bot } from "../../pages/bot"; * @param page - the playwright `page` fixture * @param homeserver - the homeserver to use * @param credentials - the credentials to use for the bot client + * @param usePassphrase - whether to use a passphrase when creating the recovery key */ export async function createBot( page: Page, homeserver: HomeserverInstance, credentials: Credentials, + usePassphrase = false, ): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> { // Visit the login page of the app, to load the matrix sdk await page.goto("/#/login"); @@ -44,6 +46,7 @@ export async function createBot( const botClient = new Bot(page, homeserver, { bootstrapCrossSigning: true, bootstrapSecretStorage: true, + usePassphrase, }); botClient.setCredentials(credentials); // Backup is prepared in the background. Poll until it is ready. diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index a279961814..107f8085cc 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -67,7 +67,7 @@ test.describe("Encryption tab", () => { "should prompt to enter the recovery key when the secrets are not cached locally", { tag: "@screenshot" }, async ({ page, app, util }) => { - await verifySession(app, "new passphrase"); + await verifySession(app, recoveryKey.encodedPrivateKey); // We need to delete the cached secrets await deleteCachedSecrets(page); @@ -99,7 +99,7 @@ test.describe("Encryption tab", () => { app, util, }) => { - await verifySession(app, "new passphrase"); + await verifySession(app, recoveryKey.encodedPrivateKey); // We need to delete the cached secrets await deleteCachedSecrets(page); diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts index 1a83097e55..22dcd9041b 100644 --- a/playwright/e2e/settings/encryption-user-tab/index.ts +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -43,7 +43,7 @@ class Helpers { */ async verifyDevice(recoveryKey: GeneratedSecretStorageKey) { // Select the security phrase - await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click(); + await this.page.getByRole("button", { name: "Verify with Security Key" }).click(); await this.enterRecoveryKey(recoveryKey); await this.page.getByRole("button", { name: "Done" }).click(); } @@ -53,9 +53,6 @@ class Helpers { * @param recoveryKey */ async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) { - // Select to use recovery key - await this.page.getByRole("button", { name: "use your Security Key" }).click(); - // Fill the recovery key const dialog = this.page.locator(".mx_Dialog"); await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 8bb16f018b..8895e4a7ee 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -7,22 +7,25 @@ import { test, expect } from "."; import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils"; +import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; test.describe("Recovery section in Encryption tab", () => { test.use({ displayName: "Alice", }); + let recoveryKey: GeneratedSecretStorageKey; test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - await createBot(page, homeserver, credentials); + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; }); test( "should change the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, homeserver, credentials, util, context }) => { - await verifySession(app, "new passphrase"); + await verifySession(app, recoveryKey.encodedPrivateKey); const dialog = await util.openEncryptionTab(); // The user can only change the recovery key @@ -49,7 +52,7 @@ test.describe("Recovery section in Encryption tab", () => { ); test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => { - await verifySession(app, "new passphrase"); + await verifySession(app, recoveryKey.encodedPrivateKey); await util.removeSecretStorageDefaultKeyId(); // The key backup is deleted and the user needs to set it up diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index ef6fc3861d..05a8948a65 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -41,6 +41,10 @@ export interface CreateBotOpts { * Whether to bootstrap the secret storage */ bootstrapSecretStorage?: boolean; + /** + * Whether to use a passphrase when creating the recovery key + */ + usePassphrase?: boolean; } const defaultCreateBotOptions = { @@ -48,6 +52,7 @@ const defaultCreateBotOptions = { autoAcceptInvites: true, startClient: true, bootstrapCrossSigning: true, + usePassphrase: false, } satisfies CreateBotOpts; type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; @@ -206,8 +211,8 @@ export class Bot extends Client { } if (this.opts.bootstrapSecretStorage) { - await clientHandle.evaluate(async (cli) => { - const passphrase = "new passphrase"; + await clientHandle.evaluate(async (cli, usePassphrase) => { + const passphrase = usePassphrase ? "new passphrase" : undefined; const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase); Object.assign(cli, { __playwright_recovery_key: recoveryKey }); @@ -216,7 +221,7 @@ export class Bot extends Client { setupNewKeyBackup: true, createSecretStorageKey: () => Promise.resolve(recoveryKey), }); - }); + }, this.opts.usePassphrase); } return clientHandle;