package callscore import ( "context" "encoding/json" "errors" "net/http" "net/url" "sort" "sync" "time" "amadan.net/oficina/calls/protocol" ) // Event crosses the native bridge as JSON. Message is a server envelope; // snapshot events carry authoritative local state. Media never crosses it. type Event struct { Info *Info `json:"info,omitempty"` Type string `json:"type"` State *Snapshot `json:"state,omitempty"` Message *protocol.Message `json:"message,omitempty"` ICE *ICEServers `json:"ice,omitempty"` Signal *protocol.Signal `json:"signal,omitempty"` } type Snapshot struct { Phase string `json:"phase"` Peer protocol.PeerID `json:"peer"` Boot string `json:"boot"` Reason string `json:"reason,omitempty"` Peers []protocol.RosterPeer `json:"peers"` Waiting []protocol.RosterPeer `json:"waiting"` } type lane struct { queue chan protocol.Signal ctx context.Context cancel context.CancelFunc } // Room is single-use. Callbacks are serialized and must return promptly. A host // should enqueue them on its UI executor, never perform media work inline. type Room struct { mu sync.Mutex emitMu sync.Mutex t *transport callback func(Event) ctx context.Context cancel context.CancelFunc wg sync.WaitGroup started bool stopped bool state Snapshot last map[protocol.PeerID]uint64 seq map[protocol.PeerID]uint64 lanes map[protocol.PeerID]*lane blocked map[protocol.PeerID]bool iceWake chan struct{} // media is this client's own microphone and camera, as the host last // set it (§15.19). It outlives a fresh join: a restart does not // unmute anybody. media protocol.MediaState // plainScreen: the server refused a report naming stalled screens // (it predates §15.21), so reports go without them. plainScreen bool // plainHearing: the server refused a report saying who this client // hears (it predates §15.24), so reports go without the key, and the // people on new clients fall back to their own evidence about us. plainHearing bool // report is the latest hello's report token (spec §15.26): a resumed // hello carries the same one, a fresh participation a new one. Kept // past Close, for the after-call window (report.go). report string } func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) { t, err := newTransport(origin, room, authenticated) if err != nil { return nil, err } if callback == nil { return nil, errors.New("a room event handler is required") } ctx, cancel := context.WithCancel(context.Background()) return &Room{t: t, callback: callback, ctx: ctx, cancel: cancel, state: Snapshot{Phase: "ready", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}}, media: protocol.MediaState{Hearing: []protocol.PeerID{}}, last: map[protocol.PeerID]uint64{}, seq: map[protocol.PeerID]uint64{}, lanes: map[protocol.PeerID]*lane{}, blocked: map[protocol.PeerID]bool{}, iceWake: make(chan struct{}, 1)}, nil } func (r *Room) emit(e Event) { r.emitMu.Lock(); defer r.emitMu.Unlock(); r.callback(e) } func (r *Room) snapshotLocked() Snapshot { s := r.state s.Peers = append([]protocol.RosterPeer{}, s.Peers...) s.Waiting = append([]protocol.RosterPeer{}, s.Waiting...) return s } func (r *Room) Snapshot() Snapshot { r.mu.Lock(); defer r.mu.Unlock(); return r.snapshotLocked() } // ShareLink asks the server for a current invitation using this session's // authority. It is blocking and belongs on the host's background executor. func (r *Room) ShareLink() (string, error) { return r.t.shareLink(r.ctx) } func (r *Room) publish() { s := r.Snapshot(); r.emit(Event{Type: "state", State: &s}) } func (r *Room) Start() error { r.mu.Lock() if r.started || r.stopped { r.mu.Unlock() return errors.New("room already started or closed") } r.started = true r.state.Phase = "connecting" r.wg.Add(2) r.mu.Unlock() r.publish() go func() { defer r.wg.Done(); r.streamLoop() }() go func() { defer r.wg.Done(); r.iceLoop() }() return nil } // Close cancels all network work and waits for callbacks to finish. Do not call // it from a callback. Leave attempts the departure before closing. func (r *Room) Close() { r.mu.Lock() r.stopped = true r.cancel() r.mu.Unlock() r.wg.Wait() } func (r *Room) Leave() { r.mu.Lock() peer := r.state.Peer r.mu.Unlock() if peer != "" { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) _ = r.t.post(ctx, protocol.Signal{Peer: peer, Type: protocol.TypeLeave}) cancel() } r.terminal("left") r.Close() } func (r *Room) terminal(reason string) { r.mu.Lock() if r.stopped { r.mu.Unlock() return } r.stopped = true r.state.Phase = "ended" r.state.Reason = reason r.state.Peers = []protocol.RosterPeer{} r.state.Waiting = []protocol.RosterPeer{} r.cancel() r.mu.Unlock() r.publish() } func (r *Room) fresh() { r.mu.Lock() r.state = Snapshot{Phase: "connecting", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}} r.last = map[protocol.PeerID]uint64{} r.seq = map[protocol.PeerID]uint64{} for _, l := range r.lanes { l.cancel() } r.lanes = map[protocol.PeerID]*lane{} r.blocked = map[protocol.PeerID]bool{} r.mu.Unlock() r.publish() } func (r *Room) streamLoop() { if info, err := r.t.info(r.ctx); err == nil { r.emit(Event{Type: "info", Info: &info}) } delay := 500 * time.Millisecond // A fresh open refused with 409 means this session already holds a // participation in the room. For an app that is its own ghost: the // previous run of the app (quit, crashed, relaunched by an update) // whose participation is still inside its thirty-second resume // window. The session is the app's alone, so it takes its own place // back, once, rather than ending the call as "open elsewhere" before // it began. A second instance that really is elsewhere is told // lost{replaced} on its own stream, and does not take it back. tookOver := false for r.ctx.Err() == nil { r.mu.Lock() peer := r.state.Peer q := url.Values{} if peer != "" { b, _ := json.Marshal(r.last) q.Set("peer", string(peer)) q.Set("last", string(b)) } r.mu.Unlock() began := time.Now() streamCtx, streamCancel := context.WithTimeout(r.ctx, 6*time.Minute) res, err := r.t.request(streamCtx, http.MethodGet, "stream", q, nil) if err == nil { err = readEvents(res.Body, r.receive) res.Body.Close() } streamCancel() if r.ctx.Err() != nil { return } var refusal *HTTPError if errors.As(err, &refusal) { switch refusal.Status { case 410: reason := refusal.Reason if reason == "" { reason = "expired" } r.terminal(reason) return case 409: if peer == "" && !tookOver { tookOver = true if r.t.takeover(r.ctx) == nil { continue } } r.terminal("replaced") return case 401, 403, 302, 303, 307, 308: r.terminal("signin") return case 404: if peer != "" { r.fresh() delay = 500 * time.Millisecond continue } r.terminal("unavailable") return case 400, 406: r.terminal("protocol") return } } r.mu.Lock() r.state.Phase = "reconnecting" r.mu.Unlock() r.publish() if time.Since(began) > 5*time.Second { delay = 500 * time.Millisecond } if !pause(r.ctx, delay) { return } delay = min(delay*2, 10*time.Second) } } func pause(ctx context.Context, d time.Duration) bool { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } // SetMedia records this client's own microphone and camera state and, // once admitted, reports it (§15.19). Before admission there is nobody // to tell; the roster that admits this client is compared against it // and the report goes then, as it does after every resume and restart. func (r *Room) SetMedia(muted, cameraOff bool) error { r.mu.Lock() if r.media.Muted == muted && r.media.CameraOff == cameraOff { r.mu.Unlock() return nil } r.media.Muted, r.media.CameraOff = muted, cameraOff r.mu.Unlock() return r.reportMedia() } // SetNoSoundFrom records which peers this client is receiving silence // from (spec §15.20) and reports it with the rest of its media state. // Only peers in the current roster other than this client are kept, in // roster order, so the report is stable and bounded; an empty list is // the ordinary report. A native app is never refused playback by an // autoplay policy, so audioBlocked is never set from here. func (r *Room) SetNoSoundFrom(peers []protocol.PeerID) error { want := make(map[protocol.PeerID]bool, len(peers)) for _, p := range peers { want[p] = true } r.mu.Lock() var next []protocol.PeerID for _, p := range r.state.Peers { if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxNoSoundFrom { next = append(next, p.Peer) } } if (protocol.MediaState{NoSoundFrom: next}).Equal(protocol.MediaState{NoSoundFrom: r.media.NoSoundFrom}) { r.mu.Unlock() return nil } r.media.NoSoundFrom = next r.mu.Unlock() return r.reportMedia() } // SetScreenStalled records whose shared screen has stopped moving here // (spec §15.21) and reports it with the rest of this client's media // state, exactly as SetNoSoundFrom does its list: roster peers only, in // roster order, bounded, and an empty list the ordinary report. func (r *Room) SetScreenStalled(peers []protocol.PeerID) error { want := make(map[protocol.PeerID]bool, len(peers)) for _, p := range peers { want[p] = true } r.mu.Lock() var next []protocol.PeerID for _, p := range r.state.Peers { if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxScreenStalled { next = append(next, p.Peer) } } if (protocol.MediaState{ScreenStalled: next}).Equal(protocol.MediaState{ScreenStalled: r.media.ScreenStalled}) { r.mu.Unlock() return nil } r.media.ScreenStalled = next r.mu.Unlock() return r.reportMedia() } // SetHearing records which peers this client is actually receiving and // playing audio from (spec §15.24) and reports it with the rest of its // media state. Roster peers only, in roster order, bounded, exactly as // SetNoSoundFrom keeps its list — except that empty is not absent here: // this client always says who it hears, and "nobody yet" is [] on the // wire, so the people it has not yet heard know to wait for it rather // than fall back to guessing. func (r *Room) SetHearing(peers []protocol.PeerID) error { want := make(map[protocol.PeerID]bool, len(peers)) for _, p := range peers { want[p] = true } r.mu.Lock() next := []protocol.PeerID{} for _, p := range r.state.Peers { if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxHearing { next = append(next, p.Peer) } } if (protocol.MediaState{Hearing: next}).Equal(protocol.MediaState{Hearing: r.media.Hearing}) { r.mu.Unlock() return nil } r.media.Hearing = next r.mu.Unlock() return r.reportMedia() } func (r *Room) reportMedia() error { r.mu.Lock() report := r.state.Phase == "admitted" && !r.stopped r.mu.Unlock() if !report { return nil } return r.sendMedia() } func (r *Room) sendMedia() error { r.mu.Lock() b, err := json.Marshal(r.wireMediaLocked()) r.mu.Unlock() if err != nil { return err } return r.Send(protocol.TypeMedia, "", 0, string(b)) } // wireMediaLocked is the report as this server will take it: the host's // state without the keys an older server has refused. func (r *Room) wireMediaLocked() protocol.MediaState { report := r.media if r.plainScreen { // A server from before §15.21 refuses the key; everything else // in the report still matters. report.ScreenStalled = nil } if r.plainHearing { report.Hearing = nil } return report } // refusedExtras is a server from before §15.24 or §15.21 refusing a media // report because of a key it does not know: the newest key goes first — // hearing, which every report from this core carries — and the report is // sent again without it; a second refusal, of a report naming stalled // screens, takes those off too. From then on reports go without them, so // a mute is never lost to an old server. Without hearing, the people on // new clients fall back to their own evidence that they can be heard // (§15.24); without screenStalled the sharer recovers on its own watchdog. func (r *Room) refusedExtras(s protocol.Signal) bool { var sent protocol.MediaState if json.Unmarshal(s.Payload, &sent) != nil { return false } r.mu.Lock() dropped := false if sent.Hearing != nil && !r.plainHearing { r.plainHearing = true dropped = true } else if len(sent.ScreenStalled) > 0 && !r.plainScreen { r.plainScreen = true dropped = true } r.mu.Unlock() if dropped { go func() { _ = r.sendMedia() }() } return dropped } // mediaStaleLocked says whether the hub's record of this client — its // own entry in the roster just applied — differs from what the host last // set, or is missing, which is how a fresh participation, a restarted // server, or a report lost while the stream was down all look. func (r *Room) mediaStaleLocked() bool { for _, p := range r.state.Peers { if p.Peer == r.state.Peer { return p.Media == nil || !p.Media.Equal(r.wireMediaLocked()) } } return false } // applyMediaLocked is a `media` event: one peer's new state, replacing // its roster entry's pointer rather than writing through it, because // snapshots already handed out share the old one. func (r *Room) applyMediaLocked(m protocol.Message) { var next protocol.MediaState if m.From == "" || json.Unmarshal(m.Payload, &next) != nil { return } for i, p := range r.state.Peers { if p.Peer == m.From { r.state.Peers[i].Media = &next } } } func (r *Room) generationLocked(peer protocol.PeerID) uint64 { for _, p := range r.state.Peers { if p.Peer == peer { return p.Gen } } return 0 } func targeted(typ string) bool { return typ == protocol.TypeOffer || typ == protocol.TypeAnswer || typ == protocol.TypeCandidate } func (r *Room) receive(m protocol.Message) error { r.mu.Lock() if r.stopped { r.mu.Unlock() return context.Canceled } join, ice, reset, report := false, false, protocol.PeerID(""), false switch m.Type { case protocol.TypeHello: join = r.state.Peer == "" r.state.Peer = m.Peer r.state.Boot = m.Boot if m.Report != "" { r.report = m.Report } if join { r.state.Phase = "joining" } case protocol.TypeRoster: if m.Roster == nil { r.mu.Unlock() return errors.New("missing roster") } for _, p := range m.Roster.Peers { if p.Gen > r.generationLocked(p.Peer) { delete(r.blocked, p.Peer) } } for _, p := range r.state.Peers { present := false for _, next := range m.Roster.Peers { if next.Peer == p.Peer { present = true } } if !present { r.dropLaneLocked(p.Peer) } } r.state.Peers = append([]protocol.RosterPeer{}, m.Roster.Peers...) r.state.Waiting = append([]protocol.RosterPeer{}, m.Roster.Waiting...) r.state.Phase = "admitted" ice = true report = r.mediaStaleLocked() case protocol.TypePeer: if m.Roster != nil { for _, p := range m.Roster.Peers { r.upsertLocked(p) } } case protocol.TypeGone: r.state.Peers = without(r.state.Peers, m.From) r.dropLaneLocked(m.From) case protocol.TypeKnock: r.state.Waiting = without(r.state.Waiting, m.From) r.state.Waiting = append(r.state.Waiting, protocol.RosterPeer{Peer: m.From, Name: m.Name, Kind: "guest"}) case protocol.TypeKnockGone: r.state.Waiting = without(r.state.Waiting, m.From) case protocol.TypeState: if m.Waiting { r.state.Phase = "waiting" } case protocol.TypeFull: if r.state.Phase != "admitted" { r.state.Phase = "full" } case protocol.TypePair: for i, p := range r.state.Peers { if p.Peer == m.Peer && m.Gen > p.Gen { r.state.Peers[i].Gen = m.Gen delete(r.blocked, m.Peer) } } case protocol.TypeMedia: r.applyMediaLocked(m) case protocol.TypeGap: reset = m.From case protocol.TypeLost: r.mu.Unlock() if m.Reason == protocol.ReasonBuffer { return errors.New("resume required") } r.terminal(string(m.Reason)) return context.Canceled default: if targeted(m.Type) { gen := r.generationLocked(m.From) if m.Seq == 0 || m.Seq <= r.last[m.From] { r.mu.Unlock() return nil } r.last[m.From] = m.Seq if gen == 0 || m.Gen < gen { r.mu.Unlock() return nil } if m.Gen > gen { for i, p := range r.state.Peers { if p.Peer == m.From { r.state.Peers[i].Gen = m.Gen } } } } } r.mu.Unlock() r.publish() r.emit(Event{Type: "message", Message: &m}) if join { if err := r.Send(protocol.TypeJoin, "", 0, ""); err != nil { return err } } if ice { select { case r.iceWake <- struct{}{}: default: } } if reset != "" { _ = r.Send(protocol.TypeReset, string(reset), 0, "") } if report { _ = r.sendMedia() } return nil } func without(peers []protocol.RosterPeer, id protocol.PeerID) []protocol.RosterPeer { out := make([]protocol.RosterPeer, 0, len(peers)) for _, p := range peers { if p.Peer != id { out = append(out, p) } } return out } func (r *Room) upsertLocked(p protocol.RosterPeer) { r.state.Peers = without(r.state.Peers, p.Peer) r.state.Peers = append(r.state.Peers, p) sort.Slice(r.state.Peers, func(i, j int) bool { return r.state.Peers[i].Peer < r.state.Peers[j].Peer }) } // Send enqueues without blocking the UI. expectedGen prevents a late native // media callback from being relabelled as part of a replacement connection. func (r *Room) Send(typ, to string, expectedGen uint64, payload string) error { r.mu.Lock() defer r.mu.Unlock() if r.stopped || !r.started || r.state.Peer == "" { return errors.New("room is not connected") } switch typ { case protocol.TypeJoin, protocol.TypeAdmit, protocol.TypeReset, protocol.TypeChat, protocol.TypeShare, protocol.TypeMedia, protocol.TypeOffer, protocol.TypeAnswer, protocol.TypeCandidate: default: return errors.New("unsupported room command") } target := protocol.PeerID(to) s := protocol.Signal{Peer: r.state.Peer, Type: typ, To: target} if payload != "" { if !json.Valid([]byte(payload)) { return errors.New("invalid signal payload") } s.Payload = json.RawMessage(payload) } if targeted(typ) { gen := r.generationLocked(target) if target == r.state.Peer || gen == 0 || gen != expectedGen || r.blocked[target] { return errors.New("peer connection is no longer current") } s.Gen = gen s.Seq = r.seq[target] + 1 } b, err := json.Marshal(s) if err != nil || len(b) > 64*1024 { return errors.New("signal exceeds Calls' message limit") } laneKey := target if typ == protocol.TypeReset { if r.generationLocked(target) == 0 { return errors.New("peer is no longer present") } laneKey = "" } l := r.lanes[laneKey] if l == nil { if len(r.lanes) >= 17 { return errors.New("too many signaling destinations") } ctx, cancel := context.WithCancel(r.ctx) l = &lane{queue: make(chan protocol.Signal, 64), ctx: ctx, cancel: cancel} r.lanes[laneKey] = l r.wg.Add(1) go func() { defer r.wg.Done(); r.sendLoop(l) }() } select { case l.queue <- s: if targeted(typ) { r.seq[target] = s.Seq } return nil default: return errors.New("signaling queue is full") } } func (r *Room) current(s protocol.Signal) bool { r.mu.Lock() defer r.mu.Unlock() return !r.stopped && s.Peer == r.state.Peer && (!targeted(s.Type) || (r.generationLocked(s.To) == s.Gen && !r.blocked[s.To])) } func (r *Room) sendLoop(l *lane) { // Cancellation removes this lane under mu before any new Send can use it. // Report every accepted chat still waiting in its queue; the UI has already // cleared the composer and must retain a visible failure instead of losing it. defer func() { for { select { case s := <-l.queue: if s.Type == protocol.TypeChat { r.emit(Event{Type: "send-failed", Signal: &s}) } default: return } } }() for { if l.ctx.Err() != nil { return } select { case <-l.ctx.Done(): return case s := <-l.queue: ctx, cancel := context.WithTimeout(l.ctx, 30*time.Second) delay := 500 * time.Millisecond accepted := false for r.current(s) { err := r.t.post(ctx, s) if err == nil { accepted = true break } var refusal *HTTPError if errors.As(err, &refusal) { if refusal.Status == 429 { delay = max(delay, refusal.RetryAfter) } else if refusal.Status < 500 { if refusal.Status == 400 && s.Type == protocol.TypeMedia && r.refusedExtras(s) { break } if refusal.Status == 401 || refusal.Status == 403 || refusal.Status == 302 || refusal.Status == 303 { r.terminal("signin") } if refusal.Status == 409 && targeted(s.Type) { r.resetAfterFailure(s) } break } } if !pause(ctx, delay) { if l.ctx.Err() == nil && targeted(s.Type) { r.resetAfterFailure(s) } break } delay = min(delay*2, 5*time.Second) } cancel() // Chat is not echoed by the hub. Report the HTTP outcome, not // merely queue acceptance, so native clients can show sent lines // and make failures visible without claiming delivery to peers. if s.Type == protocol.TypeChat { typ := "send-failed" if accepted { typ = "sent" } r.emit(Event{Type: typ, Signal: &s}) } } } } func (r *Room) iceLoop() { var timer *time.Timer var tick <-chan time.Time defer func() { if timer != nil { timer.Stop() } }() for { select { case <-r.ctx.Done(): return case <-r.iceWake: case <-tick: } s := r.Snapshot() if s.Phase != "admitted" { continue } ice, err := r.t.ice(r.ctx, s.Peer) next := time.Minute if err == nil { if r.Snapshot().Peer != s.Peer { continue } r.emit(Event{Type: "ice", ICE: &ice}) if ice.RenewAt == 0 { tick = nil if timer != nil { timer.Stop() } continue } next = max(time.Until(time.Unix(ice.RenewAt, 0)), time.Second) } else { r.emit(Event{Type: "ice-unavailable"}) } if timer == nil { timer = time.NewTimer(next) } else { timer.Reset(next) } tick = timer.C } } func (r *Room) dropLaneLocked(peer protocol.PeerID) { if l := r.lanes[peer]; l != nil { l.cancel() delete(r.lanes, peer) } delete(r.last, peer) delete(r.seq, peer) delete(r.blocked, peer) } func (r *Room) resetAfterFailure(s protocol.Signal) { r.mu.Lock() if r.stopped || r.state.Peer != s.Peer || r.generationLocked(s.To) != s.Gen { r.mu.Unlock() return } r.blocked[s.To] = true r.mu.Unlock() // The control lane has the same retry/deadline contract as other sends; // it can overtake the discarded old-generation target queue. _ = r.Send(protocol.TypeReset, string(s.To), 0, "") }