// 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.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al); check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true); check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null); check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false); check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true); 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')))); await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' }); await B.page.waitForSelector('.prof-actions', { timeout: 20000 }); await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000)); console.log('\n[rung while closed, and nobody came]'); await B.page.goto('about:blank'); const before2 = new Set(callWraps.keys()); await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); let offer2 = null; for (let i = 0; i < 50 && !offer2; i++) { await sleep(200); offer2 = [...callWraps.values()].find((w) => !before2.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); } await push(B, { type: 'notify', reason: 'call', wrapId: offer2.id }); await sleep(1500); await A.page.evaluate(() => document.querySelector('.call-hang').click()); // Alice gives up // Bob opens coinos later on, past the call's minute (the kept offer dated back) const page2 = await B.context.newPage(); await page2.goto(`http://localhost:${PORT}/icon-192.png`, { waitUntil: 'domcontentloaded' }); // the origin, without the app (which would take the offer) await page2.evaluate(async () => { const c = await caches.open('coinos-call'), r = await c.match('/__pending-call'); const j = await r.json(); j.at -= 120_000; await c.put('/__pending-call', new Response(JSON.stringify(j), { headers: { 'content-type': 'application/json' } })); }); await page2.close(); await B.page.goto(`http://localhost:${PORT}/` + npubEncode(A.pk), { waitUntil: 'domcontentloaded' }); check('the app doesn\'t ring for a call long over', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); await B.page.waitForSelector('.prof-actions', { timeout: 20000 }); await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); check('...it\'s a missed call in the conversation', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText)), 10000), JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText)))); 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);