diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 10:32:22 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 10:32:22 -0700 |
| commit | 1ea1784c4ad7d6715c0efdcd0078019c692eefe5 (patch) | |
| tree | e5f86cd28d103341114930444c3980cda72257ac | |
| parent | d59a7f0c9b32002ff23f74a2996b986cf1f0dd05 (diff) | |
| download | coinosv3-1ea1784c4ad7d6715c0efdcd0078019c692eefe5.tar.gz coinosv3-1ea1784c4ad7d6715c0efdcd0078019c692eefe5.zip | |
iPhone: a push subscription iOS dropped is made again when the app opens or comes back
WebKit removes a subscription after three pushes that showed no
notification (743011c stops new strikes, but a wallet already struck out
stayed deaf): background stayed on, getSubscription() came back empty and
refreshRegistration gave up, so NWC requests stopped waking the closed app
until the switch was toggled (reported by PullCap). It now subscribes again
(also when the VAPID key changed) and re-registers with the notifier, on
start and whenever the app returns to the screen.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K5xtiFrkFVsrkWx6JDQjiT
| -rw-r--r-- | src/features/nwc.js | 24 | ||||
| -rw-r--r-- | tools/push-resubscribe-test.js | 103 |
2 files changed, 124 insertions, 3 deletions
diff --git a/src/features/nwc.js b/src/features/nwc.js index ec79081..9c71a7f 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -822,6 +822,25 @@ export function nwcFeature(ctx) { // wrong keys — background wake-ups silently die the first time a // connection is recreated. Debounced; the server replaces by endpoint. let regTimer = null; + // The browser's subscription, made again when it's gone: iOS drops it after + // three pushes that showed no notification (and a key rotation strands an + // old one), and nothing else re-arms a wallet whose `background` is still on. + async function currentSub() { + const reg = await navigator.serviceWorker.ready; + const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); + const want = b64ToBytes(publicKey); + let sub = await reg.pushManager.getSubscription(); + if (sub) { + let have = []; + try { have = new Uint8Array(sub.options.applicationServerKey || []); } catch {} + if (have.length === want.length && have.every((b, i) => b === want[i])) return sub.toJSON(); + try { await sub.unsubscribe(); } catch {} + } + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return null; + sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: want }); + dlog('nwc: push subscription was gone, made a new one'); + return sub.toJSON(); + } function refreshRegistration() { if (!load().background) return; if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; @@ -831,8 +850,7 @@ export function nwcFeature(ctx) { const pks = watchPks(); if (!pks.length) return; const up2 = upEndpoint(); - const sub = up2 ? { endpoint: up2, unifiedpush: true } - : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON()); + const sub = up2 ? { endpoint: up2, unifiedpush: true } : await currentSub(); if (!sub) return; await fetch(`${NOTIFIER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -949,7 +967,7 @@ export function nwcFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && conns().length) listen(); + if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); } }); } } diff --git a/tools/push-resubscribe-test.js b/tools/push-resubscribe-test.js new file mode 100644 index 0000000..19998b2 --- /dev/null +++ b/tools/push-resubscribe-test.js @@ -0,0 +1,103 @@ +// iOS drops a web push subscription after three pushes that showed no +// notification, and nothing used to make a new one: the wallet's background +// switch stayed on, getSubscription() came back empty, and the notifier kept +// a dead endpoint, so NWC requests stopped waking the closed app until the +// user toggled it off and on (reported by PullCap). Opening the app, or +// coming back to it, must subscribe again and tell the notifier. +// +// Run: bun tools/push-resubscribe-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const KEY = 'BP' + 'A'.repeat(85); // any 65-byte key, base64url +await page.evaluateOnNewDocument((KEY) => { + const bytes = () => Uint8Array.from(atob(KEY.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)); + window.__subs = 0; window.__regs = []; + let sub = null; // iOS has already dropped it + const make = () => ({ + endpoint: 'https://web.push.apple.com/sub' + window.__subs, + options: { applicationServerKey: bytes().buffer }, + toJSON() { return { endpoint: this.endpoint, keys: { p256dh: 'x', auth: 'y' } }; }, + unsubscribe: () => { sub = null; return Promise.resolve(true); }, + }); + window.__drop = () => { sub = null; }; + const reg = { + pushManager: { + getSubscription: () => Promise.resolve(sub), + subscribe: () => { window.__subs++; sub = make(); return Promise.resolve(sub); }, + }, + showNotification: () => Promise.resolve(), + }; + Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve(reg) }); + Notification.requestPermission = () => Promise.resolve('granted'); + Object.defineProperty(Notification, 'permission', { get: () => 'granted' }); + const f = window.fetch; + window.fetch = async (u, o) => { + const url = String(u); + if (url.startsWith('https://nwcpush.coinos.io/vapid')) return new Response(JSON.stringify({ publicKey: KEY })); + if (url.startsWith('https://nwcpush.coinos.io/register')) { + const b = JSON.parse(o.body); + if (b.servicePubkeys) window.__regs.push(b.subscription.endpoint); + return new Response('{"ok":true}'); + } + return f(u, o); + }; +}, KEY); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms/250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(500); + await click('to import'); await sleep(400); + await page.waitForSelector('textarea'); + const mn = generateMnemonic(wordlist); + await page.type('textarea', mn); + await click('open wallet'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + + // a wallet answering in the background, whose subscription iOS took away + await page.evaluate((k) => localStorage.setItem(k + ':nwc', JSON.stringify({ + conns: [{ id: 'x', name: 'test', servicePk: 'a'.repeat(64), sk: 'b'.repeat(64), maxSat: 1000, budget: {} }], + background: true, + })), base); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await sleep(3000); + let s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs })); + check('opening the app subscribes again', s.subs >= 1, `${s.subs} subscribe()`); + check('...and registers the new endpoint for the connection', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' ')); + + // dropped again while the app sat in the background; it resumes, no reload + await page.evaluate(() => { window.__drop(); window.__regs.length = 0; }); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await sleep(3000); + const before = s.subs; + s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs })); + check('coming back to the app subscribes again', s.subs > before, `${s.subs} subscribe()`); + check('...and registers it', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' ')); + + // a live subscription is left alone + await page.evaluate(() => { window.__regs.length = 0; document.dispatchEvent(new Event('visibilitychange')); }); + await sleep(3000); + const again = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs })); + check('a subscription still there is reused', again.subs === s.subs && again.regs.length >= 1); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a dropped push subscription comes back' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
