mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 23:03:32 +00:00
Add Web Push Support (#11346)
* WIP: PoC with lots of terrible code with web push * fix service worker building * Work on WebPush Tailored to Browser * Clean Up Web And MV2 * Fix Merge Conflicts * Prettier * Use Unsupported for MV2 * Add Doc Comments * Remove Permission Button * Fix Type Test * Write Time In More Readable Format * Add SignalR Logger * `sheduleReconnect` -> `scheduleReconnect` Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Capture Support Context In Connector * Remove Unneeded CSP Change * Fix Build * Simplify `getOrCreateSubscription` * Add More Docs to Matrix * Update libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Move API Service Into Notifications Folder * Allow Connection When Account Is Locked * Add Comments to NotificationsService * Only Change Support Status If Public Key Changes * Move Service Choice Out To Method * Use Named Constant For Disabled Notification Url * Add Test & Cleanup * Flatten * Move Tests into `beforeEach` & `afterEach` * Add Tests * Test `distinctUntilChanged`'s Operators More * Make Helper And Cleanup Chain * Add Back Cast * Add extra safety to incoming config check * Put data through response object * Apply TS Strict Rules * Finish PushTechnology comment * Use `instanceof` check * Do Safer Worker Based Registration for MV3 * Remove TODO * Switch to SignalR on any WebPush Error * Fix Manifest Permissions * Add Back `webNavigation` * Sorry, Remove `webNavigation` * Fixed merge conflicts. --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com> Co-authored-by: Todd Martin <tmartin@bitwarden.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
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, SignalRConnectionService } from "./signalr-connection.service";
|
||||
import { WebPushConnectionService } from "./webpush-connection.service";
|
||||
|
||||
export const DISABLED_NOTIFICATIONS_URL = "http://-";
|
||||
|
||||
export class DefaultNotificationsService implements NotificationsServiceAbstraction {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
|
||||
private activitySubject = new BehaviorSubject<"active" | "inactive">("active");
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
private appIdService: AppIdService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logoutCallback: (logoutReason: LogoutReason, userId: UserId) => Promise<void>,
|
||||
private messagingService: MessagingService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly signalRConnectionService: SignalRConnectionService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly webPushConnectionService: WebPushConnectionService,
|
||||
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.userNotifications$(activeAccountId).pipe(
|
||||
map((notification) => [notification, activeAccountId] as const),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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((env) => env.getNotificationsUrl()),
|
||||
distinctUntilChanged(),
|
||||
switchMap((notificationsUrl) => {
|
||||
if (notificationsUrl === DISABLED_NOTIFICATIONS_URL) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.userNotificationsHelper$(userId, notificationsUrl);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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.webPushConnectionService.supportStatus$(userId);
|
||||
}),
|
||||
supportSwitch({
|
||||
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(
|
||||
(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) {
|
||||
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,
|
||||
userId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncFolderDelete:
|
||||
await this.syncService.syncDeleteFolder(
|
||||
notification.payload as SyncFolderNotification,
|
||||
userId,
|
||||
);
|
||||
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);
|
||||
this.activitySubject.next("inactive"); // Force a disconnect
|
||||
this.activitySubject.next("active"); // Allow a reconnect
|
||||
break;
|
||||
case NotificationType.LogOut:
|
||||
this.logService.info("[Notifications Service] Received logout notification");
|
||||
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;
|
||||
case NotificationType.SyncOrganizationStatusChanged:
|
||||
await this.syncService.fullSync(true);
|
||||
break;
|
||||
case NotificationType.SyncOrganizationCollectionSettingChanged:
|
||||
await this.syncService.fullSync(true);
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user