diff options
| -rw-r--r-- | src/calls.js | 25 | ||||
| -rw-r--r-- | src/dm-inbox.js | 24 | ||||
| -rw-r--r-- | src/features/messages.js | 36 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/sw-nwc.js | 35 | ||||
| -rw-r--r-- | tools/call-wake-test.js | 184 |
6 files changed, 294 insertions, 11 deletions
diff --git a/src/calls.js b/src/calls.js index 2447a2f..98d0a46 100644 --- a/src/calls.js +++ b/src/calls.js @@ -23,7 +23,8 @@ const ALT = { [CALL.HANGUP]: 'WebRTC call hangup', [CALL.REJECT]: 'WebRTC call rejection', [CALL.RENEGOTIATE]: 'WebRTC call renegotiation', }; export const STALE_S = 20; // older setup messages are a relay replaying the past -const RING_MS = 60_000; +export const RING_S = 60; +const RING_MS = RING_S * 1000; const ENDED_MS = 2500; // how long "Call ended" stays up const now = () => Math.floor(Date.now() / 1000); @@ -102,7 +103,12 @@ export function createCalls(env) { c.remote = new MediaStream(); pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); }; c.trickle = env.trickle ? env.trickle() !== false : true; - pc.onicecandidate = (e) => { if (c.trickle && e.candidate && call === c && c.state !== 'ended') sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {}); }; + c.sent = []; + pc.onicecandidate = (e) => { + if (!c.trickle || !e.candidate || call !== c || c.state === 'ended') return; + c.sent.push(e.candidate); + sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {}); + }; pc.onconnectionstatechange = () => { if (call !== c) return; const s = pc.connectionState; @@ -218,11 +224,14 @@ export function createCalls(env) { } // ---- what arrives - async function handle(ev) { + // `maxAge`: an offer the service worker kept from a push may be older than + // a relay's live one (the person had to tap the notification): it still + // rings, for what's left of its minute + async function handle(ev, { maxAge = STALE_S } = {}) { if (!ev || seen.has(ev.id)) return; + if (now() - ev.created_at > maxAge) return; seen.add(ev.id); if (seen.size > 5000) seen.clear(); - if (now() - ev.created_at > STALE_S) return; const me = env.me(); const id = callIdOf(ev), from = ev.pubkey, self = from === me; const c = call; @@ -237,7 +246,7 @@ export function createCalls(env) { clearTimeout(endTimer); const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice'; const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' }; - ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, RING_MS); + ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000))); changed(); return; } @@ -248,7 +257,11 @@ export function createCalls(env) { if (c.state === 'offering') { c.state = 'connecting'; changed(); try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); c.remoteSet = true; await addCandidates(c); } - catch { end('error', c); } + catch { end('error', c); return; } + // Our candidates went out while they rang — to an app that may have + // been closed then (woken by a push, it heard none of them). Once + // more, now that they're listening; a repeat is harmless. + if (c.trickle) for (const cand of c.sent || []) sendFor(c, CALL.ICE, c.peer, candidateJson(cand)).catch(() => {}); } else if (c.renegotiating) { // their answer to our renegotiation c.renegotiating = false; try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); } catch {} diff --git a/src/dm-inbox.js b/src/dm-inbox.js index b9eec3a..1a026c1 100644 --- a/src/dm-inbox.js +++ b/src/dm-inbox.js @@ -15,6 +15,8 @@ // notification instead of a filtered one. import { unwrapDM } from './dm.js'; +import { openCall, CALL, RING_S } from './calls.js'; +import * as nip44 from 'nostr-tools/nip44'; const DB = 'coinos-dm-inbox'; const STORE = 'inbox'; @@ -115,3 +117,25 @@ export function shouldNotifyDm(verdict) { if (verdict.followed || verdict.known) return true; return !verdict.hasList; } + +// A call ringing in (NIP-AC), the wrap a push pointed at: opened with the key +// it's for, and worth ringing only as a fresh offer from someone this +// account follows or talks to, and hasn't muted. Returns { offer, name, +// type } to ring with, or null for anything else (a candidate, a stranger, +// a stale one, a wrap we hold no key for). +export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / 1000)) { + for (const rec of inboxes) { + if (!rec || !rec.sk) continue; + let sk; + try { sk = unhex(rec.sk); } catch { continue; } + const ev = await openCall(wrap, async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, pk))); + if (!ev) continue; + const from = ev.pubkey; + if (ev.kind !== CALL.OFFER || from === rec.pubkey || nowS - ev.created_at > RING_S) return null; + if ((rec.muted || []).includes(from)) return null; + if (!(rec.follows || []).includes(from) && !(rec.known || []).includes(from)) return null; + const type = (ev.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + return { offer: ev, name: (rec.names || {})[from] || null, type }; + } + return null; +} diff --git a/src/features/messages.js b/src/features/messages.js index ec564e8..9d03a86 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -53,7 +53,7 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; -import { createCalls, openCall, wrapCall, CALL_WRAP } from '../calls.js'; +import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js'; import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js'; import { qrSvg } from '../qr.js'; @@ -2416,7 +2416,7 @@ export function messagesFeature(ctx) { // never sends what the user turned off (a suppressed-but-delivered push // would earn Chrome's generic "updated in background" nag instead) const want = (k) => s.reasons?.[k] !== false; - const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') }; + const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') }; // Stamp the network: the same seed makes the same nostr pubkey on mainnet // AND staging, so without this the notifier fans a mainnet payment push // out to the staging PWA's subscription too — and tapping it opened @@ -2480,6 +2480,7 @@ export function messagesFeature(ctx) { rowT(t('notifDm'), 'dm'), rowT(t('notifMention'), 'mention'), rowT(t('notifReaction'), 'reaction'), + rowT(t('notifCall'), 'call'), h('div', { class: 'small faint' }, t('notifChatHint'))); } @@ -2487,13 +2488,17 @@ export function messagesFeature(ctx) { // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote // signer stores no key here and simply gets unfiltered notifications. - let inboxAt = 0; + let inboxAt = 0, inboxTries = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); - if (!id) return; + // the keys not there yet (a wallet still opening): try again shortly — + // this used to count as done for five minutes, and a phone woken by a + // push in that time had no key to open a DM or a call with + if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; } + inboxTries = 0; const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [3], authors: [id.pubkey] }, 4000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; @@ -9106,6 +9111,18 @@ export function messagesFeature(ctx) { if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } } } + // the call offer a push brought (sw-nwc.js keeps it): rung now, once + async function pendingCall() { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open('coinos-call'); + const r = await cache.match('/__pending-call'); + if (!r) return; + await cache.delete('/__pending-call'); + const { offer, at } = await r.json(); + if (offer && Date.now() - at < RING_S * 1000) await callsNow().handle(offer, { maxAge: RING_S }); + } catch {} + } function startCall(peer, type) { if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } const cur = callsNow().call; @@ -9133,6 +9150,10 @@ export function messagesFeature(ctx) { ringFor = want; clearInterval(ringTimer); ringTimer = null; try { navigator.vibrate && navigator.vibrate(0); } catch {} + // answered, declined or over: its "is calling" notification goes too + if (!want || want.endsWith(':offering')) { + try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {} + } if (!want) return; const incoming = c.state === 'incoming'; const beat = () => { @@ -17111,8 +17132,15 @@ export function messagesFeature(ctx) { try { navigator.serviceWorker?.addEventListener('message', (ev) => { if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs(); + // a call the worker was woken for: rung here too (an app in the + // background may have missed it live), or picked up from the tap + if (ev.data && ev.data.type === 'call-offer' && ev.data.offer) callsNow().handle(ev.data.offer, { maxAge: RING_S }).catch(() => {}); + if (ev.data && ev.data.type === 'open' && ev.data.view === 'call') pendingCall(); }); } catch {} + // a tapped "is calling" opened the app, or brought it back: the offer the worker kept + setTimeout(pendingCall, 0); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') pendingCall(); }); if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); diff --git a/src/i18n.js b/src/i18n.js index 586a7e6..80e8146 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -518,6 +518,7 @@ const STR = { callAnsweredElsewhere: 'Answered on another device', callDeclinedElsewhere: 'Declined on another device', callUnsupported: 'This browser can’t make calls.', + notifCall: 'Calls', feedAttach: 'Attach a picture or video', postLike: 'Like', postBoost: 'Boost', diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 3dac246..b6af6b8 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -7,15 +7,48 @@ import { respondFromBg, tendRenewals } from './nwc-respond.js'; import { withRequestLock } from './nwc-lock.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; -import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js'; +import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; +const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens +// where a call's offer wrap is fetched from: the notifier, or (the app served +// on this machine, as the tests do) this origin +const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER; self.addEventListener('push', (e) => { let data = {}; try { data = e.data ? e.data.json() : {}; } catch (_) {} // User-facing notifications (payments, DMs, community chat). An open // window already shows everything live — stay quiet then. + // A call ringing in: the push names the offer's wrap, kept at the notifier + // (too big to ride in a push). Opened here with our key; a fresh offer + // from someone we follow or talk to rings, as a notification that stays + // until it's answered or missed. The offer is kept for the app to pick up + // when the tap opens it (CALL_CACHE). An open window rings by itself. + if (data.type === 'notify' && data.reason === 'call') { + e.waitUntil((async () => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (clients.some((c) => c.visibilityState === 'visible')) return; + let ring = null; + try { + const r = await fetch(WRAPS + '/wrap/' + encodeURIComponent(data.wrapId || '')); + if (r.ok) ring = await classifyCall(await r.json(), await allInboxes()); + } catch (err) { console.warn('[sw-call] could not open:', err && err.message); } + if (!ring) return; + try { + const cache = await caches.open(CALL_CACHE); + 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 self.registration.showNotification((ring.name || 'Someone') + ' is calling', { + body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.', + 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' }, + }); + })()); + return; + } if (data.type === 'notify') { e.waitUntil((async () => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js new file mode 100644 index 0000000..05705ad --- /dev/null +++ b/tools/call-wake-test.js @@ -0,0 +1,184 @@ +// 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 +// 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). +// Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; +import { bytesToHex } from '@noble/hashes/utils'; +import { npubEncode } from 'nostr-tools/nip19'; +import { classifyCall } from '../src/dm-inbox.js'; +import { callEvent, wrapCall, CALL } from '../src/calls.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 PORT = 5306; + +console.log('[the worker\'s check]'); +{ + const bob = generateSecretKey(), alice = generateSecretKey(), eve = generateSecretKey(); + const B = getPublicKey(bob), Al = getPublicKey(alice), E = getPublicKey(eve); + const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }]; + const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to); + const r = await classifyCall(offer(alice), box); + check('a fresh offer from someone followed rings, named, video', !!r && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al); + check('...a stranger\'s doesn\'t', (await classifyCall(offer(eve), box)) === null); + check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null); + check('...but someone written to does', !!(await classifyCall(offer(eve), [{ ...box[0], known: [E] }]))); + check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null); + check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null); + check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null); +} + +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const stored = [], subs = new Set(), callWraps = new Map(); +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); +const server = Bun.serve({ port: PORT, + fetch: async (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 }); + const w = /^\/nwcpush\/wrap\/([0-9a-f]{64})$/.exec(path); // the notifier's keeping of call wraps + if (w) return callWraps.has(w[1]) ? Response.json(callWraps.get(w[1])) : Response.json({ error: 'gone' }, { status: 404 }); + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); + }, + websocket: { + open(ws) { ws.data = new Map(); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { + const e = m[1]; + ws.send(JSON.stringify(['OK', e.id, true, ''])); + if (e.kind === 21059) callWraps.set(e.id, e); else if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e); + for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e])); + } else if (m[0] === 'REQ') { + const s = { ws, id: m[1], filters: m.slice(2) }; + ws.data.set(m[1], s); subs.add(s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') subs.delete(ws.data.get(m[1])); + }, + close(ws) { for (const s of ws.data.values()) subs.delete(s); }, + }, +}); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', + args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] }); +const toLocal = (port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}; +async function wallet(name) { + const context = await browser.createBrowserContext(); + await context.overridePermissions(`http://localhost:${PORT}`, ['notifications']); + const page = await context.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(toLocal, PORT); + page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e))); + const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x); + const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; + await page.goto(`http://localhost:${PORT}/`, { 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(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive'); + const sk = privateKeyFromSeedWords(mn); + return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name }; +} +const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +// the push the notifier sends, delivered to a context's service worker +async function push(w, data) { + const cdp = await w.page.target().createCDPSession(); + const regs = []; + cdp.on('ServiceWorker.workerRegistrationUpdated', (e) => regs.push(...e.registrations)); + await cdp.send('ServiceWorker.enable'); + for (let i = 0; i < 25 && !regs.some((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); i++) await sleep(200); + const reg = regs.find((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); + await cdp.send('ServiceWorker.deliverPushMessage', { origin: `http://localhost:${PORT}`, registrationId: reg.registrationId, data: JSON.stringify(data) }); + await cdp.detach(); +} +const notifications = (page) => page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map((n) => n.title + ' | ' + n.body + ' | ' + n.tag)); + +try { + console.log('\n[a closed app, rung]'); + const A = await wallet('alice'), B = await wallet('bob'); + const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() } }; + // their follow lists, where the app (and Bob's worker mirror) read them + for (const [w, other] of [[A, B], [B, A]]) { + // (newer than the starter list a new wallet publishes for itself) + stored.push(finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000) + 120, content: '', tags: [['p', other.pk]] }, w.sk)); + await w.page.evaluate(([k, pk, profiles]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':profiles', JSON.stringify(profiles)); + }, [w.base, other.pk, profiles]); + await w.page.reload({ waitUntil: 'domcontentloaded' }); + await w.waitText('receive'); + } + check('Bob\'s app has given his worker what it needs: his key, his follows', await waitFor(B.page, (pk) => new Promise((res) => { + const r = indexedDB.open('coinos-dm-inbox'); + r.onsuccess = () => { try { const g = r.result.transaction('inbox').objectStore('inbox').getAll(); g.onsuccess = () => res(g.result.some((x) => x.sk && (x.follows || []).includes(pk))); g.onerror = () => res(false); } catch { res(false); } }; + r.onerror = () => res(false); + }), 20000, A.pk)); + await B.page.evaluate(() => navigator.serviceWorker.ready); + // Bob closes coinos + await B.page.goto('about:blank'); + // Alice calls + await A.page.goto(`http://localhost:${PORT}/` + npubEncode(B.pk), { waitUntil: 'domcontentloaded' }); + await A.page.waitForSelector('.prof-actions', { timeout: 20000 }); + await A.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); + await A.page.waitForSelector('.chat-head .call-start', { timeout: 10000 }); + const wrapsBefore = new Set(callWraps.keys()); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + let offerWrap = null; + for (let i = 0; i < 50 && !offerWrap; i++) { await sleep(200); offerWrap = [...callWraps.values()].find((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); } + check('Alice\'s offer goes out, big enough for the notifier to wake Bob for', !!offerWrap, offerWrap && offerWrap.content.length + ' chars'); + const small = [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.content.length < 2000); + check('...and what follows it is small enough not to', small.length >= 1 && small.every((w) => w.content.length < 1500), small.map((w) => w.content.length).join(',')); + await push(B, { type: 'notify', reason: 'call', wrapId: offerWrap.id }); + await sleep(1500); + // 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 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; + const ringingCands = toBob(); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + check('Bob answers: they\'re connected', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000) + && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000)); + check('Alice sends her candidates again once answered (Bob\'s app heard none of the first ones)', toBob() >= 2 * ringingCands && ringingCands > 0, ringingCands + ' → ' + toBob()); + check('...and hear each other', await waitFor(B.page, () => { const a = document.querySelector('audio.call-audio'); return !!a && !!a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live'); }, 5000)); + check('the "is calling" notification is gone once answered', await waitFor(B.page, async () => !(await (await navigator.serviceWorker.ready).getNotifications({ tag: 'call' })).length, 5000)); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000); + + console.log('\n[pushes that ring nothing]'); + await B.page.goto('about:blank'); + const eve = generateSecretKey(); + const strangers = wrapCall(finalizeEvent(callEvent(CALL.OFFER, B.pk, 'x-1', 'v=0\r\n' + 'a'.repeat(2400), [['call-type', 'voice']]), eve), B.pk); + callWraps.set(strangers.id, strangers); + await push(B, { type: 'notify', reason: 'call', wrapId: strangers.id }); + await push(B, { type: 'notify', reason: 'call', wrapId: 'f'.repeat(64) }); // one the notifier no longer has + await sleep(1500); + await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await B.waitText('receive'); + check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + for (const w of [A, B]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ a call wakes a closed app' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
