Merge branch 'feat/mute-indicators' into integrate/native-2026-09-23
Carries each peer's mute and camera-off state in the room (protocol media message, hub state), badges it on web tiles, and on the Mac and iPhone tiles through MutedBadge and Call.peerMedia/isMuted/isCameraOff. Merged without textual conflicts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 files changed,
+1699
−20
Makefile+1 −0callscore/media_test.go+177 −0callscore/room.go+66 −2docs/superpowers/specs/2026-09-07-meet-design.md+68 −0internal/calls/hub/admission.go+2 −2internal/calls/hub/hub.go+4 −0internal/calls/hub/media.go+63 −0internal/calls/hub/media_test.go+361 −0internal/calls/hub/messages.go+2 −0internal/calls/icons/icons.go+2 −0internal/calls/signal.go+58 −6internal/calls/static/calls.css+48 −0internal/calls/static/room.js+187 −2internal/calls/templates/room.html+14 −0internal/callstest/browser_test.go+1 −0internal/callstest/media_test.go+170 −0native/apple/Sources/CallsInterop/Interop.swift+9 −0native/apple/Sources/CallsKit/Call.swift+26 −0native/apple/Sources/CallsKit/MacCallView.swift+8 −3native/apple/Sources/CallsKit/MacVideoTile.swift+4 −1native/apple/Sources/CallsKit/MutedBadge.swift+23 −0native/apple/Sources/CallsKit/Wire.swift+9 −0native/apple/Tests/CallsKitTests/PeerMediaTests.swift+51 −0native/bridge/bridge.go+5 −0native/ios/Sources/CallsPhone.swift+13 −4protocol/messages.go+20 −0test/browser/call.mjs+111 −0test/browser/native.mjs+19 −0test/browser/negotiation.test.mjs+3 −0test/browser/room-mute.test.mjs+174 −0
diff --git a/Makefile b/Makefile| index dbeab73..7a770bd 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -147,6 +147,7 @@ browser: |
| node --test test/browser/negotiation.test.mjs |
| node --test test/browser/chat.test.mjs |
| node --test test/browser/room-share.test.mjs |
| + node --test test/browser/room-mute.test.mjs |
| node --test test/browser/room-lane.test.mjs |
| node --test test/browser/media.test.mjs |
| node --test test/browser/audio.test.mjs |
diff --git a/callscore/media_test.go b/callscore/media_test.go| new file mode 100644 |
| index 0000000..53c1d39 |
| --- /dev/null |
| +++ b/callscore/media_test.go |
| @@ -0,0 +1,177 @@ |
| +package callscore |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/calls/protocol" |
| +) |
| + |
| +// mediaRoom is a Room seeded as connected-but-not-admitted, with every |
| +// POST it makes captured, so the tests below can drive receive() by hand |
| +// and see exactly which reports go out. |
| +func mediaRoom(t *testing.T) (*Room, chan protocol.Signal) { |
| + t.Helper() |
| + posted := make(chan protocol.Signal, 16) |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + var s protocol.Signal |
| + _ = json.NewDecoder(req.Body).Decode(&s) |
| + posted <- s |
| + })) |
| + t.Cleanup(srv.Close) |
| + r, err := New(srv.URL, "room", srv.Client(), func(Event) {}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.started = true |
| + r.state.Peer = "self" |
| + t.Cleanup(r.Close) |
| + return r, posted |
| +} |
| + |
| +func nextMedia(t *testing.T, posted chan protocol.Signal) protocol.MediaState { |
| + t.Helper() |
| + select { |
| + case s := <-posted: |
| + if s.Type != protocol.TypeMedia || s.To != "" || s.Seq != 0 || s.Gen != 0 || s.Peer != "self" { |
| + t.Fatalf("posted %+v, want an untargeted media report", s) |
| + } |
| + var m protocol.MediaState |
| + if err := json.Unmarshal(s.Payload, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + return m |
| + case <-time.After(time.Second): |
| + t.Fatal("no media report was posted") |
| + } |
| + return protocol.MediaState{} |
| +} |
| + |
| +func nothingPosted(t *testing.T, posted chan protocol.Signal, why string) { |
| + t.Helper() |
| + select { |
| + case s := <-posted: |
| + t.Fatalf("%s, but %+v was posted", why, s) |
| + case <-time.After(150 * time.Millisecond): |
| + } |
| +} |
| + |
| +func roster(peers ...protocol.RosterPeer) protocol.Message { |
| + return protocol.Message{Type: protocol.TypeRoster, Roster: &protocol.Roster{Peers: peers}} |
| +} |
| + |
| +// §15.19: the host's own state is reported when it is admitted, after a |
| +// resume whose roster disagrees, and on every change while admitted — |
| +// and not while waiting, where there is nobody to tell. |
| +func TestOwnMediaIsReportedOnAdmissionAndOnChange(t *testing.T) { |
| + r, posted := mediaRoom(t) |
| + |
| + // Muted in the lobby: remembered, not sent. |
| + if err := r.SetMedia(true, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + nothingPosted(t, posted, "a client that is not admitted has nobody to tell") |
| + |
| + // Admitted, with no media for ourselves in the roster: the hub has |
| + // never heard from this participation, so the report goes now. |
| + if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); !m.Muted || m.CameraOff { |
| + t.Fatalf("admission reported %+v, want muted", m) |
| + } |
| + |
| + // A later roster that already says what we say: nothing to send. |
| + if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil { |
| + t.Fatal(err) |
| + } |
| + nothingPosted(t, posted, "a roster that agrees needs no report") |
| + |
| + // A change while admitted goes at once. |
| + if err := r.SetMedia(true, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); !m.Muted || !m.CameraOff { |
| + t.Fatalf("change reported %+v, want muted and camera off", m) |
| + } |
| + |
| + // A resume whose roster is behind — the report was lost while the |
| + // stream was down — is corrected. |
| + if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); !m.Muted || !m.CameraOff { |
| + t.Fatalf("resume reported %+v, want the host's current state", m) |
| + } |
| +} |
| + |
| +// Other peers' state arrives two ways — in the roster and as `media` |
| +// events — and both land on the snapshot's peers, where the native UI |
| +// reads it. A peer that never reported stays nil: unknown, not "on". |
| +func TestRemoteMediaReachesTheSnapshot(t *testing.T) { |
| + r, posted := mediaRoom(t) |
| + if err := r.receive(roster( |
| + protocol.RosterPeer{Peer: "self"}, |
| + protocol.RosterPeer{Peer: "old", Gen: 1}, |
| + protocol.RosterPeer{Peer: "new", Gen: 1, Media: &protocol.MediaState{Muted: true}}, |
| + )); err != nil { |
| + t.Fatal(err) |
| + } |
| + <-posted // our own first report; covered above |
| + before := r.Snapshot() |
| + |
| + if err := r.receive(protocol.Message{Type: protocol.TypeMedia, From: "new", Payload: json.RawMessage(`{"muted":false,"cameraOff":true}`)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A malformed one, and one from nobody, change nothing. |
| + _ = r.receive(protocol.Message{Type: protocol.TypeMedia, From: "old", Payload: json.RawMessage(`"nope"`)}) |
| + _ = r.receive(protocol.Message{Type: protocol.TypeMedia, Payload: json.RawMessage(`{"muted":true,"cameraOff":true}`)}) |
| + |
| + after := r.Snapshot() |
| + byID := map[protocol.PeerID]*protocol.MediaState{} |
| + for _, p := range after.Peers { |
| + byID[p.Peer] = p.Media |
| + } |
| + if m := byID["new"]; m == nil || m.Muted || !m.CameraOff { |
| + t.Fatalf("new = %+v, want unmuted with camera off", m) |
| + } |
| + if byID["old"] != nil { |
| + t.Fatalf("a peer that never reported must stay unknown, got %+v", byID["old"]) |
| + } |
| + // The snapshot taken before is untouched: the event replaced the |
| + // pointer rather than writing through the one it had handed out. |
| + for _, p := range before.Peers { |
| + if p.Peer == "new" && (p.Media == nil || !p.Media.Muted || p.Media.CameraOff) { |
| + t.Fatalf("an earlier snapshot changed under its holder: %+v", p.Media) |
| + } |
| + } |
| + nothingPosted(t, posted, "hearing about somebody else sends nothing") |
| +} |
| + |
| +// A fresh join after a restart starts a new participation the hub knows |
| +// nothing about, so the host's state — kept across it — is reported again. |
| +func TestOwnMediaSurvivesAFreshJoin(t *testing.T) { |
| + r, posted := mediaRoom(t) |
| + _ = r.SetMedia(true, false) |
| + _ = r.receive(roster(protocol.RosterPeer{Peer: "self"})) |
| + nextMedia(t, posted) |
| + |
| + r.fresh() |
| + r.mu.Lock() |
| + r.state.Peer = "self2" |
| + r.mu.Unlock() |
| + _ = r.receive(roster(protocol.RosterPeer{Peer: "self2"})) |
| + select { |
| + case s := <-posted: |
| + var m protocol.MediaState |
| + _ = json.Unmarshal(s.Payload, &m) |
| + if s.Type != protocol.TypeMedia || s.Peer != "self2" || !m.Muted { |
| + t.Fatalf("after a restart posted %+v (%+v), want the kept muted state from the new peer", s, m) |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("no report after the fresh join") |
| + } |
| +} |
diff --git a/callscore/room.go b/callscore/room.go| index 01d240d..a02c121 100644 |
| --- a/callscore/room.go |
| +++ b/callscore/room.go |
| @@ -56,6 +56,10 @@ type Room struct { |
| 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 |
| } |
| func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) { |
| @@ -226,6 +230,60 @@ func pause(ctx context.Context, d time.Duration) bool { |
| 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() |
| + r.media = protocol.MediaState{Muted: muted, CameraOff: cameraOff} |
| + 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.media) |
| + r.mu.Unlock() |
| + if err != nil { |
| + return err |
| + } |
| + return r.Send(protocol.TypeMedia, "", 0, string(b)) |
| +} |
| + |
| +// 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 != r.media |
| + } |
| + } |
| + 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 { |
| @@ -244,7 +302,7 @@ func (r *Room) receive(m protocol.Message) error { |
| r.mu.Unlock() |
| return context.Canceled |
| } |
| - join, ice, reset := false, false, protocol.PeerID("") |
| + join, ice, reset, report := false, false, protocol.PeerID(""), false |
| switch m.Type { |
| case protocol.TypeHello: |
| join = r.state.Peer == "" |
| @@ -278,6 +336,7 @@ func (r *Room) receive(m protocol.Message) error { |
| 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 { |
| @@ -307,6 +366,8 @@ func (r *Room) receive(m protocol.Message) error { |
| delete(r.blocked, m.Peer) |
| } |
| } |
| + case protocol.TypeMedia: |
| + r.applyMediaLocked(m) |
| case protocol.TypeGap: |
| reset = m.From |
| case protocol.TypeLost: |
| @@ -354,6 +415,9 @@ func (r *Room) receive(m protocol.Message) error { |
| 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 { |
| @@ -380,7 +444,7 @@ func (r *Room) Send(typ, to string, expectedGen uint64, payload string) error { |
| return errors.New("room is not connected") |
| } |
| switch typ { |
| - case protocol.TypeJoin, protocol.TypeAdmit, protocol.TypeReset, protocol.TypeChat, protocol.TypeShare, protocol.TypeOffer, protocol.TypeAnswer, protocol.TypeCandidate: |
| + 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") |
| } |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 67a4798..9e3a680 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -567,6 +567,7 @@ a multi-host fleet; that is noted, not solved. |
| | `reset {to}` | an admitted peer | both peers of the pair | Under the hub lock, increment the pair's generation and send `pair {peer, gen}` to both, into their replay rings. Idempotent in effect: two resets yield one higher generation both sides converge on. | |
| | `chat {text}` | an admitted peer | all admitted peers | Fan out with the sender's name. 2 KB. Not stored. | |
| | `share {on}` | an admitted peer | all admitted peers | Fan out so tiles can be labelled. | |
| +| `media {muted, cameraOff}` (2026-09-23, §15.19) | an admitted peer | all other admitted peers | Record on the participation, carry in every later `roster` and `peer`; fan out only a change. | |
| | `hello`, `roster`, `peer`, `gone`, `knock`, `knock-gone`, `full`, `ended`, `lost`, `pair`, `gap`, `state` | server | as named | `roster`, `peer`, `pair` and `gap` go only to admitted peers; a waiting guest receives `hello`, `state`, then `roster` on admission, or `lost`, or `ended`, and nothing else. | |
| `offer`, `answer` and `candidate` also carry `seq` and `gen` (§4.6); |
| @@ -2227,3 +2228,70 @@ match on 2026-09-14, in the same commit. |
| state as of that observation, not asserted as current here. This |
| slice does not touch that queue or announce a deploy of its own, |
| which is checkable from this diff alone. |
| + |
| +### 15.19 §4.3, §4.5 — media state: who is muted, whose camera is off (2026-09-23) |
| + |
| +**Why.** Paul, 2026-09-23: "add mute indicators so that folks can see |
| +when someone is muted." Until now muting disabled the local audio track |
| +and sent silence, and nothing on the wire said so; nobody else could |
| +tell a muted person from a broken microphone. Camera off had the same |
| +gap, and a remote tile showed a black or frozen frame for it. |
| + |
| +**Decision.** A new message type, `media`, that goes both ways and is |
| +**state, not an event**: |
| + |
| +- Upstream, `POST /signal {"peer","type":"media","payload": |
| + {"muted":bool,"cameraOff":bool}}`. Both keys, spelled exactly, each |
| + once, both booleans, nothing else, judged on the raw bytes as §15.15 |
| + judges chat and share (a token walk, not a map). The raw payload is |
| + capped at 128 bytes before decoding (`413 {"error":"too-large"}`); |
| + any other shape, a `to`, a `seq` or a `gen` is |
| + `400 {"error":"bad-request"}`. The sender's authority is `Fanout`'s |
| + (§15.15): admitted, bound to this room and session, a member's |
| + standing re-derived first; a waiting or unjoined participation gets |
| + the same 404 an unknown peer gets. |
| +- The hub records the value on the participation (`Participation.Media`, |
| + nil until the first report). **Every roster and every `peer` event |
| + carries it**, as `media` on the `RosterPeer`, for every admitted peer |
| + including the recipient itself. That is what makes a late joiner, a |
| + resume (§4.5) and a restart correct without any new replay rule: |
| + nothing about `media` goes in the replay ring, because the roster a |
| + resume is answered with is already the authority, exactly as it is |
| + for who is present. |
| +- Downstream, a **change** is fanned out to every other admitted peer as |
| + `{type:"media", from, name, payload:{"muted","cameraOff"}}`, the |
| + envelope rebuilt from the sender's participation and the payload |
| + re-encoded from the two validated booleans. A report equal to what the |
| + hub already holds is accepted (200) and sends nothing, so clients may |
| + repeat it freely. |
| + |
| +**What a client does.** It keeps its own state locally and reports it |
| +whenever a roster's entry for itself disagrees or is missing — which is |
| +admission, a resume after a report was lost, and a fresh join after a |
| +restart — and at once on each toggle while admitted. Before admission it |
| +sends nothing. `muted` means the person's microphone is not being sent |
| +(toggled off, or there is no microphone track at all); `cameraOff` the |
| +same for the camera. Both are the sender's own claim about what it is |
| +choosing to send. The server never sees media (§0.1) and cannot check |
| +them, and nothing depends on them being true: a peer that lies about |
| +being muted is heard anyway. |
| + |
| +**Unknown is not muted.** A `RosterPeer` with no `media` key is a peer |
| +that has not reported — every client that predates this section, and |
| +every peer for the moment between admission and its first report. |
| +Clients show that as neither muted nor camera-off. The zero value is |
| +not used for it, because `{"muted":false,"cameraOff":false}` is a real |
| +report and must be told apart from silence. |
| + |
| +**Mixed versions.** An old client ignores the `media` key on a roster |
| +and the `media` event type (the web client's dispatch and the native |
| +core both drop unknown types), so it never breaks and simply never |
| +shows or sends indicators; the people talking to it see it as unknown. |
| +A new client against an old server has its report refused |
| +`400 {"error":"bad-request"}` as an unknown type; it does not retry a |
| +400 and shows nothing. Server and clients should still deploy together |
| +for the feature to exist, but no order of deployment breaks a call. |
| + |
| +**Not logged, not stored.** Like every accepted message, a report logs |
| +nothing; a refused one logs type, room, peer and outcome only (§4.4). |
| +The state lives on the in-memory participation and dies with it (§2.2). |
diff --git a/internal/calls/hub/admission.go b/internal/calls/hub/admission.go| index f736614..0bc7871 100644 |
| --- a/internal/calls/hub/admission.go |
| +++ b/internal/calls/hub/admission.go |
| @@ -31,7 +31,7 @@ func (h *Hub) rosterFor(r *room, viewer *Participation) *Roster { |
| if !p.Admitted { |
| continue |
| } |
| - rp := RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String()} |
| + rp := RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Media: p.Media} |
| if p.Peer != viewer.Peer { |
| rp.Gen = h.genLocked(r, viewer.Peer, p.Peer) |
| } |
| @@ -77,7 +77,7 @@ func (h *Hub) admitLocked(r *room, p *Participation) bool { |
| // generation with the newcomer; browsers read it from |
| // roster.peers[0]. |
| q.sub.send(Message{Type: TypePeer, Peer: p.Peer, Name: p.Name, |
| - Roster: &Roster{Peers: []RosterPeer{{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Gen: h.genLocked(r, q.Peer, p.Peer)}}}}) |
| + Roster: &Roster{Peers: []RosterPeer{{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Gen: h.genLocked(r, q.Peer, p.Peer), Media: p.Media}}}}) |
| } |
| } |
| if wasWaiting { |
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go| index 0ef0aed..59c3619 100644 |
| --- a/internal/calls/hub/hub.go |
| +++ b/internal/calls/hub/hub.go |
| @@ -98,6 +98,10 @@ type Participation struct { |
| Joined bool // has posted join (waiting or admitted) |
| JoinedAt time.Time |
| OpenedAt time.Time |
| + // Media is the peer's last report of its own microphone and camera |
| + // (§15.19), nil until it makes one. It lives and dies with the |
| + // participation, so a resume keeps it and a fresh join starts unknown. |
| + Media *MediaState |
| sub *Sub |
| resumeUntil time.Time // zero while a stream is attached |
diff --git a/internal/calls/hub/media.go b/internal/calls/hub/media.go| new file mode 100644 |
| index 0000000..6504a0d |
| --- /dev/null |
| +++ b/internal/calls/hub/media.go |
| @@ -0,0 +1,63 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| +) |
| + |
| +// SetMedia is §15.19: an admitted peer reports its own microphone and |
| +// camera state. The hub records it on the participation, where every |
| +// later roster — admission, resume, a kind change — carries it, and |
| +// fans a change out to every other admitted peer as |
| +// {type: "media", from, name, payload: MediaState}, the envelope |
| +// rebuilt from the sender's participation exactly as Fanout's is. |
| +// |
| +// It is state, not an event. A report that changes nothing is accepted |
| +// and sends nothing, so a client may repeat it freely — on every roster, |
| +// after every resume — and the room hears only changes. Nothing goes in |
| +// a replay ring: a peer that missed a `media` while detached learns the |
| +// current state from the roster its resume is answered with (§4.5), |
| +// which is the authority a ring would only have approximated. |
| +// |
| +// The sender's authority is Fanout's: a member re-derives standing |
| +// first (§15.12) and one whose standing has gone is ended here and |
| +// answered ErrNotYours; a guest never consults standing (ruling 14); a |
| +// participation not yet admitted is refused ErrNotAdmitted, because a |
| +// waiting guest has nobody to tell and learns nothing about the room by |
| +// asking (ruling 9). |
| +func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m MediaState) error { |
| + gone, err := h.memberStandingGone(ctx, sqid, session, from, true) |
| + if err != nil { |
| + return err |
| + } |
| + if gone { |
| + return ErrNotYours |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + r, ok := h.rooms[sqid] |
| + if !ok { |
| + return ErrUnknownPeer |
| + } |
| + p, found := h.findLocked(sqid, session, from) |
| + if !found { |
| + return ErrUnknownPeer |
| + } |
| + if !p.Admitted { |
| + return ErrNotAdmitted |
| + } |
| + if p.Media != nil && *p.Media == m { |
| + return nil |
| + } |
| + // A fresh value, never a write through the old pointer: rosters |
| + // already sent hold that one. |
| + next := m |
| + p.Media = &next |
| + payload, err := json.Marshal(next) |
| + if err != nil { |
| + return err |
| + } |
| + out := Message{Type: TypeMedia, From: p.Peer, Name: p.Name, Payload: payload} |
| + h.broadcastLocked(r, out, func(q *Participation) bool { return q.Admitted && q.Peer != p.Peer }) |
| + return nil |
| +} |
diff --git a/internal/calls/hub/media_test.go b/internal/calls/hub/media_test.go| new file mode 100644 |
| index 0000000..6890c14 |
| --- /dev/null |
| +++ b/internal/calls/hub/media_test.go |
| @@ -0,0 +1,361 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// rosterMedia finds one peer's media in a roster, and whether the peer |
| +// is in it at all. |
| +func rosterMedia(ro *Roster, peer PeerID) (*MediaState, bool) { |
| + if ro == nil { |
| + return nil, false |
| + } |
| + for _, rp := range ro.Peers { |
| + if rp.Peer == peer { |
| + return rp.Media, true |
| + } |
| + } |
| + return nil, false |
| +} |
| + |
| +// TestSetMediaFansOutAChangeAndNothingElse is §15.19's delivery: every |
| +// other admitted peer hears a change, in an envelope the hub built, and |
| +// nobody else hears anything — not the sender, not a waiting guest, and |
| +// nothing in any ring. A repeat of the same state is accepted and silent. |
| +func TestSetMediaFansOutAChangeAndNothingElse(t *testing.T) { |
| + now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b.Peer) |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) // waiting |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + drain(g.Sub) |
| + |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := drain(b.Sub) |
| + if len(got) != 1 { |
| + t.Fatalf("Bob got %d events, want 1: %v", len(got), types(got)) |
| + } |
| + m := got[0] |
| + if m.Type != TypeMedia || m.From != a.Peer || m.Name != "Ann" { |
| + t.Fatalf("Bob got %+v, want media from Ann", m) |
| + } |
| + if string(m.Payload) != `{"muted":true,"cameraOff":false}` { |
| + t.Fatalf("payload %s, want the canonical two booleans", m.Payload) |
| + } |
| + if m.To != "" || m.Seq != 0 || m.Gen != 0 || m.Roster != nil { |
| + t.Fatalf("Bob got %+v, want nothing but type, from, name and payload", m) |
| + } |
| + if n := len(drain(a.Sub)); n != 0 { |
| + t.Fatalf("the sender must not hear its own report, got %d", n) |
| + } |
| + if n := len(drain(g.Sub)); n != 0 { |
| + t.Fatalf("a waiting guest must hear nothing, got %d", n) |
| + } |
| + if n := ringLen(h, "r1", "sb", b.Peer); n != 0 { |
| + t.Fatalf("media is state, never ringed; ring has %d", n) |
| + } |
| + |
| + // The same state again: accepted, and the room hears nothing. |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := len(drain(b.Sub)); n != 0 { |
| + t.Fatalf("a report that changes nothing must send nothing, got %d", n) |
| + } |
| + // A different state is a change again. |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true, CameraOff: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got = drain(b.Sub) |
| + if len(got) != 1 || string(got[0].Payload) != `{"muted":true,"cameraOff":true}` { |
| + t.Fatalf("camera off should be one more media event, got %v", got) |
| + } |
| +} |
| + |
| +// TestRostersCarryMediaAndUnknownIsAbsent is the "state, not event" |
| +// half: a peer admitted after the report learns it from its roster, the |
| +// room learns a newcomer's from the peer event once it has one, and a |
| +// peer that has never reported — every client older than §15.19 — has |
| +// no media at all rather than a guessed false. |
| +func TestRostersCarryMediaAndUnknownIsAbsent(t *testing.T) { |
| + now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b.Peer) // never reports: an old client |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + |
| + c, _ := h.Open("r1", "sc", 3, "Cy", KindMember) |
| + h.Join(ctx, "r1", "sc", c.Peer) |
| + cev := drain(c.Sub) |
| + var ro *Roster |
| + for _, m := range cev { |
| + if m.Type == TypeRoster { |
| + ro = m.Roster |
| + } |
| + } |
| + am, ok := rosterMedia(ro, a.Peer) |
| + if !ok || am == nil || !am.Muted || am.CameraOff { |
| + t.Fatalf("Cy's roster should say Ann is muted, got %+v (present %v)", am, ok) |
| + } |
| + bm, ok := rosterMedia(ro, b.Peer) |
| + if !ok || bm != nil { |
| + t.Fatalf("Bob never reported, so his roster entry must carry no media, got %+v (present %v)", bm, ok) |
| + } |
| + // And on the wire, absent rather than a zero object: that is what |
| + // an old client ignores and a new one reads as unknown. |
| + raw, _ := json.Marshal(RosterPeer{Peer: b.Peer, Name: "Bob", Kind: "member"}) |
| + if string(raw) != `{"peer":"`+string(b.Peer)+`","name":"Bob","kind":"member"}` { |
| + t.Fatalf("an unreported peer marshals as %s, want no media key", raw) |
| + } |
| + |
| + // The newcomer's own first report reaches everyone already there, |
| + // the old client included: it ignores the type, and it is not the |
| + // hub's business to know that. |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + if err := h.SetMedia(ctx, "r1", "sc", c.Peer, MediaState{CameraOff: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, s := range []*Sub{a.Sub, b.Sub} { |
| + ev := drain(s) |
| + if len(ev) != 1 || ev[0].Type != TypeMedia || ev[0].From != c.Peer { |
| + t.Fatalf("Cy's report should reach both others once, got %v", types(ev)) |
| + } |
| + } |
| +} |
| + |
| +// TestPeerEventCarriesMediaFromTheParticipation: the `peer` event is |
| +// built from the participation, like the roster, so whatever media the |
| +// participation holds at admission goes with it. Today a report needs |
| +// admission first, so this is nil in practice — the test pins the |
| +// contract so that loosening that rule later cannot forget the event. |
| +func TestPeerEventCarriesMediaFromTheParticipation(t *testing.T) { |
| + now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + drain(a.Sub) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + // Bob's media is set directly under the lock, as if he had reported |
| + // before this admission; the hub's own paths only allow a report once |
| + // admitted, so this is the peer event's contract, not a flow. |
| + h.mu.Lock() |
| + p, _ := h.findLocked("r1", "sb", b.Peer) |
| + p.Media = &MediaState{Muted: true} |
| + h.mu.Unlock() |
| + h.Join(ctx, "r1", "sb", b.Peer) |
| + ev := drain(a.Sub) |
| + if len(ev) != 1 || ev[0].Type != TypePeer { |
| + t.Fatalf("Ann should get one peer event, got %v", types(ev)) |
| + } |
| + bm, ok := rosterMedia(ev[0].Roster, b.Peer) |
| + if !ok || bm == nil || !bm.Muted { |
| + t.Fatalf("the peer event should carry Bob's media, got %+v", bm) |
| + } |
| +} |
| + |
| +// TestResumeRosterCarriesMediaMissedWhileDetached: a change made while a |
| +// peer's stream is down is not ringed and not replayed, and it does not |
| +// need to be — the roster the resume is answered with says it. |
| +func TestResumeRosterCarriesMediaMissedWhileDetached(t *testing.T) { |
| + now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b.Peer) |
| + if err := h.SetMedia(ctx, "r1", "sb", b.Peer, MediaState{Muted: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + |
| + h.Detach("r1", b.Peer, b.Sub) |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := ringLen(h, "r1", "sb", b.Peer); n != 0 { |
| + t.Fatalf("a media report to a detached peer must not be ringed, ring has %d", n) |
| + } |
| + res, err := h.Resume(ctx, "r1", "sb", b.Peer, map[PeerID]uint64{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(res.Replay) != 0 || len(res.Gap) != 0 { |
| + t.Fatalf("nothing to replay and no gap, got %d replayed and %d gaps", len(res.Replay), len(res.Gap)) |
| + } |
| + am, _ := rosterMedia(res.Roster, a.Peer) |
| + if am == nil || !am.Muted { |
| + t.Fatalf("the resume roster should say Ann is muted, got %+v", am) |
| + } |
| + // Bob's own state survived his detach: the participation holds it. |
| + bm, _ := rosterMedia(res.Roster, b.Peer) |
| + if bm == nil || !bm.Muted { |
| + t.Fatalf("Bob's own report should survive his resume, got %+v", bm) |
| + } |
| + |
| + // A leave ends the participation, and with it the state: a fresh |
| + // join starts unknown. |
| + if err := h.Leave("r1", "sb", b.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + b2, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b2.Peer) |
| + ev := drain(a.Sub) |
| + var fresh *Roster |
| + for _, m := range ev { |
| + if m.Type == TypePeer && m.Peer == b2.Peer { |
| + fresh = m.Roster |
| + } |
| + } |
| + if bm, ok := rosterMedia(fresh, b2.Peer); !ok || bm != nil { |
| + t.Fatalf("a rejoin must start with no media, got %+v (present %v)", bm, ok) |
| + } |
| +} |
| + |
| +// TestSetMediaAuthority is Fanout's authority, reused: bound to this |
| +// room and session, admitted, and a member's standing re-derived. |
| +func TestSetMediaAuthority(t *testing.T) { |
| + now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| + standing := map[int64]bool{1: true, 2: true, 100: true} |
| + h := New(Config{ |
| + Now: func() time.Time { return now }, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + if id >= 100 { |
| + return KindGuest, standing[id], nil |
| + } |
| + return KindMember, standing[id], nil |
| + }, |
| + }) |
| + ctx := context.Background() |
| + m := MediaState{Muted: true} |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotAdmitted) { |
| + t.Fatalf("unjoined: %v", err) |
| + } |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + if err := h.SetMedia(ctx, "r1", "sb", a.Peer, m); !errors.Is(err, ErrUnknownPeer) { |
| + t.Fatalf("another session: %v", err) |
| + } |
| + if err := h.SetMedia(ctx, "r2", "sa", a.Peer, m); !errors.Is(err, ErrUnknownPeer) { |
| + t.Fatalf("another room: %v", err) |
| + } |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + if err := h.SetMedia(ctx, "r1", "sg", g.Peer, m); !errors.Is(err, ErrNotAdmitted) { |
| + t.Fatalf("waiting guest: %v", err) |
| + } |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b.Peer) |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + |
| + standing[1] = false |
| + if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotYours) { |
| + t.Fatalf("member whose standing is gone: %v", err) |
| + } |
| + ev := drain(b.Sub) |
| + if len(ev) != 1 || ev[0].Type != TypeGone { |
| + t.Fatalf("Bob should see Ann gone and no media, got %v", types(ev)) |
| + } |
| +} |
| + |
| +// TestSetMediaUnderConcurrency is for `make race`: reports, resumes, |
| +// rosters and a tick all at once, over the same participations. Media |
| +// is a pointer handed to rosters that are serialised on other |
| +// goroutines, so a write through it anywhere would show up here. |
| +func TestSetMediaUnderConcurrency(t *testing.T) { |
| + now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| + var clock sync.Mutex |
| + h := New(Config{ |
| + Now: func() time.Time { |
| + clock.Lock() |
| + defer clock.Unlock() |
| + return now |
| + }, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + return KindMember, true, nil |
| + }, |
| + }) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b.Peer) |
| + |
| + var wg sync.WaitGroup |
| + stop := make(chan struct{}) |
| + // Readers: marshal whatever arrives, the way the stream handler does. |
| + for _, s := range []*Sub{a.Sub, b.Sub} { |
| + wg.Add(1) |
| + go func(s *Sub) { |
| + defer wg.Done() |
| + for { |
| + select { |
| + case <-stop: |
| + return |
| + case m, ok := <-s.C(): |
| + if !ok { |
| + return |
| + } |
| + _, _ = json.Marshal(m) |
| + } |
| + } |
| + }(s) |
| + } |
| + var writers sync.WaitGroup |
| + for i, who := range []struct { |
| + session string |
| + peer PeerID |
| + }{{"sa", a.Peer}, {"sb", b.Peer}} { |
| + writers.Add(1) |
| + go func(i int, session string, peer PeerID) { |
| + defer writers.Done() |
| + for n := 0; n < 200; n++ { |
| + _ = h.SetMedia(ctx, "r1", session, peer, MediaState{Muted: n%2 == 0, CameraOff: (n+i)%3 == 0}) |
| + } |
| + }(i, who.session, who.peer) |
| + } |
| + writers.Add(1) |
| + go func() { |
| + defer writers.Done() |
| + for n := 0; n < 50; n++ { |
| + h.Tick(ctx) |
| + c, err := h.Open("r1", "sc", 3, "Cy", KindMember) |
| + if err == nil { |
| + _ = h.Join(ctx, "r1", "sc", c.Peer) |
| + _, _ = json.Marshal(drain(c.Sub)) |
| + _ = h.Leave("r1", "sc", c.Peer) |
| + } |
| + } |
| + }() |
| + writers.Wait() |
| + close(stop) |
| + wg.Wait() |
| +} |
diff --git a/internal/calls/hub/messages.go b/internal/calls/hub/messages.go| index f32ab43..45f3353 100644 |
| --- a/internal/calls/hub/messages.go |
| +++ b/internal/calls/hub/messages.go |
| @@ -9,6 +9,7 @@ type Reason = protocol.Reason |
| type Message = protocol.Message |
| type Roster = protocol.Roster |
| type RosterPeer = protocol.RosterPeer |
| +type MediaState = protocol.MediaState |
| const ( |
| KindGuest = protocol.KindGuest |
| @@ -27,6 +28,7 @@ const ( |
| TypeReset = protocol.TypeReset |
| TypeChat = protocol.TypeChat |
| TypeShare = protocol.TypeShare |
| + TypeMedia = protocol.TypeMedia |
| TypeHello = protocol.TypeHello |
| TypeRoster = protocol.TypeRoster |
| TypePeer = protocol.TypePeer |
diff --git a/internal/calls/icons/icons.go b/internal/calls/icons/icons.go| index 0289e41..a1f5590 100644 |
| --- a/internal/calls/icons/icons.go |
| +++ b/internal/calls/icons/icons.go |
| @@ -21,7 +21,9 @@ import "html/template" |
| var icons = map[string]template.HTML{ |
| "mic": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>`, |
| + "mic-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 10v2a7 7 0 0 0 12 5M15 9.34V5a3 3 0 0 0-5.68-1.33M9 9v3a3 3 0 0 0 5.12 2.12M18.89 13.23A7.12 7.12 0 0 0 19 12v-2M12 19v3M2 2l20 20"/></svg>`, |
| "video": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>`, |
| + "video-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.66 6H14a2 2 0 0 1 2 2v2.5l5.25-3.06A.5.5 0 0 1 22 7.87v8.2M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2M2 2l20 20"/></svg>`, |
| "monitor": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`, |
| "message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`, |
| "sliders-horizontal": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg>`, |
diff --git a/internal/calls/signal.go b/internal/calls/signal.go| index ef60b7a..ae2608f 100644 |
| --- a/internal/calls/signal.go |
| +++ b/internal/calls/signal.go |
| @@ -137,7 +137,17 @@ func validateFanout(typ string, raw json.RawMessage) (json.RawMessage, int, stri |
| // {"text":"b"}, and the first of those is a payload whose raw bytes say |
| // one thing and whose decoded value says another. The walk counts every |
| // member it passes, so it can refuse both. |
| -func exactKey(raw json.RawMessage, want string) bool { |
| +func exactKey(raw json.RawMessage, want string) bool { return exactKeys(raw, want) } |
| + |
| +// exactKeys is exactKey for an object with more than one member: every |
| +// name in want, each spelled byte-for-byte and appearing exactly once, |
| +// in any order, and no other member at all. |
| +func exactKeys(raw json.RawMessage, want ...string) bool { |
| + seen := make(map[string]bool, len(want)) |
| + allowed := make(map[string]bool, len(want)) |
| + for _, w := range want { |
| + allowed[w] = true |
| + } |
| dec := json.NewDecoder(bytes.NewReader(raw)) |
| open, err := dec.Token() |
| if err != nil { |
| @@ -153,13 +163,11 @@ func exactKey(raw json.RawMessage, want string) bool { |
| return false |
| } |
| name, ok := key.(string) |
| - if !ok || name != want { |
| + if !ok || !allowed[name] || seen[name] { |
| return false |
| } |
| + seen[name] = true |
| members++ |
| - if members > 1 { |
| - return false |
| - } |
| // The value, whatever shape it takes: read past it so the next |
| // token is the next key or the closing brace. Its type is the |
| // business of the decode above, not of this walk. |
| @@ -173,7 +181,36 @@ func exactKey(raw json.RawMessage, want string) bool { |
| if _, err := dec.Token(); err != nil { |
| return false |
| } |
| - return members == 1 && !dec.More() |
| + return members == len(want) && !dec.More() |
| +} |
| + |
| +// maxMediaBytes bounds §15.19's payload before it is decoded. The |
| +// longest honest one, {"muted":false,"cameraOff":false}, is 34 bytes; |
| +// the rest is room for whitespace a client's encoder might add, and |
| +// nothing a peer can use to put more on anyone's stream — what goes out |
| +// is re-encoded from two booleans. |
| +const maxMediaBytes = 128 |
| + |
| +// validateMedia is §15.19's shape: exactly {"muted": bool, "cameraOff": |
| +// bool}, both present, keys byte-exact and each once, no other member, |
| +// judged on the raw bytes the same way validateFanout judges chat and |
| +// share (Codex S4-1). It returns the decoded state, or a status and an |
| +// outcome word for the refusal log. |
| +func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) { |
| + if len(raw) > maxMediaBytes { |
| + return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size" |
| + } |
| + if !exactKeys(raw, "muted", "cameraOff") { |
| + return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| + } |
| + var p struct { |
| + Muted *bool `json:"muted"` |
| + CameraOff *bool `json:"cameraOff"` |
| + } |
| + if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil { |
| + return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| + } |
| + return protocol.MediaState{Muted: *p.Muted, CameraOff: *p.CameraOff}, 0, "" |
| } |
| // canonicalJSON encodes one validated value the way it goes out on the |
| @@ -214,6 +251,7 @@ var knownSignalTypes = map[string]bool{ |
| hub.TypeJoin: true, hub.TypeAdmit: true, hub.TypeLeave: true, |
| hub.TypeOffer: true, hub.TypeAnswer: true, hub.TypeCandidate: true, |
| hub.TypeReset: true, hub.TypeChat: true, hub.TypeShare: true, |
| + hub.TypeMedia: true, |
| } |
| func safeSignalType(t string) string { |
| @@ -352,6 +390,20 @@ func (a *app) signal(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| herr = a.hub.Fanout(r.Context(), sqid, session, body.Peer, hub.Message{Type: body.Type, Payload: payload}) |
| + case hub.TypeMedia: |
| + // §15.19: state about the sender, never targeted, and never |
| + // carrying a seq or a generation — none of them means anything |
| + // for a message the hub records rather than relays. |
| + if body.To != "" || body.Seq != 0 || body.Gen != 0 { |
| + a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| + return |
| + } |
| + m, status, outcome := validateMedia(body.Payload) |
| + if status != 0 { |
| + a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)}) |
| + return |
| + } |
| + herr = a.hub.SetMedia(r.Context(), sqid, session, body.Peer, m) |
| default: |
| a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type", map[string]any{"error": "bad-request"}) |
| return |
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index da333b5..782e9a8 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -160,6 +160,54 @@ |
| cursor: pointer; |
| } |
| +/* Spec §15.19: a muted peer's badge sits beside their name — the |
| + slashed microphone and the vendored badge's word, so the state is a |
| + shape and a word and never a colour. It is a flex item of the caption |
| + row, so it never covers the name. */ |
| +.calls-tile-muted { |
| + display: inline-flex; |
| + align-items: center; |
| + gap: var(--rst-sp-1); |
| + flex: none; |
| +} |
| + |
| +.calls-tile-muted .icon { |
| + inline-size: 1em; |
| + block-size: 1em; |
| +} |
| + |
| +.calls-tile-muted[hidden], |
| +.calls-tile-off[hidden] { |
| + display: none; |
| +} |
| + |
| +/* A camera that is off sends black frames, or holds the last one. The |
| + cover says what that is instead of leaving a frozen face or a black |
| + rectangle to be read as a fault. It lies over the video rather than |
| + hiding it: the element is also what plays the peer's audio. */ |
| +.calls-tile-off { |
| + position: absolute; |
| + inset: 0; |
| + display: flex; |
| + flex-direction: column; |
| + align-items: center; |
| + justify-content: center; |
| + gap: var(--rst-sp-2); |
| + background: var(--rst-surface-2); |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| +} |
| + |
| +.calls-tile-off .icon { |
| + inline-size: 2rem; |
| + block-size: 2rem; |
| +} |
| + |
| +/* The caption stays above the cover. */ |
| +.calls-tile-camera-off .calls-tile-name { |
| + z-index: 1; |
| +} |
| + |
| /* Never colour alone: a failed connection says so in words, and the |
| weight is only a second cue on top of the sentence. */ |
| .calls-tile-note { |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index 8b3b909..49e10a5 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -27,6 +27,9 @@ |
| // §6 chat the broadcast lane and the panel: chat.js owns |
| // the words and the log, this file owns the wire |
| // and when the panel is usable |
| +// §15.19 media state who is muted and whose camera is off: our own |
| +// reported on the broadcast lane, everyone else's |
| +// read from the roster and `media` |
| // |
| // House rules this file is written under: no library, no inline script |
| // (the page's CSP is `default-src 'self'`), nothing built with |
| @@ -64,6 +67,13 @@ |
| const muteState = $('mute-state'); |
| const cameraState = $('camera-state'); |
| const shareState = $('share-state'); |
| + // §15.19: the tile marks, rendered by the server so they are the |
| + // vendored badge and the app's own icons rather than a copy of either, |
| + // and the polite region that says a change out loud. #status is no |
| + // use for that: renderStatus rewrites it on every render. |
| + const mutedMark = $('tile-muted'); |
| + const cameraOffMark = $('tile-camera-off'); |
| + const mediaLive = $('media-live'); |
| // The chat panel's root (§6). chat.js owns everything inside it; this |
| // file owns the wire under it and the moment it becomes usable. |
| const chatEl = $('chat'); |
| @@ -241,6 +251,12 @@ |
| const names = new Map(); // peer id -> display name, from the server only |
| const frames = new Map(); // peer id -> frames rendered, for the test hook |
| const tiles = new Map(); // tile key -> {figure, video} |
| + // §15.19: peer id -> {muted, cameraOff}, for the peers that have |
| + // reported. A peer absent here is unknown and is drawn as neither. |
| + const remoteMedia = new Map(); |
| + // The state this tab last put on the broadcast lane, as mediaKey |
| + // writes it, so a toggle that changes nothing sends nothing. |
| + let mediaSent = ''; |
| // §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt` |
| // and `lastFetchAt` are all milliseconds since the epoch: the server |
| @@ -504,6 +520,11 @@ |
| muteButton.disabled = !hasAudio; |
| cameraButton.disabled = !hasVideo; |
| } |
| + // §15.19: every path that can change what we send ends here — the |
| + // two toggles, a device switch, a stream that lost a track — so the |
| + // report is made here too, and the tile marks follow. |
| + reportMedia(); |
| + if (tiles.has('self')) markTile('self', localMedia()); |
| } |
| function stopLocal() { |
| @@ -838,7 +859,10 @@ |
| // §4.3's fan-out pair. They carry no `to`, no `seq` and no `gen`, so |
| // nothing about them goes through §4.6's inbound rules: the hub |
| // rebuilt the envelope and there is nothing to dedupe against. |
| - 'chat', 'share']; |
| + 'chat', 'share', |
| + // §15.19: one peer's media state changed. State, not an event — |
| + // the roster carries it too — so a missed one costs nothing. |
| + 'media']; |
| function streamPath(peer) { |
| let path = '/m/' + encodeURIComponent(sqid) + '/stream'; |
| @@ -1140,6 +1164,7 @@ |
| case 'candidate': return onCandidate(msg); |
| case 'chat': return onChat(msg); |
| case 'share': return onShare(msg); |
| + case 'media': return onMedia(msg); |
| default: return undefined; |
| } |
| } |
| @@ -1207,12 +1232,14 @@ |
| const rp = (msg.roster && msg.roster.peers && msg.roster.peers[0]) || {}; |
| const g = rp.gen || 1; |
| if (rp.name) names.set(id, rp.name); |
| + learnMedia(id, rp.media); |
| ensurePC(id, g); |
| renderTiles(); |
| } |
| function onGone(msg) { |
| if (!msg.from) return; |
| + remoteMedia.delete(msg.from); |
| closePC(msg.from, true); |
| dropWaiting(msg.from); |
| renderTiles(); |
| @@ -1390,6 +1417,8 @@ |
| // roster costs one `setEnabled(true)` and nothing else. |
| mountChat(); |
| chatEnabled(true); |
| + // §15.19: before the tiles are drawn, so they are drawn right. |
| + rosterMedia(roster); |
| renderTiles(); |
| renderQueue(); |
| renderStatus(); |
| @@ -3879,6 +3908,144 @@ |
| return out; |
| } |
| + // --------------------------------------------------------------- |
| + // Media state (§15.19): who is muted, whose camera is off |
| + // --------------------------------------------------------------- |
| + // |
| + // State, not an event. The hub keeps each peer's last report and puts |
| + // it in every roster, so what a tile shows is always the roster's word |
| + // or a `media` since; nothing here is replayed or reconstructed. A |
| + // peer that never reports — a client from before this — is unknown, |
| + // and unknown is drawn as neither muted nor camera-off. |
| + |
| + // mediaFrom accepts exactly the two booleans and nothing looser, for a |
| + // roster entry or a `media` payload alike. Pure, and lifted by name in |
| + // the node test. |
| + function mediaFrom(value) { |
| + if (!value || typeof value !== 'object') return null; |
| + if (typeof value.muted !== 'boolean' || typeof value.cameraOff !== 'boolean') return null; |
| + return { muted: value.muted, cameraOff: value.cameraOff }; |
| + } |
| + |
| + function sameMedia(a, b) { |
| + return !!a && !!b && a.muted === b.muted && a.cameraOff === b.cameraOff; |
| + } |
| + |
| + function mediaKey(m) { |
| + return (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-'); |
| + } |
| + |
| + // localMedia is what this tab is actually sending. No microphone at |
| + // all is muted as far as anyone listening can tell, and no camera is |
| + // off; the preference alone would claim a microphone that is not there. |
| + function localMedia() { |
| + const hasAudio = !!(local && local.getAudioTracks().length); |
| + const hasVideo = !!(local && local.getVideoTracks().length); |
| + return { muted: !!prefs.muted || !hasAudio, cameraOff: !!prefs.cameraOff || !hasVideo }; |
| + } |
| + |
| + // reportMedia puts our state on the broadcast lane, once per change, |
| + // and only once admitted: before that the hub refuses it, and the |
| + // roster that admits us is where it is sent from. A refusal forgets |
| + // what was sent, so the next toggle or roster tries again — which |
| + // against a server from before §15.19 is a quiet 400 each time. |
| + function reportMedia() { |
| + if (!state.admitted || state.stopped || !state.peer) return; |
| + const now = localMedia(); |
| + const key = mediaKey(now); |
| + if (key === mediaSent) return; |
| + mediaSent = key; |
| + broadcast('media', now).then((ok) => { |
| + if (!ok && mediaSent === key) mediaSent = ''; |
| + }); |
| + } |
| + |
| + // rosterMedia takes a roster's word on everybody's media. On ours as |
| + // well: missing is a participation the hub has never heard from — a |
| + // fresh join, or a restart — and different is a report lost while the |
| + // stream was down, and either way it is told again. |
| + function rosterMedia(roster) { |
| + for (const rp of roster.peers || []) { |
| + if (!rp || !rp.peer) continue; |
| + if (rp.peer !== state.peer) { |
| + learnMedia(rp.peer, rp.media); |
| + continue; |
| + } |
| + const now = localMedia(); |
| + mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : ''; |
| + } |
| + reportMedia(); |
| + } |
| + |
| + // learnMedia records a roster's or a peer event's word on one peer. |
| + // Absent is unknown, and forgets anything older: the roster is the |
| + // authority, and a participation that has not reported has not. |
| + function learnMedia(id, value) { |
| + const m = mediaFrom(value); |
| + if (m) remoteMedia.set(id, m); |
| + else remoteMedia.delete(id); |
| + } |
| + |
| + // onMedia is one peer's change. Only a peer we hold a connection to |
| + // is drawn; the roster will carry the rest. |
| + function onMedia(msg) { |
| + const id = msg.from; |
| + const next = mediaFrom(msg.payload); |
| + if (!id || id === state.peer || !next) return; |
| + const before = remoteMedia.get(id) || null; |
| + remoteMedia.set(id, next); |
| + announceMedia(mediaChange(msg.name || names.get(id) || 'Someone', before, next)); |
| + markTile(id, next); |
| + } |
| + |
| + // mediaChange is the sentence a change earns, or '' for none. A first |
| + // report is not news — it is somebody arriving the way they arrived — |
| + // so only a known state that moved is said out loud. Pure, and lifted |
| + // by name in the node test. |
| + function mediaChange(name, before, after) { |
| + if (!before || !after) return ''; |
| + const parts = []; |
| + if (before.muted !== after.muted) { |
| + parts.push(name + (after.muted ? ' muted their microphone.' : ' unmuted their microphone.')); |
| + } |
| + if (before.cameraOff !== after.cameraOff) { |
| + parts.push(name + (after.cameraOff ? ' turned their camera off.' : ' turned their camera on.')); |
| + } |
| + return parts.join(' '); |
| + } |
| + |
| + function announceMedia(text) { |
| + if (!text || !mediaLive) return; |
| + mediaLive.textContent = text; |
| + } |
| + |
| + // tileMarks builds one tile's pair of marks from the server's |
| + // templates: the muted badge beside the name, and the camera-off cover |
| + // over the video. The cover sits over the element rather than hiding |
| + // it — the element is also what plays the peer's audio. |
| + function tileMarks() { |
| + const muted = document.createElement('span'); |
| + muted.className = 'calls-tile-muted'; |
| + muted.hidden = true; |
| + if (mutedMark) muted.append(mutedMark.content.cloneNode(true)); |
| + else muted.textContent = 'Muted'; |
| + const off = document.createElement('div'); |
| + off.className = 'calls-tile-off'; |
| + off.hidden = true; |
| + if (cameraOffMark) off.append(cameraOffMark.content.cloneNode(true)); |
| + else off.textContent = 'Camera off'; |
| + return { muted, off }; |
| + } |
| + |
| + // markTile shows or hides one tile's marks. `media` null is unknown. |
| + function markTile(key, media) { |
| + const t = tiles.get(key); |
| + if (!t || !t.muted) return; |
| + t.muted.hidden = !(media && media.muted); |
| + t.off.hidden = !(media && media.cameraOff); |
| + t.figure.classList.toggle('calls-tile-camera-off', !!(media && media.cameraOff)); |
| + } |
| + |
| // --------------------------------------------------------------- |
| // Terminal states (§4.5, §4.2) |
| // --------------------------------------------------------------- |
| @@ -3921,6 +4088,7 @@ |
| // transceivers are already closed with their connections, so this is |
| // the capture itself and the button's own state. |
| stopSharing(); |
| + remoteMedia.clear(); |
| renderTiles(); |
| iceWarning(false); |
| controlsEnabled(false); |
| @@ -4029,7 +4197,10 @@ |
| tiles.delete(key); |
| } |
| } |
| - if (local) tile('self', selfName, local, true, ''); |
| + if (local) { |
| + tile('self', selfName, local, true, ''); |
| + markTile('self', localMedia()); |
| + } |
| for (const [id, entry] of pcs) { |
| const name = names.get(id) || 'Someone'; |
| // The track as well as the stream: which video track a tile is |
| @@ -4039,6 +4210,7 @@ |
| // which case tile() falls back to the stream's own first video |
| // track, which is unambiguous by then. |
| tile(id, name, entry.stream, false, tileNote(id, entry), entry.cameraTrack); |
| + markTile(id, remoteMedia.get(id) || null); |
| // The screen's own tile carries no note: §5.3's and §5.5's |
| // sentences are about a connection, and this is the same |
| // connection as the camera tile above, which is already saying it. |
| @@ -4078,6 +4250,7 @@ |
| if (!t) { |
| const figure = document.createElement('figure'); |
| figure.className = 'calls-tile'; |
| + figure.dataset.tile = key; |
| const video = document.createElement('video'); |
| video.autoplay = true; |
| video.playsInline = true; |
| @@ -4113,12 +4286,20 @@ |
| }); |
| label.append(nameEl, note, retry, play); |
| figure.append(video, label); |
| + // §15.19's two marks, hidden until markTile says otherwise. A |
| + // screen has neither: it has no microphone, and it is never "off". |
| + const marks = key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX ? null : tileMarks(); |
| + if (marks) { |
| + nameEl.after(marks.muted); |
| + figure.append(marks.off); |
| + } |
| tilesEl.append(figure); |
| // `self` is kept on the record because rearmFrames reads it: the |
| // local preview is never counted, and only tile() knows which |
| // tile is ours. |
| t = { |
| figure, video, label, nameEl, note, retry, play, |
| + muted: marks && marks.muted, off: marks && marks.off, |
| remove: null, stream: null, track: null, self: isSelf, |
| // What judgeRender remembers between polls: the element's own |
| // clock, the decoder's count, and how long the two have |
| @@ -5092,6 +5273,10 @@ |
| // the message is what draws the tile. |
| sharing: !!share, |
| shares: sharingPeers(), |
| + // §15.19: what this tab reports, and what it has been told, by |
| + // peer id. Peers that never reported are absent. |
| + media: localMedia(), |
| + remoteMedia: Object.fromEntries(remoteMedia), |
| }; |
| }, |
| }; |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index 992527f..ff1fb23 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -87,6 +87,20 @@ |
| </div> |
| <div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div> |
| <p id="status" class="calls-status" role="status"></p> |
| + {{/* Spec §15.19. Someone muting or turning their camera off, said |
| + politely and once. A region of its own for the reason |
| + #device-note has one: renderStatus rewrites #status on every |
| + render, and would take the sentence away before it was read. |
| + Present from the first paint, empty, so a reader is already |
| + watching it when room.js first writes to it. */}} |
| + <p id="media-live" class="calls-sr-only" role="status" aria-live="polite"></p> |
| + {{/* The two tile marks room.js clones onto each tile, rendered here so |
| + they are the vendored badge and this app's own icons rather than a |
| + copy of either in script. The badge's word is the state, for a |
| + reader and for anyone who cannot tell a slashed microphone from a |
| + plain one; the icon is on top of it, never instead. */}} |
| + <template id="tile-muted">{{icon "mic-off"}}{{template "badge" dict "Label" "Muted"}}</template> |
| + <template id="tile-camera-off">{{icon "video-off"}}<span>Camera off</span></template> |
| </div> |
| {{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}} |
| {{end}} |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index d139c84..33b92d8 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -84,6 +84,7 @@ var scenarios = []string{ |
| "layout", |
| "devices", |
| "call", |
| + "mute", |
| "switching", |
| "share", |
| "mutualshare", |
diff --git a/internal/callstest/media_test.go b/internal/callstest/media_test.go| new file mode 100644 |
| index 0000000..008549f |
| --- /dev/null |
| +++ b/internal/callstest/media_test.go |
| @@ -0,0 +1,170 @@ |
| +package callstest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Spec §15.19 over the real handler: a peer's own media report reaches |
| +// the other admitted peer as `media`, from the hub's envelope, with the |
| +// payload re-encoded from the two booleans the handler judged — never |
| +// the bytes the sender chose — and the sender hears nothing back. |
| +func TestMediaReachesTheOtherPeerCanonicallyAndNotTheSender(t *testing.T) { |
| + a, sqid, pa, sb := chatPair(t) |
| + |
| + // Whitespace, and the keys in the other order: both legal, and |
| + // neither must survive onto Bob's stream. |
| + body := `{"peer":"` + pa + `","type":"media","payload":{ "cameraOff" : false , "muted" : true }}` |
| + res, rb := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("media: %d %s", res.StatusCode, rb) |
| + } |
| + ev, data, ok := sb.nextOf("media", 2*time.Second) |
| + if !ok || ev != "media" { |
| + t.Fatal("Bob did not receive the media report") |
| + } |
| + s := string(data) |
| + if !strings.Contains(s, `"from":"`+pa+`"`) || !strings.Contains(s, `"name":"paul"`) || |
| + !strings.Contains(s, `"payload":{"muted":true,"cameraOff":false}`) { |
| + t.Fatalf("envelope %s", s) |
| + } |
| + if strings.Contains(s, `"seq"`) || strings.Contains(s, `"to"`) || strings.Contains(s, `"gen"`) { |
| + t.Fatalf("a media envelope carries no seq, to or gen: %s", s) |
| + } |
| + |
| + // The same state again is accepted and silent. |
| + res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("repeat: %d", res.StatusCode) |
| + } |
| + if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" { |
| + t.Fatal("a report that changed nothing reached Bob") |
| + } |
| +} |
| + |
| +// §15.19's "state, not event": a peer who joins after the report reads |
| +// it from the roster it is admitted with, and a peer who has never |
| +// reported — an old client — is in that roster with no media at all. |
| +func TestMediaIsInTheRosterALateJoinerGets(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + a := x.browser(t) |
| + a.signIn("paul@example.com") |
| + sa := a.stream("/m/" + sqid + "/stream") |
| + defer sa.close() |
| + pa, _ := sa.hello() |
| + a.signal(sqid, pa, `{"type":"join"}`) |
| + sa.nextOf("roster", 2*time.Second) |
| + if res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":true}}`); res.StatusCode != http.StatusOK { |
| + t.Fatalf("media: %d %s", res.StatusCode, rb) |
| + } |
| + |
| + b := newMemberBrowser(t, x, "paul@rslw.com") |
| + sb := b.stream("/m/" + sqid + "/stream") |
| + defer sb.close() |
| + pb, _ := sb.hello() |
| + b.signal(sqid, pb, `{"type":"join"}`) |
| + _, data, ok := sb.nextOf("roster", 2*time.Second) |
| + if !ok { |
| + t.Fatal("Bob got no roster") |
| + } |
| + s := string(data) |
| + want := `"peer":"` + pa + `","name":"paul","kind":"member","gen":1,"media":{"muted":true,"cameraOff":true}` |
| + if !strings.Contains(s, want) { |
| + t.Fatalf("Bob's roster should carry Paul's media; got %s", s) |
| + } |
| + // Bob has not reported, so his own entry carries no media key. |
| + if strings.Count(s, `"media"`) != 1 { |
| + t.Fatalf("only Paul has reported; roster %s", s) |
| + } |
| +} |
| + |
| +// The shape is exact, and judged on raw bytes the way chat and share |
| +// are (§15.15): both keys, spelled exactly, each once, booleans, and |
| +// nothing else — and never targeted. |
| +func TestMediaShapes(t *testing.T) { |
| + a, sqid, pa, _ := chatPair(t) |
| + bad := []struct { |
| + name, payload string |
| + }{ |
| + {"missing cameraOff", `{"muted":true}`}, |
| + {"missing muted", `{"cameraOff":true}`}, |
| + {"empty object", `{}`}, |
| + {"a third key", `{"muted":true,"cameraOff":false,"deafened":true}`}, |
| + {"a capitalised key", `{"Muted":true,"cameraOff":false}`}, |
| + {"snake case", `{"muted":true,"camera_off":false}`}, |
| + {"a duplicated key", `{"muted":true,"muted":false,"cameraOff":false}`}, |
| + {"a string for a bool", `{"muted":"yes","cameraOff":false}`}, |
| + {"null for a bool", `{"muted":null,"cameraOff":false}`}, |
| + {"a number for a bool", `{"muted":1,"cameraOff":0}`}, |
| + {"not an object", `[true,false]`}, |
| + {"trailing content", `{"muted":true,"cameraOff":false} {}`}, |
| + } |
| + for _, c := range bad { |
| + t.Run(c.name, func(t *testing.T) { |
| + body := `{"peer":"` + pa + `","type":"media","payload":` + c.payload + `}` |
| + res, rb := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(rb), `"error":"bad-request"`) { |
| + t.Fatalf("%d %s, want 400 bad-request", res.StatusCode, rb) |
| + } |
| + }) |
| + } |
| + t.Run("no payload at all", func(t *testing.T) { |
| + res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media"}`) |
| + if res.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("%d %s, want 400", res.StatusCode, rb) |
| + } |
| + }) |
| + t.Run("targeted", func(t *testing.T) { |
| + res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","to":"`+strings.Repeat("a", 32)+`","payload":{"muted":true,"cameraOff":false}}`) |
| + if res.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("%d %s, want 400", res.StatusCode, rb) |
| + } |
| + }) |
| + t.Run("with a seq", func(t *testing.T) { |
| + res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","seq":3,"payload":{"muted":true,"cameraOff":false}}`) |
| + if res.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("%d %s, want 400", res.StatusCode, rb) |
| + } |
| + }) |
| + t.Run("padded past the cap", func(t *testing.T) { |
| + payload := `{"muted":true,` + strings.Repeat(" ", 128) + `"cameraOff":false}` |
| + res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`) |
| + if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) { |
| + t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb) |
| + } |
| + }) |
| +} |
| + |
| +// A waiting guest has nobody to tell: its report is the usual 404, and |
| +// it hears nothing of the room's. |
| +func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| + a := x.browser(t) |
| + a.signIn("paul@example.com") |
| + sa := a.stream("/m/" + sqid + "/stream") |
| + defer sa.close() |
| + pa, _ := sa.hello() |
| + a.signal(sqid, pa, `{"type":"join"}`) |
| + sa.nextOf("roster", 2*time.Second) |
| + |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + g.signIn("waiting-guest@example.com") |
| + gs := g.stream("/m/" + sqid + "/stream") |
| + defer gs.close() |
| + gpeer, _ := gs.hello() |
| + g.signal(sqid, gpeer, `{"type":"join"}`) |
| + sa.nextOf("knock", 2*time.Second) |
| + |
| + res, rb := g.signal(sqid, gpeer, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) |
| + if res.StatusCode != http.StatusNotFound || !strings.Contains(string(rb), `"error":"not-found"`) { |
| + t.Fatalf("a waiting guest's media must be the usual 404: %d %s", res.StatusCode, rb) |
| + } |
| + a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) |
| + if ev, _, ok := gs.next(400 * time.Millisecond); ok && ev == "media" { |
| + t.Fatal("a waiting guest heard a media report") |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index be0ff5b..8d3cfc0 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -37,6 +37,11 @@ struct Input: Decodable { |
| await call.leave() |
| exit(0) |
| } |
| + if command == "mute" { |
| + await call.mute() |
| + print("{\"muted\":\(await call.muted)}") |
| + fflush(stdout) |
| + } |
| if command == "chat" { |
| await call.sendChat("native chat probe") |
| print("{\"chatCommandHandled\":true}") |
| @@ -79,6 +84,10 @@ struct Input: Decodable { |
| "receivedChatProbe": call.chat.contains { $0.hasSuffix(": browser chat probe") }, |
| "sentChatProbe": call.chat.contains("You: native chat probe"), |
| "receivedChatCount": call.receivedChatCount, |
| + // §15.19: remote peers this app has been told are muted. |
| + "remoteMuted": (call.state?.peers ?? []).filter { |
| + $0.peer != call.state?.peer && call.isMuted($0.peer) |
| + }.count, |
| ]), let text = String(data: data, encoding: .utf8) { |
| print(text) |
| fflush(stdout) |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 2a010db..a6307b5 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -116,6 +116,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| guard epoch == joiningEpoch, bridge === client else { throw CancellationError() } |
| localTrack = media.localVideo |
| currentCamera = media.currentCamera |
| + reportMedia() |
| audio.refresh() |
| media.audio.reduceNoise = reduceNoise |
| audioProcessing = media.audio.report() |
| @@ -159,10 +160,35 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| public func mute() { |
| muted.toggle() |
| media.localAudio?.isEnabled = !muted |
| + reportMedia() |
| } |
| public func toggleCamera() { |
| cameraOff.toggle() |
| media.localVideo?.isEnabled = !cameraOff |
| + reportMedia() |
| + } |
| + |
| + // --------------------------------------------------------------- |
| + // Media state (§15.19) |
| + // --------------------------------------------------------------- |
| + |
| + /// Tells the core what this app is sending. The core reports it once |
| + /// admitted, again whenever a roster says the server has not got it — |
| + /// a resume, a restart — and at once on a change, so this is called on |
| + /// each toggle and once the tracks exist, and nowhere else. No track at |
| + /// all is muted, or off, as far as anyone listening can tell. |
| + private func reportMedia() { |
| + try? bridge?.setMedia(muted || media.localAudio == nil, cameraOff: cameraOff || media.localVideo == nil) |
| + } |
| + |
| + /// A remote peer's reported state, or nil when it has not reported. |
| + public func peerMedia(_ peer: String) -> PeerMedia? { Self.peerMedia(peer, in: state) } |
| + /// Whether a remote peer has said it is muted. Unknown is not muted. |
| + public func isMuted(_ peer: String) -> Bool { peerMedia(peer)?.muted ?? false } |
| + /// Whether a remote peer has said its camera is off. Unknown is not off. |
| + public func isCameraOff(_ peer: String) -> Bool { peerMedia(peer)?.cameraOff ?? false } |
| + nonisolated static func peerMedia(_ peer: String, in state: RoomState?) -> PeerMedia? { |
| + state?.peers.first { $0.peer == peer }?.media |
| } |
| // --------------------------------------------------------------- |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index 93a201c..e61f42b 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -121,8 +121,11 @@ |
| ) |
| .aspectRatio(16 / 9, contentMode: .fit) |
| ForEach(peers) { peer in |
| - MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| - .aspectRatio(16 / 9, contentMode: .fit) |
| + MacVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer) |
| + ) |
| + .aspectRatio(16 / 9, contentMode: .fit) |
| } |
| } |
| } |
| @@ -130,7 +133,9 @@ |
| GeometryReader { geometry in |
| ZStack(alignment: .bottomTrailing) { |
| if let peer = focusedPeer { |
| - MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| + MacVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer)) |
| } else { |
| ContentUnavailableView( |
| "Waiting for others", systemImage: "person.2", |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index b1429ff..7475eaa 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -6,6 +6,8 @@ |
| struct MacVideoTile: View { |
| let track: RTCVideoTrack? |
| let label: String |
| + /// §15.19: the peer has said it is muted. False for unknown. |
| + var muted = false |
| var mirrored = false |
| var cameraOff = false |
| @@ -27,9 +29,10 @@ |
| .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| .background(.black.opacity(0.65), in: Capsule()).padding(10) |
| } |
| + .overlay(alignment: .topLeading) { if muted { MutedBadge().padding(10) } } |
| .clipShape(RoundedRectangle(cornerRadius: 16)) |
| .accessibilityElement(children: .ignore) |
| - .accessibilityLabel(label) |
| + .accessibilityLabel(tileAccessibilityLabel(label, muted: muted)) |
| .accessibilityValue( |
| cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on") |
| } |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| new file mode 100644 |
| index 0000000..a06440b |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -0,0 +1,23 @@ |
| +import SwiftUI |
| + |
| +/// Spec §15.19: the mark a video tile carries while its peer is muted. |
| +/// A slashed microphone and the word beside it, so the state is a shape |
| +/// and a word and never a colour. The tile folds it into its own |
| +/// accessibility label, so this hides itself from assistive technology |
| +/// rather than being read twice. |
| +public struct MutedBadge: View { |
| + public init() {} |
| + public var body: some View { |
| + Label("Muted", systemImage: "mic.slash.fill") |
| + .font(.caption.bold()).foregroundStyle(.white) |
| + .padding(.horizontal, 8).padding(.vertical, 4) |
| + .background(.black.opacity(0.65), in: Capsule()) |
| + .accessibilityHidden(true) |
| + } |
| +} |
| + |
| +/// What a tile's accessibility label says: the name, and "muted" when it |
| +/// is. Shared by the Mac and iPhone tiles so VoiceOver hears one phrase. |
| +public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String { |
| + muted ? "\(label), muted" : label |
| +} |
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift| index 6abe484..4659648 100644 |
| --- a/native/apple/Sources/CallsKit/Wire.swift |
| +++ b/native/apple/Sources/CallsKit/Wire.swift |
| @@ -5,8 +5,17 @@ public struct RosterPeer: Decodable, Identifiable { |
| public let name: String |
| public let kind: String |
| public let gen: Int64? |
| + /// The peer's own last report of its microphone and camera (spec |
| + /// §15.19). Nil is unknown — a client that predates the report, or one |
| + /// that has not made it yet — and is shown as neither muted nor off. |
| + public let media: PeerMedia? |
| public var id: String { peer } |
| } |
| +/// What a peer says about its own outgoing media (spec §15.19). |
| +public struct PeerMedia: Decodable, Equatable, Sendable { |
| + public let muted: Bool |
| + public let cameraOff: Bool |
| +} |
| public struct RoomState: Decodable { |
| public let phase: String |
| public let peer: String |
diff --git a/native/apple/Tests/CallsKitTests/PeerMediaTests.swift b/native/apple/Tests/CallsKitTests/PeerMediaTests.swift| new file mode 100644 |
| index 0000000..5e5deed |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/PeerMediaTests.swift |
| @@ -0,0 +1,51 @@ |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// Spec §15.19 on the native side: the core puts each peer's reported |
| +/// media on the roster snapshot, and the tiles read it through Call. What |
| +/// is held here is the part that can be wrong without a call running — |
| +/// the wire shape, and unknown never reading as muted or off. |
| +final class PeerMediaTests: XCTestCase { |
| + private func state(_ json: String) throws -> RoomState { |
| + try JSONDecoder().decode(RoomState.self, from: Data(json.utf8)) |
| + } |
| + |
| + func testARosterCarriesReportedMediaAndLeavesTheRestUnknown() throws { |
| + let s = try state( |
| + #""" |
| + {"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[ |
| + {"peer":"me","name":"Me","kind":"member","media":{"muted":false,"cameraOff":false}}, |
| + {"peer":"bea","name":"Bea","kind":"member","gen":1,"media":{"muted":true,"cameraOff":true}}, |
| + {"peer":"old","name":"Old","kind":"guest","gen":1} |
| + ]} |
| + """#) |
| + XCTAssertEqual(Call.peerMedia("bea", in: s), PeerMedia(muted: true, cameraOff: true)) |
| + // A client from before §15.19 never reports: the snapshot says |
| + // nothing, and nothing is guessed. |
| + XCTAssertNil(Call.peerMedia("old", in: s)) |
| + XCTAssertNil(Call.peerMedia("nobody", in: s)) |
| + XCTAssertNil(Call.peerMedia("bea", in: nil)) |
| + } |
| + |
| + func testAMalformedReportDoesNotCostTheWholeState() throws { |
| + // The core only ever forwards two booleans, but a snapshot the app |
| + // cannot decode is a call that stops updating, so the shape is pinned. |
| + XCTAssertThrowsError( |
| + try state( |
| + #"{"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[{"peer":"x","name":"X","kind":"member","media":{"muted":"yes","cameraOff":false}}]}"# |
| + )) |
| + } |
| + |
| + @MainActor func testUnknownIsNeitherMutedNorOff() { |
| + let call = Call(playAudio: false) |
| + XCTAssertFalse(call.isMuted("anyone")) |
| + XCTAssertFalse(call.isCameraOff("anyone")) |
| + XCTAssertNil(call.peerMedia("anyone")) |
| + } |
| + |
| + func testTheTileSaysMutedInWordsForVoiceOver() { |
| + XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true), "Bea, muted") |
| + XCTAssertEqual(tileAccessibilityLabel("Bea", muted: false), "Bea") |
| + } |
| +} |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index cafacab..d00541f 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -106,6 +106,11 @@ func (c *Client) Send(typ, to string, generation int64, payload string) error { |
| func (c *Client) Leave() { c.room.Leave() } |
| func (c *Client) Close() { c.room.Close() } |
| +// SetMedia is this app's own microphone and camera state (spec §15.19). |
| +// The core reports it once admitted, again whenever a roster says the |
| +// server has not got it, and at once on every change. |
| +func (c *Client) SetMedia(muted, cameraOff bool) error { return c.room.SetMedia(muted, cameraOff) } |
| + |
| // RegisterDevice hands the server an Apple device token so a knock can |
| // reach this device when the app is closed. Best effort: the caller |
| // ignores the error beyond logging, because a device that did not |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index ab160d9..5d1d88a 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -125,8 +125,11 @@ struct PhoneCall: View { |
| ) |
| .aspectRatio(4 / 3, contentMode: .fit) |
| ForEach(peers) { peer in |
| - PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| - .aspectRatio(4 / 3, contentMode: .fit) |
| + PhoneVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer) |
| + ) |
| + .aspectRatio(4 / 3, contentMode: .fit) |
| } |
| } |
| } |
| @@ -134,7 +137,9 @@ struct PhoneCall: View { |
| GeometryReader { geometry in |
| ZStack(alignment: .bottomTrailing) { |
| if let peer = focused { |
| - PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| + PhoneVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer)) |
| } else { |
| ContentUnavailableView( |
| "Waiting for others", systemImage: "person.2", |
| @@ -233,6 +238,8 @@ struct PhoneCall: View { |
| struct PhoneVideoTile: View { |
| let track: RTCVideoTrack? |
| let label: String |
| + /// §15.19: the peer has said it is muted. False for unknown. |
| + var muted = false |
| var mirrored = false |
| var cameraOff = false |
| var body: some View { |
| @@ -250,8 +257,10 @@ struct PhoneVideoTile: View { |
| .padding(.horizontal, 10).padding(.vertical, 6) |
| .background(.black.opacity(0.65), in: Capsule()).padding(8) |
| } |
| + .overlay(alignment: .topLeading) { if muted { MutedBadge().padding(8) } } |
| .clipShape(RoundedRectangle(cornerRadius: 18)) |
| - .accessibilityElement(children: .ignore).accessibilityLabel(label) |
| + .accessibilityElement(children: .ignore) |
| + .accessibilityLabel(tileAccessibilityLabel(label, muted: muted)) |
| .accessibilityValue(cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on") |
| } |
| } |
diff --git a/protocol/messages.go b/protocol/messages.go| index cbb5670..67cac5e 100644 |
| --- a/protocol/messages.go |
| +++ b/protocol/messages.go |
| @@ -48,6 +48,10 @@ const ( |
| TypeReset = "reset" |
| TypeChat = "chat" // slice 4 |
| TypeShare = "share" // slice 4 |
| + // TypeMedia goes both ways: a peer reports its own MediaState |
| + // upstream, and the hub fans the change out downstream as |
| + // {type, from, name, payload: MediaState} (spec §15.19). |
| + TypeMedia = "media" |
| ) |
| // Downstream types the server emits. |
| @@ -99,4 +103,20 @@ type RosterPeer struct { |
| Name string `json:"name"` |
| Kind string `json:"kind"` |
| Gen uint64 `json:"gen,omitempty"` |
| + // Media is the peer's own last report of its microphone and camera, |
| + // absent until it has made one — which is every client that predates |
| + // §15.19, and every peer for the moment between admission and its |
| + // first report. Absent means unknown, and a client shows unknown as |
| + // neither muted nor camera-off. Never written through: a snapshot |
| + // copies the pointer, so a change replaces it. |
| + Media *MediaState `json:"media,omitempty"` |
| +} |
| + |
| +// MediaState is what a peer says about its own outgoing media (spec |
| +// §15.19): whether its microphone is muted and whether its camera is |
| +// off. It is a claim about what the peer is choosing to send, not a |
| +// measurement of what arrives — the server never sees media. |
| +type MediaState struct { |
| + Muted bool `json:"muted"` |
| + CameraOff bool `json:"cameraOff"` |
| } |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 819b624..399a780 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -1136,6 +1136,31 @@ function shareControl(page) { |
| }); |
| } |
| +// tileMarks is what spec §15.19 draws on one peer's tile, read the way a |
| +// person reads it: whether the muted badge and the camera-off cover are |
| +// showing, and the words each carries — the words, not the colour or |
| +// the icon, being the part AGENTS.md makes the contract. |
| +function tileMarks(page, key) { |
| + return page.evaluate((k) => { |
| + const f = document.querySelector('#tiles figure[data-tile="' + k + '"]'); |
| + if (!f) return null; |
| + const muted = f.querySelector('.calls-tile-muted'); |
| + const off = f.querySelector('.calls-tile-off'); |
| + const shown = (el) => !!(el && !el.hidden && el.getClientRects().length); |
| + return { |
| + muted: shown(muted), |
| + mutedText: muted ? (muted.textContent || '').trim() : '', |
| + cameraOff: shown(off), |
| + offText: off ? (off.textContent || '').trim() : '', |
| + caption: ((f.querySelector('figcaption') || {}).textContent || '').trim(), |
| + }; |
| + }, key); |
| +} |
| + |
| +function mediaLive(page) { |
| + return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim()); |
| +} |
| + |
| // ── the scenarios ──────────────────────────────────────────────────── |
| const run = { |
| @@ -1221,6 +1246,92 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // Spec §15.19: one peer mutes and turns the camera off, and the other |
| + // sees both on that peer's tile — in words, not only an icon — and |
| + // hears about it through the polite live region. Then the watcher |
| + // leaves and comes back, and the state is on the tile from the roster |
| + // alone, with no event to carry it: that is the late-joiner and resume |
| + // path, which is the reason the state lives in the roster at all. |
| + async mute() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + let pageB = await openRoom(ctxB); |
| + |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) return; |
| + const a = pair.a.peer; |
| + const nameA = await selfName(pageA); |
| + |
| + // Both report on admission, so each knows the other is on — known, |
| + // not merely unknown — before anybody touches anything. |
| + const known = await until(async () => { |
| + const s = await pageB.evaluate(() => window.meet.state().remoteMedia); |
| + return s[a] && s[a].muted === false && s[a].cameraOff === false ? s : false; |
| + }, 10000); |
| + check(known, 'B has A’s first report: on, not unknown (' + JSON.stringify(known) + ')'); |
| + const before = await tileMarks(pageB, a); |
| + check(before && !before.muted && !before.cameraOff, 'A’s tile starts with neither mark (' + JSON.stringify(before) + ')'); |
| + |
| + await pageA.click('#mute'); |
| + const muted = await until(async () => { |
| + const m = await tileMarks(pageB, a); |
| + return m && m.muted ? m : false; |
| + }, 10000); |
| + check(muted, 'B sees A’s muted badge after A mutes (' + JSON.stringify(muted || (await tileMarks(pageB, a))) + ')'); |
| + check(muted && /muted/i.test(muted.mutedText), 'the badge says Muted in words (' + (muted && muted.mutedText) + ')'); |
| + check(muted && !muted.cameraOff, 'muting alone does not cover the video'); |
| + const said = await until(async () => { |
| + const t = await mediaLive(pageB); |
| + return t.includes('muted their microphone') ? t : false; |
| + }, 5000); |
| + check(said && said.startsWith(nameA), 'B’s live region says so, politely, by name (' + (said || (await mediaLive(pageB))) + ')'); |
| + const selfMark = await tileMarks(pageA, 'self'); |
| + check(selfMark && selfMark.muted, 'A’s own tile carries the badge too (' + JSON.stringify(selfMark) + ')'); |
| + |
| + await pageA.click('#camera'); |
| + const off = await until(async () => { |
| + const m = await tileMarks(pageB, a); |
| + return m && m.cameraOff ? m : false; |
| + }, 10000); |
| + check(off, 'B sees A’s camera-off cover (' + JSON.stringify(off || (await tileMarks(pageB, a))) + ')'); |
| + check(off && off.offText === 'Camera off', 'the cover says Camera off in words (' + (off && off.offText) + ')'); |
| + check(off && off.muted, 'and A is still muted'); |
| + // For a person to look at, as guestdirect keeps the settings page. |
| + await pageB.screenshot({ path: '/tmp/calls-mute.png', fullPage: true }); |
| + |
| + // The watcher comes back: the roster alone must carry both. |
| + await leaveRoom(pageB); |
| + await pageB.close(); |
| + pageB = await openRoom(ctxB); |
| + const again = await until(async () => { |
| + const s = await state(pageB); |
| + if (!s.admitted || !s.peers.includes(a)) return false; |
| + const m = await tileMarks(pageB, a); |
| + return m && m.muted && m.cameraOff ? m : false; |
| + }, 25000); |
| + check(again, 'B, rejoining, reads both marks from the roster (' + JSON.stringify(again || (await tileMarks(pageB, a))) + ')'); |
| + check((await mediaLive(pageB)) === '', 'a state learned from the roster is not announced as news'); |
| + |
| + // And back on: both marks go. |
| + await pageA.click('#mute'); |
| + await pageA.click('#camera'); |
| + const clear = await until(async () => { |
| + const m = await tileMarks(pageB, a); |
| + return m && !m.muted && !m.cameraOff ? m : false; |
| + }, 10000); |
| + check(clear, 'unmuting and turning the camera on clears both marks (' + JSON.stringify(clear || (await tileMarks(pageB, a))) + ')'); |
| + const fb = await waitFrames(pageB, a); |
| + check(fb, 'B is still decoding A’s video afterwards'); |
| + |
| + check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| + check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // Changing a device without leaving the call. The panel opens, the |
| // microphone and then the camera are chosen again from the list, and |
| // the far side's media is watched across both. |
diff --git a/test/browser/native.mjs b/test/browser/native.mjs| index cee1b62..7227503 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -142,6 +142,25 @@ try { |
| await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).waitFor(); |
| await wait(() => sample?.sentChatProbe === true, 'native displays accepted outgoing chat'); |
| console.log('PASS chat delivered from browser to native and native to browser'); |
| + // Spec §15.19 across the two clients: the app's mute reaches the |
| + // browser tile as the badge, the browser's reaches the app's snapshot, |
| + // and both clear again. |
| + { |
| + const nativePeer = sample.peer; |
| + const nativeMarked = () => page.evaluate((id) => { |
| + const m = document.querySelector('#tiles figure[data-tile="' + id + '"] .calls-tile-muted'); |
| + return !!(m && !m.hidden); |
| + }, nativePeer); |
| + assert.equal(await nativeMarked(), false, 'the native peer starts unmuted on the browser tile'); |
| + native.stdin.write('mute\n'); |
| + await wait(nativeMarked, 'browser shows the native peer muted'); |
| + await page.click('#mute'); |
| + await wait(() => sample?.remoteMuted === 1, 'native sees the browser muted'); |
| + await page.click('#mute'); |
| + native.stdin.write('mute\n'); |
| + await wait(async () => !(await nativeMarked()) && sample?.remoteMuted === 0, 'both unmuted again'); |
| + console.log('PASS mute state crosses between native and browser both ways'); |
| + } |
| if (mode === 'chat') { |
| native.stdin.write('share\n'); |
| await wait(() => invitationReady, 'native member retrieves the original invitation'); |
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs| index 79a7aec..af952ba 100644 |
| --- a/test/browser/negotiation.test.mjs |
| +++ b/test/browser/negotiation.test.mjs |
| @@ -615,6 +615,8 @@ const reconciler = new Function( |
| 'state', 'stale', 'names', 'pcs', 'gen', 'seqOut', 'lastIn', 'queue', 'frames', |
| 'renewals', 'ensurePC', 'renderTiles', 'renderQueue', 'renderStatus', |
| 'rosters', 'lastRoster', 'waiting', 'mountChat', 'chatEnabled', 'SHARE_SUFFIX', |
| + // §15.19's roster half is its own function, gated in room-mute.test.mjs. |
| + 'rosterMedia', |
| // shareKey comes in as source rather than as a stub: closePC forgets a |
| // peer's share tile through it (§5.4), and a stub would be testing the |
| // stub. |
| @@ -631,6 +633,7 @@ function reconcileOver(pcs, seqOut, lastIn, frames) { |
| frames || new Map(), |
| new Map(), (id, g) => built.push([id, g]), () => {}, () => {}, () => {}, |
| 0, null, [], () => chat.push('mount'), (on) => chat.push(on), '#share', |
| + () => {}, |
| ); |
| return { reconcile, closePC, built, chat }; |
| } |
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs| new file mode 100644 |
| index 0000000..927b0ea |
| --- /dev/null |
| +++ b/test/browser/room-mute.test.mjs |
| @@ -0,0 +1,174 @@ |
| +// room-mute.test.mjs — room.js's media state (spec §15.19), gated |
| +// without a browser. |
| +// |
| +// node --test test/browser/room-mute.test.mjs |
| +// |
| +// Same trick as room-share.test.mjs: the functions are lifted out of the |
| +// shipped room.js by name, so a rename fails here loudly instead of |
| +// passing against a copy. |
| +// |
| +// mediaFrom what counts as a report: exactly two booleans, from a |
| +// roster entry or a `media` payload, and nothing looser — |
| +// an absent or malformed one is unknown, never "on" |
| +// sameMedia whether the hub's word on us already matches ours |
| +// mediaKey the dedupe key a toggle is compared on |
| +// mediaChange the sentence the live region says, and when it says |
| +// nothing at all |
| +// rosterMedia with reportMedia: when our own report goes — on the |
| +// roster that admits us, on one that disagrees, on a |
| +// toggle, never before admission, and again after a 400 |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| +const src = readFileSync(roomJS, 'utf8'); |
| + |
| +function lift(source, name) { |
| + const start = source.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| + let depth = 0; |
| + for (let i = source.indexOf('{', start); i < source.length; i++) { |
| + if (source[i] === '{') depth += 1; |
| + else if (source[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return source.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| +} |
| + |
| +const build = (name) => |
| + // eslint-disable-next-line no-new-func |
| + new Function(lift(src, name) + '; return ' + name + ';')(); |
| + |
| +const mediaFrom = build('mediaFrom'); |
| +const sameMedia = build('sameMedia'); |
| +const mediaKey = build('mediaKey'); |
| +const mediaChange = build('mediaChange'); |
| + |
| +test('mediaFrom takes exactly two booleans and nothing looser', () => { |
| + assert.deepEqual(mediaFrom({ muted: true, cameraOff: false }), { muted: true, cameraOff: false }); |
| + // Extra keys are dropped rather than carried: what is drawn is what |
| + // was checked. |
| + assert.deepEqual(mediaFrom({ muted: false, cameraOff: true, extra: 1 }), { muted: false, cameraOff: true }); |
| + for (const bad of [ |
| + undefined, null, 'muted', 1, [], |
| + {}, { muted: true }, { cameraOff: true }, |
| + { muted: 'true', cameraOff: false }, { muted: 1, cameraOff: 0 }, { muted: null, cameraOff: false }, |
| + ]) { |
| + assert.equal(mediaFrom(bad), null, JSON.stringify(bad) + ' is unknown'); |
| + } |
| +}); |
| + |
| +test('sameMedia never matches unknown', () => { |
| + const on = { muted: false, cameraOff: false }; |
| + assert.equal(sameMedia(on, { muted: false, cameraOff: false }), true); |
| + assert.equal(sameMedia(on, { muted: true, cameraOff: false }), false); |
| + assert.equal(sameMedia(on, { muted: false, cameraOff: true }), false); |
| + // The roster's silence about us is not agreement with "on": a fresh |
| + // participation must be told, or a peer who joined unmuted stays |
| + // unknown to everyone for the whole call. |
| + assert.equal(sameMedia(null, on), false); |
| + assert.equal(sameMedia(on, null), false); |
| +}); |
| + |
| +test('mediaKey tells every state apart', () => { |
| + const keys = new Set(); |
| + for (const muted of [false, true]) { |
| + for (const cameraOff of [false, true]) keys.add(mediaKey({ muted, cameraOff })); |
| + } |
| + assert.equal(keys.size, 4); |
| + // And never collides with the empty string mediaSent starts at. |
| + assert.equal(keys.has(''), false); |
| +}); |
| + |
| +test('mediaChange says what moved, and nothing for a first report', () => { |
| + const on = { muted: false, cameraOff: false }; |
| + assert.equal(mediaChange('Bea', null, { muted: true, cameraOff: false }), '', |
| + 'a first report is somebody arriving, not news'); |
| + assert.equal(mediaChange('Bea', on, on), ''); |
| + assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: false }), 'Bea muted their microphone.'); |
| + assert.equal(mediaChange('Bea', { muted: true, cameraOff: false }, on), 'Bea unmuted their microphone.'); |
| + assert.equal(mediaChange('Bea', on, { muted: false, cameraOff: true }), 'Bea turned their camera off.'); |
| + assert.equal(mediaChange('Bea', { muted: false, cameraOff: true }, on), 'Bea turned their camera on.'); |
| + assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: true }), |
| + 'Bea muted their microphone. Bea turned their camera off.'); |
| +}); |
| + |
| +test('the dispatch routes media, and the report rides the broadcast lane', () => { |
| + // Wire-level facts a lift cannot reach: the event name the hub sends, |
| + // and that the report is an untargeted broadcast rather than a send() |
| + // with a seq and a generation the hub would refuse. |
| + assert.match(src, /case 'media': return onMedia\(msg\);/); |
| + // An EventSource hears only the names it listens for: the dispatch |
| + // above is dead without this, and the roster path hides it (the |
| + // browser gate caught exactly that). |
| + assert.match(src, /const DOWNSTREAM = \[[^\]]*'media'\]/); |
| + assert.match(src, /broadcast\('media', now\)/); |
| +}); |
| + |
| +// rosterMedia and reportMedia, lifted together over real Maps with only |
| +// the wire and the local stream stubbed: the decision under test is when |
| +// a report goes, and that is the pair of them. |
| +function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true } = {}) { |
| + const sent = []; |
| + const env = { local, answer }; |
| + const made = new Function( |
| + 'state', 'remoteMedia', 'localMedia', 'broadcast', |
| + ['mediaFrom', 'sameMedia', 'mediaKey', 'learnMedia', 'reportMedia', 'rosterMedia'] |
| + .map((n) => lift(src, n)).join('\n') + |
| + '\nlet mediaSent = "";' + |
| + '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent };', |
| + )( |
| + { admitted, stopped: false, peer: 'me' }, |
| + new Map(), |
| + () => env.local, |
| + (type, payload) => { |
| + sent.push([type, payload]); |
| + return Promise.resolve(env.answer); |
| + }, |
| + ); |
| + return { ...made, sent, env }; |
| +} |
| + |
| +test('a roster that has never heard from us is answered with a report', () => { |
| + const r = reporter({ local: { muted: true, cameraOff: false } }); |
| + r.rosterMedia({ peers: [{ peer: 'me' }, { peer: 'bea' }] }); |
| + assert.deepEqual(r.sent, [['media', { muted: true, cameraOff: false }]]); |
| +}); |
| + |
| +test('a roster that already agrees costs nothing, and a toggle then goes once', () => { |
| + const r = reporter(); |
| + r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); |
| + assert.equal(r.sent.length, 0); |
| + r.env.local = { muted: true, cameraOff: false }; |
| + r.reportMedia(); |
| + r.reportMedia(); |
| + assert.equal(r.sent.length, 1, 'a second call with nothing changed must not send again'); |
| +}); |
| + |
| +test('a roster that disagrees — a report lost while the stream was down — is corrected', () => { |
| + const r = reporter({ local: { muted: true, cameraOff: true } }); |
| + r.reportMedia(); |
| + assert.equal(r.sent.length, 1); |
| + r.rosterMedia({ peers: [{ peer: 'me', media: { muted: true, cameraOff: false } }] }); |
| + assert.equal(r.sent.length, 2); |
| + assert.deepEqual(r.sent[1][1], { muted: true, cameraOff: true }); |
| +}); |
| + |
| +test('nothing is reported before admission', () => { |
| + const r = reporter({ admitted: false, local: { muted: true, cameraOff: false } }); |
| + r.reportMedia(); |
| + assert.equal(r.sent.length, 0); |
| +}); |
| + |
| +test('a refused report is forgotten, so the next chance tries again', async () => { |
| + const r = reporter({ answer: false, local: { muted: true, cameraOff: false } }); |
| + r.reportMedia(); |
| + await new Promise((resolve) => setTimeout(resolve, 0)); |
| + assert.equal(r.memo(), '', 'an old server’s 400 must not be remembered as sent'); |
| + r.reportMedia(); |
| + assert.equal(r.sent.length, 2); |
| +}); |