| 1 | // Package protocol defines Calls’s client-neutral signaling contract. |
| 2 | package protocol |
| 3 | |
| 4 | import "encoding/json" |
| 5 | |
| 6 | // PeerID is the server-issued identity of one participation: sixteen |
| 7 | // random bytes in hex, bound to a room and a session, shown in rosters |
| 8 | // so browsers can address each other. It is not a credential (spec §4.2). |
| 9 | type PeerID string |
| 10 | |
| 11 | // Kind is a participation's authority, re-derived from standing on |
| 12 | // every tick (spec §2.4). |
| 13 | type Kind int |
| 14 | |
| 15 | // Reason says why a stream ended. Only "buffer" is followed by a |
| 16 | // resume; the rest are terminal (spec §4.5). |
| 17 | type Reason string |
| 18 | |
| 19 | const ( |
| 20 | KindGuest Kind = iota |
| 21 | KindMember |
| 22 | ) |
| 23 | |
| 24 | func (k Kind) String() string { |
| 25 | if k == KindMember { |
| 26 | return "member" |
| 27 | } |
| 28 | return "guest" |
| 29 | } |
| 30 | |
| 31 | const ( |
| 32 | ReasonBuffer Reason = "buffer" |
| 33 | ReasonReplaced Reason = "replaced" |
| 34 | ReasonStanding Reason = "standing" |
| 35 | ReasonExpired Reason = "expired" |
| 36 | ReasonRemoved Reason = "removed" |
| 37 | ReasonEnded Reason = "ended" |
| 38 | ) |
| 39 | |
| 40 | // Upstream types a browser may send. |
| 41 | const ( |
| 42 | TypeJoin = "join" |
| 43 | TypeAdmit = "admit" |
| 44 | TypeLeave = "leave" |
| 45 | TypeOffer = "offer" |
| 46 | TypeAnswer = "answer" |
| 47 | TypeCandidate = "candidate" |
| 48 | TypeReset = "reset" |
| 49 | TypeChat = "chat" // slice 4 |
| 50 | TypeShare = "share" // slice 4 |
| 51 | // TypeMedia goes both ways: a peer reports its own MediaState |
| 52 | // upstream, and the hub fans the change out downstream as |
| 53 | // {type, from, name, payload: MediaState} (spec §15.19). |
| 54 | TypeMedia = "media" |
| 55 | ) |
| 56 | |
| 57 | // Downstream types the server emits. |
| 58 | const ( |
| 59 | TypeHello = "hello" |
| 60 | TypeRoster = "roster" |
| 61 | TypePeer = "peer" |
| 62 | TypeGone = "gone" |
| 63 | TypeKnock = "knock" |
| 64 | TypeKnockGone = "knock-gone" |
| 65 | TypeFull = "full" |
| 66 | // TypeEnded names the ending in §4.3's table, but nothing sends it: |
| 67 | // a meeting that ends closes every stream, and a closing stream |
| 68 | // carries its reason in lost, so the browser reads the ending as |
| 69 | // lost{reason: "ended"} (§4.5). The name is kept because the spec |
| 70 | // uses it. |
| 71 | TypeEnded = "ended" |
| 72 | TypeLost = "lost" |
| 73 | TypePair = "pair" |
| 74 | TypeGap = "gap" |
| 75 | TypeState = "state" |
| 76 | ) |
| 77 | |
| 78 | // Message is the one envelope, built by the server. Payload is opaque. |
| 79 | type Message struct { |
| 80 | Type string `json:"type"` |
| 81 | From PeerID `json:"from,omitempty"` |
| 82 | Name string `json:"name,omitempty"` |
| 83 | To PeerID `json:"to,omitempty"` |
| 84 | Seq uint64 `json:"seq,omitempty"` |
| 85 | Gen uint64 `json:"gen,omitempty"` |
| 86 | Payload json.RawMessage `json:"payload,omitempty"` |
| 87 | Reason Reason `json:"reason,omitempty"` |
| 88 | Roster *Roster `json:"roster,omitempty"` |
| 89 | Boot string `json:"boot,omitempty"` |
| 90 | Peer PeerID `json:"peer,omitempty"` |
| 91 | Waiting bool `json:"waiting,omitempty"` |
| 92 | // Report is hello's report token (spec §15.26): the client's authority |
| 93 | // to send quality and problem reports, for its participation and ten |
| 94 | // minutes after. Sent back in the X-Calls-Report header, never logged. |
| 95 | Report string `json:"report,omitempty"` |
| 96 | } |
| 97 | |
| 98 | // Roster is the authoritative snapshot a browser reconciles against |
| 99 | // (spec §4.3). Waiting is filled only for a member recipient. |
| 100 | type Roster struct { |
| 101 | Peers []RosterPeer `json:"peers"` |
| 102 | Waiting []RosterPeer `json:"waiting,omitempty"` |
| 103 | } |
| 104 | |
| 105 | type RosterPeer struct { |
| 106 | Peer PeerID `json:"peer"` |
| 107 | Name string `json:"name"` |
| 108 | Kind string `json:"kind"` |
| 109 | Gen uint64 `json:"gen,omitempty"` |
| 110 | // Media is the peer's own last report of its microphone and camera, |
| 111 | // absent until it has made one — which is every client that predates |
| 112 | // §15.19, and every peer for the moment between admission and its |
| 113 | // first report. Absent means unknown, and a client shows unknown as |
| 114 | // neither muted nor camera-off. Never written through: a snapshot |
| 115 | // copies the pointer, so a change replaces it. |
| 116 | Media *MediaState `json:"media,omitempty"` |
| 117 | } |
| 118 | |
| 119 | // MediaState is what a peer says about its own outgoing media (spec |
| 120 | // §15.19): whether its microphone is muted and whether its camera is |
| 121 | // off. It is a claim about what the peer is choosing to send, not a |
| 122 | // measurement of what arrives — the server never sees media. |
| 123 | type MediaState struct { |
| 124 | Muted bool `json:"muted"` |
| 125 | CameraOff bool `json:"cameraOff"` |
| 126 | // AudioBlocked and NoSoundFrom are the receiving half (spec §15.20): |
| 127 | // what this peer can say about the sound reaching it, so that the |
| 128 | // person talking can be told. Both carry only bad news and are |
| 129 | // omitted when there is none, so an ordinary report is the two-key |
| 130 | // object §15.19 defined and a server from before §15.20 still takes |
| 131 | // it. |
| 132 | // |
| 133 | // AudioBlocked: the browser refused to play remote sound (an autoplay |
| 134 | // policy wanting a click first). |
| 135 | AudioBlocked bool `json:"audioBlocked,omitempty"` |
| 136 | // NoSoundFrom: the peers whose audio is arriving here as silence — |
| 137 | // connected, reporting themselves unmuted, and flat for several |
| 138 | // seconds. At most MaxNoSoundFrom, each a peer id, none repeated. |
| 139 | NoSoundFrom []PeerID `json:"noSoundFrom,omitempty"` |
| 140 | // ScreenStalled (2026-09-28): the peers whose shared screen has stopped |
| 141 | // moving here while they say they are sharing — no frame decoded for |
| 142 | // several seconds. The sharer reads its own id in it as a request to |
| 143 | // recover (a keyframe first). Bad news only, omitted when there is |
| 144 | // none, at most MaxScreenStalled, each a peer id, none repeated. |
| 145 | ScreenStalled []PeerID `json:"screenStalled,omitempty"` |
| 146 | // Hearing (spec §15.24, 2026-10-01): the peers whose audio this client |
| 147 | // is actually receiving and playing — the path from them is |
| 148 | // established, so they can be heard the moment they speak. Unlike the |
| 149 | // members above it is good news, and its presence is itself news: nil |
| 150 | // (the key absent) is a client that does not say, an old one, and an |
| 151 | // empty list is a client that says it hears nobody yet. So it is |
| 152 | // omitted only when nil (omitzero), and an empty list travels as []. |
| 153 | // At most MaxHearing, each a peer id, none repeated. |
| 154 | Hearing []PeerID `json:"hearing,omitzero"` |
| 155 | } |
| 156 | |
| 157 | // MaxNoSoundFrom bounds MediaState.NoSoundFrom: a room holds at most |
| 158 | // sixteen participations (§4.2), so fifteen others is every one there |
| 159 | // could be. |
| 160 | const MaxNoSoundFrom = 15 |
| 161 | |
| 162 | // MaxScreenStalled bounds MediaState.ScreenStalled for the same reason. |
| 163 | const MaxScreenStalled = 15 |
| 164 | |
| 165 | // MaxHearing bounds MediaState.Hearing for the same reason. |
| 166 | const MaxHearing = 15 |
| 167 | |
| 168 | // Equal compares two reports field by field; the peer lists are compared |
| 169 | // in order, which is fine because the hub only asks whether a report |
| 170 | // changed, and a reordered list is a report worth passing on. |
| 171 | func (m MediaState) Equal(o MediaState) bool { |
| 172 | if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked { |
| 173 | return false |
| 174 | } |
| 175 | if (m.Hearing == nil) != (o.Hearing == nil) { |
| 176 | return false |
| 177 | } |
| 178 | return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled) && |
| 179 | samePeers(m.Hearing, o.Hearing) |
| 180 | } |
| 181 | |
| 182 | func samePeers(a, b []PeerID) bool { |
| 183 | if len(a) != len(b) { |
| 184 | return false |
| 185 | } |
| 186 | for i := range a { |
| 187 | if a[i] != b[i] { |
| 188 | return false |
| 189 | } |
| 190 | } |
| 191 | return true |
| 192 | } |
| 193 | |