aboutsummaryrefslogtreecommitdiffstats
path: root/src/sw-nwc.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/sw-nwc.js')
-rw-r--r--src/sw-nwc.js225
1 files changed, 183 insertions, 42 deletions
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index 3dac246..78bb2bd 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -4,20 +4,121 @@
// 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 { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js';
+import { withRequestLock, withWorkLock } from './nwc-lock.js';
+import { appendBgLog } from './nwc-bg.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
-import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js';
+import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
+// The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is
+// written, so a worker the phone stops mid-push leaves its last step behind.
+const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); };
+diary('worker started');
+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; diary(`notification shown: ${title}`); return; }
+ catch (e) { diary(`notification refused (${title}): ${e && e.message}`); }
+ try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; diary(`plain notification shown: ${title}`); }
+ catch (e) { diary(`plain notification refused too (${title}): ${e && e.message}`); }
+ };
+ diary(`push ${data.type || '?'}${data.reason ? '/' + data.reason : ''}${data.event && data.event.id ? ' ' + data.event.id.slice(0, 8) : ''}`);
+ // 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') {
- e.waitUntil((async () => {
+ 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
@@ -60,65 +161,105 @@ self.addEventListener('push', (e) => {
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 self.registration.showNotification(title, {
+ 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') return;
+ if (data.type !== 'nwc') { settle(async () => {}); return; }
const startedAt = Date.now();
- e.waitUntil((async () => {
+ 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((e) => { diary('stand-in refused: ' + (e && e.message)); return false; })) { showed = true; diary('stand-in shown'); }
// An open window handles requests itself with full wallet state — nudge
- // it and stay out of the way.
- const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ // 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
+ diary('window on screen — handed over');
return;
}
+ const notice = (n) => show(n.title, {
+ body: n.body, icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: n.tag || 'nwc-incoming', renotify: !apple, data: { url: './' },
+ });
// 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));
+ // finish sends an earlier wake left in flight, claim renewals whose round
+ // has run, cover coins without one. After the answer, inside what is
+ // left of the push budget.
+ const tend = async () => {
+ const late = await tendLnPays({ deadline: startedAt + 27_000, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; });
+ for (const n of late) await notice(n);
+ if (late.length && apple) await dropStandIn();
+ await tendRenewals({ deadline: startedAt + 27_000, log: (m) => diary('renew: ' + m) })
+ .catch((err) => diary('renewals 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 self.registration.showNotification(handled.notify.title, {
- body: handled.notify.body,
+ // one push at a time over the shared mirror (a client's retry lands
+ // seconds after its lookup, and each push builds its own manager)
+ await withWorkLock(async () => {
+ const waited = Date.now() - startedAt;
+ if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`);
+ let handled = false;
+ try {
+ handled = await withRequestLock(data.event && data.event.id,
+ () => respondFromBg(data, { notifier: NOTIFIER, log: diary }),
+ () => { diary('another context holds this request'); return true; });
+ } catch (err) {
+ diary('auto-answer failed: ' + (err && err.message));
+ }
+ // sends from an earlier wake that settled on the way to this answer
+ const late = (handled && handled.notices) || [];
+ for (const n of late) await notice(n);
+ // 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 notice(handled.notify);
+ if (apple) await dropStandIn();
+ await tend();
+ return;
+ }
+ if (handled) {
+ if (late.length && apple) await dropStandIn();
+ diary('answered quietly');
+ 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;
+ diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request');
+ 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: handled.notify.tag || 'nwc-incoming', renotify: true,
+ tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },
});
+ if (apple) await dropStandIn();
await tend();
- return;
- }
- if (handled) { 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 self.registration.showNotification(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: './' },
});
- })());
+ diary(`push done in ${Date.now() - startedAt} ms`);
+ });
});
self.addEventListener('notificationclick', (e) => {