| 1 | // Browser half of aviso: enrol this device for push against the app's |
| 2 | // server. The app supplies `save` and `remove` — same-origin fetches |
| 3 | // to the Subscribe and Unsubscribe handlers — and owns the service |
| 4 | // worker registration. Nothing here prompts except `enable`, and it |
| 5 | // prompts synchronously inside the caller's gesture, because a prompt |
| 6 | // after an await is denied by browsers and resented by people. |
| 7 | // |
| 8 | // Callback contract: `save(body)` and `remove(body)` may resolve to |
| 9 | // anything. A rejection, or a resolved value shaped like a failed |
| 10 | // Response ({ok: false}), counts as failure; anything else — including |
| 11 | // undefined from a callback that checked its own response — is success. |
| 12 | |
| 13 | function toBytes(base64url) { |
| 14 | const pad = "=".repeat((4 - (base64url.length % 4)) % 4); |
| 15 | const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/"); |
| 16 | const raw = atob(b64); |
| 17 | const out = new Uint8Array(raw.length); |
| 18 | for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| 19 | return out; |
| 20 | } |
| 21 | |
| 22 | function toBase64url(buf) { |
| 23 | let s = ""; |
| 24 | const bytes = new Uint8Array(buf); |
| 25 | for (const b of bytes) s += String.fromCharCode(b); |
| 26 | return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 27 | } |
| 28 | |
| 29 | // sameKey reports whether an existing subscription was made under the |
| 30 | // server's current key. A rotated key means every browser re-enrols. |
| 31 | function sameKey(subscription, publicKey) { |
| 32 | const key = subscription.options && subscription.options.applicationServerKey; |
| 33 | if (!key) return false; |
| 34 | return toBase64url(key) === publicKey; |
| 35 | } |
| 36 | |
| 37 | // body projects the browser's subscription to exactly what Subscribe |
| 38 | // reads — endpoint and keys — rather than forwarding toJSON() whole. |
| 39 | function body(subscription, publicKey, previousEndpoint) { |
| 40 | const json = subscription.toJSON(); |
| 41 | const out = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey }; |
| 42 | if (previousEndpoint) out.previousEndpoint = previousEndpoint; |
| 43 | return out; |
| 44 | } |
| 45 | |
| 46 | function failed(resp) { |
| 47 | return resp && typeof resp === "object" && resp.ok === false; |
| 48 | } |
| 49 | |
| 50 | async function persist(save, payload) { |
| 51 | const resp = await save(payload); |
| 52 | if (failed(resp)) { |
| 53 | throw new Error("aviso: save rejected: " + resp.status); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // capabilities reports what this browser can do. `standalone` is what |
| 58 | // the installability recipe keys its coaching on: iOS delivers push |
| 59 | // only to an installed app. |
| 60 | export function capabilities(env = globalThis) { |
| 61 | const nav = env.navigator || {}; |
| 62 | const standalone = nav.standalone === true || |
| 63 | (typeof env.matchMedia === "function" && env.matchMedia("(display-mode: standalone)").matches) || false; |
| 64 | return { |
| 65 | serviceWorker: !!nav.serviceWorker, |
| 66 | push: typeof env.PushManager !== "undefined", |
| 67 | notifications: typeof env.Notification !== "undefined", |
| 68 | standalone, |
| 69 | }; |
| 70 | } |
| 71 | |
| 72 | // status resolves the current permission and subscription, prompting |
| 73 | // nothing. |
| 74 | export async function status(registration, env = globalThis) { |
| 75 | const permission = env.Notification ? env.Notification.permission : "default"; |
| 76 | const subscription = await registration.pushManager.getSubscription(); |
| 77 | return { permission, subscription }; |
| 78 | } |
| 79 | |
| 80 | // enable asks permission (synchronously, first), subscribes, and |
| 81 | // saves. Resolves the subscription, or null when permission was |
| 82 | // denied. Anything else rejects. |
| 83 | export async function enable({ registration, publicKey, save }, env = globalThis) { |
| 84 | const permission = await env.Notification.requestPermission(); |
| 85 | if (permission !== "granted") return null; |
| 86 | const existing = await registration.pushManager.getSubscription(); |
| 87 | if (existing && sameKey(existing, publicKey)) { |
| 88 | await persist(save, body(existing, publicKey, "")); |
| 89 | return existing; |
| 90 | } |
| 91 | let previous = ""; |
| 92 | if (existing) { |
| 93 | previous = existing.endpoint; |
| 94 | await existing.unsubscribe(); |
| 95 | } |
| 96 | const sub = await registration.pushManager.subscribe({ |
| 97 | userVisibleOnly: true, |
| 98 | applicationServerKey: toBytes(publicKey), |
| 99 | }); |
| 100 | await persist(save, body(sub, publicKey, previous)); |
| 101 | return sub; |
| 102 | } |
| 103 | |
| 104 | // reconcile repairs an EXISTING subscription on every page load |
| 105 | // without prompting: re-saves it so the server's last_confirmed_at |
| 106 | // moves, or re-subscribes if the server key changed. It never creates |
| 107 | // one from nothing — permission stays granted after disable(), and a |
| 108 | // reconcile that subscribed whenever it could would silently undo the |
| 109 | // person's opt-out on their next visit. Creating is enable's job. |
| 110 | // Resolves the subscription or null. |
| 111 | export async function reconcile({ registration, publicKey, save }, env = globalThis) { |
| 112 | if (!env.Notification || env.Notification.permission !== "granted") return null; |
| 113 | const existing = await registration.pushManager.getSubscription(); |
| 114 | if (!existing) return null; |
| 115 | if (sameKey(existing, publicKey)) { |
| 116 | await persist(save, body(existing, publicKey, "")); |
| 117 | return existing; |
| 118 | } |
| 119 | const previous = existing.endpoint; |
| 120 | await existing.unsubscribe(); |
| 121 | const sub = await registration.pushManager.subscribe({ |
| 122 | userVisibleOnly: true, |
| 123 | applicationServerKey: toBytes(publicKey), |
| 124 | }); |
| 125 | await persist(save, body(sub, publicKey, previous)); |
| 126 | return sub; |
| 127 | } |
| 128 | |
| 129 | // disable removes the server row first, then the browser subscription: |
| 130 | // a crash between the two leaves a harmless orphan in the browser |
| 131 | // rather than a server row that sends to nothing. |
| 132 | export async function disable({ registration, remove }) { |
| 133 | const existing = await registration.pushManager.getSubscription(); |
| 134 | if (!existing) return; |
| 135 | const resp = await remove({ endpoint: existing.endpoint }); |
| 136 | if (failed(resp)) { |
| 137 | throw new Error("aviso: remove rejected: " + resp.status); |
| 138 | } |
| 139 | await existing.unsubscribe(); |
| 140 | } |
| 141 | |