aboutsummaryrefslogtreecommitdiffstats
path: root/src/calls.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/calls.js')
-rw-r--r--src/calls.js372
1 files changed, 372 insertions, 0 deletions
diff --git a/src/calls.js b/src/calls.js
new file mode 100644
index 0000000..6f18aec
--- /dev/null
+++ b/src/calls.js
@@ -0,0 +1,372 @@
+// 1:1 voice and video calls (NIP-AC, the draft Amethyst, nospeak and Noscall
+// speak): WebRTC between the two devices, its setup carried over nostr.
+//
+// Every setup message (offer, answer, ICE candidate, hang-up, reject,
+// renegotiate) is an event signed by its sender and wrapped for its one
+// recipient in an ephemeral gift wrap (kind 21059: NIP-44 to them, under a
+// throwaway key, no seal). Relays see only who it's for. The sound and
+// picture go device to device, encrypted by WebRTC itself (DTLS-SRTP); the
+// fingerprints that pin that encryption ride in the signed offer and answer,
+// so nobody between can stand in for either side. When the two can't reach
+// each other directly, TURN relays the (still encrypted) packets.
+//
+// No DOM here: the app hands in how to sign, send, fetch ICE servers, open
+// the camera, and who may ring; it gets back the call's state to draw.
+import { finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
+import * as nip44 from 'nostr-tools/nip44';
+
+export const CALL_WRAP = 21059;
+export const CALL = { OFFER: 25050, ANSWER: 25051, ICE: 25052, HANGUP: 25053, REJECT: 25054, RENEGOTIATE: 25055 };
+const KINDS = new Set(Object.values(CALL));
+const ALT = {
+ [CALL.OFFER]: 'WebRTC call offer', [CALL.ANSWER]: 'WebRTC call answer', [CALL.ICE]: 'WebRTC ICE candidate',
+ [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
+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);
+const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
+export const callIdOf = (ev) => tagOf(ev, 'call-id');
+export const uuid = () => (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID()
+ : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => ((Math.random() * 16) | (c === 'x' ? 0 : 8)).toString(16)));
+
+// The unsigned setup message.
+export function callEvent(kind, to, callId, content = '', extra = []) {
+ return { kind, created_at: now(), content, tags: [['p', to], ['call-id', callId], ['alt', ALT[kind]], ...extra] };
+}
+// A signed one, wrapped for one recipient.
+export function wrapCall(signed, receiverPk) {
+ const eph = generateSecretKey();
+ return finalizeEvent({
+ kind: CALL_WRAP, created_at: now(), tags: [['p', receiverPk]],
+ content: nip44.encrypt(JSON.stringify(signed), nip44.getConversationKey(eph, receiverPk)),
+ }, eph);
+}
+// A wrap to us opened: the setup message inside, its signature good, or null
+// (not a call — Concord's presence beats ride the same kind — or not ours).
+// `decryptFrom(peerPk, ciphertext)`: our key's NIP-44 decryption.
+export async function openCall(wrap, decryptFrom) {
+ if (!wrap || wrap.kind !== CALL_WRAP) return null;
+ let inner;
+ try { inner = JSON.parse(await decryptFrom(wrap.pubkey, wrap.content)); } catch { return null; }
+ if (!inner || !KINDS.has(inner.kind) || !callIdOf(inner) || !verifyEvent(inner)) return null;
+ return inner;
+}
+// ICE candidates as the spec writes them: JSON of the three fields.
+export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex });
+
+// The calls of one identity. env:
+// me() our pubkey
+// sign(template) → signed event (our key or signer)
+// send(peerPk, signed) wrap and publish to them (the app picks the relays)
+// iceServers() → RTCIceServer[] (TURN from coinos, STUN at least)
+// media(type, facing) → MediaStream: microphone, and camera for 'video'
+// mayRing(pk) 'ring' (someone followed, or written to), 'quiet' (anyone else:
+// no ring, but the call is told of, as missed), or null (muted: nothing)
+// changed() the call's state moved: draw it
+// ended(record) a call is over: { id, peer, type, dir, reason, connected, seconds, at } for its log
+// trickle() false for a signer that's slow or asks each time (a
+// bunker, Amber): the candidates go inside the offer or
+// answer instead of one signed message each
+// warm(pk) a call with them may be coming: look up what sending to
+// them takes (their relays, TURN) ahead of it
+// RTCPeerConnection (tests may pass their own)
+export function createCalls(env) {
+ const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection;
+ let call = null; // { id, peer, type, dir, state, reason, pc, local, remote, offer, remoteSet, ice: [], at, connectedAt, muted, camOff, facing }
+ const seen = new Set();
+ const early = new Map(); // peer -> candidates that came before their call did
+ let endTimer = null, ringTimer = null;
+ const changed = () => { try { env.changed(); } catch {} };
+
+ async function sendFor(c, kind, to, content = '', extra = []) {
+ const signed = await env.sign(callEvent(kind, to, c.id, content, extra));
+ await env.send(to, signed);
+ }
+
+ function stopMedia(c) {
+ for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} }
+ try { c.pc && c.pc.close(); } catch {}
+ }
+ function end(reason, c = call) {
+ if (!c || c !== call || c.state === 'ended') return;
+ clearTimeout(ringTimer);
+ stopMedia(c);
+ c.state = 'ended'; c.reason = reason; c.endedAt = Date.now();
+ changed();
+ try {
+ if (env.ended) env.ended({ id: c.id, peer: c.peer, type: c.type, dir: c.dir, reason, at: c.offerAt || c.at, connected: !!c.connectedAt,
+ seconds: c.connectedAt ? Math.round((c.endedAt - c.connectedAt) / 1000) : 0 });
+ } catch {}
+ clearTimeout(endTimer);
+ endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS);
+ }
+
+ // (iceCandidatePoolSize: the routes — TURN's included, which take a round
+ // trip to allocate — are gathered from the start, not from the answer on)
+ async function connection(c) {
+ const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 });
+ c.pc = pc;
+ 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;
+ 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;
+ if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); }
+ else if (s === 'failed') end('failed', c);
+ };
+ if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local);
+ return pc;
+ }
+ // While a call rings: its connection made and the offer set, the routes
+ // gathering — everything but the microphone and camera, which wait for
+ // Answer. Answering then only adds them and replies (it used to do all of
+ // this after the tap, seconds of "Connecting…").
+ async function prepare(c) {
+ const pc = await connection(c);
+ if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; }
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ return pc;
+ }
+ // our tracks into the transceivers the offer made
+ function attachLocal(c) {
+ const trs = c.pc.getTransceivers();
+ for (const track of c.local.getTracks()) {
+ const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped);
+ if (tr) {
+ tr.sender.replaceTrack(track);
+ tr.direction = 'sendrecv';
+ try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {}
+ } else c.pc.addTrack(track, c.local);
+ }
+ }
+ // Without trickle: the description once it holds routes enough — one
+ // through the router (srflx) and one through TURN (relay) — or after a
+ // second and a half. Waiting for gathering to finish took up to 4 s (TURN
+ // over TCP and TLS is slow to give up), all of it after the tap.
+ async function described(c) {
+ const pc = c.pc;
+ const enough = () => pc.iceGatheringState === 'complete'
+ || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
+ if (!c.trickle && !enough()) {
+ await new Promise((resolve) => {
+ const check = () => { if (enough()) resolve(); };
+ pc.addEventListener('icegatheringstatechange', check);
+ pc.addEventListener('icecandidate', check);
+ setTimeout(resolve, 1500);
+ });
+ }
+ return pc.localDescription.sdp;
+ }
+ async function addCandidates(c) {
+ const list = [...(early.get(c.peer) || []), ...c.ice];
+ early.delete(c.peer); c.ice = [];
+ for (const cand of list) { try { await c.pc.addIceCandidate(cand); } catch {} }
+ }
+
+ // ---- what the person does
+ async function start(peer, type = 'voice') {
+ if (call && call.state !== 'ended') throw new Error('busy');
+ clearTimeout(endTimer);
+ const c = call = { id: uuid(), peer, type, dir: 'out', state: 'starting', ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' };
+ changed();
+ try {
+ c.local = await env.media(type, c.facing);
+ if (call !== c) { stopMedia(c); return c; }
+ const pc = await connection(c);
+ await pc.setLocalDescription(await pc.createOffer());
+ c.state = 'offering';
+ changed();
+ await sendFor(c, CALL.OFFER, peer, await described(c), [['call-type', type]]);
+ ringTimer = setTimeout(() => { if (call === c && c.state === 'offering') { sendFor(c, CALL.HANGUP, peer, 'timeout').catch(() => {}); end('no-answer', c); } }, RING_MS);
+ } catch (e) {
+ end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
+ }
+ return c;
+ }
+ async function accept() {
+ const c = call;
+ if (!c || c.state !== 'incoming') return;
+ clearTimeout(ringTimer);
+ c.state = 'connecting';
+ changed();
+ try {
+ const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]);
+ c.local = local;
+ if (call !== c) { stopMedia(c); return; }
+ let pc = ready;
+ if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c);
+ else { // nothing made while it rang (or it failed): all of it now
+ try { pc && pc.close(); } catch {}
+ pc = await connection(c);
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ }
+ await pc.setLocalDescription(await pc.createAnswer());
+ const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c)));
+ await env.send(c.peer, signed);
+ // our other devices stop ringing: answered here
+ env.send(env.me(), await env.sign(callEvent(CALL.ANSWER, env.me(), c.id, ''))).catch(() => {});
+ } catch (e) {
+ sendFor(c, CALL.HANGUP, c.peer, 'error').catch(() => {});
+ end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
+ }
+ }
+ async function reject() {
+ const c = call;
+ if (!c || c.state !== 'incoming') return;
+ sendFor(c, CALL.REJECT, c.peer).catch(() => {});
+ env.sign(callEvent(CALL.REJECT, env.me(), c.id)).then((s) => env.send(env.me(), s)).catch(() => {});
+ end('declined', c);
+ }
+ function hangup() {
+ const c = call;
+ if (!c || c.state === 'ended') return;
+ if (c.state === 'incoming') { reject(); return; }
+ sendFor(c, CALL.HANGUP, c.peer).catch(() => {});
+ end('hung-up', c);
+ }
+ function setMuted(on) {
+ if (!call || !call.local) return;
+ call.muted = on;
+ for (const t of call.local.getAudioTracks()) t.enabled = !on;
+ changed();
+ }
+ function setCamOff(on) {
+ if (!call || !call.local) return;
+ call.camOff = on;
+ for (const t of call.local.getVideoTracks()) t.enabled = !on;
+ changed();
+ }
+ // the other camera: the sender's track swapped, no new offer needed
+ async function flip() {
+ const c = call;
+ if (!c || !c.local || !c.local.getVideoTracks().length) return;
+ const facing = c.facing === 'user' ? 'environment' : 'user';
+ try {
+ const fresh = await env.media('video-only', facing);
+ const track = fresh.getVideoTracks()[0];
+ const sender = c.pc && c.pc.getSenders().find((s) => s.track && s.track.kind === 'video');
+ if (sender) await sender.replaceTrack(track);
+ for (const old of c.local.getVideoTracks()) { c.local.removeTrack(old); old.stop(); }
+ track.enabled = !c.camOff;
+ c.local.addTrack(track);
+ c.facing = facing;
+ changed();
+ } catch {}
+ }
+
+ // ---- what arrives
+ // `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();
+ const me = env.me();
+ const id = callIdOf(ev), from = ev.pubkey, self = from === me;
+ const c = call;
+ switch (ev.kind) {
+ case CALL.OFFER: {
+ if (self) return;
+ if (c && c.state !== 'ended') {
+ if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); }
+ return;
+ }
+ const may = await env.mayRing(from);
+ if (!may) return;
+ if (may === 'quiet') {
+ // not someone who rings us: no ring, but not nothing either — it's
+ // logged as a call that didn't ring
+ try { if (env.ended) env.ended({ id, peer: from, type: tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice', dir: 'in', reason: 'unrung', connected: false, seconds: 0, at: ev.created_at * 1000 }); } catch {}
+ return;
+ }
+ 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(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' };
+ ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000)));
+ changed();
+ try { if (env.warm) env.warm(from); } catch {}
+ n.prep = prepare(n).catch(() => null);
+ return;
+ }
+ case CALL.ANSWER: {
+ if (!c || c.id !== id) return;
+ if (self) { if (c.state === 'incoming') end('answered-elsewhere', c); return; } // another of our devices took it
+ if (from !== c.peer) return;
+ 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); 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 {}
+ }
+ return;
+ }
+ case CALL.ICE: {
+ if (self) return;
+ let cand; try { cand = JSON.parse(ev.content); } catch { return; }
+ if (c && c.id === id && from === c.peer) {
+ if (c.pc && c.remoteSet) { try { await c.pc.addIceCandidate(cand); } catch {} }
+ else c.ice.push(cand);
+ } else {
+ // before the offer that opens its call: kept for when it does
+ const list = early.get(from) || []; list.push(cand); early.set(from, list.slice(-50));
+ }
+ return;
+ }
+ case CALL.HANGUP: {
+ if (self || !c || c.id !== id || from !== c.peer) return;
+ end(c.state === 'incoming' ? 'missed' : 'peer-hung-up', c);
+ return;
+ }
+ case CALL.REJECT: {
+ if (!c || c.id !== id) return;
+ if (self) { if (c.state === 'incoming') end('declined-elsewhere', c); return; }
+ if (from !== c.peer) return;
+ end(ev.content === 'busy' ? 'busy' : 'peer-declined', c);
+ return;
+ }
+ case CALL.RENEGOTIATE: {
+ if (self || !c || c.id !== id || from !== c.peer || !c.pc) return;
+ try {
+ if (c.pc.signalingState === 'have-local-offer') {
+ if (me > from) return; // ours wins (the higher key), theirs is dropped
+ await c.pc.setLocalDescription({ type: 'rollback' });
+ }
+ await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content });
+ await c.pc.setLocalDescription(await c.pc.createAnswer());
+ await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp);
+ } catch {}
+ return;
+ }
+ }
+ }
+
+ return {
+ get call() { return call; },
+ start, accept, reject, hangup, setMuted, setCamOff, flip, handle,
+ // a sign-out or account switch: whatever was going on ends here
+ stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); },
+ };
+}