diff options
| -rw-r--r-- | src/features/messages.js | 17 | ||||
| -rw-r--r-- | src/features/nwc.js | 44 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | tools/push-unavailable-test.js | 74 |
4 files changed, 129 insertions, 9 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index e1f3ea3..b12efa7 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1278,6 +1278,10 @@ export function messagesFeature(ctx) { const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); const wantKey = b64ToBytes(publicKey); let sub = await reg.pushManager.getSubscription(); + // see nwc.js: a Chromium without Google Play Services (GrapheneOS, and + // any de-Googled Android) has no push service at all, and says so in a + // way nobody can act on. Remember it so the offer can explain itself. + const noPushService = (e) => /push service|Registration failed|AbortError|NotSupportedError/i.test((e && (e.message || e.name)) || ''); // A subscription made against a DIFFERENT VAPID key can never receive a // push — the notifier's sends fail with a permanent 403 and it keeps // reusing the dead sub, so the device goes silent forever (this is what @@ -1291,8 +1295,14 @@ export function messagesFeature(ctx) { }; if (sub && !keyMatches(sub)) { try { await sub.unsubscribe(); } catch {} sub = null; } if (!sub) { - sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: wantKey }); + try { + sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: wantKey }); + } catch (e) { + if (noPushService(e)) { const s2 = st(); s2.noPushService = true; save(s2); render(); } + return false; + } } + { const s2 = st(); if (s2.noPushService) { delete s2.noPushService; save(s2); } } const r = await fetch(`${NOTIFIER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: sub.toJSON(), notify: pushWatch() }), @@ -3969,6 +3979,11 @@ export function messagesFeature(ctx) { class: 'btn-ghost btn-sm', onClick: () => { const s = st(); s.pushDismissed = true; save(s); render(); }, }, t('msgDismiss'))))); + // A browser with no push service at all: the offer above is beside the + // point (permission may even be granted), so say what's actually wrong + // wherever the user got to. + if (st().noPushService && !st().push) + kids.push(h('div', { class: 'notice info small' }, t('nwcNoPushService'))); // ---- the feed, above the conversations: it's the thing you read, they're // the things you answer diff --git a/src/features/nwc.js b/src/features/nwc.js index 45bbbf5..077b53e 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -584,18 +584,39 @@ export function nwcFeature(ctx) { return Uint8Array.from(raw, (c) => c.charCodeAt(0)); }; + // Waking a closed app needs a push service, and on Android that means the + // browser's: Chrome (and every Chromium, including GrapheneOS's Vanadium) + // routes web push through Firebase, which isn't there on a phone without + // Google Play Services. The browser says so as "Registration failed - push + // service error", which tells the user nothing. Translate it, and name the + // thing that does work. + const isNoPushService = (e) => { + const m = (e && (e.message || e.name)) || ''; + return /push service|Registration failed|AbortError|NotSupportedError|permission denied/i.test(m); + }; + async function enableBackground() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - throw new Error('this browser cannot receive push'); + throw new Error(t('nwcNoPushHere')); } const perm = await Notification.requestPermission(); - if (perm !== 'granted') throw new Error('notifications were not allowed'); + if (perm !== 'granted') throw new Error(t('nwcNoPermission')); const reg = await navigator.serviceWorker.ready; const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: b64ToBytes(publicKey), - }); + let sub; + try { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: b64ToBytes(publicKey), + }); + } catch (e) { + if (isNoPushService(e)) { + const st2 = load(); st2.pushUnavailable = true; save(st2); + throw new Error(t('nwcNoPushService')); + } + throw e; + } + { const st2 = load(); if (st2.pushUnavailable) { delete st2.pushUnavailable; save(st2); } } const pks1 = conns().map((c) => c.servicePk); if (load().offer) pks1.push(load().offer.pk); // Arming can precede the first connection (creation requires it) — the @@ -807,7 +828,11 @@ export function nwcFeature(ctx) { : h('button', { class: 'btn-sm', onClick: () => { ui.nwcShow = c.id; render(); } }, t('nwcShow'))); }), list.length - ? h('div', { class: 'row between', style: 'border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, + ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, + load().pushUnavailable && !load().background + ? h('div', { class: 'small faint' }, t('nwcNoPushService')) + : null, + h('div', { class: 'row between' }, h('span', { class: 'small' }, t('nwcBackground')), h('button', { class: 'btn-sm', onClick: async () => { try { @@ -817,6 +842,9 @@ export function nwcFeature(ctx) { const s = load(); s.backgroundOff = true; save(s); toast(t('nwcBackgroundOff')); } else { + // an explicit tap always retries: a phone that grew a push + // service (or a different browser) deserves another go + const s0 = load(); delete s0.pushUnavailable; save(s0); await enableBackground(); const s = load(); delete s.backgroundOff; save(s); await reconcileBg(); @@ -824,7 +852,7 @@ export function nwcFeature(ctx) { } } catch (e) { toast(e.message); } render(); - } }, load().background ? t('nwcOn') : t('nwcOff'))) + } }, load().background ? t('nwcOn') : t('nwcOff')))) : null, st ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, diff --git a/src/i18n.js b/src/i18n.js index 4121df1..38836d2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -852,6 +852,9 @@ const STR = { nwcTabWarning: 'Works while Coinos is open — turn on background answering below to keep working when it isn’t.', nwcAdd: 'Add a connection', nwcBackground: 'Answer while closed', + nwcNoPushHere: 'This browser can\u2019t receive push messages.', + nwcNoPermission: 'Notifications weren\u2019t allowed.', + nwcNoPushService: 'This browser has no push service, so it can\u2019t be woken while closed. On Android that\u2019s usually a phone without Google Play Services \u2014 Firefox brings its own push and works there. Zaps are still answered whenever coinos is open.', nwcOn: 'On', nwcOff: 'Off', nwcBackgroundOn: 'Requests will be answered in the background', diff --git a/tools/push-unavailable-test.js b/tools/push-unavailable-test.js new file mode 100644 index 0000000..b285aee --- /dev/null +++ b/tools/push-unavailable-test.js @@ -0,0 +1,74 @@ +// A phone with no push service — GrapheneOS, or any de-Googled Android — +// can't be woken while the app is closed, because Chromium routes web push +// through Firebase. The browser reports that as "Registration failed - push +// service error", which tells nobody anything. The app has to say what's +// actually wrong and what works instead. +// +// Run: bun tools/push-unavailable-test.js +import puppeteer from 'puppeteer-core'; +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: 5262, 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(); +await page.evaluateOnNewDocument(() => { + const reg = { + pushManager: { + getSubscription: () => Promise.resolve(null), + // exactly what Chrome throws with no Firebase behind it + subscribe: () => Promise.reject(new DOMException('Registration failed - push service error', 'AbortError')), + }, + showNotification: () => Promise.resolve(), + }; + Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve(reg) }); + Notification.requestPermission = () => Promise.resolve('granted'); + Object.defineProperty(Notification, 'permission', { get: () => 'granted' }); +}); +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:5262/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('get started'); await sleep(500); + await click('import existing'); 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'); + + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(2500); + const txt = await page.evaluate(() => document.body.innerText); + check('the chat says why it can\'t be woken', /no push service/i.test(txt), (txt.match(/[^\n]*push service[^\n]*/i) || ['nothing'])[0].slice(0, 60)); + check('...and names what does work', /Firefox/.test(txt) && /Google Play Services/.test(txt)); + check('...and says zaps still work with the app open', /whenever coinos is open/i.test(txt)); + const flag = await page.evaluate((k) => (JSON.parse(localStorage.getItem(k + ':messages') || '{}')).noPushService === true, base); + check('the failure is remembered, so it isn\'t retried on a loop', flag); + + // the same explanation where the zap-answering toggle lives + 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: {} }], + pushUnavailable: true, + })), base); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /settings/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(700); + await click('nostr'); await sleep(1200); + const settings = await page.evaluate(() => document.body.innerText); + check('the "answer while closed" toggle explains itself too', + /no push service/i.test(settings), (settings.match(/[^\n]*push service[^\n]*/i) || ['nothing'])[0].slice(0, 50)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a phone without push is told why' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
