aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 07:32:50 -0700
committerAdam Soltys <[email protected]>2026-10-22 07:32:50 -0700
commit743011c02300ef884333d41c87a88900173023c9 (patch)
treeb5cab06fa9e8f5d4462281d96a37e64b1564f830 /src
parentd0752d70b54e9d58bba5e11aea06c9ba1678e89b (diff)
downloadcoinosv3-743011c02300ef884333d41c87a88900173023c9.tar.gz
coinosv3-743011c02300ef884333d41c87a88900173023c9.zip
iPhone: every push ends in a notification, so iOS stops dropping the wallet's push subscription
WebKit allows no silent pushes: a push that shows no notification is a strike (even with the app open), and after three it removes the subscription, so a closed wallet stopped waking for NWC requests until it was opened again (reported by PullCap). On an Apple endpoint, the paths that used to end quietly (lookups answered, another device took the request, DMs/reactions we don't announce, an open window, unknown pushes) now show a silent self-replacing "Coinos" line, closed at once when the app is on screen. A backgrounded iOS app is suspended, so an NWC request is no longer handed to it: the worker answers itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/sw-nwc.js70
1 files changed, 56 insertions, 14 deletions
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index f9d383e..0b65268 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -15,9 +15,48 @@ const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app th
// 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;
+ 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();
+ }
+}
+
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;
+ const show = (title, opts) => { showed = true; return self.registration.showNotification(title, opts); };
+ // 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 +65,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 +90,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,21 +143,24 @@ 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 () => {
// 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 apple = await applePush();
+ 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 });
return;
@@ -141,7 +183,7 @@ self.addEventListener('push', (e) => {
// 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, {
+ 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,
@@ -150,11 +192,11 @@ self.addEventListener('push', (e) => {
await tend();
return;
}
- if (handled) { 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 self.registration.showNotification(incoming ? 'Incoming payment' : '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.',
@@ -162,7 +204,7 @@ self.addEventListener('push', (e) => {
tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },
});
- })());
+ });
});
self.addEventListener('notificationclick', (e) => {