/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / p2p / network.ts
622 lines · 19.1 KBBlameHistoryRaw
1import {selfId} from './identity'
2import {Nostr, peerTopic, rootTopic} from './nostr'
3import {Peer, type Signal} from './peer'
4import {
5 DEFAULT_SETTINGS,
6 QUALITY_PARAMS,
7 SETTING_VALIDATORS,
8 type CallSettings,
9 type VideoQuality
10} from './settings'
12// ---------------------------------------------------------------------------
13// CommonCall network layer.
14//
15// Presence: everyone who has entered an ID announces {peerId, name, busy} on
16// the root topic every few seconds; entries expire when announcements stop.
17//
18// Calls: clicking a peer publishes a call-request on that peer's personal
19// topic. The callee must explicitly accept (call-accept) before either side
20// touches getUserMedia or WebRTC — both users must agree. After acceptance the
21// two sides exchange offer/answer/ICE via {t:'signal'} messages on the same
22// per-peer topics, exactly the technique used by commonview, and the media
23// flows peer-to-peer.
24//
25// Messages are authenticated by the nostr layer: every event is schnorr-signed
26// and the sender's pubkey IS the peer ID, so `from` cannot be spoofed.
27// ---------------------------------------------------------------------------
29interface Announcement {
30 peerId: string
31 name: string
32 busy: boolean
35type PeerMsg =
36 | {t: 'call-request'; name: string}
37 | {t: 'call-accept'; name: string}
38 | {t: 'call-decline'; busy?: boolean}
39 | {t: 'call-cancel'}
40 | {t: 'hang-up'}
41 | {t: 'signal'; signal: Signal}
43// Messages on the in-call control data channel (WebRTC, not nostr).
44type ControlMsg =
45 | {t: 'hang-up'}
46 | {t: 'set'; key: string; value: unknown; rev: number}
48const ROOM_ID = 'default'
49const ANNOUNCE_INTERVAL_MS = 5000
50const PRESENCE_TTL_MS = 15000
51// Nostr events are ephemeral and relays are flaky, so re-publish the ring
52// while it's pending; the event-id dedup on the far side absorbs repeats.
53const RING_RESEND_MS = 4000
54const RING_TIMEOUT_MS = 45000
55const CONNECT_TIMEOUT_MS = 45000
57const NAME_KEY = 'commoncall:name'
59export type CallPhase = 'outgoing' | 'incoming' | 'connecting' | 'connected'
61interface Call {
62 phase: CallPhase
63 peerId: string
64 peerName: string
65 peer: Peer | null
66 localStream: MediaStream | null
67 remoteStream: MediaStream | null
68 /** Set while the outgoing video track is a screen capture, not the camera. */
69 screenStream: MediaStream | null
70 /** Signals that arrived before our getUserMedia resolved. */
71 pendingSignals: Signal[]
72 /** Shared settings for this call, synced over the control channel. */
73 settings: CallSettings
74 /** Per-key revision counters for the last-writer-wins settings sync. */
75 settingsRevs: Partial<Record<keyof CallSettings, number>>
76 ringInterval: number | null
77 ringTimeout: number | null
78 connectTimeout: number | null
81export interface RosterEntry {
82 peerId: string
83 name: string
84 busy: boolean
87export interface CallInfo {
88 phase: CallPhase
89 peerId: string
90 peerName: string
91 localStream: MediaStream | null
92 remoteStream: MediaStream | null
93 screenStream: MediaStream | null
94 settings: CallSettings
97export interface Snapshot {
98 selfId: string
99 name: string | null
100 roster: RosterEntry[]
101 call: CallInfo | null
102 notice: string | null
105export class Network {
106 private nostr = new Nostr()
107 private rootReady: Promise<string>
108 private presence = new Map<
109 string,
110 {name: string; busy: boolean; lastSeen: number}
111 >()
112 private name: string | null = null
113 private call: Call | null = null
114 private notice: string | null = null
116 private snapshot!: Snapshot
117 private listeners = new Set<() => void>()
119 /** Last name used on this browser, for prefilling the join form. */
120 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
122 constructor() {
123 this.rebuildSnapshot()
124 this.rootReady = rootTopic(ROOM_ID)
125 void this.start()
126 if (this.savedName) this.join(this.savedName)
127 }
129 private async start() {
130 const root = await this.rootReady
132 // Call requests + WebRTC signaling addressed to us.
133 const selfTopic = await peerTopic(root, selfId)
134 this.nostr.subscribe(selfTopic, (content, from) => {
135 if (from === selfId) return
136 let msg: PeerMsg
137 try {
138 msg = JSON.parse(content)
139 } catch {
140 return
141 }
142 this.handlePeerMsg(from, msg)
143 })
145 // Presence announcements.
146 this.nostr.subscribe(root, (content, from) => {
147 if (from === selfId) return
148 let ann: Partial<Announcement>
149 try {
150 ann = JSON.parse(content)
151 } catch {
152 return
153 }
154 if (ann.peerId !== from || typeof ann.name !== 'string') return
155 const prev = this.presence.get(from)
156 const busy = ann.busy === true
157 this.presence.set(from, {name: ann.name, busy, lastSeen: Date.now()})
158 if (!prev || prev.name !== ann.name || prev.busy !== busy) {
159 this.rebuildSnapshot()
160 }
161 })
163 setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS)
164 setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS)
166 window.addEventListener('online', () => void this.announce())
167 }
169 private async announce() {
170 if (!this.name) return
171 const root = await this.rootReady
172 const ann: Announcement = {
173 peerId: selfId,
174 name: this.name,
175 busy: this.call !== null
176 }
177 void this.nostr.publish(root, JSON.stringify(ann))
178 }
180 private sweepPresence() {
181 const cutoff = Date.now() - PRESENCE_TTL_MS
182 let changed = false
183 for (const [peerId, p] of this.presence) {
184 if (p.lastSeen < cutoff) {
185 this.presence.delete(peerId)
186 changed = true
187 }
188 }
189 if (changed) this.rebuildSnapshot()
190 }
192 private async sendToPeer(peerId: string, msg: PeerMsg) {
193 const root = await this.rootReady
194 const topic = await peerTopic(root, peerId)
195 void this.nostr.publish(topic, JSON.stringify(msg))
196 }
198 // ---- incoming messages ------------------------------------------------
200 private handlePeerMsg(from: string, msg: PeerMsg) {
201 switch (msg.t) {
202 case 'call-request': {
203 if (this.call) {
204 if (this.call.peerId !== from) {
205 // Busy with someone else.
206 void this.sendToPeer(from, {t: 'call-decline', busy: true})
207 } else if (this.call.phase === 'outgoing') {
208 // Glare: we each called the other — that's mutual agreement.
209 this.beginConnecting()
210 } else if (
211 this.call.phase === 'connecting' ||
212 this.call.phase === 'connected'
213 ) {
214 // Their resent ring means our accept was lost; send it again.
215 void this.sendToPeer(from, {
216 t: 'call-accept',
217 name: this.name ?? ''
218 })
219 }
220 // phase 'incoming': duplicate ring, ignore.
221 return
222 }
223 if (!this.name) {
224 // Not joined; we shouldn't be getting calls — turn them away.
225 void this.sendToPeer(from, {t: 'call-decline', busy: true})
226 return
227 }
228 this.notice = null
229 this.call = this.newCall('incoming', from, msg.name)
230 this.rebuildSnapshot()
231 void this.announce()
232 return
233 }
235 case 'call-accept': {
236 if (this.call?.phase === 'outgoing' && this.call.peerId === from) {
237 if (msg.name) this.call.peerName = msg.name
238 this.beginConnecting()
239 }
240 return
241 }
243 case 'call-decline': {
244 if (this.call?.peerId === from && this.call.phase === 'outgoing') {
245 const who = this.call.peerName
246 this.teardown(msg.busy ? `${who} is busy.` : `${who} declined.`)
247 }
248 return
249 }
251 case 'call-cancel': {
252 if (this.call?.peerId === from) {
253 this.teardown(`${this.call.peerName} canceled the call.`)
254 }
255 return
256 }
258 case 'hang-up': {
259 if (this.call?.peerId === from) {
260 this.teardown(`${this.call.peerName} hung up.`)
261 }
262 return
263 }
265 case 'signal': {
266 const call = this.call
267 if (!call || call.peerId !== from) return
268 if (call.phase !== 'connecting' && call.phase !== 'connected') return
269 if (call.peer) void call.peer.signal(msg.signal)
270 else call.pendingSignals.push(msg.signal)
271 return
272 }
273 }
274 }
276 // ---- call lifecycle ---------------------------------------------------
278 private newCall(phase: CallPhase, peerId: string, peerName: string): Call {
279 return {
280 phase,
281 peerId,
282 peerName,
283 peer: null,
284 localStream: null,
285 remoteStream: null,
286 screenStream: null,
287 pendingSignals: [],
288 settings: {...DEFAULT_SETTINGS},
289 settingsRevs: {},
290 ringInterval: null,
291 ringTimeout: null,
292 connectTimeout: null
293 }
294 }
296 private clearCallTimers(call: Call) {
297 if (call.ringInterval !== null) clearInterval(call.ringInterval)
298 if (call.ringTimeout !== null) clearTimeout(call.ringTimeout)
299 if (call.connectTimeout !== null) clearTimeout(call.connectTimeout)
300 call.ringInterval = null
301 call.ringTimeout = null
302 call.connectTimeout = null
303 }
305 /** Both sides agreed: get the camera/mic and bring up the WebRTC call. */
306 private beginConnecting() {
307 const call = this.call
308 if (!call || call.phase === 'connecting' || call.phase === 'connected') {
309 return
310 }
311 this.clearCallTimers(call)
312 call.phase = 'connecting'
313 call.connectTimeout = window.setTimeout(() => {
314 if (this.call === call && call.phase === 'connecting') {
315 void this.sendToPeer(call.peerId, {t: 'hang-up'})
316 this.teardown('Could not establish a connection.')
317 }
318 }, CONNECT_TIMEOUT_MS)
319 this.rebuildSnapshot()
320 void this.startMedia(call)
321 }
323 private async startMedia(call: Call) {
324 let stream: MediaStream
325 try {
326 stream = await navigator.mediaDevices.getUserMedia({
327 video: true,
328 audio: true
329 })
330 } catch {
331 if (this.call === call) {
332 void this.sendToPeer(call.peerId, {t: 'hang-up'})
333 this.teardown('Could not access your camera/microphone.')
334 }
335 return
336 }
337 if (this.call !== call || call.phase !== 'connecting') {
338 // The call went away while we were waiting for permission.
339 for (const track of stream.getTracks()) track.stop()
340 return
341 }
343 call.localStream = stream
344 // Deterministic initiator (no glare): the smaller peer ID makes the offer.
345 const peer = new Peer(selfId < call.peerId, stream)
346 call.peer = peer
347 peer.setHandlers({
348 signal: signal => {
349 void this.sendToPeer(call.peerId, {t: 'signal', signal})
350 },
351 track: remote => {
352 if (this.call !== call) return
353 call.remoteStream = remote
354 this.rebuildSnapshot()
355 },
356 connect: () => {
357 if (this.call !== call) return
358 call.phase = 'connected'
359 this.clearCallTimers(call)
360 this.rebuildSnapshot()
361 },
362 data: raw => {
363 if (this.call !== call) return
364 let msg: ControlMsg
365 try {
366 msg = JSON.parse(raw)
367 } catch {
368 return
369 }
370 if (msg.t === 'hang-up') {
371 this.teardown(`${call.peerName} hung up.`)
372 } else if (msg.t === 'set') {
373 this.applyRemoteSetting(call, msg)
374 }
375 },
376 close: () => {
377 if (this.call === call) this.teardown('Call ended.')
378 }
379 })
380 for (const signal of call.pendingSignals.splice(0)) {
381 void peer.signal(signal)
382 }
383 this.rebuildSnapshot()
384 }
386 // ---- shared call settings --------------------------------------------
387 //
388 // One settings object per call, visible and editable by BOTH parties.
389 // Sync is per-key last-writer-wins over the control channel: every change
390 // bumps that key's revision counter and is sent as {t:'set'}. The channel
391 // is reliable and ordered, so divergence only happens when both sides
392 // change the same key concurrently (same revision) — that tie must resolve
393 // identically on both sides, so the smaller peer ID's value wins.
395 private setSetting<K extends keyof CallSettings>(
396 key: K,
397 value: CallSettings[K]
398 ) {
399 const call = this.call
400 if (!call || !call.peer || call.settings[key] === value) return
401 const rev = (call.settingsRevs[key] ?? 0) + 1
402 call.settingsRevs[key] = rev
403 call.settings = {...call.settings}
404 call.settings[key] = value
405 call.peer.send(JSON.stringify({t: 'set', key, value, rev}))
406 this.settingChanged(call, key)
407 this.rebuildSnapshot()
408 }
410 private applyRemoteSetting(
411 call: Call,
412 msg: {key: string; value: unknown; rev: number}
413 ) {
414 if (!(msg.key in SETTING_VALIDATORS)) return
415 const key = msg.key as keyof CallSettings
416 if (!SETTING_VALIDATORS[key](msg.value)) return
417 if (!Number.isInteger(msg.rev) || msg.rev < 1) return
418 const localRev = call.settingsRevs[key] ?? 0
419 if (msg.rev < localRev) return // stale
420 if (msg.rev === localRev && selfId < call.peerId) return // tie: we win
421 call.settingsRevs[key] = msg.rev
422 if (call.settings[key] === msg.value) return
423 call.settings = {...call.settings}
424 call.settings[key] = msg.value
425 this.settingChanged(call, key)
426 this.rebuildSnapshot()
427 }
429 /** Side effects of a setting taking a new value (local or remote). */
430 private settingChanged(call: Call, key: keyof CallSettings) {
431 if (key === 'videoQuality') this.applyVideoParams(call)
432 }
434 /** Push the current quality preset into the outgoing video sender. */
435 private applyVideoParams(call: Call) {
436 if (!call.peer) return
437 const p = QUALITY_PARAMS[call.settings.videoQuality]
438 const sharing = call.screenStream !== null
439 void call.peer.setVideoParameters({
440 maxBitrate: p.maxBitrate,
441 // Downscaled screen text is unreadable: while sharing, send full
442 // resolution and let the bitrate/framerate caps do the limiting.
443 scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
444 maxFramerate: p.maxFramerate,
445 degradationPreference: sharing ? 'maintain-resolution' : undefined
446 })
447 }
449 private teardown(notice: string | null) {
450 const call = this.call
451 if (!call) return
452 this.call = null // cleared first so the peer's close handler no-ops
453 this.clearCallTimers(call)
454 call.peer?.destroy()
455 if (call.localStream) {
456 for (const track of call.localStream.getTracks()) track.stop()
457 }
458 if (call.screenStream) {
459 for (const track of call.screenStream.getTracks()) track.stop()
460 }
461 this.notice = notice
462 this.rebuildSnapshot()
463 void this.announce()
464 }
466 // ---- public API -------------------------------------------------------
468 join(name: string) {
469 const trimmed = name.trim().slice(0, 40)
470 if (!trimmed) return
471 this.name = trimmed
472 localStorage.setItem(NAME_KEY, trimmed)
473 this.notice = null
474 this.rebuildSnapshot()
475 void this.announce()
476 }
478 leave() {
479 if (this.call) this.endCall()
480 this.name = null
481 this.rebuildSnapshot()
482 // Others will drop us from their rosters when announcements stop.
483 }
485 callPeer(peerId: string) {
486 if (!this.name || this.call || peerId === selfId) return
487 const peerName = this.presence.get(peerId)?.name ?? peerId.slice(0, 8)
488 this.notice = null
489 const call = this.newCall('outgoing', peerId, peerName)
490 this.call = call
491 const ring = () => void this.sendToPeer(peerId, {
492 t: 'call-request',
493 name: this.name ?? ''
494 })
495 ring()
496 call.ringInterval = window.setInterval(ring, RING_RESEND_MS)
497 call.ringTimeout = window.setTimeout(() => {
498 if (this.call === call && call.phase === 'outgoing') {
499 void this.sendToPeer(peerId, {t: 'call-cancel'})
500 this.teardown(`${call.peerName} did not answer.`)
501 }
502 }, RING_TIMEOUT_MS)
503 this.rebuildSnapshot()
504 void this.announce()
505 }
507 accept() {
508 const call = this.call
509 if (!call || call.phase !== 'incoming') return
510 void this.sendToPeer(call.peerId, {t: 'call-accept', name: this.name ?? ''})
511 this.beginConnecting()
512 }
514 decline() {
515 const call = this.call
516 if (!call || call.phase !== 'incoming') return
517 void this.sendToPeer(call.peerId, {t: 'call-decline'})
518 this.teardown(null)
519 }
521 endCall() {
522 const call = this.call
523 if (!call) return
524 if (call.phase === 'outgoing') {
525 void this.sendToPeer(call.peerId, {t: 'call-cancel'})
526 } else if (call.phase === 'incoming') {
527 void this.sendToPeer(call.peerId, {t: 'call-decline'})
528 } else {
529 // Belt and braces: the control channel may not be open yet.
530 call.peer?.send(JSON.stringify({t: 'hang-up'}))
531 void this.sendToPeer(call.peerId, {t: 'hang-up'})
532 }
533 this.teardown(null)
534 }
536 /** Change the shared video-quality preset. It applies to BOTH senders:
537 * each side caps its own outgoing video, and the change syncs across. */
538 setVideoQuality(quality: VideoQuality) {
539 this.setSetting('videoQuality', quality)
540 }
542 /** Swap the outgoing camera track for a screen capture. The remote side
543 * sees the screen in place of the camera; no renegotiation involved. */
544 async startScreenShare() {
545 const call = this.call
546 if (!call || !call.peer || call.screenStream) return
547 let stream: MediaStream
548 try {
549 stream = await navigator.mediaDevices.getDisplayMedia({video: true})
550 } catch {
551 return // user canceled the picker (or capture is unsupported)
552 }
553 const track = stream.getVideoTracks()[0]
554 const ok =
555 this.call === call && call.peer && track
556 ? await call.peer.replaceVideoTrack(track)
557 : false
558 if (!ok || this.call !== call) {
559 for (const t of stream.getTracks()) t.stop()
560 return
561 }
562 call.screenStream = stream
563 this.applyVideoParams(call) // re-derive caps for screen-share mode
564 // The browser's own "Stop sharing" bar ends the track; swap back then.
565 track.onended = () => void this.stopScreenShare()
566 this.rebuildSnapshot()
567 }
569 async stopScreenShare() {
570 const call = this.call
571 if (!call || !call.screenStream) return
572 const screen = call.screenStream
573 call.screenStream = null
574 const camTrack = call.localStream?.getVideoTracks()[0]
575 if (call.peer && camTrack) await call.peer.replaceVideoTrack(camTrack)
576 for (const t of screen.getTracks()) t.stop()
577 if (this.call === call) {
578 this.applyVideoParams(call) // restore camera-mode caps
579 this.rebuildSnapshot()
580 }
581 }
583 dismissNotice() {
584 this.notice = null
585 this.rebuildSnapshot()
586 }
588 getSnapshot = (): Snapshot => this.snapshot
590 subscribe = (listener: () => void): (() => void) => {
591 this.listeners.add(listener)
592 return () => this.listeners.delete(listener)
593 }
595 private rebuildSnapshot() {
596 const roster: RosterEntry[] = [...this.presence.entries()]
597 .map(([peerId, p]) => ({peerId, name: p.name, busy: p.busy}))
598 .sort(
599 (a, b) =>
600 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
601 )
602 const call: CallInfo | null = this.call
603 ? {
604 phase: this.call.phase,
605 peerId: this.call.peerId,
606 peerName: this.call.peerName,
607 localStream: this.call.localStream,
608 remoteStream: this.call.remoteStream,
609 screenStream: this.call.screenStream,
610 settings: this.call.settings
611 }
612 : null
613 this.snapshot = {
614 selfId,
615 name: this.name,
616 roster,
617 call,
618 notice: this.notice
619 }
620 for (const l of this.listeners) l()
621 }
moveopenescclose