aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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);