eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// Web Push helpers, driven by the chat page's onboarding flow (see shell.js).
2
3export function idbSet(key, value) {
4 return new Promise((resolve, reject) => {
5 const req = indexedDB.open("lchat", 1);
6 req.onupgradeneeded = () => req.result.createObjectStore("kv");
7 req.onsuccess = () => {
8 const db = req.result;
9 // Close as soon as the write lands, and yield to a schema upgrade from
10 // elsewhere: a connection held open with no versionchange handler would
11 // block any future version bump in another tab forever (its open would
12 // stall pre-paint). Pinned at v1 today, so this is armour, not a fix.
13 db.onversionchange = () => db.close();
14 const tx = db.transaction("kv", "readwrite");
15 tx.objectStore("kv").put(value, key);
16 tx.oncomplete = () => {
17 db.close();
18 resolve();
19 };
20 tx.onerror = () => {
21 db.close();
22 reject(tx.error);
23 };
24 };
25 req.onerror = () => reject(req.error);
26 req.onblocked = () => reject(new Error("blocked"));
27 });
28}
29
30function urlB64ToUint8Array(b64) {
31 const pad = "=".repeat((4 - (b64.length % 4)) % 4);
32 const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/");
33 const raw = atob(base64);
34 const out = new Uint8Array(raw.length);
35 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
36 return out;
37}
38
39const isiOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
40const isStandalone =
41 window.matchMedia("(display-mode: standalone)").matches ||
42 window.navigator.standalone === true;
43
44// iOS Safari can't do web push until the app is added to the Home Screen.
45export function needsHomeScreen() {
46 return isiOS && !isStandalone;
47}
48
49export function pushSupported() {
50 return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
51}
52
53// Push is usable here and now (not "iOS, but needs install first").
54export function pushAvailable() {
55 return pushSupported() && !needsHomeScreen();
56}
57
58export function pushEnabled() {
59 return pushSupported() && Notification.permission === "granted";
60}
61
62export function pushDenied() {
63 return "Notification" in window && Notification.permission === "denied";
64}
65
66let swReg = null;
67
68// Register the service worker. The per-thread keys it needs to decrypt push
69// payloads are written to IndexedDB separately by shell.js (idbSet "threadKeys").
70// Best-effort; keeps any existing subscription in sync.
71export async function registerSW(session) {
72 if (!("serviceWorker" in navigator)) return;
73 try {
74 swReg = await navigator.serviceWorker.register("/sw.js");
75 } catch (e) {
76 console.warn("service worker registration failed", e);
77 return;
78 }
79 if (pushEnabled()) {
80 // Re-validate the subscription against the current VAPID key (it changes if
81 // the server DB was reset) and recreate it if stale — self-heals on load.
82 await subscribePush(session);
83 }
84}
85
86// Request permission (if needed) and subscribe. Returns "on" | "denied" | "error".
87export async function subscribePush(session) {
88 if (!pushAvailable()) return "error";
89 if (Notification.permission === "default") {
90 let perm;
91 try {
92 perm = await Notification.requestPermission();
93 } catch {
94 return "error";
95 }
96 if (perm !== "granted") return "denied";
97 }
98 if (Notification.permission !== "granted") return "denied";
99 try {
100 // Ensure the worker is registered AND active before subscribing. Right after
101 // first registration the worker is still "installing"; subscribing then fails
102 // silently on iOS — permission reads "granted" but no subscription ever
103 // reaches the server, so notifications look allowed yet stay off until a
104 // later menu toggle. serviceWorker.ready resolves only once a worker is active.
105 await navigator.serviceWorker.register("/sw.js").catch(() => {});
106 const reg = await navigator.serviceWorker.ready;
107 swReg = reg;
108 const { publicKey } = await (await fetch("/api/push/vapid")).json();
109 const appKey = urlB64ToUint8Array(publicKey);
110 let sub = await reg.pushManager.getSubscription();
111 // If an existing subscription was made with a different (old) VAPID key, the
112 // push service will reject sends — recreate it against the current key.
113 if (sub && !sameAppServerKey(sub, appKey)) {
114 try {
115 await sub.unsubscribe();
116 } catch {}
117 sub = null;
118 }
119 if (!sub) {
120 sub = await reg.pushManager.subscribe({
121 userVisibleOnly: true,
122 applicationServerKey: appKey,
123 });
124 }
125 await sendSubscription(session.token, sub);
126 return "on";
127 } catch (e) {
128 console.warn("push subscribe failed", e);
129 return "error";
130 }
131}
132
133function sameAppServerKey(sub, appKey) {
134 try {
135 const cur = sub.options && sub.options.applicationServerKey;
136 if (!cur) return false;
137 const a = new Uint8Array(cur);
138 if (a.length !== appKey.length) return false;
139 for (let i = 0; i < a.length; i++) {
140 if (a[i] !== appKey[i]) return false;
141 }
142 return true;
143 } catch {
144 return false;
145 }
146}
147
148export async function isSubscribed() {
149 if (!("serviceWorker" in navigator)) return false;
150 try {
151 const reg = swReg || (await navigator.serviceWorker.getRegistration());
152 if (!reg) return false;
153 return !!(await reg.pushManager.getSubscription());
154 } catch {
155 return false;
156 }
157}
158
159// Turn push off: remove the subscription from the server, then the browser.
160export async function unsubscribePush() {
161 try {
162 const reg = swReg || (await navigator.serviceWorker.getRegistration());
163 if (!reg) return;
164 const sub = await reg.pushManager.getSubscription();
165 if (!sub) return;
166 await fetch("/api/push/unsubscribe", {
167 method: "POST",
168 headers: { "Content-Type": "application/json" },
169 body: JSON.stringify({ token: session.token, endpoint: sub.endpoint }),
170 });
171 await sub.unsubscribe();
172 } catch (e) {
173 console.warn("unsubscribe failed", e);
174 }
175}
176
177function sendSubscription(token, sub) {
178 return fetch("/api/push/subscribe", {
179 method: "POST",
180 headers: { "Content-Type": "application/json" },
181 body: JSON.stringify({ token, subscription: sub }),
182 });
183}
184