1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 08:43:33 +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:
Justin Baur
2025-01-29 08:49:01 -05:00
committed by GitHub
parent 222392d1fa
commit b07d6c29a4
35 changed files with 1435 additions and 391 deletions

View File

@@ -0,0 +1,168 @@
import {
concat,
concatMap,
defer,
distinctUntilChanged,
fromEvent,
map,
Observable,
Subject,
Subscription,
switchMap,
} from "rxjs";
import { PushTechnology } from "../../../enums/push-technology.enum";
import { NotificationResponse } from "../../../models/response/notification.response";
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 "./web-push-notifications-api.service";
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
// Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event
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.
*/
export class WorkerWebPushConnectionService implements WebPushConnectionService {
private pushEvent = new Subject<PushEvent>();
private pushChangeEvent = new Subject<PushSubscriptionChangeEvent>();
constructor(
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<PushEvent>();
this.pushChangeEvent = new Subject<PushSubscriptionChangeEvent>();
});
const pushEventSubscription = fromEvent<PushEvent>(self, "push").subscribe(this.pushEvent);
const pushChangeEventSubscription = fromEvent<PushSubscriptionChangeEvent>(
self,
"pushsubscriptionchange",
).subscribe(this.pushChangeEvent);
subscription.add(pushEventSubscription);
subscription.add(pushChangeEventSubscription);
return subscription;
}
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
// 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) =>
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: "not-supported",
reason: "server-not-configured",
} satisfies SupportStatus<WebPushConnector>;
}
return {
type: "supported",
service: new MyWebPushConnector(
publicKey,
userId,
this.webPushApiService,
this.serviceWorkerRegistration,
this.pushEvent,
this.pushChangeEvent,
),
} satisfies SupportStatus<WebPushConnector>;
}),
);
}
}
class MyWebPushConnector implements WebPushConnector {
notifications$: Observable<NotificationResponse>;
constructor(
private readonly vapidPublicKey: string,
private readonly userId: UserId,
private readonly webPushApiService: WebPushNotificationsApiService,
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
private readonly pushEvent$: Observable<PushEvent>,
private readonly pushChangeEvent$: Observable<PushSubscriptionChangeEvent>,
) {
this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe(
concatMap((subscription) => {
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)),
);
}),
);
}
private async pushManagerSubscribe(key: string) {
return await this.serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key,
});
}
private getOrCreateSubscription$(key: string) {
return concat(
defer(async () => {
const existingSubscription =
await this.serviceWorkerRegistration.pushManager.getSubscription();
if (existingSubscription == null) {
return await this.pushManagerSubscribe(key);
}
const subscriptionKey = Utils.fromBufferToUrlB64(
// 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) {
// 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(map((event) => event.newSubscription)),
);
}
}