aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 09:56:33 -0700
committerAdam Soltys <[email protected]>2026-09-12 09:56:33 -0700
commitcab447766734cb99821c36988ebc8d27a0bce228 (patch)
tree821b3670984b075261662463d99a721090de3847
parent1ca77a5999bc546f92ec7189acfba1dc8088a2a7 (diff)
downloadcoinosv3-cab447766734cb99821c36988ebc8d27a0bce228.tar.gz
coinosv3-cab447766734cb99821c36988ebc8d27a0bce228.zip
Tell a phone with no push service what's actually wrong.
"Registration failed - push service error" is Chrome's own wording, and it tells nobody anything. The cause is that Chromium routes web push through Firebase: on GrapheneOS — or any Android without Google Play Services — there is no push service to register with, so the app cannot be woken while it's closed, and no amount of tapping the toggle will change that. Both places that ask for push (the chat's offer and the NWC "answer while closed" toggle) now recognise that specific failure and say: this browser has no push service; on Android that's usually a phone without Play Services; Firefox brings its own and works there; and zaps are still answered whenever coinos is open. The last part matters most — the feature isn't broken, it's narrower than they thought. The failure is remembered so the app stops retrying it on every launch, and an explicit tap on the toggle always clears that and tries again, because a phone that changed browsers deserves another go. tools/push-unavailable-test.js runs a browser whose subscribe() fails exactly as Chrome's does and reads back what the user is told. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--src/features/messages.js17
-rw-r--r--src/features/nwc.js44
-rw-r--r--src/i18n.js3
-rw-r--r--tools/push-unavailable-test.js74
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);