From 395ed3f5d464355d448204d944eee51094bdc28c Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 19 Apr 2024 14:02:40 -0500 Subject: [PATCH] [PM-7489] Introduce `MessageSender` & `MessageListener` (#8709) * Introduce MessageSender * Update `messageSenderFactory` * Remove Comment * Use BrowserApi * Update Comment * Rename to CommandDefinition * Add More Documentation to MessageSender * Add `EMPTY` helpers and remove NoopMessageSender * Calm Down Logging * Limit Logging On Known Errors * Use `messageStream` Parameter Co-authored-by: Matt Gibson * Add eslint rules * Update Error Handling Co-authored-by: Cesar Gonzalez * Delete Lazy Classes In Favor of Observable Factories * Remove Fido Messages --------- Co-authored-by: Matt Gibson Co-authored-by: Cesar Gonzalez --- .eslintrc.json | 16 ++ .../browser/src/background/main.background.ts | 54 +++--- .../background/nativeMessaging.background.ts | 2 +- .../src/background/runtime.background.ts | 156 ++++++++++-------- .../message-sender.factory.ts | 17 ++ .../messaging-service.factory.ts | 24 +-- .../messaging/chrome-message.sender.ts | 37 +++++ ...ssaging-private-mode-background.service.ts | 8 - ...er-messaging-private-mode-popup.service.ts | 8 - .../services/browser-messaging.service.ts | 9 - .../utils/from-chrome-runtime-messaging.ts | 26 +++ apps/browser/src/popup/app.component.ts | 145 ++++++++-------- .../src/popup/services/services.module.ts | 81 +++++++-- apps/cli/src/bw.ts | 10 +- .../src/app/services/services.module.ts | 30 +++- apps/desktop/src/main.ts | 18 +- apps/desktop/src/main/power-monitor.main.ts | 5 +- apps/desktop/src/platform/preload.ts | 21 ++- .../electron-renderer-message.sender.ts | 12 ++ .../electron-renderer-messaging.service.ts | 20 --- .../src/platform/utils/from-ipc-messaging.ts | 15 ++ .../electron-main-messaging.service.ts | 17 +- .../trial-billing-step.component.ts | 3 +- .../organization-plans.component.ts | 3 +- .../app/core/broadcaster-messaging.service.ts | 14 -- apps/web/src/app/core/core.module.ts | 7 - .../vault/components/premium-badge.stories.ts | 10 +- .../platform/services/broadcaster.service.ts | 6 - libs/angular/src/services/injection-tokens.ts | 6 +- .../src/services/jslib-services.module.ts | 30 +++- .../abstractions/messaging.service.ts | 6 +- .../src/platform/messaging/helpers.spec.ts | 46 ++++++ libs/common/src/platform/messaging/helpers.ts | 23 +++ libs/common/src/platform/messaging/index.ts | 4 + .../common/src/platform/messaging/internal.ts | 5 + .../messaging/message.listener.spec.ts | 47 ++++++ .../platform/messaging/message.listener.ts | 41 +++++ .../src/platform/messaging/message.sender.ts | 62 +++++++ .../messaging/subject-message.sender.spec.ts | 65 ++++++++ .../messaging/subject-message.sender.ts | 17 ++ libs/common/src/platform/messaging/types.ts | 13 ++ .../platform/services/broadcaster.service.ts | 34 ---- .../services/default-broadcaster.service.ts | 36 ++++ .../services/noop-messaging.service.ts | 7 - 44 files changed, 855 insertions(+), 361 deletions(-) create mode 100644 apps/browser/src/platform/background/service-factories/message-sender.factory.ts create mode 100644 apps/browser/src/platform/messaging/chrome-message.sender.ts delete mode 100644 apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts delete mode 100644 apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts delete mode 100644 apps/browser/src/platform/services/browser-messaging.service.ts create mode 100644 apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts create mode 100644 apps/desktop/src/platform/services/electron-renderer-message.sender.ts delete mode 100644 apps/desktop/src/platform/services/electron-renderer-messaging.service.ts create mode 100644 apps/desktop/src/platform/utils/from-ipc-messaging.ts delete mode 100644 apps/web/src/app/core/broadcaster-messaging.service.ts delete mode 100644 libs/angular/src/platform/services/broadcaster.service.ts create mode 100644 libs/common/src/platform/messaging/helpers.spec.ts create mode 100644 libs/common/src/platform/messaging/helpers.ts create mode 100644 libs/common/src/platform/messaging/index.ts create mode 100644 libs/common/src/platform/messaging/internal.ts create mode 100644 libs/common/src/platform/messaging/message.listener.spec.ts create mode 100644 libs/common/src/platform/messaging/message.listener.ts create mode 100644 libs/common/src/platform/messaging/message.sender.ts create mode 100644 libs/common/src/platform/messaging/subject-message.sender.spec.ts create mode 100644 libs/common/src/platform/messaging/subject-message.sender.ts create mode 100644 libs/common/src/platform/messaging/types.ts delete mode 100644 libs/common/src/platform/services/broadcaster.service.ts create mode 100644 libs/common/src/platform/services/default-broadcaster.service.ts delete mode 100644 libs/common/src/platform/services/noop-messaging.service.ts diff --git a/.eslintrc.json b/.eslintrc.json index 671e7b2fab..61bebbf483 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -246,6 +246,22 @@ } ] } + }, + { + "files": ["**/*.ts"], + "excludedFiles": ["**/platform/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + "**/platform/**/internal", // General internal pattern + // All features that have been converted to barrel files + "**/platform/messaging/**" + ] + } + ] + } } ] } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 8432c398b7..f2003f9621 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { Subject, firstValueFrom, merge } from "rxjs"; import { PinCryptoServiceAbstraction, @@ -82,7 +82,6 @@ import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/co import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AbstractMemoryStorageService, @@ -95,6 +94,9 @@ import { DefaultBiometricStateService, } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; +// eslint-disable-next-line no-restricted-imports -- Used for dependency creation +import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; @@ -208,13 +210,14 @@ import { Account } from "../models/account"; import { BrowserApi } from "../platform/browser/browser-api"; import { flagEnabled } from "../platform/flags"; import { UpdateBadge } from "../platform/listeners/update-badge"; +/* eslint-disable no-restricted-imports */ +import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender"; +/* eslint-enable no-restricted-imports */ import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service"; -import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service"; -import BrowserMessagingService from "../platform/services/browser-messaging.service"; import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service"; import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service"; import I18nService from "../platform/services/i18n.service"; @@ -223,6 +226,7 @@ import { BackgroundPlatformUtilsService } from "../platform/services/platform-ut import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { BackgroundDerivedStateProvider } from "../platform/state/background-derived-state.provider"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; +import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import FilelessImporterBackground from "../tools/background/fileless-importer.background"; import { Fido2Background as Fido2BackgroundAbstraction } from "../vault/fido2/background/abstractions/fido2.background"; @@ -236,7 +240,7 @@ import { NativeMessagingBackground } from "./nativeMessaging.background"; import RuntimeBackground from "./runtime.background"; export default class MainBackground { - messagingService: MessagingServiceAbstraction; + messagingService: MessageSender; storageService: BrowserLocalStorageService; secureStorageService: AbstractStorageService; memoryStorageService: AbstractMemoryStorageService; @@ -326,6 +330,8 @@ export default class MainBackground { stateEventRunnerService: StateEventRunnerService; ssoLoginService: SsoLoginServiceAbstraction; billingAccountProfileStateService: BillingAccountProfileStateService; + // eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module + intraprocessMessagingSubject: Subject>; userKeyInitService: UserKeyInitService; scriptInjectorService: BrowserScriptInjectorService; @@ -369,15 +375,25 @@ export default class MainBackground { const logoutCallback = async (expired: boolean, userId?: UserId) => await this.logout(expired, userId); - this.messagingService = - this.isPrivateMode && BrowserApi.isManifestVersion(2) - ? new BrowserMessagingPrivateModeBackgroundService() - : new BrowserMessagingService(); this.logService = new ConsoleLogService(false); this.cryptoFunctionService = new WebCryptoFunctionService(self); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); this.storageService = new BrowserLocalStorageService(); + this.intraprocessMessagingSubject = new Subject>(); + + this.messagingService = MessageSender.combine( + new SubjectMessageSender(this.intraprocessMessagingSubject), + new ChromeMessageSender(this.logService), + ); + + const messageListener = new MessageListener( + merge( + this.intraprocessMessagingSubject.asObservable(), // For messages from the same context + fromChromeRuntimeMessaging(), // For messages from other contexts + ), + ); + const mv3MemoryStorageCreator = (partitionName: string) => { // TODO: Consider using multithreaded encrypt service in popup only context return new LocalBackedSessionStorageService( @@ -560,21 +576,6 @@ export default class MainBackground { this.twoFactorService = new TwoFactorService(this.i18nService, this.platformUtilsService); - // eslint-disable-next-line - const that = this; - const backgroundMessagingService = new (class extends MessagingServiceAbstraction { - // AuthService should send the messages to the background not popup. - send = (subscriber: string, arg: any = {}) => { - if (BrowserApi.isManifestVersion(3)) { - that.messagingService.send(subscriber, arg); - return; - } - - const message = Object.assign({}, { command: subscriber }, arg); - void that.runtimeBackground.processMessage(message, that as any); - }; - })(); - this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); @@ -605,7 +606,7 @@ export default class MainBackground { this.authService = new AuthService( this.accountService, - backgroundMessagingService, + this.messagingService, this.cryptoService, this.apiService, this.stateService, @@ -626,7 +627,7 @@ export default class MainBackground { this.tokenService, this.appIdService, this.platformUtilsService, - backgroundMessagingService, + this.messagingService, this.logService, this.keyConnectorService, this.environmentService, @@ -914,6 +915,7 @@ export default class MainBackground { this.logService, this.configService, this.fido2Background, + messageListener, ); this.nativeMessagingBackground = new NativeMessagingBackground( this.accountService, diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index e5eed06c21..5ac9961147 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -399,7 +399,7 @@ export class NativeMessagingBackground { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.runtimeBackground.processMessage({ command: "unlocked" }, null); + this.runtimeBackground.processMessage({ command: "unlocked" }); } break; } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 44fe4818e0..f457889e96 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, mergeMap } from "rxjs"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; @@ -10,6 +10,7 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { MessageListener } from "../../../../libs/common/src/platform/messaging"; import { closeUnlockPopout, openSsoAuthResultPopout, @@ -44,6 +45,7 @@ export default class RuntimeBackground { private logService: LogService, private configService: ConfigService, private fido2Background: Fido2Background, + private messageListener: MessageListener, ) { // onInstalled listener must be wired up before anything else, so we do it in the ctor chrome.runtime.onInstalled.addListener((details: any) => { @@ -60,92 +62,47 @@ export default class RuntimeBackground { const backgroundMessageListener = ( msg: any, sender: chrome.runtime.MessageSender, - sendResponse: any, + sendResponse: (response: any) => void, ) => { const messagesWithResponse = ["biometricUnlock"]; if (messagesWithResponse.includes(msg.command)) { - this.processMessage(msg, sender).then( + this.processMessageWithSender(msg, sender).then( (value) => sendResponse({ result: value }), (error) => sendResponse({ error: { ...error, message: error.message } }), ); return true; } - this.processMessage(msg, sender).catch((e) => this.logService.error(e)); + void this.processMessageWithSender(msg, sender).catch((err) => + this.logService.error( + `Error while processing message in RuntimeBackground '${msg?.command}'. Error: ${err?.message ?? "Unknown Error"}`, + ), + ); + return false; }; + this.messageListener.allMessages$ + .pipe( + mergeMap(async (message: any) => { + await this.processMessage(message); + }), + ) + .subscribe(); + + // For messages that require the full on message interface BrowserApi.messageListener("runtime.background", backgroundMessageListener); - if (this.main.popupOnlyContext) { - (self as any).bitwardenBackgroundMessageListener = backgroundMessageListener; - } } - async processMessage(msg: any, sender: chrome.runtime.MessageSender) { + // Messages that need the chrome sender and send back a response need to be registered in this method. + async processMessageWithSender(msg: any, sender: chrome.runtime.MessageSender) { switch (msg.command) { - case "loggedIn": - case "unlocked": { - let item: LockedVaultPendingNotificationsData; - - if (msg.command === "loggedIn") { - await this.sendBwInstalledMessageToVault(); - } - - if (this.lockedVaultPendingNotifications?.length > 0) { - item = this.lockedVaultPendingNotifications.pop(); - await closeUnlockPopout(); - } - - await this.notificationsService.updateConnection(msg.command === "loggedIn"); - await this.main.refreshBadge(); - await this.main.refreshMenu(false); - this.systemService.cancelProcessReload(); - - if (item) { - await BrowserApi.focusWindow(item.commandToRetry.sender.tab.windowId); - await BrowserApi.focusTab(item.commandToRetry.sender.tab.id); - await BrowserApi.tabSendMessageData( - item.commandToRetry.sender.tab, - "unlockCompleted", - item, - ); - } - break; - } - case "addToLockedVaultPendingNotifications": - this.lockedVaultPendingNotifications.push(msg.data); - break; - case "logout": - await this.main.logout(msg.expired, msg.userId); - break; - case "syncCompleted": - if (msg.successfully) { - setTimeout(async () => { - await this.main.refreshBadge(); - await this.main.refreshMenu(); - }, 2000); - await this.configService.ensureConfigFetched(); - } - break; - case "openPopup": - await this.main.openPopup(); - break; case "triggerAutofillScriptInjection": await this.autofillService.injectAutofillScripts(sender.tab, sender.frameId); break; case "bgCollectPageDetails": await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId); break; - case "bgUpdateContextMenu": - case "editedCipher": - case "addedCipher": - case "deletedCipher": - await this.main.refreshBadge(); - await this.main.refreshMenu(); - break; - case "bgReseedStorage": - await this.main.reseedStorage(); - break; case "collectPageDetailsResponse": switch (msg.sender) { case "autofiller": @@ -209,6 +166,72 @@ export default class RuntimeBackground { break; } break; + case "biometricUnlock": { + const result = await this.main.biometricUnlock(); + return result; + } + } + } + + async processMessage(msg: any) { + switch (msg.command) { + case "loggedIn": + case "unlocked": { + let item: LockedVaultPendingNotificationsData; + + if (msg.command === "loggedIn") { + await this.sendBwInstalledMessageToVault(); + } + + if (this.lockedVaultPendingNotifications?.length > 0) { + item = this.lockedVaultPendingNotifications.pop(); + await closeUnlockPopout(); + } + + await this.notificationsService.updateConnection(msg.command === "loggedIn"); + await this.main.refreshBadge(); + await this.main.refreshMenu(false); + this.systemService.cancelProcessReload(); + + if (item) { + await BrowserApi.focusWindow(item.commandToRetry.sender.tab.windowId); + await BrowserApi.focusTab(item.commandToRetry.sender.tab.id); + await BrowserApi.tabSendMessageData( + item.commandToRetry.sender.tab, + "unlockCompleted", + item, + ); + } + break; + } + case "addToLockedVaultPendingNotifications": + this.lockedVaultPendingNotifications.push(msg.data); + break; + case "logout": + await this.main.logout(msg.expired, msg.userId); + break; + case "syncCompleted": + if (msg.successfully) { + setTimeout(async () => { + await this.main.refreshBadge(); + await this.main.refreshMenu(); + }, 2000); + await this.configService.ensureConfigFetched(); + } + break; + case "openPopup": + await this.main.openPopup(); + break; + case "bgUpdateContextMenu": + case "editedCipher": + case "addedCipher": + case "deletedCipher": + await this.main.refreshBadge(); + await this.main.refreshMenu(); + break; + case "bgReseedStorage": + await this.main.reseedStorage(); + break; case "authResult": { const env = await firstValueFrom(this.environmentService.environment$); const vaultUrl = env.getWebVaultUrl(); @@ -265,9 +288,6 @@ export default class RuntimeBackground { await this.main.clearClipboard(msg.clipboardValue, msg.timeoutMs); break; } - case "biometricUnlock": { - return await this.main.biometricUnlock(); - } } } diff --git a/apps/browser/src/platform/background/service-factories/message-sender.factory.ts b/apps/browser/src/platform/background/service-factories/message-sender.factory.ts new file mode 100644 index 0000000000..6f50b4b8f5 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/message-sender.factory.ts @@ -0,0 +1,17 @@ +import { MessageSender } from "@bitwarden/common/platform/messaging"; + +import { CachedServices, factory, FactoryOptions } from "./factory-options"; + +type MessagingServiceFactoryOptions = FactoryOptions; + +export type MessageSenderInitOptions = MessagingServiceFactoryOptions; + +export function messageSenderFactory( + cache: { messagingService?: MessageSender } & CachedServices, + opts: MessageSenderInitOptions, +): Promise { + // NOTE: Name needs to match that of MainBackground property until we delete these. + return factory(cache, "messagingService", opts, () => { + throw new Error("Not implemented, not expected to be used."); + }); +} diff --git a/apps/browser/src/platform/background/service-factories/messaging-service.factory.ts b/apps/browser/src/platform/background/service-factories/messaging-service.factory.ts index 46852712aa..20c6e3f424 100644 --- a/apps/browser/src/platform/background/service-factories/messaging-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/messaging-service.factory.ts @@ -1,19 +1,5 @@ -import { MessagingService as AbstractMessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; - -import { - CachedServices, - factory, - FactoryOptions, -} from "../../background/service-factories/factory-options"; -import BrowserMessagingService from "../../services/browser-messaging.service"; - -type MessagingServiceFactoryOptions = FactoryOptions; - -export type MessagingServiceInitOptions = MessagingServiceFactoryOptions; - -export function messagingServiceFactory( - cache: { messagingService?: AbstractMessagingService } & CachedServices, - opts: MessagingServiceInitOptions, -): Promise { - return factory(cache, "messagingService", opts, () => new BrowserMessagingService()); -} +// Export old messaging service stuff to minimize changes +export { + messageSenderFactory as messagingServiceFactory, + MessageSenderInitOptions as MessagingServiceInitOptions, +} from "./message-sender.factory"; diff --git a/apps/browser/src/platform/messaging/chrome-message.sender.ts b/apps/browser/src/platform/messaging/chrome-message.sender.ts new file mode 100644 index 0000000000..0e57ecfb4e --- /dev/null +++ b/apps/browser/src/platform/messaging/chrome-message.sender.ts @@ -0,0 +1,37 @@ +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CommandDefinition, MessageSender } from "@bitwarden/common/platform/messaging"; +import { getCommand } from "@bitwarden/common/platform/messaging/internal"; + +type ErrorHandler = (logger: LogService, command: string) => void; + +const HANDLED_ERRORS: Record = { + "Could not establish connection. Receiving end does not exist.": (logger, command) => + logger.debug(`Receiving end didn't exist for command '${command}'`), + + "The message port closed before a response was received.": (logger, command) => + logger.debug(`Port was closed for command '${command}'`), +}; + +export class ChromeMessageSender implements MessageSender { + constructor(private readonly logService: LogService) {} + + send( + commandDefinition: string | CommandDefinition, + payload: object | T = {}, + ): void { + const command = getCommand(commandDefinition); + chrome.runtime.sendMessage(Object.assign(payload, { command: command }), () => { + if (chrome.runtime.lastError) { + const errorHandler = HANDLED_ERRORS[chrome.runtime.lastError.message]; + if (errorHandler != null) { + errorHandler(this.logService, command); + return; + } + + this.logService.warning( + `Unhandled error while sending message with command '${command}': ${chrome.runtime.lastError.message}`, + ); + } + }); + } +} diff --git a/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts b/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts deleted file mode 100644 index 0c7008473b..0000000000 --- a/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; - -export default class BrowserMessagingPrivateModeBackgroundService implements MessagingService { - send(subscriber: string, arg: any = {}) { - const message = Object.assign({}, { command: subscriber }, arg); - (self as any).bitwardenPopupMainMessageListener(message); - } -} diff --git a/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts b/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts deleted file mode 100644 index 5883f61197..0000000000 --- a/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; - -export default class BrowserMessagingPrivateModePopupService implements MessagingService { - send(subscriber: string, arg: any = {}) { - const message = Object.assign({}, { command: subscriber }, arg); - (self as any).bitwardenBackgroundMessageListener(message); - } -} diff --git a/apps/browser/src/platform/services/browser-messaging.service.ts b/apps/browser/src/platform/services/browser-messaging.service.ts deleted file mode 100644 index 5eff957cb5..0000000000 --- a/apps/browser/src/platform/services/browser-messaging.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; - -import { BrowserApi } from "../browser/browser-api"; - -export default class BrowserMessagingService implements MessagingService { - send(subscriber: string, arg: any = {}) { - return BrowserApi.sendMessage(subscriber, arg); - } -} diff --git a/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts b/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts new file mode 100644 index 0000000000..e30f35b680 --- /dev/null +++ b/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts @@ -0,0 +1,26 @@ +import { map, share } from "rxjs"; + +import { tagAsExternal } from "@bitwarden/common/platform/messaging/internal"; + +import { fromChromeEvent } from "../browser/from-chrome-event"; + +/** + * Creates an observable that listens to messages through `chrome.runtime.onMessage`. + * @returns An observable stream of messages. + */ +export const fromChromeRuntimeMessaging = () => { + return fromChromeEvent(chrome.runtime.onMessage).pipe( + map(([message, sender]) => { + message ??= {}; + + // Force the sender onto the message as long as we won't overwrite anything + if (!("webExtSender" in message)) { + message.webExtSender = sender; + } + + return message; + }), + tagAsExternal, + share(), + ); +}; diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 2aba93ac95..7acaf1ba93 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,17 +1,16 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { filter, concatMap, Subject, takeUntil, firstValueFrom, map } from "rxjs"; +import { filter, concatMap, Subject, takeUntil, firstValueFrom, tap, map } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { BrowserApi } from "../platform/browser/browser-api"; -import { ZonedMessageListenerService } from "../platform/browser/zoned-message-listener.service"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service"; import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service"; @@ -34,7 +33,6 @@ export class AppComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor( - private broadcasterService: BroadcasterService, private authService: AuthService, private i18nService: I18nService, private router: Router, @@ -46,7 +44,7 @@ export class AppComponent implements OnInit, OnDestroy { private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private dialogService: DialogService, - private browserMessagingApi: ZonedMessageListenerService, + private messageListener: MessageListener, private toastService: ToastService, ) {} @@ -78,77 +76,76 @@ export class AppComponent implements OnInit, OnDestroy { window.onkeypress = () => this.recordActivity(); }); - const bitwardenPopupMainMessageListener = (msg: any, sender: any) => { - if (msg.command === "doneLoggingOut") { - this.authService.logOut(async () => { - if (msg.expired) { - this.toastService.showToast({ - variant: "warning", - title: this.i18nService.t("loggedOut"), - message: this.i18nService.t("loginExpired"), + this.messageListener.allMessages$ + .pipe( + tap((msg: any) => { + if (msg.command === "doneLoggingOut") { + this.authService.logOut(async () => { + if (msg.expired) { + this.toastService.showToast({ + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }); + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["home"]); }); + this.changeDetectorRef.detectChanges(); + } else if (msg.command === "authBlocked") { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["home"]); + } else if ( + msg.command === "locked" && + (msg.userId == null || msg.userId == this.activeUserId) + ) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["lock"]); + } else if (msg.command === "showDialog") { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.showDialog(msg); + } else if (msg.command === "showNativeMessagingFinterprintDialog") { + // TODO: Should be refactored to live in another service. + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.showNativeMessagingFingerprintDialog(msg); + } else if (msg.command === "showToast") { + this.toastService._showToast(msg); + } else if (msg.command === "reloadProcess") { + const forceWindowReload = + this.platformUtilsService.isSafari() || + this.platformUtilsService.isFirefox() || + this.platformUtilsService.isOpera(); + // Wait to make sure background has reloaded first. + window.setTimeout( + () => BrowserApi.reloadExtension(forceWindowReload ? window : null), + 2000, + ); + } else if (msg.command === "reloadPopup") { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["/"]); + } else if (msg.command === "convertAccountToKeyConnector") { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["/remove-password"]); + } else if (msg.command === "switchAccountFinish") { + // TODO: unset loading? + // this.loading = false; + } else if (msg.command == "update-temp-password") { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["/update-temp-password"]); } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["home"]); - }); - this.changeDetectorRef.detectChanges(); - } else if (msg.command === "authBlocked") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["home"]); - } else if ( - msg.command === "locked" && - (msg.userId == null || msg.userId == this.activeUserId) - ) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["lock"]); - } else if (msg.command === "showDialog") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.showDialog(msg); - } else if (msg.command === "showNativeMessagingFinterprintDialog") { - // TODO: Should be refactored to live in another service. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.showNativeMessagingFingerprintDialog(msg); - } else if (msg.command === "showToast") { - this.toastService._showToast(msg); - } else if (msg.command === "reloadProcess") { - const forceWindowReload = - this.platformUtilsService.isSafari() || - this.platformUtilsService.isFirefox() || - this.platformUtilsService.isOpera(); - // Wait to make sure background has reloaded first. - window.setTimeout( - () => BrowserApi.reloadExtension(forceWindowReload ? window : null), - 2000, - ); - } else if (msg.command === "reloadPopup") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); - } else if (msg.command === "convertAccountToKeyConnector") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/remove-password"]); - } else if (msg.command === "switchAccountFinish") { - // TODO: unset loading? - // this.loading = false; - } else if (msg.command == "update-temp-password") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/update-temp-password"]); - } else { - msg.webExtSender = sender; - this.broadcasterService.send(msg); - } - }; - - (self as any).bitwardenPopupMainMessageListener = bitwardenPopupMainMessageListener; - this.browserMessagingApi.messageListener("app.component", bitwardenPopupMainMessageListener); + }), + takeUntil(this.destroy$), + ) + .subscribe(); // eslint-disable-next-line rxjs/no-async-subscribe this.router.events.pipe(takeUntil(this.destroy$)).subscribe(async (event) => { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index f3be8490c1..4ab1fe2368 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,5 +1,6 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { Router } from "@angular/router"; +import { Subject, merge } from "rxjs"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; @@ -11,6 +12,7 @@ import { OBSERVABLE_MEMORY_STORAGE, SYSTEM_THEME_OBSERVABLE, SafeInjectionToken, + INTRAPROCESS_MESSAGING_SUBJECT, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { @@ -54,7 +56,6 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { @@ -63,6 +64,9 @@ import { ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; +// eslint-disable-next-line no-restricted-imports -- Used for dependency injection +import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; @@ -89,20 +93,23 @@ import AutofillService from "../../autofill/services/autofill.service"; import MainBackground from "../../background/main.background"; import { Account } from "../../models/account"; import { BrowserApi } from "../../platform/browser/browser-api"; +import { runInsideAngular } from "../../platform/browser/run-inside-angular.operator"; +/* eslint-disable no-restricted-imports */ +import { ChromeMessageSender } from "../../platform/messaging/chrome-message.sender"; +/* eslint-enable no-restricted-imports */ import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; -import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service"; -import BrowserMessagingService from "../../platform/services/browser-messaging.service"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service"; import I18nService from "../../platform/services/i18n.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; +import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; @@ -155,15 +162,6 @@ const safeProviders: SafeProvider[] = [ useClass: UnauthGuardService, deps: [AuthServiceAbstraction, Router], }), - safeProvider({ - provide: MessagingService, - useFactory: () => { - return needsBackgroundInit && BrowserApi.isManifestVersion(2) - ? new BrowserMessagingPrivateModePopupService() - : new BrowserMessagingService(); - }, - deps: [], - }), safeProvider({ provide: TwoFactorService, useFactory: getBgService("twoFactorService"), @@ -484,6 +482,65 @@ const safeProviders: SafeProvider[] = [ useClass: BrowserSendStateService, deps: [StateProvider], }), + safeProvider({ + provide: MessageListener, + useFactory: (subject: Subject>, ngZone: NgZone) => + new MessageListener( + merge( + subject.asObservable(), // For messages in the same context + fromChromeRuntimeMessaging().pipe(runInsideAngular(ngZone)), // For messages in the same context + ), + ), + deps: [INTRAPROCESS_MESSAGING_SUBJECT, NgZone], + }), + safeProvider({ + provide: MessageSender, + useFactory: (subject: Subject>, logService: LogService) => + MessageSender.combine( + new SubjectMessageSender(subject), // For sending messages in the same context + new ChromeMessageSender(logService), // For sending messages to different contexts + ), + deps: [INTRAPROCESS_MESSAGING_SUBJECT, LogService], + }), + safeProvider({ + provide: INTRAPROCESS_MESSAGING_SUBJECT, + useFactory: () => { + if (BrowserPopupUtils.backgroundInitializationRequired()) { + // There is no persistent main background which means we have one in memory, + // we need the same instance that our in memory background is utilizing. + return getBgService("intraprocessMessagingSubject")(); + } else { + return new Subject>(); + } + }, + deps: [], + }), + safeProvider({ + provide: MessageSender, + useFactory: (subject: Subject>, logService: LogService) => + MessageSender.combine( + new SubjectMessageSender(subject), // For sending messages in the same context + new ChromeMessageSender(logService), // For sending messages to different contexts + ), + deps: [INTRAPROCESS_MESSAGING_SUBJECT, LogService], + }), + safeProvider({ + provide: INTRAPROCESS_MESSAGING_SUBJECT, + useFactory: () => { + if (needsBackgroundInit) { + // We will have created a popup within this context, in that case + // we want to make sure we have the same subject as that context so we + // can message with it. + return getBgService("intraprocessMessagingSubject")(); + } else { + // There isn't a locally created background so we will communicate with + // the true background through chrome apis, in that case, we can just create + // one for ourself. + return new Subject>(); + } + }, + deps: [], + }), ]; @NgModule({ diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 7fbefc10e3..e784997d82 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -60,10 +60,10 @@ import { } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { MessageSender } from "@bitwarden/common/platform/messaging"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; -import { BroadcasterService } from "@bitwarden/common/platform/services/broadcaster.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; @@ -75,7 +75,6 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; -import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { @@ -155,7 +154,7 @@ global.DOMParser = new jsdom.JSDOM().window.DOMParser; const packageJson = require("../package.json"); export class Main { - messagingService: NoopMessagingService; + messagingService: MessageSender; storageService: LowdbStorageService; secureStorageService: NodeEnvSecureStorageService; memoryStorageService: MemoryStorageService; @@ -212,7 +211,6 @@ export class Main { organizationService: OrganizationService; providerService: ProviderService; twoFactorService: TwoFactorService; - broadcasterService: BroadcasterService; folderApiService: FolderApiService; userVerificationApiService: UserVerificationApiService; organizationApiService: OrganizationApiServiceAbstraction; @@ -298,7 +296,7 @@ export class Main { stateEventRegistrarService, ); - this.messagingService = new NoopMessagingService(); + this.messagingService = MessageSender.EMPTY; this.accountService = new AccountServiceImplementation( this.messagingService, @@ -422,8 +420,6 @@ export class Main { this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); - this.broadcasterService = new BroadcasterService(); - this.collectionService = new CollectionService( this.cryptoService, this.i18nService, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 264f26cbe2..d1d51c0f1c 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -1,4 +1,5 @@ import { APP_INITIALIZER, NgModule } from "@angular/core"; +import { Subject, merge } from "rxjs"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { @@ -14,6 +15,7 @@ import { SYSTEM_THEME_OBSERVABLE, SafeInjectionToken, STATE_FACTORY, + INTRAPROCESS_MESSAGING_SUBJECT, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -23,7 +25,6 @@ import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/ab import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -42,6 +43,9 @@ import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/ import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; +// eslint-disable-next-line no-restricted-imports -- Used for dependency injection +import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; @@ -63,11 +67,12 @@ import { ELECTRON_SUPPORTS_SECURE_STORAGE, ElectronPlatformUtilsService, } from "../../platform/services/electron-platform-utils.service"; -import { ElectronRendererMessagingService } from "../../platform/services/electron-renderer-messaging.service"; +import { ElectronRendererMessageSender } from "../../platform/services/electron-renderer-message.sender"; import { ElectronRendererSecureStorageService } from "../../platform/services/electron-renderer-secure-storage.service"; import { ElectronRendererStorageService } from "../../platform/services/electron-renderer-storage.service"; import { ElectronStateService } from "../../platform/services/electron-state.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; +import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging"; import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme"; import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service"; import { NativeMessageHandlerService } from "../../services/native-message-handler.service"; @@ -138,9 +143,24 @@ const safeProviders: SafeProvider[] = [ deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY, GlobalStateProvider], }), safeProvider({ - provide: MessagingServiceAbstraction, - useClass: ElectronRendererMessagingService, - deps: [BroadcasterServiceAbstraction], + provide: MessageSender, + useFactory: (subject: Subject>) => + MessageSender.combine( + new ElectronRendererMessageSender(), // Communication with main process + new SubjectMessageSender(subject), // Communication with ourself + ), + deps: [INTRAPROCESS_MESSAGING_SUBJECT], + }), + safeProvider({ + provide: MessageListener, + useFactory: (subject: Subject>) => + new MessageListener( + merge( + subject.asObservable(), // For messages from the same context + fromIpcMessaging(), // For messages from the main process + ), + ), + deps: [INTRAPROCESS_MESSAGING_SUBJECT], }), safeProvider({ provide: AbstractStorageService, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a4783e0573..0655e5600d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as path from "path"; import { app } from "electron"; -import { firstValueFrom } from "rxjs"; +import { Subject, firstValueFrom } from "rxjs"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; @@ -11,6 +11,9 @@ import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwar import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DefaultBiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Message, MessageSender } from "@bitwarden/common/platform/messaging"; +// eslint-disable-next-line no-restricted-imports -- For dependency creation +import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; @@ -18,7 +21,6 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; -import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; /* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed */ import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider"; @@ -59,7 +61,7 @@ export class Main { storageService: ElectronStorageService; memoryStorageService: MemoryStorageService; memoryStorageForStateProviders: MemoryStorageServiceForStateProviders; - messagingService: ElectronMainMessagingService; + messagingService: MessageSender; stateService: StateService; environmentService: DefaultEnvironmentService; mainCryptoFunctionService: MainCryptoFunctionService; @@ -131,7 +133,7 @@ export class Main { this.i18nService = new I18nMainService("en", "./locales/", globalStateProvider); const accountService = new AccountServiceImplementation( - new NoopMessagingService(), + MessageSender.EMPTY, this.logService, globalStateProvider, ); @@ -223,7 +225,13 @@ export class Main { this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain); this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService); - this.messagingService = new ElectronMainMessagingService(this.windowMain, (message) => { + const messageSubject = new Subject>(); + this.messagingService = MessageSender.combine( + new SubjectMessageSender(messageSubject), // For local messages + new ElectronMainMessagingService(this.windowMain), + ); + + messageSubject.asObservable().subscribe((message) => { this.messagingMain.onMessage(message); }); diff --git a/apps/desktop/src/main/power-monitor.main.ts b/apps/desktop/src/main/power-monitor.main.ts index 067a380ba0..8cad5c1d9e 100644 --- a/apps/desktop/src/main/power-monitor.main.ts +++ b/apps/desktop/src/main/power-monitor.main.ts @@ -1,6 +1,7 @@ import { powerMonitor } from "electron"; -import { ElectronMainMessagingService } from "../services/electron-main-messaging.service"; +import { MessageSender } from "@bitwarden/common/platform/messaging"; + import { isSnapStore } from "../utils"; // tslint:disable-next-line @@ -10,7 +11,7 @@ const IdleCheckInterval = 30 * 1000; // 30 seconds export class PowerMonitorMain { private idle = false; - constructor(private messagingService: ElectronMainMessagingService) {} + constructor(private messagingService: MessageSender) {} init() { // ref: https://github.com/electron/electron/issues/13767 diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 04819998d5..771d25ef0a 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -124,12 +124,21 @@ export default { sendMessage: (message: { command: string } & any) => ipcRenderer.send("messagingService", message), - onMessage: (callback: (message: { command: string } & any) => void) => { - ipcRenderer.on("messagingService", (_event, message: any) => { - if (message.command) { - callback(message); - } - }); + onMessage: { + addListener: (callback: (message: { command: string } & any) => void) => { + ipcRenderer.addListener("messagingService", (_event, message: any) => { + if (message.command) { + callback(message); + } + }); + }, + removeListener: (callback: (message: { command: string } & any) => void) => { + ipcRenderer.removeListener("messagingService", (_event, message: any) => { + if (message.command) { + callback(message); + } + }); + }, }, launchUri: (uri: string) => ipcRenderer.invoke("launchUri", uri), diff --git a/apps/desktop/src/platform/services/electron-renderer-message.sender.ts b/apps/desktop/src/platform/services/electron-renderer-message.sender.ts new file mode 100644 index 0000000000..037c303b3b --- /dev/null +++ b/apps/desktop/src/platform/services/electron-renderer-message.sender.ts @@ -0,0 +1,12 @@ +import { MessageSender, CommandDefinition } from "@bitwarden/common/platform/messaging"; +import { getCommand } from "@bitwarden/common/platform/messaging/internal"; + +export class ElectronRendererMessageSender implements MessageSender { + send( + commandDefinition: CommandDefinition | string, + payload: object | T = {}, + ): void { + const command = getCommand(commandDefinition); + ipc.platform.sendMessage(Object.assign({}, { command: command }, payload)); + } +} diff --git a/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts b/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts deleted file mode 100644 index 192efc1dc6..0000000000 --- a/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; - -export class ElectronRendererMessagingService implements MessagingService { - constructor(private broadcasterService: BroadcasterService) { - ipc.platform.onMessage((message) => this.sendMessage(message.command, message, false)); - } - - send(subscriber: string, arg: any = {}) { - this.sendMessage(subscriber, arg, true); - } - - private sendMessage(subscriber: string, arg: any = {}, toMain: boolean) { - const message = Object.assign({}, { command: subscriber }, arg); - this.broadcasterService.send(message); - if (toMain) { - ipc.platform.sendMessage(message); - } - } -} diff --git a/apps/desktop/src/platform/utils/from-ipc-messaging.ts b/apps/desktop/src/platform/utils/from-ipc-messaging.ts new file mode 100644 index 0000000000..254a215ceb --- /dev/null +++ b/apps/desktop/src/platform/utils/from-ipc-messaging.ts @@ -0,0 +1,15 @@ +import { fromEventPattern, share } from "rxjs"; + +import { Message } from "@bitwarden/common/platform/messaging"; +import { tagAsExternal } from "@bitwarden/common/platform/messaging/internal"; + +/** + * Creates an observable that when subscribed to will listen to messaging events through IPC. + * @returns An observable stream of messages. + */ +export const fromIpcMessaging = () => { + return fromEventPattern>( + (handler) => ipc.platform.onMessage.addListener(handler), + (handler) => ipc.platform.onMessage.removeListener(handler), + ).pipe(tagAsExternal, share()); +}; diff --git a/apps/desktop/src/services/electron-main-messaging.service.ts b/apps/desktop/src/services/electron-main-messaging.service.ts index 71e1b1d7d5..ce4ffd903a 100644 --- a/apps/desktop/src/services/electron-main-messaging.service.ts +++ b/apps/desktop/src/services/electron-main-messaging.service.ts @@ -2,18 +2,17 @@ import * as path from "path"; import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme, Notification, shell } from "electron"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; +import { MessageSender, CommandDefinition } from "@bitwarden/common/platform/messaging"; +// eslint-disable-next-line no-restricted-imports -- Using implementation helper in implementation +import { getCommand } from "@bitwarden/common/platform/messaging/internal"; import { SafeUrls } from "@bitwarden/common/platform/misc/safe-urls"; import { WindowMain } from "../main/window.main"; import { RendererMenuItem } from "../utils"; -export class ElectronMainMessagingService implements MessagingService { - constructor( - private windowMain: WindowMain, - private onMessage: (message: any) => void, - ) { +export class ElectronMainMessagingService implements MessageSender { + constructor(private windowMain: WindowMain) { ipcMain.handle("appVersion", () => { return app.getVersion(); }); @@ -88,9 +87,9 @@ export class ElectronMainMessagingService implements MessagingService { }); } - send(subscriber: string, arg: any = {}) { - const message = Object.assign({}, { command: subscriber }, arg); - this.onMessage(message); + send(commandDefinition: CommandDefinition | string, arg: T | object = {}) { + const command = getCommand(commandDefinition); + const message = Object.assign({}, { command: command }, arg); if (this.windowMain.win != null) { this.windowMain.win.webContents.send("messagingService", message); } diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index 0c5f1d706b..bd138cad29 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -103,7 +103,8 @@ export class TrialBillingStepComponent implements OnInit { planDescription, }); - this.messagingService.send("organizationCreated", organizationId); + // TODO: No one actually listening to this? + this.messagingService.send("organizationCreated", { organizationId }); } protected changedCountry() { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 645b1f29ac..23d48d93be 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -587,7 +587,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formPromise = doSubmit(); const organizationId = await this.formPromise; this.onSuccess.emit({ organizationId: organizationId }); - this.messagingService.send("organizationCreated", organizationId); + // TODO: No one actually listening to this message? + this.messagingService.send("organizationCreated", { organizationId }); } catch (e) { this.logService.error(e); } diff --git a/apps/web/src/app/core/broadcaster-messaging.service.ts b/apps/web/src/app/core/broadcaster-messaging.service.ts deleted file mode 100644 index 7c8e4eef43..0000000000 --- a/apps/web/src/app/core/broadcaster-messaging.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; - -@Injectable() -export class BroadcasterMessagingService implements MessagingService { - constructor(private broadcasterService: BroadcasterService) {} - - send(subscriber: string, arg: any = {}) { - const message = Object.assign({}, { command: subscriber }, arg); - this.broadcasterService.send(message); - } -} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 9d53bc39f0..a274764756 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -22,7 +22,6 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -51,7 +50,6 @@ import { WebStorageServiceProvider } from "../platform/web-storage-service.provi import { WindowStorageService } from "../platform/window-storage.service"; import { CollectionAdminService } from "../vault/core/collection-admin.service"; -import { BroadcasterMessagingService } from "./broadcaster-messaging.service"; import { EventService } from "./event.service"; import { InitService } from "./init.service"; import { ModalService } from "./modal.service"; @@ -117,11 +115,6 @@ const safeProviders: SafeProvider[] = [ useClass: WebPlatformUtilsService, useAngularDecorators: true, }), - safeProvider({ - provide: MessagingServiceAbstraction, - useClass: BroadcasterMessagingService, - useAngularDecorators: true, - }), safeProvider({ provide: ModalServiceAbstraction, useClass: ModalService, diff --git a/apps/web/src/app/vault/components/premium-badge.stories.ts b/apps/web/src/app/vault/components/premium-badge.stories.ts index 5433dd9981..c61bbb46a5 100644 --- a/apps/web/src/app/vault/components/premium-badge.stories.ts +++ b/apps/web/src/app/vault/components/premium-badge.stories.ts @@ -4,15 +4,15 @@ import { of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { MessageSender } from "@bitwarden/common/platform/messaging"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; import { PremiumBadgeComponent } from "./premium-badge.component"; -class MockMessagingService implements MessagingService { - send(subscriber: string, arg?: any) { +class MockMessagingService implements MessageSender { + send = () => { alert("Clicked on badge"); - } + }; } export default { @@ -31,7 +31,7 @@ export default { }, }, { - provide: MessagingService, + provide: MessageSender, useFactory: () => { return new MockMessagingService(); }, diff --git a/libs/angular/src/platform/services/broadcaster.service.ts b/libs/angular/src/platform/services/broadcaster.service.ts deleted file mode 100644 index cf58d2b311..0000000000 --- a/libs/angular/src/platform/services/broadcaster.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { BroadcasterService as BaseBroadcasterService } from "@bitwarden/common/platform/services/broadcaster.service"; - -@Injectable() -export class BroadcasterService extends BaseBroadcasterService {} diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 7d39078797..6fffe722fb 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -1,5 +1,5 @@ import { InjectionToken } from "@angular/core"; -import { Observable } from "rxjs"; +import { Observable, Subject } from "rxjs"; import { AbstractMemoryStorageService, @@ -8,6 +8,7 @@ import { } from "@bitwarden/common/platform/abstractions/storage.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Message } from "@bitwarden/common/platform/messaging"; declare const tag: unique symbol; /** @@ -49,3 +50,6 @@ export const LOG_MAC_FAILURES = new SafeInjectionToken("LOG_MAC_FAILURE export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken>( "SYSTEM_THEME_OBSERVABLE", ); +export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken>>( + "INTRAPROCESS_MESSAGING_SUBJECT", +); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b0d84e7c3b..204ff5a294 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,4 +1,5 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; +import { Subject } from "rxjs"; import { AuthRequestServiceAbstraction, @@ -116,7 +117,7 @@ import { BillingApiService } from "@bitwarden/common/billing/services/billing-ap import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -137,6 +138,9 @@ import { DefaultBiometricStateService, } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; +// eslint-disable-next-line no-restricted-imports -- Used for dependency injection +import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { devFlagEnabled, flagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; @@ -147,6 +151,7 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; +import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/default-broadcaster.service"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; @@ -247,7 +252,6 @@ import { import { AuthGuard } from "../auth/guards/auth.guard"; import { UnauthGuard } from "../auth/guards/unauth.guard"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; -import { BroadcasterService } from "../platform/services/broadcaster.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; import { LoggingErrorHandler } from "../platform/services/logging-error-handler"; import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; @@ -270,6 +274,7 @@ import { SYSTEM_LANGUAGE, SYSTEM_THEME_OBSERVABLE, WINDOW, + INTRAPROCESS_MESSAGING_SUBJECT, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -625,7 +630,11 @@ const safeProviders: SafeProvider[] = [ BillingAccountProfileStateService, ], }), - safeProvider({ provide: BroadcasterServiceAbstraction, useClass: BroadcasterService, deps: [] }), + safeProvider({ + provide: BroadcasterService, + useClass: DefaultBroadcasterService, + deps: [MessageSender, MessageListener], + }), safeProvider({ provide: VaultTimeoutSettingsServiceAbstraction, useClass: VaultTimeoutSettingsService, @@ -1127,6 +1136,21 @@ const safeProviders: SafeProvider[] = [ useClass: LoggingErrorHandler, deps: [], }), + safeProvider({ + provide: INTRAPROCESS_MESSAGING_SUBJECT, + useFactory: () => new Subject>(), + deps: [], + }), + safeProvider({ + provide: MessageListener, + useFactory: (subject: Subject>) => new MessageListener(subject.asObservable()), + deps: [INTRAPROCESS_MESSAGING_SUBJECT], + }), + safeProvider({ + provide: MessageSender, + useFactory: (subject: Subject>) => new SubjectMessageSender(subject), + deps: [INTRAPROCESS_MESSAGING_SUBJECT], + }), safeProvider({ provide: ProviderApiServiceAbstraction, useClass: ProviderApiService, diff --git a/libs/common/src/platform/abstractions/messaging.service.ts b/libs/common/src/platform/abstractions/messaging.service.ts index ab4332c283..f24279f932 100644 --- a/libs/common/src/platform/abstractions/messaging.service.ts +++ b/libs/common/src/platform/abstractions/messaging.service.ts @@ -1,3 +1,3 @@ -export abstract class MessagingService { - abstract send(subscriber: string, arg?: any): void; -} +// Export the new message sender as the legacy MessagingService to minimize changes in the initial PR, +// team specific PR's will come after. +export { MessageSender as MessagingService } from "../messaging/message.sender"; diff --git a/libs/common/src/platform/messaging/helpers.spec.ts b/libs/common/src/platform/messaging/helpers.spec.ts new file mode 100644 index 0000000000..fcd36b4411 --- /dev/null +++ b/libs/common/src/platform/messaging/helpers.spec.ts @@ -0,0 +1,46 @@ +import { Subject, firstValueFrom } from "rxjs"; + +import { getCommand, isExternalMessage, tagAsExternal } from "./helpers"; +import { Message, CommandDefinition } from "./types"; + +describe("helpers", () => { + describe("getCommand", () => { + it("can get the command from just a string", () => { + const command = getCommand("myCommand"); + + expect(command).toEqual("myCommand"); + }); + + it("can get the command from a message definition", () => { + const commandDefinition = new CommandDefinition("myCommand"); + + const command = getCommand(commandDefinition); + + expect(command).toEqual("myCommand"); + }); + }); + + describe("tag integration", () => { + it("can tag and identify as tagged", async () => { + const messagesSubject = new Subject>(); + + const taggedMessages = messagesSubject.asObservable().pipe(tagAsExternal); + + const firstValuePromise = firstValueFrom(taggedMessages); + + messagesSubject.next({ command: "test" }); + + const result = await firstValuePromise; + + expect(isExternalMessage(result)).toEqual(true); + }); + }); + + describe("isExternalMessage", () => { + it.each([null, { command: "myCommand", test: "object" }, undefined] as Message< + Record + >[])("returns false when value is %s", (value: Message) => { + expect(isExternalMessage(value)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/platform/messaging/helpers.ts b/libs/common/src/platform/messaging/helpers.ts new file mode 100644 index 0000000000..bf119432e0 --- /dev/null +++ b/libs/common/src/platform/messaging/helpers.ts @@ -0,0 +1,23 @@ +import { MonoTypeOperatorFunction, map } from "rxjs"; + +import { Message, CommandDefinition } from "./types"; + +export const getCommand = (commandDefinition: CommandDefinition | string) => { + if (typeof commandDefinition === "string") { + return commandDefinition; + } else { + return commandDefinition.command; + } +}; + +export const EXTERNAL_SOURCE_TAG = Symbol("externalSource"); + +export const isExternalMessage = (message: Message) => { + return (message as Record)?.[EXTERNAL_SOURCE_TAG] === true; +}; + +export const tagAsExternal: MonoTypeOperatorFunction> = map( + (message: Message) => { + return Object.assign(message, { [EXTERNAL_SOURCE_TAG]: true }); + }, +); diff --git a/libs/common/src/platform/messaging/index.ts b/libs/common/src/platform/messaging/index.ts new file mode 100644 index 0000000000..a9b4eca5ae --- /dev/null +++ b/libs/common/src/platform/messaging/index.ts @@ -0,0 +1,4 @@ +export { MessageListener } from "./message.listener"; +export { MessageSender } from "./message.sender"; +export { Message, CommandDefinition } from "./types"; +export { isExternalMessage } from "./helpers"; diff --git a/libs/common/src/platform/messaging/internal.ts b/libs/common/src/platform/messaging/internal.ts new file mode 100644 index 0000000000..08763d48bc --- /dev/null +++ b/libs/common/src/platform/messaging/internal.ts @@ -0,0 +1,5 @@ +// Built in implementations +export { SubjectMessageSender } from "./subject-message.sender"; + +// Helpers meant to be used only by other implementations +export { tagAsExternal, getCommand } from "./helpers"; diff --git a/libs/common/src/platform/messaging/message.listener.spec.ts b/libs/common/src/platform/messaging/message.listener.spec.ts new file mode 100644 index 0000000000..98bbf1fdc8 --- /dev/null +++ b/libs/common/src/platform/messaging/message.listener.spec.ts @@ -0,0 +1,47 @@ +import { Subject } from "rxjs"; + +import { subscribeTo } from "../../../spec/observable-tracker"; + +import { MessageListener } from "./message.listener"; +import { Message, CommandDefinition } from "./types"; + +describe("MessageListener", () => { + const subject = new Subject>(); + const sut = new MessageListener(subject.asObservable()); + + const testCommandDefinition = new CommandDefinition<{ test: number }>("myCommand"); + + describe("allMessages$", () => { + it("runs on all nexts", async () => { + const tracker = subscribeTo(sut.allMessages$); + + const pausePromise = tracker.pauseUntilReceived(2); + + subject.next({ command: "command1", test: 1 }); + subject.next({ command: "command2", test: 2 }); + + await pausePromise; + + expect(tracker.emissions[0]).toEqual({ command: "command1", test: 1 }); + expect(tracker.emissions[1]).toEqual({ command: "command2", test: 2 }); + }); + }); + + describe("messages$", () => { + it("runs on only my commands", async () => { + const tracker = subscribeTo(sut.messages$(testCommandDefinition)); + + const pausePromise = tracker.pauseUntilReceived(2); + + subject.next({ command: "notMyCommand", test: 1 }); + subject.next({ command: "myCommand", test: 2 }); + subject.next({ command: "myCommand", test: 3 }); + subject.next({ command: "notMyCommand", test: 4 }); + + await pausePromise; + + expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 2 }); + expect(tracker.emissions[1]).toEqual({ command: "myCommand", test: 3 }); + }); + }); +}); diff --git a/libs/common/src/platform/messaging/message.listener.ts b/libs/common/src/platform/messaging/message.listener.ts new file mode 100644 index 0000000000..df453c8422 --- /dev/null +++ b/libs/common/src/platform/messaging/message.listener.ts @@ -0,0 +1,41 @@ +import { EMPTY, Observable, filter } from "rxjs"; + +import { Message, CommandDefinition } from "./types"; + +/** + * A class that allows for listening to messages coming through the application, + * allows for listening of all messages or just the messages you care about. + * + * @note Consider NOT using messaging at all if you can. State Providers offer an observable stream of + * data that is persisted. This can serve messages that might have been used to notify of settings changes + * or vault data changes and those observables should be preferred over messaging. + */ +export class MessageListener { + constructor(private readonly messageStream: Observable>) {} + + /** + * A stream of all messages sent through the application. It does not contain type information for the + * other properties on the messages. You are encouraged to instead subscribe to an individual message + * through {@link messages$}. + */ + allMessages$ = this.messageStream; + + /** + * Creates an observable stream filtered to just the command given via the {@link CommandDefinition} and typed + * to the generic contained in the CommandDefinition. Be careful using this method unless all your messages are being + * sent through `MessageSender.send`, if that isn't the case you should have lower confidence in the message + * payload being the expected type. + * + * @param commandDefinition The CommandDefinition containing the information about the message type you care about. + */ + messages$(commandDefinition: CommandDefinition): Observable { + return this.allMessages$.pipe( + filter((msg) => msg?.command === commandDefinition.command), + ) as Observable; + } + + /** + * A helper property for returning a MessageListener that will never emit any messages and will immediately complete. + */ + static readonly EMPTY = new MessageListener(EMPTY); +} diff --git a/libs/common/src/platform/messaging/message.sender.ts b/libs/common/src/platform/messaging/message.sender.ts new file mode 100644 index 0000000000..6bf2661580 --- /dev/null +++ b/libs/common/src/platform/messaging/message.sender.ts @@ -0,0 +1,62 @@ +import { CommandDefinition } from "./types"; + +class MultiMessageSender implements MessageSender { + constructor(private readonly innerMessageSenders: MessageSender[]) {} + + send( + commandDefinition: string | CommandDefinition, + payload: object | T = {}, + ): void { + for (const messageSender of this.innerMessageSenders) { + messageSender.send(commandDefinition, payload); + } + } +} + +export abstract class MessageSender { + /** + * A method for sending messages in a type safe manner. The passed in command definition + * will require you to provide a compatible type in the payload parameter. + * + * @example + * const MY_COMMAND = new CommandDefinition<{ test: number }>("myCommand"); + * + * this.messageSender.send(MY_COMMAND, { test: 14 }); + * + * @param commandDefinition + * @param payload + */ + abstract send(commandDefinition: CommandDefinition, payload: T): void; + + /** + * A legacy method for sending messages in a non-type safe way. + * + * @remarks Consider defining a {@link CommandDefinition} and passing that in for the first parameter to + * get compilation errors when defining an incompatible payload. + * + * @param command The string based command of your message. + * @param payload Extra contextual information regarding the message. Be aware that this payload may + * be serialized and lose all prototype information. + */ + abstract send(command: string, payload?: object): void; + + /** Implementation of the other two overloads, read their docs instead. */ + abstract send( + commandDefinition: CommandDefinition | string, + payload: T | object, + ): void; + + /** + * A helper method for combine multiple {@link MessageSender}'s. + * @param messageSenders The message senders that should be combined. + * @returns A message sender that will relay all messages to the given message senders. + */ + static combine(...messageSenders: MessageSender[]) { + return new MultiMessageSender(messageSenders); + } + + /** + * A helper property for creating a {@link MessageSender} that sends to nowhere. + */ + static readonly EMPTY: MessageSender = new MultiMessageSender([]); +} diff --git a/libs/common/src/platform/messaging/subject-message.sender.spec.ts b/libs/common/src/platform/messaging/subject-message.sender.spec.ts new file mode 100644 index 0000000000..4278fca7bc --- /dev/null +++ b/libs/common/src/platform/messaging/subject-message.sender.spec.ts @@ -0,0 +1,65 @@ +import { Subject } from "rxjs"; + +import { subscribeTo } from "../../../spec/observable-tracker"; + +import { SubjectMessageSender } from "./internal"; +import { MessageSender } from "./message.sender"; +import { Message, CommandDefinition } from "./types"; + +describe("SubjectMessageSender", () => { + const subject = new Subject>(); + const subjectObservable = subject.asObservable(); + + const sut: MessageSender = new SubjectMessageSender(subject); + + describe("send", () => { + it("will send message with command from message definition", async () => { + const commandDefinition = new CommandDefinition<{ test: number }>("myCommand"); + + const tracker = subscribeTo(subjectObservable); + const pausePromise = tracker.pauseUntilReceived(1); + + sut.send(commandDefinition, { test: 1 }); + + await pausePromise; + + expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 1 }); + }); + + it("will send message with command from normal string", async () => { + const tracker = subscribeTo(subjectObservable); + const pausePromise = tracker.pauseUntilReceived(1); + + sut.send("myCommand", { test: 1 }); + + await pausePromise; + + expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 1 }); + }); + + it("will send message with object even if payload not given", async () => { + const tracker = subscribeTo(subjectObservable); + const pausePromise = tracker.pauseUntilReceived(1); + + sut.send("myCommand"); + + await pausePromise; + + expect(tracker.emissions[0]).toEqual({ command: "myCommand" }); + }); + + it.each([null, undefined])( + "will send message with object even if payload is null-ish (%s)", + async (payloadValue) => { + const tracker = subscribeTo(subjectObservable); + const pausePromise = tracker.pauseUntilReceived(1); + + sut.send("myCommand", payloadValue); + + await pausePromise; + + expect(tracker.emissions[0]).toEqual({ command: "myCommand" }); + }, + ); + }); +}); diff --git a/libs/common/src/platform/messaging/subject-message.sender.ts b/libs/common/src/platform/messaging/subject-message.sender.ts new file mode 100644 index 0000000000..94ae6f27f3 --- /dev/null +++ b/libs/common/src/platform/messaging/subject-message.sender.ts @@ -0,0 +1,17 @@ +import { Subject } from "rxjs"; + +import { getCommand } from "./internal"; +import { MessageSender } from "./message.sender"; +import { Message, CommandDefinition } from "./types"; + +export class SubjectMessageSender implements MessageSender { + constructor(private readonly messagesSubject: Subject>) {} + + send( + commandDefinition: string | CommandDefinition, + payload: object | T = {}, + ): void { + const command = getCommand(commandDefinition); + this.messagesSubject.next(Object.assign(payload ?? {}, { command: command })); + } +} diff --git a/libs/common/src/platform/messaging/types.ts b/libs/common/src/platform/messaging/types.ts new file mode 100644 index 0000000000..f30163344f --- /dev/null +++ b/libs/common/src/platform/messaging/types.ts @@ -0,0 +1,13 @@ +declare const tag: unique symbol; + +/** + * A class for defining information about a message, this is helpful + * alonside `MessageSender` and `MessageListener` for providing a type + * safe(-ish) way of sending and receiving messages. + */ +export class CommandDefinition { + [tag]: T; + constructor(readonly command: string) {} +} + +export type Message = { command: string } & T; diff --git a/libs/common/src/platform/services/broadcaster.service.ts b/libs/common/src/platform/services/broadcaster.service.ts deleted file mode 100644 index 9d823b00e0..0000000000 --- a/libs/common/src/platform/services/broadcaster.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - BroadcasterService as BroadcasterServiceAbstraction, - MessageBase, -} from "../abstractions/broadcaster.service"; - -export class BroadcasterService implements BroadcasterServiceAbstraction { - subscribers: Map void> = new Map< - string, - (message: MessageBase) => void - >(); - - send(message: MessageBase, id?: string) { - if (id != null) { - if (this.subscribers.has(id)) { - this.subscribers.get(id)(message); - } - return; - } - - this.subscribers.forEach((value) => { - value(message); - }); - } - - subscribe(id: string, messageCallback: (message: MessageBase) => void) { - this.subscribers.set(id, messageCallback); - } - - unsubscribe(id: string) { - if (this.subscribers.has(id)) { - this.subscribers.delete(id); - } - } -} diff --git a/libs/common/src/platform/services/default-broadcaster.service.ts b/libs/common/src/platform/services/default-broadcaster.service.ts new file mode 100644 index 0000000000..a16745c643 --- /dev/null +++ b/libs/common/src/platform/services/default-broadcaster.service.ts @@ -0,0 +1,36 @@ +import { Subscription } from "rxjs"; + +import { BroadcasterService, MessageBase } from "../abstractions/broadcaster.service"; +import { MessageListener, MessageSender } from "../messaging"; + +/** + * Temporary implementation that just delegates to the message sender and message listener + * and manages their subscriptions. + */ +export class DefaultBroadcasterService implements BroadcasterService { + subscriptions = new Map(); + + constructor( + private readonly messageSender: MessageSender, + private readonly messageListener: MessageListener, + ) {} + + send(message: MessageBase, id?: string) { + this.messageSender.send(message?.command, message); + } + + subscribe(id: string, messageCallback: (message: MessageBase) => void) { + this.subscriptions.set( + id, + this.messageListener.allMessages$.subscribe((message) => { + messageCallback(message); + }), + ); + } + + unsubscribe(id: string) { + const subscription = this.subscriptions.get(id); + subscription?.unsubscribe(); + this.subscriptions.delete(id); + } +} diff --git a/libs/common/src/platform/services/noop-messaging.service.ts b/libs/common/src/platform/services/noop-messaging.service.ts deleted file mode 100644 index d1a60bc5bc..0000000000 --- a/libs/common/src/platform/services/noop-messaging.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { MessagingService } from "../abstractions/messaging.service"; - -export class NoopMessagingService implements MessagingService { - send(subscriber: string, arg: any = {}) { - // Do nothing... - } -}