rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

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
13function 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
22function 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.
31function 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.
39function 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
46function failed(resp) {
47 return resp && typeof resp === "object" && resp.ok === false;
48}
49
50async 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.
60export 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.
74export 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.
83export 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.
111export 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.
132export 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