diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2025-11-08 16:28:10 -0500 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2025-11-08 16:28:10 -0500 |
| commit | fc6914831743f6d683c59adb367479defe6f8b3a (patch) | |
| tree | 5b997adac55f47b52f30022013b8ec3b2c10bcc5 /ui/lib/state | |
| parent | 0ef69c7d256380e660edc45ace7f1d6151226340 (diff) | |
| parent | 6bab5b4405c9adafb2ce76540595a62eea80acc0 (diff) | |
Integrate the prototype push notification support.
We're going to move forwards with this for now, as low-utility as it is, so that we can more easily iterate on it in a real-world environment (hi.grimoire.ca).
Diffstat (limited to 'ui/lib/state')
| -rw-r--r-- | ui/lib/state/local/push.svelte.js | 141 |
1 files changed, 141 insertions, 0 deletions
diff --git a/ui/lib/state/local/push.svelte.js b/ui/lib/state/local/push.svelte.js new file mode 100644 index 0000000..82846b1 --- /dev/null +++ b/ui/lib/state/local/push.svelte.js @@ -0,0 +1,141 @@ +import * as api from '$lib/apiServer.js'; + +// In a few places in this module, I've used suffix to identify which type we're working with: +// * ...Base64 is a string containing a URL-safe base64 value; +// * ...Buffer is an ArrayBuffer holding untyped bytes; and +// * ...Bytes is a Uint8Array holding typed bytes. +// Working with byte streams in JS is _fun_. + +export class Push { + static async boot(events) { + const serviceWorker = await navigator.serviceWorker.ready; + const pushManager = serviceWorker.pushManager; + const subscription = await serviceWorker.pushManager.getSubscription(); + + const push = new Push(pushManager, subscription); + + for (const event of events) { + push.onEvent(event); + } + + return push; + } + + vapidKey = $state(null); + subscription = $state(null); + + constructor(pushManager, subscription) { + this.pushManager = pushManager; + this.subscription = subscription; + } + + async hasPermission() { + const vapidKeyBuffer = this.vapidKey; + const pushOptions = { + userVisibleOnly: true, + applicationServerKey: vapidKeyBuffer, + }; + + const state = await this.pushManager.permissionState(pushOptions); + return state === 'granted'; + } + + async subscribe() { + const vapidKeyBuffer = this.vapidKey; + const pushOptions = { + userVisibleOnly: true, + applicationServerKey: vapidKeyBuffer, + }; + this.subscription = await this.pushManager.subscribe(pushOptions); + + const subscriptionJSON = this.subscription.toJSON(); + const vapidKeyBytes = new Uint8Array(vapidKeyBuffer); + const vapidKeyBase64 = vapidKeyBytes.toBase64({ alphabet: 'base64url' }); + await api.createPushSubscription(subscriptionJSON, vapidKeyBase64); + } + + async resubscribe() { + if (this.subscription !== null) { + // If we have a subscription but it's not for the current VAPID key, then the VAPID key has + // been rotated and we need to replace the subscription. The server cannot deliver messages to + // subscriptions associated with the old VAPID key after rotation. + + // Per spec, the `options.applicationServerKey` field is either null or an ArrayBuffer, + // regardless of the representation passed into `subscribe` to create the subscription: + // + // <https://w3c.github.io/push-api/#pushsubscriptionoptions-interface> + if (!equalArrayBuffers(this.subscription.options.applicationServerKey, this.vapidKey)) { + // We have a subscription, and the server key has rotated, so resubscribe. Destroy the old + // subscription first - some UAs (Firefox) want subscriptions within an origin to share a + // consistent VAPID key, and we're explicitly changing the VAPID key here. + await this.unsubscribe(); + await this.subscribe(); + } + } else if (await this.hasPermission()) { + // If we have permission to create push subscriptions, but no push subscription, then set up + // a new subscription. This primarily happens after logging into Pilcrow if the user + // previously had a push subscription and has logged out. + await this.subscribe(); + } + } + + async unsubscribe() { + if (this.subscription !== null) { + await this.subscription.unsubscribe(); + this.subscription = null; + } + } + + onEvent(event) { + switch (event.type) { + case 'vapid': + return this.onVapidEvent(event); + } + } + + onVapidEvent(event) { + switch (event.event) { + case 'changed': + return this.onVapidChanged(event); + } + } + + onVapidChanged(event) { + const { key: vapidKeyBase64 } = event; + + // For ease of use later on, parse the key into an ArrayBuffer. This is a little fiddly because + // of the APIs involved, but it makes comparing the key to the current subscription's key + // (which is also provided as an ArrayBuffer) much easier. + const vapidKeyBytes = Uint8Array.fromBase64(vapidKeyBase64, { + alphabet: 'base64url', + }); + // In practice, `vapidKeyBytes.buffer` is going to be the same bytes as this slice, but in + // principle it could be a subset of the underlying buffer, and there's very little downside to + // being meticulous here. + this.vapidKey = vapidKeyBytes.buffer.slice( + vapidKeyBytes.byteOffset, + vapidKeyBytes.byteOffset + vapidKeyBytes.byteLength, + ); + + // Note that `resubscribe()` is async; this will start the [re]subscription process but will + // return to the caller before it completes. I'm not willing to make the entire event handling + // chain all the way back up to the EventSource asynchronous, since EventSource isn't designed + // to support that (we could make it work), and we can't wait for async functions in a non-async + // context, so this is the best we can do. + this.resubscribe(); + } +} + +function equalArrayBuffers(aBuffer, bBuffer) { + // You might be thinking, surely there's a way to compare two array buffers for equality. I + // certainly expected that there would be. [Nope]. + // + // [Nope]: https://github.com/wbinnssmith/arraybuffer-equal + // + // The algorithm here is simple enough not to need an external dependency. However, this + // comparison is not designed to deal with detached ArrayBuffer instances. + const aBytes = new Uint8Array(aBuffer); + const bBytes = new Uint8Array(bBuffer); + + return aBytes.length === bBytes.length && aBytes.every((value, index) => value === bBytes[index]); +} |
