diff --git a/js/room/src/local.test.ts b/js/room/src/local.test.ts index b8779209e4..51f95746b1 100644 --- a/js/room/src/local.test.ts +++ b/js/room/src/local.test.ts @@ -54,23 +54,30 @@ function setup() { return { local, bandwidth, screen: sources.at(-1) as FakeSource, broadcasts: { camera, screen: screenBroadcast } }; } -// Replays LeavingStopsPublishing in `verify/Room.tla`: the screen broadcast is enabled by a live -// share alone, so a participant who never joined, or who left, still publishes their screen. -test("residual: a screen share publishes while not joined and after leaving", async () => { +// Replays LeavingStopsPublishing in `verify/Room.tla`: a participant who has not joined, or has +// left, neither publishes nor encodes their screen, even with a live share. +test("a screen share publishes only while joined", async () => { const { local, screen, broadcasts } = setup(); + // The screen's hd, sd, and audio encoders are constructed last. + const screenEncoders = () => encoders.slice(-3).map((e) => (e.enabled as Signal).peek()); try { local.screenEnabled.set(true); screen.out.state.set({ kind: "live", media: { video: {} } }); await flush(); expect(broadcasts.camera.enabled.peek()).toBe(false); - expect(broadcasts.screen.enabled.peek()).toBe(true); + expect(broadcasts.screen.enabled.peek()).toBe(false); + expect(screenEncoders()).toEqual([false, false, false]); local.enabled.set(true); await flush(); + expect(broadcasts.screen.enabled.peek()).toBe(true); + expect(screenEncoders()).toEqual([true, true, true]); + local.enabled.set(false); await flush(); expect(broadcasts.camera.enabled.peek()).toBe(false); - expect(broadcasts.screen.enabled.peek()).toBe(true); + expect(broadcasts.screen.enabled.peek()).toBe(false); + expect(screenEncoders()).toEqual([false, false, false]); } finally { local.close(); } diff --git a/js/room/src/local.ts b/js/room/src/local.ts index 8b77333139..845d0f5aa1 100644 --- a/js/room/src/local.ts +++ b/js/room/src/local.ts @@ -33,7 +33,8 @@ export interface LocalProps { * * Enable {@link enabled} to join (announce `{identity}/camera.hang`). Camera and * microphone capture are separate knobs; the screenshare broadcast is - * announced only while a share is live. + * announced only while joined and a share is live, so nothing is published + * before joining or after leaving. */ export class Local { /** Participant identity. */ @@ -96,6 +97,9 @@ export class Local { #screenAudioSource = new Signal(undefined); #screenLive = new Signal(false); + // The screen broadcast and its encoders run only while joined and sharing. + #screenPublished = new Signal(false); + #signals = new Effect(); #control(value: GetterInit | undefined): Signal { @@ -189,9 +193,13 @@ export class Local { }); this.#signals.cleanup(() => this.camera.close()); + this.#signals.run((effect) => { + this.#screenPublished.set(effect.get(this.enabled) && effect.get(this.#screenLive)); + }); + this.screen = new Publish.Broadcast({ origin, - enabled: this.#screenLive, + enabled: this.#screenPublished, name: screenName, display: this.screenCapture.out.display, }); @@ -236,7 +244,7 @@ export class Local { const screenHd = new Publish.Video.Encoder("video/hd", { broadcast: this.screen, capture: this.screenCapture, - enabled: this.#screenLive, + enabled: this.#screenPublished, bandwidth, config: { maxPixels: 1920 * 1080 }, }); @@ -245,7 +253,7 @@ export class Local { const screenSd = new Publish.Video.Encoder("video/sd", { broadcast: this.screen, capture: this.screenCapture, - enabled: this.#screenLive, + enabled: this.#screenPublished, bandwidth, config: { maxPixels: 960 * 540 }, }); @@ -260,7 +268,7 @@ export class Local { const screenAudio = new Publish.Audio.Encoder("audio", { broadcast: this.screen, capture: screenAudioCapture, - enabled: this.#screenLive, + enabled: this.#screenPublished, bandwidth, }); this.#signals.cleanup(() => screenAudio.close()); diff --git a/js/room/verify/README.md b/js/room/verify/README.md index 1fce4e9ef3..bb29efa475 100644 --- a/js/room/verify/README.md +++ b/js/room/verify/README.md @@ -2,13 +2,13 @@ `Room.tla` is a TLA+ model of you and Bob in a room, written to be drawn as a statechart with `/statechart`. Your side is `src/local.ts`: joining announces your camera broadcast, and a live -screen share announces your screen broadcast. Your view of Bob is `src/room.ts`: each of his +screen share announces your screen broadcast while joined. Your view of Bob is `src/room.ts`: each of his announcements adds or removes one of his broadcasts, and your connection dropping removes him until the reconnect announces him again. -| Rule | Holds when | Today | -| --- | --- | --- | -| `LeavingStopsPublishing` | Someone who has not joined the room, or has left it, publishes nothing to it. | Breaks: the screen broadcast is enabled by a live share alone, so a share started before joining, or kept after leaving, stays published, and the room shows its owner. `residual: a screen share publishes while not joined and after leaving` in `src/local.test.ts` replays it. | +| Rule | Holds when | +| --- | --- | +| `LeavingStopsPublishing` | Someone who has not joined the room, or has left it, publishes nothing to it. | The chart also shows every participant leaving your screen while your connection is down and coming back once it reconnects. @@ -19,12 +19,11 @@ coming back once it reconnects. just js tla room/verify/Room.tla ``` -TLC exits non-zero while the rule above breaks. To draw the chart, run `/statechart` with +To draw the chart, run `/statechart` with `Room.tla`, `Room.cfg`, and `Room.view.ts`. `Room.cfg` plays only Bob's camera; add `"screen"` to `Kinds` to play his share too. ## Left out Chat, typing, and user metadata, more than one remote participant, a participant restarting under -the same identity, identity and prefix changes, and anything an app does on top of `Local`, such -as turning sharing off when you leave. +the same identity, identity and prefix changes, and anything an app does on top of `Local`. diff --git a/js/room/verify/Room.tla b/js/room/verify/Room.tla index 141c9b54cc..3ef784e117 100644 --- a/js/room/verify/Room.tla +++ b/js/room/verify/Room.tla @@ -2,8 +2,7 @@ \* You and another participant, Bob, in a room, as you see it. \* \* Your side is js/room/src/local.ts: `enabled` joins by announcing `{you}/camera.hang`, and the -\* screen broadcast `{you}/screen.hang` is announced while a share is live (`#screenLive`), -\* whether or not you joined. Your view of Bob is js/room/src/room.ts: each announcement under +\* screen broadcast `{you}/screen.hang` is announced while joined and a share is live. Your view of Bob is js/room/src/room.ts: each announcement under \* the room adds or removes one of Bob's broadcasts, and Bob is on your screen while one is. \* Announcements reach you one at a time. When your connection drops, the entries it fed retract \* at once (reload.ts), and the reconnect announces them again. @@ -24,7 +23,7 @@ Init == /\ seen = {} \* What you publish into the room. -Yours == (IF joined THEN {"camera"} ELSE {}) \cup (IF sharing THEN {"screen"} ELSE {}) +Yours == (IF joined THEN {"camera"} ELSE {}) \cup (IF joined /\ sharing THEN {"screen"} ELSE {}) \* You. Join == ~joined /\ joined' = TRUE /\ UNCHANGED <> diff --git a/quest/m1/room-screen-joined.md b/quest/m1/room-screen-joined.md deleted file mode 100644 index 330425e8d3..0000000000 --- a/quest/m1/room-screen-joined.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] A screen share publishes only while joined - -## Goal - -`@moq/room`'s `Local` publishes nothing, screen share included, while the -user has not joined a room or after they leave. Nobody sees a screen the user -did not share into the room. - -## Plan - -Found 2026-11-10 by the room chart ([moq-dev/moq#8447](https://github.com/moq-dev/moq/pull/8447)): -`js/room/verify/Room.tla` breaks `LeavingStopsPublishing` in 1 step. In -`js/room/src/local.ts` the screen broadcast is enabled by `#screenLive` alone, -not gated on `enabled`, so sharing before joining, or leaving while sharing, -publishes the screen as a screen-only participant with no name. -`residual: a screen share publishes while not joined and after leaving` in -`js/room/src/local.test.ts` pins it. - -Gate the screen broadcast on `enabled && #screenLive`, fix `Local`'s doc, -which describes today's behaviour, flip the residual test, and update the -Room chart so its rule holds. Check `moq-room` (Rust) for the same gate. - -Caused by: [moq-dev/moq#3634](https://github.com/moq-dev/moq/pull/3634), which -extracted conferencing into `@moq/room` with this gate. - -Public API: none (behaviour fix). Wire: none.