oficina / meet Public

Carry each peer's mute and camera-off state in the room

A peer reports {muted, cameraOff} with a new `media` signal. The hub
keeps it on the participation, puts it in every roster and peer event,
and fans out only a change, so late joiners, resumes and restarts need
no replay rule. Unreported peers carry no media key: unknown, never a
guessed "on". callscore reports the host's state on admission, whenever
a roster disagrees, and on each change; the bridge exposes SetMedia.

Spec §15.19.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 673893733f9e37ef1c9a0c719b0ac4c4f5ea9fe2 parent 3846ca4
12 files changed, +996 −10
  • callscore/media_test.go +177 −0
  • callscore/room.go +66 −2
  • docs/superpowers/specs/2026-09-07-meet-design.md +68 −0
  • internal/calls/hub/admission.go +2 −2
  • internal/calls/hub/hub.go +4 −0
  • internal/calls/hub/media.go +63 −0
  • internal/calls/hub/media_test.go +361 −0
  • internal/calls/hub/messages.go +2 −0
  • internal/calls/signal.go +58 −6
  • internal/callstest/media_test.go +170 −0
  • native/bridge/bridge.go +5 −0
  • protocol/messages.go +20 −0
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/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/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/bridge/bridge.go b/native/bridge/bridge.go
index 8784a66..39bf103 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -104,6 +104,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/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"`
}