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 | |
| 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')
| -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 | ||||
| -rw-r--r-- | ui/routes/(app)/me/+page.svelte | 16 | ||||
| -rw-r--r-- | ui/routes/(swatch)/.swatch/+page.svelte | 1 | ||||
| -rw-r--r-- | ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte | 79 | ||||
| -rw-r--r-- | ui/service-worker.js | 9 |
8 files changed, 298 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]); +} diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte index 0c960c8..a21d160 100644 --- a/ui/routes/(app)/me/+page.svelte +++ b/ui/routes/(app)/me/+page.svelte @@ -2,10 +2,16 @@ import LogOut from '$lib/components/LogOut.svelte'; import Invites from '$lib/components/Invites.svelte'; import ChangePassword from '$lib/components/ChangePassword.svelte'; + import PushSubscription from '$lib/components/PushSubscription.svelte'; import { goto } from '$app/navigation'; import * as api from '$lib/apiServer.js'; + const { data } = $props(); + const { session } = data; + const subscription = $derived(session.push.subscription); + const vapid = $derived(session.push.vapidKey); + let invites = $state([]); async function logOut() { @@ -25,10 +31,20 @@ invites.push(response.data); } } + + async function subscribe() { + await session.push.subscribe(); + } + + async function ping() { + await api.sendPing(); + } </script> <ChangePassword {changePassword} /> <hr /> +<PushSubscription {subscription} {vapid} {subscribe} {ping} /> +<hr /> <Invites {invites} {createInvite} /> <hr /> <LogOut {logOut} /> diff --git a/ui/routes/(swatch)/.swatch/+page.svelte b/ui/routes/(swatch)/.swatch/+page.svelte index 5334438..c1969e5 100644 --- a/ui/routes/(swatch)/.swatch/+page.svelte +++ b/ui/routes/(swatch)/.swatch/+page.svelte @@ -19,5 +19,6 @@ <li><a href="MessageRun">MessageRun</a></li> <li><a href="MessageInput">MessageInput</a></li> <li><a href="Message">Message</a></li> + <li><a href="PushSubscription">PushSubscription</a></li> <li><a href="swatch/EventLog">swatch/EventLog</a></li> </ul> diff --git a/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte b/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte new file mode 100644 index 0000000..3d564a3 --- /dev/null +++ b/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte @@ -0,0 +1,79 @@ +<script> + import { DateTime } from 'luxon'; + + import PushSubscription from '$lib/components/PushSubscription.svelte'; + import { makeDeriver } from '$lib/swatch/derive.js'; + import EventLog from '$lib/components/swatch/EventLog.svelte'; + import EventCapture from '$lib/swatch/event-capture.svelte.js'; + import * as json from '$lib/swatch/json.js'; + + function fromBase64(str) { + if (str.trim().length === 0) { + return null; + } + const bytes = Uint8Array.fromBase64(str, { alphabet: 'base64url' }); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + + const base64Deriver = makeDeriver(fromBase64); + + // This is a "real" key, but it's not a key that's in use anywhere. I generated it for this + // purpose. -o + const testVapidKey = + 'BJXUH-WxM8BoxntTsrLufxc2Zlbwk-A1wsF01-ykUyh9pSUZG1Ymk3R-FOxJTGApQeJWIYTW9j-1sLQFIL8cGBU='; + + let vapidInput = $state(''); + let vapid = $derived(base64Deriver(vapidInput)); + + // See <> for schema. This is an approximation of the browser subscription object. + const testSubscription = json.encode({ + endpoint: 'https://push.example.com/1234', + expirationTime: performance.now() + 86400 /* sec */ * 1000 /* millisec */, + options: { + userVisibleOnly: true, + applicationServerKey: null, + }, + }); + + let subscriptionInput = $state(''); + let subscription = $derived(json.decode(subscriptionInput)); + + let capture = $state(new EventCapture()); + const subscribe = capture.on('subscribe'); + const ping = capture.on('ping'); +</script> + +<h1><code>PushSubscription</code></h1> + +<nav><p><a href=".">Back to swatches</a></p></nav> + +<h2>properties</h2> + +<div class="component-properties"> + <label>vapid key <input type="text" bind:value={vapidInput} /></label> + <div class="suggestion"> + interesting values: + <button onclick={() => (vapidInput = '')}>(none)</button> + <button onclick={() => (vapidInput = testVapidKey)}>test key</button> + </div> + + <label + ><p>subscription (json)</p> + <textarea bind:value={subscriptionInput}></textarea> + <div class="suggestion"> + interesting values: + <button onclick={() => (subscriptionInput = '')}>(none)</button> + <button onclick={() => (subscriptionInput = testSubscription)}>example</button> + </div> + </label> +</div> + +<h2>rendered</h2> + +<div class="component-preview"> + <PushSubscription {vapid} {subscription} {subscribe} {ping} /> +</div> + +<h2>events</h2> + +<EventLog events={capture.events} clear={capture.clear.bind(capture)} /> diff --git a/ui/service-worker.js b/ui/service-worker.js index d9b2a7c..cb32d0d 100644 --- a/ui/service-worker.js +++ b/ui/service-worker.js @@ -52,3 +52,12 @@ async function cacheFirst(request) { self.addEventListener('fetch', (event) => { event.respondWith(cacheFirst(event.request)); }); + +self.addEventListener('push', (event) => { + event.waitUntil( + self.registration.showNotification('Test notification', { + actions: [], + body: event.data.text(), + }), + ); +}); |
