From 91dbee408ad59ef4eb85dbec3eff4d512c9b4792 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 23 Oct 2026 08:12:40 -0700 Subject: Push handler: a notification the platform refuses is retried plain and never counts as shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PullCap's iPhone paid four times overnight with the "payments sent" notifications on and never showed a "Payment sent" line. The worker hands the handler the heads-up (covered by a test now); the handler flagged the push as answered BEFORE calling showNotification, so a refusal left the push with no line at all — on iOS a silent push, a strike towards losing the subscription. Now the flag follows success, a refused notification is retried with the plainest options, and the stand-in covers the rest. iOS never replaces by tag, so renotify is left out there. Co-Authored-By: Claude Fable 5.1 --- src/sw-nwc.js | 19 +++++++++++++++---- tools/nwc-sw-test.js | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/sw-nwc.js b/src/sw-nwc.js index dbd2d98..211c4f9 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -29,7 +29,7 @@ async function applePush() { } catch (_) { return false; } } async function standIn(visible) { - if (!(await applePush())) return; + 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: './' }, @@ -38,6 +38,7 @@ async function standIn(visible) { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); } + return true; } self.addEventListener('push', (e) => { @@ -45,7 +46,17 @@ self.addEventListener('push', (e) => { try { data = e.data ? e.data.json() : {}; } catch (_) {} // every path below ends in a notification or, failing that, the stand-in let showed = false; - const show = (title, opts) => { showed = true; return self.registration.showNotification(title, opts); }; + // 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 () => { @@ -165,7 +176,7 @@ self.addEventListener('push', (e) => { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); }; - if (apple) { showed = true; await standIn(false).catch(() => {}); } + 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. @@ -197,7 +208,7 @@ self.addEventListener('push', (e) => { await show(handled.notify.title, { body: handled.notify.body, icon: 'icon-192.png', badge: 'badge-96.png', - tag: handled.notify.tag || 'nwc-incoming', renotify: true, + tag: handled.notify.tag || 'nwc-incoming', renotify: !apple, data: { url: './' }, }); if (apple) await dropStandIn(); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index f037275..1ce9944 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -178,6 +178,31 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z check('exhausted key window → wake the user', handled === false); } +console.log('\n[pay outcome]'); +{ + // a payment that goes through: the worker hands the push handler a + // "Payment sent" heads-up (unless the user turned those off) + const { ArkManager } = await import('../src/ark/manager.js'); + const orig = { init: ArkManager.prototype.init, pay: ArkManager.prototype.payLnInvoice, wait: ArkManager.prototype.awaitLnPay, act: ArkManager.prototype.lnAction }; + const done = { id: 'ln-1', step: 'done', amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) }; + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + ArkManager.prototype.payLnInvoice = async () => 'ln-1'; + ArkManager.prototype.awaitLnPay = async () => done; + ArkManager.prototype.lnAction = () => done; + try { + const { deps, published, decryptReply } = harness(); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('a paid invoice answers with the preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === done.preimage, JSON.stringify(handled)); + check('…and asks for a "Payment sent" notification', handled && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled)); + const rec = baseRec(); rec.prefs = { paySent: false }; + const h2 = harness({ rec }); + const quiet = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, h2.deps); + check('payments-sent notifications off → paid quietly', quiet === true && h2.published.length === 1); + } finally { + Object.assign(ArkManager.prototype, { init: orig.init, payLnInvoice: orig.pay, awaitLnPay: orig.wait, lnAction: orig.act }); + } +} + console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense -- cgit v1.2.3