diff options
| -rw-r--r-- | src/features/ark.js | 4 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/notify-text.js | 79 | ||||
| -rw-r--r-- | src/nwc-respond.js | 4 | ||||
| -rw-r--r-- | src/sw-nwc.js | 32 | ||||
| -rw-r--r-- | tools/call-wake-test.js | 4 | ||||
| -rw-r--r-- | tools/notif-spam-score.js | 82 | ||||
| -rw-r--r-- | tools/sw-push-quota.test.js | 2 |
8 files changed, 186 insertions, 26 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 8b3121c..d1fa356 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3616,8 +3616,8 @@ export function arkFeature(ctx) { date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }); navigator.serviceWorker?.ready - .then((reg) => reg.showNotification('coinos', { body })) - .catch(() => { try { new Notification('coinos', { body }); } catch {} }); + .then((reg) => reg.showNotification(t('arkRenewWarnTitle'), { body })) + .catch(() => { try { new Notification(t('arkRenewWarnTitle'), { body }); } catch {} }); } catch {} } diff --git a/src/i18n.js b/src/i18n.js index ac6066a..db54708 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1235,7 +1235,10 @@ const STR = { arkRenewForecast: 'Coins renew in ~{when} for {fee}.', arkRenewForecastNow: 'Coins renew shortly for {fee}.', arkBoardStuck: 'Moving money hit a snag and will keep retrying: {error}', - arkRenewWarnPush: 'Your Spending balance is too small to renew itself and expires around {date}. Receive any amount or spend it to keep it alive.', + // the push's words are measured against Chrome's spam model (src/notify-text.js): + // 'too small to renew itself and expires around' scored 98, this 11 + arkRenewWarnTitle: 'Spending balance', + arkRenewWarnPush: 'Receive or spend sats before {date} to keep it alive.', giftArkOutstanding: '{n} unclaimed instant gift(s)', giftArkRevoked: 'Gift swept back into Spending.', claimArkFree: 'instant, no fee', diff --git a/src/notify-text.js b/src/notify-text.js new file mode 100644 index 0000000..a88ab14 --- /dev/null +++ b/src/notify-text.js @@ -0,0 +1,79 @@ +// The words of every notification the worker shows while the app is closed. +// +// Chrome on Android (and, quietly, on desktop) scores each web notification's +// title and body with an on-device "unwanted notification" model before it +// shows it, and above 90 of 100 the notification is replaced by a "Possible +// spam" warning — hidden until the user taps "Show notification", and nine +// such in a week revoke the site's permission outright on a phone that +// doesn't visit much (Chromium kAutoRevokeSuspiciousNotification). Our old +// wording scored 92–99 across the board: "Payment received", "Incoming +// payment", "Payment request", "alice is calling", "New message", "alice +// sent you a message" — the exact shape of the scam notifications the model +// was trained (on synthetic data) to catch. What it lets through is less a +// rule than a measurement; the scores here come from Chrome 156's model via +// tools/notif-spam-score.js, which re-scores SAMPLES in a throwaway Chrome. +// Measured 2026-11-03: "sats" reads as money without reading as a payment +// scam (9–14), "payment" does not (95–97); a person's NAME in the body of a +// message or reaction line pushes it over (80–99), "New chat" / "<name>" +// does not (8–32); "reply" in any title is 96–99, "Someone replied" / +// "Open coinos to see it." is 13; a reply's TEXT is uncontrollable (an +// "@name" or a link in it scores 97+), so none rides along; "Calling" / +// "<name>" is 11–29 where every "<name> is calling" shape is 80–99. +// Re-run the scorer before changing any of these. + +export const OPEN = 'Open coinos to see it.'; + +function sats(n) { + const v = Number(n); + return v > 0 ? `+${v.toLocaleString()} sats. ${OPEN}` : OPEN; +} + +// A user-facing notification by reason. `d` is the push payload (amountSat, +// emoji) plus what the device adds (a sender's name). +export function notifyText(reason, d = {}) { + switch (reason) { + case 'payment': return ['Sats received', sats(d.amountSat)]; + case 'zap': return ['Zap received', sats(d.amountSat)]; + case 'dm': return ['New chat', d.name || 'Someone wrote to you']; + case 'chat': return ['New chat activity', 'There are new messages in your communities.']; + case 'mention': return [d.reply ? 'Someone replied' : 'You were mentioned', OPEN]; + case 'reaction': { + // a unicode emoji in the title is fine (17); a :shortcode: is not (85) + const e = d.emoji && !/^:[\w-]+:$/.test(d.emoji) ? ' ' + d.emoji : ''; + return ['New reaction' + e, `to your post. ${OPEN}`]; + } + case 'call': return ['Calling', d.name || 'Someone']; + // the wallet was asked for an invoice while closed (someone paying its name) + case 'incoming': return ['Someone is sending sats', 'Open coinos to receive them.']; + // a connected app asked the closed wallet to pay + case 'spend': return ['Spend request', 'A connected app asked to spend sats. Open coinos to approve.']; + default: return notifyText('chat'); + } +} + +// What tools/notif-spam-score.js scores: one line per shape a user can see. +export const SAMPLES = [ + notifyText('payment', { amountSat: 1000 }), + notifyText('payment', { amountSat: 21 }), + notifyText('payment'), + notifyText('zap', { amountSat: 1000 }), + notifyText('zap'), + notifyText('dm', { name: 'alice' }), + notifyText('dm', { name: 'Bitcoin Bob' }), + notifyText('dm', { name: 'Jarryd' }), + notifyText('dm'), + notifyText('chat'), + notifyText('mention'), + notifyText('mention', { reply: true }), + notifyText('reaction', { emoji: '❤️' }), + notifyText('reaction', { emoji: '🤙' }), + notifyText('reaction', { emoji: ':pepe:' }), + notifyText('call', { name: 'alice' }), + notifyText('call', { name: 'Bitcoin Bob' }), + notifyText('call', { name: 'Alice Caller' }), + notifyText('call'), + notifyText('incoming'), + notifyText('spend'), + ['Payment sent', '-1,000 sats via Damus'], + ['Spending balance', 'Receive or spend sats before 11/20/2026 to keep it alive.'], +]; diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 12101f5..98fd19c 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -14,6 +14,7 @@ // to a notification that opens the wallet. import { hex } from '@scure/base'; +import { notifyText } from './notify-text.js'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent } from 'nostr-tools/pure'; @@ -198,7 +199,8 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log await saveFn(walletKey, rec); await publish({ bolt11: a.invoice }); log(`minted ${sat} sat offer invoice while closed`); - return { notify: { title: 'Incoming payment', body: 'Someone is paying you. Open Coinos to receive it.' } }; + const [title, body] = notifyText('incoming'); + return { notify: { title, body } }; } catch (e) { log('offer mint failed: ' + e.message); await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {}); diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 3a48cff..a4e4e56 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -9,6 +9,7 @@ import { withRequestLock, withWorkLock } from './nwc-lock.js'; import { appendBgLog, takeLease } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; +import { notifyText } from './notify-text.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; // The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is @@ -108,8 +109,11 @@ 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 show((ring.name || 'Someone') + ' is calling', { - body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.', + // the words are measured against Chrome's spam model (notify-text.js): + // "<name> is calling" is hidden behind a warning, "Calling" / "<name>" shows + const [callTitle, callBody] = notifyText('call', { name: ring.name }); + await show(callTitle, { + body: callBody, 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' }, @@ -141,24 +145,15 @@ self.addEventListener('push', (e) => { if (verdict && verdict.name) dmName = verdict.name; } catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); } } - // A reaction names who, when this device knows them, and is dropped - // when they are muted here — the notifier can't know either. - let who = null; + // A reaction from someone muted on this device is dropped — the + // notifier can't know. (It no longer names who: a name in the line + // is what tips Chrome's spam model over, see notify-text.js.) if (data.reason === 'reaction' && data.from) { const boxes = await allInboxes().catch(() => []); if (boxes.some((b) => (b.muted || []).includes(data.from))) return; - who = boxes.map((b) => b.names && b.names[data.from]).find(Boolean) || null; } const emoji = !data.emoji || data.emoji === '+' ? '\u2764\ufe0f' : /^:[\w-]+:$/.test(data.emoji) ? data.emoji : data.emoji.slice(0, 8); - const T = { - payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Open coinos to see it.'], - dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'], - chat: ['New chat activity', 'There are new messages in your communities.'], - mention: [data.reply ? 'New reply to your post' : 'You were mentioned', data.text || 'Open coinos to see it.'], - zap: ['Zap received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Someone zapped you. Open coinos to see it.'], - reaction: [who ? `${who} reacted ${emoji}` : `New reaction ${emoji}`, 'to your post. Open coinos to see it.'], - }; - const [title, body] = T[data.reason] || T.chat; + const [title, body] = notifyText(data.reason, { amountSat: data.amountSat, name: dmName, reply: data.reply, emoji }); // 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 show(title, { @@ -254,10 +249,9 @@ self.addEventListener('push', (e) => { // mint an invoice) — "an app wants to pay" would read as the opposite. const incoming = data.event && data.event.kind === 21001; diary(incoming ? 'waking the user: incoming payment' : 'waking the user: 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.', + const [reqTitle, reqBody] = notifyText(incoming ? 'incoming' : 'spend'); + await show(reqTitle, { + body: reqBody, icon: 'icon-192.png', badge: 'badge-96.png', tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js index 54c42ee..b0f64cc 100644 --- a/tools/call-wake-test.js +++ b/tools/call-wake-test.js @@ -1,7 +1,7 @@ // A call wakes a closed app: with Bob's coinos closed, Alice calls; the push // the notifier would send (delivered here to Bob's service worker through // Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open -// it with Bob's key and ring — "Alice Caller is calling" — and the tap that +// it with Bob's key and ring — "Calling | Alice Caller" — and the tap that // opens the app finds the offer kept for it: it rings there, Bob answers, // they talk. A push for a stranger's call, or for something that isn't an // offer, rings nothing. Plus the worker's check on its own (classifyCall). @@ -152,7 +152,7 @@ try { // the tap: the worker opens the app at ?open=call await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' }); const ns = await notifications(B.page); - check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); + check('the worker rang: "Calling | Alice Caller"', ns.some((n) => /^Calling \| Alice Caller/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000)); // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one) const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length; diff --git a/tools/notif-spam-score.js b/tools/notif-spam-score.js new file mode 100644 index 0000000..c9e48ba --- /dev/null +++ b/tools/notif-spam-score.js @@ -0,0 +1,82 @@ +// Scores every notification the wallet can show against Chrome's on-device +// "unwanted notification" model — the one that replaces a web notification +// with "Possible spam" on Android when its title + body score above 90. +// +// Not part of the test suite: it launches a throwaway headful-less Chrome +// (/usr/bin/google-chrome) that fetches the model from Google's optimization +// guide on first use (~10 s, network), shows each line from src/notify-text.js +// SAMPLES as a persistent notification on a local page, and reads the score +// off chrome://histograms (SafeBrowsing.NotificationContentDetection +// .SuspiciousScore, one new sample per notification). Desktop Chrome runs the +// model too, flag or no flag; SystemNotifications is off so nothing reaches +// the desktop's notification tray. Exit 1 if any line scores 90 or more. +// +// bun tools/notif-spam-score.js # SAMPLES from src/notify-text.js +// bun tools/notif-spam-score.js "Title" "Body" ["Title" "Body" ...] +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import puppeteer from 'puppeteer-core'; +import { SAMPLES } from '../src/notify-text.js'; + +const CHROME = process.env.CHROME || '/usr/bin/google-chrome'; +const PORT = 8766, DEBUG = 9445, THRESHOLD = 90; +const argv = process.argv.slice(2); +const items = argv.length ? argv.reduce((a, v, i) => (i % 2 ? a[a.length - 1].push(v) : a.push([v]), a), []) : SAMPLES; + +const server = Bun.serve({ port: PORT, hostname: '127.0.0.1', fetch(req) { + const p = new URL(req.url).pathname; + if (p === '/sw.js') return new Response("self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));", { headers: { 'content-type': 'text/javascript' } }); + return new Response(`<!doctype html><title>score</title><script>navigator.serviceWorker.register('./sw.js'); + window.show = async (t, b) => { const r = await navigator.serviceWorker.ready; await r.showNotification(t, { body: b, tag: 's' + Date.now() + Math.random() }); };</script>score`, + { headers: { 'content-type': 'text/html' } }); +} }); +const profile = mkdtempSync(join(tmpdir(), 'notif-score-')); +const chrome = spawn(CHROME, ['--headless=new', `--user-data-dir=${profile}`, `--remote-debugging-port=${DEBUG}`, '--no-first-run', '--password-store=basic', + '--enable-features=ShowWarningsForSuspiciousNotifications', '--disable-features=SystemNotifications', 'about:blank'], { stdio: 'ignore' }); +const cleanup = () => { try { chrome.kill(); } catch {} try { server.stop(true); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} }; +process.on('exit', cleanup); +let browser; +for (let i = 0; i < 40 && !browser; i++) { + await Bun.sleep(500); + browser = await puppeteer.connect({ browserURL: `http://localhost:${DEBUG}`, defaultViewport: null }).catch(() => null); +} +if (!browser) { console.error('could not reach the scoring Chrome'); process.exit(2); } +await browser.defaultBrowserContext().overridePermissions(`http://127.0.0.1:${PORT}`, ['notifications']); +const page = await browser.newPage(); +await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'load' }); +await page.evaluate(() => navigator.serviceWorker.ready); +const hist = await browser.newPage(); +async function counts() { + await hist.goto('chrome://histograms/SafeBrowsing.NotificationContentDetection.SuspiciousScore', { waitUntil: 'load' }); + await Bun.sleep(250); + const txt = await hist.evaluate(() => document.body.innerText); + const m = {}; + for (const line of txt.split('\n')) { const r = /^(\d+)\s+.*\((\d+) = /.exec(line); if (r) m[r[1]] = +r[2]; } + const t = /recorded (\d+) samples/.exec(txt); + return { m, total: t ? +t[1] : 0 }; +} +// the model arrives on first use: warm up until one line scores +let before = await counts(); +for (let i = 0; i < 12 && before.total === 0; i++) { + await page.evaluate(() => window.show('warm up', 'the model is still downloading')); + await Bun.sleep(5000); + before = await counts(); +} +if (before.total === 0) { console.error('the model never scored anything (offline? no optimization-guide access?)'); process.exit(2); } +let worst = 0; +for (const [title, body] of items) { + await page.evaluate((t, b) => window.show(t, b), title, body); + let after, tries = 0; + do { await Bun.sleep(600); after = await counts(); } while (after.total === before.total && ++tries < 25); + const d = after.total - before.total; + const bucket = Object.keys(after.m).filter((k) => (after.m[k] || 0) > (before.m[k] || 0)).join('+'); + const score = d === 1 ? Number(bucket) : NaN; + worst = Math.max(worst, score || 0); + console.log(`${(d === 1 ? String(score) : d === 0 ? '??' : 'ambiguous').padStart(9)} | ${title} | ${body}`); + before = after; +} +await page.close(); await hist.close(); browser.disconnect(); +console.log(worst >= THRESHOLD ? `\nFAIL: a line scored ${worst} (warning at >${THRESHOLD - 1})` : `\nok: every line under ${THRESHOLD}, worst ${worst}`); +process.exit(worst >= THRESHOLD ? 1 : 0); diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index cf4a7bf..544c72e 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -85,5 +85,5 @@ test('elsewhere: quiet stays quiet', async () => { expect(asked).toBe(0); // the open (if hidden) window answers on Android windows = []; respond = async () => false; - expect(await push(nwc)).toEqual(['Payment request']); + expect(await push(nwc)).toEqual(['Spend request']); }); |
