1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
|
// Service-worker side of background NWC. Bundled by build.js and appended to
// the generated sw.js, replacing the notify-only push handler: a push now
// first tries to ANSWER the request from the background state mirror (see
// nwc-respond.js), and only falls back to the wake-the-user notification
// when it can't.
import { respondFromBg, tendRenewals } from './nwc-respond.js';
import { withRequestLock } from './nwc-lock.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens
// where a call's offer wrap is fetched from: the notifier, or (the app served
// on this machine, as the tests do) this origin
const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER;
// WebKit (Safari, and every browser on iOS) allows no silent pushes: a push
// event that ends without showing a notification is a strike, even with the
// app open, and after three it drops the subscription. The wallet then stops
// waking until it is opened again. Elsewhere a quiet push is fine, so only an
// Apple endpoint gets a stand-in where we'd otherwise stay silent: closed at
// once when the app is on screen (it shows everything live there), left as
// one self-replacing silent line in the background.
async function applePush() {
try {
const s = await self.registration.pushManager.getSubscription();
return !!s && /(^|\.)push\.apple\.com$/.test(new URL(s.endpoint).hostname);
} catch (_) { return false; }
}
async function standIn(visible) {
if (!(await applePush())) return false;
await self.registration.showNotification('Coinos', {
body: 'Your wallet checked in.', icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'coinos-bg', renotify: false, silent: true, data: { url: './' },
});
if (visible) {
const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []);
for (const n of open) n.close();
}
return true;
}
self.addEventListener('push', (e) => {
let data = {};
try { data = e.data ? e.data.json() : {}; } catch (_) {}
// every path below ends in a notification or, failing that, the stand-in
let showed = false;
// A notification the platform refuses must not count as shown: the old
// code flagged it before the call, so a refusal left the push with no line
// at all — a silent push, on iOS a strike towards losing the subscription.
// (PullCap's night of 2026-10-22: paid four times, no "Payment sent" seen.)
// Try the full options; refused, try again with the plainest ones.
const show = async (title, opts) => {
try { await self.registration.showNotification(title, opts); showed = true; return; }
catch (e) { console.warn('[sw] notification refused:', e && e.message); }
try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; }
catch (e) { console.warn('[sw] plain notification refused too:', e && e.message); }
};
// the stand-in, as soon as a path knows it has nothing to say (a long tail
// of work after it mustn't keep the push waiting for its notification)
const quiet = async () => {
if (showed) return;
showed = true;
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }).catch(() => []);
await standIn(clients.some((c) => c.visibilityState === 'visible')).catch(() => {});
};
const settle = (work) => e.waitUntil((async () => {
try { await work(); } finally { await quiet(); }
})());
// User-facing notifications (payments, DMs, community chat). An open
// window already shows everything live — stay quiet then.
// A call ringing in: the push names the offer's wrap, kept at the notifier
// (too big to ride in a push). Opened here with our key; a fresh offer
// from someone we follow or talk to rings, as a notification that stays
// until it's answered or missed. The offer is kept for the app to pick up
// when the tap opens it (CALL_CACHE). An open window rings by itself.
if (data.type === 'notify' && data.reason === 'call') {
settle(async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.some((c) => c.visibilityState === 'visible')) return;
let ring = null;
try {
const r = await fetch(WRAPS + '/wrap/' + encodeURIComponent(data.wrapId || ''));
if (r.ok) ring = await classifyCall(await r.json(), await allInboxes());
} catch (err) { console.warn('[sw-call] could not open:', err && err.message); }
if (!ring) return;
if (ring.quiet) {
// no ring for them; a note for the app, which lists it as a missed call
try {
const cache = await caches.open(CALL_CACHE);
const had = await cache.match('/__quiet-calls');
const list = had ? await had.json() : [];
list.push(ring.offer);
await cache.put('/__quiet-calls', new Response(JSON.stringify(list.slice(-20)), { headers: { 'content-type': 'application/json' } }));
} catch (_) {}
return;
}
try {
const cache = await caches.open(CALL_CACHE);
await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } }));
} catch (_) {}
for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} }
await show((ring.name || 'Someone') + ' is calling', {
body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.',
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400],
data: { url: './?open=call', view: 'call' },
});
});
return;
}
if (data.type === 'notify') {
settle(async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.some((c) => c.visibilityState === 'visible')) return;
// Money landing is exactly when a forwarding rule wants to run, and this
// wake-up is the only moment the worker gets. Failures are recorded in
// the mirror; the notification still goes out either way.
if (data.reason === 'payment' || data.reason === 'zap') {
try { await bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }); }
catch (err) { console.warn('[sw-aw] failed:', err && err.message); }
}
// A DM arrives wrapped, so only this device can see who sent it. Open it
// here: a message from someone we follow gets their name on it, our own
// sent-copy and a stranger's get nothing at all. Anything we can't open
// — a remote-signer wallet, an oversized wrap — falls through to the
// generic notification rather than being dropped silently.
let dmName = null;
if (data.reason === 'dm' && data.wrap) {
try {
const verdict = await classifyDm(data.wrap, await allInboxes());
if (!shouldNotifyDm(verdict)) return;
if (verdict && verdict.name) dmName = verdict.name;
} catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); }
}
// A reaction names who, when this device knows them, and is dropped
// when they are muted here — the notifier can't know either.
let who = null;
if (data.reason === 'reaction' && data.from) {
const boxes = await allInboxes().catch(() => []);
if (boxes.some((b) => (b.muted || []).includes(data.from))) return;
who = boxes.map((b) => b.names && b.names[data.from]).find(Boolean) || null;
}
const emoji = !data.emoji || data.emoji === '+' ? '\u2764\ufe0f' : /^:[\w-]+:$/.test(data.emoji) ? data.emoji : data.emoji.slice(0, 8);
const T = {
payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Open coinos to see it.'],
dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'],
chat: ['New chat activity', 'There are new messages in your communities.'],
mention: [data.reply ? 'New reply to your post' : 'You were mentioned', data.text || 'Open coinos to see it.'],
zap: ['Zap received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Someone zapped you. Open coinos to see it.'],
reaction: [who ? `${who} reacted ${emoji}` : `New reaction ${emoji}`, 'to your post. Open coinos to see it.'],
};
const [title, body] = T[data.reason] || T.chat;
// a reply lands you in the Notifications list, where it is
const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' };
await show(title, {
body,
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'notify-' + (data.reason || 'chat'), renotify: ['payment', 'zap', 'mention'].includes(data.reason),
data: target,
});
});
return;
}
if (data.type !== 'nwc') { settle(async () => {}); return; }
const startedAt = Date.now();
settle(async () => {
// WebKit's silent-push quota counts a push whose notification comes
// late: a payment can take most of the push budget, and a worker stopped
// before its last line never shows one (PullCap's night of 2026-10-22:
// four payments, no line for the fourth, then no more wakes). On an
// Apple endpoint the stand-in goes up FIRST; the outcome replaces it.
const apple = await applePush();
const dropStandIn = async () => {
const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []);
for (const n of open) n.close();
};
if (apple && await standIn(false).catch(() => false)) showed = true;
// An open window handles requests itself with full wallet state — nudge
// it and stay out of the way. On iOS only one on screen counts: a
// backgrounded app is suspended, so a nudge to it went unanswered.
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const clients = all.filter((c) => !apple || c.visibilityState === 'visible');
if (clients.length) {
for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
if (apple) await dropStandIn(); // the window on screen shows it live
return;
}
// A wake is the only moment a closed wallet gets to look after its coins:
// claim renewals whose round has run, cover coins without one. After the
// answer, inside what is left of the push budget.
const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) })
.catch((err) => console.warn('[sw-renew] failed:', err && err.message));
// an NWC wake-up may also have moved money in; check the rule after
setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0);
let handled = false;
try {
handled = await withRequestLock(data.event && data.event.id,
() => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }),
() => { console.log('[sw-nwc] another context holds this request'); return true; });
} catch (err) {
console.warn('[sw-nwc] auto-answer failed:', err && err.message);
}
// handled-with-a-heads-up: e.g. an invoice was minted while closed and
// the user should open coinos to complete the receive
if (handled && handled.notify) {
await show(handled.notify.title, {
body: handled.notify.body,
icon: 'icon-192.png', badge: 'badge-96.png',
tag: handled.notify.tag || 'nwc-incoming', renotify: !apple,
data: { url: './' },
});
if (apple) await dropStandIn();
await tend();
return;
}
if (handled) { await quiet(); await tend(); return; }
// A kind-21001 offer request is money coming IN (someone asking us to
// mint an invoice) — "an app wants to pay" would read as the opposite.
const incoming = data.event && data.event.kind === 21001;
await show(incoming ? 'Incoming payment' : 'Payment request', {
body: incoming
? 'Someone is trying to send you money. Open coinos so your wallet can receive it.'
: 'An app is asking your wallet to pay. Open Coinos to approve.',
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },
});
if (apple) await dropStandIn();
});
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const d = (e.notification && e.notification.data) || {};
e.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const open = all.find((c) => 'focus' in c);
if (open) {
// an open window goes where the notification points, without reloading
if (d.view) { try { open.postMessage({ type: 'open', view: d.view }); } catch (_) {} }
return open.focus();
}
if (self.clients.openWindow) return self.clients.openWindow(d.url || './');
})());
});
|