/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / p2p / network.ts
688 lines · 21.5 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}
47 | {t: 'mute'; audio: boolean; video: boolean}
49const ROOM_ID = 'default'
50const ANNOUNCE_INTERVAL_MS = 5000
51const PRESENCE_TTL_MS = 15000
52// Nostr events are ephemeral and relays are flaky, so re-publish the ring
53// while it's pending; the event-id dedup on the far side absorbs repeats.
54const RING_RESEND_MS = 4000
55const RING_TIMEOUT_MS = 45000
56const CONNECT_TIMEOUT_MS = 45000
58const NAME_KEY = 'commoncall:name'
60export type CallPhase = 'outgoing' | 'incoming' | 'connecting' | 'connected'
62interface Call {
63 phase: CallPhase
64 peerId: string
65 peerName: string
66 peer: Peer | null
67 localStream: MediaStream | null
68 remoteStream: MediaStream | null
69 /** Set while the outgoing video track is a screen capture, not the camera. */
70 screenStream: MediaStream | null
71 /** Signals that arrived before our getUserMedia resolved. */
72 pendingSignals: Signal[]
73 /** Shared settings for this call, synced over the control channel. */
74 settings: CallSettings
75 /** Per-key revision counters for the last-writer-wins settings sync. */
76 settingsRevs: Partial<Record<keyof CallSettings, number>>
77 /** Local mute state (audio = mic; video = camera, latent while sharing). */
78 audioMuted: boolean
79 videoMuted: boolean
80 /** The other party's effective outgoing mute state, as they reported it. */
81 peerAudioMuted: boolean
82 peerVideoMuted: boolean
83 ringInterval: number | null
84 ringTimeout: number | null
85 connectTimeout: number | null
88export interface RosterEntry {
89 peerId: string
90 name: string
91 busy: boolean
94export interface CallInfo {
95 phase: CallPhase
96 peerId: string
97 peerName: string
98 localStream: MediaStream | null
99 remoteStream: MediaStream | null
100 screenStream: MediaStream | null
101 settings: CallSettings
102 audioMuted: boolean
103 videoMuted: boolean
104 peerAudioMuted: boolean
105 peerVideoMuted: boolean
108export interface Snapshot {
109 selfId: string
110 name: string | null
111 roster: RosterEntry[]
112 call: CallInfo | null
113 notice: string | null
116export class Network {
117 private nostr = new Nostr()
118 private rootReady: Promise<string>
119 private presence = new Map<
120 string,
121 {name: string; busy: boolean; lastSeen: number}
122 >()
123 private name: string | null = null
124 private call: Call | null = null
125 private notice: string | null = null
127 private snapshot!: Snapshot
128 private listeners = new Set<() => void>()
130 /** Last name used on this browser, for prefilling the join form. */
131 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
133 constructor() {
134 this.rebuildSnapshot()
135 this.rootReady = rootTopic(ROOM_ID)
136 void this.start()
137 if (this.savedName) this.join(this.savedName)
138 }
140 private async start() {
141 const root = await this.rootReady
143 // Call requests + WebRTC signaling addressed to us.
144 const selfTopic = await peerTopic(root, selfId)
145 this.nostr.subscribe(selfTopic, (content, from) => {
146 if (from === selfId) return
147 let msg: PeerMsg
148 try {
149 msg = JSON.parse(content)
150 } catch {
151 return
152 }
153 this.handlePeerMsg(from, msg)
154 })
156 // Presence announcements.
157 this.nostr.subscribe(root, (content, from) => {
158 if (from === selfId) return
159 let ann: Partial<Announcement>
160 try {
161 ann = JSON.parse(content)
162 } catch {
163 return
164 }
165 if (ann.peerId !== from || typeof ann.name !== 'string') return
166 const prev = this.presence.get(from)
167 const busy = ann.busy === true
168 this.presence.set(from, {name: ann.name, busy, lastSeen: Date.now()})
169 if (!prev || prev.name !== ann.name || prev.busy !== busy) {
170 this.rebuildSnapshot()
171 }
172 })
174 setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS)
175 setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS)
177 window.addEventListener('online', () => void this.announce())
178 }
180 private async announce() {
181 if (!this.name) return
182 const root = await this.rootReady
183 const ann: Announcement = {
184 peerId: selfId,
185 name: this.name,
186 busy: this.call !== null
187 }
188 void this.nostr.publish(root, JSON.stringify(ann))
189 }
191 private sweepPresence() {
192 const cutoff = Date.now() - PRESENCE_TTL_MS
193 let changed = false
194 for (const [peerId, p] of this.presence) {
195 if (p.lastSeen < cutoff) {
196 this.presence.delete(peerId)
197 changed = true
198 }
199 }
200 if (changed) this.rebuildSnapshot()
201 }
203 private async sendToPeer(peerId: string, msg: PeerMsg) {
204 const root = await this.rootReady
205 const topic = await peerTopic(root, peerId)
206 void this.nostr.publish(topic, JSON.stringify(msg))
207 }
209 // ---- incoming messages ------------------------------------------------
211 private handlePeerMsg(from: string, msg: PeerMsg) {
212 switch (msg.t) {
213 case 'call-request': {
214 if (this.call) {
215 if (this.call.peerId !== from) {
216 // Busy with someone else.
217 void this.sendToPeer(from, {t: 'call-decline', busy: true})
218 } else if (this.call.phase === 'outgoing') {
219 // Glare: we each called the other — that's mutual agreement.
220 this.beginConnecting()
221 } else if (
222 this.call.phase === 'connecting' ||
223 this.call.phase === 'connected'
224 ) {
225 // Their resent ring means our accept was lost; send it again.
226 void this.sendToPeer(from, {
227 t: 'call-accept',
228 name: this.name ?? ''
229 })
230 }
231 // phase 'incoming': duplicate ring, ignore.
232 return
233 }
234 if (!this.name) {
235 // Not joined; we shouldn't be getting calls — turn them away.
236 void this.sendToPeer(from, {t: 'call-decline', busy: true})
237 return
238 }
239 this.notice = null
240 this.call = this.newCall('incoming', from, msg.name)
241 this.rebuildSnapshot()
242 void this.announce()
243 return
244 }
246 case 'call-accept': {
247 if (this.call?.phase === 'outgoing' && this.call.peerId === from) {
248 if (msg.name) this.call.peerName = msg.name
249 this.beginConnecting()
250 }
251 return
252 }
254 case 'call-decline': {
255 if (this.call?.peerId === from && this.call.phase === 'outgoing') {
256 const who = this.call.peerName
257 this.teardown(msg.busy ? `${who} is busy.` : `${who} declined.`)
258 }
259 return
260 }
262 case 'call-cancel': {
263 if (this.call?.peerId === from) {
264 this.teardown(`${this.call.peerName} canceled the call.`)
265 }
266 return
267 }
269 case 'hang-up': {
270 if (this.call?.peerId === from) {
271 this.teardown(`${this.call.peerName} hung up.`)
272 }
273 return
274 }
276 case 'signal': {
277 const call = this.call
278 if (!call || call.peerId !== from) return
279 if (call.phase !== 'connecting' && call.phase !== 'connected') return
280 if (call.peer) void call.peer.signal(msg.signal)
281 else call.pendingSignals.push(msg.signal)
282 return
283 }
284 }
285 }
287 // ---- call lifecycle ---------------------------------------------------
289 private newCall(phase: CallPhase, peerId: string, peerName: string): Call {
290 return {
291 phase,
292 peerId,
293 peerName,
294 peer: null,
295 localStream: null,
296 remoteStream: null,
297 screenStream: null,
298 pendingSignals: [],
299 settings: {...DEFAULT_SETTINGS},
300 settingsRevs: {},
301 audioMuted: false,
302 videoMuted: false,
303 peerAudioMuted: false,
304 peerVideoMuted: false,
305 ringInterval: null,
306 ringTimeout: null,
307 connectTimeout: null
308 }
309 }
311 private clearCallTimers(call: Call) {
312 if (call.ringInterval !== null) clearInterval(call.ringInterval)
313 if (call.ringTimeout !== null) clearTimeout(call.ringTimeout)
314 if (call.connectTimeout !== null) clearTimeout(call.connectTimeout)
315 call.ringInterval = null
316 call.ringTimeout = null
317 call.connectTimeout = null
318 }
320 /** Both sides agreed: get the camera/mic and bring up the WebRTC call. */
321 private beginConnecting() {
322 const call = this.call
323 if (!call || call.phase === 'connecting' || call.phase === 'connected') {
324 return
325 }
326 this.clearCallTimers(call)
327 call.phase = 'connecting'
328 call.connectTimeout = window.setTimeout(() => {
329 if (this.call === call && call.phase === 'connecting') {
330 void this.sendToPeer(call.peerId, {t: 'hang-up'})
331 this.teardown('Could not establish a connection.')
332 }
333 }, CONNECT_TIMEOUT_MS)
334 this.rebuildSnapshot()
335 void this.startMedia(call)
336 }
338 private async startMedia(call: Call) {
339 let stream: MediaStream
340 try {
341 stream = await navigator.mediaDevices.getUserMedia({
342 video: true,
343 audio: true
344 })
345 } catch {
346 if (this.call === call) {
347 void this.sendToPeer(call.peerId, {t: 'hang-up'})
348 this.teardown('Could not access your camera/microphone.')
349 }
350 return
351 }
352 if (this.call !== call || call.phase !== 'connecting') {
353 // The call went away while we were waiting for permission.
354 for (const track of stream.getTracks()) track.stop()
355 return
356 }
358 call.localStream = stream
359 // Deterministic initiator (no glare): the smaller peer ID makes the offer.
360 const peer = new Peer(selfId < call.peerId, stream)
361 call.peer = peer
362 peer.setHandlers({
363 signal: signal => {
364 void this.sendToPeer(call.peerId, {t: 'signal', signal})
365 },
366 track: remote => {
367 if (this.call !== call) return
368 call.remoteStream = remote
369 this.rebuildSnapshot()
370 },
371 connect: () => {
372 if (this.call !== call) return
373 call.phase = 'connected'
374 this.clearCallTimers(call)
375 this.rebuildSnapshot()
376 },
377 data: raw => {
378 if (this.call !== call) return
379 let msg: ControlMsg
380 try {
381 msg = JSON.parse(raw)
382 } catch {
383 return
384 }
385 if (msg.t === 'hang-up') {
386 this.teardown(`${call.peerName} hung up.`)
387 } else if (msg.t === 'set') {
388 this.applyRemoteSetting(call, msg)
389 } else if (msg.t === 'mute') {
390 if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
391 return
392 }
393 call.peerAudioMuted = msg.audio
394 call.peerVideoMuted = msg.video
395 this.rebuildSnapshot()
396 }
397 },
398 close: () => {
399 if (this.call === call) this.teardown('Call ended.')
400 }
401 })
402 for (const signal of call.pendingSignals.splice(0)) {
403 void peer.signal(signal)
404 }
405 this.rebuildSnapshot()
406 }
408 // ---- shared call settings --------------------------------------------
409 //
410 // One settings object per call, visible and editable by BOTH parties.
411 // Sync is per-key last-writer-wins over the control channel: every change
412 // bumps that key's revision counter and is sent as {t:'set'}. The channel
413 // is reliable and ordered, so divergence only happens when both sides
414 // change the same key concurrently (same revision) — that tie must resolve
415 // identically on both sides, so the smaller peer ID's value wins.
417 private setSetting<K extends keyof CallSettings>(
418 key: K,
419 value: CallSettings[K]
420 ) {
421 const call = this.call
422 if (!call || !call.peer || call.settings[key] === value) return
423 const rev = (call.settingsRevs[key] ?? 0) + 1
424 call.settingsRevs[key] = rev
425 call.settings = {...call.settings}
426 call.settings[key] = value
427 call.peer.send(JSON.stringify({t: 'set', key, value, rev}))
428 this.settingChanged(call, key)
429 this.rebuildSnapshot()
430 }
432 private applyRemoteSetting(
433 call: Call,
434 msg: {key: string; value: unknown; rev: number}
435 ) {
436 if (!(msg.key in SETTING_VALIDATORS)) return
437 const key = msg.key as keyof CallSettings
438 if (!SETTING_VALIDATORS[key](msg.value)) return
439 if (!Number.isInteger(msg.rev) || msg.rev < 1) return
440 const localRev = call.settingsRevs[key] ?? 0
441 if (msg.rev < localRev) return // stale
442 if (msg.rev === localRev && selfId < call.peerId) return // tie: we win
443 call.settingsRevs[key] = msg.rev
444 if (call.settings[key] === msg.value) return
445 call.settings = {...call.settings}
446 call.settings[key] = msg.value
447 this.settingChanged(call, key)
448 this.rebuildSnapshot()
449 }
451 /** Side effects of a setting taking a new value (local or remote). */
452 private settingChanged(call: Call, key: keyof CallSettings) {
453 if (key === 'videoQuality') this.applyVideoParams(call)
454 }
456 /** Push the current quality preset into the outgoing video sender. */
457 private applyVideoParams(call: Call) {
458 if (!call.peer) return
459 const p = QUALITY_PARAMS[call.settings.videoQuality]
460 const sharing = call.screenStream !== null
461 void call.peer.setVideoParameters({
462 maxBitrate: p.maxBitrate,
463 // Downscaled screen text is unreadable: while sharing, send full
464 // resolution and let the bitrate/framerate caps do the limiting.
465 scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
466 maxFramerate: p.maxFramerate,
467 degradationPreference: sharing ? 'maintain-resolution' : undefined
468 })
469 }
471 // ---- mute ------------------------------------------------------------
472 //
473 // Mute is per-party state, not a shared setting: each side owns its own
474 // flags (so no revision counters — the ordered channel makes last-sent
475 // win naturally) and just notifies the other side. Toggling track.enabled
476 // sends silence/black frames without renegotiation.
478 setAudioMuted(muted: boolean) {
479 const call = this.call
480 if (!call || !call.localStream || call.audioMuted === muted) return
481 call.audioMuted = muted
482 for (const t of call.localStream.getAudioTracks()) t.enabled = !muted
483 this.sendMuteNotice(call)
484 this.rebuildSnapshot()
485 }
487 setVideoMuted(muted: boolean) {
488 const call = this.call
489 if (!call || !call.localStream || call.videoMuted === muted) return
490 call.videoMuted = muted
491 for (const t of call.localStream.getVideoTracks()) t.enabled = !muted
492 this.sendMuteNotice(call)
493 this.rebuildSnapshot()
494 }
496 /** Tell the peer our EFFECTIVE outgoing mute state: while screen sharing
497 * the outgoing video is the (always live) screen, so a muted camera is
498 * latent until the share ends. */
499 private sendMuteNotice(call: Call) {
500 call.peer?.send(
501 JSON.stringify({
502 t: 'mute',
503 audio: call.audioMuted,
504 video: call.videoMuted && !call.screenStream
505 })
506 )
507 }
509 private teardown(notice: string | null) {
510 const call = this.call
511 if (!call) return
512 this.call = null // cleared first so the peer's close handler no-ops
513 this.clearCallTimers(call)
514 call.peer?.destroy()
515 if (call.localStream) {
516 for (const track of call.localStream.getTracks()) track.stop()
517 }
518 if (call.screenStream) {
519 for (const track of call.screenStream.getTracks()) track.stop()
520 }
521 this.notice = notice
522 this.rebuildSnapshot()
523 void this.announce()
524 }
526 // ---- public API -------------------------------------------------------
528 join(name: string) {
529 const trimmed = name.trim().slice(0, 40)
530 if (!trimmed) return
531 this.name = trimmed
532 localStorage.setItem(NAME_KEY, trimmed)
533 this.notice = null
534 this.rebuildSnapshot()
535 void this.announce()
536 }
538 leave() {
539 if (this.call) this.endCall()
540 this.name = null
541 this.rebuildSnapshot()
542 // Others will drop us from their rosters when announcements stop.
543 }
545 callPeer(peerId: string) {
546 if (!this.name || this.call || peerId === selfId) return
547 const peerName = this.presence.get(peerId)?.name ?? peerId.slice(0, 8)
548 this.notice = null
549 const call = this.newCall('outgoing', peerId, peerName)
550 this.call = call
551 const ring = () => void this.sendToPeer(peerId, {
552 t: 'call-request',
553 name: this.name ?? ''
554 })
555 ring()
556 call.ringInterval = window.setInterval(ring, RING_RESEND_MS)
557 call.ringTimeout = window.setTimeout(() => {
558 if (this.call === call && call.phase === 'outgoing') {
559 void this.sendToPeer(peerId, {t: 'call-cancel'})
560 this.teardown(`${call.peerName} did not answer.`)
561 }
562 }, RING_TIMEOUT_MS)
563 this.rebuildSnapshot()
564 void this.announce()
565 }
567 accept() {
568 const call = this.call
569 if (!call || call.phase !== 'incoming') return
570 void this.sendToPeer(call.peerId, {t: 'call-accept', name: this.name ?? ''})
571 this.beginConnecting()
572 }
574 decline() {
575 const call = this.call
576 if (!call || call.phase !== 'incoming') return
577 void this.sendToPeer(call.peerId, {t: 'call-decline'})
578 this.teardown(null)
579 }
581 endCall() {
582 const call = this.call
583 if (!call) return
584 if (call.phase === 'outgoing') {
585 void this.sendToPeer(call.peerId, {t: 'call-cancel'})
586 } else if (call.phase === 'incoming') {
587 void this.sendToPeer(call.peerId, {t: 'call-decline'})
588 } else {
589 // Belt and braces: the control channel may not be open yet.
590 call.peer?.send(JSON.stringify({t: 'hang-up'}))
591 void this.sendToPeer(call.peerId, {t: 'hang-up'})
592 }
593 this.teardown(null)
594 }
596 /** Change the shared video-quality preset. It applies to BOTH senders:
597 * each side caps its own outgoing video, and the change syncs across. */
598 setVideoQuality(quality: VideoQuality) {
599 this.setSetting('videoQuality', quality)
600 }
602 /** Swap the outgoing camera track for a screen capture. The remote side
603 * sees the screen in place of the camera; no renegotiation involved. */
604 async startScreenShare() {
605 const call = this.call
606 if (!call || !call.peer || call.screenStream) return
607 let stream: MediaStream
608 try {
609 stream = await navigator.mediaDevices.getDisplayMedia({video: true})
610 } catch {
611 return // user canceled the picker (or capture is unsupported)
612 }
613 const track = stream.getVideoTracks()[0]
614 const ok =
615 this.call === call && call.peer && track
616 ? await call.peer.replaceVideoTrack(track)
617 : false
618 if (!ok || this.call !== call) {
619 for (const t of stream.getTracks()) t.stop()
620 return
621 }
622 call.screenStream = stream
623 this.applyVideoParams(call) // re-derive caps for screen-share mode
624 this.sendMuteNotice(call) // outgoing video is now the live screen
625 // The browser's own "Stop sharing" bar ends the track; swap back then.
626 track.onended = () => void this.stopScreenShare()
627 this.rebuildSnapshot()
628 }
630 async stopScreenShare() {
631 const call = this.call
632 if (!call || !call.screenStream) return
633 const screen = call.screenStream
634 call.screenStream = null
635 const camTrack = call.localStream?.getVideoTracks()[0]
636 if (call.peer && camTrack) await call.peer.replaceVideoTrack(camTrack)
637 for (const t of screen.getTracks()) t.stop()
638 if (this.call === call) {
639 this.applyVideoParams(call) // restore camera-mode caps
640 this.sendMuteNotice(call) // the camera, with its mute state, is back
641 this.rebuildSnapshot()
642 }
643 }
645 dismissNotice() {
646 this.notice = null
647 this.rebuildSnapshot()
648 }
650 getSnapshot = (): Snapshot => this.snapshot
652 subscribe = (listener: () => void): (() => void) => {
653 this.listeners.add(listener)
654 return () => this.listeners.delete(listener)
655 }
657 private rebuildSnapshot() {
658 const roster: RosterEntry[] = [...this.presence.entries()]
659 .map(([peerId, p]) => ({peerId, name: p.name, busy: p.busy}))
660 .sort(
661 (a, b) =>
662 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
663 )
664 const call: CallInfo | null = this.call
665 ? {
666 phase: this.call.phase,
667 peerId: this.call.peerId,
668 peerName: this.call.peerName,
669 localStream: this.call.localStream,
670 remoteStream: this.call.remoteStream,
671 screenStream: this.call.screenStream,
672 settings: this.call.settings,
673 audioMuted: this.call.audioMuted,
674 videoMuted: this.call.videoMuted,
675 peerAudioMuted: this.call.peerAudioMuted,
676 peerVideoMuted: this.call.peerVideoMuted
677 }
678 : null
679 this.snapshot = {
680 selfId,
681 name: this.name,
682 roster,
683 call,
684 notice: this.notice
685 }
686 for (const l of this.listeners) l()
687 }
moveopenescclose