From df8ac40c9e5358edbe6a212c6a567abf8e6eb4f0 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 6 Aug 2024 15:13:06 -0700 Subject: [PATCH 01/43] WIP: PoC with lots of terrible code with web push --- .../browser/src/background/main.background.ts | 28 ++++++++ apps/browser/src/manifest.json | 3 +- apps/browser/src/manifest.v3.json | 4 +- apps/browser/webpack.config.js | 1 + apps/web/src/app/app.component.ts | 22 +++++++ .../src/app/auth/login/login.component.html | 1 + .../web/src/app/auth/login/login.component.ts | 9 +++ apps/web/webpack.config.js | 2 + libs/common/src/enums/push-technology.enum.ts | 4 ++ .../abstractions/config/server-config.ts | 18 +++++ .../models/data/server-config.data.ts | 19 ++++++ .../models/response/server-config.response.ts | 18 +++++ .../services/notifications/service.worker.ts | 26 ++++++++ .../web-push-notifications-api.service.ts | 29 ++++++++ .../web-push-notifications.service.ts | 66 +++++++++++++++++++ .../notifications/web-push.request.ts | 13 ++++ .../services/web-crypto-function.service.ts | 2 +- 17 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 libs/common/src/enums/push-technology.enum.ts create mode 100644 libs/common/src/platform/services/notifications/service.worker.ts create mode 100644 libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts create mode 100644 libs/common/src/platform/services/notifications/web-push-notifications.service.ts create mode 100644 libs/common/src/platform/services/notifications/web-push.request.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 8de3014fb2c..9ab1cff405a 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -116,6 +116,8 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; +import { DefaultWebPushNotificationsApiService } from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; +import { WebPushNotificationsService } from "@bitwarden/common/platform/services/notifications/web-push-notifications.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; @@ -280,6 +282,7 @@ export default class MainBackground { exportService: VaultExportServiceAbstraction; searchService: SearchServiceAbstraction; notificationsService: NotificationsServiceAbstraction; + webPushNotificationsService: WebPushNotificationsService; stateService: StateServiceAbstraction; userNotificationSettingsService: UserNotificationSettingsServiceAbstraction; autofillSettingsService: AutofillSettingsServiceAbstraction; @@ -937,6 +940,27 @@ export default class MainBackground { this.authService, this.messagingService, ); + if (BrowserApi.isManifestVersion(3)) { + this.webPushNotificationsService = new WebPushNotificationsService( + (self as any).registration as ServiceWorkerRegistration, + new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), + this.configService, + ); + } else { + const websocket = new WebSocket("wss://push.services.mozilla.com"); + websocket.onopen = (e) => { + this.logService.debug("Websocket opened", e); + websocket.send( + JSON.stringify({ + messageType: "hello", + uaid: "", + channel_ids: [], + status: 0, + use_webpush: true, + }), + ); + }; + } this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); this.fido2AuthenticatorService = new Fido2AuthenticatorService( @@ -1185,6 +1209,10 @@ export default class MainBackground { this.webRequestBackground?.startListening(); this.syncServiceListener?.listener$().subscribe(); + if (this.webPushNotificationsService) { + await this.webPushNotificationsService.init(); + } + return new Promise((resolve) => { setTimeout(async () => { await this.refreshBadge(); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index b1c51911ec8..9f93390dd13 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -61,7 +61,8 @@ "idle", "webRequest", "webRequestBlocking", - "webNavigation" + "webNavigation", + "notifications" ], "optional_permissions": ["nativeMessaging", "privacy"], "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 40060a7fd93..82aed62a154 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -39,6 +39,7 @@ } ], "background": { + "page": "background.html", "service_worker": "background.js", "type": "module" }, @@ -63,7 +64,8 @@ "offscreen", "webRequest", "webRequestAuthProvider", - "webNavigation" + "webNavigation", + "notifications" ], "optional_permissions": ["nativeMessaging", "privacy"], "host_permissions": ["https://*/*", "http://*/*"], diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index eb1244bc26d..3610ec202a4 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -171,6 +171,7 @@ const mainConfig = { "overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts", "overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts", "encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts", + "service-worker": "../../libs/common/src/platform/services/notifications/service-worker.ts", "content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts", "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", "content/lp-suppress-import-download": "./src/tools/content/lp-suppress-import-download.ts", diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 1c5527504d9..7776930c9c3 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -15,6 +15,7 @@ import { } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -27,6 +28,7 @@ import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-con import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -34,6 +36,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { DefaultWebPushNotificationsApiService } from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; +import { WebPushNotificationsService } from "@bitwarden/common/platform/services/notifications/web-push-notifications.service"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; @@ -101,6 +105,8 @@ export class AppComponent implements OnDestroy, OnInit { private paymentMethodWarningService: PaymentMethodWarningService, private organizationService: InternalOrganizationServiceAbstraction, private accountService: AccountService, + private apiService: ApiService, + private appIdService: AppIdService, ) {} ngOnInit() { @@ -117,6 +123,22 @@ export class AppComponent implements OnDestroy, OnInit { window.onkeypress = () => this.recordActivity(); }); + void navigator.serviceWorker + .register( + new URL( + /* webpackChunkName: 'service-worker' */ + "@bitwarden/common/platform/services/notifications/service.worker.ts", + import.meta.url, + ), + ) + .then((registration) => { + new WebPushNotificationsService( + registration, + new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), + this.configService, + ); + }); + /// ############ DEPRECATED ############ /// Please do not use the AppComponent to send events between services. /// diff --git a/apps/web/src/app/auth/login/login.component.html b/apps/web/src/app/auth/login/login.component.html index 1ee2c8dd84b..c1fd9123312 100644 --- a/apps/web/src/app/auth/login/login.component.html +++ b/apps/web/src/app/auth/login/login.component.html @@ -1,3 +1,4 @@ +
) { this.version = serverConfigResponse?.version; @@ -27,6 +29,9 @@ export class ServerConfigData { ? new EnvironmentServerConfigData(serverConfigResponse.environment) : null; this.featureStates = serverConfigResponse?.featureStates; + this.push = serverConfigResponse?.push + ? new PushSettingsConfigData(serverConfigResponse.push) + : null; } static fromJSON(obj: Jsonify): ServerConfigData { @@ -37,6 +42,20 @@ export class ServerConfigData { } } +export class PushSettingsConfigData { + pushTechnology: number; + vapidPublicKey?: string; + + constructor(response: Partial) { + this.pushTechnology = response.pushTechnology; + this.vapidPublicKey = response.vapidPublicKey; + } + + static fromJSON(obj: Jsonify): PushSettingsConfigData { + return Object.assign(new PushSettingsConfigData({}), obj); + } +} + export class ThirdPartyServerConfigData { name: string; url: string; diff --git a/libs/common/src/platform/models/response/server-config.response.ts b/libs/common/src/platform/models/response/server-config.response.ts index f611acf6f42..e59962c1e4b 100644 --- a/libs/common/src/platform/models/response/server-config.response.ts +++ b/libs/common/src/platform/models/response/server-config.response.ts @@ -7,6 +7,7 @@ export class ServerConfigResponse extends BaseResponse { server: ThirdPartyServerConfigResponse; environment: EnvironmentServerConfigResponse; featureStates: { [key: string]: string } = {}; + push: PushSettingsConfigResponse; constructor(response: any) { super(response); @@ -20,6 +21,23 @@ export class ServerConfigResponse extends BaseResponse { this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server")); this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment")); this.featureStates = this.getResponseProperty("FeatureStates"); + this.push = new PushSettingsConfigResponse(this.getResponseProperty("Push")); + } +} + +export class PushSettingsConfigResponse extends BaseResponse { + pushTechnology: number; + vapidPublicKey: string; + + constructor(data: any = null) { + super(data); + + if (data == null) { + return; + } + + this.pushTechnology = this.getResponseProperty("PushTechnology"); + this.vapidPublicKey = this.getResponseProperty("VapidPublicKey"); } } diff --git a/libs/common/src/platform/services/notifications/service.worker.ts b/libs/common/src/platform/services/notifications/service.worker.ts new file mode 100644 index 00000000000..0af22a00b5f --- /dev/null +++ b/libs/common/src/platform/services/notifications/service.worker.ts @@ -0,0 +1,26 @@ +interface ExtendableEvent extends Event { + waitUntil(f: Promise): void; +} + +interface PushEvent extends ExtendableEvent { + // From PushEvent + data: { + arrayBuffer(): ArrayBuffer; + blob(): Blob; + bytes(): Uint8Array; + json(): any; + text(): string; + }; +} + +// Register event listener for the 'push' event. +self.addEventListener("push", function (e: unknown) { + const event: PushEvent = e as PushEvent; + // Retrieve the textual payload from event.data (a PushMessageData object). + // Other formats are supported (ArrayBuffer, Blob, JSON), check out the documentation + // on https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData. + const payload = event.data ? event.data.text() : "no payload"; + + // eslint-disable-next-line no-console -- temporary PoC code FIXME: handle payloads + console.log("Received a push message with payload:", payload); +}); diff --git a/libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts b/libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts new file mode 100644 index 00000000000..9066fd0a064 --- /dev/null +++ b/libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts @@ -0,0 +1,29 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { AppIdService } from "../../abstractions/app-id.service"; + +import { WebPushRequest } from "./web-push.request"; + +export abstract class WebPushNotificationsApiService { + /** + * Posts a device-user association to the server and ensures it's installed for push notifications + */ + abstract putSubscription(pushSubscription: PushSubscriptionJSON): Promise; +} + +export class DefaultWebPushNotificationsApiService implements WebPushNotificationsApiService { + constructor( + private readonly apiService: ApiService, + private readonly appIdService: AppIdService, + ) {} + + async putSubscription(pushSubscription: PushSubscriptionJSON): Promise { + const request = WebPushRequest.from(pushSubscription); + await this.apiService.send( + "POST", + `/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`, + request, + true, + false, + ); + } +} diff --git a/libs/common/src/platform/services/notifications/web-push-notifications.service.ts b/libs/common/src/platform/services/notifications/web-push-notifications.service.ts new file mode 100644 index 00000000000..d1d86814a99 --- /dev/null +++ b/libs/common/src/platform/services/notifications/web-push-notifications.service.ts @@ -0,0 +1,66 @@ +import { firstValueFrom } from "rxjs"; + +import { NotificationsService } from "../../../abstractions/notifications.service"; +import { ConfigService } from "../../abstractions/config/config.service"; +import { Utils } from "../../misc/utils"; + +import { WebPushNotificationsApiService } from "./web-push-notifications-api.service"; + +export class WebPushNotificationsService implements NotificationsService { + constructor( + private readonly serviceWorkerRegistration: ServiceWorkerRegistration, + private readonly apiService: WebPushNotificationsApiService, + private readonly configService: ConfigService, + ) { + (self as any).bwPush = this; + } + + async init() { + const subscription = await this.subscription(); + await this.apiService.putSubscription(subscription.toJSON()); + } + + private async subscription(): Promise { + await this.GetPermission(); + const key = await firstValueFrom(this.configService.serverConfig$).then( + (config) => config.push.vapidPublicKey, + ); + const existingSub = await this.serviceWorkerRegistration.pushManager.getSubscription(); + + let result: PushSubscription; + if (existingSub && Utils.fromBufferToB64(existingSub.options?.applicationServerKey) === key) { + result = existingSub; + } else if (existingSub) { + await existingSub.unsubscribe(); + } + + if (!result) { + result = await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }); + } + return result; + } + + private async GetPermission() { + if (self.Notification.permission !== "granted") { + const p = await self.Notification.requestPermission(); + if (p !== "granted") { + await this.GetPermission(); + } + } + } + + async updateConnection() { + throw new Error("Method not implemented."); + } + + async reconnectFromActivity() { + throw new Error("Method not implemented."); + } + + async disconnectFromInactivity() { + throw new Error("Method not implemented."); + } +} diff --git a/libs/common/src/platform/services/notifications/web-push.request.ts b/libs/common/src/platform/services/notifications/web-push.request.ts new file mode 100644 index 00000000000..788d491781a --- /dev/null +++ b/libs/common/src/platform/services/notifications/web-push.request.ts @@ -0,0 +1,13 @@ +export class WebPushRequest { + endpoint: string; + p256dh: string; + auth: string; + + static from(pushSubscription: PushSubscriptionJSON): WebPushRequest { + const result = new WebPushRequest(); + result.endpoint = pushSubscription.endpoint; + result.p256dh = pushSubscription.keys.p256dh; + result.auth = pushSubscription.keys.auth; + return result; + } +} diff --git a/libs/common/src/platform/services/web-crypto-function.service.ts b/libs/common/src/platform/services/web-crypto-function.service.ts index fd0763714ad..dd6a8b1024d 100644 --- a/libs/common/src/platform/services/web-crypto-function.service.ts +++ b/libs/common/src/platform/services/web-crypto-function.service.ts @@ -12,7 +12,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService { private subtle: SubtleCrypto; private wasmSupported: boolean; - constructor(globalContext: Window | typeof global) { + constructor(globalContext: { crypto: Crypto }) { this.crypto = typeof globalContext.crypto !== "undefined" ? globalContext.crypto : null; this.subtle = !!this.crypto && typeof this.crypto.subtle !== "undefined" ? this.crypto.subtle : null; From 4952f031fe99e73ea2e0baa6d17ad499c75d0558 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 26 Aug 2024 10:33:21 -0700 Subject: [PATCH 02/43] fix service worker building --- apps/browser/webpack.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 3610ec202a4..4a0697b7d9b 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -171,7 +171,7 @@ const mainConfig = { "overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts", "overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts", "encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts", - "service-worker": "../../libs/common/src/platform/services/notifications/service-worker.ts", + "service-worker": "../../libs/common/src/platform/services/notifications/service.worker.ts", "content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts", "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", "content/lp-suppress-import-download": "./src/tools/content/lp-suppress-import-download.ts", From 1b5a0e99da2a1753dc89dd41d681afeca35d11dd Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 12 Sep 2024 16:00:04 -0400 Subject: [PATCH 03/43] Work on WebPush Tailored to Browser --- .../browser/src/background/idle.background.ts | 2 +- .../browser/src/background/main.background.ts | 88 +++--- .../src/background/runtime.background.ts | 3 +- .../src/popup/services/services.module.ts | 2 +- apps/desktop/src/app/app.component.ts | 11 +- apps/desktop/src/app/services/init.service.ts | 6 +- apps/web/src/app/app.component.ts | 38 +-- apps/web/src/app/core/init.service.ts | 6 +- libs/angular/src/services/injection-tokens.ts | 2 + .../src/services/jslib-services.module.ts | 61 ++++- libs/common/spec/matrix.spec.ts | 76 ++++++ libs/common/spec/matrix.ts | 69 +++++ .../src/abstractions/notifications.service.ts | 6 - .../src/platform/misc/support-status.ts | 47 ++++ .../models/data/server-config.data.spec.ts | 4 + .../src/platform/notifications/index.ts | 1 + .../default-notifications.service.spec.ts | 178 ++++++++++++ .../internal/default-notifications.service.ts | 205 ++++++++++++++ .../platform/notifications/internal/index.ts | 4 + .../internal/noop-notifications.service.ts | 23 ++ ...ignalr-notifications-connection.service.ts | 94 +++++++ ...ebpush-notifications-connection.service.ts | 157 +++++++++++ .../notifications/notifications.service.ts | 7 + .../services/noop-notifications.service.ts | 28 -- .../web-push-notifications.service.ts | 66 ----- .../src/services/notifications.service.ts | 253 ------------------ 26 files changed, 975 insertions(+), 462 deletions(-) create mode 100644 libs/common/spec/matrix.spec.ts create mode 100644 libs/common/spec/matrix.ts delete mode 100644 libs/common/src/abstractions/notifications.service.ts create mode 100644 libs/common/src/platform/misc/support-status.ts create mode 100644 libs/common/src/platform/notifications/index.ts create mode 100644 libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts create mode 100644 libs/common/src/platform/notifications/internal/default-notifications.service.ts create mode 100644 libs/common/src/platform/notifications/internal/index.ts create mode 100644 libs/common/src/platform/notifications/internal/noop-notifications.service.ts create mode 100644 libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts create mode 100644 libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts create mode 100644 libs/common/src/platform/notifications/notifications.service.ts delete mode 100644 libs/common/src/platform/services/noop-notifications.service.ts delete mode 100644 libs/common/src/platform/services/notifications/web-push-notifications.service.ts delete mode 100644 libs/common/src/services/notifications.service.ts diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index eef033b364b..d70c2d9cc1b 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,10 +1,10 @@ import { firstValueFrom } from "rxjs"; -import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9ab1cff405a..e464e7c1bd5 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -14,7 +14,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -103,6 +102,12 @@ import { Lazy } from "@bitwarden/common/platform/misc/lazy"; import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; +import { + DefaultNotificationsService, + DefaultWebPushConnectionService, + SignalRNotificationsConnectionService, +} from "@bitwarden/common/platform/notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -117,7 +122,6 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultWebPushNotificationsApiService } from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; -import { WebPushNotificationsService } from "@bitwarden/common/platform/services/notifications/web-push-notifications.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; @@ -145,7 +149,6 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; -import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { @@ -281,8 +284,7 @@ export default class MainBackground { importService: ImportServiceAbstraction; exportService: VaultExportServiceAbstraction; searchService: SearchServiceAbstraction; - notificationsService: NotificationsServiceAbstraction; - webPushNotificationsService: WebPushNotificationsService; + notificationsService: NotificationsService; stateService: StateServiceAbstraction; userNotificationSettingsService: UserNotificationSettingsServiceAbstraction; autofillSettingsService: AutofillSettingsServiceAbstraction; @@ -342,6 +344,8 @@ export default class MainBackground { offscreenDocumentService: OffscreenDocumentService; syncServiceListener: SyncServiceListener; + webPushConnectionService: DefaultWebPushConnectionService; + onUpdatedRan: boolean; onReplacedRan: boolean; loginToAutoFill: CipherView = null; @@ -363,11 +367,6 @@ export default class MainBackground { constructor(public popupOnlyContext: boolean = false) { // Services const lockedCallback = async (userId?: string) => { - if (this.notificationsService != null) { - // 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.notificationsService.updateConnection(false); - } await this.refreshBadge(); await this.refreshMenu(true); if (this.systemService != null) { @@ -929,38 +928,46 @@ export default class MainBackground { this.organizationVaultExportService, ); - this.notificationsService = new NotificationsService( - this.logService, + this.webPushConnectionService = new DefaultWebPushConnectionService( + true, + this.configService, + new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), + (self as unknown as { registration: ServiceWorkerRegistration }).registration, + ); + + this.notificationsService = new DefaultNotificationsService( this.syncService, this.appIdService, - this.apiService, this.environmentService, logoutCallback, - this.stateService, - this.authService, this.messagingService, + this.accountService, + new SignalRNotificationsConnectionService(this.apiService, this.logService), + this.authService, + this.webPushConnectionService, + this.logService, ); - if (BrowserApi.isManifestVersion(3)) { - this.webPushNotificationsService = new WebPushNotificationsService( - (self as any).registration as ServiceWorkerRegistration, - new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), - this.configService, - ); - } else { - const websocket = new WebSocket("wss://push.services.mozilla.com"); - websocket.onopen = (e) => { - this.logService.debug("Websocket opened", e); - websocket.send( - JSON.stringify({ - messageType: "hello", - uaid: "", - channel_ids: [], - status: 0, - use_webpush: true, - }), - ); - }; - } + // if (BrowserApi.isManifestVersion(3)) { + // this.webPushNotificationsService = new WebPushNotificationsService( + // (self as any).registration as ServiceWorkerRegistration, + // new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), + // this.configService, + // ); + // } else { + // const websocket = new WebSocket("wss://push.services.mozilla.com"); + // websocket.onopen = (e) => { + // this.logService.debug("Websocket opened", e); + // websocket.send( + // JSON.stringify({ + // messageType: "hello", + // uaid: "", + // channel_ids: [], + // status: 0, + // use_webpush: true, + // }), + // ); + // }; + // } this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); this.fido2AuthenticatorService = new Fido2AuthenticatorService( @@ -1169,6 +1176,7 @@ export default class MainBackground { } async bootstrap() { + this.webPushConnectionService.start(); this.containerService.attachToGlobal(self); // Only the "true" background should run migrations @@ -1209,15 +1217,11 @@ export default class MainBackground { this.webRequestBackground?.startListening(); this.syncServiceListener?.listener$().subscribe(); - if (this.webPushNotificationsService) { - await this.webPushNotificationsService.init(); - } - return new Promise((resolve) => { setTimeout(async () => { await this.refreshBadge(); await this.fullSync(true); - setTimeout(() => this.notificationsService.init(), 2500); + this.notificationsService.startListening(); resolve(); }, 500); }); @@ -1293,7 +1297,6 @@ export default class MainBackground { ForceSetPasswordReason.None; await this.systemService.clearPendingClipboard(); - await this.notificationsService.updateConnection(false); if (nextAccountStatus === AuthenticationStatus.LoggedOut) { this.messagingService.send("goHome"); @@ -1397,7 +1400,6 @@ export default class MainBackground { } await this.refreshBadge(); await this.mainContextMenuHandler?.noAccess(); - await this.notificationsService.updateConnection(false); await this.systemService.clearPendingClipboard(); await this.systemService.startProcessReload(this.authService); } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 94e96e2dc89..9d6c87f4c34 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,6 +1,5 @@ import { firstValueFrom, map, mergeMap } from "rxjs"; -import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; @@ -11,6 +10,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { CipherType } from "@bitwarden/common/vault/enums"; import { MessageListener, isExternalMessage } from "../../../../libs/common/src/platform/messaging"; @@ -209,7 +209,6 @@ export default class RuntimeBackground { await closeUnlockPopout(); } - await this.notificationsService.updateConnection(msg.command === "loggedIn"); this.systemService.cancelProcessReload(); if (item) { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7c187d00514..439462eabc9 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -19,7 +19,6 @@ import { import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AuthRequestServiceAbstraction, PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -68,6 +67,7 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf // 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 { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index e01a11b8c77..1e3918c34d7 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -16,7 +16,6 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; @@ -41,6 +40,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; @@ -186,9 +186,6 @@ export class AppComponent implements OnInit, OnDestroy { this.recordActivity(); // 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.notificationsService.updateConnection(); - // 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.updateAppMenu(); this.systemService.cancelProcessReload(); break; @@ -196,9 +193,6 @@ export class AppComponent implements OnInit, OnDestroy { this.modalService.closeAll(); // 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.notificationsService.updateConnection(); - // 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.updateAppMenu(); await this.systemService.clearPendingClipboard(); await this.systemService.startProcessReload(this.authService); @@ -239,9 +233,6 @@ export class AppComponent implements OnInit, OnDestroy { ) { await this.router.navigate(["lock"]); } - // 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.notificationsService.updateConnection(); await this.updateAppMenu(); await this.systemService.clearPendingClipboard(); await this.systemService.startProcessReload(this.authService); diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 8793587300f..a79e1673bc0 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -5,7 +5,6 @@ import { firstValueFrom } from "rxjs"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -13,6 +12,7 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt. import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync"; @@ -32,7 +32,7 @@ export class InitService { private i18nService: I18nServiceAbstraction, private eventUploadService: EventUploadServiceAbstraction, private twoFactorService: TwoFactorServiceAbstraction, - private notificationsService: NotificationsServiceAbstraction, + private notificationsService: NotificationsService, private platformUtilsService: PlatformUtilsServiceAbstraction, private stateService: StateServiceAbstraction, private cryptoService: CryptoServiceAbstraction, @@ -68,7 +68,7 @@ export class InitService { await (this.i18nService as I18nRendererService).init(); (this.eventUploadService as EventUploadService).init(true); this.twoFactorService.init(); - setTimeout(() => this.notificationsService.init(), 3000); + this.notificationsService.startListening(); const htmlEl = this.win.document.documentElement; htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString()); this.themingService.applyThemeChangesTo(this.document); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 7776930c9c3..56ee210fa93 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -17,7 +17,6 @@ import { import { LogoutReason } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -36,8 +35,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; -import { DefaultWebPushNotificationsApiService } from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; -import { WebPushNotificationsService } from "@bitwarden/common/platform/services/notifications/web-push-notifications.service"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; @@ -123,22 +121,6 @@ export class AppComponent implements OnDestroy, OnInit { window.onkeypress = () => this.recordActivity(); }); - void navigator.serviceWorker - .register( - new URL( - /* webpackChunkName: 'service-worker' */ - "@bitwarden/common/platform/services/notifications/service.worker.ts", - import.meta.url, - ), - ) - .then((registration) => { - new WebPushNotificationsService( - registration, - new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), - this.configService, - ); - }); - /// ############ DEPRECATED ############ /// Please do not use the AppComponent to send events between services. /// @@ -150,21 +132,6 @@ export class AppComponent implements OnDestroy, OnInit { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.ngZone.run(async () => { switch (message.command) { - case "loggedIn": - // 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.notificationsService.updateConnection(false); - break; - case "loggedOut": - // 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.notificationsService.updateConnection(false); - break; - case "unlocked": - // 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.notificationsService.updateConnection(false); - break; case "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 @@ -177,9 +144,6 @@ export class AppComponent implements OnDestroy, OnInit { await this.vaultTimeoutService.lock(); break; case "locked": - // 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.notificationsService.updateConnection(false); // 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"]); diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 55dc1544ffd..7f0cf62430c 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -5,13 +5,13 @@ import { firstValueFrom } from "rxjs"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; @@ -21,7 +21,7 @@ import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/va export class InitService { constructor( @Inject(WINDOW) private win: Window, - private notificationsService: NotificationsServiceAbstraction, + private notificationsService: NotificationsService, private vaultTimeoutService: VaultTimeoutService, private i18nService: I18nServiceAbstraction, private eventUploadService: EventUploadServiceAbstraction, @@ -46,7 +46,7 @@ export class InitService { await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id); } - setTimeout(() => this.notificationsService.init(), 3000); + this.notificationsService.startListening(); await this.vaultTimeoutService.init(true); await this.i18nService.init(); (this.eventUploadService as EventUploadService).init(true); diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 40405b062c6..ece4ef634d5 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -58,3 +58,5 @@ export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>( "REFRESH_ACCESS_TOKEN_ERROR_CALLBACK", ); + +export const CLIENT_SUPPORTS_WEB_PUSH = new SafeInjectionToken("CLIENT_SUPPORTS_WEB_PUSH"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b956acb7637..299a98592f3 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -24,7 +24,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; @@ -157,6 +156,14 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter 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"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; +// eslint-disable-next-line no-restricted-imports -- Needed for service creation +import { + DefaultNotificationsService, + DefaultWebPushConnectionService, + NoopNotificationsService, + SignalRNotificationsConnectionService, +} from "@bitwarden/common/platform/notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -170,7 +177,10 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; -import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service"; +import { + DefaultWebPushNotificationsApiService, + WebPushNotificationsApiService, +} from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; @@ -203,7 +213,6 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; -import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; @@ -288,6 +297,7 @@ import { INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + CLIENT_SUPPORTS_WEB_PUSH, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -797,18 +807,51 @@ const safeProviders: SafeProvider[] = [ deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ - provide: NotificationsServiceAbstraction, - useClass: devFlagEnabled("noopNotifications") ? NoopNotificationsService : NotificationsService, + provide: CLIENT_SUPPORTS_WEB_PUSH, + useValue: false, + }), + safeProvider({ + provide: WebPushNotificationsApiService, + useClass: DefaultWebPushNotificationsApiService, + deps: [ApiServiceAbstraction, AppIdServiceAbstraction], + }), + safeProvider({ + provide: SignalRNotificationsConnectionService, + useClass: SignalRNotificationsConnectionService, + deps: [ApiServiceAbstraction, LogService], + }), + safeProvider({ + provide: DefaultWebPushConnectionService, + useFactory: ( + clientSupportsWebPush: boolean, + configService: ConfigService, + webPushApiService: WebPushNotificationsApiService, + ) => + // TODO: CHANGE isClientSupported to be an injection token + new DefaultWebPushConnectionService( + clientSupportsWebPush, + configService, + webPushApiService, + null, + ), + deps: [CLIENT_SUPPORTS_WEB_PUSH, ConfigService, WebPushNotificationsApiService], + }), + safeProvider({ + provide: NotificationsService, + useClass: devFlagEnabled("noopNotifications") + ? NoopNotificationsService + : DefaultNotificationsService, deps: [ - LogService, SyncService, AppIdServiceAbstraction, - ApiServiceAbstraction, EnvironmentService, LOGOUT_CALLBACK, - StateServiceAbstraction, - AuthServiceAbstraction, MessagingServiceAbstraction, + AccountServiceAbstraction, + SignalRNotificationsConnectionService, + AuthServiceAbstraction, + DefaultWebPushConnectionService, + LogService, ], }), safeProvider({ diff --git a/libs/common/spec/matrix.spec.ts b/libs/common/spec/matrix.spec.ts new file mode 100644 index 00000000000..b1a5e7a9644 --- /dev/null +++ b/libs/common/spec/matrix.spec.ts @@ -0,0 +1,76 @@ +import { Matrix } from "./matrix"; + +class TestObject { + value: number = 0; + + constructor() {} + + increment() { + this.value++; + } +} + +describe("matrix", () => { + it("caches entries in a matrix properly with a single argument", () => { + const mockFunction = jest.fn(); + const getter = Matrix.autoMockMethod(mockFunction, () => new TestObject()); + + const obj = getter("test1"); + expect(obj.value).toBe(0); + + // Change the state of the object + obj.increment(); + + // Should return the same instance the second time this is called + expect(getter("test1").value).toBe(1); + + // Using the getter should not call the mock function + expect(mockFunction).not.toHaveBeenCalled(); + + const mockedFunctionReturn1 = mockFunction("test1"); + expect(mockedFunctionReturn1.value).toBe(1); + + // Totally new value + const mockedFunctionReturn2 = mockFunction("test2"); + expect(mockedFunctionReturn2.value).toBe(0); + + expect(mockFunction).toHaveBeenCalledTimes(2); + }); + + it("caches entries in matrix properly with multiple arguments", () => { + const mockFunction = jest.fn(); + + const getter = Matrix.autoMockMethod(mockFunction, () => { + return new TestObject(); + }); + + const obj = getter("test1", 4); + expect(obj.value).toBe(0); + + obj.increment(); + + expect(getter("test1", 4).value).toBe(1); + + expect(mockFunction("test1", 3).value).toBe(0); + }); + + it("should give original args in creator even if it has multiple key layers", () => { + const mockFunction = jest.fn(); + + let invoked = false; + + const getter = Matrix.autoMockMethod(mockFunction, (args) => { + expect(args).toHaveLength(3); + expect(args[0]).toBe("test"); + expect(args[1]).toBe(42); + expect(args[2]).toBe(true); + + invoked = true; + + return new TestObject(); + }); + + getter("test", 42, true); + expect(invoked).toBe(true); + }); +}); diff --git a/libs/common/spec/matrix.ts b/libs/common/spec/matrix.ts new file mode 100644 index 00000000000..68747729e89 --- /dev/null +++ b/libs/common/spec/matrix.ts @@ -0,0 +1,69 @@ +type PickFirst = Array extends [infer First, ...unknown[]] ? First : never; + +type MatrixOrValue = Array extends [] + ? Value + : Matrix; + +type RemoveFirst = T extends [unknown, ...infer Rest] ? Rest : never; + +/** + * A matrix is intended to manage cached values for a set of method arguments. + */ +export class Matrix { + private map: Map, MatrixOrValue, TValue>> = new Map(); + + /** + * + * @param mockFunction + * @param creator + * @returns + */ + static autoMockMethod( + mockFunction: jest.Mock, + creator: (args: TArgs) => TActualReturn, + ): (...args: TArgs) => TActualReturn { + const matrix = new Matrix(); + + const getter = (...args: TArgs) => { + return matrix.getOrCreateEntry(args, creator); + }; + + mockFunction.mockImplementation(getter); + + return getter; + } + + getOrCreateEntry(args: TKeys, creator: (args: TKeys) => TValue): TValue { + if (args.length === 0) { + throw new Error("Matrix is not for you."); + } + + if (args.length === 1) { + const arg = args[0] as PickFirst; + if (this.map.has(arg)) { + // Get the cached value + return this.map.get(arg) as TValue; + } else { + const value = creator(args); + // Save the value for the next time + this.map.set(arg, value as MatrixOrValue, TValue>); + return value; + } + } + + // There are for sure 2 or more args + const [first, ...rest] = args as unknown as [PickFirst, ...RemoveFirst]; + + let matrix: Matrix, TValue> | null = null; + + if (this.map.has(first)) { + // We've already created a map for this argument + matrix = this.map.get(first) as Matrix, TValue>; + } else { + matrix = new Matrix, TValue>(); + this.map.set(first, matrix as MatrixOrValue, TValue>); + } + + return matrix.getOrCreateEntry(rest, () => creator(args)); + } +} diff --git a/libs/common/src/abstractions/notifications.service.ts b/libs/common/src/abstractions/notifications.service.ts deleted file mode 100644 index 921e8e62d52..00000000000 --- a/libs/common/src/abstractions/notifications.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -export abstract class NotificationsService { - init: () => Promise; - updateConnection: (sync?: boolean) => Promise; - reconnectFromActivity: () => Promise; - disconnectFromInactivity: () => Promise; -} diff --git a/libs/common/src/platform/misc/support-status.ts b/libs/common/src/platform/misc/support-status.ts new file mode 100644 index 00000000000..c8e04dfc903 --- /dev/null +++ b/libs/common/src/platform/misc/support-status.ts @@ -0,0 +1,47 @@ +import { filter, map, ObservableInput, OperatorFunction, pipe, switchMap } from "rxjs"; + +/** + * Indicates that the given set of actions is not supported and there is + * not anything the user can do to make it supported. The reason property + * should contain a documented and machine readable string so more in + * depth details can be shown to the user. + */ +export type NotSupported = { type: "not-supported"; reason: string }; + +/** + * Indicates that the given set of actions does not currently work but + * could be supported if configuration, either inside Bitwarden or outside, + * is done. The reason property should contain a documented and + * machine readable string so further instruction can be supplied to the caller. + */ +export type NeedsConfiguration = { type: "needs-configuration"; reason: string }; + +/** + * Indicates that the actions in the service property are supported. + */ +export type Supported = { type: "supported"; service: T }; + +/** + * A type encapsulating the status of support for a service. + */ +export type SupportStatus = Supported | NeedsConfiguration | NotSupported; + +export function filterSupported(): OperatorFunction, T> { + return pipe( + filter>((supportStatus) => supportStatus.type === "supported"), + map, T>((supportStatus) => supportStatus.service), + ); +} + +export function supportSwitch(selectors: { + supported: (service: TService, index: number) => ObservableInput; + notSupported: (index: number) => ObservableInput; +}): OperatorFunction, TSupported | TNotSupported> { + return switchMap((supportStatus, index) => { + if (supportStatus.type === "supported") { + return selectors.supported(supportStatus.service, index); + } + + return selectors.notSupported(index); + }); +} diff --git a/libs/common/src/platform/models/data/server-config.data.spec.ts b/libs/common/src/platform/models/data/server-config.data.spec.ts index b94092662a6..2f999d191cf 100644 --- a/libs/common/src/platform/models/data/server-config.data.spec.ts +++ b/libs/common/src/platform/models/data/server-config.data.spec.ts @@ -1,3 +1,4 @@ +import { PushTechnology } from "../../../enums/push-technology.enum"; import { Region } from "../../abstractions/environment.service"; import { @@ -26,6 +27,9 @@ describe("ServerConfigData", () => { }, utcDate: "2020-01-01T00:00:00.000Z", featureStates: { feature: "state" }, + push: { + pushTechnology: PushTechnology.SignalR, + }, }; const serverConfigData = ServerConfigData.fromJSON(json); diff --git a/libs/common/src/platform/notifications/index.ts b/libs/common/src/platform/notifications/index.ts new file mode 100644 index 00000000000..b1b842f5152 --- /dev/null +++ b/libs/common/src/platform/notifications/index.ts @@ -0,0 +1 @@ +export { NotificationsService } from "./notifications.service"; diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts new file mode 100644 index 00000000000..e06d82d20b5 --- /dev/null +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -0,0 +1,178 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs"; + +import { LogoutReason } from "@bitwarden/auth/common"; + +import { Matrix } from "../../../../spec/matrix"; +import { AccountService } from "../../../auth/abstractions/account.service"; +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { NotificationType } from "../../../enums"; +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { AppIdService } from "../../abstractions/app-id.service"; +import { Environment, EnvironmentService } from "../../abstractions/environment.service"; +import { MessageSender } from "../../messaging"; +import { SupportStatus } from "../../misc/support-status"; +import { SyncService } from "../../sync"; + +import { DefaultNotificationsService } from "./default-notifications.service"; +import { + SignalRNotification, + SignalRNotificationsConnectionService, +} from "./signalr-notifications-connection.service"; +import { + WebPushConnector, + DefaultWebPushConnectionService, +} from "./webpush-notifications-connection.service"; + +describe("NotificationsService", () => { + const syncService = mock(); + const appIdService = mock(); + const environmentService = mock(); + const logoutCallback = jest.fn, [logoutReason: LogoutReason]>(); + const messagingService = mock(); + const accountService = mock(); + const signalRNotificationConnectionService = mock(); + const authService = mock(); + const webPushNotificationConnectionService = mock(); + + const activeAccount = new BehaviorSubject>( + null, + ); + accountService.activeAccount$ = activeAccount.asObservable(); + + const environment = new BehaviorSubject>({ + getNotificationsUrl: () => "https://notifications.bitwarden.com", + } as Environment); + + environmentService.environment$ = environment; + + const authStatusGetter = Matrix.autoMockMethod( + authService.authStatusFor$, + () => new BehaviorSubject(AuthenticationStatus.LoggedOut), + ); + + const webPushSupportGetter = Matrix.autoMockMethod( + webPushNotificationConnectionService.supportStatus$, + () => + new BehaviorSubject>({ + type: "not-supported", + reason: "test", + }), + ); + + const signalrNotificationGetter = Matrix.autoMockMethod( + signalRNotificationConnectionService.connect$, + () => new Subject(), + ); + + const mockUser1 = "user1" as UserId; + const mockUser2 = "user2" as UserId; + + function emitActiveUser(userId: UserId) { + if (userId == null) { + activeAccount.next(null); + } else { + activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true }); + } + } + + function emitNotificationUrl(url: string) { + environment.next({ + getNotificationsUrl: () => url, + } as Environment); + } + + const sut = new DefaultNotificationsService( + syncService, + appIdService, + environmentService, + logoutCallback, + messagingService, + accountService, + signalRNotificationConnectionService, + authService, + webPushNotificationConnectionService, + ); + + test("observable chain reacts to inputs properly", async () => { + // Sets up two active unlocked user, one pointing to an environment with WebPush, the other + // falling back to using SignalR + + // We start with one active user with an unlocked account that + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + const webPush = mock(); + + const webPushGetter = Matrix.autoMockMethod( + webPush.connect$, + () => new Subject(), + ); + + // Start listening to notifications + const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(4))); + + // Pretend web push becomes supported + webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush }); + + // Emit a couple notifications through WebPush + webPushGetter(mockUser1).next(new NotificationResponse({ type: NotificationType.LogOut })); + webPushGetter(mockUser1).next( + new NotificationResponse({ type: NotificationType.SyncCipherCreate }), + ); + + // Switch to having no active user + emitActiveUser(null); + + // Switch to another user + emitActiveUser(mockUser2); + + // User unlocks + authStatusGetter(mockUser2).next(AuthenticationStatus.Unlocked); + + // Web push is not supported for second user + webPushSupportGetter(mockUser2).next({ type: "not-supported", reason: "test" }); + + // They should connect and receive notifications from signalR + signalrNotificationGetter(mockUser2, "http://test.example.com").next({ + type: "ReceiveMessage", + message: new NotificationResponse({ type: NotificationType.SyncCipherUpdate }), + }); + + // Heartbeats should be ignored. + signalrNotificationGetter(mockUser2, "http://test.example.com").next({ + type: "Heartbeat", + }); + + // User could turn off notifications (this would generally happen while there is no active user) + emitNotificationUrl("http://-"); + + // User could turn them back on + emitNotificationUrl("http://test.example.com"); + + // SignalR emits another notification + signalrNotificationGetter(mockUser2, "http://test.example.com").next({ + type: "ReceiveMessage", + message: new NotificationResponse({ type: NotificationType.SyncCipherDelete }), + }); + + const notifications = await notificationsPromise; + + const expectNotification = ( + notification: readonly [NotificationResponse, UserId], + expectedUser: UserId, + expectedType: NotificationType, + ) => { + const [actualNotification, actualUser] = notification; + expect(actualUser).toBe(expectedUser); + expect(actualNotification.type).toBe(expectedType); + }; + + expectNotification(notifications[0], mockUser1, NotificationType.LogOut); + expectNotification(notifications[1], mockUser1, NotificationType.SyncCipherCreate); + expectNotification(notifications[2], mockUser2, NotificationType.SyncCipherUpdate); + expectNotification(notifications[3], mockUser2, NotificationType.SyncCipherDelete); + }); +}); diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts new file mode 100644 index 00000000000..5d41cf7d16f --- /dev/null +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -0,0 +1,205 @@ +import { + BehaviorSubject, + distinctUntilChanged, + EMPTY, + filter, + map, + mergeMap, + Observable, + switchMap, +} from "rxjs"; + +import { LogoutReason } from "@bitwarden/auth/common"; + +import { AccountService } from "../../../auth/abstractions/account.service"; +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { NotificationType } from "../../../enums"; +import { + NotificationResponse, + SyncCipherNotification, + SyncFolderNotification, + SyncSendNotification, +} from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; +import { AppIdService } from "../../abstractions/app-id.service"; +import { EnvironmentService } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; +import { supportSwitch } from "../../misc/support-status"; +import { NotificationsService as NotificationsServiceAbstraction } from "../notifications.service"; + +import { + ReceiveMessage, + SignalRNotificationsConnectionService, +} from "./signalr-notifications-connection.service"; +import { DefaultWebPushConnectionService as WebPushNotificationConnectionService } from "./webpush-notifications-connection.service"; + +export class DefaultNotificationsService implements NotificationsServiceAbstraction { + notifications$: Observable; + + private activitySubject = new BehaviorSubject<"active" | "inactive">("active"); + + constructor( + private syncService: SyncService, + private appIdService: AppIdService, + private environmentService: EnvironmentService, + private logoutCallback: (logoutReason: LogoutReason, userId: UserId) => Promise, + private messagingService: MessagingService, + private readonly accountService: AccountService, + private readonly signalRNotificationConnectionService: SignalRNotificationsConnectionService, + private readonly authService: AuthService, + private readonly webPushNotificationService: WebPushNotificationConnectionService, + private readonly logService: LogService, + ) { + this.notifications$ = this.accountService.activeAccount$.pipe( + map((account) => account?.id), + distinctUntilChanged(), + switchMap((activeAccountId) => { + if (activeAccountId == null) { + // We don't emit notifications for inactive accounts currently + return EMPTY; + } + + return this.connectUser$(activeAccountId); + }), + ); + } + + private connectUser$(userId: UserId) { + return this.environmentService.environment$.pipe( + map((environment) => environment.getNotificationsUrl()), + distinctUntilChanged(), + switchMap((notificationsUrl) => { + if (notificationsUrl === "http://-") { + return EMPTY; + } + + // Check if authenticated + return this.authService.authStatusFor$(userId).pipe( + distinctUntilChanged(), + switchMap((authStatus) => { + if (authStatus !== AuthenticationStatus.Unlocked) { + return EMPTY; + } + + return this.activitySubject.pipe( + switchMap((activityStatus) => { + if (activityStatus === "inactive") { + return EMPTY; + } + + return this.webPushNotificationService.supportStatus$(userId).pipe( + supportSwitch({ + supported: (service) => + service.connect$(userId).pipe(map((n) => [n, userId] as const)), + notSupported: () => + this.signalRNotificationConnectionService + .connect$(userId, notificationsUrl) + .pipe( + filter((n) => n.type === "ReceiveMessage"), + map((n) => [(n as ReceiveMessage).message, userId] as const), + ), + }), + ); + }), + ); + }), + ); + }), + ); + } + + private async processNotification(notification: NotificationResponse, userId: UserId) { + const appId = await this.appIdService.getAppId(); + if (notification == null || notification.contextId === appId) { + return; + } + + const payloadUserId = notification.payload.userId || notification.payload.UserId; + if (payloadUserId != null && payloadUserId !== userId) { + return; + } + + switch (notification.type) { + case NotificationType.SyncCipherCreate: + case NotificationType.SyncCipherUpdate: + await this.syncService.syncUpsertCipher( + notification.payload as SyncCipherNotification, + notification.type === NotificationType.SyncCipherUpdate, + ); + break; + case NotificationType.SyncCipherDelete: + case NotificationType.SyncLoginDelete: + await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification); + break; + case NotificationType.SyncFolderCreate: + case NotificationType.SyncFolderUpdate: + await this.syncService.syncUpsertFolder( + notification.payload as SyncFolderNotification, + notification.type === NotificationType.SyncFolderUpdate, + ); + break; + case NotificationType.SyncFolderDelete: + await this.syncService.syncDeleteFolder(notification.payload as SyncFolderNotification); + break; + case NotificationType.SyncVault: + case NotificationType.SyncCiphers: + case NotificationType.SyncSettings: + await this.syncService.fullSync(false); + + break; + case NotificationType.SyncOrganizations: + // An organization update may not have bumped the user's account revision date, so force a sync + await this.syncService.fullSync(true); + break; + case NotificationType.SyncOrgKeys: + await this.syncService.fullSync(true); + // Stop so a reconnect can be made + // TODO: Replace + // await this.signalrConnection.stop(); + break; + case NotificationType.LogOut: + await this.logoutCallback("logoutNotification", userId); + break; + case NotificationType.SyncSendCreate: + case NotificationType.SyncSendUpdate: + await this.syncService.syncUpsertSend( + notification.payload as SyncSendNotification, + notification.type === NotificationType.SyncSendUpdate, + ); + break; + case NotificationType.SyncSendDelete: + await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); + break; + case NotificationType.AuthRequest: + { + this.messagingService.send("openLoginApproval", { + notificationId: notification.payload.id, + }); + } + break; + default: + break; + } + } + + startListening() { + return this.notifications$ + .pipe( + mergeMap(async ([notification, userId]) => this.processNotification(notification, userId)), + ) + .subscribe({ + error: (e: unknown) => this.logService.warning("Error in notifications$ observable", e), + }); + } + + reconnectFromActivity(): void { + this.activitySubject.next("active"); + } + + disconnectFromInactivity(): void { + this.activitySubject.next("inactive"); + } +} diff --git a/libs/common/src/platform/notifications/internal/index.ts b/libs/common/src/platform/notifications/internal/index.ts new file mode 100644 index 00000000000..7a758f6c9ea --- /dev/null +++ b/libs/common/src/platform/notifications/internal/index.ts @@ -0,0 +1,4 @@ +export * from "./webpush-notifications-connection.service"; +export * from "./signalr-notifications-connection.service"; +export * from "./default-notifications.service"; +export * from "./noop-notifications.service"; diff --git a/libs/common/src/platform/notifications/internal/noop-notifications.service.ts b/libs/common/src/platform/notifications/internal/noop-notifications.service.ts new file mode 100644 index 00000000000..f79cabfca8a --- /dev/null +++ b/libs/common/src/platform/notifications/internal/noop-notifications.service.ts @@ -0,0 +1,23 @@ +import { Subscription } from "rxjs"; + +import { LogService } from "../../abstractions/log.service"; +import { NotificationsService } from "../notifications.service"; + +export class NoopNotificationsService implements NotificationsService { + constructor(private logService: LogService) {} + + startListening(): Subscription { + this.logService.info( + "Initializing no-op notification service, no push notifications will be received", + ); + return Subscription.EMPTY; + } + + reconnectFromActivity(): void { + this.logService.info("Reconnecting notification service from activity"); + } + + disconnectFromInactivity(): void { + this.logService.info("Disconnecting notification service from inactivity"); + } +} diff --git a/libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts new file mode 100644 index 00000000000..98d3faa639e --- /dev/null +++ b/libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts @@ -0,0 +1,94 @@ +import { HttpTransportType, HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; +import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; +import { Observable, Subscription } from "rxjs"; + +import { ApiService } from "../../../abstractions/api.service"; +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { LogService } from "../../abstractions/log.service"; + +const MIN_RECONNECT_TIME = 120000; +const MAX_RECONNECT_TIME = 300000; + +export type Heartbeat = { type: "Heartbeat" }; +export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResponse }; + +export type SignalRNotification = Heartbeat | ReceiveMessage; + +export class SignalRNotificationsConnectionService { + constructor( + private readonly apiService: ApiService, + private readonly logService: LogService, + ) {} + + connect$(userId: UserId, notificationsUrl: string) { + return new Observable((subsciber) => { + const connection = new HubConnectionBuilder() + .withUrl(notificationsUrl + "/hub", { + accessTokenFactory: () => this.apiService.getActiveBearerToken(), + skipNegotiation: true, + transport: HttpTransportType.WebSockets, + }) + .withHubProtocol(new MessagePackHubProtocol()) + .build(); + + connection.on("ReceiveMessage", (data: any) => { + subsciber.next({ type: "ReceiveMessage", message: data }); + }); + + connection.on("Heartbeat", () => { + subsciber.next({ type: "Heartbeat" }); + }); + + let reconnectSubscription: Subscription | null = null; + + // Create schedule reconnect function + const sheduleReconnect = (): Subscription => { + if ( + connection == null || + connection.state !== HubConnectionState.Disconnected || + (reconnectSubscription != null && !reconnectSubscription.closed) + ) { + return Subscription.EMPTY; + } + + // TODO: Schedule reconnect with scheduler + const randomTime = this.random(); + const timeoutHandler = setTimeout(() => { + connection + .start() + .then(() => (reconnectSubscription = null)) + .catch((error) => { + reconnectSubscription = sheduleReconnect(); + }); + }, randomTime); + + reconnectSubscription = new Subscription(() => clearTimeout(timeoutHandler)); + }; + + connection.onclose((error) => { + // TODO: Do anything with error? + reconnectSubscription = sheduleReconnect(); + }); + + // Start connection + connection.start().catch((error) => { + reconnectSubscription = sheduleReconnect(); + }); + + return () => { + connection?.stop().catch((error) => { + this.logService.error("Error while stopping SignalR connection", error); + // TODO: Does calling stop call `onclose`? + reconnectSubscription?.unsubscribe(); + }); + }; + }); + } + + private random() { + return ( + Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME + ); + } +} diff --git a/libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts b/libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts new file mode 100644 index 00000000000..42dd9fed2e7 --- /dev/null +++ b/libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts @@ -0,0 +1,157 @@ +import { + concat, + concatMap, + defer, + fromEvent, + map, + Observable, + of, + Subject, + Subscription, + switchMap, +} from "rxjs"; + +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { ConfigService } from "../../abstractions/config/config.service"; +import { PushTechnology } from "../../abstractions/config/server-config"; +import { SupportStatus } from "../../misc/support-status"; +import { Utils } from "../../misc/utils"; +import { WebPushNotificationsApiService } from "../../services/notifications/web-push-notifications-api.service"; + +// Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event +interface PushSubscriptionChangeEvent extends ExtendableEvent { + readonly newSubscription?: PushSubscription; + readonly oldSubscription?: PushSubscription; +} + +export interface WebPushConnector { + connect$(userId: UserId): Observable; +} + +type MySupport = SupportStatus; + +export class DefaultWebPushConnectionService implements WebPushConnector { + private pushEvent = new Subject(); + private pushChangeEvent = new Subject(); + + constructor( + private readonly isClientSupported: boolean, + private readonly configService: ConfigService, + private readonly webPushApiService: WebPushNotificationsApiService, + private readonly serviceWorkerRegistration: ServiceWorkerRegistration, + ) {} + + start(): Subscription { + const subscription = new Subscription(() => { + this.pushEvent.complete(); + this.pushChangeEvent.complete(); + this.pushEvent = new Subject(); + this.pushChangeEvent = new Subject(); + }); + + const pushEventSubscription = fromEvent(self, "push").subscribe(this.pushEvent); + + const pushChangeEventSubscription = fromEvent(self, "pushsubscriptionchange").subscribe( + this.pushChangeEvent, + ); + + subscription.add(pushEventSubscription); + subscription.add(pushChangeEventSubscription); + + return subscription; + } + + supportStatus$(userId: UserId): Observable> { + if (!this.isClientSupported) { + // If the client doesn't support it, nothing more we can check + return of({ type: "not-supported", reason: "client-not-supported" } satisfies MySupport); + } + + // Check the server config to see if it supports sending WebPush notifications + return this.configService.serverConfig$.pipe( + map((config) => { + if (config.push?.pushTechnology === PushTechnology.WebPush) { + return { + type: "supported", + service: this, + } satisfies MySupport; + } + + return { type: "not-supported", reason: "server-not-configured" } satisfies MySupport; + }), + ); + } + + connect$(userId: UserId): Observable { + if (!this.isClientSupported) { + // Fail as early as possible when connect$ should not have been called. + throw new Error( + "This client does not support WebPush, call 'supportStatus$' to check if the WebPush is supported before calling 'connect$'", + ); + } + + // Do connection + return this.configService.serverConfig$.pipe( + switchMap((config) => { + if (config.push?.pushTechnology !== PushTechnology.WebPush) { + throw new Error( + "This client does not support WebPush, call 'supportStatus$' to check if the WebPush is supported before calling 'connect$'", + ); + } + + // Create connection + return this.getOrCreateSubscription$(config.push.vapidPublicKey).pipe( + concatMap((subscription) => { + return defer(async () => { + await this.webPushApiService.putSubscription(subscription.toJSON()); + }).pipe( + switchMap(() => this.pushEvent), + map((e) => new NotificationResponse(e.data.json().data)), + ); + }), + ); + }), + ); + } + + getOrCreateSubscription$(key: string) { + return concat( + defer(async () => await this.serviceWorkerRegistration.pushManager.getSubscription()).pipe( + concatMap((subscription) => { + if (subscription == null) { + return this.pushManagerSubscribe$(key); + } + + const subscriptionKey = Utils.fromBufferToUrlB64( + subscription.options?.applicationServerKey, + ); + if (subscriptionKey !== key) { + // There is a subscription, but it's not for the current server, unsubscribe and then make a new one + return defer(() => subscription.unsubscribe()).pipe( + concatMap(() => this.pushManagerSubscribe$(key)), + ); + } + + return of(subscription); + }), + ), + this.pushChangeEvent.pipe( + concatMap((event) => { + // TODO: Is this enough, do I need to do something with oldSubscription + return of(event.newSubscription); + }), + ), + ); + } + + private pushManagerSubscribe$(key: string) { + return defer( + async () => + await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }), + ); + } +} diff --git a/libs/common/src/platform/notifications/notifications.service.ts b/libs/common/src/platform/notifications/notifications.service.ts new file mode 100644 index 00000000000..c365d6e4011 --- /dev/null +++ b/libs/common/src/platform/notifications/notifications.service.ts @@ -0,0 +1,7 @@ +import { Subscription } from "rxjs"; + +export abstract class NotificationsService { + abstract startListening(): Subscription; + abstract reconnectFromActivity(): void; + abstract disconnectFromInactivity(): void; +} diff --git a/libs/common/src/platform/services/noop-notifications.service.ts b/libs/common/src/platform/services/noop-notifications.service.ts deleted file mode 100644 index edfeccd322d..00000000000 --- a/libs/common/src/platform/services/noop-notifications.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NotificationsService as NotificationsServiceAbstraction } from "../../abstractions/notifications.service"; -import { LogService } from "../abstractions/log.service"; - -export class NoopNotificationsService implements NotificationsServiceAbstraction { - constructor(private logService: LogService) {} - - init(): Promise { - this.logService.info( - "Initializing no-op notification service, no push notifications will be received", - ); - return Promise.resolve(); - } - - updateConnection(sync?: boolean): Promise { - this.logService.info("Updating notification service connection"); - return Promise.resolve(); - } - - reconnectFromActivity(): Promise { - this.logService.info("Reconnecting notification service from activity"); - return Promise.resolve(); - } - - disconnectFromInactivity(): Promise { - this.logService.info("Disconnecting notification service from inactivity"); - return Promise.resolve(); - } -} diff --git a/libs/common/src/platform/services/notifications/web-push-notifications.service.ts b/libs/common/src/platform/services/notifications/web-push-notifications.service.ts deleted file mode 100644 index d1d86814a99..00000000000 --- a/libs/common/src/platform/services/notifications/web-push-notifications.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { firstValueFrom } from "rxjs"; - -import { NotificationsService } from "../../../abstractions/notifications.service"; -import { ConfigService } from "../../abstractions/config/config.service"; -import { Utils } from "../../misc/utils"; - -import { WebPushNotificationsApiService } from "./web-push-notifications-api.service"; - -export class WebPushNotificationsService implements NotificationsService { - constructor( - private readonly serviceWorkerRegistration: ServiceWorkerRegistration, - private readonly apiService: WebPushNotificationsApiService, - private readonly configService: ConfigService, - ) { - (self as any).bwPush = this; - } - - async init() { - const subscription = await this.subscription(); - await this.apiService.putSubscription(subscription.toJSON()); - } - - private async subscription(): Promise { - await this.GetPermission(); - const key = await firstValueFrom(this.configService.serverConfig$).then( - (config) => config.push.vapidPublicKey, - ); - const existingSub = await this.serviceWorkerRegistration.pushManager.getSubscription(); - - let result: PushSubscription; - if (existingSub && Utils.fromBufferToB64(existingSub.options?.applicationServerKey) === key) { - result = existingSub; - } else if (existingSub) { - await existingSub.unsubscribe(); - } - - if (!result) { - result = await this.serviceWorkerRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: key, - }); - } - return result; - } - - private async GetPermission() { - if (self.Notification.permission !== "granted") { - const p = await self.Notification.requestPermission(); - if (p !== "granted") { - await this.GetPermission(); - } - } - } - - async updateConnection() { - throw new Error("Method not implemented."); - } - - async reconnectFromActivity() { - throw new Error("Method not implemented."); - } - - async disconnectFromInactivity() { - throw new Error("Method not implemented."); - } -} diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts deleted file mode 100644 index d5c7170e23c..00000000000 --- a/libs/common/src/services/notifications.service.ts +++ /dev/null @@ -1,253 +0,0 @@ -import * as signalR from "@microsoft/signalr"; -import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack"; -import { firstValueFrom } from "rxjs"; - -import { LogoutReason } from "@bitwarden/auth/common"; - -import { ApiService } from "../abstractions/api.service"; -import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; -import { AuthService } from "../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../auth/enums/authentication-status"; -import { NotificationType } from "../enums"; -import { - NotificationResponse, - SyncCipherNotification, - SyncFolderNotification, - SyncSendNotification, -} from "../models/response/notification.response"; -import { AppIdService } from "../platform/abstractions/app-id.service"; -import { EnvironmentService } from "../platform/abstractions/environment.service"; -import { LogService } from "../platform/abstractions/log.service"; -import { MessagingService } from "../platform/abstractions/messaging.service"; -import { StateService } from "../platform/abstractions/state.service"; -import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction"; - -export class NotificationsService implements NotificationsServiceAbstraction { - private signalrConnection: signalR.HubConnection; - private url: string; - private connected = false; - private inited = false; - private inactive = false; - private reconnectTimer: any = null; - - constructor( - private logService: LogService, - private syncService: SyncService, - private appIdService: AppIdService, - private apiService: ApiService, - private environmentService: EnvironmentService, - private logoutCallback: (logoutReason: LogoutReason) => Promise, - private stateService: StateService, - private authService: AuthService, - private messagingService: MessagingService, - ) { - this.environmentService.environment$.subscribe(() => { - if (!this.inited) { - return; - } - - // 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.init(); - }); - } - - async init(): Promise { - this.inited = false; - this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl(); - - // Set notifications server URL to `https://-` to effectively disable communication - // with the notifications server from the client app - if (this.url === "https://-") { - return; - } - - if (this.signalrConnection != null) { - this.signalrConnection.off("ReceiveMessage"); - this.signalrConnection.off("Heartbeat"); - await this.signalrConnection.stop(); - this.connected = false; - this.signalrConnection = null; - } - - this.signalrConnection = new signalR.HubConnectionBuilder() - .withUrl(this.url + "/hub", { - accessTokenFactory: () => this.apiService.getActiveBearerToken(), - skipNegotiation: true, - transport: signalR.HttpTransportType.WebSockets, - }) - .withHubProtocol(new signalRMsgPack.MessagePackHubProtocol() as signalR.IHubProtocol) - // .configureLogging(signalR.LogLevel.Trace) - .build(); - - this.signalrConnection.on("ReceiveMessage", (data: any) => - this.processNotification(new NotificationResponse(data)), - ); - // eslint-disable-next-line - this.signalrConnection.on("Heartbeat", (data: any) => { - /*console.log('Heartbeat!');*/ - }); - this.signalrConnection.onclose(() => { - this.connected = false; - // 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.reconnect(true); - }); - this.inited = true; - if (await this.isAuthedAndUnlocked()) { - await this.reconnect(false); - } - } - - async updateConnection(sync = false): Promise { - if (!this.inited) { - return; - } - try { - if (await this.isAuthedAndUnlocked()) { - await this.reconnect(sync); - } else { - await this.signalrConnection.stop(); - } - } catch (e) { - this.logService.error(e.toString()); - } - } - - async reconnectFromActivity(): Promise { - this.inactive = false; - if (this.inited && !this.connected) { - await this.reconnect(true); - } - } - - async disconnectFromInactivity(): Promise { - this.inactive = true; - if (this.inited && this.connected) { - await this.signalrConnection.stop(); - } - } - - private async processNotification(notification: NotificationResponse) { - const appId = await this.appIdService.getAppId(); - if (notification == null || notification.contextId === appId) { - return; - } - - const isAuthenticated = await this.stateService.getIsAuthenticated(); - const payloadUserId = notification.payload.userId || notification.payload.UserId; - const myUserId = await this.stateService.getUserId(); - if (isAuthenticated && payloadUserId != null && payloadUserId !== myUserId) { - return; - } - - switch (notification.type) { - case NotificationType.SyncCipherCreate: - case NotificationType.SyncCipherUpdate: - await this.syncService.syncUpsertCipher( - notification.payload as SyncCipherNotification, - notification.type === NotificationType.SyncCipherUpdate, - ); - break; - case NotificationType.SyncCipherDelete: - case NotificationType.SyncLoginDelete: - await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification); - break; - case NotificationType.SyncFolderCreate: - case NotificationType.SyncFolderUpdate: - await this.syncService.syncUpsertFolder( - notification.payload as SyncFolderNotification, - notification.type === NotificationType.SyncFolderUpdate, - ); - break; - case NotificationType.SyncFolderDelete: - await this.syncService.syncDeleteFolder(notification.payload as SyncFolderNotification); - break; - case NotificationType.SyncVault: - case NotificationType.SyncCiphers: - case NotificationType.SyncSettings: - if (isAuthenticated) { - await this.syncService.fullSync(false); - } - break; - case NotificationType.SyncOrganizations: - if (isAuthenticated) { - // An organization update may not have bumped the user's account revision date, so force a sync - await this.syncService.fullSync(true); - } - break; - case NotificationType.SyncOrgKeys: - if (isAuthenticated) { - await this.syncService.fullSync(true); - // Stop so a reconnect can be made - await this.signalrConnection.stop(); - } - break; - case NotificationType.LogOut: - if (isAuthenticated) { - // 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.logoutCallback("logoutNotification"); - } - break; - case NotificationType.SyncSendCreate: - case NotificationType.SyncSendUpdate: - await this.syncService.syncUpsertSend( - notification.payload as SyncSendNotification, - notification.type === NotificationType.SyncSendUpdate, - ); - break; - case NotificationType.SyncSendDelete: - await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); - break; - case NotificationType.AuthRequest: - { - this.messagingService.send("openLoginApproval", { - notificationId: notification.payload.id, - }); - } - break; - default: - break; - } - } - - private async reconnect(sync: boolean) { - if (this.reconnectTimer != null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - if (this.connected || !this.inited || this.inactive) { - return; - } - const authedAndUnlocked = await this.isAuthedAndUnlocked(); - if (!authedAndUnlocked) { - return; - } - - try { - await this.signalrConnection.start(); - this.connected = true; - if (sync) { - await this.syncService.fullSync(false); - } - } catch (e) { - this.logService.error(e); - } - - if (!this.connected) { - this.reconnectTimer = setTimeout(() => this.reconnect(sync), this.random(120000, 300000)); - } - } - - private async isAuthedAndUnlocked() { - const authStatus = await this.authService.getAuthStatus(); - return authStatus >= AuthenticationStatus.Unlocked; - } - - private random(min: number, max: number) { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min + 1)) + min; - } -} From 99cf6a3295c58a4449ecb662ae49567a0dfd2f91 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:21:21 -0400 Subject: [PATCH 04/43] Clean Up Web And MV2 --- .../browser/src/background/main.background.ts | 49 ++++++----------- apps/web/src/app/core/core.module.ts | 10 ++++ .../permissions-webpush-connection.service.ts | 53 +++++++++++++++++++ libs/angular/src/services/injection-tokens.ts | 2 - .../src/services/jslib-services.module.ts | 35 ++++-------- .../default-notifications.service.spec.ts | 13 ++--- .../internal/default-notifications.service.ts | 23 ++++---- .../platform/notifications/internal/index.ts | 7 ++- ...rvice.ts => signalr-connection.service.ts} | 2 +- .../unsupported-webpush-connection.service.ts | 15 ++++++ .../internal/webpush-connection.service.ts | 13 +++++ .../websocket-webpush-connection.service.ts | 38 +++++++++++++ ...s => worker-webpush-connection.service.ts} | 34 ++++-------- 13 files changed, 186 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts rename libs/common/src/platform/notifications/internal/{signalr-notifications-connection.service.ts => signalr-connection.service.ts} (98%) create mode 100644 libs/common/src/platform/notifications/internal/unsupported-webpush-connection.service.ts create mode 100644 libs/common/src/platform/notifications/internal/webpush-connection.service.ts create mode 100644 libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts rename libs/common/src/platform/notifications/internal/{webpush-notifications-connection.service.ts => worker-webpush-connection.service.ts} (82%) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e464e7c1bd5..1c924ca54e8 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -105,8 +105,9 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { DefaultNotificationsService, - DefaultWebPushConnectionService, - SignalRNotificationsConnectionService, + WorkerWebPushConnectionService, + SignalRConnectionService, + WebSocketWebPushConnectionService, } from "@bitwarden/common/platform/notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; @@ -344,7 +345,7 @@ export default class MainBackground { offscreenDocumentService: OffscreenDocumentService; syncServiceListener: SyncServiceListener; - webPushConnectionService: DefaultWebPushConnectionService; + webPushConnectionService: WorkerWebPushConnectionService | WebSocketWebPushConnectionService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -928,12 +929,15 @@ export default class MainBackground { this.organizationVaultExportService, ); - this.webPushConnectionService = new DefaultWebPushConnectionService( - true, - this.configService, - new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), - (self as unknown as { registration: ServiceWorkerRegistration }).registration, - ); + if (BrowserApi.isManifestVersion(3)) { + this.webPushConnectionService = new WorkerWebPushConnectionService( + this.configService, + new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), + (self as unknown as { registration: ServiceWorkerRegistration }).registration, + ); + } else { + this.webPushConnectionService = new WebSocketWebPushConnectionService(); + } this.notificationsService = new DefaultNotificationsService( this.syncService, @@ -942,32 +946,11 @@ export default class MainBackground { logoutCallback, this.messagingService, this.accountService, - new SignalRNotificationsConnectionService(this.apiService, this.logService), + new SignalRConnectionService(this.apiService, this.logService), this.authService, this.webPushConnectionService, this.logService, ); - // if (BrowserApi.isManifestVersion(3)) { - // this.webPushNotificationsService = new WebPushNotificationsService( - // (self as any).registration as ServiceWorkerRegistration, - // new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), - // this.configService, - // ); - // } else { - // const websocket = new WebSocket("wss://push.services.mozilla.com"); - // websocket.onopen = (e) => { - // this.logService.debug("Websocket opened", e); - // websocket.send( - // JSON.stringify({ - // messageType: "hello", - // uaid: "", - // channel_ids: [], - // status: 0, - // use_webpush: true, - // }), - // ); - // }; - // } this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); this.fido2AuthenticatorService = new Fido2AuthenticatorService( @@ -1176,7 +1159,9 @@ export default class MainBackground { } async bootstrap() { - this.webPushConnectionService.start(); + if ("start" in this.webPushConnectionService) { + this.webPushConnectionService.start(); + } this.containerService.attachToGlobal(self); // Only the "true" background should run migrations diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 7dae8c07e54..0cbe7055336 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -34,6 +34,10 @@ import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/p import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { + UnsupportedWebPushConnectionService, + WebPushConnectionService, +} from "@bitwarden/common/platform/notifications/internal"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; @@ -190,6 +194,12 @@ const safeProviders: SafeProvider[] = [ PolicyService, ], }), + safeProvider({ + provide: WebPushConnectionService, + // We can support web in the future by creating a worker + useClass: UnsupportedWebPushConnectionService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts b/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts new file mode 100644 index 00000000000..39c7da370dc --- /dev/null +++ b/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts @@ -0,0 +1,53 @@ +import { concat, defer, fromEvent, map, Observable, of, switchMap } from "rxjs"; + +import { SupportStatus } from "@bitwarden/common/platform/misc/support-status"; +import { + WebPushConnector, + WorkerWebPushConnectionService, +} from "@bitwarden/common/platform/notifications/internal"; +import { UserId } from "@bitwarden/common/types/guid"; + +export class PermissionsWebPushConnectionService extends WorkerWebPushConnectionService { + override supportStatus$(userId: UserId): Observable> { + return this.notificationPermission$().pipe( + switchMap((notificationPermission) => { + if (notificationPermission === "denied") { + return of>({ + type: "not-supported", + reason: "permission-denied", + }); + } + + if (notificationPermission === "default") { + return of>({ + type: "needs-configuration", + reason: "permission-not-requested", + }); + } + + if (notificationPermission === "prompt") { + return of>({ + type: "needs-configuration", + reason: "prompt-must-be-granted", + }); + } + + // Delegate to default worker checks + return super.supportStatus$(userId); + }), + ); + } + + private notificationPermission$() { + return concat( + of(Notification.permission), + defer(async () => { + return await window.navigator.permissions.query({ name: "notifications" }); + }).pipe( + switchMap((permissionStatus) => { + return fromEvent(permissionStatus, "change").pipe(map(() => permissionStatus.state)); + }), + ), + ); + } +} diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index ece4ef634d5..40405b062c6 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -58,5 +58,3 @@ export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>( "REFRESH_ACCESS_TOKEN_ERROR_CALLBACK", ); - -export const CLIENT_SUPPORTS_WEB_PUSH = new SafeInjectionToken("CLIENT_SUPPORTS_WEB_PUSH"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 299a98592f3..5205b9af72a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -160,9 +160,10 @@ import { NotificationsService } from "@bitwarden/common/platform/notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { DefaultNotificationsService, - DefaultWebPushConnectionService, NoopNotificationsService, - SignalRNotificationsConnectionService, + SignalRConnectionService, + UnsupportedWebPushConnectionService, + WebPushConnectionService, } from "@bitwarden/common/platform/notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; @@ -297,7 +298,6 @@ import { INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, - CLIENT_SUPPORTS_WEB_PUSH, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -806,35 +806,20 @@ const safeProviders: SafeProvider[] = [ useClass: SearchService, deps: [LogService, I18nServiceAbstraction, StateProvider], }), - safeProvider({ - provide: CLIENT_SUPPORTS_WEB_PUSH, - useValue: false, - }), safeProvider({ provide: WebPushNotificationsApiService, useClass: DefaultWebPushNotificationsApiService, deps: [ApiServiceAbstraction, AppIdServiceAbstraction], }), safeProvider({ - provide: SignalRNotificationsConnectionService, - useClass: SignalRNotificationsConnectionService, + provide: SignalRConnectionService, + useClass: SignalRConnectionService, deps: [ApiServiceAbstraction, LogService], }), safeProvider({ - provide: DefaultWebPushConnectionService, - useFactory: ( - clientSupportsWebPush: boolean, - configService: ConfigService, - webPushApiService: WebPushNotificationsApiService, - ) => - // TODO: CHANGE isClientSupported to be an injection token - new DefaultWebPushConnectionService( - clientSupportsWebPush, - configService, - webPushApiService, - null, - ), - deps: [CLIENT_SUPPORTS_WEB_PUSH, ConfigService, WebPushNotificationsApiService], + provide: WebPushConnectionService, + useClass: UnsupportedWebPushConnectionService, + deps: [], }), safeProvider({ provide: NotificationsService, @@ -848,9 +833,9 @@ const safeProviders: SafeProvider[] = [ LOGOUT_CALLBACK, MessagingServiceAbstraction, AccountServiceAbstraction, - SignalRNotificationsConnectionService, + SignalRConnectionService, AuthServiceAbstraction, - DefaultWebPushConnectionService, + WebPushConnectionService, LogService, ], }), diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index e06d82d20b5..567e66294fd 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -17,14 +17,11 @@ import { SupportStatus } from "../../misc/support-status"; import { SyncService } from "../../sync"; import { DefaultNotificationsService } from "./default-notifications.service"; -import { - SignalRNotification, - SignalRNotificationsConnectionService, -} from "./signalr-notifications-connection.service"; +import { SignalRNotification, SignalRConnectionService } from "./signalr-connection.service"; import { WebPushConnector, - DefaultWebPushConnectionService, -} from "./webpush-notifications-connection.service"; + WorkerWebPushConnectionService, +} from "./worker-webpush-connection.service"; describe("NotificationsService", () => { const syncService = mock(); @@ -33,9 +30,9 @@ describe("NotificationsService", () => { const logoutCallback = jest.fn, [logoutReason: LogoutReason]>(); const messagingService = mock(); const accountService = mock(); - const signalRNotificationConnectionService = mock(); + const signalRNotificationConnectionService = mock(); const authService = mock(); - const webPushNotificationConnectionService = mock(); + const webPushNotificationConnectionService = mock(); const activeAccount = new BehaviorSubject>( null, diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 5d41cf7d16f..550730f4ebe 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -30,11 +30,8 @@ import { MessagingService } from "../../abstractions/messaging.service"; import { supportSwitch } from "../../misc/support-status"; import { NotificationsService as NotificationsServiceAbstraction } from "../notifications.service"; -import { - ReceiveMessage, - SignalRNotificationsConnectionService, -} from "./signalr-notifications-connection.service"; -import { DefaultWebPushConnectionService as WebPushNotificationConnectionService } from "./webpush-notifications-connection.service"; +import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service"; +import { WebPushConnectionService } from "./webpush-connection.service"; export class DefaultNotificationsService implements NotificationsServiceAbstraction { notifications$: Observable; @@ -48,9 +45,9 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract private logoutCallback: (logoutReason: LogoutReason, userId: UserId) => Promise, private messagingService: MessagingService, private readonly accountService: AccountService, - private readonly signalRNotificationConnectionService: SignalRNotificationsConnectionService, + private readonly signalRConnectionService: SignalRConnectionService, private readonly authService: AuthService, - private readonly webPushNotificationService: WebPushNotificationConnectionService, + private readonly webPushConnectionService: WebPushConnectionService, private readonly logService: LogService, ) { this.notifications$ = this.accountService.activeAccount$.pipe( @@ -90,17 +87,15 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return EMPTY; } - return this.webPushNotificationService.supportStatus$(userId).pipe( + return this.webPushConnectionService.supportStatus$(userId).pipe( supportSwitch({ supported: (service) => service.connect$(userId).pipe(map((n) => [n, userId] as const)), notSupported: () => - this.signalRNotificationConnectionService - .connect$(userId, notificationsUrl) - .pipe( - filter((n) => n.type === "ReceiveMessage"), - map((n) => [(n as ReceiveMessage).message, userId] as const), - ), + this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( + filter((n) => n.type === "ReceiveMessage"), + map((n) => [(n as ReceiveMessage).message, userId] as const), + ), }), ); }), diff --git a/libs/common/src/platform/notifications/internal/index.ts b/libs/common/src/platform/notifications/internal/index.ts index 7a758f6c9ea..5571d227c38 100644 --- a/libs/common/src/platform/notifications/internal/index.ts +++ b/libs/common/src/platform/notifications/internal/index.ts @@ -1,4 +1,7 @@ -export * from "./webpush-notifications-connection.service"; -export * from "./signalr-notifications-connection.service"; +export * from "./worker-webpush-connection.service"; +export * from "./signalr-connection.service"; export * from "./default-notifications.service"; export * from "./noop-notifications.service"; +export * from "./unsupported-webpush-connection.service"; +export * from "./webpush-connection.service"; +export * from "./websocket-webpush-connection.service"; diff --git a/libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts similarity index 98% rename from libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts rename to libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 98d3faa639e..90d5b976f0e 100644 --- a/libs/common/src/platform/notifications/internal/signalr-notifications-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -15,7 +15,7 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp export type SignalRNotification = Heartbeat | ReceiveMessage; -export class SignalRNotificationsConnectionService { +export class SignalRConnectionService { constructor( private readonly apiService: ApiService, private readonly logService: LogService, diff --git a/libs/common/src/platform/notifications/internal/unsupported-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/unsupported-webpush-connection.service.ts new file mode 100644 index 00000000000..0016a882949 --- /dev/null +++ b/libs/common/src/platform/notifications/internal/unsupported-webpush-connection.service.ts @@ -0,0 +1,15 @@ +import { Observable, of } from "rxjs"; + +import { UserId } from "../../../types/guid"; +import { SupportStatus } from "../../misc/support-status"; + +import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; + +/** + * An implementation of {@see WebPushConnectionService} for clients that do not have support for WebPush + */ +export class UnsupportedWebPushConnectionService implements WebPushConnectionService { + supportStatus$(userId: UserId): Observable> { + return of({ type: "not-supported", reason: "client-not-supported" }); + } +} diff --git a/libs/common/src/platform/notifications/internal/webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/webpush-connection.service.ts new file mode 100644 index 00000000000..edc0828f64e --- /dev/null +++ b/libs/common/src/platform/notifications/internal/webpush-connection.service.ts @@ -0,0 +1,13 @@ +import { Observable } from "rxjs"; + +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { SupportStatus } from "../../misc/support-status"; + +export interface WebPushConnector { + connect$(userId: UserId): Observable; +} + +export abstract class WebPushConnectionService { + abstract supportStatus$(userId: UserId): Observable>; +} diff --git a/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts new file mode 100644 index 00000000000..f6339cafbef --- /dev/null +++ b/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts @@ -0,0 +1,38 @@ +import { fromEvent, Observable, of } from "rxjs"; + +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { SupportStatus } from "../../misc/support-status"; + +import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; + +export class WebSocketWebPushConnectionService + implements WebPushConnectionService, WebPushConnector +{ + supportStatus$(userId: UserId): Observable> { + return of({ type: "not-supported", reason: "work-in-progress" }); + } + + connect$(userId: UserId): Observable { + // TODO: Not currently recieving notifications + return new Observable((subscriber) => { + const socket = new WebSocket("wss://push.services.mozilla.com"); + + const messageSubscription = fromEvent(socket, "message").subscribe({ + next: (event) => { + subscriber.next(new NotificationResponse(event.data)); + }, + }); + + const closeSubscription = fromEvent(socket, "close").subscribe(() => + subscriber.complete(), + ); + + return () => { + messageSubscription.unsubscribe(); + closeSubscription.unsubscribe(); + socket.close(); + }; + }); + } +} diff --git a/libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts similarity index 82% rename from libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts rename to libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index 42dd9fed2e7..953a89bc5c7 100644 --- a/libs/common/src/platform/notifications/internal/webpush-notifications-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -19,24 +19,22 @@ import { SupportStatus } from "../../misc/support-status"; import { Utils } from "../../misc/utils"; import { WebPushNotificationsApiService } from "../../services/notifications/web-push-notifications-api.service"; +import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; + // Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event interface PushSubscriptionChangeEvent extends ExtendableEvent { readonly newSubscription?: PushSubscription; readonly oldSubscription?: PushSubscription; } -export interface WebPushConnector { - connect$(userId: UserId): Observable; -} - -type MySupport = SupportStatus; - -export class DefaultWebPushConnectionService implements WebPushConnector { +/** + * An implementation for connecting to web push based notifications running in a Worker. + */ +export class WorkerWebPushConnectionService implements WebPushConnectionService, WebPushConnector { private pushEvent = new Subject(); private pushChangeEvent = new Subject(); constructor( - private readonly isClientSupported: boolean, private readonly configService: ConfigService, private readonly webPushApiService: WebPushNotificationsApiService, private readonly serviceWorkerRegistration: ServiceWorkerRegistration, @@ -63,34 +61,22 @@ export class DefaultWebPushConnectionService implements WebPushConnector { } supportStatus$(userId: UserId): Observable> { - if (!this.isClientSupported) { - // If the client doesn't support it, nothing more we can check - return of({ type: "not-supported", reason: "client-not-supported" } satisfies MySupport); - } - // Check the server config to see if it supports sending WebPush notifications - return this.configService.serverConfig$.pipe( + return this.configService.serverConfig$.pipe>( map((config) => { if (config.push?.pushTechnology === PushTechnology.WebPush) { return { type: "supported", service: this, - } satisfies MySupport; + }; } - return { type: "not-supported", reason: "server-not-configured" } satisfies MySupport; + return { type: "not-supported", reason: "server-not-configured" }; }), ); } connect$(userId: UserId): Observable { - if (!this.isClientSupported) { - // Fail as early as possible when connect$ should not have been called. - throw new Error( - "This client does not support WebPush, call 'supportStatus$' to check if the WebPush is supported before calling 'connect$'", - ); - } - // Do connection return this.configService.serverConfig$.pipe( switchMap((config) => { @@ -115,7 +101,7 @@ export class DefaultWebPushConnectionService implements WebPushConnector { ); } - getOrCreateSubscription$(key: string) { + private getOrCreateSubscription$(key: string) { return concat( defer(async () => await this.serviceWorkerRegistration.pushManager.getSubscription()).pipe( concatMap((subscription) => { From dce334e61e620298db9efc5003339043c034b376 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 30 Sep 2024 15:55:30 -0400 Subject: [PATCH 05/43] Fix Merge Conflicts --- apps/browser/src/background/main.background.ts | 1 - libs/angular/src/services/jslib-services.module.ts | 1 - .../internal/default-notifications.service.ts | 7 +++---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index fa9b9b491d4..9870b6e963a 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1037,7 +1037,6 @@ export default class MainBackground { this.authService, this.webPushConnectionService, this.logService, - this.taskSchedulerService, ); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6f506436c24..8a35b5affd5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -874,7 +874,6 @@ const safeProviders: SafeProvider[] = [ AuthServiceAbstraction, WebPushConnectionService, LogService, - TaskSchedulerService, ], }), safeProvider({ diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 550730f4ebe..6d1e7fbbab7 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -112,7 +112,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return; } - const payloadUserId = notification.payload.userId || notification.payload.UserId; + const payloadUserId = notification.payload?.userId || notification.payload?.UserId; if (payloadUserId != null && payloadUserId !== userId) { return; } @@ -151,9 +151,8 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract break; case NotificationType.SyncOrgKeys: await this.syncService.fullSync(true); - // Stop so a reconnect can be made - // TODO: Replace - // await this.signalrConnection.stop(); + this.activitySubject.next("inactive"); // Force a disconnect + this.activitySubject.next("active"); // Allow a reconnect break; case NotificationType.LogOut: await this.logoutCallback("logoutNotification", userId); From 2998144c043a379887cf6695a230f204ebff79ba Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:20:24 -0400 Subject: [PATCH 06/43] Prettier --- .../platform/popup/layout/popup-header.component.html | 2 +- .../platform/popup/layout/popup-page.component.html | 2 +- apps/cli/src/tools/export.command.ts | 2 +- .../filters/collection-filter.component.html | 4 ++-- .../vault-filter/filters/folder-filter.component.html | 6 +++--- .../filters/organization-filter.component.html | 4 ++-- .../vault-filter/filters/type-filter.component.html | 2 +- .../account/change-avatar-dialog.component.html | 2 +- .../complete-trial-initiation.component.html | 2 +- .../secrets-manager-trial-paid-stepper.component.html | 2 +- .../trial-initiation/trial-initiation.component.html | 2 +- .../vertical-step-content.component.html | 8 ++++---- .../vertical-stepper/vertical-step.component.html | 2 +- .../organizations/change-plan-dialog.component.html | 4 ++-- .../organizations/change-plan-dialog.component.ts | 2 +- .../src/app/layouts/header/web-header.component.html | 2 +- .../product-switcher-content.component.html | 2 +- .../onboarding/onboarding-task.component.html | 2 +- .../app/vault/individual-vault/add-edit.component.html | 2 +- .../services/routed-vault-filter.service.ts | 2 +- .../components/vault-filter-section.component.html | 6 +++--- .../bit-web/src/app/auth/sso/sso.component.html | 2 +- .../clients/create-client-dialog.component.html | 2 +- .../manage-client-subscription-dialog.component.html | 2 +- .../components/environment-selector.component.html | 2 +- .../src/angular/anon-layout/anon-layout.component.html | 2 +- libs/common/src/platform/services/state.service.ts | 4 ++-- libs/common/src/platform/state/derive-definition.ts | 2 +- libs/common/src/platform/state/key-definition.ts | 2 +- libs/common/src/platform/state/user-key-definition.ts | 2 +- .../src/tools/integration/integration-context.ts | 2 +- libs/common/src/tools/integration/rpc/rest-client.ts | 2 +- .../src/chip-select/chip-select.component.html | 6 +++--- .../components/src/dialog/dialog/dialog.component.html | 2 +- libs/components/src/layout/layout.component.html | 2 +- libs/components/src/navigation/nav-item.component.html | 10 +++++----- libs/components/src/navigation/side-nav.component.html | 4 ++-- libs/components/src/section/section.component.ts | 2 +- .../core/src/engine/crypto-service-randomizer.ts | 2 +- .../generator/core/src/engine/email-calculator.ts | 2 +- .../generator/core/src/engine/email-randomizer.ts | 4 ++-- .../generator/core/src/engine/forwarder-context.ts | 4 ++-- .../core/src/policies/available-algorithms-policy.ts | 2 +- .../policies/passphrase-generator-options-evaluator.ts | 2 +- .../src/strategies/eff-username-generator-strategy.ts | 4 ++-- .../custom-fields/custom-fields.component.html | 2 +- .../item-history/item-history-v2.component.html | 2 +- 47 files changed, 68 insertions(+), 68 deletions(-) diff --git a/apps/browser/src/platform/popup/layout/popup-header.component.html b/apps/browser/src/platform/popup/layout/popup-header.component.html index 4fab1870767..fefc7154314 100644 --- a/apps/browser/src/platform/popup/layout/popup-header.component.html +++ b/apps/browser/src/platform/popup/layout/popup-header.component.html @@ -4,7 +4,7 @@ 'tw-bg-background-alt tw-border-transparent': this.background === 'alt' && !pageContentScrolled(), 'tw-bg-background tw-border-secondary-300': - (this.background === 'alt' && pageContentScrolled()) || this.background === 'default' + (this.background === 'alt' && pageContentScrolled()) || this.background === 'default', }" >
diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html index 0c1bf410dcc..8a7bedf0882 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -6,7 +6,7 @@ [ngClass]="{ 'tw-invisible': loading || nonScrollable.childElementCount === 0, 'tw-border-secondary-300': scrolled(), - 'tw-border-transparent': !scrolled() + 'tw-border-transparent': !scrolled(), }" > diff --git a/apps/cli/src/tools/export.command.ts b/apps/cli/src/tools/export.command.ts index 71858a9c95d..51c96908909 100644 --- a/apps/cli/src/tools/export.command.ts +++ b/apps/cli/src/tools/export.command.ts @@ -38,7 +38,7 @@ export class ExportCommand { // format is 'undefined' => Defaults to 'csv' // Any other case => returns the options.format const format = - password && options.format == "json" ? "encrypted_json" : options.format ?? "csv"; + password && options.format == "json" ? "encrypted_json" : (options.format ?? "csv"); if (!this.isSupportedExportFormat(format)) { return Response.badRequest( diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.html index 28c815d8371..667cdf6798c 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.html @@ -13,7 +13,7 @@

aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed(collectionsGrouping), - 'bwi-angle-down': !isCollapsed(collectionsGrouping) + 'bwi-angle-down': !isCollapsed(collectionsGrouping), }" >  {{ collectionsGrouping.name | i18n }} @@ -42,7 +42,7 @@

aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed(c.node), - 'bwi-angle-down': !isCollapsed(c.node) + 'bwi-angle-down': !isCollapsed(c.node), }" > diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.html index 218a4c12692..a2240b03ff5 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.html @@ -13,7 +13,7 @@

aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed(foldersGrouping), - 'bwi-angle-down': !isCollapsed(foldersGrouping) + 'bwi-angle-down': !isCollapsed(foldersGrouping), }" >  {{ foldersGrouping.name | i18n }} @@ -33,7 +33,7 @@

  • @@ -52,7 +52,7 @@

    aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed(f.node), - 'bwi-angle-down': !isCollapsed(f.node) + 'bwi-angle-down': !isCollapsed(f.node), }" > diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html index 2740b229781..f77f279a96f 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html @@ -15,7 +15,7 @@ aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed, - 'bwi-angle-down': !isCollapsed + 'bwi-angle-down': !isCollapsed, }" > @@ -74,7 +74,7 @@

    aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed, - 'bwi-angle-down': !isCollapsed + 'bwi-angle-down': !isCollapsed, }" > diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html index 1df87a69ed6..381c06e8b67 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html @@ -12,7 +12,7 @@

    aria-hidden="true" [ngClass]="{ 'bwi-angle-right': isCollapsed, - 'bwi-angle-down': !isCollapsed + 'bwi-angle-down': !isCollapsed, }" >  {{ typesNode.name | i18n }} diff --git a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html index 05fff978b0c..34e9f734fc0 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html @@ -26,7 +26,7 @@ title="{{ 'customColor' | i18n }}" [ngClass]="{ '!tw-outline-[3px] tw-outline-primary-600 hover:tw-outline-[3px] hover:tw-outline-primary-600': - customColorSelected + customColorSelected, }" class="tw-relative tw-flex tw-h-24 tw-w-24 tw-cursor-pointer tw-place-content-center tw-content-center tw-justify-center tw-rounded-full tw-border tw-border-solid tw-border-secondary-600 tw-outline tw-outline-0 tw-outline-offset-1 hover:tw-outline-1 hover:tw-outline-primary-300 focus:tw-outline-2 focus:tw-outline-primary-600" [style.background-color]="customColor$ | async" diff --git a/apps/web/src/app/auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index 83327cfd805..9400e512c30 100644 --- a/apps/web/src/app/auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -34,7 +34,7 @@ [organizationInfo]="{ name: orgInfoFormGroup.value.name, email: orgInfoFormGroup.value.billingEmail, - type: trialOrganizationType + type: trialOrganizationType, }" [subscriptionProduct]=" product === ProductType.SecretsManager diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html index b827dc98bf4..1acf4c32097 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html @@ -33,7 +33,7 @@ [organizationInfo]="{ name: formGroup.get('name').value, email: formGroup.get('email').value, - type: productType + type: productType, }" [subscriptionProduct]="SubscriptionProduct.SecretsManager" (steppedBack)="steppedBack()" diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index d8e646c8d58..ed1dc6cda9b 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -102,7 +102,7 @@

    [organizationInfo]="{ name: orgInfoFormGroup.get('name').value, email: orgInfoFormGroup.get('email').value, - type: trialOrganizationType + type: trialOrganizationType, }" [subscriptionProduct]="SubscriptionProduct.PasswordManager" (steppedBack)="previousStep()" diff --git a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step-content.component.html b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step-content.component.html index 06b1dc7c51a..5d7d3c62d2f 100644 --- a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step-content.component.html @@ -6,7 +6,7 @@ [disabled]="disabled" class="tw-flex tw-w-full tw-items-center tw-border-none tw-bg-transparent" [ngClass]="{ - 'hover:tw-bg-secondary-100': !disabled && step.editable + 'hover:tw-bg-secondary-100': !disabled && step.editable, }" [attr.aria-expanded]="selected" > @@ -16,7 +16,7 @@ [ngClass]="{ 'tw-bg-primary-600 tw-text-contrast': selected, 'tw-bg-secondary-300 tw-text-main': !selected && !disabled && step.editable, - 'tw-bg-transparent tw-text-muted': disabled + 'tw-bg-transparent tw-text-muted': disabled, }" > {{ stepNumber }} @@ -30,13 +30,13 @@

    {{ step.label }} diff --git a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html index 427a4099175..1881df1c7f3 100644 --- a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html +++ b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html @@ -3,7 +3,7 @@ class="tw-inline-block tw-w-11/12 tw-pl-7" [ngClass]="{ 'tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300': applyBorder, - 'tw-pt-6': addSubLabelSpacing + 'tw-pt-6': addSubLabelSpacing, }" > diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 69c89d2430f..e6ed6475c4a 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -73,7 +73,7 @@ class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1" [ngClass]="{ 'tw-bg-primary-700 !tw-text-contrast': selectableProduct === selectedPlan, - 'tw-bg-secondary-100': !(selectableProduct === selectedPlan) + 'tw-bg-secondary-100': !(selectableProduct === selectedPlan), }" > {{ "recommended" | i18n }} @@ -82,7 +82,7 @@ class="tw-px-2 tw-pb-[4px]" [ngClass]="{ 'tw-py-1': !(selectableProduct === selectedPlan), - 'tw-py-0': selectableProduct === selectedPlan + 'tw-py-0': selectableProduct === selectedPlan, }" >

    diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html index 55f72401946..41346675bbb 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html @@ -5,7 +5,7 @@
    diff --git a/apps/web/src/app/shared/components/onboarding/onboarding-task.component.html b/apps/web/src/app/shared/components/onboarding/onboarding-task.component.html index 6623ac4afb7..f0c0b01e06e 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding-task.component.html +++ b/apps/web/src/app/shared/components/onboarding/onboarding-task.component.html @@ -2,7 +2,7 @@ {{ title }} diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.html b/apps/web/src/app/vault/individual-vault/add-edit.component.html index e1471c7784c..f8d3c894034 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.html +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.html @@ -497,7 +497,7 @@

    {{ title }}

    aria-hidden="true" [ngClass]="{ 'bwi-eye': !showCardNumber, - 'bwi-eye-slash': showCardNumber + 'bwi-eye-slash': showCardNumber, }" > diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts index 80d2d485481..a42b5228272 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts @@ -66,7 +66,7 @@ export class RoutedVaultFilterService implements OnDestroy { collectionId: filter.collectionId ?? null, folderId: filter.folderId ?? null, organizationId: - filter.organizationIdParamType === "path" ? null : filter.organizationId ?? null, + filter.organizationIdParamType === "path" ? null : (filter.organizationId ?? null), type: filter.type ?? null, }, queryParamsHandling: "merge", diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index 4fd9f2a6ff7..bb52dd4feb7 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -25,7 +25,7 @@ >

     {{ headerNode.node.name | i18n }} @@ -44,7 +44,7 @@

  • @@ -62,7 +62,7 @@

    class="bwi bwi-fw" [ngClass]="{ 'bwi-angle-right': isCollapsed(f.node), - 'bwi-angle-down': !isCollapsed(f.node) + 'bwi-angle-down': !isCollapsed(f.node), }" aria-hidden="true" > diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index 530fd6087f4..914d015110b 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -263,7 +263,7 @@

    aria-hidden="true" [ngClass]="{ 'bwi-angle-right': !showOpenIdCustomizations, - 'bwi-angle-down': showOpenIdCustomizations + 'bwi-angle-down': showOpenIdCustomizations, }" > diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html index 66ac422441a..ed58650f211 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html @@ -61,7 +61,7 @@

    {{ planCard.name }}

    class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1" [ngClass]="{ 'tw-grid-rows-1': additionalSeatsPurchased <= 0, - 'tw-grid-rows-2': additionalSeatsPurchased > 0 + 'tw-grid-rows-2': additionalSeatsPurchased > 0, }" > diff --git a/libs/angular/src/auth/components/environment-selector.component.html b/libs/angular/src/auth/components/environment-selector.component.html index a8dab8f1213..673f6479849 100644 --- a/libs/angular/src/auth/components/environment-selector.component.html +++ b/libs/angular/src/auth/components/environment-selector.component.html @@ -1,6 +1,6 @@
    diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 464ec84495a..9e6c27f6016 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -6,7 +6,7 @@ 'tw-pt-8': !decreaseTopPadding, 'tw-min-h-screen': clientType === 'web', 'tw-min-h-[calc(100vh-72px)]': clientType === 'browser', - 'tw-min-h-[calc(100vh-54px)]': clientType === 'desktop' + 'tw-min-h-[calc(100vh-54px)]': clientType === 'desktop', }" > diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 03adb11f21e..d46a5189a48 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -404,11 +404,11 @@ export class StateService< } const account = options?.useSecureStorage - ? (await this.secureStorageService.get(options.userId, options)) ?? + ? ((await this.secureStorageService.get(options.userId, options)) ?? (await this.storageService.get( options.userId, this.reconcileOptions(options, { htmlStorageLocation: HtmlStorageLocation.Local }), - )) + ))) : await this.storageService.get(options.userId, options); return account; } diff --git a/libs/common/src/platform/state/derive-definition.ts b/libs/common/src/platform/state/derive-definition.ts index 8f62d3a342c..826608b574c 100644 --- a/libs/common/src/platform/state/derive-definition.ts +++ b/libs/common/src/platform/state/derive-definition.ts @@ -164,7 +164,7 @@ export class DeriveDefinition { * Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. */ get cleanupDelayMs() { - return this.options.cleanupDelayMs < 0 ? 0 : this.options.cleanupDelayMs ?? 1000; + return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); } /** diff --git a/libs/common/src/platform/state/user-key-definition.ts b/libs/common/src/platform/state/user-key-definition.ts index 397e12d774f..1548ab2a7c1 100644 --- a/libs/common/src/platform/state/user-key-definition.ts +++ b/libs/common/src/platform/state/user-key-definition.ts @@ -63,7 +63,7 @@ export class UserKeyDefinition { * Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. */ get cleanupDelayMs() { - return this.options.cleanupDelayMs < 0 ? 0 : this.options.cleanupDelayMs ?? 1000; + return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); } /** diff --git a/libs/common/src/tools/integration/integration-context.ts b/libs/common/src/tools/integration/integration-context.ts index b527e79ce9e..3debf54ba36 100644 --- a/libs/common/src/tools/integration/integration-context.ts +++ b/libs/common/src/tools/integration/integration-context.ts @@ -60,7 +60,7 @@ export class IntegrationContext { options: { base64?: boolean; suffix?: string } = null, ): Settings extends ApiSettings ? string : never { // normalize `token` then assert it has a value - let token = "token" in this.settings ? (this.settings.token as string) ?? "" : ""; + let token = "token" in this.settings ? ((this.settings.token as string) ?? "") : ""; if (token === "") { const error = this.i18n.t("forwaderInvalidToken", this.metadata.name); throw error; diff --git a/libs/common/src/tools/integration/rpc/rest-client.ts b/libs/common/src/tools/integration/rpc/rest-client.ts index 3cfb8adce1f..c9096d0ce1d 100644 --- a/libs/common/src/tools/integration/rpc/rest-client.ts +++ b/libs/common/src/tools/integration/rpc/rest-client.ts @@ -91,7 +91,7 @@ export class RestClient { const message = parsed.message?.toString() ?? null; // `false` signals no message found - const result = error && message ? `${error}: ${message}` : error ?? message ?? false; + const result = error && message ? `${error}: ${message}` : (error ?? message ?? false); return result; } diff --git a/libs/components/src/chip-select/chip-select.component.html b/libs/components/src/chip-select/chip-select.component.html index 69181f9c87d..eb4ea89387e 100644 --- a/libs/components/src/chip-select/chip-select.component.html +++ b/libs/components/src/chip-select/chip-select.component.html @@ -6,7 +6,7 @@ ? 'tw-bg-text-muted tw-text-contrast tw-gap-1' : 'tw-bg-transparent tw-text-muted tw-gap-1.5', focusVisibleWithin() ? 'tw-ring-2 tw-ring-primary-500 tw-ring-offset-1' : '', - fullWidth ? 'tw-w-full' : 'tw-max-w-52' + fullWidth ? 'tw-w-full' : 'tw-max-w-52', ]" > @@ -14,7 +14,7 @@ type="button" class="fvw-target tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-pl-3 last:tw-pr-3 tw-truncate tw-text-[inherit]" [ngClass]="{ - 'tw-cursor-not-allowed': disabled + 'tw-cursor-not-allowed': disabled, }" [bitMenuTriggerFor]="menu" [disabled]="disabled" @@ -40,7 +40,7 @@ [disabled]="disabled" class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-p-1 tw-my-1 tw-mr-1 tw-text-[inherit] tw-border-solid tw-border tw-border-text-muted hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-aspect-square tw-flex tw-items-center tw-justify-center tw-h-fit focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent" [ngClass]="{ - 'tw-cursor-not-allowed': disabled + 'tw-cursor-not-allowed': disabled, }" (click)="clear()" > diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 6f38f3d64e8..c07239af6d6 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -38,7 +38,7 @@

    diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 2daefce556e..56c0af8f0db 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -25,7 +25,7 @@
    @@ -30,7 +30,7 @@
    Date: Tue, 1 Oct 2024 16:04:27 -0400 Subject: [PATCH 10/43] Fix Type Test --- .../default-notifications.service.spec.ts | 8 +++---- .../worker-webpush-connection.service.ts | 21 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index 567e66294fd..c5bb9a43b84 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -12,16 +12,15 @@ import { NotificationResponse } from "../../../models/response/notification.resp import { UserId } from "../../../types/guid"; import { AppIdService } from "../../abstractions/app-id.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; import { MessageSender } from "../../messaging"; import { SupportStatus } from "../../misc/support-status"; import { SyncService } from "../../sync"; import { DefaultNotificationsService } from "./default-notifications.service"; import { SignalRNotification, SignalRConnectionService } from "./signalr-connection.service"; -import { - WebPushConnector, - WorkerWebPushConnectionService, -} from "./worker-webpush-connection.service"; +import { WebPushConnector } from "./webpush-connection.service"; +import { WorkerWebPushConnectionService } from "./worker-webpush-connection.service"; describe("NotificationsService", () => { const syncService = mock(); @@ -91,6 +90,7 @@ describe("NotificationsService", () => { signalRNotificationConnectionService, authService, webPushNotificationConnectionService, + mock(), ); test("observable chain reacts to inputs properly", async () => { diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index 0d77fbc369a..e9c95dca3dd 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -22,11 +22,21 @@ import { WebPushNotificationsApiService } from "../../services/notifications/web import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; // Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event -interface PushSubscriptionChangeEvent extends ExtendableEvent { +interface PushSubscriptionChangeEvent { readonly newSubscription?: PushSubscription; readonly oldSubscription?: PushSubscription; } +// Ref: https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData +interface PushMessageData { + json(): any; +} + +// Ref: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent +interface PushEvent { + data: PushMessageData; +} + /** * An implementation for connecting to web push based notifications running in a Worker. */ @@ -48,11 +58,12 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService, this.pushChangeEvent = new Subject(); }); - const pushEventSubscription = fromEvent(self, "push").subscribe(this.pushEvent); + const pushEventSubscription = fromEvent(self, "push").subscribe(this.pushEvent); - const pushChangeEventSubscription = fromEvent(self, "pushsubscriptionchange").subscribe( - this.pushChangeEvent, - ); + const pushChangeEventSubscription = fromEvent( + self, + "pushsubscriptionchange", + ).subscribe(this.pushChangeEvent); subscription.add(pushEventSubscription); subscription.add(pushChangeEventSubscription); From 3802bb66e8ace143362bb50372f499006ea3adca Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:06:15 -0400 Subject: [PATCH 11/43] Write Time In More Readable Format --- .../notifications/internal/signalr-connection.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 90d5b976f0e..16339846049 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -7,8 +7,10 @@ import { NotificationResponse } from "../../../models/response/notification.resp import { UserId } from "../../../types/guid"; import { LogService } from "../../abstractions/log.service"; -const MIN_RECONNECT_TIME = 120000; -const MAX_RECONNECT_TIME = 300000; +// 2 Minutes +const MIN_RECONNECT_TIME = 2 * 60 * 1000; +// 5 Minutes +const MAX_RECONNECT_TIME = 5 * 60 * 1000; export type Heartbeat = { type: "Heartbeat" }; export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResponse }; From 71d9b4f47d1cc59f17cf2622454712b5753a8a16 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:32:31 -0400 Subject: [PATCH 12/43] Add SignalR Logger --- .../internal/signalr-connection.service.ts | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 16339846049..730a69c20bc 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -1,4 +1,10 @@ -import { HttpTransportType, HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; +import { + HttpTransportType, + HubConnectionBuilder, + HubConnectionState, + ILogger, + LogLevel, +} from "@microsoft/signalr"; import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; import { Observable, Subscription } from "rxjs"; @@ -17,6 +23,30 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp export type SignalRNotification = Heartbeat | ReceiveMessage; +class SignalRLogger implements ILogger { + constructor(private readonly logService: LogService) {} + + log(logLevel: LogLevel, message: string): void { + switch (logLevel) { + case LogLevel.Critical: + this.logService.error(message); + break; + case LogLevel.Error: + this.logService.error(message); + break; + case LogLevel.Warning: + this.logService.warning(message); + break; + case LogLevel.Information: + this.logService.info(message); + break; + case LogLevel.Debug: + this.logService.debug(message); + break; + } + } +} + export class SignalRConnectionService { constructor( private readonly apiService: ApiService, @@ -32,6 +62,7 @@ export class SignalRConnectionService { transport: HttpTransportType.WebSockets, }) .withHubProtocol(new MessagePackHubProtocol()) + .configureLogging(new SignalRLogger(this.logService)) .build(); connection.on("ReceiveMessage", (data: any) => { @@ -54,13 +85,12 @@ export class SignalRConnectionService { return Subscription.EMPTY; } - // TODO: Schedule reconnect with scheduler const randomTime = this.random(); const timeoutHandler = setTimeout(() => { connection .start() .then(() => (reconnectSubscription = null)) - .catch((error) => { + .catch(() => { reconnectSubscription = sheduleReconnect(); }); }, randomTime); @@ -69,12 +99,11 @@ export class SignalRConnectionService { }; connection.onclose((error) => { - // TODO: Do anything with error? reconnectSubscription = sheduleReconnect(); }); // Start connection - connection.start().catch((error) => { + connection.start().catch(() => { reconnectSubscription = sheduleReconnect(); }); From 5c276b14bec32335f7e130de2f49423ec85c9987 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 17 Oct 2024 08:14:55 -0400 Subject: [PATCH 13/43] `sheduleReconnect` -> `scheduleReconnect` Co-authored-by: Matt Gibson --- .../notifications/internal/signalr-connection.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 730a69c20bc..55a58b14bd0 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -76,7 +76,7 @@ export class SignalRConnectionService { let reconnectSubscription: Subscription | null = null; // Create schedule reconnect function - const sheduleReconnect = (): Subscription => { + const scheduleReconnect = (): Subscription => { if ( connection == null || connection.state !== HubConnectionState.Disconnected || @@ -91,7 +91,7 @@ export class SignalRConnectionService { .start() .then(() => (reconnectSubscription = null)) .catch(() => { - reconnectSubscription = sheduleReconnect(); + reconnectSubscription = scheduleReconnect(); }); }, randomTime); @@ -99,12 +99,12 @@ export class SignalRConnectionService { }; connection.onclose((error) => { - reconnectSubscription = sheduleReconnect(); + reconnectSubscription = scheduleReconnect(); }); // Start connection connection.start().catch(() => { - reconnectSubscription = sheduleReconnect(); + reconnectSubscription = scheduleReconnect(); }); return () => { From bf1d5967e963b1afa1ec2b4b03e7e2b3fc0259fc Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:21:00 -0400 Subject: [PATCH 14/43] Capture Support Context In Connector --- .../internal/webpush-connection.service.ts | 2 +- .../worker-webpush-connection.service.ts | 68 ++++++++++--------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/webpush-connection.service.ts index edc0828f64e..573cf2db0e3 100644 --- a/libs/common/src/platform/notifications/internal/webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/webpush-connection.service.ts @@ -5,7 +5,7 @@ import { UserId } from "../../../types/guid"; import { SupportStatus } from "../../misc/support-status"; export interface WebPushConnector { - connect$(userId: UserId): Observable; + connect$(): Observable; } export abstract class WebPushConnectionService { diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index e9c95dca3dd..02e238fa89c 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -40,7 +40,7 @@ interface PushEvent { /** * An implementation for connecting to web push based notifications running in a Worker. */ -export class WorkerWebPushConnectionService implements WebPushConnectionService, WebPushConnector { +export class WorkerWebPushConnectionService implements WebPushConnectionService { private pushEvent = new Subject(); private pushChangeEvent = new Subject(); @@ -78,7 +78,14 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService, if (config.push?.pushTechnology === PushTechnology.WebPush) { return { type: "supported", - service: this, + service: new MyWebPushConnector( + config.push.vapidPublicKey, + userId, + this.webPushApiService, + this.serviceWorkerRegistration, + this.pushEvent, + this.pushChangeEvent, + ), }; } @@ -86,32 +93,39 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService, }), ); } +} - connect$(userId: UserId): Observable { - // Do connection - return this.configService.serverConfig$.pipe( - switchMap((config) => { - if (config.push?.pushTechnology !== PushTechnology.WebPush) { - throw new Error( - "This client does not support WebPush, call 'supportStatus$' to check if the WebPush is supported before calling 'connect$'", - ); - } +class MyWebPushConnector implements WebPushConnector { + constructor( + private readonly vapidPublicKey: string, + private readonly userId: UserId, + private readonly webPushApiService: WebPushNotificationsApiService, + private readonly serviceWorkerRegistration: ServiceWorkerRegistration, + private readonly pushEvent$: Observable, + private readonly pushChangeEvent$: Observable, + ) {} - // Create connection - return this.getOrCreateSubscription$(config.push.vapidPublicKey).pipe( - concatMap((subscription) => { - return defer(async () => { - await this.webPushApiService.putSubscription(subscription.toJSON()); - }).pipe( - switchMap(() => this.pushEvent), - map((e) => new NotificationResponse(e.data.json().data)), - ); - }), + connect$(): Observable { + return this.getOrCreateSubscription$(this.vapidPublicKey).pipe( + concatMap((subscription) => { + return defer(() => this.webPushApiService.putSubscription(subscription.toJSON())).pipe( + switchMap(() => this.pushEvent$), + map((e) => new NotificationResponse(e.data.json().data)), ); }), ); } + private pushManagerSubscribe$(key: string) { + return defer( + async () => + await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }), + ); + } + private getOrCreateSubscription$(key: string) { return concat( defer(async () => await this.serviceWorkerRegistration.pushManager.getSubscription()).pipe( @@ -133,7 +147,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService, return of(subscription); }), ), - this.pushChangeEvent.pipe( + this.pushChangeEvent$.pipe( concatMap((event) => { // TODO: Is this enough, do I need to do something with oldSubscription return of(event.newSubscription); @@ -141,14 +155,4 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService, ), ); } - - private pushManagerSubscribe$(key: string) { - return defer( - async () => - await this.serviceWorkerRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: key, - }), - ); - } } From 2a81514540155174395b3fd1cabdd664318e7536 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:51:40 -0400 Subject: [PATCH 15/43] Remove Unneeded CSP Change --- apps/web/webpack.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index 5b02a4466d4..df325015aad 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -280,8 +280,6 @@ const devServer = ;connect-src 'self' ${envConfig.dev.wsConnectSrc ?? ""} - wss://* - https://push.services.mozilla.com wss://notifications.bitwarden.com https://notifications.bitwarden.com https://cdn.bitwarden.net From 9c65fc7e9ac7034a5750477ec3d1935c1542e3e6 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:23:57 -0400 Subject: [PATCH 16/43] Fix Build --- .../notifications/internal/default-notifications.service.ts | 2 +- .../internal/websocket-webpush-connection.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 6d1e7fbbab7..249af04cfea 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -90,7 +90,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return this.webPushConnectionService.supportStatus$(userId).pipe( supportSwitch({ supported: (service) => - service.connect$(userId).pipe(map((n) => [n, userId] as const)), + service.connect$().pipe(map((n) => [n, userId] as const)), notSupported: () => this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( filter((n) => n.type === "ReceiveMessage"), diff --git a/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts index f6339cafbef..a0952c313ec 100644 --- a/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts @@ -13,7 +13,7 @@ export class WebSocketWebPushConnectionService return of({ type: "not-supported", reason: "work-in-progress" }); } - connect$(userId: UserId): Observable { + connect$(): Observable { // TODO: Not currently recieving notifications return new Observable((subscriber) => { const socket = new WebSocket("wss://push.services.mozilla.com"); From cf427cb0eb1f214cfcdcc78b94cc65d26914ac70 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:26:43 -0400 Subject: [PATCH 17/43] Simplify `getOrCreateSubscription` --- .../worker-webpush-connection.service.ts | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index 02e238fa89c..b1680759490 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -5,7 +5,6 @@ import { fromEvent, map, Observable, - of, Subject, Subscription, switchMap, @@ -116,42 +115,38 @@ class MyWebPushConnector implements WebPushConnector { ); } - private pushManagerSubscribe$(key: string) { - return defer( - async () => - await this.serviceWorkerRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: key, - }), - ); + private async pushManagerSubscribe(key: string) { + return await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }); } private getOrCreateSubscription$(key: string) { return concat( - defer(async () => await this.serviceWorkerRegistration.pushManager.getSubscription()).pipe( - concatMap((subscription) => { - if (subscription == null) { - return this.pushManagerSubscribe$(key); - } - - const subscriptionKey = Utils.fromBufferToUrlB64( - subscription.options?.applicationServerKey, - ); - if (subscriptionKey !== key) { - // There is a subscription, but it's not for the current server, unsubscribe and then make a new one - return defer(() => subscription.unsubscribe()).pipe( - concatMap(() => this.pushManagerSubscribe$(key)), - ); - } - - return of(subscription); - }), - ), + defer(async () => { + const existingSubscription = + await this.serviceWorkerRegistration.pushManager.getSubscription(); + + if (existingSubscription == null) { + return await this.pushManagerSubscribe(key); + } + + const subscriptionKey = Utils.fromBufferToUrlB64( + existingSubscription.options?.applicationServerKey, + ); + + if (subscriptionKey !== key) { + // There is a subscription, but it's not for the current server, unsubscribe and then make a new one + await existingSubscription.unsubscribe(); + return await this.pushManagerSubscribe(key); + } + + return existingSubscription; + }), this.pushChangeEvent$.pipe( - concatMap((event) => { - // TODO: Is this enough, do I need to do something with oldSubscription - return of(event.newSubscription); - }), + // TODO: Is this enough, do I need to do something with oldSubscription? + map((event) => event.newSubscription), ), ); } From 2b267ef3e00b91bd03a084f8079c23710bd23983 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:34:48 -0400 Subject: [PATCH 18/43] Add More Docs to Matrix --- libs/common/spec/matrix.ts | 52 +++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/libs/common/spec/matrix.ts b/libs/common/spec/matrix.ts index 68747729e89..e4ac9f5537f 100644 --- a/libs/common/spec/matrix.ts +++ b/libs/common/spec/matrix.ts @@ -13,10 +13,46 @@ export class Matrix { private map: Map, MatrixOrValue, TValue>> = new Map(); /** + * This is especially useful for methods on a service that take inputs but return Observables. + * Generally when interacting with observables in tests, you want to use a simple SubjectLike + * type to back it instead, so that you can easily `next` values to simulate an emission. * - * @param mockFunction - * @param creator - * @returns + * @param mockFunction The function to have a Matrix based implementation added to it. + * @param creator The function to use to create the underlying value to return for the given arguments. + * @returns A "getter" function that allows you to retrieve the backing value that is used for the given arguments. + * + * @example + * ```ts + * interface MyService { + * event$(userId: UserId) => Observable + * } + * + * // Test + * const myService = mock(); + * const eventGetter = Matrix.autoMockMethod(myService.event$, (userId) => BehaviorSubject()); + * + * eventGetter("userOne").next(new UserEvent()); + * eventGetter("userTwo").next(new UserEvent()); + * ``` + * + * This replaces a more manual way of doing things like: + * + * ```ts + * const myService = mock(); + * const userOneSubject = new BehaviorSubject(); + * const userTwoSubject = new BehaviorSubject(); + * myService.event$.mockImplementation((userId) => { + * if (userId === "userOne") { + * return userOneSubject; + * } else if (userId === "userTwo") { + * return userTwoSubject; + * } + * return new BehaviorSubject(); + * }); + * + * userOneSubject.next(new UserEvent()); + * userTwoSubject.next(new UserEvent()); + * ``` */ static autoMockMethod( mockFunction: jest.Mock, @@ -33,6 +69,16 @@ export class Matrix { return getter; } + /** + * Gives the ability to get or create an entry in the matrix via the given args. + * + * @note The args are evaulated using Javascript equality so primivites work best. + * + * @param args The arguments to use to evaluate if an entry in the matrix exists already, + * or a value should be created and stored with those arguments. + * @param creator The function to call with the arguments to build a value. + * @returns The existing entry if one already exists or a new value created with the creator param. + */ getOrCreateEntry(args: TKeys, creator: (args: TKeys) => TValue): TValue { if (args.length === 0) { throw new Error("Matrix is not for you."); From ae42d4da7c2014f06e1a3b6a3148858de505de20 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 24 Oct 2024 09:37:40 -0400 Subject: [PATCH 19/43] Update libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts Co-authored-by: Matt Gibson --- .../notifications/internal/worker-webpush-connection.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index b1680759490..651bde5316a 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -72,6 +72,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService supportStatus$(userId: UserId): Observable> { // Check the server config to see if it supports sending WebPush notifications + // FIXME: get config of server for the specified userId, once ConfigService supports it return this.configService.serverConfig$.pipe>( map((config) => { if (config.push?.pushTechnology === PushTechnology.WebPush) { From 847a18c27c5abbbf41881afdcb27ca7d467e61c5 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 24 Oct 2024 11:25:06 -0400 Subject: [PATCH 20/43] Move API Service Into Notifications Folder --- .../browser/src/background/main.background.ts | 4 +-- .../src/services/jslib-services.module.ts | 7 ++--- .../platform/notifications/internal/index.ts | 1 + .../web-push-notifications-api.service.ts | 12 +++------ .../internal}/web-push.request.ts | 0 .../worker-webpush-connection.service.ts | 2 +- .../services/notifications/service.worker.ts | 26 ------------------- 7 files changed, 10 insertions(+), 42 deletions(-) rename libs/common/src/platform/{services/notifications => notifications/internal}/web-push-notifications-api.service.ts (75%) rename libs/common/src/platform/{services/notifications => notifications/internal}/web-push.request.ts (100%) delete mode 100644 libs/common/src/platform/services/notifications/service.worker.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index c740d75cce4..9d2e18c8026 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -112,6 +112,7 @@ import { WorkerWebPushConnectionService, SignalRConnectionService, UnsupportedWebPushConnectionService, + WebPushNotificationsApiService, } from "@bitwarden/common/platform/notifications/internal"; import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; @@ -130,7 +131,6 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; -import { DefaultWebPushNotificationsApiService } from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; import { StateService } from "@bitwarden/common/platform/services/state.service"; @@ -1039,7 +1039,7 @@ export default class MainBackground { if (BrowserApi.isManifestVersion(3)) { this.webPushConnectionService = new WorkerWebPushConnectionService( this.configService, - new DefaultWebPushNotificationsApiService(this.apiService, this.appIdService), + new WebPushNotificationsApiService(this.apiService, this.appIdService), (self as unknown as { registration: ServiceWorkerRegistration }).registration, ); } else { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 784f1f500dc..2245fd44081 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -172,6 +172,7 @@ import { SignalRConnectionService, UnsupportedWebPushConnectionService, WebPushConnectionService, + WebPushNotificationsApiService, } from "@bitwarden/common/platform/notifications/internal"; import { TaskSchedulerService, @@ -190,10 +191,6 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; -import { - DefaultWebPushNotificationsApiService, - WebPushNotificationsApiService, -} from "@bitwarden/common/platform/services/notifications/web-push-notifications-api.service"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; @@ -853,7 +850,7 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: WebPushNotificationsApiService, - useClass: DefaultWebPushNotificationsApiService, + useClass: WebPushNotificationsApiService, deps: [ApiServiceAbstraction, AppIdServiceAbstraction], }), safeProvider({ diff --git a/libs/common/src/platform/notifications/internal/index.ts b/libs/common/src/platform/notifications/internal/index.ts index 5571d227c38..067320ee56c 100644 --- a/libs/common/src/platform/notifications/internal/index.ts +++ b/libs/common/src/platform/notifications/internal/index.ts @@ -5,3 +5,4 @@ export * from "./noop-notifications.service"; export * from "./unsupported-webpush-connection.service"; export * from "./webpush-connection.service"; export * from "./websocket-webpush-connection.service"; +export * from "./web-push-notifications-api.service"; diff --git a/libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts b/libs/common/src/platform/notifications/internal/web-push-notifications-api.service.ts similarity index 75% rename from libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts rename to libs/common/src/platform/notifications/internal/web-push-notifications-api.service.ts index 9066fd0a064..b824b8c7d65 100644 --- a/libs/common/src/platform/services/notifications/web-push-notifications-api.service.ts +++ b/libs/common/src/platform/notifications/internal/web-push-notifications-api.service.ts @@ -3,19 +3,15 @@ import { AppIdService } from "../../abstractions/app-id.service"; import { WebPushRequest } from "./web-push.request"; -export abstract class WebPushNotificationsApiService { - /** - * Posts a device-user association to the server and ensures it's installed for push notifications - */ - abstract putSubscription(pushSubscription: PushSubscriptionJSON): Promise; -} - -export class DefaultWebPushNotificationsApiService implements WebPushNotificationsApiService { +export class WebPushNotificationsApiService { constructor( private readonly apiService: ApiService, private readonly appIdService: AppIdService, ) {} + /** + * Posts a device-user association to the server and ensures it's installed for push notifications + */ async putSubscription(pushSubscription: PushSubscriptionJSON): Promise { const request = WebPushRequest.from(pushSubscription); await this.apiService.send( diff --git a/libs/common/src/platform/services/notifications/web-push.request.ts b/libs/common/src/platform/notifications/internal/web-push.request.ts similarity index 100% rename from libs/common/src/platform/services/notifications/web-push.request.ts rename to libs/common/src/platform/notifications/internal/web-push.request.ts diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index b1680759490..e5aadfc12d4 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -16,8 +16,8 @@ import { UserId } from "../../../types/guid"; import { ConfigService } from "../../abstractions/config/config.service"; import { SupportStatus } from "../../misc/support-status"; import { Utils } from "../../misc/utils"; -import { WebPushNotificationsApiService } from "../../services/notifications/web-push-notifications-api.service"; +import { WebPushNotificationsApiService } from "./web-push-notifications-api.service"; import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; // Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event diff --git a/libs/common/src/platform/services/notifications/service.worker.ts b/libs/common/src/platform/services/notifications/service.worker.ts deleted file mode 100644 index 0af22a00b5f..00000000000 --- a/libs/common/src/platform/services/notifications/service.worker.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface ExtendableEvent extends Event { - waitUntil(f: Promise): void; -} - -interface PushEvent extends ExtendableEvent { - // From PushEvent - data: { - arrayBuffer(): ArrayBuffer; - blob(): Blob; - bytes(): Uint8Array; - json(): any; - text(): string; - }; -} - -// Register event listener for the 'push' event. -self.addEventListener("push", function (e: unknown) { - const event: PushEvent = e as PushEvent; - // Retrieve the textual payload from event.data (a PushMessageData object). - // Other formats are supported (ArrayBuffer, Blob, JSON), check out the documentation - // on https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData. - const payload = event.data ? event.data.text() : "no payload"; - - // eslint-disable-next-line no-console -- temporary PoC code FIXME: handle payloads - console.log("Received a push message with payload:", payload); -}); From cec35ea00709a54129d6dd2fcf586d81167cc007 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:40:17 -0400 Subject: [PATCH 21/43] Allow Connection When Account Is Locked --- .../default-notifications.service.spec.ts | 34 ++++++++++++++----- .../internal/default-notifications.service.ts | 9 +++-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index c5bb9a43b84..4a271f2665f 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject import { LogoutReason } from "@bitwarden/auth/common"; +import { awaitAsync } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; @@ -102,11 +103,8 @@ describe("NotificationsService", () => { emitNotificationUrl("http://test.example.com"); authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); const webPush = mock(); - - const webPushGetter = Matrix.autoMockMethod( - webPush.connect$, - () => new Subject(), - ); + const webPushSubject = new Subject(); + webPush.connect$.mockImplementation(() => webPushSubject); // Start listening to notifications const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(4))); @@ -115,10 +113,8 @@ describe("NotificationsService", () => { webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush }); // Emit a couple notifications through WebPush - webPushGetter(mockUser1).next(new NotificationResponse({ type: NotificationType.LogOut })); - webPushGetter(mockUser1).next( - new NotificationResponse({ type: NotificationType.SyncCipherCreate }), - ); + webPushSubject.next(new NotificationResponse({ type: NotificationType.LogOut })); + webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncCipherCreate })); // Switch to having no active user emitActiveUser(null); @@ -172,4 +168,24 @@ describe("NotificationsService", () => { expectNotification(notifications[2], mockUser2, NotificationType.SyncCipherUpdate); expectNotification(notifications[3], mockUser2, NotificationType.SyncCipherDelete); }); + + test("that a transition from locked to unlocked doesn't reconnect", async () => { + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.Locked); + webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" }); + + const notificationsSubscriptions = sut.notifications$.subscribe(); + await awaitAsync(1); + + authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + await awaitAsync(1); + + expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1); + expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith( + mockUser1, + "http://test.example.com", + ); + notificationsSubscriptions.unsubscribe(); + }); }); diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 14a8571d459..7a10742844b 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -75,9 +75,14 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract // Check if authenticated return this.authService.authStatusFor$(userId).pipe( + map( + (authStatus) => + authStatus === AuthenticationStatus.Locked || + authStatus === AuthenticationStatus.Unlocked, + ), distinctUntilChanged(), - switchMap((authStatus) => { - if (authStatus !== AuthenticationStatus.Unlocked) { + switchMap((hasAccessToken) => { + if (!hasAccessToken) { return EMPTY; } From 72dfc1fee9a1e26387b3d8ee02c4a382bb418486 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 30 Oct 2024 13:41:13 -0400 Subject: [PATCH 22/43] Add Comments to NotificationsService --- .../platform/notifications/notifications.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libs/common/src/platform/notifications/notifications.service.ts b/libs/common/src/platform/notifications/notifications.service.ts index c365d6e4011..aa4ff2a57a6 100644 --- a/libs/common/src/platform/notifications/notifications.service.ts +++ b/libs/common/src/platform/notifications/notifications.service.ts @@ -1,7 +1,18 @@ import { Subscription } from "rxjs"; +/** + * A service offering abilities to interact with push notifications from the server. + */ export abstract class NotificationsService { + /** + * Starts automatic listening and processing of notifications, should only be called once per application, + * or you will risk notifications being processed multiple times. + */ abstract startListening(): Subscription; + // TODO: Delete this method in favor of an `ActivityService` that notifications can depend on. + // https://bitwarden.atlassian.net/browse/PM-14264 abstract reconnectFromActivity(): void; + // TODO: Delete this method in favor of an `ActivityService` that notifications can depend on. + // https://bitwarden.atlassian.net/browse/PM-14264 abstract disconnectFromInactivity(): void; } From 71e6ea14b84e125835824d45c72bf3a6a91475e9 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:32:39 -0400 Subject: [PATCH 23/43] Only Change Support Status If Public Key Changes --- .../worker-webpush-connection.service.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index 3b5549da58a..ff7ee1e2b7b 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -2,6 +2,7 @@ import { concat, concatMap, defer, + distinctUntilChanged, fromEvent, map, Observable, @@ -73,23 +74,31 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService supportStatus$(userId: UserId): Observable> { // Check the server config to see if it supports sending WebPush notifications // FIXME: get config of server for the specified userId, once ConfigService supports it - return this.configService.serverConfig$.pipe>( - map((config) => { - if (config.push?.pushTechnology === PushTechnology.WebPush) { + return this.configService.serverConfig$.pipe( + map((config) => + config.push?.pushTechnology === PushTechnology.WebPush ? config.push.vapidPublicKey : null, + ), + // No need to re-emit when there is new server config if the vapidPublicKey is still there and the exact same + distinctUntilChanged(), + map((publicKey) => { + if (publicKey == null) { return { - type: "supported", - service: new MyWebPushConnector( - config.push.vapidPublicKey, - userId, - this.webPushApiService, - this.serviceWorkerRegistration, - this.pushEvent, - this.pushChangeEvent, - ), - }; + type: "not-supported", + reason: "server-not-configured", + } satisfies SupportStatus; } - return { type: "not-supported", reason: "server-not-configured" }; + return { + type: "supported", + service: new MyWebPushConnector( + publicKey, + userId, + this.webPushApiService, + this.serviceWorkerRegistration, + this.pushEvent, + this.pushChangeEvent, + ), + } satisfies SupportStatus; }), ); } From a3516642c1048dec15adf4f7a00bca818a8d2e1e Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:18:51 -0400 Subject: [PATCH 24/43] Move Service Choice Out To Method --- .../internal/default-notifications.service.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 7a10742844b..e4dfef64d70 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -92,17 +92,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return EMPTY; } - return this.webPushConnectionService.supportStatus$(userId).pipe( - supportSwitch({ - supported: (service) => - service.connect$().pipe(map((n) => [n, userId] as const)), - notSupported: () => - this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( - filter((n) => n.type === "ReceiveMessage"), - map((n) => [(n as ReceiveMessage).message, userId] as const), - ), - }), - ); + return this.choosePushService(userId, notificationsUrl); }), ); }), @@ -111,6 +101,19 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract ); } + private choosePushService(userId: UserId, notificationsUrl: string) { + return this.webPushConnectionService.supportStatus$(userId).pipe( + supportSwitch({ + supported: (service) => service.connect$().pipe(map((n) => [n, userId] as const)), + notSupported: () => + this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( + filter((n) => n.type === "ReceiveMessage"), + map((n) => [(n as ReceiveMessage).message, userId] as const), + ), + }), + ); + } + private async processNotification(notification: NotificationResponse, userId: UserId) { const appId = await this.appIdService.getAppId(); if (notification == null || notification.contextId === appId) { From 37e2eff7244de17f8b8bb8ebdbaa2c13ea36554a Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:21:30 -0400 Subject: [PATCH 25/43] Use Named Constant For Disabled Notification Url --- .../notifications/internal/default-notifications.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index e4dfef64d70..3e54f198b3c 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -33,6 +33,8 @@ import { NotificationsService as NotificationsServiceAbstraction } from "../noti import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service"; import { WebPushConnectionService } from "./webpush-connection.service"; +const DISABLED_NOTIFICATIONS_URL = "http://-"; + export class DefaultNotificationsService implements NotificationsServiceAbstraction { notifications$: Observable; @@ -69,7 +71,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract map((environment) => environment.getNotificationsUrl()), distinctUntilChanged(), switchMap((notificationsUrl) => { - if (notificationsUrl === "http://-") { + if (notificationsUrl === DISABLED_NOTIFICATIONS_URL) { return EMPTY; } From 340f56b304f57b8165e3590de34d3f124f3f9438 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:49:09 -0500 Subject: [PATCH 26/43] Add Test & Cleanup --- .../default-notifications.service.spec.ts | 15 ++++++++-- .../internal/default-notifications.service.ts | 4 +-- .../internal/webpush-connection.service.ts | 2 +- .../websocket-webpush-connection.service.ts | 30 ++----------------- .../worker-webpush-connection.service.ts | 8 ++--- 5 files changed, 22 insertions(+), 37 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index 4a271f2665f..530e567bd72 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -18,7 +18,10 @@ import { MessageSender } from "../../messaging"; import { SupportStatus } from "../../misc/support-status"; import { SyncService } from "../../sync"; -import { DefaultNotificationsService } from "./default-notifications.service"; +import { + DefaultNotificationsService, + DISABLED_NOTIFICATIONS_URL, +} from "./default-notifications.service"; import { SignalRNotification, SignalRConnectionService } from "./signalr-connection.service"; import { WebPushConnector } from "./webpush-connection.service"; import { WorkerWebPushConnectionService } from "./worker-webpush-connection.service"; @@ -104,7 +107,7 @@ describe("NotificationsService", () => { authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); const webPush = mock(); const webPushSubject = new Subject(); - webPush.connect$.mockImplementation(() => webPushSubject); + webPush.notifications$ = webPushSubject; // Start listening to notifications const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(4))); @@ -188,4 +191,12 @@ describe("NotificationsService", () => { ); notificationsSubscriptions.unsubscribe(); }); + + test("that a disabled notification stream does not connect to any notification stream", () => { + emitActiveUser(mockUser1); + emitNotificationUrl(DISABLED_NOTIFICATIONS_URL); + + expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled(); + expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled(); + }); }); diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 3e54f198b3c..aefde13a96f 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -33,7 +33,7 @@ import { NotificationsService as NotificationsServiceAbstraction } from "../noti import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service"; import { WebPushConnectionService } from "./webpush-connection.service"; -const DISABLED_NOTIFICATIONS_URL = "http://-"; +export const DISABLED_NOTIFICATIONS_URL = "http://-"; export class DefaultNotificationsService implements NotificationsServiceAbstraction { notifications$: Observable; @@ -106,7 +106,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract private choosePushService(userId: UserId, notificationsUrl: string) { return this.webPushConnectionService.supportStatus$(userId).pipe( supportSwitch({ - supported: (service) => service.connect$().pipe(map((n) => [n, userId] as const)), + supported: (service) => service.notifications$.pipe(map((n) => [n, userId] as const)), notSupported: () => this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( filter((n) => n.type === "ReceiveMessage"), diff --git a/libs/common/src/platform/notifications/internal/webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/webpush-connection.service.ts index 573cf2db0e3..17ef87ea83e 100644 --- a/libs/common/src/platform/notifications/internal/webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/webpush-connection.service.ts @@ -5,7 +5,7 @@ import { UserId } from "../../../types/guid"; import { SupportStatus } from "../../misc/support-status"; export interface WebPushConnector { - connect$(): Observable; + notifications$: Observable; } export abstract class WebPushConnectionService { diff --git a/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts index a0952c313ec..7a25fb4ce50 100644 --- a/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/websocket-webpush-connection.service.ts @@ -1,38 +1,12 @@ -import { fromEvent, Observable, of } from "rxjs"; +import { Observable, of } from "rxjs"; -import { NotificationResponse } from "../../../models/response/notification.response"; import { UserId } from "../../../types/guid"; import { SupportStatus } from "../../misc/support-status"; import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; -export class WebSocketWebPushConnectionService - implements WebPushConnectionService, WebPushConnector -{ +export class WebSocketWebPushConnectionService implements WebPushConnectionService { supportStatus$(userId: UserId): Observable> { return of({ type: "not-supported", reason: "work-in-progress" }); } - - connect$(): Observable { - // TODO: Not currently recieving notifications - return new Observable((subscriber) => { - const socket = new WebSocket("wss://push.services.mozilla.com"); - - const messageSubscription = fromEvent(socket, "message").subscribe({ - next: (event) => { - subscriber.next(new NotificationResponse(event.data)); - }, - }); - - const closeSubscription = fromEvent(socket, "close").subscribe(() => - subscriber.complete(), - ); - - return () => { - messageSubscription.unsubscribe(); - closeSubscription.unsubscribe(); - socket.close(); - }; - }); - } } diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index ff7ee1e2b7b..b0b5c07ecad 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -105,6 +105,8 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService } class MyWebPushConnector implements WebPushConnector { + notifications$: Observable; + constructor( private readonly vapidPublicKey: string, private readonly userId: UserId, @@ -112,10 +114,8 @@ class MyWebPushConnector implements WebPushConnector { private readonly serviceWorkerRegistration: ServiceWorkerRegistration, private readonly pushEvent$: Observable, private readonly pushChangeEvent$: Observable, - ) {} - - connect$(): Observable { - return this.getOrCreateSubscription$(this.vapidPublicKey).pipe( + ) { + this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe( concatMap((subscription) => { return defer(() => this.webPushApiService.putSubscription(subscription.toJSON())).pipe( switchMap(() => this.pushEvent$), From 4b2ad4f9f549a1121aa3283a26be3c2bf32a5470 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:02:35 -0500 Subject: [PATCH 27/43] Flatten --- .../internal/default-notifications.service.ts | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index aefde13a96f..a798eead45c 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -76,29 +76,30 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract } // Check if authenticated - return this.authService.authStatusFor$(userId).pipe( - map( - (authStatus) => - authStatus === AuthenticationStatus.Locked || - authStatus === AuthenticationStatus.Unlocked, - ), - distinctUntilChanged(), - switchMap((hasAccessToken) => { - if (!hasAccessToken) { - return EMPTY; - } - - return this.activitySubject.pipe( - switchMap((activityStatus) => { - if (activityStatus === "inactive") { - return EMPTY; - } - - return this.choosePushService(userId, notificationsUrl); - }), - ); - }), - ); + return this.evaluateAuthStatus(userId, notificationsUrl); + }), + ); + } + + private evaluateAuthStatus(userId: UserId, notificationsUrl: string) { + return this.authService.authStatusFor$(userId).pipe( + map( + (authStatus) => + authStatus === AuthenticationStatus.Locked || + authStatus === AuthenticationStatus.Unlocked, + ), + distinctUntilChanged(), + switchMap((hasAccessToken) => { + if (!hasAccessToken) { + return EMPTY; + } + return this.activitySubject; + }), + switchMap((activityStatus) => { + if (activityStatus !== "inactive") { + return EMPTY; + } + return this.choosePushService(userId, notificationsUrl); }), ); } From 4df0dec0006dba0a5f8b576c49feace95d8be9b0 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 20 Nov 2024 12:43:07 -0500 Subject: [PATCH 28/43] Move Tests into `beforeEach` & `afterEach` --- .../default-notifications.service.spec.ts | 183 ++++++++++++------ .../internal/default-notifications.service.ts | 2 +- 2 files changed, 120 insertions(+), 65 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index 530e567bd72..244ae97980e 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -1,4 +1,4 @@ -import { mock } from "jest-mock-extended"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; @@ -23,49 +23,87 @@ import { DISABLED_NOTIFICATIONS_URL, } from "./default-notifications.service"; import { SignalRNotification, SignalRConnectionService } from "./signalr-connection.service"; -import { WebPushConnector } from "./webpush-connection.service"; +import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; import { WorkerWebPushConnectionService } from "./worker-webpush-connection.service"; describe("NotificationsService", () => { - const syncService = mock(); - const appIdService = mock(); - const environmentService = mock(); - const logoutCallback = jest.fn, [logoutReason: LogoutReason]>(); - const messagingService = mock(); - const accountService = mock(); - const signalRNotificationConnectionService = mock(); - const authService = mock(); - const webPushNotificationConnectionService = mock(); - - const activeAccount = new BehaviorSubject>( - null, - ); - accountService.activeAccount$ = activeAccount.asObservable(); - - const environment = new BehaviorSubject>({ - getNotificationsUrl: () => "https://notifications.bitwarden.com", - } as Environment); - - environmentService.environment$ = environment; - - const authStatusGetter = Matrix.autoMockMethod( - authService.authStatusFor$, - () => new BehaviorSubject(AuthenticationStatus.LoggedOut), - ); - - const webPushSupportGetter = Matrix.autoMockMethod( - webPushNotificationConnectionService.supportStatus$, - () => - new BehaviorSubject>({ - type: "not-supported", - reason: "test", - }), - ); - - const signalrNotificationGetter = Matrix.autoMockMethod( - signalRNotificationConnectionService.connect$, - () => new Subject(), - ); + let syncService: MockProxy; + let appIdService: MockProxy; + let environmentService: MockProxy; + let logoutCallback: jest.Mock, [logoutReason: LogoutReason]>; + let messagingService: MockProxy; + let accountService: MockProxy; + let signalRNotificationConnectionService: MockProxy; + let authService: MockProxy; + let webPushNotificationConnectionService: MockProxy; + + let activeAccount: BehaviorSubject>; + + let environment: BehaviorSubject>; + + let authStatusGetter: (userId: UserId) => BehaviorSubject; + + let webPushSupportGetter: (userId: UserId) => BehaviorSubject>; + + let signalrNotificationGetter: ( + userId: UserId, + notificationsUrl: string, + ) => Subject; + + let sut: DefaultNotificationsService; + + beforeEach(() => { + syncService = mock(); + appIdService = mock(); + environmentService = mock(); + logoutCallback = jest.fn, [logoutReason: LogoutReason]>(); + messagingService = mock(); + accountService = mock(); + signalRNotificationConnectionService = mock(); + authService = mock(); + webPushNotificationConnectionService = mock(); + + activeAccount = new BehaviorSubject>(null); + accountService.activeAccount$ = activeAccount.asObservable(); + + environment = new BehaviorSubject>({ + getNotificationsUrl: () => "https://notifications.bitwarden.com", + } as Environment); + + environmentService.environment$ = environment; + + authStatusGetter = Matrix.autoMockMethod( + authService.authStatusFor$, + () => new BehaviorSubject(AuthenticationStatus.LoggedOut), + ); + + webPushSupportGetter = Matrix.autoMockMethod( + webPushNotificationConnectionService.supportStatus$, + () => + new BehaviorSubject>({ + type: "not-supported", + reason: "test", + }), + ); + + signalrNotificationGetter = Matrix.autoMockMethod( + signalRNotificationConnectionService.connect$, + () => new Subject(), + ); + + sut = new DefaultNotificationsService( + syncService, + appIdService, + environmentService, + logoutCallback, + messagingService, + accountService, + signalRNotificationConnectionService, + authService, + webPushNotificationConnectionService, + mock(), + ); + }); const mockUser1 = "user1" as UserId; const mockUser2 = "user2" as UserId; @@ -84,18 +122,35 @@ describe("NotificationsService", () => { } as Environment); } - const sut = new DefaultNotificationsService( - syncService, - appIdService, - environmentService, - logoutCallback, - messagingService, - accountService, - signalRNotificationConnectionService, - authService, - webPushNotificationConnectionService, - mock(), - ); + const expectNotification = ( + notification: readonly [NotificationResponse, UserId], + expectedUser: UserId, + expectedType: NotificationType, + ) => { + const [actualNotification, actualUser] = notification; + expect(actualUser).toBe(expectedUser); + expect(actualNotification.type).toBe(expectedType); + }; + + it("emits notifications through WebPush when supported", async () => { + const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2))); + + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + + const webPush = mock(); + const webPushSubject = new Subject(); + webPush.notifications$ = webPushSubject; + + webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush }); + webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate })); + webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderDelete })); + + const notifications = await notificationsPromise; + expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate); + expectNotification(notifications[1], mockUser1, NotificationType.SyncFolderDelete); + }); test("observable chain reacts to inputs properly", async () => { // Sets up two active unlocked user, one pointing to an environment with WebPush, the other @@ -145,6 +200,12 @@ describe("NotificationsService", () => { // User could turn off notifications (this would generally happen while there is no active user) emitNotificationUrl("http://-"); + // Since notifications are shut down by this url, this notification shouldn't be read ever + signalrNotificationGetter(mockUser2, "http://-").next({ + type: "ReceiveMessage", + message: new NotificationResponse({ type: NotificationType.LogOut }), + }); + // User could turn them back on emitNotificationUrl("http://test.example.com"); @@ -156,23 +217,13 @@ describe("NotificationsService", () => { const notifications = await notificationsPromise; - const expectNotification = ( - notification: readonly [NotificationResponse, UserId], - expectedUser: UserId, - expectedType: NotificationType, - ) => { - const [actualNotification, actualUser] = notification; - expect(actualUser).toBe(expectedUser); - expect(actualNotification.type).toBe(expectedType); - }; - expectNotification(notifications[0], mockUser1, NotificationType.LogOut); expectNotification(notifications[1], mockUser1, NotificationType.SyncCipherCreate); expectNotification(notifications[2], mockUser2, NotificationType.SyncCipherUpdate); expectNotification(notifications[3], mockUser2, NotificationType.SyncCipherDelete); }); - test("that a transition from locked to unlocked doesn't reconnect", async () => { + it("does not re-connect when the user transitions from locked to unlocked doesn't reconnect", async () => { emitActiveUser(mockUser1); emitNotificationUrl("http://test.example.com"); authStatusGetter(mockUser1).next(AuthenticationStatus.Locked); @@ -192,6 +243,10 @@ describe("NotificationsService", () => { notificationsSubscriptions.unsubscribe(); }); + it("re-connects when a user transitions from ", () => { + // + }); + test("that a disabled notification stream does not connect to any notification stream", () => { emitActiveUser(mockUser1); emitNotificationUrl(DISABLED_NOTIFICATIONS_URL); diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index a798eead45c..df60ca0078d 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -96,7 +96,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return this.activitySubject; }), switchMap((activityStatus) => { - if (activityStatus !== "inactive") { + if (activityStatus === "inactive") { return EMPTY; } return this.choosePushService(userId, notificationsUrl); From ac47da4ee0119413fa75107e55e21665ab9df51b Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:22:06 -0500 Subject: [PATCH 29/43] Add Tests --- .../default-notifications.service.spec.ts | 115 +++++++++++------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index 244ae97980e..41322be8924 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -152,75 +152,76 @@ describe("NotificationsService", () => { expectNotification(notifications[1], mockUser1, NotificationType.SyncFolderDelete); }); - test("observable chain reacts to inputs properly", async () => { - // Sets up two active unlocked user, one pointing to an environment with WebPush, the other - // falling back to using SignalR + it("switches to SignalR when web push is not supported.", async () => { + const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2))); - // We start with one active user with an unlocked account that emitActiveUser(mockUser1); emitNotificationUrl("http://test.example.com"); authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + const webPush = mock(); const webPushSubject = new Subject(); webPush.notifications$ = webPushSubject; - // Start listening to notifications - const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(4))); - - // Pretend web push becomes supported webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush }); + webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate })); - // Emit a couple notifications through WebPush - webPushSubject.next(new NotificationResponse({ type: NotificationType.LogOut })); - webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncCipherCreate })); - - // Switch to having no active user - emitActiveUser(null); - - // Switch to another user emitActiveUser(mockUser2); - - // User unlocks authStatusGetter(mockUser2).next(AuthenticationStatus.Unlocked); - - // Web push is not supported for second user + // Second user does not support web push webPushSupportGetter(mockUser2).next({ type: "not-supported", reason: "test" }); - // They should connect and receive notifications from signalR signalrNotificationGetter(mockUser2, "http://test.example.com").next({ type: "ReceiveMessage", message: new NotificationResponse({ type: NotificationType.SyncCipherUpdate }), }); - // Heartbeats should be ignored. - signalrNotificationGetter(mockUser2, "http://test.example.com").next({ - type: "Heartbeat", - }); + const notifications = await notificationsPromise; + expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate); + expectNotification(notifications[1], mockUser2, NotificationType.SyncCipherUpdate); + }); - // User could turn off notifications (this would generally happen while there is no active user) - emitNotificationUrl("http://-"); + it("switches to WebPush when it becomes supported.", async () => { + const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2))); - // Since notifications are shut down by this url, this notification shouldn't be read ever - signalrNotificationGetter(mockUser2, "http://-").next({ + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" }); + + signalrNotificationGetter(mockUser1, "http://test.example.com").next({ type: "ReceiveMessage", - message: new NotificationResponse({ type: NotificationType.LogOut }), + message: new NotificationResponse({ type: NotificationType.AuthRequest }), }); - // User could turn them back on + const webPush = mock(); + const webPushSubject = new Subject(); + webPush.notifications$ = webPushSubject; + + webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush }); + webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncLoginDelete })); + + const notifications = await notificationsPromise; + expectNotification(notifications[0], mockUser1, NotificationType.AuthRequest); + expectNotification(notifications[1], mockUser1, NotificationType.SyncLoginDelete); + }); + + it("does not emit SignalR heartbeats", async () => { + const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(1))); + + emitActiveUser(mockUser1); emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" }); - // SignalR emits another notification - signalrNotificationGetter(mockUser2, "http://test.example.com").next({ + signalrNotificationGetter(mockUser1, "http://test.example.com").next({ type: "Heartbeat" }); + signalrNotificationGetter(mockUser1, "http://test.example.com").next({ type: "ReceiveMessage", - message: new NotificationResponse({ type: NotificationType.SyncCipherDelete }), + message: new NotificationResponse({ type: NotificationType.AuthRequestResponse }), }); const notifications = await notificationsPromise; - - expectNotification(notifications[0], mockUser1, NotificationType.LogOut); - expectNotification(notifications[1], mockUser1, NotificationType.SyncCipherCreate); - expectNotification(notifications[2], mockUser2, NotificationType.SyncCipherUpdate); - expectNotification(notifications[3], mockUser2, NotificationType.SyncCipherDelete); + expectNotification(notifications[0], mockUser1, NotificationType.AuthRequestResponse); }); it("does not re-connect when the user transitions from locked to unlocked doesn't reconnect", async () => { @@ -243,15 +244,41 @@ describe("NotificationsService", () => { notificationsSubscriptions.unsubscribe(); }); - it("re-connects when a user transitions from ", () => { - // - }); - - test("that a disabled notification stream does not connect to any notification stream", () => { + it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])( + "connects when a user transitions from logged out to %s", + async (newStatus: AuthenticationStatus) => { + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.LoggedOut); + webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" }); + + const notificationsSubscriptions = sut.notifications$.subscribe(); + await awaitAsync(1); + + authStatusGetter(mockUser1).next(newStatus); + await awaitAsync(1); + + expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1); + expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith( + mockUser1, + "http://test.example.com", + ); + notificationsSubscriptions.unsubscribe(); + }, + ); + + it("does not connect to any notification stream when notifications are disabled through special url", () => { emitActiveUser(mockUser1); emitNotificationUrl(DISABLED_NOTIFICATIONS_URL); expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled(); expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled(); }); + + it("does not connect to any notification stream when there is no active user", () => { + emitActiveUser(null); + + expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled(); + expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled(); + }); }); From 98076cfc97b48057145d2f27522f631369c9fcdc Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:36:01 -0500 Subject: [PATCH 30/43] Test `distinctUntilChanged`'s Operators More --- .../default-notifications.service.spec.ts | 64 ++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts index 41322be8924..13be6a61de5 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.spec.ts @@ -224,25 +224,33 @@ describe("NotificationsService", () => { expectNotification(notifications[0], mockUser1, NotificationType.AuthRequestResponse); }); - it("does not re-connect when the user transitions from locked to unlocked doesn't reconnect", async () => { - emitActiveUser(mockUser1); - emitNotificationUrl("http://test.example.com"); - authStatusGetter(mockUser1).next(AuthenticationStatus.Locked); - webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" }); + it.each([ + { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked }, + { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked }, + { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked }, + { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked }, + ])( + "does not re-connect when the user transitions from $initialStatus to $updatedStatus", + async ({ initialStatus, updatedStatus }) => { + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(initialStatus); + webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" }); - const notificationsSubscriptions = sut.notifications$.subscribe(); - await awaitAsync(1); + const notificationsSubscriptions = sut.notifications$.subscribe(); + await awaitAsync(1); - authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); - await awaitAsync(1); + authStatusGetter(mockUser1).next(updatedStatus); + await awaitAsync(1); - expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1); - expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith( - mockUser1, - "http://test.example.com", - ); - notificationsSubscriptions.unsubscribe(); - }); + expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1); + expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith( + mockUser1, + "http://test.example.com", + ); + notificationsSubscriptions.unsubscribe(); + }, + ); it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])( "connects when a user transitions from logged out to %s", @@ -268,17 +276,41 @@ describe("NotificationsService", () => { ); it("does not connect to any notification stream when notifications are disabled through special url", () => { + const subscription = sut.notifications$.subscribe(); emitActiveUser(mockUser1); emitNotificationUrl(DISABLED_NOTIFICATIONS_URL); expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled(); expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled(); + + subscription.unsubscribe(); }); it("does not connect to any notification stream when there is no active user", () => { + const subscription = sut.notifications$.subscribe(); emitActiveUser(null); expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled(); expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + it("does not reconnect if the same notification url is emitted", async () => { + const subscription = sut.notifications$.subscribe(); + + emitActiveUser(mockUser1); + emitNotificationUrl("http://test.example.com"); + authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked); + + await awaitAsync(1); + + expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1); + emitNotificationUrl("http://test.example.com"); + + await awaitAsync(1); + + expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1); + subscription.unsubscribe(); }); }); From 20c49e80a1bf91168c7daf11b531e0e51481bdc5 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:08:45 -0500 Subject: [PATCH 31/43] Make Helper And Cleanup Chain --- .../internal/default-notifications.service.ts | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index df60ca0078d..e6ec3390378 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -30,7 +30,7 @@ import { MessagingService } from "../../abstractions/messaging.service"; import { supportSwitch } from "../../misc/support-status"; import { NotificationsService as NotificationsServiceAbstraction } from "../notifications.service"; -import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service"; +import { SignalRConnectionService } from "./signalr-connection.service"; import { WebPushConnectionService } from "./webpush-connection.service"; export const DISABLED_NOTIFICATIONS_URL = "http://-"; @@ -61,62 +61,69 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return EMPTY; } - return this.connectUser$(activeAccountId); + return this.userNotifications$(activeAccountId).pipe( + map((notification) => [notification, activeAccountId] as const), + ); }), ); } - private connectUser$(userId: UserId) { + /** + * Retrieves a stream of push notifications for the given user. + * @param userId The user id of the user to get the push notifications for. + */ + private userNotifications$(userId: UserId) { return this.environmentService.environment$.pipe( - map((environment) => environment.getNotificationsUrl()), + map((env) => env.getNotificationsUrl()), distinctUntilChanged(), switchMap((notificationsUrl) => { if (notificationsUrl === DISABLED_NOTIFICATIONS_URL) { return EMPTY; } - // Check if authenticated - return this.evaluateAuthStatus(userId, notificationsUrl); + return this.userNotificationsHelper$(userId, notificationsUrl); }), ); } - private evaluateAuthStatus(userId: UserId, notificationsUrl: string) { - return this.authService.authStatusFor$(userId).pipe( - map( - (authStatus) => - authStatus === AuthenticationStatus.Locked || - authStatus === AuthenticationStatus.Unlocked, - ), - distinctUntilChanged(), + private userNotificationsHelper$(userId: UserId, notificationsUrl: string) { + return this.hasAccessToken$(userId).pipe( switchMap((hasAccessToken) => { if (!hasAccessToken) { return EMPTY; } + return this.activitySubject; }), switchMap((activityStatus) => { if (activityStatus === "inactive") { return EMPTY; } - return this.choosePushService(userId, notificationsUrl); - }), - ); - } - private choosePushService(userId: UserId, notificationsUrl: string) { - return this.webPushConnectionService.supportStatus$(userId).pipe( + return this.webPushConnectionService.supportStatus$(userId); + }), supportSwitch({ - supported: (service) => service.notifications$.pipe(map((n) => [n, userId] as const)), + supported: (service) => service.notifications$, notSupported: () => this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( filter((n) => n.type === "ReceiveMessage"), - map((n) => [(n as ReceiveMessage).message, userId] as const), + map((n) => n.message), ), }), ); } + private hasAccessToken$(userId: UserId) { + return this.authService.authStatusFor$(userId).pipe( + map( + (authStatus) => + authStatus === AuthenticationStatus.Locked || + authStatus === AuthenticationStatus.Unlocked, + ), + distinctUntilChanged(), + ); + } + private async processNotification(notification: NotificationResponse, userId: UserId) { const appId = await this.appIdService.getAppId(); if (notification == null || notification.contextId === appId) { From 0a49496081bb963a16341f97cb1f01c13107d1c2 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:49:21 -0500 Subject: [PATCH 32/43] Add Back Cast --- .../notifications/internal/default-notifications.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index e6ec3390378..40066a2a0a7 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -30,7 +30,7 @@ import { MessagingService } from "../../abstractions/messaging.service"; import { supportSwitch } from "../../misc/support-status"; import { NotificationsService as NotificationsServiceAbstraction } from "../notifications.service"; -import { SignalRConnectionService } from "./signalr-connection.service"; +import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service"; import { WebPushConnectionService } from "./webpush-connection.service"; export const DISABLED_NOTIFICATIONS_URL = "http://-"; @@ -107,7 +107,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract notSupported: () => this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( filter((n) => n.type === "ReceiveMessage"), - map((n) => n.message), + map((n) => (n as ReceiveMessage).message), ), }), ); From d4efd2a3aec8056fff28c20daf71546a081f34bc Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:39:01 -0500 Subject: [PATCH 33/43] Add extra safety to incoming config check --- .../notifications/internal/worker-webpush-connection.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index b0b5c07ecad..6479a145e47 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -76,7 +76,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService // FIXME: get config of server for the specified userId, once ConfigService supports it return this.configService.serverConfig$.pipe( map((config) => - config.push?.pushTechnology === PushTechnology.WebPush ? config.push.vapidPublicKey : null, + config?.push?.pushTechnology === PushTechnology.WebPush ? config.push.vapidPublicKey : null, ), // No need to re-emit when there is new server config if the vapidPublicKey is still there and the exact same distinctUntilChanged(), From ef370dad56d5a98974a86e43d93fd3b076072df0 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:41:05 -0500 Subject: [PATCH 34/43] Put data through response object --- .../notifications/internal/signalr-connection.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 55a58b14bd0..4f8b23b4e33 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -66,7 +66,7 @@ export class SignalRConnectionService { .build(); connection.on("ReceiveMessage", (data: any) => { - subsciber.next({ type: "ReceiveMessage", message: data }); + subsciber.next({ type: "ReceiveMessage", message: new NotificationResponse(data) }); }); connection.on("Heartbeat", () => { From c23b88616f36975d8da49ddc71e8875bddb5a413 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 17 Dec 2024 08:22:48 -0500 Subject: [PATCH 35/43] Apply TS Strict Rules --- .../internal/signalr-connection.service.ts | 2 +- .../notifications/internal/web-push.request.ts | 10 +++++----- .../internal/worker-webpush-connection.service.ts | 12 ++++++++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 4f8b23b4e33..e5d210266c0 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -95,7 +95,7 @@ export class SignalRConnectionService { }); }, randomTime); - reconnectSubscription = new Subscription(() => clearTimeout(timeoutHandler)); + return new Subscription(() => clearTimeout(timeoutHandler)); }; connection.onclose((error) => { diff --git a/libs/common/src/platform/notifications/internal/web-push.request.ts b/libs/common/src/platform/notifications/internal/web-push.request.ts index 788d491781a..c6375986324 100644 --- a/libs/common/src/platform/notifications/internal/web-push.request.ts +++ b/libs/common/src/platform/notifications/internal/web-push.request.ts @@ -1,13 +1,13 @@ export class WebPushRequest { - endpoint: string; - p256dh: string; - auth: string; + endpoint: string | undefined; + p256dh: string | undefined; + auth: string | undefined; static from(pushSubscription: PushSubscriptionJSON): WebPushRequest { const result = new WebPushRequest(); result.endpoint = pushSubscription.endpoint; - result.p256dh = pushSubscription.keys.p256dh; - result.auth = pushSubscription.keys.auth; + result.p256dh = pushSubscription.keys?.p256dh; + result.auth = pushSubscription.keys?.auth; return result; } } diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index 6479a145e47..1928e1d9b5b 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -117,7 +117,12 @@ class MyWebPushConnector implements WebPushConnector { ) { this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe( concatMap((subscription) => { - return defer(() => this.webPushApiService.putSubscription(subscription.toJSON())).pipe( + return defer(() => { + if (subscription == null) { + throw new Error("Expected a non-null subscription."); + } + return this.webPushApiService.putSubscription(subscription.toJSON()); + }).pipe( switchMap(() => this.pushEvent$), map((e) => new NotificationResponse(e.data.json().data)), ); @@ -143,7 +148,10 @@ class MyWebPushConnector implements WebPushConnector { } const subscriptionKey = Utils.fromBufferToUrlB64( - existingSubscription.options?.applicationServerKey, + // REASON: `Utils.fromBufferToUrlB64` handles null by returning null back to it. + // its annotation should be updated and then this assertion can be removed. + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + existingSubscription.options?.applicationServerKey!, ); if (subscriptionKey !== key) { From 8de29d830a7beb48fbb9b00bdc2c3adaa748ed9b Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 17 Dec 2024 09:59:43 -0500 Subject: [PATCH 36/43] Finish PushTechnology comment --- libs/common/src/enums/push-technology.enum.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/common/src/enums/push-technology.enum.ts b/libs/common/src/enums/push-technology.enum.ts index ad54e3153a8..9452c144bb7 100644 --- a/libs/common/src/enums/push-technology.enum.ts +++ b/libs/common/src/enums/push-technology.enum.ts @@ -7,7 +7,7 @@ export enum PushTechnology { */ SignalR = 0, /** - * Indicatates that we should + * Indicatates that we should use WebPush to receive push notifications from the server. */ WebPush = 1, } From b3958414cb98ce7b487d99b5936ae00b93a26fa3 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:00:18 -0500 Subject: [PATCH 37/43] Use `instanceof` check --- apps/browser/src/background/main.background.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 1bc138c1b76..02c03fa9aa5 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1262,7 +1262,7 @@ export default class MainBackground { } async bootstrap() { - if ("start" in this.webPushConnectionService) { + if (this.webPushConnectionService instanceof WorkerWebPushConnectionService) { this.webPushConnectionService.start(); } this.containerService.attachToGlobal(self); From d9ee535d50d0422e94309b59e64ed0fcf9183508 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 18 Dec 2024 08:00:03 -0500 Subject: [PATCH 38/43] Do Safer Worker Based Registration for MV3 --- apps/browser/src/background/main.background.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 02c03fa9aa5..40f4ce62f4f 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1022,11 +1022,18 @@ export default class MainBackground { ); if (BrowserApi.isManifestVersion(3)) { - this.webPushConnectionService = new WorkerWebPushConnectionService( - this.configService, - new WebPushNotificationsApiService(this.apiService, this.appIdService), - (self as unknown as { registration: ServiceWorkerRegistration }).registration, - ); + const registration = (self as unknown as { registration: ServiceWorkerRegistration }) + ?.registration; + + if (registration != null) { + this.webPushConnectionService = new WorkerWebPushConnectionService( + this.configService, + new WebPushNotificationsApiService(this.apiService, this.appIdService), + registration, + ); + } else { + this.webPushConnectionService = new UnsupportedWebPushConnectionService(); + } } else { this.webPushConnectionService = new UnsupportedWebPushConnectionService(); } From be8387794f9dead5ebbcc2f7e55d73031078f5ff Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 18 Dec 2024 08:01:10 -0500 Subject: [PATCH 39/43] Remove TODO --- .../internal/worker-webpush-connection.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts index 1928e1d9b5b..631c624d667 100644 --- a/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts @@ -162,10 +162,7 @@ class MyWebPushConnector implements WebPushConnector { return existingSubscription; }), - this.pushChangeEvent$.pipe( - // TODO: Is this enough, do I need to do something with oldSubscription? - map((event) => event.newSubscription), - ), + this.pushChangeEvent$.pipe(map((event) => event.newSubscription)), ); } } From d74b544b9663ddac40ce118d3000cee3ecb02ae7 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 18 Dec 2024 08:05:34 -0500 Subject: [PATCH 40/43] Switch to SignalR on any WebPush Error --- .../internal/default-notifications.service.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 40066a2a0a7..a98966adbfa 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -1,5 +1,6 @@ import { BehaviorSubject, + catchError, distinctUntilChanged, EMPTY, filter, @@ -103,16 +104,25 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract return this.webPushConnectionService.supportStatus$(userId); }), supportSwitch({ - supported: (service) => service.notifications$, - notSupported: () => - this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( - filter((n) => n.type === "ReceiveMessage"), - map((n) => (n as ReceiveMessage).message), + supported: (service) => + service.notifications$.pipe( + catchError((err: unknown) => { + this.logService.warning("Issue with web push, falling back to SignalR", err); + return this.connectSignalR$(userId, notificationsUrl); + }), ), + notSupported: () => this.connectSignalR$(userId, notificationsUrl), }), ); } + private connectSignalR$(userId: UserId, notificationsUrl: string) { + return this.signalRConnectionService.connect$(userId, notificationsUrl).pipe( + filter((n) => n.type === "ReceiveMessage"), + map((n) => (n as ReceiveMessage).message), + ); + } + private hasAccessToken$(userId: UserId) { return this.authService.authStatusFor$(userId).pipe( map( From dcc4729aa89a82fd44ba8d6e5ae14490768cdba2 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:56:16 -0500 Subject: [PATCH 41/43] Fix Manifest Permissions --- apps/browser/src/manifest.json | 6 ++---- apps/browser/src/manifest.v3.json | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 876f3971241..8d3f9f747ce 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -62,8 +62,7 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking", - "permissions" + "webRequestBlocking" ], "__safari__permissions": [ "", @@ -80,8 +79,7 @@ "webNavigation", "webRequest", "webRequestBlocking", - "webNavigation", - "notifications" + "webNavigation" ], "optional_permissions": ["nativeMessaging", "privacy"], "__firefox__optional_permissions": ["nativeMessaging"], diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index eb812318764..fab3841e897 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -84,8 +84,7 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestAuthProvider", - "notifications" + "webRequestAuthProvider" ], "optional_permissions": ["nativeMessaging", "privacy"], "__firefox__optional_permissions": ["nativeMessaging"], From 3a1bd252f0ba734bf9e8cfef52a47792c12e3494 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 26 Dec 2024 17:03:30 -0500 Subject: [PATCH 42/43] Add Back `webNavigation` --- apps/browser/src/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 8d3f9f747ce..4a528c47886 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -62,7 +62,8 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking" + "webRequestBlocking", + "webNavigation" ], "__safari__permissions": [ "", From 57c6eecad260aefae12af349715cab25692ab841 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 26 Dec 2024 17:04:23 -0500 Subject: [PATCH 43/43] Sorry, Remove `webNavigation` --- apps/browser/src/manifest.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 4a528c47886..32fa5135226 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -62,8 +62,7 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking", - "webNavigation" + "webRequestBlocking" ], "__safari__permissions": [ "", @@ -79,8 +78,7 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking", - "webNavigation" + "webRequestBlocking" ], "optional_permissions": ["nativeMessaging", "privacy"], "__firefox__optional_permissions": ["nativeMessaging"],