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 | |
| 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')
| -rw-r--r-- | ui/lib/apiServer.js | 10 | ||||
| -rw-r--r-- | ui/lib/components/PushSubscription.svelte | 30 | ||||
| -rw-r--r-- | ui/lib/session.svelte.js | 19 | ||||
| -rw-r--r-- | ui/lib/state/local/push.svelte.js | 141 |
4 files changed, 193 insertions, 7 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index ac707a5..9eeb128 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -47,7 +47,7 @@ export async function getInvite(inviteId) { } export async function acceptInvite(inviteId, name, password) { - return apiServer + return await apiServer .post(`/invite/${inviteId}`, { name, password, @@ -55,6 +55,14 @@ export async function acceptInvite(inviteId, name, password) { .catch(responseError); } +export async function createPushSubscription(subscription, vapid) { + return await apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError); +} + +export async function sendPing() { + return await apiServer.post('/push/ping', {}).catch(responseError); +} + export function subscribeToEvents(resumePoint) { const eventsUrl = apiServer.getUri({ url: '/events', diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte new file mode 100644 index 0000000..aab4929 --- /dev/null +++ b/ui/lib/components/PushSubscription.svelte @@ -0,0 +1,30 @@ +<script> + let { vapid, subscription, subscribe = async () => null, ping = async () => null } = $props(); + let pending = $state(false); + + function onsubmit(callback) { + return async (evt) => { + evt.preventDefault(); + + pending = true; + try { + await callback(); + } finally { + pending = false; + } + }; + } +</script> + +{#if !!vapid} + {#if !subscription} + <form class="form" onsubmit={onsubmit(subscribe)}> + <button disabled={pending} type="submit">create push subscription</button> + </form> + {/if} + <form class="form" onsubmit={onsubmit(ping)}> + <button disabled={pending} type="submit">send test notification</button> + </form> +{:else} + Waiting for VAPID key… +{/if} diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index c415d0c..cd41aa4 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -5,6 +5,7 @@ import { goto } from '$app/navigation'; import * as api from './apiServer.js'; import * as r from './state/remote/state.svelte.js'; import * as l from './state/local/conversations.svelte.js'; +import * as p from './state/local/push.svelte.js'; import { Watchdog } from './watchdog.js'; import { DateTime } from 'luxon'; @@ -51,6 +52,7 @@ class Message { class Session { remote = $state(); local = $state(); + push = $state(); currentUser = $derived(this.remote.currentUser); users = $derived(this.remote.users.all); messages = $derived( @@ -62,7 +64,7 @@ class Session { ), ); - static boot({ login, resume_point, heartbeat, events }) { + static async boot({ login, resume_point, heartbeat, events }) { const remote = r.State.boot({ currentUser: login, resumePoint: resume_point, @@ -70,22 +72,25 @@ class Session { events, }); const local = l.Conversations.fromLocalStorage(); - return new Session(remote, local); + const push = await p.Push.boot(events); + return new Session(remote, local, push); } - reboot({ login, resume_point, heartbeat, events }) { + async reboot({ login, resume_point, heartbeat, events }) { this.remote = r.State.boot({ currentUser: login, resumePoint: resume_point, heartbeat, events, }); + this.push = await p.Push.boot(events); } - constructor(remote, local) { + constructor(remote, local, push) { this.watchdog = new Watchdog(this.watchdogExpired.bind(this)); this.remote = remote; this.local = local; + this.push = push; } begin() { @@ -107,6 +112,7 @@ class Session { onMessage(message) { const event = JSON.parse(message.data); this.remote.onEvent(event); + this.push.onEvent(event); this.local.retainConversations(this.remote.conversations.all); this.watchdog.reset(this.heartbeatMillis()); } @@ -127,7 +133,7 @@ class Session { // which the session may have been abandoned. if (!this.active()) return; - this.reboot(response); + await this.reboot(response); this.begin(); } } @@ -139,6 +145,7 @@ async function bootOrNavigate(navigateTo) { } catch (err) { switch (true) { case err instanceof api.LoggedOut: + await this.push.unsubscribe(); await navigateTo('/login'); break; case err instanceof api.SetupRequired: @@ -152,5 +159,5 @@ async function bootOrNavigate(navigateTo) { export async function boot() { const response = await bootOrNavigate(async (url) => redirect(307, url)); - return Session.boot(response); + return await Session.boot(response); } 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]); +} |
