diff options
Diffstat (limited to 'src/sw-nwc.js')
| -rw-r--r-- | src/sw-nwc.js | 194 |
1 files changed, 149 insertions, 45 deletions
diff --git a/src/sw-nwc.js b/src/sw-nwc.js index f9d383e..3a48cff 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -4,20 +4,77 @@ // 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, takeLease } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.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${typeof navigator !== 'undefined' && navigator.locks ? '' : ' (no Web Locks)'}`); 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 @@ -26,7 +83,7 @@ self.addEventListener('push', (e) => { // 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') { - e.waitUntil((async () => { + settle(async () => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); if (clients.some((c) => c.visibilityState === 'visible')) return; let ring = null; @@ -51,17 +108,17 @@ self.addEventListener('push', (e) => { 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 self.registration.showNotification((ring.name || 'Someone') + ' is calling', { + 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 @@ -104,65 +161,112 @@ 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(); + // What a push may take. iOS stops the worker about ten seconds in (PullCap's + // diary of 2026-10-24: every wake's last line 8–12 s after its push, in the + // middle of a payment), whatever the waitUntil — so there everything is + // planned inside nine; elsewhere the usual thirty. + const budgetEnd = startedAt + (apple ? 9_000 : 27_000); + 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: budgetEnd, 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: budgetEnd, 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`); + if (Date.now() > budgetEnd - 2_000) { diary('no time left after waiting — leaving it to the next try'); return; } + let handled = false; + try { + handled = await withRequestLock(data.event && data.event.id, + () => respondFromBg(data, { notifier: NOTIFIER, log: diary, deadline: budgetEnd }), + () => { 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; + } + if (Date.now() > budgetEnd) { diary('out of time — leaving it to the next try'); 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: './' }, - }); - })()); + }, { lease: () => takeLease('work', { wait: Math.max(0, budgetEnd - Date.now() - 3_000) }) }); + diary(`push done in ${Date.now() - startedAt} ms`); + }); }); self.addEventListener('notificationclick', (e) => { |
