summaryrefslogtreecommitdiff
path: root/ui/lib/state
diff options
context:
space:
mode:
Diffstat (limited to 'ui/lib/state')
-rw-r--r--ui/lib/state/local/push.svelte.js141
-rw-r--r--ui/lib/state/remote/state.svelte.js15
2 files changed, 141 insertions, 15 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]);
+}
diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js
index 8845e02..3d65e4a 100644
--- a/ui/lib/state/remote/state.svelte.js
+++ b/ui/lib/state/remote/state.svelte.js
@@ -7,7 +7,6 @@ export class State {
users = $state(new Users());
conversations = $state(new Conversations());
messages = $state(new Messages());
- vapid_key = $state(null);
static boot({ currentUser, heartbeat, resumePoint, events }) {
const state = new State({
@@ -37,8 +36,6 @@ export class State {
return this.onUserEvent(event);
case 'message':
return this.onMessageEvent(event);
- case 'vapid':
- return this.onVapidEvent(event);
}
}
@@ -91,16 +88,4 @@ export class State {
const { id } = event;
this.messages.remove(id);
}
-
- onVapidEvent(event) {
- switch (event.event) {
- case 'changed':
- return this.onVapidChanged(event);
- }
- }
-
- onVapidChanged(event) {
- let { key } = event;
- this.vapid_key = key;
- }
}