aboutsummaryrefslogtreecommitdiffstats
path: root/src/calls.js
blob: 6f18aec36eb30ace6f9888756b1c688e3b2fdf47 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
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(); },
  };
}